diff --git a/package-lock.json b/package-lock.json index 8054c17..28bce02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.17.0", "globals": "^17.4.0", + "jsdom": "^29.1.0", "playwright": "^1.59.1", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", @@ -41,6 +42,57 @@ "vitest-browser-svelte": "^2.1.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@blazediff/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", @@ -48,6 +100,159 @@ "dev": true, "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -231,6 +436,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -1382,6 +1605,16 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -1462,6 +1695,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1481,6 +1728,20 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1499,6 +1760,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1541,6 +1809,19 @@ "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", "license": "MIT" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -1950,6 +2231,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1993,6 +2287,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -2009,6 +2310,47 @@ "dev": true, "license": "ISC" }, + "node_modules/jsdom": { + "version": "29.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.0.tgz", + "integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2364,6 +2706,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -2382,6 +2734,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2512,6 +2871,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2799,6 +3171,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", @@ -2874,6 +3256,19 @@ "node": ">=6" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -3093,6 +3488,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -3143,6 +3545,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -3153,6 +3575,32 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3225,6 +3673,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -3469,12 +3927,60 @@ "vitest": "^4.0.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-vitals": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz", "integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==", "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3540,6 +4046,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 9f4308d..63acdd1 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,10 @@ "private": true, "version": "0.0.1", "type": "module", - "sideEffects": ["**/*.css", "**/*.svelte"], + "sideEffects": [ + "**/*.css", + "**/*.svelte" + ], "scripts": { "dev": "vite dev", "build": "vite build", @@ -11,6 +14,7 @@ "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "dev:conn-chat": "node scripts/conn-chat-server.mjs", "lint": "prettier --check . && eslint .", "format": "prettier --write .", "test:unit": "vitest", @@ -28,6 +32,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.17.0", "globals": "^17.4.0", + "jsdom": "^29.1.0", "playwright": "^1.59.1", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", @@ -40,13 +45,13 @@ "vitest-browser-svelte": "^2.1.0" }, "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", "@sentry/browser": "^10.50.0", - "web-vitals": "^5.2.0", - "@floating-ui/core": "^1.7.1", - "@floating-ui/dom": "^1.7.1", - "csstype": "^3.2.3", - "esm-env": "^1.1.2", - "runed": "0.37.1", - "tabbable": "^6.2.0" + "csstype": "^3.2.3", + "esm-env": "^1.1.2", + "runed": "0.37.1", + "tabbable": "^6.2.0", + "web-vitals": "^5.2.0" } } diff --git a/scripts/conn-chat-server.mjs b/scripts/conn-chat-server.mjs new file mode 100644 index 0000000..7bb8aad --- /dev/null +++ b/scripts/conn-chat-server.mjs @@ -0,0 +1,312 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { createServer } from 'node:http'; + +const HOST = process.env.CONN_CHAT_HOST ?? '127.0.0.1'; +const PORT = Number(process.env.CONN_CHAT_PORT ?? 8787); +const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; +const HEADER_UPGRADE = 'upgrade'; +const HEADER_WEBSOCKET_KEY = 'sec-websocket-key'; +const UPGRADE_WEBSOCKET = 'websocket'; +const OPCODE_TEXT = 0x1; +const OPCODE_CLOSE = 0x8; +const OPCODE_PING = 0x9; +const OPCODE_PONG = 0xa; +const CLOSE_CODE_NORMAL = 1000; +const CLOSE_CODE_PROTOCOL_ERROR = 1002; +const FRAME_TYPE_AUTH = 'conn.auth'; +const FRAME_TYPE_JOIN = 'conn.join'; +const FRAME_TYPE_LEAVE = 'conn.leave'; +const FRAME_TYPE_PING = 'conn.ping'; +const FRAME_TYPE_PONG = 'conn.pong'; +const SERVER_REPLY_EVENT = 'server.reply'; +const CHAT_EVENT_MESSAGE = 'chat.message'; +const CHAT_EVENT_SYSTEM = 'chat.system'; +const CHAT_EVENT_TYPING = 'chat.typing'; +const CHAT_EVENT_WHOAMI = 'chat.whoami'; +const DEMO_EVENT_DISCONNECT = 'demo.disconnect'; +const BOT_NAME = 'Ava Bot'; +const DEFAULT_ROOM = 'room:lobby'; +const DEFAULT_USER = 'Anon'; +const BOT_REPLY_DELAY_MS = 450; +const FORCED_CLOSE_DELAY_MS = 80; + +const clients = new Map(); + +function makeAcceptKey(key) { + return createHash('sha1').update(`${key}${WEBSOCKET_GUID}`).digest('base64'); +} + +function encodeFrame(opcode, payload = Buffer.alloc(0)) { + const data = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload)); + const len = data.length; + let header; + if (len < 126) { + header = Buffer.alloc(2); + header[1] = len; + } else if (len <= 0xffff) { + header = Buffer.alloc(4); + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.alloc(10); + header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + header[0] = 0x80 | opcode; + return Buffer.concat([header, data]); +} + +function sendText(socket, text) { + if (socket.destroyed) return; + socket.write(encodeFrame(OPCODE_TEXT, text)); +} + +function sendJson(socket, frame) { + sendText(socket, JSON.stringify(frame)); +} + +function closeSocket(socket, code = CLOSE_CODE_NORMAL, reason = '') { + if (socket.destroyed) return; + const body = Buffer.alloc(2 + Buffer.byteLength(reason)); + body.writeUInt16BE(code, 0); + body.write(reason, 2); + socket.write(encodeFrame(OPCODE_CLOSE, body)); + socket.end(); +} + +function createMessage(user, text, kind) { + return { + id: randomUUID(), + user, + text, + at: Date.now(), + kind + }; +} + +function reply(socket, frame, payload, error) { + if (typeof frame.id !== 'string') return; + sendJson(socket, { + type: SERVER_REPLY_EVENT, + replyTo: frame.id, + payload, + error + }); +} + +function broadcast(room, frame) { + for (const [socket, client] of clients) { + if (client.room !== room) continue; + sendJson(socket, frame); + } +} + +function broadcastSystem(room, text) { + broadcast(room, { + topic: room, + type: CHAT_EVENT_SYSTEM, + payload: createMessage('Sistema', text, 'system') + }); +} + +function parseFrames(client) { + const out = []; + let offset = 0; + const buffer = client.buffer; + + while (offset + 2 <= buffer.length) { + const first = buffer[offset]; + const second = buffer[offset + 1]; + const opcode = first & 0x0f; + const masked = (second & 0x80) !== 0; + let len = second & 0x7f; + let cursor = offset + 2; + + if (len === 126) { + if (cursor + 2 > buffer.length) break; + len = buffer.readUInt16BE(cursor); + cursor += 2; + } else if (len === 127) { + if (cursor + 8 > buffer.length) break; + const longLen = buffer.readBigUInt64BE(cursor); + if (longLen > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error('websocket frame too large'); + } + len = Number(longLen); + cursor += 8; + } + + const maskOffset = cursor; + if (masked) cursor += 4; + if (cursor + len > buffer.length) break; + + const payload = Buffer.from(buffer.subarray(cursor, cursor + len)); + if (masked) { + const mask = buffer.subarray(maskOffset, maskOffset + 4); + for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4]; + } + + out.push({ opcode, payload }); + offset = cursor + len; + } + + client.buffer = buffer.subarray(offset); + return out; +} + +function handleFrame(socket, rawFrame) { + const client = clients.get(socket); + if (client === undefined) return; + + if (rawFrame.opcode === OPCODE_CLOSE) { + closeSocket(socket); + return; + } + if (rawFrame.opcode === OPCODE_PING) { + socket.write(encodeFrame(OPCODE_PONG, rawFrame.payload)); + return; + } + if (rawFrame.opcode !== OPCODE_TEXT) return; + + let frame; + try { + frame = JSON.parse(rawFrame.payload.toString('utf8')); + } catch { + closeSocket(socket, CLOSE_CODE_PROTOCOL_ERROR, 'invalid json'); + return; + } + + if (frame.type === FRAME_TYPE_AUTH) { + client.authed = true; + client.user = frame.payload?.user ?? client.user; + reply(socket, frame, { accepted: true, server: 'conn-chat-server' }); + return; + } + + if (frame.type === FRAME_TYPE_JOIN) { + const room = typeof frame.topic === 'string' ? frame.topic : DEFAULT_ROOM; + client.room = room; + client.user = frame.payload?.user ?? client.user; + broadcastSystem(room, `${client.user} ha entrado en ${room}.`); + return; + } + + if (frame.type === FRAME_TYPE_LEAVE) { + const room = client.room; + client.room = null; + if (room !== null) broadcastSystem(room, `${client.user} ha salido de ${room}.`); + return; + } + + if (frame.type === FRAME_TYPE_PING) { + sendJson(socket, { type: FRAME_TYPE_PONG, payload: { at: Date.now() } }); + return; + } + + if (frame.type === CHAT_EVENT_WHOAMI) { + reply(socket, frame, { + user: client.user, + room: client.room, + connection: client.id, + generation: client.generation + }); + return; + } + + if (frame.type === DEMO_EVENT_DISCONNECT) { + setTimeout(() => { + closeSocket(socket, CLOSE_CODE_NORMAL, 'demo disconnect'); + }, FORCED_CLOSE_DELAY_MS); + return; + } + + if (frame.type === CHAT_EVENT_MESSAGE) { + const room = typeof frame.topic === 'string' ? frame.topic : client.room; + if (room === null) return; + broadcast(room, frame); + broadcast(room, { + topic: room, + type: CHAT_EVENT_TYPING, + payload: { user: BOT_NAME, active: true } + }); + setTimeout(() => { + broadcast(room, { + topic: room, + type: CHAT_EVENT_TYPING, + payload: { user: BOT_NAME, active: false } + }); + broadcast(room, { + topic: room, + type: CHAT_EVENT_MESSAGE, + payload: createMessage( + BOT_NAME, + `Recibido por ${client.user}: "${frame.payload?.text ?? ''}"`, + 'bot' + ) + }); + }, BOT_REPLY_DELAY_MS); + } +} + +const server = createServer((_, response) => { + response.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }); + response.end('conn chat websocket server\n'); +}); + +server.on('upgrade', (request, socket) => { + const upgrade = request.headers[HEADER_UPGRADE]; + const key = request.headers[HEADER_WEBSOCKET_KEY]; + if (upgrade !== UPGRADE_WEBSOCKET || typeof key !== 'string') { + socket.destroy(); + return; + } + + socket.write( + [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${makeAcceptKey(key)}`, + '', + '' + ].join('\r\n') + ); + + const client = { + id: randomUUID(), + user: DEFAULT_USER, + room: null, + authed: false, + generation: Date.now(), + buffer: Buffer.alloc(0) + }; + clients.set(socket, client); + console.log(`[conn-chat] connected ${client.id}`); + + socket.on('data', (chunk) => { + const current = clients.get(socket); + if (current === undefined) return; + current.buffer = Buffer.concat([current.buffer, chunk]); + try { + for (const frame of parseFrames(current)) handleFrame(socket, frame); + } catch (error) { + console.error('[conn-chat] protocol error', error); + closeSocket(socket, CLOSE_CODE_PROTOCOL_ERROR, 'protocol error'); + } + }); + + socket.on('close', () => { + const current = clients.get(socket); + clients.delete(socket); + if (current?.room) broadcastSystem(current.room, `${current.user} se ha desconectado.`); + console.log(`[conn-chat] disconnected ${current?.id ?? 'unknown'}`); + }); + + socket.on('error', (error) => { + console.error('[conn-chat] socket error', error); + }); +}); + +server.listen(PORT, HOST, () => { + console.log(`[conn-chat] ws://${HOST}:${PORT}`); +}); diff --git a/src/arts/aapp/README.md b/src/arts/aapp/README.md index d1da749..a76356d 100644 --- a/src/arts/aapp/README.md +++ b/src/arts/aapp/README.md @@ -29,16 +29,18 @@ App.dispose(); ## What it composes -| Member | Always present | Default when not configured | -| -------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `App.Logger` | yes | engine default — `level: WARN` + `consoleTransport()`. Pass `{ level: LogLevel.NONE, transports: [] }` for silence | -| `App.Lang` | yes | mono — `t('a.b')` returns `'a.b'`, ` | fallback`honored,`ts(record)`returns first entry. DEV warns once per unresolved path via Logger under`lang.mono` | -| `App.Formats` | yes | real, locale = `DEFAULT_LOCALE` (`'en-US'`) | -| `App.Frontend` | yes | real with default theme/mode/density | -| `App.Dom` | yes | real with default breakpoints | -| `App.Storage` | yes | in-memory adapter (resets on reload). Configure `storage: { adapter: localAdapter }` for real persistence; `onError` is wired through `Logger.error('storage', ...)` | -| `App.Http` | yes | engine default — `globalThis.fetch`, no `baseUrl`, idempotent-by-default retry, 10s per-attempt timeout. The shared `Logger` is wired automatically; configure `http: { baseUrl, timeout, retry, hooks }` for app-wide defaults | -| `App.Sess` | **opt-in** | only present when `sess: { userSchema, ... }` is configured. Auto-wires bearer header into `App.Http` (per attempt) and a 401-rescue hook (`createBeforeErrorHook(Sess)`) into `http.hooks.beforeError`. The shared Logger is injected. | +| Member | Always present | Default when not configured | +| -------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `App.Logger` | yes | engine default — `level: WARN` + `consoleTransport()`. Pass `{ level: LogLevel.NONE, transports: [] }` for silence | +| `App.Lang` | yes | mono — `t('a.b')` returns `'a.b'`, `t('a.b\|Fallback')` returns `'Fallback'`, and DEV warns once per unresolved path through Logger under `lang.mono` | +| `App.Formats` | yes | real, locale = `DEFAULT_LOCALE` (`'en-US'`) | +| `App.Frontend` | yes | real with default theme/mode/density | +| `App.Dom` | yes | real with default breakpoints | +| `App.Storage` | yes | in-memory adapter (resets on reload). Configure `storage: { adapter: localAdapter }` for real persistence; `onError` is wired through `Logger.error('storage', ...)` | +| `App.Http` | yes | engine default — `globalThis.fetch`, no `baseUrl`, idempotent-by-default retry, 10s per-attempt timeout. The shared `Logger` is wired automatically; configure `http: { baseUrl, timeout, retry }` | +| `App.Timers` | yes | `ActiveTimers` scheduler owned by App. Used by artifacts that need keyed runtime timers (`sess` auto-refresh, `conn` reconnect/heartbeat/ack) and disposed by `App.dispose()` | +| `App.Sess` | no | created lazily through `App.createActiveSession(...)`. Logger is injected automatically; storage, refresh/revoke handlers and HTTP hooks remain explicit so auth policy does not become hidden magic | +| `Connections` | no | created lazily through `App.createActiveConnections(...)`. App injects Logger, Timers and a structural session bridge; each connection opts into session behavior independently | `Sium` is **not** a member of App. Validation is page-scoped — pages with forms construct their own engine via the one-line `App.createSiumEngine()` @@ -68,6 +70,18 @@ The method lives on App rather than as a standalone helper because App is already in scope on every page via context — `App.createSiumEngine()` is the natural call site. +`Connections` is also lazy, but for the opposite reason: realtime is +application-scoped infrastructure, while the connection map is app-specific and +benefits from call-site generics: + +```ts +const Connections = App.createActiveConnections(); +``` + +Each registry is disposed by `App.dispose()`. Individual connections decide +whether they react to session refresh/expire events via their own `session` +option. + ## What it solves - **Single locale source.** `App.setLocale('es-MX')` propagates to `Lang`, @@ -78,8 +92,9 @@ the natural call site. Sentry, Datadog, ...). - **Uniform call sites.** `App.Lang.t(label)` and `App.Formats.*` always work, whether or not the caller configured i18n or fmts. No null checks. -- **Single lifecycle.** `App.dispose()` runs `Frontend → Formats → Lang → -Logger` in that order, flushing buffered transports before clearing them. +- **Single lifecycle.** `App.dispose()` tears down the optional session, + timers, persistence bridge, frontend, dom, formats, storage, lang and logger + in a deterministic order. ## Composition order @@ -95,16 +110,14 @@ Logger` in that order, flushing buffered transports before clearing them. 6. **Frontend** — receives Dom and the same `localeSource`. When `frontend.persist` is configured, persisted values seed the initial options and `onPreferenceChange` is wired to write back to Storage. -7. **Sess** — built before Http so its `bearerHeader()` and 401-rescue - hook can be auto-wired into the HTTP engine. Optional — only built when - `sess: { userSchema, ... }` is configured. -8. **Http** — built last with the shared `Logger` injected automatically - so request/retry/error events land under category `'http'`. When `Sess` - is configured: `headers: () => Sess.bearerHeader() ?? {}` is layered - onto user-supplied default headers (Sess wins on `Authorization` - conflicts) and `createBeforeErrorHook(Sess)` is prepended to - `hooks.beforeError`. In SvelteKit `load`, scope to the request via - `App.Http.with({ fetch: event.fetch })`. +7. **Http** — built with the shared `Logger` injected automatically so + request/retry/error events land under category `'http'`. In SvelteKit + `load`, scope to the request via `App.Http.with({ fetch: event.fetch })`. +8. **Timers** — built with the shared `Logger`. This is the App-owned + scheduler used by long-lived runtime tasks; no module-global singleton. +9. **Sess** — created lazily via `App.createActiveSession(...)`, not from + `createActiveApp(...)` options. App injects Logger, but the consumer keeps + auth policy explicit (`storage`, `onRefresh`, `onRevoke`, HTTP hooks). `dispose()` runs in reverse order. @@ -291,14 +304,14 @@ workspace, route param), use `App.Storage.dynamicEntry()`: ```svelte ``` @@ -313,18 +326,17 @@ instant cross-tab synchronization through `BroadcastChannel`: import { createActiveApp, localAdapter, cookieAdapter, withBroadcast } from '$aapp'; const App = createActiveApp({ - storage: { - adapter: withBroadcast(localAdapter, { channel: 'my-app' }), - namespace: 'my-app' - } + storage: { + adapter: withBroadcast(localAdapter, { channel: 'my-app' }), + namespace: 'my-app' + } }); // Cookies + broadcast = changes propagate across tabs the moment they // happen (browsers do not emit a native event for cookie mutations). -const session = withBroadcast( - cookieAdapter({ path: '/', maxAge: 3600 }), - { channel: 'my-app:session' } -); +const session = withBroadcast(cookieAdapter({ path: '/', maxAge: 3600 }), { + channel: 'my-app:session' +}); ``` ### Persisting Frontend preferences @@ -432,8 +444,14 @@ interface ActiveAppOptions { logger?: LoggerOptions; lang?: { schema: S; defaultLocale?: SupportedLocale; fallbackChain?: SupportedLocale[] }; formats?: Omit; - frontend?: Omit; + frontend?: Omit & { + persist?: FrontendPersist; + }; dom?: ActiveDomProps; + storage?: ActiveAppStorageOptions; + http?: Omit; + timers?: Omit; + connections?: Omit; } interface ActiveApp { @@ -442,12 +460,22 @@ interface ActiveApp { readonly Formats: ActiveFormats; readonly Frontend: ActiveFrontend; readonly Dom: ActiveDom; + readonly Storage: ActiveStorage; + readonly Http: EngineHttp; + readonly Timers: ActiveTimers; + readonly Sess: ActiveSession | undefined; getLocale(): SupportedLocale; setLocale(locale: SupportedLocale): void; onLocaleChange(fn: (locale: SupportedLocale) => void): () => void; createSiumEngine(): EngineSium; + createActiveSession( + options?: Omit, 'logger'> + ): ActiveSession; + createActiveConnections( + options?: Omit + ): ActiveConnections; dispose(): void; } diff --git a/src/arts/aapp/active-app.svelte.ts b/src/arts/aapp/active-app.svelte.ts index 6143fad..8a3c27b 100644 --- a/src/arts/aapp/active-app.svelte.ts +++ b/src/arts/aapp/active-app.svelte.ts @@ -1,4 +1,6 @@ import { createActiveDom } from '$adom'; +import { createActiveConnections as createActiveConnectionsRegistry } from '$conn'; +import type { ActiveConnections, ActiveConnectionsOptions, ConnectionMap } from '$conn'; import { createActiveFrontend } from '$fend'; import { createActiveFormats } from '$fmts'; import { createEngineHttp } from '$http'; @@ -6,6 +8,8 @@ import type { ActiveLang, LangNode } from '$lang'; import { createActiveLang } from '$lang/active-lang.svelte'; import { createActiveMonoLang } from '$lang/mono-lang.svelte'; import { createEngineLogger } from '$logr'; +import { createActivePermissions as createActivePermissionsClient } from '$perm'; +import type { ActivePermissions, ActivePermissionsOptions } from '$perm'; import { createActiveSession, SessAlreadyCreatedError, @@ -18,12 +22,19 @@ import { LOGGER_CATEGORY as STORAGE_LOGGER_CATEGORY, type StorageErrorContext } from '$stor'; +import { createActiveTimers } from '$timr'; +import { SvelteSet } from 'svelte/reactivity'; import { applyFrontendPreferenceSnapshot, bindFrontendStorage, loadPersistedFrontendPreferences } from './integrations/frontend-storage'; +import { + APP_ERROR_ALREADY_CREATED_PERMISSIONS, + APP_ERROR_ALREADY_CREATED_SESSION, + APP_STORAGE_ERROR_MESSAGE +} from './consts.ts'; import type { ActiveApp, ActiveAppOptions } from './types.ts'; /** @@ -51,7 +62,7 @@ export function createActiveApp( adapter: options.storage?.adapter, namespace: options.storage?.namespace, onError: (ctx: StorageErrorContext) => { - Logger.error(STORAGE_LOGGER_CATEGORY, `${ctx.op} on "${ctx.fullKey}"`, { + Logger.error(STORAGE_LOGGER_CATEGORY, APP_STORAGE_ERROR_MESSAGE(ctx.op, ctx.fullKey), { error: ctx.error, context: { adapter: ctx.adapter, key: ctx.key } }); @@ -87,8 +98,33 @@ export function createActiveApp( logger: Logger }); + const Timers = createActiveTimers({ + ...options.timers, + logger: Logger + }); + let disposed = false; let Sess: ActiveSession | undefined; + let Permissions: ActivePermissions | undefined; + let detachSessionBridge: (() => void) | undefined; + const sessionBridgeListeners = new SvelteSet<(change: { readonly event: string }) => void>(); + const connectionRegistries = new SvelteSet(); + + const sessionBridge = { + onChange(listener: (change: { readonly event: string }) => void) { + sessionBridgeListeners.add(listener); + return () => { + sessionBridgeListeners.delete(listener); + }; + } + }; + + function attachSessionBridge(session: ActiveSession): void { + detachSessionBridge?.(); + detachSessionBridge = session.onChange((change) => { + for (const listener of [...sessionBridgeListeners]) listener({ event: change.event }); + }); + } const app: ActiveApp = { Logger, @@ -98,11 +134,16 @@ export function createActiveApp( Dom, Storage, Http, + Timers, get Sess() { return Sess; }, + get Permissions() { + return Permissions; + }, + getLocale: () => Lang.getLocale(), setLocale: (locale) => Lang.setLocale(locale), onLocaleChange: (fn) => Lang.onLocaleChange(fn), @@ -119,23 +160,64 @@ export function createActiveApp( sessOptions: Omit, 'logger'> = {} ): ActiveSession { if (Sess !== undefined) { - throw new SessAlreadyCreatedError( - '[aapp] App.createActiveSession() called twice — only one session per App.' - ); + throw new SessAlreadyCreatedError(APP_ERROR_ALREADY_CREATED_SESSION); } const built = createActiveSession({ ...sessOptions, logger: Logger }); Sess = built as ActiveSession; + attachSessionBridge(Sess); + return built; + }, + + createActiveConnections( + connectionOptions: Omit = {} + ): ActiveConnections { + const built = createActiveConnectionsRegistry({ + ...options.connections, + ...connectionOptions, + logger: Logger, + timers: Timers, + session: sessionBridge + }); + connectionRegistries.add(built as ActiveConnections); + return built; + }, + + createActivePermissions( + permissionOptions: Partial> = {} + ): ActivePermissions { + if (Permissions !== undefined) { + throw new Error(APP_ERROR_ALREADY_CREATED_PERMISSIONS); + } + const merged = { + ...options.permissions, + ...permissionOptions + }; + const built = createActivePermissionsClient({ + ...merged, + endpoint: merged.endpoint ?? '', + logger: Logger, + http: Http + }); + Permissions = built; return built; }, dispose() { if (disposed) return; disposed = true; + Permissions?.dispose(); + Permissions = undefined; + for (const registry of connectionRegistries) registry.dispose(); + connectionRegistries.clear(); + detachSessionBridge?.(); + detachSessionBridge = undefined; + sessionBridgeListeners.clear(); Sess?.dispose(); Sess = undefined; + Timers.dispose(); teardownPersistence(); Frontend.dispose(); Dom.dispose(); diff --git a/src/arts/aapp/consts.ts b/src/arts/aapp/consts.ts index a39688a..92984ac 100644 --- a/src/arts/aapp/consts.ts +++ b/src/arts/aapp/consts.ts @@ -1 +1,10 @@ export const LOGGER_CATEGORY = 'app'; + +export const APP_STORAGE_ERROR_MESSAGE = (op: string, fullKey: string): string => + `${op} on "${fullKey}"`; + +export const APP_ERROR_ALREADY_CREATED_SESSION = + '[aapp] App.createActiveSession() called twice — only one session per App.'; + +export const APP_ERROR_ALREADY_CREATED_PERMISSIONS = + '[aapp] App.createActivePermissions() called twice — only one permissions client per App.'; diff --git a/src/arts/aapp/test/active-app.test.ts b/src/arts/aapp/test/active-app.test.ts index ba989b1..8b89a6b 100644 --- a/src/arts/aapp/test/active-app.test.ts +++ b/src/arts/aapp/test/active-app.test.ts @@ -19,6 +19,7 @@ import { createActiveApp } from '../active-app.svelte'; import { LogLevel, type LogEntry } from '$logr'; import type { LangNode } from '$lang'; import { MONO_LANG_CATEGORY } from '$lang/mono-lang.svelte'; +import { createMockTransport } from '$conn'; const schema = { greeting: { es: 'Hola', en: 'Hello', 'es-MX': 'Qué onda' }, @@ -112,6 +113,25 @@ describe('createActiveApp — composition', () => { expect(() => App.dispose()).not.toThrow(); }); + it('creates App-wired connection registries', async () => { + const App = createActiveApp({ + logger: { level: LogLevel.NONE, transports: [] } + }); + const Connections = App.createActiveConnections(); + const Main = Connections.createConnection('main', { + transport: createMockTransport(), + heartbeat: false, + reconnect: false + }); + + const result = await Main.connect(); + + expect(result.ok).toBe(true); + expect(Connections.names()).toEqual(['main']); + + App.dispose(); + }); + it('dispose() flushes buffered transports', () => { const writes: string[] = []; const App = createActiveApp({ diff --git a/src/arts/aapp/types.ts b/src/arts/aapp/types.ts index 8182389..45e03f9 100644 --- a/src/arts/aapp/types.ts +++ b/src/arts/aapp/types.ts @@ -1,13 +1,16 @@ import type { ActiveDom, ActiveDomProps } from '$adom'; +import type { ActiveConnections, ActiveConnectionsOptions, ConnectionMap } from '$conn'; import type { ActiveFrontend, ActiveFrontendOptions } from '$fend'; import type { FrontendPreferenceKey } from '$fend'; import type { ActiveFormats, ActiveFormatsOptions } from '$fmts'; import type { EngineHttp, EngineHttpOptions } from '$http'; import type { ActiveLang, LangNode, SupportedLocale } from '$lang'; import type { EngineLogger, LoggerOptions } from '$logr'; +import type { ActivePermissions, ActivePermissionsOptions } from '$perm'; import type { ActiveSession, EngineSessionOptions } from '$sess'; import type { EngineSium } from '$sium'; import type { ActiveStorage, SyncStorageAdapter } from '$stor'; +import type { ActiveTimers, EngineTimersOptions } from '$timr'; /** Frontend preferences eligible for App-managed persistence. */ export type FrontendPersistKey = FrontendPreferenceKey; @@ -91,6 +94,12 @@ export interface ActiveAppOptions { }; dom?: ActiveDomProps; storage?: ActiveAppStorageOptions; + /** + * Runtime timer scheduler defaults. App injects Logger automatically. + * Exposed as `App.Timers` and reused by integrations such as session + * auto-refresh. + */ + timers?: Omit; /** * HTTP client defaults — `baseUrl`, default headers, retry, timeout, hooks. * When omitted, `App.Http` is still present with engine defaults @@ -102,6 +111,17 @@ export interface ActiveAppOptions { * resolution flow through the framework. */ http?: Omit; + /** + * Defaults for `App.createActiveConnections()`. App injects Logger, + * Timers and the session bridge automatically. + */ + connections?: Omit; + /** + * Defaults for `App.createActivePermissions()`. App injects Logger and + * Http automatically; the endpoint remains explicit because the client + * only reflects server decisions. + */ + permissions?: Omit; } /** @@ -128,6 +148,7 @@ export interface ActiveApp { readonly Dom: ActiveDom; readonly Storage: ActiveStorage; readonly Http: EngineHttp; + readonly Timers: ActiveTimers; /** Active locale. Sourced from Lang (real or mono). */ getLocale: () => SupportedLocale; @@ -190,12 +211,32 @@ export interface ActiveApp { options?: Omit, 'logger'> ): ActiveSession; + /** + * Build an App-scoped realtime connection registry. Each call returns a + * fresh registry; App injects `Logger`, `Timers` and a structural session + * event bridge so individual connections can opt into auth/session + * behavior without importing `sess` directly. + */ + createActiveConnections( + options?: Omit + ): ActiveConnections; + + /** + * Build the App-scoped reactive permissions client. The authoritative + * runtime is `createEnginePermissions()` on the server; this client is + * only for UI/UX reflection, snapshots and cache. + */ + createActivePermissions( + options?: Partial> + ): ActivePermissions; + /** * The active session, when one has been built via * `App.createActiveSession(...)`. `undefined` until then. Pages narrow * with `if (App.Sess) { ... }`. */ readonly Sess: ActiveSession | undefined; + readonly Permissions: ActivePermissions | undefined; /** Tear down owned instances in reverse construction order. Idempotent. */ dispose: () => void; diff --git a/src/arts/conn/DESIGN_CONN.md b/src/arts/conn/DESIGN_CONN.md index cc32511..8b07d80 100644 --- a/src/arts/conn/DESIGN_CONN.md +++ b/src/arts/conn/DESIGN_CONN.md @@ -7,6 +7,8 @@ > **Status.** Design target for first implementation. > > **Core decision.** The artifact is called `conn` / `connections`, not `sock` / `sockets`, because the public model must support WebSocket, SSE, polling, WebTransport, mocks, and future transports through adapters. +> +> **Naming correction applied.** `EngineConnections` / `ActiveConnections` are the root registry names. An individual runtime unit is `Connection`. Any older references below to `EngineConnection` / `ActiveConnection` describe that unit conceptually, but are not implementation names. --- @@ -43,7 +45,6 @@ App A connection is not necessarily a WebSocket. A connection is an abstract realtime transport endpoint. - --- ## 1.1 Mandatory v1 scope @@ -101,19 +102,19 @@ Every physical transport must implement: ```ts export interface ConnectionTransport { - readonly kind: string; - readonly state: ConnectionTransportState; - readonly bufferedAmount: number; - readonly canSend: boolean; - - open(): Promise; - send(data: string | ArrayBuffer): Promise | void; - close(code?: number, reason?: string): void; - - onOpen(listener: () => void): () => void; - onMessage(listener: (message: string | ArrayBuffer) => void): () => void; - onClose(listener: (event: ConnectionCloseEvent) => void): () => void; - onError(listener: (error: unknown) => void): () => void; + readonly kind: string; + readonly state: ConnectionTransportState; + readonly bufferedAmount: number; + readonly canSend: boolean; + + open(): Promise; + send(data: string | ArrayBuffer): Promise | void; + close(code?: number, reason?: string): void; + + onOpen(listener: () => void): () => void; + onMessage(listener: (message: string | ArrayBuffer) => void): () => void; + onClose(listener: (event: ConnectionCloseEvent) => void): () => void; + onError(listener: (error: unknown) => void): () => void; } ``` @@ -128,7 +129,7 @@ Required behavior: v1 MUST include: ```ts -createWebSocketTransport(options) +createWebSocketTransport(options); ``` Required behavior: @@ -141,16 +142,16 @@ Required behavior: - Supports dynamic URL: ```ts -url: string | (() => string) +url: string | (() => string); ``` Required usage: ```ts const Main = Connections.createConnection('main', { - transport: createWebSocketTransport({ - url: () => '/realtime' - }) + transport: createWebSocketTransport({ + url: () => '/realtime' + }) }); ``` @@ -159,7 +160,7 @@ const Main = Connections.createConnection('main', { v1 MUST include: ```ts -createMockTransport() +createMockTransport(); ``` Required behavior: @@ -189,13 +190,13 @@ Required state: ```ts export type ConnectionState = - | 'idle' - | 'connecting' - | 'open' - | 'reconnecting' - | 'closing' - | 'closed' - | 'failed'; + | 'idle' + | 'connecting' + | 'open' + | 'reconnecting' + | 'closing' + | 'closed' + | 'failed'; ``` Required `EngineConnection`/`ActiveConnection` properties: @@ -221,12 +222,12 @@ Required change shape: ```ts export interface ConnectionStateChange { - readonly connection: string; - readonly from: ConnectionState; - readonly to: ConnectionState; - readonly generation: number; - readonly error?: unknown; - readonly at: number; + readonly connection: string; + readonly from: ConnectionState; + readonly to: ConnectionState; + readonly generation: number; + readonly error?: unknown; + readonly at: number; } ``` @@ -309,14 +310,14 @@ Required options: ```ts export interface ConnectionReconnectOptions { - readonly enabled?: boolean; - readonly minDelayMs?: number; - readonly maxDelayMs?: number; - readonly factor?: number; - readonly jitterMs?: number; - readonly maxAttempts?: number; - readonly reconnectOnVisible?: boolean; - readonly reconnectOnOnline?: boolean; + readonly enabled?: boolean; + readonly minDelayMs?: number; + readonly maxDelayMs?: number; + readonly factor?: number; + readonly jitterMs?: number; + readonly maxAttempts?: number; + readonly reconnectOnVisible?: boolean; + readonly reconnectOnOnline?: boolean; } ``` @@ -329,6 +330,10 @@ Required behavior: - Reconnect increments connection generation. - After reconnect, configured channels rejoin. - After reconnect, auth re-runs if configured. +- Reconnect delay must be computed through the shared timer/backoff primitives + (`$libs/timers.computeBackoffDelay`) and scheduled through injected + `TimerScheduler`/`App.Timers`, not raw `setTimeout` inside the connection + engine. A native fallback is allowed only in the standalone engine path. ### 1.1.9 Heartbeat @@ -338,11 +343,11 @@ Required options: ```ts export interface ConnectionHeartbeatOptions { - readonly enabled?: boolean; - readonly intervalMs?: number; - readonly timeoutMs?: number; - readonly pingType?: string; - readonly pongType?: string; + readonly enabled?: boolean; + readonly intervalMs?: number; + readonly timeoutMs?: number; + readonly pingType?: string; + readonly pongType?: string; } ``` @@ -424,7 +429,7 @@ auth?: { or shorthand: ```ts -auth: () => Record | null | Promise | null> +auth: () => Record | null | Promise | null>; ``` ### 1.1.12 Minimum v1 deliverables @@ -453,7 +458,6 @@ consts.ts Presence, SSE and polling may be deferred, but WebSocket + mock are mandatory. - --- ## 2. Explicit non-goals @@ -503,9 +507,9 @@ App.Connections.connection('main'); Do **not** put this under: ```ts -Sess.connections -Sess.socket -Sess.realtime +Sess.connections; +Sess.socket; +Sess.realtime; ``` Session is only a source of auth/lifecycle events. @@ -552,10 +556,10 @@ Example SSE adapter: ```ts createSseTransport({ - url: () => '/events', - send: async (frame) => { - await App.Http.post('/events/send', { body: frame }); - } + url: () => '/events', + send: async (frame) => { + await App.Http.post('/events/send', { body: frame }); + } }); ``` @@ -577,6 +581,33 @@ No persistence by default. Connection state is runtime state. Do not persist connections in storage. +### 3.6 With `timr` + +`arts/conn` should use `App.Timers` when built from `aapp`. Reconnect, +heartbeat and pending-ack timeouts must be keyed timers so app-level debug +panels and `App.dispose()` can see and cancel them uniformly. + +Recommended key shape: + +```txt +conn::reconnect +conn::heartbeat +conn::ack: +``` + +The engine accepts a structural scheduler option compatible with +`TimerScheduler` instead of importing Svelte-specific active wrappers. + +### 3.7 With `libs/http` and `libs/timers` + +Protocol vocabulary that is not connection-specific belongs in `libs/*`. + +- HTTP method/header/status names used by polling/SSE transports must come + from `$libs/http`. +- Reconnect backoff must reuse `$libs/timers`. +- `arts/conn` owns only connection-specific constants: states, events, + transport kinds, frame keys, close reasons, logger messages and error names. + --- ## 4. Naming @@ -590,39 +621,39 @@ src/arts/conn/ Public names: ```ts -EngineConnections -ActiveConnections +EngineConnections; +ActiveConnections; -EngineConnection -ActiveConnection +EngineConnection; +ActiveConnection; -ConnectionChannel -ConnectionPresence +ConnectionChannel; +ConnectionPresence; -ConnectionTransport -ConnectionSerializer +ConnectionTransport; +ConnectionSerializer; ``` Factory names: ```ts -createEngineConnections -createActiveConnections +createEngineConnections; +createActiveConnections; -createWebSocketTransport -createSseTransport -createPollingTransport -createMockTransport +createWebSocketTransport; +createSseTransport; +createPollingTransport; +createMockTransport; -jsonConnectionSerializer +jsonConnectionSerializer; ``` Avoid names such as: ```ts -SocketService -RealtimeManager -ConnectionManager +SocketService; +RealtimeManager; +ConnectionManager; ``` --- @@ -688,36 +719,32 @@ Rules: The hub owns a registry of named connections. ```ts -export interface EngineConnections< - TConnections extends ConnectionMap = ConnectionMap -> { - createConnection( - name: K, - options: ConnectionOptions - ): EngineConnection; +export interface EngineConnections { + createConnection( + name: K, + options: ConnectionOptions + ): EngineConnection; - createConnection( - name: string, - options: ConnectionOptions - ): EngineConnection; + createConnection( + name: string, + options: ConnectionOptions + ): EngineConnection; - connection( - name: K - ): EngineConnection; + connection(name: K): EngineConnection; - connection( - name: string - ): EngineConnection; + connection( + name: string + ): EngineConnection; - has(name: string): boolean; + has(name: string): boolean; - names(): readonly string[]; + names(): readonly string[]; - close(name: string, reason?: string): void; + close(name: string, reason?: string): void; - closeAll(reason?: string): void; + closeAll(reason?: string): void; - dispose(): void; + dispose(): void; } ``` @@ -729,8 +756,6 @@ Behavior: - `closeAll()` disconnects all registered connections. - `dispose()` disconnects everything and clears listeners. - - ### 6.1.1 Hub lifecycle methods In addition to create/lookup methods, the hub MUST expose lifecycle methods by connection name: @@ -755,15 +780,14 @@ Connections.connection(name).reconnect(); Both levels are intentionally supported. - ### 6.2 Active hub ```ts export interface ActiveConnections< - TConnections extends ConnectionMap = ConnectionMap + TConnections extends ConnectionMap = ConnectionMap > extends EngineConnections { - readonly size: number; - readonly activeNames: readonly string[]; + readonly size: number; + readonly activeNames: readonly string[]; } ``` @@ -808,55 +832,53 @@ Each connection has its own: - pending acks/requests ```ts -export interface EngineConnection< - TChannels extends ConnectionChannelMap = ConnectionChannelMap -> { - readonly name: string; - readonly state: ConnectionState; - readonly connected: boolean; - readonly generation: number; +export interface EngineConnection { + readonly name: string; + readonly state: ConnectionState; + readonly connected: boolean; + readonly generation: number; - connect(): Promise; + connect(): Promise; - disconnect(reason?: string): void; + disconnect(reason?: string): void; - reconnect(reason?: string): Promise; + reconnect(reason?: string): Promise; - reauthenticate(): Promise; + reauthenticate(): Promise; - send( - type: string, - payload: TPayload, - options?: ConnectionSendOptions - ): Promise; + send( + type: string, + payload: TPayload, + options?: ConnectionSendOptions + ): Promise; - request( - type: string, - payload: TPayload, - options?: ConnectionRequestOptions - ): Promise>; + request( + type: string, + payload: TPayload, + options?: ConnectionRequestOptions + ): Promise>; - channel( - name: K, - options?: ConnectionChannelOptions - ): ConnectionChannel; + channel( + name: K, + options?: ConnectionChannelOptions + ): ConnectionChannel; - channel( - name: string, - options?: ConnectionChannelOptions - ): ConnectionChannel; + channel( + name: string, + options?: ConnectionChannelOptions + ): ConnectionChannel; - channels(): readonly ConnectionChannel[]; + channels(): readonly ConnectionChannel[]; - hasChannel(name: string): boolean; + hasChannel(name: string): boolean; - leaveChannel(name: string): Promise; + leaveChannel(name: string): Promise; - onState(listener: (change: ConnectionStateChange) => void): () => void; + onState(listener: (change: ConnectionStateChange) => void): () => void; - onAny(listener: (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void): () => void; + onAny(listener: (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void): () => void; - dispose(): void; + dispose(): void; } ``` @@ -864,12 +886,12 @@ export interface EngineConnection< ```ts export interface ActiveConnection< - TChannels extends ConnectionChannelMap = ConnectionChannelMap + TChannels extends ConnectionChannelMap = ConnectionChannelMap > extends EngineConnection { - readonly state: ConnectionState; - readonly connected: boolean; - readonly generation: number; - readonly channelNames: readonly string[]; + readonly state: ConnectionState; + readonly connected: boolean; + readonly generation: number; + readonly channelNames: readonly string[]; } ``` @@ -879,13 +901,13 @@ export interface ActiveConnection< ```ts export type ConnectionState = - | 'idle' - | 'connecting' - | 'open' - | 'reconnecting' - | 'closing' - | 'closed' - | 'failed'; + | 'idle' + | 'connecting' + | 'open' + | 'reconnecting' + | 'closing' + | 'closed' + | 'failed'; ``` State transitions: @@ -922,44 +944,38 @@ The connection engine must not know whether the transport is WebSocket, SSE or p ```ts export type ConnectionTransportState = - | 'idle' - | 'opening' - | 'open' - | 'closing' - | 'closed' - | 'failed'; + | 'idle' + | 'opening' + | 'open' + | 'closing' + | 'closed' + | 'failed'; export interface ConnectionCloseEvent { - readonly code?: number; - readonly reason?: string; - readonly clean?: boolean; + readonly code?: number; + readonly reason?: string; + readonly clean?: boolean; } export interface ConnectionTransport { - readonly kind: string; - readonly state: ConnectionTransportState; - readonly bufferedAmount: number; - readonly canSend: boolean; + readonly kind: string; + readonly state: ConnectionTransportState; + readonly bufferedAmount: number; + readonly canSend: boolean; - open(): Promise; + open(): Promise; - send(data: string | ArrayBuffer): Promise | void; + send(data: string | ArrayBuffer): Promise | void; - close(code?: number, reason?: string): void; + close(code?: number, reason?: string): void; - onOpen(listener: () => void): () => void; + onOpen(listener: () => void): () => void; - onMessage( - listener: (message: string | ArrayBuffer) => void - ): () => void; + onMessage(listener: (message: string | ArrayBuffer) => void): () => void; - onClose( - listener: (event: ConnectionCloseEvent) => void - ): () => void; + onClose(listener: (event: ConnectionCloseEvent) => void): () => void; - onError( - listener: (error: unknown) => void - ): () => void; + onError(listener: (error: unknown) => void): () => void; } ``` @@ -967,8 +983,8 @@ export interface ConnectionTransport { ```ts createWebSocketTransport({ - url: () => '/realtime', - protocols: ['app-v1'] + url: () => '/realtime', + protocols: ['app-v1'] }); ``` @@ -989,18 +1005,18 @@ SSE is receive-first. Sending is optional. ```ts createSseTransport({ - url: () => '/events', + url: () => '/events', - send: async (frame) => { - await App.Http.post('/events/send', { body: frame }); - } + send: async (frame) => { + await App.Http.post('/events/send', { body: frame }); + } }); ``` If no `send` function is provided: ```ts -canSend === false +canSend === false; ``` Calling `send()` should return: @@ -1015,15 +1031,15 @@ at the connection layer. ```ts createPollingTransport({ - poll: async (cursor) => { - return App.Http.get('/events', { query: { cursor } }); - }, + poll: async (cursor) => { + return App.Http.get('/events', { query: { cursor } }); + }, - send: async (frame) => { - await App.Http.post('/events', { body: frame }); - }, + send: async (frame) => { + await App.Http.post('/events', { body: frame }); + }, - intervalMs: 5_000 + intervalMs: 5_000 }); ``` @@ -1049,15 +1065,15 @@ Do not hardcode JSON deeply into the engine. ```ts export interface ConnectionSerializer { - encode(frame: TFrame): string | ArrayBuffer; - decode(raw: string | ArrayBuffer): TFrame; + encode(frame: TFrame): string | ArrayBuffer; + decode(raw: string | ArrayBuffer): TFrame; } ``` Default: ```ts -jsonConnectionSerializer() +jsonConnectionSerializer(); ``` `jsonConnectionSerializer` must: @@ -1074,18 +1090,15 @@ jsonConnectionSerializer() Base frame: ```ts -export interface ConnectionFrame< - TType extends string = string, - TPayload = unknown -> { - readonly id?: string; - readonly topic?: string; - readonly type: TType; - readonly payload: TPayload; - readonly ts?: number; - readonly ack?: boolean; - readonly replyTo?: string; - readonly error?: ConnectionFrameError; +export interface ConnectionFrame { + readonly id?: string; + readonly topic?: string; + readonly type: TType; + readonly payload: TPayload; + readonly ts?: number; + readonly ack?: boolean; + readonly replyTo?: string; + readonly error?: ConnectionFrameError; } ``` @@ -1120,29 +1133,29 @@ Example: ```ts type AppConnections = { - main: { - orders: { - 'order.created': OrderCreated; - 'order.updated': OrderUpdated; - }; - - cart: { - 'cart.changed': CartChanged; - }; - }; - - chat: { - rooms: { - 'message.created': MessageCreated; - 'typing.started': TypingStarted; - }; - }; - - market: { - prices: { - 'price.tick': PriceTick; - }; - }; + main: { + orders: { + 'order.created': OrderCreated; + 'order.updated': OrderUpdated; + }; + + cart: { + 'cart.changed': CartChanged; + }; + }; + + chat: { + rooms: { + 'message.created': MessageCreated; + 'typing.started': TypingStarted; + }; + }; + + market: { + prices: { + 'price.tick': PriceTick; + }; + }; }; ``` @@ -1168,44 +1181,36 @@ The generic typing should help at call-sites, but runtime validation is optional A channel is a logical topic within one connection. ```ts -export type ConnectionChannelState = - | 'idle' - | 'joining' - | 'joined' - | 'leaving' - | 'left' - | 'failed'; +export type ConnectionChannelState = 'idle' | 'joining' | 'joined' | 'leaving' | 'left' | 'failed'; export interface ConnectionChannel { - readonly name: string; - readonly state: ConnectionChannelState; + readonly name: string; + readonly state: ConnectionChannelState; - join(params?: unknown): Promise; + join(params?: unknown): Promise; - leave(): Promise; + leave(): Promise; - send( - type: K, - payload: TEvents[K], - options?: ConnectionSendOptions - ): Promise; + send( + type: K, + payload: TEvents[K], + options?: ConnectionSendOptions + ): Promise; - request( - type: K, - payload: TEvents[K], - options?: ConnectionRequestOptions - ): Promise>; + request( + type: K, + payload: TEvents[K], + options?: ConnectionRequestOptions + ): Promise>; - on( - type: K, - listener: (payload: TEvents[K], meta: ConnectionMessageMeta) => void - ): () => void; + on( + type: K, + listener: (payload: TEvents[K], meta: ConnectionMessageMeta) => void + ): () => void; - onAny( - listener: (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void - ): () => void; + onAny(listener: (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void): () => void; - dispose(): void; + dispose(): void; } ``` @@ -1221,9 +1226,9 @@ Channel options: ```ts export interface ConnectionChannelOptions { - readonly autoJoin?: boolean; - readonly rejoinOnReconnect?: boolean; - readonly params?: () => unknown | Promise; + readonly autoJoin?: boolean; + readonly rejoinOnReconnect?: boolean; + readonly params?: () => unknown | Promise; } ``` @@ -1237,19 +1242,19 @@ Do not implement a complex distributed presence model in v1. Provide a minimal a ```ts export interface ConnectionPresence { - readonly members: readonly ConnectionPresenceMember[]; + readonly members: readonly ConnectionPresenceMember[]; - onJoin(listener: (member: ConnectionPresenceMember) => void): () => void; + onJoin(listener: (member: ConnectionPresenceMember) => void): () => void; - onLeave(listener: (member: ConnectionPresenceMember) => void): () => void; + onLeave(listener: (member: ConnectionPresenceMember) => void): () => void; - onSync(listener: (members: readonly ConnectionPresenceMember[]) => void): () => void; + onSync(listener: (members: readonly ConnectionPresenceMember[]) => void): () => void; } export interface ConnectionPresenceMember { - readonly id: string; - readonly payload: TPresence; - readonly joinedAt?: number; + readonly id: string; + readonly payload: TPresence; + readonly joinedAt?: number; } ``` @@ -1263,18 +1268,18 @@ Presence may be implemented later if v1 scope is too large. Do not block the bas ```ts export type ConnectionSendResult = - | { readonly ok: true; readonly id?: string } - | { - readonly ok: false; - readonly reason: - | 'closed' - | 'send_not_supported' - | 'buffer_full' - | 'invalid_frame' - | 'serialize_failed' - | 'transport_error'; - readonly error?: unknown; - }; + | { readonly ok: true; readonly id?: string } + | { + readonly ok: false; + readonly reason: + | 'closed' + | 'send_not_supported' + | 'buffer_full' + | 'invalid_frame' + | 'serialize_failed' + | 'transport_error'; + readonly error?: unknown; + }; ``` `send()` should not throw for normal runtime failures. @@ -1283,17 +1288,12 @@ export type ConnectionSendResult = ```ts export type ConnectionAckResult = - | { readonly ok: true; readonly payload: T } - | { - readonly ok: false; - readonly reason: - | 'timeout' - | 'closed' - | 'rejected' - | 'transport_error' - | 'invalid_reply'; - readonly error?: unknown; - }; + | { readonly ok: true; readonly payload: T } + | { + readonly ok: false; + readonly reason: 'timeout' | 'closed' | 'rejected' | 'transport_error' | 'invalid_reply'; + readonly error?: unknown; + }; ``` Request behavior: @@ -1309,7 +1309,7 @@ Options: ```ts export interface ConnectionRequestOptions extends ConnectionSendOptions { - readonly timeoutMs?: number; + readonly timeoutMs?: number; } ``` @@ -1319,14 +1319,14 @@ export interface ConnectionRequestOptions extends ConnectionSendOptions { ```ts export interface ConnectionReconnectOptions { - readonly enabled?: boolean; - readonly minDelayMs?: number; - readonly maxDelayMs?: number; - readonly factor?: number; - readonly jitterMs?: number; - readonly maxAttempts?: number; - readonly reconnectOnVisible?: boolean; - readonly reconnectOnOnline?: boolean; + readonly enabled?: boolean; + readonly minDelayMs?: number; + readonly maxDelayMs?: number; + readonly factor?: number; + readonly jitterMs?: number; + readonly maxAttempts?: number; + readonly reconnectOnVisible?: boolean; + readonly reconnectOnOnline?: boolean; } ``` @@ -1362,11 +1362,11 @@ Rules: ```ts export interface ConnectionHeartbeatOptions { - readonly enabled?: boolean; - readonly intervalMs?: number; - readonly timeoutMs?: number; - readonly pingType?: string; - readonly pongType?: string; + readonly enabled?: boolean; + readonly intervalMs?: number; + readonly timeoutMs?: number; + readonly pingType?: string; + readonly pongType?: string; } ``` @@ -1394,9 +1394,9 @@ Behavior: ```ts export interface ConnectionBufferOptions { - readonly policy?: 'buffer' | 'drop' | 'fail'; - readonly maxMessages?: number; - readonly maxBytes?: number; + readonly policy?: 'buffer' | 'drop' | 'fail'; + readonly maxMessages?: number; + readonly maxBytes?: number; } ``` @@ -1429,12 +1429,9 @@ Auth is connection-specific and opaque. export type ConnectionAuthPayload = Record; export interface ConnectionAuthOptions { - readonly getAuth?: () => - | ConnectionAuthPayload - | null - | Promise; + readonly getAuth?: () => ConnectionAuthPayload | null | Promise; - readonly authType?: string; + readonly authType?: string; } ``` @@ -1458,17 +1455,12 @@ Result: ```ts export type ConnectionAuthResult = - | { readonly ok: true } - | { - readonly ok: false; - readonly reason: - | 'no_auth_provider' - | 'closed' - | 'rejected' - | 'timeout' - | 'transport_error'; - readonly error?: unknown; - }; + | { readonly ok: true } + | { + readonly ok: false; + readonly reason: 'no_auth_provider' | 'closed' | 'rejected' | 'timeout' | 'transport_error'; + readonly error?: unknown; + }; ``` Do not hardcode Bearer/JWT. @@ -1483,11 +1475,11 @@ Suggested App-level options: ```ts export interface ActiveAppConnectionsOptions { - readonly defaults?: Partial; + readonly defaults?: Partial; - readonly connections?: Record>; + readonly connections?: Record>; - readonly autoCreate?: boolean; + readonly autoCreate?: boolean; } ``` @@ -1497,16 +1489,16 @@ Declarative: ```ts const App = createActiveApp({ - connections: { - connections: { - main: { - reconnect: { enabled: true } - }, - market: { - buffer: { policy: 'drop' } - } - } - } + connections: { + connections: { + main: { + reconnect: { enabled: true } + }, + market: { + buffer: { policy: 'drop' } + } + } + } }); ``` @@ -1516,9 +1508,9 @@ Typed creation: const Connections = App.createActiveConnections(); const Main = Connections.createConnection('main', { - transport: createWebSocketTransport({ - url: () => '/realtime' - }) + transport: createWebSocketTransport({ + url: () => '/realtime' + }) }); ``` @@ -1555,34 +1547,34 @@ Do not auto-wire domain behavior. ## 21. Options ```ts -export interface ConnectionOptions< - TChannels extends ConnectionChannelMap = ConnectionChannelMap -> { - readonly transport: ConnectionTransport | (() => ConnectionTransport); +export interface ConnectionOptions { + readonly transport: ConnectionTransport | (() => ConnectionTransport); - readonly serializer?: ConnectionSerializer; + readonly serializer?: ConnectionSerializer; - readonly reconnect?: ConnectionReconnectOptions | false; + readonly reconnect?: ConnectionReconnectOptions | false; - readonly heartbeat?: ConnectionHeartbeatOptions | false; + readonly heartbeat?: ConnectionHeartbeatOptions | false; - readonly buffer?: ConnectionBufferOptions; + readonly buffer?: ConnectionBufferOptions; - readonly auth?: ConnectionAuthOptions | (() => ConnectionAuthPayload | null | Promise); + readonly auth?: + | ConnectionAuthOptions + | (() => ConnectionAuthPayload | null | Promise); - readonly session?: - | false - | { - readonly enabled?: boolean; - readonly reauthOnRefresh?: boolean; - readonly disconnectOnExpire?: boolean; - }; + readonly session?: + | false + | { + readonly enabled?: boolean; + readonly reauthOnRefresh?: boolean; + readonly disconnectOnExpire?: boolean; + }; - readonly channels?: { - readonly [K in keyof TChannels & string]?: ConnectionChannelOptions; - }; + readonly channels?: { + readonly [K in keyof TChannels & string]?: ConnectionChannelOptions; + }; - readonly loggerScope?: string; + readonly loggerScope?: string; } ``` @@ -1600,13 +1592,13 @@ Use custom errors for programmer errors, not normal runtime failures. Examples: ```ts -ConnConnectionAlreadyExistsError -ConnConnectionNotFoundError -ConnInvalidConnectionNameError -ConnInvalidFrameError -ConnDisposedError -ConnChannelAlreadyExistsError -ConnChannelNotFoundError +ConnConnectionAlreadyExistsError; +ConnConnectionNotFoundError; +ConnInvalidConnectionNameError; +ConnInvalidFrameError; +ConnDisposedError; +ConnChannelAlreadyExistsError; +ConnChannelNotFoundError; ``` Runtime network failures should generally return tagged results or emit state/error events, not throw unexpectedly. @@ -1633,9 +1625,29 @@ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000; export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 10_000; export const DEFAULT_ACK_TIMEOUT_MS = 10_000; + +export const TRANSPORT_KIND_WEBSOCKET = 'websocket'; +export const TRANSPORT_KIND_MOCK = 'mock'; + +export const FRAME_KEY_ID = 'id'; +export const FRAME_KEY_TYPE = 'type'; +export const FRAME_KEY_TOPIC = 'topic'; +export const FRAME_KEY_PAYLOAD = 'payload'; +export const FRAME_KEY_REPLY_TO = 'replyTo'; + +export const LOGGER_CATEGORY = 'conn'; +export const LOG_MSG_LISTENER_THREW_PREFIX = 'Listener threw on '; + +export const ERROR_NAME_DISPOSED = 'ConnDisposedError'; +export const ERROR_NAME_ALREADY_EXISTS = 'ConnConnectionAlreadyExistsError'; +export const ERROR_NAME_NOT_FOUND = 'ConnConnectionNotFoundError'; +export const ERROR_NAME_INVALID_NAME = 'ConnInvalidConnectionNameError'; +export const ERROR_NAME_INVALID_FRAME = 'ConnInvalidFrameError'; ``` -Prefer exported constants over magic strings/numbers. +Prefer exported constants over magic strings/numbers. Engine bodies should not +inline connection states, event names, transport kinds, close reasons, frame +field names, logger categories or error names. --- @@ -1717,10 +1729,12 @@ Runtime frame validation should be minimal by default: ```ts function isConnectionFrame(value: unknown): value is ConnectionFrame { - return typeof value === 'object' - && value !== null - && typeof (value as { type?: unknown }).type === 'string' - && 'payload' in value; + return ( + typeof value === 'object' && + value !== null && + typeof (value as { type?: unknown }).type === 'string' && + 'payload' in value + ); } ``` @@ -1842,58 +1856,56 @@ Do not implement presence in the first pass unless everything above is stable. ```ts type AppConnections = { - main: { - orders: { - 'order.updated': { - orderId: string; - status: string; - }; - }; - - cart: { - 'cart.changed': { - cartId: string; - total: number; - }; - }; - }; - - market: { - prices: { - 'price.tick': { - symbol: string; - price: number; - }; - }; - }; + main: { + orders: { + 'order.updated': { + orderId: string; + status: string; + }; + }; + + cart: { + 'cart.changed': { + cartId: string; + total: number; + }; + }; + }; + + market: { + prices: { + 'price.tick': { + symbol: string; + price: number; + }; + }; + }; }; const Connections = App.createActiveConnections(); const Main = Connections.createConnection('main', { - transport: createWebSocketTransport({ - url: () => '/realtime' - }), + transport: createWebSocketTransport({ + url: () => '/realtime' + }), - auth: () => { - const session = Sess.current; + auth: () => { + const session = Sess.current; - return session?.credential - ? { token: session.credential.accessToken } - : null; - }, + return session?.credential ? { token: session.credential.accessToken } : null; + }, - session: { - enabled: true, - reauthOnRefresh: true, - disconnectOnExpire: true - } + session: { + enabled: true, + reauthOnRefresh: true, + disconnectOnExpire: true + } }); const Orders = Main.channel('orders'); Orders.on('order.updated', (payload) => { - console.log(payload.orderId, payload.status); + console.log(payload.orderId, payload.status); }); await Main.connect(); @@ -1903,27 +1915,27 @@ Second connection: ```ts const Market = Connections.createConnection('market', { - transport: createWebSocketTransport({ - url: () => 'wss://prices.example.com/stream' - }), + transport: createWebSocketTransport({ + url: () => 'wss://prices.example.com/stream' + }), - reconnect: { - minDelayMs: 250, - maxDelayMs: 3_000 - }, + reconnect: { + minDelayMs: 250, + maxDelayMs: 3_000 + }, - buffer: { - policy: 'drop', - maxMessages: 50 - }, + buffer: { + policy: 'drop', + maxMessages: 50 + }, - session: false + session: false }); const Prices = Market.channel('prices'); Prices.on('price.tick', (tick) => { - console.log(tick.symbol, tick.price); + console.log(tick.symbol, tick.price); }); await Market.connect(); diff --git a/src/arts/conn/README.md b/src/arts/conn/README.md new file mode 100644 index 0000000..33cba91 --- /dev/null +++ b/src/arts/conn/README.md @@ -0,0 +1,73 @@ +# `arts/conn` + +`conn` manages runtime realtime connections without coupling the app to WebSocket as a concept. The root artifact is a registry: + +```ts +import { createEngineConnections, createWebSocketTransport } from '$conn'; + +const Connections = createEngineConnections(); + +const Main = Connections.createConnection('main', { + transport: createWebSocketTransport({ url: () => '/realtime' }), + heartbeat: false +}); + +await Main.connect(); +``` + +## Naming + +- Root engine: `createEngineConnections()` / `EngineConnections`. +- Reactive root: `createActiveConnections()` / `ActiveConnections`. +- Individual unit: `Connection`. +- Logical topic: `ConnectionChannel`. + +There is intentionally no `EngineConnection` or `ActiveConnection`; `Engine*` and `Active*` are reserved for artifact roots. + +## App Integration + +`aapp` exposes a factory instead of a permanent property: + +```ts +const Connections = App.createActiveConnections(); +``` + +App injects `Logger`, `Timers` and a structural session event bridge. A connection reacts to session events only when it opts in: + +```ts +Connections.createConnection('main', { + transport: createWebSocketTransport({ url: '/realtime' }), + auth: () => ({ token: App.Sess?.current?.credential }), + session: { + enabled: true, + reauthOnRefresh: true, + disconnectOnExpire: true + } +}); +``` + +## Channels + +Channels are cached by name and route frames by `topic`. + +```ts +const Orders = Main.channel<{ 'order.updated': { id: string } }>('orders'); + +Orders.on('order.updated', (payload) => { + console.log(payload.id); +}); + +await Orders.join({ tenant: 'acme' }); +``` + +## Request / Reply + +`request()` creates a frame id, sends `ack: true`, and resolves when an incoming frame carries `replyTo` with that id. + +```ts +const result = await Main.request<{ id: string }, { ok: boolean }>('order.sync', { + id: 'o1' +}); +``` + +Runtime failures return tagged results. Programmer errors such as duplicated names or invalid connection names throw typed `Conn*` errors. diff --git a/src/arts/conn/active-connections.svelte.ts b/src/arts/conn/active-connections.svelte.ts new file mode 100644 index 0000000..f6bd62d --- /dev/null +++ b/src/arts/conn/active-connections.svelte.ts @@ -0,0 +1,127 @@ +import { createEngineConnections } from './engine-connections.ts'; +import { + CONNECTION_STATE_CLOSED, + CONNECTION_STATE_CONNECTING, + CONNECTION_STATE_FAILED, + CONNECTION_STATE_OPEN, + CONNECTION_STATE_RECONNECTING +} from './consts.ts'; +import { SvelteMap } from 'svelte/reactivity'; +import type { + ActiveConnections, + ActiveConnectionsOptions, + Connection, + ConnectionChannelMap, + ConnectionMap, + ConnectionOptions, + ConnectionState +} from './types.ts'; + +export function createActiveConnections( + options: ActiveConnectionsOptions = {} +): ActiveConnections { + const engine = createEngineConnections(options); + const detachers = new SvelteMap void>(); + let activeNames = $state(engine.names()); + let states = $state>>({}); + + function refreshNames(): void { + activeNames = engine.names(); + } + + function setConnectionState(name: string, state: ConnectionState): void { + states = { ...states, [name]: state }; + } + + function namesBy(state: ConnectionState): readonly string[] { + return activeNames.filter((name) => states[name] === state); + } + + function observe(name: string, connection: Connection): void { + detachers.get(name)?.(); + setConnectionState(name, connection.state); + detachers.set( + name, + connection.onState((change) => { + setConnectionState(change.connection, change.to); + }) + ); + refreshNames(); + } + + const active: ActiveConnections = { + get size() { + return activeNames.length; + }, + get activeNames() { + return activeNames; + }, + get states() { + return states; + }, + get connectedNames() { + return namesBy(CONNECTION_STATE_OPEN); + }, + get connectingNames() { + return namesBy(CONNECTION_STATE_CONNECTING); + }, + get reconnectingNames() { + return namesBy(CONNECTION_STATE_RECONNECTING); + }, + get failedNames() { + return namesBy(CONNECTION_STATE_FAILED); + }, + get closedNames() { + return namesBy(CONNECTION_STATE_CLOSED); + }, + get allConnected() { + return ( + activeNames.length > 0 && + activeNames.every((name) => states[name] === CONNECTION_STATE_OPEN) + ); + }, + get anyConnected() { + return activeNames.some((name) => states[name] === CONNECTION_STATE_OPEN); + }, + get anyConnecting() { + return activeNames.some((name) => states[name] === CONNECTION_STATE_CONNECTING); + }, + get anyReconnecting() { + return activeNames.some((name) => states[name] === CONNECTION_STATE_RECONNECTING); + }, + get anyFailed() { + return activeNames.some((name) => states[name] === CONNECTION_STATE_FAILED); + }, + createConnection( + name: string, + connectionOptions: ConnectionOptions + ): Connection { + const connection = engine.createConnection(name, connectionOptions); + observe(name, connection as Connection); + return connection; + }, + connection( + name: string + ): Connection { + return engine.connection(name); + }, + has: (name) => engine.has(name), + names: () => engine.names(), + openConnection: (name) => engine.openConnection(name), + closeConnection: (name, reason) => engine.closeConnection(name, reason), + reconnectConnection: (name, reason) => engine.reconnectConnection(name, reason), + openAll: () => engine.openAll(), + closeAll: (reason) => engine.closeAll(reason), + reconnectAll: (reason) => engine.reconnectAll(reason), + close: (name, reason) => engine.close(name, reason), + dispose() { + for (const detach of detachers.values()) detach(); + detachers.clear(); + activeNames = []; + states = {}; + engine.dispose(); + } + }; + + return active; +} diff --git a/src/arts/conn/channel.ts b/src/arts/conn/channel.ts new file mode 100644 index 0000000..3d51318 --- /dev/null +++ b/src/arts/conn/channel.ts @@ -0,0 +1,177 @@ +import { + CONNECTION_CHANNEL_JOIN_REASON_CLOSED, + CONNECTION_CHANNEL_JOIN_REASON_REJECTED, + CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR, + CONNECTION_CHANNEL_STATE_FAILED, + CONNECTION_CHANNEL_STATE_IDLE, + CONNECTION_CHANNEL_STATE_JOINED, + CONNECTION_CHANNEL_STATE_JOINING, + CONNECTION_CHANNEL_STATE_LEAVING, + CONNECTION_CHANNEL_STATE_LEFT, + CONNECTION_FRAME_TYPE_JOIN, + CONNECTION_FRAME_TYPE_LEAVE, + CONNECTION_SEND_REASON_CLOSED +} from './consts.ts'; +import type { + Connection, + ConnectionAckResult, + ConnectionChannel, + ConnectionChannelJoinResult, + ConnectionChannelOptions, + ConnectionChannelState, + ConnectionEventMap, + ConnectionFrame, + ConnectionMessageMeta, + ConnectionRequestOptions, + ConnectionSendOptions, + ConnectionSendResult +} from './types.ts'; + +export interface InternalConnectionChannel< + TEvents extends ConnectionEventMap = ConnectionEventMap +> extends ConnectionChannel { + readonly shouldRejoin: boolean; + receive(frame: ConnectionFrame, meta: ConnectionMessageMeta): void; + rejoin(): Promise; +} + +export interface ConnectionChannelRuntime { + readonly connection: Connection>; + readonly reportListenerError?: (event: string, error: unknown) => void; +} + +function mapSendToJoinResult(result: ConnectionSendResult): ConnectionChannelJoinResult { + if (result.ok) return { ok: true }; + if (result.reason === CONNECTION_SEND_REASON_CLOSED) { + return { ok: false, reason: CONNECTION_CHANNEL_JOIN_REASON_CLOSED, error: result.error }; + } + return { + ok: false, + reason: + result.error === undefined + ? CONNECTION_CHANNEL_JOIN_REASON_REJECTED + : CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR, + error: result.error + }; +} + +export function createConnectionChannel( + name: string, + options: ConnectionChannelOptions = {}, + runtime: ConnectionChannelRuntime +): InternalConnectionChannel { + let state: ConnectionChannelState = CONNECTION_CHANNEL_STATE_IDLE; + let desiredJoined = options.autoJoin === true; + let disposed = false; + + const anyListeners = new Set<(frame: ConnectionFrame, meta: ConnectionMessageMeta) => void>(); + const typeListeners = new Map< + string, + Set<(payload: unknown, meta: ConnectionMessageMeta) => void> + >(); + + function setState(next: ConnectionChannelState): void { + state = next; + } + + function emitToListeners(frame: ConnectionFrame, meta: ConnectionMessageMeta): void { + for (const listener of [...anyListeners]) { + try { + listener(frame, meta); + } catch (error) { + runtime.reportListenerError?.(frame.type, error); + } + } + const listeners = typeListeners.get(frame.type); + if (listeners === undefined) return; + for (const listener of [...listeners]) { + try { + listener(frame.payload, meta); + } catch (error) { + runtime.reportListenerError?.(frame.type, error); + } + } + } + + async function resolveJoinPayload(explicitParams: unknown): Promise { + if (explicitParams !== undefined) return explicitParams; + return options.params?.(); + } + + const channel: InternalConnectionChannel = { + name, + get state() { + return state; + }, + get shouldRejoin() { + return desiredJoined && options.rejoinOnReconnect !== false; + }, + async join(params) { + if (disposed) return { ok: false, reason: CONNECTION_CHANNEL_JOIN_REASON_CLOSED }; + setState(CONNECTION_CHANNEL_STATE_JOINING); + const payload = await resolveJoinPayload(params); + const result = await runtime.connection.send(CONNECTION_FRAME_TYPE_JOIN, payload, { + topic: name + }); + const mapped = mapSendToJoinResult(result); + if (mapped.ok) { + desiredJoined = true; + setState(CONNECTION_CHANNEL_STATE_JOINED); + } else { + setState(CONNECTION_CHANNEL_STATE_FAILED); + } + return mapped; + }, + async leave() { + if (disposed) return; + setState(CONNECTION_CHANNEL_STATE_LEAVING); + desiredJoined = false; + await runtime.connection.send(CONNECTION_FRAME_TYPE_LEAVE, undefined, { topic: name }); + setState(CONNECTION_CHANNEL_STATE_LEFT); + }, + send(type, payload, sendOptions?: ConnectionSendOptions): Promise { + return runtime.connection.send(type, payload, { ...sendOptions, topic: name }); + }, + request( + type: K, + payload: TEvents[K], + requestOptions?: ConnectionRequestOptions + ): Promise> { + return runtime.connection.request(type, payload, { ...requestOptions, topic: name }); + }, + on(type, listener) { + let listeners = typeListeners.get(type); + if (listeners === undefined) { + listeners = new Set(); + typeListeners.set(type, listeners); + } + const wrapped = listener as (payload: unknown, meta: ConnectionMessageMeta) => void; + listeners.add(wrapped); + return () => { + listeners?.delete(wrapped); + if (listeners?.size === 0) typeListeners.delete(type); + }; + }, + onAny(listener) { + anyListeners.add(listener); + return () => { + anyListeners.delete(listener); + }; + }, + receive(frame, meta) { + if (disposed) return; + emitToListeners(frame, meta); + }, + rejoin() { + if (!channel.shouldRejoin) return Promise.resolve({ ok: true }); + return channel.join(); + }, + dispose() { + disposed = true; + anyListeners.clear(); + typeListeners.clear(); + } + }; + + return channel; +} diff --git a/src/arts/conn/connection.ts b/src/arts/conn/connection.ts new file mode 100644 index 0000000..8b12182 --- /dev/null +++ b/src/arts/conn/connection.ts @@ -0,0 +1,865 @@ +import { computeBackoffDelay } from '$libs/timers'; +import type { TimerScheduler } from '$timr'; +import { createConnectionChannel, type InternalConnectionChannel } from './channel.ts'; +import { + BROWSER_EVENT_ONLINE, + BROWSER_EVENT_VISIBILITY_CHANGE, + CONNECTION_ACK_REASON_CLOSED, + CONNECTION_ACK_REASON_REJECTED, + CONNECTION_ACK_REASON_TIMEOUT, + CONNECTION_ACK_REASON_TRANSPORT_ERROR, + CONNECTION_AUTH_REASON_CLOSED, + CONNECTION_AUTH_REASON_NO_PROVIDER, + CONNECTION_AUTH_REASON_REJECTED, + CONNECTION_AUTH_REASON_TIMEOUT, + CONNECTION_AUTH_REASON_TRANSPORT_ERROR, + CONNECTION_BUFFER_POLICY_BUFFER, + CONNECTION_BUFFER_POLICY_DROP, + CONNECTION_CLOSE_REASON_DISCONNECT, + CONNECTION_CLOSE_REASON_DISPOSE, + CONNECTION_CLOSE_REASON_AUTH_FAILED, + CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT, + CONNECTION_CLOSE_REASON_RECONNECT, + CONNECTION_CLOSE_REASON_SESSION_EXPIRED, + CONNECTION_CONNECT_REASON_AUTH_FAILED, + CONNECTION_CONNECT_REASON_DISPOSED, + CONNECTION_CONNECT_REASON_TRANSPORT_ERROR, + CONNECTION_FRAME_TYPE_AUTH, + CONNECTION_FRAME_TYPE_PING, + CONNECTION_FRAME_TYPE_PONG, + CONNECTION_ID_PREFIX, + CONNECTION_ID_SEPARATOR, + CONNECTION_SEND_REASON_BUFFER_FULL, + CONNECTION_SEND_REASON_CLOSED, + CONNECTION_SEND_REASON_INVALID_FRAME, + CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED, + CONNECTION_SEND_REASON_SERIALIZE_FAILED, + CONNECTION_SEND_REASON_TRANSPORT_ERROR, + CONNECTION_STATE_CLOSED, + CONNECTION_STATE_CLOSING, + CONNECTION_STATE_CONNECTING, + CONNECTION_STATE_FAILED, + CONNECTION_STATE_IDLE, + CONNECTION_STATE_OPEN, + CONNECTION_STATE_RECONNECTING, + CONNECTION_TRANSPORT_STATE_OPEN, + DEFAULT_ACK_TIMEOUT_MS, + DEFAULT_BUFFER_MAX_BYTES, + DEFAULT_BUFFER_MAX_MESSAGES, + DEFAULT_HEARTBEAT_ENABLED, + DEFAULT_HEARTBEAT_INTERVAL_MS, + DEFAULT_HEARTBEAT_TIMEOUT_MS, + DEFAULT_RECONNECT_ENABLED, + DEFAULT_RECONNECT_FACTOR, + DEFAULT_RECONNECT_JITTER_MS, + DEFAULT_RECONNECT_MAX_DELAY_MS, + DEFAULT_RECONNECT_MIN_DELAY_MS, + DEFAULT_RECONNECT_ON_ONLINE, + DEFAULT_RECONNECT_ON_VISIBLE, + DOCUMENT_VISIBILITY_VISIBLE, + LOG_MSG_AUTH_FAILED, + LOG_MSG_BROWSER_RECONNECT, + LOG_MSG_CONNECT_FAILED, + LOG_MSG_FRAME_DECODE_FAILED, + LOG_MSG_FRAME_ENCODE_FAILED, + LOG_MSG_HEARTBEAT_TIMEOUT, + LOG_MSG_REAUTH_FAILED, + LOG_MSG_RECONNECT_EXHAUSTED, + LOG_MSG_SEND_FAILED, + LOG_MSG_SESSION_EXPIRED, + LOG_MSG_SESSION_REFRESHED, + LOG_MSG_SESSION_REVOKED, + LOG_MSG_TRANSPORT_ERROR, + SESSION_EVENT_EXPIRED, + SESSION_EVENT_REFRESHED, + SESSION_EVENT_REVOKED, + TIMER_KEY_ACK, + TIMER_KEY_HEARTBEAT, + TIMER_KEY_HEARTBEAT_TIMEOUT, + TIMER_KEY_RECONNECT, + loggerScope, + listenerThrewMessage, + timerKey +} from './consts.ts'; +import { assertConnectionFrame, createFrame } from './serializer.ts'; +import { jsonConnectionSerializer } from './serializers/json.ts'; +import type { + Connection, + ConnectionAckResult, + ConnectionAuthPayload, + ConnectionAuthResult, + ConnectionChannel, + ConnectionChannelMap, + ConnectionChannelOptions, + ConnectionCloseEvent, + ConnectionConnectResult, + ConnectionFrame, + ConnectionLogger, + ConnectionMessageMeta, + ConnectionOptions, + ConnectionRequestOptions, + ConnectionSendResult, + ConnectionSessionSource, + ConnectionState, + ConnectionStateChange, + ConnectionTransport +} from './types.ts'; + +interface ConnectionRuntime { + readonly logger?: ConnectionLogger; + readonly timers: TimerScheduler; + readonly session?: ConnectionSessionSource; +} + +interface PendingAck { + readonly timer: string; + readonly resolve: (result: ConnectionAckResult) => void; +} + +type GlobalListener = (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void; +type StateListener = (change: ConnectionStateChange) => void; + +function createIdFactory(connectionName: string): () => string { + let next = 0; + return () => { + next += 1; + return [CONNECTION_ID_PREFIX, connectionName, String(next)].join(CONNECTION_ID_SEPARATOR); + }; +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return typeof value === 'object' && value !== null && 'then' in value; +} + +function isClosedLike(state: ConnectionState): boolean { + return ( + state === CONNECTION_STATE_CLOSED || + state === CONNECTION_STATE_FAILED || + state === CONNECTION_STATE_IDLE + ); +} + +export function createConnection( + name: string, + options: ConnectionOptions, + runtime: ConnectionRuntime +): Connection { + const serializer = options.serializer ?? jsonConnectionSerializer; + const reconnectDisabled = options.reconnect === false; + const reconnectOptions = reconnectDisabled ? undefined : options.reconnect; + const heartbeatOptions = options.heartbeat === false ? false : options.heartbeat; + const bufferOptions = options.buffer ?? {}; + const stateListeners = new Set(); + const globalListeners = new Set(); + const pendingAcks = new Map(); + const bufferedFrames: ConnectionFrame[] = []; + const channels = new Map(); + const detachBrowserListeners: (() => void)[] = []; + const nextId = createIdFactory(name); + const scope = loggerScope(name, options.loggerScope); + + let transport: ConnectionTransport | null = null; + let detachTransportListeners: (() => void)[] = []; + let state: ConnectionState = CONNECTION_STATE_IDLE; + let generation = 0; + let error: unknown | null = null; + let openedAt: number | null = null; + let closedAt: number | null = null; + let lastMessageAt: number | null = null; + let reconnectAttempt = 0; + let disposed = false; + let intentionalClose = false; + let connectPromise: Promise | null = null; + + function now(): number { + return Date.now(); + } + + function logDebug(message: string, meta?: unknown): void { + runtime.logger?.debug?.(scope, message, meta); + } + + function logWarn(message: string, meta?: unknown): void { + runtime.logger?.warn?.(scope, message, meta); + } + + function logError(message: string, meta?: unknown): void { + runtime.logger?.error?.(scope, message, meta); + } + + function emitState(to: ConnectionState, changeError?: unknown): void { + if (state === to && changeError === undefined) return; + const from = state; + state = to; + const change: ConnectionStateChange = { + connection: name, + from, + to, + generation, + error: changeError, + at: now() + }; + for (const listener of [...stateListeners]) { + try { + listener(change); + } catch (err) { + logError(listenerThrewMessage(to), { error: err }); + } + } + } + + function resolveTransport(): ConnectionTransport { + return typeof options.transport === 'function' ? options.transport() : options.transport; + } + + function detachTransport(): void { + for (const detach of detachTransportListeners) detach(); + detachTransportListeners = []; + } + + function attachTransport(next: ConnectionTransport): void { + detachTransport(); + detachTransportListeners = [ + next.onOpen(handleTransportOpen), + next.onMessage(handleTransportMessage), + next.onClose(handleTransportClose), + next.onError(handleTransportError) + ]; + } + + function markOpen(): void { + if (state === CONNECTION_STATE_OPEN) return; + generation += 1; + error = null; + openedAt = now(); + closedAt = null; + reconnectAttempt = 0; + emitState(CONNECTION_STATE_OPEN); + startHeartbeat(); + } + + function markClosed(nextState: ConnectionState, cause?: unknown): void { + closedAt = now(); + stopHeartbeat(); + resolvePendingAcks({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED }); + emitState(nextState, cause); + } + + function handleTransportOpen(): void { + markOpen(); + } + + function handleTransportMessage(raw: string | ArrayBuffer): void { + lastMessageAt = now(); + cancelTimer(TIMER_KEY_HEARTBEAT_TIMEOUT); + let frame: ConnectionFrame; + try { + frame = serializer.decode(raw); + assertConnectionFrame(frame); + } catch (err) { + error = err; + logError(LOG_MSG_FRAME_DECODE_FAILED, { error: err }); + return; + } + + if (frame.replyTo !== undefined) { + resolveAckFromFrame(frame); + return; + } + if (frame.type === CONNECTION_FRAME_TYPE_PONG) return; + + const meta: ConnectionMessageMeta = { + connection: name, + topic: frame.topic, + receivedAt: lastMessageAt, + generation + }; + + if (frame.topic !== undefined) { + channels.get(frame.topic)?.receive(frame, meta); + } + emitGlobal(frame, meta); + } + + function handleTransportClose(event: ConnectionCloseEvent): void { + if (disposed) return; + if (intentionalClose) { + markClosed(CONNECTION_STATE_CLOSED); + return; + } + error = event; + if (shouldReconnect()) { + markClosed(CONNECTION_STATE_RECONNECTING, event); + scheduleReconnect(); + return; + } + markClosed(CONNECTION_STATE_FAILED, event); + } + + function handleTransportError(err: unknown): void { + error = err; + logError(LOG_MSG_TRANSPORT_ERROR, { error: err }); + } + + function emitGlobal(frame: ConnectionFrame, meta: ConnectionMessageMeta): void { + for (const listener of [...globalListeners]) { + try { + listener(frame, meta); + } catch (err) { + logError(listenerThrewMessage(frame.type), { error: err }); + } + } + } + + function cancelTimer(kind: string, id?: string): void { + runtime.timers.cancel(timerKey(name, kind, id)); + } + + function scheduleTimer( + kind: string, + delayMs: number, + task: () => void | Promise, + id?: string + ): void { + runtime.timers.schedule(timerKey(name, kind, id), delayMs, task, { replace: true }); + } + + function shouldReconnect(): boolean { + if (disposed || intentionalClose) return false; + if (reconnectDisabled) return false; + return reconnectOptions?.enabled ?? DEFAULT_RECONNECT_ENABLED; + } + + function scheduleReconnect(): void { + if (!shouldReconnect()) return; + const maxAttempts = reconnectOptions?.maxAttempts; + if (maxAttempts !== undefined && reconnectAttempt >= maxAttempts) { + logWarn(LOG_MSG_RECONNECT_EXHAUSTED, { reconnectAttempt, maxAttempts }); + error = new Error(LOG_MSG_RECONNECT_EXHAUSTED); + emitState(CONNECTION_STATE_FAILED, error); + return; + } + reconnectAttempt += 1; + const delayMs = computeBackoffDelay(reconnectAttempt - 1, { + minDelayMs: reconnectOptions?.minDelayMs ?? DEFAULT_RECONNECT_MIN_DELAY_MS, + maxDelayMs: reconnectOptions?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS, + factor: reconnectOptions?.factor ?? DEFAULT_RECONNECT_FACTOR, + jitterMs: reconnectOptions?.jitterMs ?? DEFAULT_RECONNECT_JITTER_MS + }); + scheduleTimer(TIMER_KEY_RECONNECT, delayMs, () => { + void connectInternal(true); + }); + } + + function startHeartbeat(): void { + if (heartbeatOptions === false) return; + if ((heartbeatOptions?.enabled ?? DEFAULT_HEARTBEAT_ENABLED) === false) return; + const intervalMs = heartbeatOptions?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; + runtime.timers.interval( + timerKey(name, TIMER_KEY_HEARTBEAT), + intervalMs, + () => { + void sendHeartbeat(); + }, + { replace: true, awaitTask: false } + ); + } + + function stopHeartbeat(): void { + cancelTimer(TIMER_KEY_HEARTBEAT); + cancelTimer(TIMER_KEY_HEARTBEAT_TIMEOUT); + } + + async function sendHeartbeat(): Promise { + if (!isConnected()) return; + const pingType = heartbeatOptions === false ? undefined : heartbeatOptions?.pingType; + const result = await sendFrame( + createFrame({ + type: pingType ?? CONNECTION_FRAME_TYPE_PING, + payload: undefined + }), + false + ); + if (!result.ok) return; + const timeoutMs = + heartbeatOptions === false + ? DEFAULT_HEARTBEAT_TIMEOUT_MS + : (heartbeatOptions?.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS); + scheduleTimer(TIMER_KEY_HEARTBEAT_TIMEOUT, timeoutMs, () => { + logWarn(LOG_MSG_HEARTBEAT_TIMEOUT); + transport?.close(undefined, CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT); + }); + } + + function isConnected(): boolean { + return state === CONNECTION_STATE_OPEN && transport?.state === CONNECTION_TRANSPORT_STATE_OPEN; + } + + function resolvePendingAcks(result: ConnectionAckResult): void { + for (const id of [...pendingAcks.keys()]) resolveAck(id, result); + } + + function resolveAck(id: string, result: ConnectionAckResult): void { + const pending = pendingAcks.get(id); + if (pending === undefined) return; + pendingAcks.delete(id); + runtime.timers.cancel(pending.timer); + pending.resolve(result); + } + + function resolveAckFromFrame(frame: ConnectionFrame): void { + if (frame.replyTo === undefined) return; + if (frame.error !== undefined) { + resolveAck(frame.replyTo, { + ok: false, + reason: CONNECTION_ACK_REASON_REJECTED, + error: frame.error + }); + return; + } + resolveAck(frame.replyTo, { ok: true, payload: frame.payload }); + } + + function mapSendFailureToAck(result: ConnectionSendResult): ConnectionAckResult { + if (result.ok) return { ok: true, payload: undefined }; + if (result.reason === CONNECTION_SEND_REASON_CLOSED) { + return { ok: false, reason: CONNECTION_ACK_REASON_CLOSED, error: result.error }; + } + return { ok: false, reason: CONNECTION_ACK_REASON_TRANSPORT_ERROR, error: result.error }; + } + + function canBuffer(frame: ConnectionFrame, allowBuffer: boolean): boolean { + if (!allowBuffer) return false; + if (frame.ack === true) return false; + return bufferOptions.policy === CONNECTION_BUFFER_POLICY_BUFFER; + } + + function bufferFrame(frame: ConnectionFrame): ConnectionSendResult { + const maxMessages = bufferOptions.maxMessages ?? DEFAULT_BUFFER_MAX_MESSAGES; + if (bufferedFrames.length >= maxMessages) { + return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL }; + } + bufferedFrames.push(frame); + return { ok: true, id: frame.id }; + } + + async function flushBuffer(): Promise { + while (isConnected() && bufferedFrames.length > 0) { + const frame = bufferedFrames.shift(); + if (frame === undefined) return; + const result = await sendFrame(frame, false); + if (!result.ok) { + bufferedFrames.unshift(frame); + return; + } + } + } + + async function sendFrame( + frame: ConnectionFrame, + allowBuffer: boolean + ): Promise { + try { + createFrame(frame); + } catch (err) { + return { ok: false, reason: CONNECTION_SEND_REASON_INVALID_FRAME, error: err }; + } + + if (!isConnected()) { + if (canBuffer(frame, allowBuffer)) return bufferFrame(frame); + if (bufferOptions.policy === CONNECTION_BUFFER_POLICY_DROP) { + return { ok: true, id: frame.id }; + } + return { ok: false, reason: CONNECTION_SEND_REASON_CLOSED }; + } + + const currentTransport = transport; + if (currentTransport === null || !currentTransport.canSend) { + return { ok: false, reason: CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED }; + } + const maxBytes = bufferOptions.maxBytes ?? DEFAULT_BUFFER_MAX_BYTES; + if (currentTransport.bufferedAmount > maxBytes) { + return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL }; + } + + let encoded: string | ArrayBuffer; + try { + encoded = serializer.encode(frame); + } catch (err) { + logError(LOG_MSG_FRAME_ENCODE_FAILED, { error: err }); + return { ok: false, reason: CONNECTION_SEND_REASON_SERIALIZE_FAILED, error: err }; + } + + try { + const maybe = currentTransport.send(encoded); + if (isPromiseLike(maybe)) await maybe; + return { ok: true, id: frame.id }; + } catch (err) { + error = err; + logError(LOG_MSG_SEND_FAILED, { error: err }); + return { ok: false, reason: CONNECTION_SEND_REASON_TRANSPORT_ERROR, error: err }; + } + } + + function getAuthProvider(): + | (() => ConnectionAuthPayload | null | Promise) + | null { + if (typeof options.auth === 'function') return options.auth; + return options.auth?.getAuth ?? null; + } + + async function runAuth(): Promise { + if (!isConnected()) return { ok: false, reason: CONNECTION_AUTH_REASON_CLOSED }; + const provider = getAuthProvider(); + if (provider === null) return { ok: false, reason: CONNECTION_AUTH_REASON_NO_PROVIDER }; + let payload: ConnectionAuthPayload | null; + try { + payload = await provider(); + } catch (err) { + return { ok: false, reason: CONNECTION_AUTH_REASON_REJECTED, error: err }; + } + if (payload === null) return { ok: false, reason: CONNECTION_AUTH_REASON_NO_PROVIDER }; + const authOptions = typeof options.auth === 'function' ? undefined : options.auth; + const authType = + typeof options.auth === 'function' ? CONNECTION_FRAME_TYPE_AUTH : authOptions?.authType; + const result = await requestFrame( + authType ?? CONNECTION_FRAME_TYPE_AUTH, + payload, + { timeoutMs: authOptions?.timeoutMs } + ); + if (result.ok) return { ok: true }; + if (result.reason === CONNECTION_ACK_REASON_TIMEOUT) { + return { ok: false, reason: CONNECTION_AUTH_REASON_TIMEOUT, error: result.error }; + } + if (result.reason === CONNECTION_ACK_REASON_CLOSED) { + return { ok: false, reason: CONNECTION_AUTH_REASON_CLOSED, error: result.error }; + } + if (result.reason === CONNECTION_ACK_REASON_REJECTED) { + return { ok: false, reason: CONNECTION_AUTH_REASON_REJECTED, error: result.error }; + } + return { ok: false, reason: CONNECTION_AUTH_REASON_TRANSPORT_ERROR, error: result.error }; + } + + async function requestFrame( + type: string, + payload: TPayload, + requestOptions: ConnectionRequestOptions = {} + ): Promise> { + if (!isConnected()) return { ok: false, reason: CONNECTION_ACK_REASON_CLOSED }; + const id = requestOptions.id ?? nextId(); + const frame = createFrame({ + id, + type, + payload, + topic: requestOptions.topic, + ack: true, + ts: requestOptions.ts + }); + const timeoutMs = requestOptions.timeoutMs ?? DEFAULT_ACK_TIMEOUT_MS; + const ackTimer = timerKey(name, TIMER_KEY_ACK, id); + const promise = new Promise>((resolve) => { + pendingAcks.set(id, { + timer: ackTimer, + resolve: resolve as (result: ConnectionAckResult) => void + }); + runtime.timers.schedule( + ackTimer, + timeoutMs, + () => { + resolveAck(id, { ok: false, reason: CONNECTION_ACK_REASON_TIMEOUT }); + }, + { replace: true } + ); + }); + const result = await sendFrame(frame, requestOptions.buffer === true); + if (!result.ok) resolveAck(id, mapSendFailureToAck(result)); + return promise; + } + + async function joinConfiguredChannels(reconnect: boolean): Promise { + const configured = (options.channels ?? {}) as Record< + string, + ConnectionChannelOptions | undefined + >; + for (const channelName of Object.keys(configured)) { + const configuredOptions = configured[channelName] ?? {}; + const current = connection.channel( + channelName, + configuredOptions + ) as InternalConnectionChannel; + if (configuredOptions.autoJoin === true || (reconnect && current.shouldRejoin)) { + await current.join(); + } + } + if (!reconnect) return; + for (const current of channels.values()) { + if (current.shouldRejoin) await current.rejoin(); + } + } + + async function connectInternal(reconnect: boolean): Promise { + if (disposed) { + return { + ok: false, + state, + reason: CONNECTION_CONNECT_REASON_DISPOSED + }; + } + if (state === CONNECTION_STATE_OPEN && isConnected()) { + return { ok: true, state, reused: true }; + } + if (connectPromise !== null) return connectPromise; + + connectPromise = (async () => { + intentionalClose = false; + cancelTimer(TIMER_KEY_RECONNECT); + const nextTransport = resolveTransport(); + transport = nextTransport; + attachTransport(nextTransport); + emitState(reconnect ? CONNECTION_STATE_RECONNECTING : CONNECTION_STATE_CONNECTING); + try { + await nextTransport.open(); + markOpen(); + if (getAuthProvider() !== null) { + const auth = await runAuth(); + if (!auth.ok) { + error = auth.error ?? auth.reason; + logWarn(LOG_MSG_AUTH_FAILED, auth); + detachTransport(); + transport?.close(undefined, CONNECTION_CLOSE_REASON_AUTH_FAILED); + markClosed(CONNECTION_STATE_FAILED, error); + return { + ok: false, + state, + reason: CONNECTION_CONNECT_REASON_AUTH_FAILED, + error + }; + } + } + await flushBuffer(); + await joinConfiguredChannels(reconnect); + return { ok: true, state }; + } catch (err) { + error = err; + logError(LOG_MSG_CONNECT_FAILED, { error: err }); + emitState(CONNECTION_STATE_FAILED, err); + return { + ok: false, + state, + reason: CONNECTION_CONNECT_REASON_TRANSPORT_ERROR, + error: err + }; + } finally { + connectPromise = null; + } + })(); + + return connectPromise; + } + + function closeTransport(reason: string): void { + intentionalClose = true; + cancelTimer(TIMER_KEY_RECONNECT); + stopHeartbeat(); + resolvePendingAcks({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED }); + emitState(CONNECTION_STATE_CLOSING); + transport?.close(undefined, reason); + markClosed(CONNECTION_STATE_CLOSED); + } + + function wireBrowserReconnect(): void { + if (reconnectDisabled) return; + const reconnectOnOnline = reconnectOptions?.reconnectOnOnline ?? DEFAULT_RECONNECT_ON_ONLINE; + const reconnectOnVisible = reconnectOptions?.reconnectOnVisible ?? DEFAULT_RECONNECT_ON_VISIBLE; + const target = globalThis as { + addEventListener?: (type: string, listener: () => void) => void; + removeEventListener?: (type: string, listener: () => void) => void; + document?: { + readonly visibilityState?: string; + addEventListener?: (type: string, listener: () => void) => void; + removeEventListener?: (type: string, listener: () => void) => void; + }; + }; + if (reconnectOnOnline && target.addEventListener && target.removeEventListener) { + const onOnline = (): void => { + if (intentionalClose || disposed || !isClosedLike(state)) return; + logDebug(LOG_MSG_BROWSER_RECONNECT); + void connectInternal(true); + }; + target.addEventListener(BROWSER_EVENT_ONLINE, onOnline); + detachBrowserListeners.push(() => { + target.removeEventListener?.(BROWSER_EVENT_ONLINE, onOnline); + }); + } + if ( + reconnectOnVisible && + target.document?.addEventListener && + target.document.removeEventListener + ) { + const onVisible = (): void => { + if (target.document?.visibilityState !== DOCUMENT_VISIBILITY_VISIBLE) return; + if (intentionalClose || disposed || !isClosedLike(state)) return; + logDebug(LOG_MSG_BROWSER_RECONNECT); + void connectInternal(true); + }; + target.document.addEventListener(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible); + detachBrowserListeners.push(() => { + target.document?.removeEventListener?.(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible); + }); + } + } + + function wireSession(): () => void { + const sessionOptions = options.session; + if ( + sessionOptions === undefined || + sessionOptions === false || + sessionOptions.enabled === false + ) { + return () => {}; + } + const source = runtime.session; + if (source === undefined) return () => {}; + return source.onChange((change) => { + if (change.event === SESSION_EVENT_REFRESHED && sessionOptions.reauthOnRefresh !== false) { + logDebug(LOG_MSG_SESSION_REFRESHED); + void connection.reauthenticate().then((result) => { + if (!result.ok) logWarn(LOG_MSG_REAUTH_FAILED, result); + }); + return; + } + if ( + (change.event === SESSION_EVENT_EXPIRED || change.event === SESSION_EVENT_REVOKED) && + sessionOptions.disconnectOnExpire !== false + ) { + logWarn( + change.event === SESSION_EVENT_EXPIRED ? LOG_MSG_SESSION_EXPIRED : LOG_MSG_SESSION_REVOKED + ); + connection.disconnect(CONNECTION_CLOSE_REASON_SESSION_EXPIRED); + } + }); + } + + function reportChannelListenerError(event: string, err: unknown): void { + logError(listenerThrewMessage(event), { error: err }); + } + + let detachSession = (): void => {}; + + const connection: Connection = { + name, + get state() { + return state; + }, + get connected() { + return isConnected(); + }, + get generation() { + return generation; + }, + get error() { + return error; + }, + get openedAt() { + return openedAt; + }, + get closedAt() { + return closedAt; + }, + get lastMessageAt() { + return lastMessageAt; + }, + get reconnectAttempt() { + return reconnectAttempt; + }, + connect() { + return connectInternal(false); + }, + disconnect(reason = CONNECTION_CLOSE_REASON_DISCONNECT) { + if (disposed) return; + closeTransport(reason); + }, + reconnect(reason = CONNECTION_CLOSE_REASON_RECONNECT) { + if (disposed) { + return Promise.resolve({ + ok: false, + state, + reason: CONNECTION_CONNECT_REASON_DISPOSED + }); + } + closeTransport(reason); + intentionalClose = false; + return connectInternal(true); + }, + reauthenticate() { + return runAuth(); + }, + send(type, payload, sendOptions = {}) { + const frame = createFrame({ + id: sendOptions.id, + type, + payload, + topic: sendOptions.topic, + ack: sendOptions.ack, + ts: sendOptions.ts + }); + return sendFrame(frame, sendOptions.buffer !== false); + }, + request(type, payload, requestOptions = {}) { + return requestFrame(type, payload, requestOptions); + }, + channel = Record>( + channelName: string, + channelOptions: ConnectionChannelOptions = {} + ): ConnectionChannel { + const existing = channels.get(channelName); + if (existing !== undefined) return existing as ConnectionChannel; + const created = createConnectionChannel(channelName, channelOptions, { + connection: connection as unknown as Connection>, + reportListenerError: reportChannelListenerError + }); + channels.set(channelName, created as InternalConnectionChannel); + return created; + }, + channels() { + return [...channels.values()]; + }, + hasChannel(channelName) { + return channels.has(channelName); + }, + async leaveChannel(channelName) { + await channels.get(channelName)?.leave(); + }, + onState(listener) { + stateListeners.add(listener); + return () => { + stateListeners.delete(listener); + }; + }, + onAny(listener) { + globalListeners.add(listener); + return () => { + globalListeners.delete(listener); + }; + }, + dispose() { + if (disposed) return; + disposed = true; + closeTransport(CONNECTION_CLOSE_REASON_DISPOSE); + detachSession(); + for (const detach of detachBrowserListeners) detach(); + detachBrowserListeners.length = 0; + detachTransport(); + for (const current of channels.values()) current.dispose(); + channels.clear(); + stateListeners.clear(); + globalListeners.clear(); + bufferedFrames.length = 0; + } + }; + + detachSession = wireSession(); + wireBrowserReconnect(); + + return connection; +} diff --git a/src/arts/conn/consts.ts b/src/arts/conn/consts.ts new file mode 100644 index 0000000..6da641d --- /dev/null +++ b/src/arts/conn/consts.ts @@ -0,0 +1,249 @@ +export const LOGGER_CATEGORY = 'conn'; +export const LOGGER_SCOPE_SEPARATOR = ':'; + +export const CONNECTION_STATE_IDLE = 'idle'; +export const CONNECTION_STATE_CONNECTING = 'connecting'; +export const CONNECTION_STATE_OPEN = 'open'; +export const CONNECTION_STATE_RECONNECTING = 'reconnecting'; +export const CONNECTION_STATE_CLOSING = 'closing'; +export const CONNECTION_STATE_CLOSED = 'closed'; +export const CONNECTION_STATE_FAILED = 'failed'; + +export const CONNECTION_STATES = [ + CONNECTION_STATE_IDLE, + CONNECTION_STATE_CONNECTING, + CONNECTION_STATE_OPEN, + CONNECTION_STATE_RECONNECTING, + CONNECTION_STATE_CLOSING, + CONNECTION_STATE_CLOSED, + CONNECTION_STATE_FAILED +] as const; + +export const CONNECTION_CHANNEL_STATE_IDLE = 'idle'; +export const CONNECTION_CHANNEL_STATE_JOINING = 'joining'; +export const CONNECTION_CHANNEL_STATE_JOINED = 'joined'; +export const CONNECTION_CHANNEL_STATE_LEAVING = 'leaving'; +export const CONNECTION_CHANNEL_STATE_LEFT = 'left'; +export const CONNECTION_CHANNEL_STATE_FAILED = 'failed'; + +export const CONNECTION_TRANSPORT_STATE_IDLE = 'idle'; +export const CONNECTION_TRANSPORT_STATE_OPENING = 'opening'; +export const CONNECTION_TRANSPORT_STATE_OPEN = 'open'; +export const CONNECTION_TRANSPORT_STATE_CLOSING = 'closing'; +export const CONNECTION_TRANSPORT_STATE_CLOSED = 'closed'; + +export const CONNECTION_TRANSPORT_STATES = [ + CONNECTION_TRANSPORT_STATE_IDLE, + CONNECTION_TRANSPORT_STATE_OPENING, + CONNECTION_TRANSPORT_STATE_OPEN, + CONNECTION_TRANSPORT_STATE_CLOSING, + CONNECTION_TRANSPORT_STATE_CLOSED +] as const; + +export const TRANSPORT_KIND_WEBSOCKET = 'websocket'; +export const TRANSPORT_KIND_MOCK = 'mock'; + +export const FRAME_KEY_ID = 'id'; +export const FRAME_KEY_TYPE = 'type'; +export const FRAME_KEY_TOPIC = 'topic'; +export const FRAME_KEY_PAYLOAD = 'payload'; +export const FRAME_KEY_TS = 'ts'; +export const FRAME_KEY_ACK = 'ack'; +export const FRAME_KEY_REPLY_TO = 'replyTo'; +export const FRAME_KEY_ERROR = 'error'; + +export const DEFAULT_ACK_TIMEOUT_MS = 10_000; +export const DEFAULT_RECONNECT_MIN_DELAY_MS = 500; +export const DEFAULT_RECONNECT_MAX_DELAY_MS = 15_000; +export const DEFAULT_RECONNECT_FACTOR = 1.8; +export const DEFAULT_RECONNECT_JITTER_MS = 500; +export const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000; +export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 10_000; +export const DEFAULT_BUFFER_MAX_MESSAGES = 100; +export const DEFAULT_BUFFER_MAX_BYTES = 1_000_000; + +export const DEFAULT_RECONNECT_ENABLED = true; +export const DEFAULT_RECONNECT_ON_VISIBLE = true; +export const DEFAULT_RECONNECT_ON_ONLINE = true; +export const DEFAULT_HEARTBEAT_ENABLED = true; + +export const CONNECTION_BUFFER_POLICY_BUFFER = 'buffer'; +export const CONNECTION_BUFFER_POLICY_DROP = 'drop'; +export const CONNECTION_BUFFER_POLICY_FAIL = 'fail'; + +export const CONNECTION_SEND_REASON_CLOSED = 'closed'; +export const CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED = 'send_not_supported'; +export const CONNECTION_SEND_REASON_BUFFER_FULL = 'buffer_full'; +export const CONNECTION_SEND_REASON_INVALID_FRAME = 'invalid_frame'; +export const CONNECTION_SEND_REASON_SERIALIZE_FAILED = 'serialize_failed'; +export const CONNECTION_SEND_REASON_TRANSPORT_ERROR = 'transport_error'; + +export const CONNECTION_ACK_REASON_TIMEOUT = 'timeout'; +export const CONNECTION_ACK_REASON_CLOSED = 'closed'; +export const CONNECTION_ACK_REASON_REJECTED = 'rejected'; +export const CONNECTION_ACK_REASON_TRANSPORT_ERROR = 'transport_error'; +export const CONNECTION_ACK_REASON_INVALID_REPLY = 'invalid_reply'; + +export const CONNECTION_AUTH_REASON_NO_PROVIDER = 'no_auth_provider'; +export const CONNECTION_AUTH_REASON_CLOSED = 'closed'; +export const CONNECTION_AUTH_REASON_REJECTED = 'rejected'; +export const CONNECTION_AUTH_REASON_TIMEOUT = 'timeout'; +export const CONNECTION_AUTH_REASON_TRANSPORT_ERROR = 'transport_error'; + +export const CONNECTION_CONNECT_REASON_TRANSPORT_ERROR = 'transport_error'; +export const CONNECTION_CONNECT_REASON_AUTH_FAILED = 'auth_failed'; +export const CONNECTION_CONNECT_REASON_DISPOSED = 'disposed'; +export const CONNECTION_CONNECT_REASON_RECONNECT_EXHAUSTED = 'reconnect_exhausted'; + +export const CONNECTION_CHANNEL_JOIN_REASON_CLOSED = 'closed'; +export const CONNECTION_CHANNEL_JOIN_REASON_REJECTED = 'rejected'; +export const CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR = 'transport_error'; + +export const CONNECTION_EVENT_STATE = 'state'; +export const CONNECTION_EVENT_MESSAGE = 'message'; +export const CONNECTION_EVENT_CHANNELS = 'channels'; +export const CONNECTION_EVENT_DISPOSED = 'disposed'; + +export const CONNECTION_METHOD_CONNECT = 'connect'; +export const CONNECTION_METHOD_DISCONNECT = 'disconnect'; +export const CONNECTION_METHOD_RECONNECT = 'reconnect'; +export const CONNECTION_METHOD_SEND = 'send'; +export const CONNECTION_METHOD_REQUEST = 'request'; +export const CONNECTION_METHOD_CREATE_CONNECTION = 'createConnection'; +export const CONNECTION_METHOD_CONNECTION = 'connection'; +export const CONNECTION_METHOD_OPEN_CONNECTION = 'openConnection'; +export const CONNECTION_METHOD_CLOSE_CONNECTION = 'closeConnection'; +export const CONNECTION_METHOD_RECONNECT_CONNECTION = 'reconnectConnection'; +export const CONNECTION_METHOD_OPEN_ALL = 'openAll'; +export const CONNECTION_METHOD_CLOSE_ALL = 'closeAll'; +export const CONNECTION_METHOD_RECONNECT_ALL = 'reconnectAll'; +export const CONNECTION_METHOD_REAUTHENTICATE = 'reauthenticate'; +export const CONNECTION_METHOD_JOIN = 'join'; +export const CONNECTION_METHOD_LEAVE = 'leave'; + +export const CONNECTION_FRAME_TYPE_AUTH = 'conn.auth'; +export const CONNECTION_FRAME_TYPE_JOIN = 'conn.join'; +export const CONNECTION_FRAME_TYPE_LEAVE = 'conn.leave'; +export const CONNECTION_FRAME_TYPE_PING = 'conn.ping'; +export const CONNECTION_FRAME_TYPE_PONG = 'conn.pong'; + +export const CONNECTION_ID_PREFIX = 'conn'; +export const CONNECTION_ID_SEPARATOR = '-'; + +export const CONNECTION_CLOSE_REASON_DISCONNECT = 'disconnect'; +export const CONNECTION_CLOSE_REASON_RECONNECT = 'reconnect'; +export const CONNECTION_CLOSE_REASON_DISPOSE = 'dispose'; +export const CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT = 'heartbeat_timeout'; +export const CONNECTION_CLOSE_REASON_SESSION_EXPIRED = 'session_expired'; +export const CONNECTION_CLOSE_REASON_AUTH_FAILED = 'auth_failed'; + +export const TIMER_KEY_RECONNECT = 'reconnect'; +export const TIMER_KEY_HEARTBEAT = 'heartbeat'; +export const TIMER_KEY_HEARTBEAT_TIMEOUT = 'heartbeat-timeout'; +export const TIMER_KEY_ACK = 'ack'; + +export const BROWSER_EVENT_OPEN = 'open'; +export const BROWSER_EVENT_MESSAGE = 'message'; +export const BROWSER_EVENT_CLOSE = 'close'; +export const BROWSER_EVENT_ERROR = 'error'; +export const BROWSER_EVENT_ONLINE = 'online'; +export const BROWSER_EVENT_VISIBILITY_CHANGE = 'visibilitychange'; +export const DOCUMENT_VISIBILITY_VISIBLE = 'visible'; + +export const WEBSOCKET_BINARY_TYPE_ARRAYBUFFER = 'arraybuffer'; +export const WEBSOCKET_READY_STATE_CONNECTING = 0; +export const WEBSOCKET_READY_STATE_OPEN = 1; +export const WEBSOCKET_READY_STATE_CLOSING = 2; +export const WEBSOCKET_READY_STATE_CLOSED = 3; +export const WEBSOCKET_CLOSE_CODE_NORMAL = 1000; +export const WEBSOCKET_CLOSE_CODE_ABNORMAL = 1006; + +export const MOCK_TRANSPORT_DEFAULT_CLOSE_CODE = WEBSOCKET_CLOSE_CODE_NORMAL; +export const MOCK_TRANSPORT_DEFAULT_CLOSE_CLEAN = true; + +export const SESSION_EVENT_REFRESHED = 'REFRESHED'; +export const SESSION_EVENT_EXPIRED = 'EXPIRED'; +export const SESSION_EVENT_REVOKED = 'REVOKED'; + +export const LOG_MSG_LISTENER_THREW_PREFIX = 'Listener threw on '; +export const LOG_MSG_TRANSPORT_ERROR = 'transport error'; +export const LOG_MSG_RECONNECT_EXHAUSTED = 'reconnect attempts exhausted'; +export const LOG_MSG_CONNECT_FAILED = 'connect failed'; +export const LOG_MSG_SEND_FAILED = 'send failed'; +export const LOG_MSG_FRAME_DECODE_FAILED = 'frame decode failed'; +export const LOG_MSG_FRAME_ENCODE_FAILED = 'frame encode failed'; +export const LOG_MSG_AUTH_FAILED = 'auth failed'; +export const LOG_MSG_REAUTH_FAILED = 'reauthentication failed'; +export const LOG_MSG_HEARTBEAT_TIMEOUT = 'heartbeat timeout'; +export const LOG_MSG_SESSION_REVOKED = 'session revoked; disconnecting connection'; +export const LOG_MSG_SESSION_EXPIRED = 'session expired; disconnecting connection'; +export const LOG_MSG_SESSION_REFRESHED = 'session refreshed; reauthenticating connection'; +export const LOG_MSG_BROWSER_RECONNECT = 'browser lifecycle reconnect requested'; + +export const ERROR_PREFIX = '[conn] '; +export const ERROR_NAME_DISPOSED = 'ConnDisposedError'; +export const ERROR_NAME_ALREADY_EXISTS = 'ConnConnectionAlreadyExistsError'; +export const ERROR_NAME_NOT_FOUND = 'ConnConnectionNotFoundError'; +export const ERROR_NAME_INVALID_NAME = 'ConnInvalidConnectionNameError'; +export const ERROR_NAME_INVALID_FRAME = 'ConnInvalidFrameError'; +export const ERROR_NAME_CHANNEL_ALREADY_EXISTS = 'ConnChannelAlreadyExistsError'; +export const ERROR_NAME_CHANNEL_NOT_FOUND = 'ConnChannelNotFoundError'; + +export const ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed connection engine'; +export const ERROR_MSG_ALREADY_EXISTS_PREFIX = 'connection already exists: '; +export const ERROR_MSG_NOT_FOUND_PREFIX = 'connection not found: '; +export const ERROR_MSG_INVALID_NAME_PREFIX = 'invalid connection name: '; +export const ERROR_MSG_INVALID_FRAME = 'invalid connection frame'; +export const ERROR_MSG_FRAME_MUST_BE_OBJECT = 'Connection frame must be an object'; +export const ERROR_MSG_FRAME_TYPE_MUST_BE_STRING = + 'Connection frame type must be a non-empty string'; +export const ERROR_MSG_FRAME_ID_MUST_BE_STRING = 'Connection frame id must be a string'; +export const ERROR_MSG_FRAME_TOPIC_MUST_BE_STRING = 'Connection frame topic must be a string'; +export const ERROR_MSG_FRAME_REPLY_TO_MUST_BE_STRING = 'Connection frame replyTo must be a string'; +export const ERROR_MSG_FRAME_ACK_MUST_BE_BOOLEAN = 'Connection frame ack must be a boolean'; +export const ERROR_MSG_FRAME_TS_MUST_BE_NUMBER = 'Connection frame ts must be a number'; +export const ERROR_MSG_FRAME_ERROR_MUST_BE_OBJECT = 'Connection frame error must be an object'; +export const ERROR_MSG_JSON_DECODE_FAILED = 'Connection JSON payload is invalid'; +export const ERROR_MSG_WEBSOCKET_UNAVAILABLE = 'WebSocket is not available in this runtime'; +export const ERROR_MSG_CHANNEL_ALREADY_EXISTS_PREFIX = 'channel already exists: '; +export const ERROR_MSG_CHANNEL_NOT_FOUND_PREFIX = 'channel not found: '; + +export function disposedErrorMessage(method: string): string { + return `${ERROR_PREFIX}${method}${ERROR_MSG_DISPOSED_SUFFIX}`; +} + +export function alreadyExistsErrorMessage(name: string): string { + return `${ERROR_PREFIX}${ERROR_MSG_ALREADY_EXISTS_PREFIX}${name}`; +} + +export function notFoundErrorMessage(name: string): string { + return `${ERROR_PREFIX}${ERROR_MSG_NOT_FOUND_PREFIX}${name}`; +} + +export function invalidNameErrorMessage(name: unknown): string { + return `${ERROR_PREFIX}${ERROR_MSG_INVALID_NAME_PREFIX}${String(name)}`; +} + +export function channelAlreadyExistsErrorMessage(name: string): string { + return `${ERROR_PREFIX}${ERROR_MSG_CHANNEL_ALREADY_EXISTS_PREFIX}${name}`; +} + +export function channelNotFoundErrorMessage(name: string): string { + return `${ERROR_PREFIX}${ERROR_MSG_CHANNEL_NOT_FOUND_PREFIX}${name}`; +} + +export function listenerThrewMessage(event: string): string { + return `${LOG_MSG_LISTENER_THREW_PREFIX}${event}`; +} + +export function loggerScope(name: string, topic?: string): string { + return topic === undefined + ? `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${name}` + : `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${name}${LOGGER_SCOPE_SEPARATOR}${topic}`; +} + +export function timerKey(connection: string, kind: string, id?: string): string { + return id === undefined + ? `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${connection}${LOGGER_SCOPE_SEPARATOR}${kind}` + : `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${connection}${LOGGER_SCOPE_SEPARATOR}${kind}${LOGGER_SCOPE_SEPARATOR}${id}`; +} diff --git a/src/arts/conn/engine-connections.ts b/src/arts/conn/engine-connections.ts new file mode 100644 index 0000000..d2cdc5d --- /dev/null +++ b/src/arts/conn/engine-connections.ts @@ -0,0 +1,173 @@ +import { createEngineTimers } from '$timr'; +import { createConnection } from './connection.ts'; +import { + CONNECTION_METHOD_CLOSE_ALL, + CONNECTION_METHOD_CLOSE_CONNECTION, + CONNECTION_METHOD_CONNECTION, + CONNECTION_METHOD_CREATE_CONNECTION, + CONNECTION_METHOD_OPEN_ALL, + CONNECTION_METHOD_OPEN_CONNECTION, + CONNECTION_METHOD_RECONNECT_ALL, + CONNECTION_METHOD_RECONNECT_CONNECTION, + alreadyExistsErrorMessage, + disposedErrorMessage, + invalidNameErrorMessage, + notFoundErrorMessage +} from './consts.ts'; +import { + ConnConnectionAlreadyExistsError, + ConnConnectionNotFoundError, + ConnDisposedError, + ConnInvalidConnectionNameError +} from './errors.ts'; +import type { + Connection, + ConnectionChannelMap, + ConnectionMap, + ConnectionOptions, + EngineConnections, + EngineConnectionsOptions +} from './types.ts'; + +function validateConnectionName(name: unknown): asserts name is string { + if (typeof name !== 'string' || name.trim().length === 0) { + throw new ConnInvalidConnectionNameError(invalidNameErrorMessage(name)); + } +} + +function mergeObject( + defaults: T | undefined, + next: T | undefined +): T | undefined { + if (defaults === undefined) return next; + if (next === undefined) return defaults; + return { ...defaults, ...next }; +} + +function mergeConnectionOptions( + defaults: Partial | undefined, + options: ConnectionOptions +): ConnectionOptions { + const reconnect = + options.reconnect === false + ? false + : defaults?.reconnect === false + ? (options.reconnect ?? false) + : mergeObject(defaults?.reconnect, options.reconnect); + const heartbeat = + options.heartbeat === false + ? false + : defaults?.heartbeat === false + ? (options.heartbeat ?? false) + : mergeObject(defaults?.heartbeat, options.heartbeat); + return { + ...defaults, + ...options, + reconnect, + heartbeat, + buffer: mergeObject(defaults?.buffer, options.buffer), + channels: mergeObject( + defaults?.channels, + options.channels + ) as ConnectionOptions['channels'] + } as ConnectionOptions; +} + +export function createEngineConnections( + options: EngineConnectionsOptions = {} +): EngineConnections { + const connections = new Map(); + const timers = + options.timers ?? + createEngineTimers({ + logger: options.logger + }); + const ownsTimers = options.timers === undefined; + let disposed = false; + + function ensureLive(method: string): void { + if (disposed) throw new ConnDisposedError(disposedErrorMessage(method)); + } + + function getConnection(name: string, method: string): Connection { + ensureLive(method); + validateConnectionName(name); + const connection = connections.get(name); + if (connection === undefined) { + throw new ConnConnectionNotFoundError(notFoundErrorMessage(name), name); + } + return connection; + } + + const engine: EngineConnections = { + createConnection( + name: string, + connectionOptions: ConnectionOptions + ): Connection { + ensureLive(CONNECTION_METHOD_CREATE_CONNECTION); + validateConnectionName(name); + if (connections.has(name)) { + throw new ConnConnectionAlreadyExistsError(alreadyExistsErrorMessage(name), name); + } + const connection = createConnection( + name, + mergeConnectionOptions(options.defaults, connectionOptions), + { + logger: options.logger, + timers, + session: options.session + } + ); + connections.set(name, connection as Connection); + return connection; + }, + connection( + name: string + ): Connection { + return getConnection(name, CONNECTION_METHOD_CONNECTION) as Connection; + }, + has(name) { + if (disposed) return false; + return connections.has(name); + }, + names() { + if (disposed) return []; + return [...connections.keys()]; + }, + openConnection(name) { + return getConnection(name, CONNECTION_METHOD_OPEN_CONNECTION).connect(); + }, + closeConnection(name, reason) { + getConnection(name, CONNECTION_METHOD_CLOSE_CONNECTION).disconnect(reason); + }, + reconnectConnection(name, reason) { + return getConnection(name, CONNECTION_METHOD_RECONNECT_CONNECTION).reconnect(reason); + }, + openAll() { + ensureLive(CONNECTION_METHOD_OPEN_ALL); + return Promise.all([...connections.values()].map((connection) => connection.connect())); + }, + closeAll(reason) { + ensureLive(CONNECTION_METHOD_CLOSE_ALL); + for (const connection of connections.values()) connection.disconnect(reason); + }, + reconnectAll(reason) { + ensureLive(CONNECTION_METHOD_RECONNECT_ALL); + return Promise.all( + [...connections.values()].map((connection) => connection.reconnect(reason)) + ); + }, + close(name, reason) { + this.closeConnection(name, reason); + }, + dispose() { + if (disposed) return; + disposed = true; + for (const connection of connections.values()) connection.dispose(); + connections.clear(); + if (ownsTimers) timers.dispose(); + } + }; + + return engine; +} diff --git a/src/arts/conn/errors.ts b/src/arts/conn/errors.ts new file mode 100644 index 0000000..594273e --- /dev/null +++ b/src/arts/conn/errors.ts @@ -0,0 +1,90 @@ +import { + ERROR_NAME_ALREADY_EXISTS, + ERROR_NAME_CHANNEL_ALREADY_EXISTS, + ERROR_NAME_CHANNEL_NOT_FOUND, + ERROR_NAME_DISPOSED, + ERROR_NAME_INVALID_FRAME, + ERROR_NAME_INVALID_NAME, + ERROR_NAME_NOT_FOUND +} from './consts.ts'; + +abstract class ConnEngineError extends Error { + abstract readonly name: string; + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + } +} + +export class ConnDisposedError extends ConnEngineError { + readonly name = ERROR_NAME_DISPOSED; +} + +export class ConnConnectionAlreadyExistsError extends ConnEngineError { + readonly name = ERROR_NAME_ALREADY_EXISTS; + readonly connection: string; + constructor(message: string, connection: string) { + super(message); + this.connection = connection; + } +} + +export class ConnConnectionNotFoundError extends ConnEngineError { + readonly name = ERROR_NAME_NOT_FOUND; + readonly connection: string; + constructor(message: string, connection: string) { + super(message); + this.connection = connection; + } +} + +export class ConnInvalidConnectionNameError extends ConnEngineError { + readonly name = ERROR_NAME_INVALID_NAME; +} + +export class ConnInvalidFrameError extends ConnEngineError { + readonly name = ERROR_NAME_INVALID_FRAME; +} + +export class ConnChannelAlreadyExistsError extends ConnEngineError { + readonly name = ERROR_NAME_CHANNEL_ALREADY_EXISTS; + readonly channel: string; + constructor(message: string, channel: string) { + super(message); + this.channel = channel; + } +} + +export class ConnChannelNotFoundError extends ConnEngineError { + readonly name = ERROR_NAME_CHANNEL_NOT_FOUND; + readonly channel: string; + constructor(message: string, channel: string) { + super(message); + this.channel = channel; + } +} + +export function isConnDisposedError(value: unknown): value is ConnDisposedError { + return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_DISPOSED; +} + +export function isConnConnectionAlreadyExistsError( + value: unknown +): value is ConnConnectionAlreadyExistsError { + return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_ALREADY_EXISTS; +} + +export function isConnConnectionNotFoundError( + value: unknown +): value is ConnConnectionNotFoundError { + return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_NOT_FOUND; +} + +export function isConnInvalidConnectionNameError( + value: unknown +): value is ConnInvalidConnectionNameError { + return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INVALID_NAME; +} + +export function isConnInvalidFrameError(value: unknown): value is ConnInvalidFrameError { + return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INVALID_FRAME; +} diff --git a/src/arts/conn/index.ts b/src/arts/conn/index.ts new file mode 100644 index 0000000..3e4cf10 --- /dev/null +++ b/src/arts/conn/index.ts @@ -0,0 +1,12 @@ +export { createActiveConnections } from './active-connections.svelte.ts'; +export { createConnectionChannel } from './channel.ts'; +export { createConnection } from './connection.ts'; +export { createEngineConnections } from './engine-connections.ts'; +export { assertConnectionFrame, createFrame } from './serializer.ts'; +export { jsonConnectionSerializer } from './serializers/json.ts'; +export { createMockTransport } from './transports/mock.ts'; +export { createWebSocketTransport } from './transports/websocket.ts'; + +export * from './consts.ts'; +export * from './errors.ts'; +export * from './types.ts'; diff --git a/src/arts/conn/serializer.ts b/src/arts/conn/serializer.ts new file mode 100644 index 0000000..da6d1c1 --- /dev/null +++ b/src/arts/conn/serializer.ts @@ -0,0 +1,87 @@ +import { + FRAME_KEY_ACK, + FRAME_KEY_ERROR, + FRAME_KEY_ID, + FRAME_KEY_PAYLOAD, + FRAME_KEY_REPLY_TO, + FRAME_KEY_TOPIC, + FRAME_KEY_TS, + FRAME_KEY_TYPE, + ERROR_MSG_FRAME_ACK_MUST_BE_BOOLEAN, + ERROR_MSG_FRAME_ERROR_MUST_BE_OBJECT, + ERROR_MSG_FRAME_ID_MUST_BE_STRING, + ERROR_MSG_FRAME_MUST_BE_OBJECT, + ERROR_MSG_FRAME_REPLY_TO_MUST_BE_STRING, + ERROR_MSG_FRAME_TOPIC_MUST_BE_STRING, + ERROR_MSG_FRAME_TS_MUST_BE_NUMBER, + ERROR_MSG_FRAME_TYPE_MUST_BE_STRING +} from './consts.ts'; +import { ConnInvalidFrameError } from './errors.ts'; +import type { ConnectionFrame } from './types.ts'; + +export function assertConnectionFrame(value: unknown): asserts value is ConnectionFrame { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new ConnInvalidFrameError(ERROR_MSG_FRAME_MUST_BE_OBJECT); + } + const frame = value as Record; + if (typeof frame[FRAME_KEY_TYPE] !== 'string' || frame[FRAME_KEY_TYPE].length === 0) { + throw new ConnInvalidFrameError(ERROR_MSG_FRAME_TYPE_MUST_BE_STRING); + } + if ( + FRAME_KEY_ID in frame && + frame[FRAME_KEY_ID] !== undefined && + typeof frame[FRAME_KEY_ID] !== 'string' + ) { + throw new ConnInvalidFrameError(ERROR_MSG_FRAME_ID_MUST_BE_STRING); + } + if ( + FRAME_KEY_TOPIC in frame && + frame[FRAME_KEY_TOPIC] !== undefined && + typeof frame[FRAME_KEY_TOPIC] !== 'string' + ) { + throw new ConnInvalidFrameError(ERROR_MSG_FRAME_TOPIC_MUST_BE_STRING); + } + if ( + FRAME_KEY_REPLY_TO in frame && + frame[FRAME_KEY_REPLY_TO] !== undefined && + typeof frame[FRAME_KEY_REPLY_TO] !== 'string' + ) { + throw new ConnInvalidFrameError(ERROR_MSG_FRAME_REPLY_TO_MUST_BE_STRING); + } + if ( + FRAME_KEY_ACK in frame && + frame[FRAME_KEY_ACK] !== undefined && + typeof frame[FRAME_KEY_ACK] !== 'boolean' + ) { + throw new ConnInvalidFrameError(ERROR_MSG_FRAME_ACK_MUST_BE_BOOLEAN); + } + if ( + FRAME_KEY_TS in frame && + frame[FRAME_KEY_TS] !== undefined && + typeof frame[FRAME_KEY_TS] !== 'number' + ) { + throw new ConnInvalidFrameError(ERROR_MSG_FRAME_TS_MUST_BE_NUMBER); + } + if ( + FRAME_KEY_ERROR in frame && + frame[FRAME_KEY_ERROR] !== undefined && + (frame[FRAME_KEY_ERROR] === null || typeof frame[FRAME_KEY_ERROR] !== 'object') + ) { + throw new ConnInvalidFrameError(ERROR_MSG_FRAME_ERROR_MUST_BE_OBJECT); + } +} + +export function createFrame(input: ConnectionFrame): ConnectionFrame { + assertConnectionFrame(input); + const frame: Record = { + [FRAME_KEY_TYPE]: input.type, + [FRAME_KEY_PAYLOAD]: input.payload + }; + if (input.id !== undefined) frame[FRAME_KEY_ID] = input.id; + if (input.topic !== undefined) frame[FRAME_KEY_TOPIC] = input.topic; + if (input.ts !== undefined) frame[FRAME_KEY_TS] = input.ts; + if (input.ack !== undefined) frame[FRAME_KEY_ACK] = input.ack; + if (input.replyTo !== undefined) frame[FRAME_KEY_REPLY_TO] = input.replyTo; + if (input.error !== undefined) frame[FRAME_KEY_ERROR] = input.error; + return frame as unknown as ConnectionFrame; +} diff --git a/src/arts/conn/serializers/json.ts b/src/arts/conn/serializers/json.ts new file mode 100644 index 0000000..5d37be3 --- /dev/null +++ b/src/arts/conn/serializers/json.ts @@ -0,0 +1,26 @@ +import { ERROR_MSG_JSON_DECODE_FAILED } from '../consts.ts'; +import { ConnInvalidFrameError } from '../errors.ts'; +import { assertConnectionFrame, createFrame } from '../serializer.ts'; +import type { ConnectionFrame, ConnectionSerializer } from '../types.ts'; + +const textDecoder = new TextDecoder(); + +function rawToText(raw: string | ArrayBuffer): string { + return typeof raw === 'string' ? raw : textDecoder.decode(raw); +} + +export const jsonConnectionSerializer: ConnectionSerializer = { + encode(frame) { + return JSON.stringify(createFrame(frame)); + }, + decode(raw) { + let parsed: unknown; + try { + parsed = JSON.parse(rawToText(raw)); + } catch (cause) { + throw new ConnInvalidFrameError(ERROR_MSG_JSON_DECODE_FAILED, { cause }); + } + assertConnectionFrame(parsed); + return createFrame(parsed); + } +}; diff --git a/src/arts/conn/test/connection.test.ts b/src/arts/conn/test/connection.test.ts new file mode 100644 index 0000000..e3422d4 --- /dev/null +++ b/src/arts/conn/test/connection.test.ts @@ -0,0 +1,345 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + CONNECTION_ACK_REASON_CLOSED, + CONNECTION_ACK_REASON_TIMEOUT, + CONNECTION_BUFFER_POLICY_BUFFER, + CONNECTION_CONNECT_REASON_AUTH_FAILED, + CONNECTION_FRAME_TYPE_JOIN, + CONNECTION_SEND_REASON_CLOSED, + CONNECTION_STATE_CLOSED, + CONNECTION_STATE_FAILED, + CONNECTION_STATE_OPEN, + CONNECTION_STATE_RECONNECTING, + SESSION_EVENT_EXPIRED, + createEngineConnections, + createFrame, + createMockTransport +} from '../index.ts'; +import type { ConnectionFrame, MockConnectionTransport } from '../index.ts'; + +function latestFrame(transport: MockConnectionTransport): ConnectionFrame { + const sent = transport.sentMessages(); + return JSON.parse(sent[sent.length - 1] as string) as ConnectionFrame; +} + +function emitFrame(transport: MockConnectionTransport, frame: ConnectionFrame): void { + transport.emitMessage(JSON.stringify(createFrame(frame))); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('Connection lifecycle', () => { + it('connects and disconnects through the transport', async () => { + const Connections = createEngineConnections(); + const transport = createMockTransport(); + const Main = Connections.createConnection('main', { + transport, + heartbeat: false, + reconnect: false + }); + + const result = await Main.connect(); + expect(result.ok).toBe(true); + expect(Main.state).toBe(CONNECTION_STATE_OPEN); + expect(Main.connected).toBe(true); + + Main.disconnect(); + + expect(Main.state).toBe(CONNECTION_STATE_CLOSED); + expect(Main.connected).toBe(false); + + Connections.dispose(); + }); + + it('does not reconnect after an intentional disconnect', async () => { + vi.useFakeTimers(); + const Connections = createEngineConnections(); + const transport = createMockTransport(); + const Main = Connections.createConnection('main', { + transport, + heartbeat: false, + reconnect: { minDelayMs: 1, maxDelayMs: 1, jitterMs: 0 } + }); + + await Main.connect(); + Main.disconnect(); + await vi.advanceTimersByTimeAsync(10); + + expect(Main.state).toBe(CONNECTION_STATE_CLOSED); + + Connections.dispose(); + }); + + it('reconnects after an unexpected close', async () => { + vi.useFakeTimers(); + const Connections = createEngineConnections(); + const transport = createMockTransport(); + const Main = Connections.createConnection('main', { + transport, + heartbeat: false, + reconnect: { minDelayMs: 1, maxDelayMs: 1, jitterMs: 0 } + }); + + await Main.connect(); + const firstGeneration = Main.generation; + + transport.emitClose({ clean: false }); + expect(Main.state).toBe(CONNECTION_STATE_RECONNECTING); + + await vi.advanceTimersByTimeAsync(1); + + expect(Main.state).toBe(CONNECTION_STATE_OPEN); + expect(Main.generation).toBe(firstGeneration + 1); + + Connections.dispose(); + }); + + it('closes the transport when connect-time auth fails', async () => { + vi.useFakeTimers(); + const Connections = createEngineConnections(); + const transport = createMockTransport(); + const Main = Connections.createConnection('main', { + transport, + heartbeat: false, + reconnect: false, + auth: { + getAuth: () => ({}), + timeoutMs: 5 + } + }); + + const pending = Main.connect(); + await vi.advanceTimersByTimeAsync(5); + const result = await pending; + + expect(result).toMatchObject({ + ok: false, + reason: CONNECTION_CONNECT_REASON_AUTH_FAILED + }); + expect(Main.state).toBe(CONNECTION_STATE_FAILED); + expect(transport.canSend).toBe(false); + + Connections.dispose(); + }); +}); + +describe('Connection send/request', () => { + it('sends a serialized frame when open', async () => { + const Connections = createEngineConnections(); + const transport = createMockTransport(); + const Main = Connections.createConnection('main', { + transport, + heartbeat: false, + reconnect: false + }); + + await Main.connect(); + const result = await Main.send('demo.event', { ok: true }); + const sent = latestFrame(transport); + + expect(result.ok).toBe(true); + expect(sent).toMatchObject({ + type: 'demo.event', + payload: { ok: true } + }); + + Connections.dispose(); + }); + + it('fails fire-and-forget sends while closed unless buffering is enabled', async () => { + const Connections = createEngineConnections(); + const Main = Connections.createConnection('main', { + transport: createMockTransport(), + heartbeat: false, + reconnect: false + }); + + const result = await Main.send('demo.event', undefined); + + expect(result).toEqual({ ok: false, reason: CONNECTION_SEND_REASON_CLOSED }); + + Connections.dispose(); + }); + + it('buffers fire-and-forget frames and flushes on connect', async () => { + const Connections = createEngineConnections(); + const transport = createMockTransport(); + const Main = Connections.createConnection('main', { + transport, + heartbeat: false, + reconnect: false, + buffer: { policy: CONNECTION_BUFFER_POLICY_BUFFER } + }); + + const buffered = await Main.send('demo.buffered', { queued: true }); + expect(buffered.ok).toBe(true); + expect(transport.sentMessages()).toHaveLength(0); + + await Main.connect(); + + expect(latestFrame(transport)).toMatchObject({ + type: 'demo.buffered', + payload: { queued: true } + }); + + Connections.dispose(); + }); + + it('resolves a request from an incoming replyTo frame', async () => { + const Connections = createEngineConnections(); + const transport = createMockTransport(); + const Main = Connections.createConnection('main', { + transport, + heartbeat: false, + reconnect: false + }); + + await Main.connect(); + const pending = Main.request<{ ask: boolean }, { answer: boolean }>('demo.request', { + ask: true + }); + await Promise.resolve(); + const sent = latestFrame(transport); + + emitFrame(transport, { + type: 'demo.reply', + payload: { answer: true }, + replyTo: sent.id + }); + + await expect(pending).resolves.toEqual({ ok: true, payload: { answer: true } }); + + Connections.dispose(); + }); + + it('times out unanswered requests', async () => { + vi.useFakeTimers(); + const Connections = createEngineConnections(); + const Main = Connections.createConnection('main', { + transport: createMockTransport(), + heartbeat: false, + reconnect: false + }); + + await Main.connect(); + const pending = Main.request('demo.request', undefined, { timeoutMs: 5 }); + await vi.advanceTimersByTimeAsync(5); + + await expect(pending).resolves.toEqual({ ok: false, reason: CONNECTION_ACK_REASON_TIMEOUT }); + + Connections.dispose(); + }); + + it('closes pending requests on disconnect', async () => { + const Connections = createEngineConnections(); + const Main = Connections.createConnection('main', { + transport: createMockTransport(), + heartbeat: false, + reconnect: false + }); + + await Main.connect(); + const pending = Main.request('demo.request', undefined); + Main.disconnect(); + + await expect(pending).resolves.toEqual({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED }); + + Connections.dispose(); + }); +}); + +describe('Connection channels', () => { + it('caches channel instances by name', () => { + const Connections = createEngineConnections(); + const Main = Connections.createConnection('main', { + transport: createMockTransport(), + heartbeat: false, + reconnect: false + }); + + expect(Main.channel('orders')).toBe(Main.channel('orders')); + + Connections.dispose(); + }); + + it('routes topic frames to the matching channel', async () => { + const Connections = createEngineConnections(); + const transport = createMockTransport(); + const Main = Connections.createConnection('main', { + transport, + heartbeat: false, + reconnect: false + }); + const Orders = Main.channel<{ 'order.updated': { id: string } }>('orders'); + const calls: string[] = []; + + Orders.on('order.updated', (payload) => { + calls.push(payload.id); + }); + + await Main.connect(); + emitFrame(transport, { + topic: 'orders', + type: 'order.updated', + payload: { id: 'o1' } + }); + + expect(calls).toEqual(['o1']); + + Connections.dispose(); + }); + + it('join sends the standard channel join frame', async () => { + const Connections = createEngineConnections(); + const transport = createMockTransport(); + const Main = Connections.createConnection('main', { + transport, + heartbeat: false, + reconnect: false + }); + const Orders = Main.channel('orders'); + + await Main.connect(); + const result = await Orders.join({ tenant: 'acme' }); + + expect(result.ok).toBe(true); + expect(latestFrame(transport)).toMatchObject({ + topic: 'orders', + type: CONNECTION_FRAME_TYPE_JOIN, + payload: { tenant: 'acme' } + }); + + Connections.dispose(); + }); +}); + +describe('Connection session bridge', () => { + it('disconnects opted-in connections when session expires', async () => { + let listener: ((change: { readonly event: string }) => void) | undefined; + const Connections = createEngineConnections({ + session: { + onChange(fn) { + listener = fn; + return () => { + listener = undefined; + }; + } + } + }); + const Main = Connections.createConnection('main', { + transport: createMockTransport(), + heartbeat: false, + reconnect: false, + session: { enabled: true } + }); + + await Main.connect(); + listener?.({ event: SESSION_EVENT_EXPIRED }); + + expect(Main.state).toBe(CONNECTION_STATE_CLOSED); + + Connections.dispose(); + }); +}); diff --git a/src/arts/conn/test/engine-connections.test.ts b/src/arts/conn/test/engine-connections.test.ts new file mode 100644 index 0000000..6d12cc5 --- /dev/null +++ b/src/arts/conn/test/engine-connections.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + CONNECTION_STATE_CLOSED, + CONNECTION_STATE_OPEN, + createEngineConnections, + createMockTransport, + isConnConnectionAlreadyExistsError, + isConnConnectionNotFoundError +} from '../index.ts'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('createEngineConnections', () => { + it('creates, retrieves and lists named connections', () => { + const Connections = createEngineConnections(); + const transport = createMockTransport(); + const Main = Connections.createConnection('main', { + transport, + heartbeat: false, + reconnect: false + }); + + expect(Connections.has('main')).toBe(true); + expect(Connections.names()).toEqual(['main']); + expect(Connections.connection('main')).toBe(Main); + + Connections.dispose(); + }); + + it('rejects duplicate names with a typed error', () => { + const Connections = createEngineConnections(); + Connections.createConnection('main', { + transport: createMockTransport(), + heartbeat: false, + reconnect: false + }); + + try { + Connections.createConnection('main', { + transport: createMockTransport(), + heartbeat: false, + reconnect: false + }); + } catch (error) { + expect(isConnConnectionAlreadyExistsError(error)).toBe(true); + } + + Connections.dispose(); + }); + + it('throws a typed error for missing names', () => { + const Connections = createEngineConnections(); + + try { + Connections.connection('missing'); + } catch (error) { + expect(isConnConnectionNotFoundError(error)).toBe(true); + } + + Connections.dispose(); + }); + + it('opens and closes every registered connection', async () => { + const Connections = createEngineConnections(); + Connections.createConnection('main', { + transport: createMockTransport(), + heartbeat: false, + reconnect: false + }); + Connections.createConnection('chat', { + transport: createMockTransport(), + heartbeat: false, + reconnect: false + }); + + const results = await Connections.openAll(); + expect(results.every((result) => result.ok)).toBe(true); + expect(Connections.connection('main').state).toBe(CONNECTION_STATE_OPEN); + expect(Connections.connection('chat').state).toBe(CONNECTION_STATE_OPEN); + + Connections.closeAll(); + + expect(Connections.connection('main').state).toBe(CONNECTION_STATE_CLOSED); + expect(Connections.connection('chat').state).toBe(CONNECTION_STATE_CLOSED); + + Connections.dispose(); + }); +}); diff --git a/src/arts/conn/transport.ts b/src/arts/conn/transport.ts new file mode 100644 index 0000000..65e2c8f --- /dev/null +++ b/src/arts/conn/transport.ts @@ -0,0 +1,25 @@ +export interface TransportEmitter void> { + on(listener: TListener): () => void; + emit(...args: Parameters): void; + clear(): void; +} + +export function createTransportEmitter< + TListener extends (...args: never[]) => void +>(): TransportEmitter { + const listeners = new Set(); + return { + on(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + emit(...args) { + for (const listener of [...listeners]) listener(...args); + }, + clear() { + listeners.clear(); + } + }; +} diff --git a/src/arts/conn/transports/mock.ts b/src/arts/conn/transports/mock.ts new file mode 100644 index 0000000..14dd084 --- /dev/null +++ b/src/arts/conn/transports/mock.ts @@ -0,0 +1,99 @@ +import { + CONNECTION_TRANSPORT_STATE_CLOSED, + CONNECTION_TRANSPORT_STATE_CLOSING, + CONNECTION_TRANSPORT_STATE_IDLE, + CONNECTION_TRANSPORT_STATE_OPEN, + CONNECTION_TRANSPORT_STATE_OPENING, + MOCK_TRANSPORT_DEFAULT_CLOSE_CLEAN, + MOCK_TRANSPORT_DEFAULT_CLOSE_CODE, + TRANSPORT_KIND_MOCK +} from '../consts.ts'; +import { createTransportEmitter } from '../transport.ts'; +import type { + ConnectionCloseEvent, + ConnectionTransportState, + MockConnectionTransport, + MockTransportOptions +} from '../types.ts'; + +export function createMockTransport(options: MockTransportOptions = {}): MockConnectionTransport { + const autoOpen = options.autoOpen ?? true; + const openEmitter = createTransportEmitter<() => void>(); + const messageEmitter = createTransportEmitter<(message: string | ArrayBuffer) => void>(); + const closeEmitter = createTransportEmitter<(event: ConnectionCloseEvent) => void>(); + const errorEmitter = createTransportEmitter<(error: unknown) => void>(); + const sent: (string | ArrayBuffer)[] = []; + let state: ConnectionTransportState = CONNECTION_TRANSPORT_STATE_IDLE; + const openResolvers = new Set<() => void>(); + + function resolveOpenWaiters(): void { + for (const resolve of openResolvers) resolve(); + openResolvers.clear(); + } + + function emitOpen(): void { + state = CONNECTION_TRANSPORT_STATE_OPEN; + resolveOpenWaiters(); + openEmitter.emit(); + } + + function emitClose(event: Partial = {}): void { + state = CONNECTION_TRANSPORT_STATE_CLOSED; + resolveOpenWaiters(); + closeEmitter.emit({ + code: event.code ?? MOCK_TRANSPORT_DEFAULT_CLOSE_CODE, + reason: event.reason, + clean: event.clean ?? MOCK_TRANSPORT_DEFAULT_CLOSE_CLEAN + }); + } + + return { + kind: TRANSPORT_KIND_MOCK, + get state() { + return state; + }, + get bufferedAmount() { + return sent.length; + }, + get canSend() { + return state === CONNECTION_TRANSPORT_STATE_OPEN; + }, + open() { + if (state === CONNECTION_TRANSPORT_STATE_OPEN) return Promise.resolve(); + state = CONNECTION_TRANSPORT_STATE_OPENING; + if (autoOpen) { + emitOpen(); + return Promise.resolve(); + } + return new Promise((resolve) => { + openResolvers.add(resolve); + }); + }, + send(data) { + sent.push(data); + }, + close(code, reason) { + if (state === CONNECTION_TRANSPORT_STATE_CLOSED) return; + state = CONNECTION_TRANSPORT_STATE_CLOSING; + emitClose({ code, reason }); + }, + onOpen: (listener) => openEmitter.on(listener), + onMessage: (listener) => messageEmitter.on(listener), + onClose: (listener) => closeEmitter.on(listener), + onError: (listener) => errorEmitter.on(listener), + emitOpen, + emitMessage(message) { + messageEmitter.emit(message); + }, + emitClose, + emitError(error) { + errorEmitter.emit(error); + }, + sentMessages() { + return [...sent]; + }, + clearSent() { + sent.length = 0; + } + }; +} diff --git a/src/arts/conn/transports/websocket.ts b/src/arts/conn/transports/websocket.ts new file mode 100644 index 0000000..0b1a4c4 --- /dev/null +++ b/src/arts/conn/transports/websocket.ts @@ -0,0 +1,187 @@ +import { + BROWSER_EVENT_CLOSE, + BROWSER_EVENT_ERROR, + BROWSER_EVENT_MESSAGE, + BROWSER_EVENT_OPEN, + CONNECTION_TRANSPORT_STATE_CLOSED, + CONNECTION_TRANSPORT_STATE_CLOSING, + CONNECTION_TRANSPORT_STATE_IDLE, + CONNECTION_TRANSPORT_STATE_OPEN, + CONNECTION_TRANSPORT_STATE_OPENING, + ERROR_MSG_WEBSOCKET_UNAVAILABLE, + TRANSPORT_KIND_WEBSOCKET, + WEBSOCKET_BINARY_TYPE_ARRAYBUFFER, + WEBSOCKET_CLOSE_CODE_ABNORMAL, + WEBSOCKET_CLOSE_CODE_NORMAL, + WEBSOCKET_READY_STATE_CLOSED, + WEBSOCKET_READY_STATE_CLOSING, + WEBSOCKET_READY_STATE_OPEN +} from '../consts.ts'; +import { createTransportEmitter } from '../transport.ts'; +import type { + ConnectionCloseEvent, + ConnectionTransport, + ConnectionTransportState, + WebSocketConstructorLike, + WebSocketLike, + WebSocketTransportOptions +} from '../types.ts'; + +type OpenWaiter = { + resolve(): void; + reject(error: unknown): void; +}; + +export function createWebSocketTransport(options: WebSocketTransportOptions): ConnectionTransport { + const openEmitter = createTransportEmitter<() => void>(); + const messageEmitter = createTransportEmitter<(message: string | ArrayBuffer) => void>(); + const closeEmitter = createTransportEmitter<(event: ConnectionCloseEvent) => void>(); + const errorEmitter = createTransportEmitter<(error: unknown) => void>(); + + let socket: WebSocketLike | null = null; + let state: ConnectionTransportState = CONNECTION_TRANSPORT_STATE_IDLE; + let openWaiter: OpenWaiter | null = null; + let detachSocketListeners: (() => void) | null = null; + + function resolveUrl(): string | URL { + return typeof options.url === 'function' ? options.url() : options.url; + } + + function resolveProtocols(): string | string[] | undefined { + const protocols = options.protocols; + return typeof protocols === 'function' ? protocols() : protocols; + } + + function resolveConstructor(): WebSocketConstructorLike { + const ctor = + options.WebSocket ?? + (globalThis as { readonly WebSocket?: WebSocketConstructorLike }).WebSocket; + if (ctor === undefined) throw new Error(ERROR_MSG_WEBSOCKET_UNAVAILABLE); + return ctor; + } + + function settleOpen(error?: unknown): void { + const waiter = openWaiter; + openWaiter = null; + if (waiter === null) return; + if (error === undefined) waiter.resolve(); + else waiter.reject(error); + } + + function readNativeState(): ConnectionTransportState { + const current = socket; + if (current === null) return state; + if (current.readyState === WEBSOCKET_READY_STATE_OPEN) return CONNECTION_TRANSPORT_STATE_OPEN; + if (current.readyState === WEBSOCKET_READY_STATE_CLOSING) { + return CONNECTION_TRANSPORT_STATE_CLOSING; + } + if (current.readyState === WEBSOCKET_READY_STATE_CLOSED) { + return CONNECTION_TRANSPORT_STATE_CLOSED; + } + return CONNECTION_TRANSPORT_STATE_OPENING; + } + + function removeSocketListeners(): void { + detachSocketListeners?.(); + detachSocketListeners = null; + } + + function attachSocketListeners(next: WebSocketLike): void { + const handleOpen = (): void => { + state = CONNECTION_TRANSPORT_STATE_OPEN; + settleOpen(); + openEmitter.emit(); + }; + const handleMessage = (event: unknown): void => { + const data = (event as { readonly data?: unknown }).data; + if (typeof data === 'string' || data instanceof ArrayBuffer) { + messageEmitter.emit(data); + return; + } + if (data !== undefined) messageEmitter.emit(String(data)); + }; + const handleClose = (event: unknown): void => { + state = CONNECTION_TRANSPORT_STATE_CLOSED; + const closeEvent = event as { + readonly code?: unknown; + readonly reason?: unknown; + readonly wasClean?: unknown; + }; + const mapped: ConnectionCloseEvent = { + code: typeof closeEvent.code === 'number' ? closeEvent.code : WEBSOCKET_CLOSE_CODE_ABNORMAL, + reason: typeof closeEvent.reason === 'string' ? closeEvent.reason : undefined, + clean: closeEvent.wasClean === true + }; + settleOpen(mapped); + closeEmitter.emit(mapped); + }; + const handleError = (event: unknown): void => { + errorEmitter.emit(event); + settleOpen(event); + }; + next.addEventListener(BROWSER_EVENT_OPEN, handleOpen); + next.addEventListener(BROWSER_EVENT_MESSAGE, handleMessage); + next.addEventListener(BROWSER_EVENT_CLOSE, handleClose); + next.addEventListener(BROWSER_EVENT_ERROR, handleError); + detachSocketListeners = () => { + next.removeEventListener(BROWSER_EVENT_OPEN, handleOpen); + next.removeEventListener(BROWSER_EVENT_MESSAGE, handleMessage); + next.removeEventListener(BROWSER_EVENT_CLOSE, handleClose); + next.removeEventListener(BROWSER_EVENT_ERROR, handleError); + }; + } + + return { + kind: TRANSPORT_KIND_WEBSOCKET, + get state() { + return readNativeState(); + }, + get bufferedAmount() { + return socket?.bufferedAmount ?? 0; + }, + get canSend() { + return socket?.readyState === WEBSOCKET_READY_STATE_OPEN; + }, + open() { + if (socket?.readyState === WEBSOCKET_READY_STATE_OPEN) return Promise.resolve(); + if (openWaiter !== null) { + return new Promise((resolve, reject) => { + const previous = openWaiter; + openWaiter = { + resolve() { + previous?.resolve(); + resolve(); + }, + reject(error) { + previous?.reject(error); + reject(error); + } + }; + }); + } + removeSocketListeners(); + state = CONNECTION_TRANSPORT_STATE_OPENING; + const Ctor = resolveConstructor(); + const protocols = resolveProtocols(); + const next = + protocols === undefined ? new Ctor(resolveUrl()) : new Ctor(resolveUrl(), protocols); + next.binaryType = WEBSOCKET_BINARY_TYPE_ARRAYBUFFER; + socket = next; + attachSocketListeners(next); + return new Promise((resolve, reject) => { + openWaiter = { resolve, reject }; + }); + }, + send(data) { + socket?.send(data); + }, + close(code = WEBSOCKET_CLOSE_CODE_NORMAL, reason) { + state = CONNECTION_TRANSPORT_STATE_CLOSING; + socket?.close(code, reason); + }, + onOpen: (listener) => openEmitter.on(listener), + onMessage: (listener) => messageEmitter.on(listener), + onClose: (listener) => closeEmitter.on(listener), + onError: (listener) => errorEmitter.on(listener) + }; +} diff --git a/src/arts/conn/types.ts b/src/arts/conn/types.ts new file mode 100644 index 0000000..fae2530 --- /dev/null +++ b/src/arts/conn/types.ts @@ -0,0 +1,430 @@ +import type { TimerScheduler } from '$timr'; +import type { + CONNECTION_ACK_REASON_CLOSED, + CONNECTION_ACK_REASON_INVALID_REPLY, + CONNECTION_ACK_REASON_REJECTED, + CONNECTION_ACK_REASON_TIMEOUT, + CONNECTION_ACK_REASON_TRANSPORT_ERROR, + CONNECTION_AUTH_REASON_CLOSED, + CONNECTION_AUTH_REASON_NO_PROVIDER, + CONNECTION_AUTH_REASON_REJECTED, + CONNECTION_AUTH_REASON_TIMEOUT, + CONNECTION_AUTH_REASON_TRANSPORT_ERROR, + CONNECTION_BUFFER_POLICY_BUFFER, + CONNECTION_BUFFER_POLICY_DROP, + CONNECTION_BUFFER_POLICY_FAIL, + CONNECTION_CHANNEL_JOIN_REASON_CLOSED, + CONNECTION_CHANNEL_JOIN_REASON_REJECTED, + CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR, + CONNECTION_CHANNEL_STATE_FAILED, + CONNECTION_CHANNEL_STATE_IDLE, + CONNECTION_CHANNEL_STATE_JOINED, + CONNECTION_CHANNEL_STATE_JOINING, + CONNECTION_CHANNEL_STATE_LEAVING, + CONNECTION_CHANNEL_STATE_LEFT, + CONNECTION_CONNECT_REASON_AUTH_FAILED, + CONNECTION_CONNECT_REASON_DISPOSED, + CONNECTION_CONNECT_REASON_RECONNECT_EXHAUSTED, + CONNECTION_CONNECT_REASON_TRANSPORT_ERROR, + CONNECTION_SEND_REASON_BUFFER_FULL, + CONNECTION_SEND_REASON_CLOSED, + CONNECTION_SEND_REASON_INVALID_FRAME, + CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED, + CONNECTION_SEND_REASON_SERIALIZE_FAILED, + CONNECTION_SEND_REASON_TRANSPORT_ERROR, + CONNECTION_STATES, + CONNECTION_TRANSPORT_STATES +} from './consts.ts'; + +export type ConnectionState = (typeof CONNECTION_STATES)[number]; +export type ConnectionTransportState = (typeof CONNECTION_TRANSPORT_STATES)[number]; + +export type ConnectionChannelState = + | typeof CONNECTION_CHANNEL_STATE_IDLE + | typeof CONNECTION_CHANNEL_STATE_JOINING + | typeof CONNECTION_CHANNEL_STATE_JOINED + | typeof CONNECTION_CHANNEL_STATE_LEAVING + | typeof CONNECTION_CHANNEL_STATE_LEFT + | typeof CONNECTION_CHANNEL_STATE_FAILED; + +export type ConnectionEventMap = Record; +export type ConnectionChannelMap = Record; +export type ConnectionMap = Record; + +export interface ConnectionLogger { + debug?(category: string, message: string, input?: unknown): void; + info?(category: string, message: string, input?: unknown): void; + warn?(category: string, message: string, input?: unknown): void; + error?(category: string, message: string, input?: unknown): void; +} + +export interface ConnectionCloseEvent { + readonly code: number; + readonly reason?: string; + readonly clean: boolean; +} + +export interface ConnectionTransport { + readonly kind: string; + readonly state: ConnectionTransportState; + readonly bufferedAmount: number; + readonly canSend: boolean; + open(): Promise; + send(data: string | ArrayBuffer): Promise | void; + close(code?: number, reason?: string): void; + onOpen(listener: () => void): () => void; + onMessage(listener: (message: string | ArrayBuffer) => void): () => void; + onClose(listener: (event: ConnectionCloseEvent) => void): () => void; + onError(listener: (error: unknown) => void): () => void; +} + +export type WebSocketUrlSource = string | URL | (() => string | URL); +export type WebSocketProtocolSource = string | string[] | (() => string | string[] | undefined); +export type WebSocketEventListener = (event: unknown) => void; + +export interface WebSocketLike { + readonly readyState: number; + readonly bufferedAmount: number; + binaryType: string; + send(data: string | ArrayBuffer): void; + close(code?: number, reason?: string): void; + addEventListener(type: string, listener: WebSocketEventListener): void; + removeEventListener(type: string, listener: WebSocketEventListener): void; +} + +export interface WebSocketConstructorLike { + new (url: string | URL, protocols?: string | string[]): WebSocketLike; +} + +export interface WebSocketTransportOptions { + readonly url: WebSocketUrlSource; + readonly protocols?: WebSocketProtocolSource; + readonly WebSocket?: WebSocketConstructorLike; +} + +export interface MockTransportOptions { + readonly autoOpen?: boolean; +} + +export interface MockConnectionTransport extends ConnectionTransport { + emitOpen(): void; + emitMessage(message: string | ArrayBuffer): void; + emitClose(event?: Partial): void; + emitError(error: unknown): void; + sentMessages(): readonly (string | ArrayBuffer)[]; + clearSent(): void; +} + +export interface ConnectionSerializer { + encode(frame: TFrame): string | ArrayBuffer; + decode(raw: string | ArrayBuffer): TFrame; +} + +export interface ConnectionFrameError { + readonly message: string; + readonly code?: string; + readonly details?: unknown; +} + +export interface ConnectionFrame { + readonly id?: string; + readonly topic?: string; + readonly type: TType; + readonly payload: TPayload; + readonly ts?: number; + readonly ack?: boolean; + readonly replyTo?: string; + readonly error?: ConnectionFrameError; +} + +export interface ConnectionMessageMeta { + readonly connection: string; + readonly topic?: string; + readonly receivedAt: number; + readonly generation: number; +} + +export interface ConnectionStateChange { + readonly connection: string; + readonly from: ConnectionState; + readonly to: ConnectionState; + readonly generation: number; + readonly error?: unknown; + readonly at: number; +} + +export interface ConnectionReconnectOptions { + readonly enabled?: boolean; + readonly minDelayMs?: number; + readonly maxDelayMs?: number; + readonly factor?: number; + readonly jitterMs?: number; + readonly maxAttempts?: number; + readonly reconnectOnVisible?: boolean; + readonly reconnectOnOnline?: boolean; +} + +export interface ConnectionHeartbeatOptions { + readonly enabled?: boolean; + readonly intervalMs?: number; + readonly timeoutMs?: number; + readonly pingType?: string; + readonly pongType?: string; +} + +export interface ConnectionBufferOptions { + readonly policy?: + | typeof CONNECTION_BUFFER_POLICY_BUFFER + | typeof CONNECTION_BUFFER_POLICY_DROP + | typeof CONNECTION_BUFFER_POLICY_FAIL; + readonly maxMessages?: number; + readonly maxBytes?: number; +} + +export type ConnectionAuthPayload = Record; + +export interface ConnectionAuthOptions { + readonly getAuth?: () => ConnectionAuthPayload | null | Promise; + readonly authType?: string; + readonly timeoutMs?: number; +} + +export interface ConnectionSessionOptions { + readonly enabled?: boolean; + readonly reauthOnRefresh?: boolean; + readonly disconnectOnExpire?: boolean; +} + +export interface ConnectionChannelOptions { + readonly autoJoin?: boolean; + readonly rejoinOnReconnect?: boolean; + readonly params?: () => unknown | Promise; +} + +export interface ConnectionOptions { + readonly transport: ConnectionTransport | (() => ConnectionTransport); + readonly serializer?: ConnectionSerializer; + readonly reconnect?: ConnectionReconnectOptions | false; + readonly heartbeat?: ConnectionHeartbeatOptions | false; + readonly buffer?: ConnectionBufferOptions; + readonly auth?: + | ConnectionAuthOptions + | (() => ConnectionAuthPayload | null | Promise); + readonly session?: false | ConnectionSessionOptions; + readonly channels?: { + readonly [K in keyof TChannels & string]?: ConnectionChannelOptions; + }; + readonly loggerScope?: string; +} + +export interface ConnectionSendOptions { + readonly id?: string; + readonly topic?: string; + readonly ack?: boolean; + readonly ts?: number; + readonly buffer?: boolean; +} + +export interface ConnectionRequestOptions extends ConnectionSendOptions { + readonly timeoutMs?: number; +} + +export type ConnectionSendResult = + | { readonly ok: true; readonly id?: string } + | { + readonly ok: false; + readonly reason: + | typeof CONNECTION_SEND_REASON_CLOSED + | typeof CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED + | typeof CONNECTION_SEND_REASON_BUFFER_FULL + | typeof CONNECTION_SEND_REASON_INVALID_FRAME + | typeof CONNECTION_SEND_REASON_SERIALIZE_FAILED + | typeof CONNECTION_SEND_REASON_TRANSPORT_ERROR; + readonly error?: unknown; + }; + +export type ConnectionAckResult = + | { readonly ok: true; readonly payload: T } + | { + readonly ok: false; + readonly reason: + | typeof CONNECTION_ACK_REASON_TIMEOUT + | typeof CONNECTION_ACK_REASON_CLOSED + | typeof CONNECTION_ACK_REASON_REJECTED + | typeof CONNECTION_ACK_REASON_TRANSPORT_ERROR + | typeof CONNECTION_ACK_REASON_INVALID_REPLY; + readonly error?: unknown; + }; + +export type ConnectionAuthResult = + | { readonly ok: true } + | { + readonly ok: false; + readonly reason: + | typeof CONNECTION_AUTH_REASON_NO_PROVIDER + | typeof CONNECTION_AUTH_REASON_CLOSED + | typeof CONNECTION_AUTH_REASON_REJECTED + | typeof CONNECTION_AUTH_REASON_TIMEOUT + | typeof CONNECTION_AUTH_REASON_TRANSPORT_ERROR; + readonly error?: unknown; + }; + +export type ConnectionConnectResult = + | { readonly ok: true; readonly state: ConnectionState; readonly reused?: boolean } + | { + readonly ok: false; + readonly state: ConnectionState; + readonly reason: + | typeof CONNECTION_CONNECT_REASON_TRANSPORT_ERROR + | typeof CONNECTION_CONNECT_REASON_AUTH_FAILED + | typeof CONNECTION_CONNECT_REASON_DISPOSED + | typeof CONNECTION_CONNECT_REASON_RECONNECT_EXHAUSTED; + readonly error?: unknown; + }; + +export type ConnectionChannelJoinResult = + | { readonly ok: true } + | { + readonly ok: false; + readonly reason: + | typeof CONNECTION_CHANNEL_JOIN_REASON_CLOSED + | typeof CONNECTION_CHANNEL_JOIN_REASON_REJECTED + | typeof CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR; + readonly error?: unknown; + }; + +export interface ConnectionChannel { + readonly name: string; + readonly state: ConnectionChannelState; + join(params?: unknown): Promise; + leave(): Promise; + send( + type: K, + payload: TEvents[K], + options?: ConnectionSendOptions + ): Promise; + request( + type: K, + payload: TEvents[K], + options?: ConnectionRequestOptions + ): Promise>; + on( + type: K, + listener: (payload: TEvents[K], meta: ConnectionMessageMeta) => void + ): () => void; + onAny(listener: (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void): () => void; + dispose(): void; +} + +export interface Connection { + readonly name: string; + readonly state: ConnectionState; + readonly connected: boolean; + readonly generation: number; + readonly error: unknown | null; + readonly openedAt: number | null; + readonly closedAt: number | null; + readonly lastMessageAt: number | null; + readonly reconnectAttempt: number; + connect(): Promise; + disconnect(reason?: string): void; + reconnect(reason?: string): Promise; + reauthenticate(): Promise; + send( + type: string, + payload: TPayload, + options?: ConnectionSendOptions + ): Promise; + request( + type: string, + payload: TPayload, + options?: ConnectionRequestOptions + ): Promise>; + channel( + name: K, + options?: ConnectionChannelOptions + ): ConnectionChannel; + channel( + name: string, + options?: ConnectionChannelOptions + ): ConnectionChannel; + channels(): readonly ConnectionChannel[]; + hasChannel(name: string): boolean; + leaveChannel(name: string): Promise; + onState(listener: (change: ConnectionStateChange) => void): () => void; + onAny(listener: (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void): () => void; + dispose(): void; +} + +export interface EngineConnectionsOptions { + readonly logger?: ConnectionLogger; + readonly timers?: TimerScheduler; + readonly defaults?: Partial; + readonly session?: ConnectionSessionSource; +} + +export type ActiveConnectionsOptions = EngineConnectionsOptions; + +export interface ConnectionSessionChange { + readonly event: string; +} + +export interface ConnectionSessionSource { + onChange(listener: (change: ConnectionSessionChange) => void): () => void; +} + +export interface EngineConnections { + createConnection( + name: K, + options: ConnectionOptions + ): Connection; + createConnection( + name: string, + options: ConnectionOptions + ): Connection; + connection(name: K): Connection; + connection( + name: string + ): Connection; + has(name: string): boolean; + names(): readonly string[]; + openConnection(name: string): Promise; + closeConnection(name: string, reason?: string): void; + reconnectConnection(name: string, reason?: string): Promise; + openAll(): Promise; + closeAll(reason?: string): void; + reconnectAll(reason?: string): Promise; + close(name: string, reason?: string): void; + dispose(): void; +} + +export interface ActiveConnections extends Omit< + EngineConnections, + 'createConnection' | 'connection' +> { + readonly size: number; + readonly activeNames: readonly string[]; + readonly states: Readonly>; + readonly connectedNames: readonly string[]; + readonly connectingNames: readonly string[]; + readonly reconnectingNames: readonly string[]; + readonly failedNames: readonly string[]; + readonly closedNames: readonly string[]; + readonly allConnected: boolean; + readonly anyConnected: boolean; + readonly anyConnecting: boolean; + readonly anyReconnecting: boolean; + readonly anyFailed: boolean; + createConnection( + name: K, + options: ConnectionOptions + ): Connection; + createConnection( + name: string, + options: ConnectionOptions + ): Connection; + connection(name: K): Connection; + connection( + name: string + ): Connection; +} diff --git a/src/arts/http/body.ts b/src/arts/http/body.ts index d875f3b..54130c3 100644 --- a/src/arts/http/body.ts +++ b/src/arts/http/body.ts @@ -1,129 +1,8 @@ -import { NULL_BODY_STATUSES } from './consts.ts'; -import type { HttpBodyInit, HttpHeadersHook, HttpHeadersInit } from './types.ts'; - -const JSON_CONTENT_TYPE_PATTERN = /\/(?:.*[.+-])?json(?:;|$)/i; - -/** - * Detect whether the value is "plain enough" for the engine to JSON-stringify - * it automatically. Rejects native `BodyInit` types (FormData, Blob, etc.) - * because they should be passed through to `fetch` verbatim. - * - * Adapted from `ofetch`'s `isJSONSerializable` heuristic. - */ -export function isJSONSerializable(value: unknown): value is Record | unknown[] { - if (value === null || value === undefined) return false; - if (typeof value !== 'object') return false; - if (Array.isArray(value)) return true; - - const obj = value as { buffer?: unknown; constructor?: { name?: string }; toJSON?: unknown }; - - // Typed arrays (Uint8Array, etc.) — their `.buffer` is the ArrayBuffer. - if (obj.buffer !== undefined && obj.buffer instanceof ArrayBuffer) return false; - // Native `BodyInit` instances we want to pass through. - if ( - value instanceof FormData || - value instanceof URLSearchParams || - value instanceof Blob || - value instanceof ArrayBuffer || - (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream) - ) { - return false; - } - - // Plain object literals or objects with a `toJSON` method. - const ctor = obj.constructor?.name; - if (ctor === 'Object' || ctor === undefined) return true; - if (typeof obj.toJSON === 'function') return true; - - return false; -} - -/** - * Serialize a body for `fetch`. Returns the canonical body and (when the - * engine should auto-set it) the inferred `Content-Type`. Returns - * `{ body: undefined }` for `null`/`undefined` so callers can spread safely. - */ -export function serializeBody(body: HttpBodyInit): { body: BodyInit | undefined; contentType?: string } { - if (body === null || body === undefined) return { body: undefined }; - - if (isJSONSerializable(body)) { - return { body: JSON.stringify(body), contentType: 'application/json' }; - } - - // Native BodyInit — pass through. We do not set Content-Type for - // FormData / URLSearchParams so the browser regenerates the multipart - // boundary or sets the form-encoded type itself. - return { body: body as BodyInit }; -} - -/** - * Read a Response body, returning the most informative shape: - * - `undefined` when the status carries no body or the method is HEAD - * - parsed JSON when the response declares a JSON-shaped Content-Type - * - text otherwise - * - * Errors during body read resolve to `undefined` — callers that need raw - * access reach for `response.body` directly. - */ -export async function parseBody(response: Response, method: string): Promise { - if (NULL_BODY_STATUSES.has(response.status)) return undefined; - if (method.toUpperCase() === 'HEAD') return undefined; - - const ct = response.headers.get('content-type') ?? ''; - - if (JSON_CONTENT_TYPE_PATTERN.test(ct)) { - try { - return await response.json(); - } catch { - return undefined; - } - } - - try { - const text = await response.text(); - return text === '' ? undefined : text; - } catch { - return undefined; - } -} - -/** - * Merge engine-default headers with per-call headers. Per-call wins on - * conflict. Hooks resolve eagerly (so `await` is honored before the request - * leaves the engine). Returns a fresh `Headers` instance so callers cannot - * mutate the engine's default reference. - */ -export async function mergeHeaders( - defaults: HttpHeadersInit | HttpHeadersHook | undefined, - override: HttpHeadersInit | HttpHeadersHook | undefined -): Promise { - const result = new Headers(); - - if (defaults !== undefined) await applyHeaders(result, defaults); - if (override !== undefined) await applyHeaders(result, override); - - return result; -} - -/** - * Apply a single header source onto an existing `Headers` instance. Exported - * so the engine can compose multiple sources from `with()` chains without - * losing the parent layer. - */ -export async function applyHeaders(target: Headers, source: HttpHeadersInit | HttpHeadersHook): Promise { - const resolved = typeof source === 'function' ? await source() : source; - - if (resolved instanceof Headers) { - resolved.forEach((value, key) => target.set(key, value)); - return; - } - - if (Array.isArray(resolved)) { - for (const [key, value] of resolved) target.set(key, value); - return; - } - - for (const [key, value] of Object.entries(resolved)) { - target.set(key, value); - } -} +export { + applyHeaders, + isJSONSerializable, + isJsonContentType, + mergeHeaders, + parseBody, + serializeBody +} from '$libs/http'; diff --git a/src/arts/http/consts.ts b/src/arts/http/consts.ts index 734177f..f650781 100644 --- a/src/arts/http/consts.ts +++ b/src/arts/http/consts.ts @@ -1,30 +1,31 @@ import type { HttpMethod, RetryConfig } from './types.ts'; +import { + HTTP_DEFAULT_RETRY_STATUSES, + HTTP_HEADER_CONTENT_TYPE, + HTTP_IDEMPOTENT_METHODS, + HTTP_METHOD_DELETE, + HTTP_METHOD_GET, + HTTP_METHOD_HEAD, + HTTP_METHOD_OPTIONS, + HTTP_METHOD_PATCH, + HTTP_METHOD_POST, + HTTP_METHOD_PUT, + HTTP_NULL_BODY_STATUSES, + HTTP_RETRY_AFTER_HEADERS +} from '$libs/http'; /** Logger category emitted by the engine. */ export const LOGGER_CATEGORY = 'http'; +export const ERROR_PREFIX = '[http] '; /** HTTP statuses with no body — `parseBody` returns `undefined` for these. */ -export const NULL_BODY_STATUSES: ReadonlySet = new Set([101, 204, 205, 304]); +export const NULL_BODY_STATUSES = HTTP_NULL_BODY_STATUSES; /** Methods retried by default (idempotent + safe). */ -export const DEFAULT_RETRY_METHODS: ReadonlyArray = [ - 'GET', - 'HEAD', - 'PUT', - 'DELETE', - 'OPTIONS' -]; +export const DEFAULT_RETRY_METHODS: ReadonlyArray = HTTP_IDEMPOTENT_METHODS; /** Status codes that trigger a retry by default. */ -export const DEFAULT_RETRY_STATUSES: ReadonlyArray = [ - 408, // Request Timeout - 425, // Too Early - 429, // Too Many Requests - 500, // Internal Server Error - 502, // Bad Gateway - 503, // Service Unavailable - 504 // Gateway Timeout -]; +export const DEFAULT_RETRY_STATUSES: ReadonlyArray = HTTP_DEFAULT_RETRY_STATUSES; /** * Default retry policy. Idempotent-by-default — POST/PATCH never retry unless @@ -46,10 +47,93 @@ export const DEFAULT_TIMEOUT = 10_000; export const MAX_ERROR_BODY_BYTES = 10 * 1024 * 1024; /** Headers from which `Retry-After` semantics are read, in priority order. */ -export const RETRY_AFTER_HEADERS: ReadonlyArray = [ - 'retry-after', - 'ratelimit-reset', - 'x-ratelimit-reset', - 'x-rate-limit-reset', - 'x-ratelimit-retry-after' -]; +export const RETRY_AFTER_HEADERS: ReadonlyArray = HTTP_RETRY_AFTER_HEADERS; + +export const HTTP_ERROR_NAME_NETWORK = 'HttpNetworkError'; +export const HTTP_ERROR_NAME_TIMEOUT = 'HttpTimeoutError'; +export const HTTP_ERROR_NAME_ABORT = 'HttpAbortError'; +export const HTTP_ERROR_NAME_BODY_VALIDATION = 'HttpBodyValidationError'; + +export const HTTP_TIMEOUT_SCOPE_ATTEMPT = 'attempt'; +export const HTTP_TIMEOUT_SCOPE_TOTAL = 'total'; + +export const HTTP_RESULT_KIND_HTTP = 'http'; +export const HTTP_RESULT_KIND_VALIDATION = 'validation'; +export const HTTP_RESULT_KIND_NETWORK = 'network'; + +export const LOG_MSG_BODY_SCHEMA_REJECTED = 'bodySchema rejected request payload'; +export const LOG_MSG_NETWORK_ERROR = 'network error'; +export const LOG_MSG_RESPONSE_SCHEMA_FAILED = 'response failed schema validation'; +export const LOG_MSG_RETRYING_PREFIX = 'retrying after'; +export const LOG_MSG_HTTP_PREFIX = 'HTTP'; +export const ERROR_MSG_ATTEMPT_TIMEOUT_PREFIX = 'Request exceeded per-attempt timeout'; +export const ERROR_MSG_TOTAL_TIMEOUT_PREFIX = 'Request exceeded total timeout'; +export const ERROR_MSG_ABORT_PREFIX = 'Request aborted: '; +export const ERROR_MSG_ABORT_UNKNOWN_REASON = 'unknown reason'; + +export function requestBodyValidationErrorMessage(method: HttpMethod, url: string): string { + return `${ERROR_PREFIX}Request body failed validation for ${method} ${url}`; +} + +export function requestLogMessage(method: HttpMethod, url: string): string { + return `${method} ${url}`; +} + +export function bodySchemaRejectedLogMessage(method: HttpMethod, url: string): string { + return `${requestLogMessage(method, url)} - ${LOG_MSG_BODY_SCHEMA_REJECTED}`; +} + +export function networkErrorLogMessage(method: HttpMethod, url: string): string { + return `${requestLogMessage(method, url)} - ${LOG_MSG_NETWORK_ERROR}`; +} + +export function retryingLogMessage( + method: HttpMethod, + url: string, + delayMs: number, + nextAttempt: number, + totalAttempts: number +): string { + return `${requestLogMessage(method, url)} - ${LOG_MSG_RETRYING_PREFIX} ${delayMs}ms (attempt ${nextAttempt}/${totalAttempts})`; +} + +export function responseSchemaFailedLogMessage(method: HttpMethod, url: string): string { + return `${requestLogMessage(method, url)} - ${LOG_MSG_RESPONSE_SCHEMA_FAILED}`; +} + +export function httpStatusLogMessage( + method: HttpMethod, + url: string, + status: number, + statusText: string +): string { + return `${requestLogMessage(method, url)} - ${LOG_MSG_HTTP_PREFIX} ${status} ${statusText}`; +} + +export function httpStatusErrorMessage(status: number): string { + return `${LOG_MSG_HTTP_PREFIX} ${status}`; +} + +export function attemptTimeoutErrorMessage(timeoutMs: number): string { + return `${ERROR_MSG_ATTEMPT_TIMEOUT_PREFIX} (${timeoutMs}ms)`; +} + +export function totalTimeoutErrorMessage(totalMs: number): string { + return `${ERROR_MSG_TOTAL_TIMEOUT_PREFIX} (${totalMs}ms)`; +} + +export function abortErrorMessage(reason: unknown): string { + if (reason instanceof Error) return `${ERROR_MSG_ABORT_PREFIX}${reason.message}`; + return `${ERROR_MSG_ABORT_PREFIX}${typeof reason === 'string' ? reason : ERROR_MSG_ABORT_UNKNOWN_REASON}`; +} + +export { + HTTP_HEADER_CONTENT_TYPE, + HTTP_METHOD_DELETE, + HTTP_METHOD_GET, + HTTP_METHOD_HEAD, + HTTP_METHOD_OPTIONS, + HTTP_METHOD_PATCH, + HTTP_METHOD_POST, + HTTP_METHOD_PUT +}; diff --git a/src/arts/http/engine-http.ts b/src/arts/http/engine-http.ts index 06f61ff..e6e1e01 100644 --- a/src/arts/http/engine-http.ts +++ b/src/arts/http/engine-http.ts @@ -2,7 +2,30 @@ import type { StandardSchemaV1 } from '$libs/standard-schema'; import { isPromiseLike } from '$libs/standard-schema'; import { applyHeaders, mergeHeaders, parseBody, serializeBody } from './body.ts'; -import { DEFAULT_RETRY, DEFAULT_TIMEOUT, LOGGER_CATEGORY } from './consts.ts'; +import { + DEFAULT_RETRY, + DEFAULT_TIMEOUT, + HTTP_HEADER_CONTENT_TYPE, + HTTP_METHOD_DELETE, + HTTP_METHOD_GET, + HTTP_METHOD_HEAD, + HTTP_METHOD_OPTIONS, + HTTP_METHOD_PATCH, + HTTP_METHOD_POST, + HTTP_METHOD_PUT, + HTTP_RESULT_KIND_HTTP, + HTTP_RESULT_KIND_NETWORK, + HTTP_RESULT_KIND_VALIDATION, + LOGGER_CATEGORY, + bodySchemaRejectedLogMessage, + httpStatusErrorMessage, + httpStatusLogMessage, + networkErrorLogMessage, + requestBodyValidationErrorMessage, + requestLogMessage, + responseSchemaFailedLogMessage, + retryingLogMessage +} from './consts.ts'; import { HttpBodyValidationError } from './errors.ts'; import { computeRetryDelay, delayWithSignal, shouldRetryRequest } from './retry.ts'; import { appendSearch, normalizeSearch, resolveUrl } from './search.ts'; @@ -45,25 +68,25 @@ export function createEngineHttp(options: EngineHttpOptions = {}): EngineHttp { return createEngineHttp(mergeOptions(options, overrides)); }, get(url, init) { - return execute(defaults, 'GET', url, init); + return execute(defaults, HTTP_METHOD_GET, url, init); }, head(url, init) { - return execute(defaults, 'HEAD', url, init); + return execute(defaults, HTTP_METHOD_HEAD, url, init); }, delete(url, init) { - return execute(defaults, 'DELETE', url, init); + return execute(defaults, HTTP_METHOD_DELETE, url, init); }, options(url, init) { - return execute(defaults, 'OPTIONS', url, init); + return execute(defaults, HTTP_METHOD_OPTIONS, url, init); }, post(url, init) { - return execute(defaults, 'POST', url, init); + return execute(defaults, HTTP_METHOD_POST, url, init); }, put(url, init) { - return execute(defaults, 'PUT', url, init); + return execute(defaults, HTTP_METHOD_PUT, url, init); }, patch(url, init) { - return execute(defaults, 'PATCH', url, init); + return execute(defaults, HTTP_METHOD_PATCH, url, init); } }; @@ -104,10 +127,7 @@ function freezeDefaults(options: EngineHttpOptions): ResolvedDefaults { }; } -function mergeOptions( - parent: EngineHttpOptions, - override: EngineHttpOptions -): EngineHttpOptions { +function mergeOptions(parent: EngineHttpOptions, override: EngineHttpOptions): EngineHttpOptions { return { baseUrl: override.baseUrl ?? parent.baseUrl, // Headers compose: when both layers set them, build a synthetic hook @@ -119,15 +139,9 @@ function mergeOptions( totalTimeout: override.totalTimeout ?? parent.totalTimeout, retry: { ...parent.retry, ...override.retry }, hooks: { - beforeRequest: concatHooks( - parent.hooks?.beforeRequest, - override.hooks?.beforeRequest - ), + beforeRequest: concatHooks(parent.hooks?.beforeRequest, override.hooks?.beforeRequest), beforeRetry: concatHooks(parent.hooks?.beforeRetry, override.hooks?.beforeRetry), - afterResponse: concatHooks( - parent.hooks?.afterResponse, - override.hooks?.afterResponse - ), + afterResponse: concatHooks(parent.hooks?.afterResponse, override.hooks?.afterResponse), beforeError: concatHooks(parent.hooks?.beforeError, override.hooks?.beforeError) }, logger: override.logger ?? parent.logger @@ -172,15 +186,9 @@ function resolveCallHooks( ): Required { if (init?.hooks === undefined) return defaults.hooks; return { - beforeRequest: [ - ...defaults.hooks.beforeRequest, - ...(init.hooks.beforeRequest ?? []) - ], + beforeRequest: [...defaults.hooks.beforeRequest, ...(init.hooks.beforeRequest ?? [])], beforeRetry: [...defaults.hooks.beforeRetry, ...(init.hooks.beforeRetry ?? [])], - afterResponse: [ - ...defaults.hooks.afterResponse, - ...(init.hooks.afterResponse ?? []) - ], + afterResponse: [...defaults.hooks.afterResponse, ...(init.hooks.afterResponse ?? [])], beforeError: [...defaults.hooks.beforeError, ...(init.hooks.beforeError ?? [])] }; } @@ -212,14 +220,14 @@ async function execute( const validated = await runStandardValidate(bodySchema, bodyInput); if (validated.issues !== undefined) { const error = new HttpBodyValidationError( - `[http] Request body failed validation for ${method} ${url}`, + requestBodyValidationErrorMessage(method, url), validated.issues ); // Programmer error: log it before throwing so the failure is visible // in the logger pipeline even when the caller's catch swallows the // throw. if (logger !== undefined) { - logger.error(LOGGER_CATEGORY, `${method} ${url} — bodySchema rejected request payload`, { + logger.error(LOGGER_CATEGORY, bodySchemaRejectedLogMessage(method, url), { context: { url, issueCount: validated.issues.length }, error }); @@ -261,8 +269,8 @@ async function execute( // (auth refresh pattern) and `beforeRetry` may have updated the // closure-captured token. const headers = await mergeHeaders(defaults.headers, init?.headers); - if (contentType !== undefined && !headers.has('content-type')) { - headers.set('content-type', contentType); + if (contentType !== undefined && !headers.has(HTTP_HEADER_CONTENT_TYPE)) { + headers.set(HTTP_HEADER_CONTENT_TYPE, contentType); } // Fresh request bag per attempt — hooks mutate this; mutations within @@ -281,25 +289,26 @@ async function execute( try { if (logger !== undefined) { - logger.debug(LOGGER_CATEGORY, `${method} ${ctx.url}`, { + logger.debug(LOGGER_CATEGORY, requestLogMessage(method, ctx.url), { context: { attempt, url: ctx.url } }); } - response = earlyResponse instanceof Response - ? earlyResponse - : await fetchImpl(ctx.url, { - method: ctx.request.method, - headers: ctx.request.headers, - body: ctx.request.body, - signal: ctx.request.signal - }); + response = + earlyResponse instanceof Response + ? earlyResponse + : await fetchImpl(ctx.url, { + method: ctx.request.method, + headers: ctx.request.headers, + body: ctx.request.body, + signal: ctx.request.signal + }); lastError = undefined; } catch (err) { response = undefined; lastError = classifyFetchError(err, signal); if (logger !== undefined) { - logger.warn(LOGGER_CATEGORY, `${method} ${ctx.url} — network error`, { + logger.warn(LOGGER_CATEGORY, networkErrorLogMessage(method, ctx.url), { context: { attempt }, error: lastError instanceof Error ? lastError : new Error(String(lastError)) }); @@ -318,7 +327,7 @@ async function execute( if (logger !== undefined) { logger.warn( LOGGER_CATEGORY, - `${method} ${fullUrl} — retrying after ${delay}ms (attempt ${attempt + 1}/${retry.limit + 1})`, + retryingLogMessage(method, fullUrl, delay, attempt + 1, retry.limit + 1), { context: { attempt, retryDelay: delay } } ); } @@ -363,12 +372,10 @@ async function execute( // Validation failure on a 2xx body is a contract violation between // client and server — log at ERROR so it surfaces independently of // the result handling path. - if (!result.ok && result.kind === 'validation' && logger !== undefined) { - logger.error( - LOGGER_CATEGORY, - `${method} ${fullUrl} — response failed schema validation`, - { context: { url: fullUrl, issueCount: result.issues.length } } - ); + if (!result.ok && result.kind === HTTP_RESULT_KIND_VALIDATION && logger !== undefined) { + logger.error(LOGGER_CATEGORY, responseSchemaFailedLogMessage(method, fullUrl), { + context: { url: fullUrl, issueCount: result.issues.length } + }); } return result; } @@ -377,7 +384,7 @@ async function execute( const rescued = await runBeforeError( hooks.beforeError, { ...finalCtx, response: finalResponse }, - lastError ?? new Error(`HTTP ${finalResponse.status}`) + lastError ?? new Error(httpStatusErrorMessage(finalResponse.status)) ); if (rescued instanceof Response) { return await buildOkOrValidation( @@ -392,7 +399,7 @@ async function execute( if (logger !== undefined) { logger.warn( LOGGER_CATEGORY, - `${method} ${fullUrl} — HTTP ${finalResponse.status} ${finalResponse.statusText}`, + httpStatusLogMessage(method, fullUrl, finalResponse.status, finalResponse.statusText), { context: { url: fullUrl, @@ -415,7 +422,7 @@ async function execute( ); } - return { ok: false, kind: 'network', error: lastError } as HttpResult>; + return { ok: false, kind: HTTP_RESULT_KIND_NETWORK, error: lastError } as HttpResult>; } // ============================================================================ @@ -488,7 +495,7 @@ async function buildOkOrValidation( if (validated.issues !== undefined) { return { ok: false, - kind: 'validation', + kind: HTTP_RESULT_KIND_VALIDATION, issues: validated.issues, response }; @@ -503,7 +510,7 @@ async function buildHttpFailure( const body = await parseBody(response, method); return { ok: false, - kind: 'http', + kind: HTTP_RESULT_KIND_HTTP, status: response.status, statusText: response.statusText, body, diff --git a/src/arts/http/errors.ts b/src/arts/http/errors.ts index 07f9954..bb11033 100644 --- a/src/arts/http/errors.ts +++ b/src/arts/http/errors.ts @@ -1,4 +1,12 @@ import type { StandardSchemaV1 } from '$libs/standard-schema'; +import { + HTTP_ERROR_NAME_ABORT, + HTTP_ERROR_NAME_BODY_VALIDATION, + HTTP_ERROR_NAME_NETWORK, + HTTP_ERROR_NAME_TIMEOUT, + HTTP_TIMEOUT_SCOPE_ATTEMPT, + HTTP_TIMEOUT_SCOPE_TOTAL +} from './consts.ts'; /** * Hierarchy of error classes raised internally by the engine. Most of them @@ -24,7 +32,7 @@ abstract class HttpEngineError extends Error { * cause (DNS failure, CORS preflight, offline, etc.) in `cause`. */ export class HttpNetworkError extends HttpEngineError { - readonly name = 'HttpNetworkError' as const; + readonly name = HTTP_ERROR_NAME_NETWORK; } /** @@ -32,9 +40,12 @@ export class HttpNetworkError extends HttpEngineError { * surfaces as `kind: 'network'` only on the final attempt. */ export class HttpTimeoutError extends HttpEngineError { - readonly name = 'HttpTimeoutError' as const; - readonly scope: 'attempt' | 'total'; - constructor(message: string, scope: 'attempt' | 'total') { + readonly name = HTTP_ERROR_NAME_TIMEOUT; + readonly scope: typeof HTTP_TIMEOUT_SCOPE_ATTEMPT | typeof HTTP_TIMEOUT_SCOPE_TOTAL; + constructor( + message: string, + scope: typeof HTTP_TIMEOUT_SCOPE_ATTEMPT | typeof HTTP_TIMEOUT_SCOPE_TOTAL + ) { super(message); this.scope = scope; } @@ -44,7 +55,7 @@ export class HttpTimeoutError extends HttpEngineError { * The user-supplied `signal` aborted. Engine never retries past this. */ export class HttpAbortError extends HttpEngineError { - readonly name = 'HttpAbortError' as const; + readonly name = HTTP_ERROR_NAME_ABORT; readonly reason: unknown; constructor(message: string, reason: unknown) { super(message); @@ -59,7 +70,7 @@ export class HttpAbortError extends HttpEngineError { * programmer error, not a runtime data condition. */ export class HttpBodyValidationError extends HttpEngineError { - readonly name = 'HttpBodyValidationError' as const; + readonly name = HTTP_ERROR_NAME_BODY_VALIDATION; readonly issues: ReadonlyArray; constructor(message: string, issues: ReadonlyArray) { super(message); @@ -70,19 +81,19 @@ export class HttpBodyValidationError extends HttpEngineError { // ── Type guards ───────────────────────────────────────────────────────────── export function isHttpNetworkError(value: unknown): value is HttpNetworkError { - return value instanceof Error && (value as { name?: string }).name === 'HttpNetworkError'; + return value instanceof Error && (value as { name?: string }).name === HTTP_ERROR_NAME_NETWORK; } export function isHttpTimeoutError(value: unknown): value is HttpTimeoutError { - return value instanceof Error && (value as { name?: string }).name === 'HttpTimeoutError'; + return value instanceof Error && (value as { name?: string }).name === HTTP_ERROR_NAME_TIMEOUT; } export function isHttpAbortError(value: unknown): value is HttpAbortError { - return value instanceof Error && (value as { name?: string }).name === 'HttpAbortError'; + return value instanceof Error && (value as { name?: string }).name === HTTP_ERROR_NAME_ABORT; } export function isHttpBodyValidationError(value: unknown): value is HttpBodyValidationError { return ( - value instanceof Error && (value as { name?: string }).name === 'HttpBodyValidationError' + value instanceof Error && (value as { name?: string }).name === HTTP_ERROR_NAME_BODY_VALIDATION ); } diff --git a/src/arts/http/retry.ts b/src/arts/http/retry.ts index 2abbe31..4427763 100644 --- a/src/arts/http/retry.ts +++ b/src/arts/http/retry.ts @@ -1,47 +1,8 @@ -import { RETRY_AFTER_HEADERS } from './consts.ts'; +import { parseRetryAfter } from '$libs/http'; +import { HTTP_TIMEOUT_SCOPE_TOTAL } from './consts.ts'; import { HttpAbortError, HttpTimeoutError } from './errors.ts'; import type { HttpMethod, RetryConfig } from './types.ts'; - -/** - * Pre-flight ceiling for `Retry-After` interpretation. Values above this in - * seconds are treated as epoch-second timestamps; below as a relative delay. - * Mirrors `ky`'s heuristic — chosen because no reasonable retry delay is - * larger than this and no realistic past timestamp is smaller. Any value - * after January 1 2024 (≈1.7e9) is unambiguously a date. - */ -const RETRY_AFTER_DATE_CEILING_SECONDS = 1_700_000_000; - -/** - * Parse a `Retry-After` (or one of the rate-limit-reset variants) header. - * Returns the delay in milliseconds, clamped to non-negative. Returns - * `undefined` when no header carried a usable value. - */ -export function parseRetryAfter(headers: Headers, now: number = Date.now()): number | undefined { - for (const name of RETRY_AFTER_HEADERS) { - const raw = headers.get(name); - if (raw === null) continue; - - const trimmed = raw.trim(); - if (trimmed === '') continue; - - // Numeric: seconds (delay) when small, epoch-seconds when large. - const numeric = Number(trimmed); - if (Number.isFinite(numeric)) { - if (numeric < 0) return 0; - if (numeric > RETRY_AFTER_DATE_CEILING_SECONDS) { - // Epoch seconds. - return Math.max(0, numeric * 1000 - now); - } - // Delta-seconds. - return numeric * 1000; - } - - // HTTP-date. - const parsed = Date.parse(trimmed); - if (Number.isFinite(parsed)) return Math.max(0, parsed - now); - } - return undefined; -} +export { parseRetryAfter } from '$libs/http'; /** * Decide whether a failed attempt should be retried. Hierarchy: @@ -60,7 +21,8 @@ export function shouldRetryRequest( ): boolean { // Hard stops independent of policy. if (ctx.error instanceof HttpAbortError) return false; - if (ctx.error instanceof HttpTimeoutError && ctx.error.scope === 'total') return false; + if (ctx.error instanceof HttpTimeoutError && ctx.error.scope === HTTP_TIMEOUT_SCOPE_TOTAL) + return false; if (policy.shouldRetry) return policy.shouldRetry(ctx); diff --git a/src/arts/http/search.ts b/src/arts/http/search.ts index 6dce996..fb4117e 100644 --- a/src/arts/http/search.ts +++ b/src/arts/http/search.ts @@ -1,59 +1 @@ -import type { HttpSearchInit } from './types.ts'; - -/** - * Normalize any supported search input into a `URLSearchParams`. `null` and - * `undefined` values are stripped — callers that need an empty query value - * pass an explicit empty string. - */ -export function normalizeSearch(input: HttpSearchInit | undefined): URLSearchParams | undefined { - if (input === undefined) return undefined; - - if (input instanceof URLSearchParams) return new URLSearchParams(input); - - if (typeof input === 'string') { - return new URLSearchParams(input.startsWith('?') ? input.slice(1) : input); - } - - const out = new URLSearchParams(); - - if (Array.isArray(input)) { - for (const [key, value] of input) { - out.append(key, String(value)); - } - return out; - } - - for (const [key, value] of Object.entries(input)) { - if (value === null || value === undefined) continue; - out.append(key, String(value)); - } - return out; -} - -/** - * Append `params` to the existing query string of `url`. If both sides - * declare the same key, both are kept (multi-value query semantics). - */ -export function appendSearch(url: string, params: URLSearchParams | undefined): string { - if (params === undefined || params.size === 0) return url; - - const sep = url.includes('?') ? '&' : '?'; - return `${url}${sep}${params.toString()}`; -} - -/** - * Resolve a URL against an optional `baseUrl`. Behavior: - * - `url` is absolute (`http(s)://...`, `data:`, `blob:`, ...) → returned as-is - * - `baseUrl` is empty/undefined → `url` returned as-is (relative) - * - both supplied → `baseUrl` and `url` are joined with exactly one `/` between - * and any trailing query/hash on `baseUrl` is stripped first (rare, but - * defensive) - */ -export function resolveUrl(url: string, baseUrl: string | undefined): string { - if (baseUrl === undefined || baseUrl === '') return url; - if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url)) return url; - - const trimmedBase = baseUrl.split('?')[0].split('#')[0].replace(/\/+$/, ''); - const trimmedUrl = url.replace(/^\/+/, ''); - return `${trimmedBase}/${trimmedUrl}`; -} +export { appendSearch, normalizeSearch, resolveUrl } from '$libs/http'; diff --git a/src/arts/http/timeout.ts b/src/arts/http/timeout.ts index 6f48e14..505dad5 100644 --- a/src/arts/http/timeout.ts +++ b/src/arts/http/timeout.ts @@ -1,4 +1,11 @@ import { HttpAbortError, HttpTimeoutError } from './errors.ts'; +import { + HTTP_TIMEOUT_SCOPE_ATTEMPT, + HTTP_TIMEOUT_SCOPE_TOTAL, + abortErrorMessage, + attemptTimeoutErrorMessage, + totalTimeoutErrorMessage +} from './consts.ts'; /** * Compose any number of `AbortSignal`s into a single one that fires when the @@ -34,7 +41,10 @@ export function attemptTimeoutSignal(timeoutMs: number | undefined): AbortSignal if (timeoutMs === undefined || timeoutMs <= 0) return undefined; const ctrl = new AbortController(); const id = setTimeout( - () => ctrl.abort(new HttpTimeoutError(`Request exceeded per-attempt timeout (${timeoutMs}ms)`, 'attempt')), + () => + ctrl.abort( + new HttpTimeoutError(attemptTimeoutErrorMessage(timeoutMs), HTTP_TIMEOUT_SCOPE_ATTEMPT) + ), timeoutMs ); // Best-effort `unref` so the timer does not keep Node alive after the @@ -51,7 +61,8 @@ export function totalTimeoutSignal(totalMs: number | undefined): AbortSignal | u if (totalMs === undefined || totalMs <= 0) return undefined; const ctrl = new AbortController(); const id = setTimeout( - () => ctrl.abort(new HttpTimeoutError(`Request exceeded total timeout (${totalMs}ms)`, 'total')), + () => + ctrl.abort(new HttpTimeoutError(totalTimeoutErrorMessage(totalMs), HTTP_TIMEOUT_SCOPE_TOTAL)), totalMs ); (id as unknown as { unref?: () => void }).unref?.(); @@ -73,10 +84,7 @@ export function classifyAbort(signal: AbortSignal): HttpTimeoutError | HttpAbort if (reason instanceof HttpTimeoutError) return reason; if (reason instanceof Error) { - return new HttpAbortError(`Request aborted: ${reason.message}`, reason); + return new HttpAbortError(abortErrorMessage(reason), reason); } - return new HttpAbortError( - `Request aborted: ${typeof reason === 'string' ? reason : 'unknown reason'}`, - reason - ); + return new HttpAbortError(abortErrorMessage(reason), reason); } diff --git a/src/arts/http/types.ts b/src/arts/http/types.ts index ef5761b..fd91c2a 100644 --- a/src/arts/http/types.ts +++ b/src/arts/http/types.ts @@ -16,45 +16,32 @@ */ import type { StandardSchemaV1 } from '$libs/standard-schema'; +import type { + HttpBodyInit, + HttpHeadersHook, + HttpHeadersInit, + HttpMethod, + HttpSearchInit +} from '$libs/http'; import type { EngineLogger } from '$logr'; +import type { + HTTP_RESULT_KIND_HTTP, + HTTP_RESULT_KIND_NETWORK, + HTTP_RESULT_KIND_VALIDATION +} from './consts.ts'; + +export type { + HttpBodyInit, + HttpHeadersHook, + HttpHeadersInit, + HttpMethod, + HttpSearchInit +} from '$libs/http'; // ============================================================================ // CORE // ============================================================================ -export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'; - -/** - * Search params input. Strings/numbers/booleans are stringified; - * `null` / `undefined` values are stripped (use `''` for `?key=`). - */ -export type HttpSearchInit = - | string - | URLSearchParams - | Record - | ReadonlyArray; - -/** - * Headers input. The hook form is invoked per attempt — useful for auth - * tokens that must be refreshed (return a `Promise` for - * async refresh). - */ -export type HttpHeadersInit = Headers | Record | ReadonlyArray; - -export type HttpHeadersHook = () => HttpHeadersInit | Promise; - -/** - * Body input. Native `BodyInit` types (`FormData`, `URLSearchParams`, `Blob`, - * `ArrayBuffer`, `ReadableStream`, string) pass through. Plain objects and - * arrays are JSON-stringified automatically and `Content-Type` is set to - * `application/json` if the caller did not set one. - */ -export type HttpBodyInit = BodyInit | Record | unknown[] | null | undefined; - -// ============================================================================ -// SCHEMA INFERENCE -// ============================================================================ - /** * Compute the validated output type when a schema is provided, else `unknown`. * Mirrors Sium's `InferOutput` for ergonomic chaining. @@ -89,7 +76,7 @@ export type HttpResult = | { readonly ok: true; readonly value: T; readonly response: Response } | { readonly ok: false; - readonly kind: 'http'; + readonly kind: typeof HTTP_RESULT_KIND_HTTP; readonly status: number; readonly statusText: string; readonly body: unknown; @@ -97,11 +84,11 @@ export type HttpResult = } | { readonly ok: false; - readonly kind: 'validation'; + readonly kind: typeof HTTP_RESULT_KIND_VALIDATION; readonly issues: ReadonlyArray; readonly response: Response; } - | { readonly ok: false; readonly kind: 'network'; readonly error: unknown }; + | { readonly ok: false; readonly kind: typeof HTTP_RESULT_KIND_NETWORK; readonly error: unknown }; // ============================================================================ // REQUEST INIT @@ -185,9 +172,7 @@ export interface HookContext { * response). To rewrite the outgoing request, mutate `ctx.url` or * `ctx.request` in place — both flow through to `fetch`. */ -export type BeforeRequestHook = ( - ctx: HookContext -) => void | Response | Promise; +export type BeforeRequestHook = (ctx: HookContext) => void | Response | Promise; /** * Pre-retry hook: fires between attempts after the failure has been diff --git a/src/arts/perm/Can.svelte b/src/arts/perm/Can.svelte new file mode 100644 index 0000000..9f5b9fb --- /dev/null +++ b/src/arts/perm/Can.svelte @@ -0,0 +1,55 @@ + + +{#if allowed} + {@render children?.()} +{:else if pending && loading} + {@render loading()} +{:else} + {@render fallback?.()} +{/if} diff --git a/src/arts/perm/README.md b/src/arts/perm/README.md new file mode 100644 index 0000000..56454a3 --- /dev/null +++ b/src/arts/perm/README.md @@ -0,0 +1,941 @@ +# perm + +`perm` is the authorization artifact of the framework. + +It is not an RBAC helper. It is a typed authorization runtime built around explicit +decisions: + +```ts +actor + action + resource + context -> decision +``` + +The most important rule is: + +> The server decides. The client reflects. + +Use `createEnginePermissions()` in the authoritative runtime: server routes, server actions, +API handlers, command handlers, job processors. + +Use `createActivePermissions()` in Svelte/UI code only to improve UX: hide buttons, show +disabled states, hydrate snapshots, cache remote checks and render ``. + +Client-side authorization is never a security boundary. + +## What This Solves + +Most permission systems collapse too early into one of these shapes: + +- RBAC: `user has role admin`. +- ABAC: `user.department === resource.department`. +- ReBAC: `user is owner/member/viewer of resource`. +- UI-only helpers: `can('edit', post)`. + +Real applications need all of them, often in the same decision. + +`perm` models authorization as a policy runtime: + +- Roles are actor attributes. +- Ownership and membership are relations. +- Request/session/risk data lives in context. +- Policies return rich decisions, not booleans. +- Deny overrides allow. +- Unknown deny fails closed. +- Decisions can be explained. +- List queries can be filtered or compiled into query plans. + +## Public Surface + +```ts +import { + createEnginePermissions, + createActivePermissions, + createPermissionHttpHandlers, + definePermSchema, + definePolicies, + allow, + deny, + attr, + actor, + resource, + ctx, + rel, + and, + or, + not, + mask, + redact, + audit, + requireMfa, + createSqlCompiler +} from '$perm'; +``` + +Main APIs: + +- `createEnginePermissions(options)` creates the authoritative engine. +- `createActivePermissions(options)` creates a reactive client-side reflector. +- `App.createActivePermissions(options)` creates an App-wired active client with `App.Http` and `App.Logger`. +- `createPermissionHttpHandlers(engine, resolveActor)` exposes `check`, `batch`, `what`, `explain`. +- `` renders UI based on `Permissions.can(...)`. + +## Core Concepts + +### Actor + +The subject asking for access. + +```ts +const actor = { + type: 'user', + id: 'u1', + status: 'active', + role: 'admin', + teamIds: ['team-a'] +}; +``` + +Required fields: + +- `type`: actor kind, usually `user`, `service`, `token`, `anonymous`. +- `id`: stable identifier. + +Everything else is an attribute. + +### Action + +A string in the form `resource.action`. + +```ts +'post.read'; +'post.update'; +'invoice.approve'; +'project.member.invite'; +``` + +Wildcards are supported in policies: + +```ts +deny('post.*'); +``` + +Use constants in application code if the action is shared across modules. + +### Resource + +The thing being accessed. + +```ts +const post = { + type: 'post', + id: 'p1', + visibility: 'private', + status: 'draft', + ownerId: 'u1', + teamId: 'team-a' +}; +``` + +Required field: + +- `type`: resource kind. + +Recommended field: + +- `id`: stable identifier. + +Everything else is an attribute. + +### Context + +Per-decision data that is neither actor nor resource. + +```ts +const context = { + risk: { mfa: true }, + request: { ip: '127.0.0.1' }, + tenant: 'acme' +}; +``` + +Typical context values: + +- MFA or risk flags. +- Tenant id. +- Request metadata. +- Environment. +- Time window. +- Feature flag snapshot. + +## Decisions + +`perm` does not return plain booleans from the engine. `check()` returns a decision: + +```ts +type PermissionDecision = + | { + effect: 'allow'; + policy: string; + reason?: string; + obligations?: ObligationIR[]; + advice?: AdviceIR[]; + ttl?: number; + } + | { effect: 'deny'; policy?: string; code: string; reason: string; advice?: AdviceIR[] } + | { + effect: 'indeterminate'; + reason: string; + fallback: 'deny' | 'allow'; + policy?: string; + errors?: unknown[]; + } + | { effect: 'not_applicable'; reason?: string }; +``` + +Decision meaning: + +- `allow`: access is granted. +- `deny`: access is explicitly denied. +- `indeterminate`: the runtime could not safely decide. +- `not_applicable`: no policy matched. + +`can()` is sugar over `check()`: + +```ts +await Permissions.can(input); // true only when effect === 'allow' +``` + +Everything else is false. + +## Conflict Rules + +The combiner is deny-overrides: + +1. A matching `deny` with `true` condition wins. +2. A matching `deny` with `unknown` or `error` returns `indeterminate` with fallback `deny`. +3. A matching `allow` with `true` condition allows. +4. A matching `allow` with `unknown` or `error` returns `indeterminate`. +5. No matching policy returns `not_applicable`. + +The important safety rule: + +> If a deny policy might apply but cannot be evaluated safely, access is not allowed. + +This avoids the classic production bug: "the membership provider failed, therefore the allow +policy won". + +## Schema + +The schema declares actors, resources, actions, attributes and relations. + +```ts +const schema = definePermSchema({ + actors: { + user: { + attributes: { + status: 'string', + role: 'string', + teamIds: 'string[]' + } + } + }, + resources: { + post: { + actions: ['read', 'update', 'publish', 'delete'], + attributes: { + visibility: 'string', + status: 'string', + ownerId: 'string', + teamId: 'string' + } + } + }, + relations: { + 'post.owner': { from: 'post', to: 'user' }, + 'post.team.member': { from: 'post', to: 'user' }, + 'post.team.admin': { from: 'post', to: 'user' } + }, + context: { + risk: { mfa: 'boolean' } + } +}); +``` + +The schema is intentionally lightweight. It is not a database schema and it is not a validation +schema. It is the authorization vocabulary. + +## Policies + +Policies are built as data. The fluent builder creates an IR that can be evaluated, explained, +serialized and partially compiled. + +```ts +const policies = definePolicies(schema, [ + deny('post.*') + .id('post.deny.suspended') + .priority(1000) + .when(attr('actor.status').eq('suspended')) + .because('Suspended users cannot access posts', 'user_suspended'), + + allow('post.read') + .id('post.read.public-or-member') + .when(or(attr('post.visibility').eq('public'), rel('post.team.member').is(actor()))) + .oblige(mask('internalNotes')), + + allow('post.update') + .id('post.update.owner') + .when(and(rel('post.owner').is(actor()), attr('post.status').notEq('archived'))), + + allow('post.publish') + .id('post.publish.admin-with-mfa') + .when(and(rel('post.team.admin').is(actor()), attr('context.risk.mfa').eq(true))) + .oblige(audit('post.publish', 'medium')) +]); +``` + +Policy methods: + +- `.id(id)` gives the policy a stable id. Always use this in real code. +- `.priority(number)` resolves conflicts inside same effect class. +- `.when(expr)` sets the condition. +- `.because(reason, code?)` attaches denial/explanation metadata. +- `.oblige(...items)` attaches obligations to an allow. +- `.advise(...items)` attaches non-mandatory advice. +- `.meta(record)` stores free-form metadata. + +## Expressions + +Available expression helpers: + +```ts +attr('actor.status').eq('active'); +attr('post.visibility').eq('public'); +attr('post.status').notEq('archived'); +attr('context.risk.mfa').eq(true); + +rel('post.owner').is(actor()); +rel('post.team.admin').has(actor()); + +and(exprA, exprB); +or(exprA, exprB); +not(expr); +``` + +Reference helpers: + +- `actor()` references the full actor. +- `actor('id')` references `actor.id`. +- `resource()` references the full resource. +- `resource('ownerId')` references `resource.ownerId`. +- `ctx('risk.mfa')` references `context.risk.mfa`. +- `attr('actor.role')`, `attr('post.visibility')`, `attr('context.risk.mfa')` are convenient path refs. + +## Providers + +Policies should stay declarative. Providers resolve data that is not already present. + +### Relation Provider + +Use a relation provider for ownership, membership and graph-like checks. + +```ts +const Permissions = createEnginePermissions({ + schema, + policies, + providers: { + relations: { + hasRelation({ relation, resource, subject }) { + if (relation === 'post.owner') { + return resource.ownerId === subject.id; + } + + if (relation === 'post.team.member') { + return Array.isArray(subject.teamIds) && subject.teamIds.includes(resource.teamId); + } + + return 'unknown'; + } + } + } +}); +``` + +Return values: + +- `true`: relation exists. +- `false`: relation does not exist. +- `'unknown'`: provider cannot answer safely. + +Use `'unknown'` instead of guessing. Unknown deny fails closed. + +### Attribute Provider + +Use an attribute provider when actor/resource/context attributes should be resolved lazily. + +```ts +providers: { + attributes: { + getAttribute({ root, path, context }) { + // Example: load an attribute from another source. + return undefined; + } + } +} +``` + +Most apps can start without an attribute provider by passing needed attributes directly on the +actor, resource or context. + +## Engine Usage + +Create the server-side runtime: + +```ts +export const Permissions = createEnginePermissions({ + schema, + policies, + providers, + compilers: [createSqlCompiler()] +}); +``` + +Check a permission: + +```ts +const decision = await Permissions.check({ + actor, + action: 'post.update', + resource: post, + context: { risk: { mfa: true } } +}); + +if (decision.effect !== 'allow') { + // return 403, throw, log, explain, etc. +} +``` + +Assert a permission: + +```ts +await Permissions.assert({ + actor, + action: 'post.delete', + resource: post +}); +``` + +`assert()` throws `PermissionDeniedError` for every non-allow decision. + +Use `can()` only when a boolean is enough: + +```ts +if (await Permissions.can({ actor, action: 'post.read', resource: post })) { + return post; +} +``` + +## Server Route Pattern + +The server must enforce permissions before reading or mutating protected data. + +```ts +export async function updatePost(event) { + const actor = await resolveActor(event); + const post = await loadPost(event.params.id); + + await Permissions.assert({ + actor, + action: 'post.update', + resource: post, + context: { tenant: event.locals.tenant } + }); + + return savePost(post, await event.request.json()); +} +``` + +Do not rely on `` or `ActivePermissions.can()` for this. + +## HTTP Handlers + +The active client talks to HTTP handlers. + +```ts +import { createPermissionHttpHandlers } from '$perm'; +import { Permissions } from '$lib/server/permissions'; + +const handlers = createPermissionHttpHandlers(Permissions, async (request) => { + const session = await readSession(request); + return { + type: 'user', + id: session.user.id, + status: session.user.status, + role: session.user.role, + teamIds: session.user.teamIds + }; +}); +``` + +The returned object contains: + +- `check(request)` +- `batch(request)` +- `what(request)` +- `explain(request)` + +Expose those from your SvelteKit route however your routing convention prefers. + +Conceptual route shape: + +```ts +// POST /api/permissions/check +return json(await handlers.check(request)); + +// POST /api/permissions/batch +return json(await handlers.batch(request)); + +// POST /api/permissions/what +return json(await handlers.what(request)); + +// POST /api/permissions/explain +return json(await handlers.explain(request)); +``` + +## Active Client + +Create the client directly: + +```ts +const Permissions = createActivePermissions({ + endpoint: '/api/permissions', + cacheTtlMs: 30_000 +}); +``` + +Or through App: + +```ts +const App = createActiveApp({ + permissions: { + endpoint: '/api/permissions', + cacheTtlMs: 30_000 + } +}); + +const Permissions = App.createActivePermissions(); +``` + +`App.createActivePermissions()` injects: + +- `App.Http` +- `App.Logger` + +The endpoint remains explicit because the client is remote by design. + +Active client API: + +```ts +await Permissions.check({ action, resource, context }); +await Permissions.can({ action, resource, context }); +await Permissions.batch({ checks }); +await Permissions.what({ resource, actions, context }); +await Permissions.explain({ action, resource, context }); + +Permissions.hydrate(snapshot); +Permissions.snapshot(); +Permissions.invalidate(); +Permissions.subscribe((snapshot) => {}); +``` + +Reactive fields: + +```ts +Permissions.currentSnapshot; +Permissions.decisions; +Permissions.size; +Permissions.loading; +Permissions.lastError; +``` + +## Snapshots And Cache + +The active client has a small decision cache. + +```ts +const Permissions = createActivePermissions({ + endpoint: '/api/permissions', + initialSnapshot, + cacheTtlMs: 10_000 +}); +``` + +Snapshot shape: + +```ts +interface PermissionSnapshot { + actor?: SubjectRef; + version?: string; + decisions?: Record; + global?: Record; + expiresAt?: string; +} +``` + +Use snapshots for SSR hydration or first paint. + +Important: + +- Cache is for UX. +- Cache is not security. +- Mutations still require server-side `assert()`. +- Call `invalidate()` after actor/session/resource changes. + +## The `` Component + +`` is a small Svelte component that renders its children only when +`Permissions.can(...)` returns `true`. + +It reads the active client from Svelte context: + +```ts +import { setPermissionsContext } from '$perm'; + +const Permissions = App.createActivePermissions(); +setPermissionsContext(Permissions); +``` + +Basic usage: + +```svelte + + + + + + {#snippet fallback()} + You cannot edit this post. + {/snippet} + + {#snippet loading()} + Checking permissions... + {/snippet} + +``` + +Props: + +- `action`: permission action, for example `'post.update'`. +- `resource`: optional resource object. +- `context`: optional decision context. +- `children`: rendered on allow. +- `fallback`: rendered on deny, indeterminate, not_applicable or request failure. +- `loading`: rendered while the async check is in progress. + +Again: `` is only UI. It prevents confusing affordances; it does not protect data. + +## what() + +Use `what()` when a view needs the full action matrix for one resource. + +```ts +const actions = await Permissions.what({ + actor, + resource: post +}); + +actions['post.read']; +actions['post.update']; +actions['post.delete']; +``` + +Client-side: + +```ts +const actions = await Permissions.what({ + resource: post, + actions: ['post.read', 'post.update'] +}); +``` + +This is better than firing many separate checks for a toolbar or detail page. + +## explain() + +Use `explain()` for debugging, audit panels and tests. + +```ts +const result = await Permissions.explain({ + actor, + action: 'post.publish', + resource: post, + context: { risk: { mfa: false } } +}); + +console.log(result.decision); +console.table(result.trace); +console.log(result.dependencies); +``` + +`explain()` returns: + +- `decision`: final combined decision. +- `trace`: every target policy and condition result. +- `dependencies`: actor/resource/context/relation keys used during evaluation. + +Do not expose full explanations to untrusted users unless you intentionally want to reveal policy +details. + +## filter() + +Use `filter(action)` to turn authorization into a list query. + +In-memory predicate: + +```ts +const canRead = await Permissions.filter('post.read').for(actor).resource('post').toPredicate(); + +const visible = []; +for (const post of posts) { + if (await canRead(post)) visible.push(post); +} +``` + +Query plan: + +```ts +const plan = await Permissions.filter('post.read') + .for(actor) + .resource('post') + .context({ tenant: 'acme' }) + .toPlan('sql'); +``` + +SQL compiler: + +```ts +const Permissions = createEnginePermissions({ + schema, + policies, + compilers: [ + createSqlCompiler({ + resourceAlias: 'post', + relation({ relation, resourceAlias, actor, param }) { + if (relation === 'post.owner') { + return `${resourceAlias}.owner_id = ${param(actor.id)}`; + } + return undefined; + } + }) + ] +}); +``` + +Compilation is conservative: + +- Fully compilable policies produce `strategy: 'compiled'`. +- Partially compilable policies produce `strategy: 'partial'` and `residualPolicies`. +- Non-compilable policies produce `strategy: 'not_compilable'`. + +Unsupported policies are never silently erased. + +## Obligations And Advice + +An allow can carry obligations: + +```ts +allow('post.read') + .id('post.read.public') + .when(attr('post.visibility').eq('public')) + .oblige(mask('internalNotes')); +``` + +Common obligations: + +- `mask(field, mode?)` +- `redact(field)` +- `audit(event, severity?)` +- `requireMfa(reason?)` + +Obligations are returned in the decision. It is the caller's job to enforce them. + +Example: + +```ts +const decision = await Permissions.check({ actor, action: 'post.read', resource: post }); + +if (decision.effect === 'allow') { + return applyObligations(post, decision.obligations); +} +``` + +Advice is similar but non-mandatory. + +## Integration With Other Artifacts + +### aapp + +`App.createActivePermissions()` builds the UI client and injects `App.Http` and `App.Logger`. + +```ts +const App = createActiveApp({ + permissions: { endpoint: '/api/permissions' } +}); + +const Permissions = App.createActivePermissions(); +``` + +`App.Permissions` is `undefined` until `createActivePermissions()` is called. + +### sess + +Session/authentication resolves the actor. `perm` does not log users in and does not own tokens. + +Typical flow: + +```ts +const actor = actorFromSession(App.Sess.current); +await Permissions.assert({ actor, action, resource }); +``` + +On the server, resolve the actor from server-side session state, not from a client payload. + +### http + +The active client can use `App.Http`, so existing headers, fetch scoping and hooks apply. + +### conn + +Realtime channels should use `perm` on the server side before allowing joins, sends or privileged +events. A client-side `` around a chat button is UX only. + +### logr + +`createEnginePermissions({ logger })` emits structured logs under category `'perm'` for decisions, +denials and indeterminate decisions. + +## Testing + +Unit-test policies as data. + +```ts +it('denies suspended users', async () => { + const decision = await Permissions.check({ + actor: { type: 'user', id: 'u1', status: 'suspended' }, + action: 'post.read', + resource: { type: 'post', id: 'p1', visibility: 'public' } + }); + + expect(decision.effect).toBe('deny'); +}); +``` + +Test dangerous cases: + +- Deny wins over allow. +- Unknown deny fails closed. +- Missing relation provider does not allow access. +- `what()` returns the expected action matrix. +- `filter().toPlan()` does not erase residual policies. +- Client cache invalidates when actor/resource/context changes. + +There is an interactive page at: + +```txt +/test/perm +``` + +It exercises: + +- `createEnginePermissions()` +- `App.createActivePermissions()` +- HTTP handlers +- client cache +- `` +- `check()` +- `what()` +- `explain()` +- SQL query plan + +## Security Checklist + +- Always enforce permissions on the server. +- Treat `ActivePermissions` and `` as UI helpers only. +- Never trust actor data sent by the browser. +- Prefer stable policy ids. +- Prefer constants for shared actions, relation names and policy ids. +- Return `'unknown'` from providers when data cannot be resolved safely. +- Review every `indeterminate` as denied unless there is a deliberate exception. +- Do not expose `explain()` details to users unless policy disclosure is acceptable. +- Apply obligations explicitly. +- Invalidate client cache after login, logout, session refresh, role change or resource mutation. + +## Minimal Complete Example + +```ts +import { + actor, + allow, + and, + attr, + createEnginePermissions, + definePermSchema, + definePolicies, + deny, + rel +} from '$perm'; + +const schema = definePermSchema({ + actors: { + user: { attributes: { status: 'string', role: 'string' } } + }, + resources: { + post: { actions: ['read', 'update'] } + }, + relations: { + 'post.owner': { from: 'post', to: 'user' } + } +}); + +const policies = definePolicies(schema, [ + deny('post.*') + .id('post.deny.suspended') + .priority(1000) + .when(attr('actor.status').eq('suspended')), + + allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public')), + + allow('post.update') + .id('post.update.owner') + .when(and(rel('post.owner').is(actor()), attr('post.status').notEq('archived'))) +]); + +export const Permissions = createEnginePermissions({ + schema, + policies, + providers: { + relations: { + hasRelation({ relation, resource, subject }) { + if (relation === 'post.owner') return resource.ownerId === subject.id; + return 'unknown'; + } + } + } +}); +``` + +Usage: + +```ts +await Permissions.assert({ + actor: { type: 'user', id: 'u1', status: 'active' }, + action: 'post.update', + resource: { + type: 'post', + id: 'p1', + ownerId: 'u1', + status: 'draft', + visibility: 'private' + } +}); +``` diff --git a/src/arts/perm/active-permissions.svelte.ts b/src/arts/perm/active-permissions.svelte.ts new file mode 100644 index 0000000..f353d21 --- /dev/null +++ b/src/arts/perm/active-permissions.svelte.ts @@ -0,0 +1,70 @@ +import { untrack } from 'svelte'; +import { createPermissionClient } from './client.ts'; +import { PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED } from './consts.ts'; +import type { ActivePermissions, ActivePermissionsOptions, PermissionSnapshot } from './types.ts'; + +export function createActivePermissions(options: ActivePermissionsOptions): ActivePermissions { + if (!options.endpoint) throw new Error(PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED); + + const client = createPermissionClient(options); + let snapshotCell = $state(client.snapshot()); + let loadingCount = $state(0); + let lastErrorCell = $state(null); + + const off = client.subscribe((snapshot) => { + snapshotCell = snapshot; + }); + + function updateLoading(delta: number): void { + loadingCount = Math.max(0, untrack(() => loadingCount) + delta); + } + + async function track(task: () => Promise): Promise { + updateLoading(1); + try { + const value = await task(); + lastErrorCell = null; + return value; + } catch (error) { + lastErrorCell = error; + throw error; + } finally { + updateLoading(-1); + } + } + + return { + get currentSnapshot() { + return snapshotCell; + }, + get decisions() { + return snapshotCell.decisions ?? {}; + }, + get size() { + return Object.keys(snapshotCell.decisions ?? {}).length; + }, + get loading() { + return loadingCount > 0; + }, + get lastError() { + return lastErrorCell; + }, + check: (input) => track(() => client.check(input)), + can: (input) => track(() => client.can(input)), + batch: (input) => track(() => client.batch(input)), + what: (input) => track(() => client.what(input)), + explain: (input) => track(() => client.explain(input)), + hydrate(snapshot) { + client.hydrate(snapshot); + }, + snapshot: () => client.snapshot(), + invalidate(scope) { + client.invalidate(scope); + }, + subscribe: (listener) => client.subscribe(listener), + decisionKey: (input) => client.decisionKey(input), + dispose() { + off(); + } + }; +} diff --git a/src/arts/perm/client.ts b/src/arts/perm/client.ts new file mode 100644 index 0000000..e7d7db9 --- /dev/null +++ b/src/arts/perm/client.ts @@ -0,0 +1,285 @@ +import { + PERMISSION_DECISION_CODE_SNAPSHOT_DENIED, + PERMISSION_EFFECT_ALLOW, + PERMISSION_EFFECT_DENY, + PERMISSION_EFFECT_INDETERMINATE, + PERMISSION_FALLBACK_DENY +} from '$libs/perm'; +import { HTTP_CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE, HTTP_METHOD_POST } from '$libs/http'; +import { permissionDecisionKey } from './keys.ts'; +import { + LOGGER_CATEGORY, + PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS, + PERMISSION_CLIENT_PATH_BATCH, + PERMISSION_CLIENT_PATH_CHECK, + PERMISSION_CLIENT_PATH_EXPLAIN, + PERMISSION_CLIENT_PATH_WHAT, + PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX, + PERMISSION_HTTP_CREDENTIALS_INCLUDE, + PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, + PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED, + PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED, + PERMISSION_REQUEST_FIELD_ACTION, + PERMISSION_REQUEST_FIELD_CHECKS, + PERMISSION_REQUEST_FIELD_CONTEXT, + PERMISSION_REQUEST_FIELD_RESOURCE, + PERMISSION_RESPONSE_FIELD_ACTIONS, + PERMISSION_RESPONSE_FIELD_DECISIONS, + PERMISSION_SNAPSHOT_GLOBAL_POLICY +} from './consts.ts'; +import type { + PermissionClient, + PermissionClientBatchInput, + PermissionClientCheckInput, + PermissionClientOptions, + PermissionSnapshot +} from './types.ts'; +import type { ExplainResult, PermissionDecision } from '$libs/perm'; + +interface CacheEntry { + readonly decision: PermissionDecision; + readonly expiresAt: number; +} + +function now(): number { + return Date.now(); +} + +function joinUrl(base: string, path: string): string { + return `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`; +} + +async function postJson( + options: PermissionClientOptions, + path: string, + body: unknown +): Promise { + const url = joinUrl(options.endpoint, path); + if (options.http) { + const response = await options.http.post(url, { body: body as Record }); + if (response.ok) return response.value as T; + throw new Error(`${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${url}`); + } + + const fetcher = options.fetcher ?? fetch.bind(globalThis); + const response = await fetcher(url, { + method: HTTP_METHOD_POST, + headers: { [HTTP_HEADER_CONTENT_TYPE]: HTTP_CONTENT_TYPE_JSON }, + credentials: PERMISSION_HTTP_CREDENTIALS_INCLUDE, + body: JSON.stringify(body) + }); + + if (!response.ok) { + throw new Error( + `${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${response.status} ${response.statusText}` + ); + } + + return (await response.json()) as T; +} + +export function createPermissionClient(options: PermissionClientOptions): PermissionClient { + const cacheTtlMs = options.cacheTtlMs ?? PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS; + const cache = new Map(); + const pending = new Map>(); + const listeners = new Set<(snapshot: PermissionSnapshot) => void>(); + let currentSnapshot: PermissionSnapshot = options.initialSnapshot ?? { decisions: {} }; + + function emit(): void { + for (const listener of listeners) listener(currentSnapshot); + } + + function decisionKey(input: PermissionClientCheckInput): string { + return permissionDecisionKey(input); + } + + function snapshotStillValid(snapshot: PermissionSnapshot): boolean { + return snapshot.expiresAt === undefined || Date.parse(snapshot.expiresAt) > now(); + } + + function readSnapshotDecision(input: PermissionClientCheckInput): PermissionDecision | undefined { + if (!snapshotStillValid(currentSnapshot)) return undefined; + const key = decisionKey(input); + const direct = currentSnapshot.decisions?.[key]; + if (direct) return direct; + const global = currentSnapshot.global?.[input.action]; + if (typeof global === 'boolean') { + return global + ? { effect: PERMISSION_EFFECT_ALLOW, policy: PERMISSION_SNAPSHOT_GLOBAL_POLICY } + : { + effect: PERMISSION_EFFECT_DENY, + code: PERMISSION_DECISION_CODE_SNAPSHOT_DENIED, + reason: PERMISSION_SNAPSHOT_GLOBAL_POLICY + }; + } + return global; + } + + function setCached(input: PermissionClientCheckInput, decision: PermissionDecision): void { + const key = decisionKey(input); + const ttl = + decision.effect === PERMISSION_EFFECT_ALLOW && decision.ttl ? decision.ttl : cacheTtlMs; + cache.set(key, { decision, expiresAt: now() + ttl }); + currentSnapshot = { + ...currentSnapshot, + decisions: { + ...(currentSnapshot.decisions ?? {}), + [key]: decision + } + }; + emit(); + } + + async function check(input: PermissionClientCheckInput): Promise { + const key = decisionKey(input); + const cached = cache.get(key); + if (cached && cached.expiresAt > now()) return cached.decision; + + const snapshotDecision = readSnapshotDecision(input); + if (snapshotDecision) { + cache.set(key, { decision: snapshotDecision, expiresAt: now() + cacheTtlMs }); + return snapshotDecision; + } + + const inFlight = pending.get(key); + if (inFlight) return inFlight; + + const request = postJson(options, PERMISSION_CLIENT_PATH_CHECK, { + [PERMISSION_REQUEST_FIELD_ACTION]: input.action, + [PERMISSION_REQUEST_FIELD_RESOURCE]: input.resource, + [PERMISSION_REQUEST_FIELD_CONTEXT]: input.context + }) + .then((decision) => { + setCached(input, decision); + return decision; + }) + .catch((error) => { + options.onError?.(error); + options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED, { + error, + context: { input } + }); + return { + effect: PERMISSION_EFFECT_INDETERMINATE, + reason: PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED, + fallback: PERMISSION_FALLBACK_DENY, + errors: [error] + } satisfies PermissionDecision; + }) + .finally(() => { + pending.delete(key); + }); + pending.set(key, request); + return request; + } + + async function batch( + input: PermissionClientBatchInput + ): Promise> { + try { + const result = await postJson<{ decisions: Record }>( + options, + PERMISSION_CLIENT_PATH_BATCH, + { [PERMISSION_REQUEST_FIELD_CHECKS]: input.checks } + ); + for (const item of input.checks) { + const key = decisionKey(item); + const decision = result[PERMISSION_RESPONSE_FIELD_DECISIONS][key]; + if (decision) setCached(item, decision); + } + return result[PERMISSION_RESPONSE_FIELD_DECISIONS]; + } catch (error) { + options.onError?.(error); + options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, { + error, + context: { input } + }); + const decisions: Record = {}; + for (const item of input.checks) { + decisions[decisionKey(item)] = { + effect: PERMISSION_EFFECT_INDETERMINATE, + reason: PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, + fallback: PERMISSION_FALLBACK_DENY, + errors: [error] + }; + } + return decisions; + } + } + + async function what(input: { + readonly resource?: PermissionClientCheckInput['resource']; + readonly actions?: readonly string[]; + readonly context?: PermissionClientCheckInput['context']; + }): Promise> { + try { + const result = await postJson<{ actions: Record }>( + options, + PERMISSION_CLIENT_PATH_WHAT, + input + ); + for (const [action, decision] of Object.entries(result[PERMISSION_RESPONSE_FIELD_ACTIONS])) { + setCached({ action, resource: input.resource, context: input.context }, decision); + } + return result[PERMISSION_RESPONSE_FIELD_ACTIONS]; + } catch (error) { + options.onError?.(error); + options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED, { + error, + context: { input } + }); + return {}; + } + } + + async function explain(input: PermissionClientCheckInput): Promise { + try { + return await postJson(options, PERMISSION_CLIENT_PATH_EXPLAIN, input); + } catch (error) { + options.onError?.(error); + return null; + } + } + + function hydrate(snapshot: PermissionSnapshot): void { + currentSnapshot = snapshot; + cache.clear(); + for (const [key, decision] of Object.entries(snapshot.decisions ?? {})) { + cache.set(key, { decision, expiresAt: now() + cacheTtlMs }); + } + emit(); + } + + function invalidate(scope?: string): void { + if (!scope) { + cache.clear(); + currentSnapshot = { ...currentSnapshot, decisions: {} }; + emit(); + return; + } + for (const key of [...cache.keys()]) if (key.includes(scope)) cache.delete(key); + const decisions = { ...(currentSnapshot.decisions ?? {}) }; + for (const key of Object.keys(decisions)) if (key.includes(scope)) delete decisions[key]; + currentSnapshot = { ...currentSnapshot, decisions }; + emit(); + } + + return { + check, + async can(input) { + return (await check(input)).effect === PERMISSION_EFFECT_ALLOW; + }, + batch, + what, + explain, + hydrate, + snapshot: () => currentSnapshot, + invalidate, + subscribe(listener) { + listeners.add(listener); + listener(currentSnapshot); + return () => listeners.delete(listener); + }, + decisionKey + }; +} diff --git a/src/arts/perm/consts.ts b/src/arts/perm/consts.ts new file mode 100644 index 0000000..ddd5788 --- /dev/null +++ b/src/arts/perm/consts.ts @@ -0,0 +1,49 @@ +export const LOGGER_CATEGORY = 'perm'; +export const PERMISSION_CONTEXT_KEY = 'active.permissions'; + +export const PERMISSION_CLIENT_PATH_CHECK = '/check'; +export const PERMISSION_CLIENT_PATH_BATCH = '/batch'; +export const PERMISSION_CLIENT_PATH_WHAT = '/what'; +export const PERMISSION_CLIENT_PATH_EXPLAIN = '/explain'; + +export const PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS = 30_000; +export const PERMISSION_SNAPSHOT_DECISIONS_KEY = 'decisions'; +export const PERMISSION_SNAPSHOT_GLOBAL_POLICY = 'snapshot.global'; + +export const PERMISSION_CLIENT_KEY_SEPARATOR = ':'; +export const PERMISSION_CLIENT_KEY_GLOBAL = 'global'; +export const PERMISSION_CLIENT_KEY_NONE = 'none'; +export const PERMISSION_CLIENT_CONTEXT_EMPTY = ''; + +export const PERMISSION_HTTP_STATUS_OK = 200; +export const PERMISSION_HTTP_STATUS_BAD_REQUEST = 400; +export const PERMISSION_HTTP_STATUS_FORBIDDEN = 403; + +export const PERMISSION_HTTP_CREDENTIALS_INCLUDE = 'include'; + +export const PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED = 'remote authorization check failed'; +export const PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED = 'remote authorization batch failed'; +export const PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED = 'remote authorization what failed'; +export const PERMISSION_LOG_MSG_DECISION = 'authorization decision'; +export const PERMISSION_LOG_MSG_DENIED = 'authorization denied'; +export const PERMISSION_LOG_MSG_INDETERMINATE = 'authorization indeterminate'; + +export const PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX = 'Authorization request failed: '; +export const PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT = 'Request body must be an object'; +export const PERMISSION_ERROR_MSG_NO_CONTEXT = 'Permission context is not available'; +export const PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED = + 'createActivePermissions requires an endpoint'; + +export const PERMISSION_REQUEST_FIELD_ACTION = 'action'; +export const PERMISSION_REQUEST_FIELD_RESOURCE = 'resource'; +export const PERMISSION_REQUEST_FIELD_CONTEXT = 'context'; +export const PERMISSION_REQUEST_FIELD_CHECKS = 'checks'; +export const PERMISSION_REQUEST_FIELD_ACTIONS = 'actions'; +export const PERMISSION_RESPONSE_FIELD_DECISIONS = 'decisions'; +export const PERMISSION_RESPONSE_FIELD_ACTIONS = 'actions'; + +export const PERMISSION_ACTIVE_EVENT_HYDRATE = 'hydrate'; +export const PERMISSION_ACTIVE_EVENT_CHECK = 'check'; +export const PERMISSION_ACTIVE_EVENT_BATCH = 'batch'; +export const PERMISSION_ACTIVE_EVENT_WHAT = 'what'; +export const PERMISSION_ACTIVE_EVENT_INVALIDATE = 'invalidate'; diff --git a/src/arts/perm/context.ts b/src/arts/perm/context.ts new file mode 100644 index 0000000..86f286b --- /dev/null +++ b/src/arts/perm/context.ts @@ -0,0 +1,16 @@ +import { getContext, setContext } from 'svelte'; +import { PERMISSION_CONTEXT_KEY, PERMISSION_ERROR_MSG_NO_CONTEXT } from './consts.ts'; +import type { ActivePermissions } from './types.ts'; + +const PERMISSION_CONTEXT = Symbol(PERMISSION_CONTEXT_KEY); + +export function setPermissionsContext(client: ActivePermissions): ActivePermissions { + setContext(PERMISSION_CONTEXT, client); + return client; +} + +export function getPermissionsContext(): ActivePermissions { + const client = getContext(PERMISSION_CONTEXT); + if (!client) throw new Error(PERMISSION_ERROR_MSG_NO_CONTEXT); + return client; +} diff --git a/src/arts/perm/engine-permissions.ts b/src/arts/perm/engine-permissions.ts new file mode 100644 index 0000000..93b85da --- /dev/null +++ b/src/arts/perm/engine-permissions.ts @@ -0,0 +1,72 @@ +import { + PERMISSION_EFFECT_ALLOW, + PERMISSION_EFFECT_DENY, + PERMISSION_EFFECT_INDETERMINATE, + PermissionDeniedError, + createPermissionRuntime +} from '$libs/perm'; +import { + LOGGER_CATEGORY, + PERMISSION_LOG_MSG_DECISION, + PERMISSION_LOG_MSG_DENIED, + PERMISSION_LOG_MSG_INDETERMINATE +} from './consts.ts'; +import type { EnginePermissions, EnginePermissionsOptions } from './types.ts'; + +export function createEnginePermissions(options: EnginePermissionsOptions): EnginePermissions { + const runtime = createPermissionRuntime(options); + let disposed = false; + + async function check(input: Parameters[0]) { + const decision = await runtime.check(input); + if (decision.effect === PERMISSION_EFFECT_DENY) { + options.logger?.warn?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_DENIED, { + context: { + action: input.action, + resource: input.resource, + decision + } + }); + } else if (decision.effect === PERMISSION_EFFECT_INDETERMINATE) { + options.logger?.warn?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_INDETERMINATE, { + context: { + action: input.action, + resource: input.resource, + decision + } + }); + } else { + options.logger?.debug?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_DECISION, { + context: { + action: input.action, + resource: input.resource, + decision + } + }); + } + return decision; + } + + return { + schema: options.schema, + policies: options.policies, + compilers: options.compilers ?? [], + check, + async can(input) { + const decision = await check(input); + return decision.effect === PERMISSION_EFFECT_ALLOW; + }, + async assert(input) { + const decision = await check(input); + if (decision.effect !== PERMISSION_EFFECT_ALLOW) throw new PermissionDeniedError(decision); + }, + explain: (input) => runtime.explain(input), + what: (input) => runtime.what(input), + who: (input) => runtime.who(input), + filter: (action) => runtime.filter(action), + dispose() { + if (disposed) return; + disposed = true; + } + }; +} diff --git a/src/arts/perm/http.ts b/src/arts/perm/http.ts new file mode 100644 index 0000000..c70a28e --- /dev/null +++ b/src/arts/perm/http.ts @@ -0,0 +1,95 @@ +import { + PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT, + PERMISSION_HTTP_STATUS_OK, + PERMISSION_REQUEST_FIELD_ACTION, + PERMISSION_REQUEST_FIELD_ACTIONS, + PERMISSION_REQUEST_FIELD_CHECKS, + PERMISSION_REQUEST_FIELD_CONTEXT, + PERMISSION_REQUEST_FIELD_RESOURCE, + PERMISSION_RESPONSE_FIELD_ACTIONS, + PERMISSION_RESPONSE_FIELD_DECISIONS +} from './consts.ts'; +import { permissionDecisionKey } from './keys.ts'; +import type { + EnginePermissions, + PermissionActorResolver, + PermissionClientCheckInput, + PermissionHttpRequestLike, + PermissionHttpResponse +} from './types.ts'; +import type { PermissionCheckInput } from '$libs/perm'; + +function assertBodyObject(body: unknown): asserts body is Record { + if (!body || typeof body !== 'object') throw new Error(PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT); +} + +function readCheck(body: Record): PermissionClientCheckInput { + return { + action: String(body[PERMISSION_REQUEST_FIELD_ACTION]), + resource: body[PERMISSION_REQUEST_FIELD_RESOURCE] as PermissionClientCheckInput['resource'], + context: body[PERMISSION_REQUEST_FIELD_CONTEXT] as PermissionClientCheckInput['context'] + }; +} + +export function createPermissionHttpHandlers( + runtime: EnginePermissions, + resolveActor: PermissionActorResolver +) { + return { + async check(request: PermissionHttpRequestLike): Promise { + const body = await request.json(); + assertBodyObject(body); + const actor = await resolveActor(request, body); + const check = readCheck(body); + const decision = await runtime.check({ actor, ...check }); + return { status: PERMISSION_HTTP_STATUS_OK, body: decision }; + }, + + async batch(request: PermissionHttpRequestLike): Promise { + const body = await request.json(); + assertBodyObject(body); + const actor = await resolveActor(request, body); + const checks = Array.isArray(body[PERMISSION_REQUEST_FIELD_CHECKS]) + ? (body[PERMISSION_REQUEST_FIELD_CHECKS] as Array>) + : []; + const decisions: Record = {}; + + for (const item of checks) { + const check = readCheck(item); + decisions[permissionDecisionKey(check)] = await runtime.check({ actor, ...check }); + } + + return { + status: PERMISSION_HTTP_STATUS_OK, + body: { [PERMISSION_RESPONSE_FIELD_DECISIONS]: decisions } + }; + }, + + async what(request: PermissionHttpRequestLike): Promise { + const body = await request.json(); + assertBodyObject(body); + const actor = await resolveActor(request, body); + const result = await runtime.what({ + actor, + resource: body[PERMISSION_REQUEST_FIELD_RESOURCE] as PermissionCheckInput['resource'], + context: body[PERMISSION_REQUEST_FIELD_CONTEXT] as PermissionCheckInput['context'], + actions: Array.isArray(body[PERMISSION_REQUEST_FIELD_ACTIONS]) + ? (body[PERMISSION_REQUEST_FIELD_ACTIONS] as unknown[]).map(String) + : undefined + }); + return { + status: PERMISSION_HTTP_STATUS_OK, + body: { [PERMISSION_RESPONSE_FIELD_ACTIONS]: result.actions } + }; + }, + + async explain(request: PermissionHttpRequestLike): Promise { + const body = await request.json(); + assertBodyObject(body); + const actor = await resolveActor(request, body); + const check = readCheck(body); + const result = await runtime.explain({ actor, ...check }); + return { status: PERMISSION_HTTP_STATUS_OK, body: result }; + } + }; +} diff --git a/src/arts/perm/index.ts b/src/arts/perm/index.ts new file mode 100644 index 0000000..2d1fb72 --- /dev/null +++ b/src/arts/perm/index.ts @@ -0,0 +1,49 @@ +export { createEnginePermissions } from './engine-permissions.ts'; +export { createActivePermissions } from './active-permissions.svelte.ts'; +export { createPermissionClient } from './client.ts'; +export { createPermissionHttpHandlers } from './http.ts'; +export { getPermissionsContext, setPermissionsContext } from './context.ts'; +export { permissionDecisionKey, stablePermissionStringify } from './keys.ts'; + +export * from './consts.ts'; +export * from './types.ts'; + +export { + actionMatches, + actionResource, + actionsForResource, + actor, + allow, + and, + attr, + audit, + ctx, + definePermSchema, + definePolicies, + deny, + ExprBuilder, + mask, + not, + or, + PERMISSION_EFFECT_ALLOW, + PERMISSION_EFFECT_DENY, + PERMISSION_EFFECT_INDETERMINATE, + PERMISSION_EFFECT_NOT_APPLICABLE, + PERMISSION_QUERY_TARGET_SQL, + PolicyBuilder, + redact, + rel, + RelationBuilder, + requireMfa, + resource, + resourceKey, + val, + createSqlCompiler +} from '$libs/perm'; + +export type { + CreateSqlCompilerOptions, + SqlCompileResult, + SqlRelationCompiler, + SqlRelationCompilerInput +} from '$libs/perm'; diff --git a/src/arts/perm/keys.ts b/src/arts/perm/keys.ts new file mode 100644 index 0000000..ceaf6d6 --- /dev/null +++ b/src/arts/perm/keys.ts @@ -0,0 +1,30 @@ +import { + PERMISSION_CLIENT_CONTEXT_EMPTY, + PERMISSION_CLIENT_KEY_GLOBAL, + PERMISSION_CLIENT_KEY_NONE, + PERMISSION_CLIENT_KEY_SEPARATOR +} from './consts.ts'; +import type { PermissionClientCheckInput } from './types.ts'; + +export function stablePermissionStringify(input: unknown): string { + if (input === undefined) return PERMISSION_CLIENT_CONTEXT_EMPTY; + if (input === null || typeof input !== 'object') return JSON.stringify(input); + if (Array.isArray(input)) return `[${input.map(stablePermissionStringify).join(',')}]`; + const sorted = Object.entries(input as Record).sort(([a], [b]) => + a.localeCompare(b) + ); + return `{${sorted + .map(([key, value]) => `${JSON.stringify(key)}:${stablePermissionStringify(value)}`) + .join(',')}}`; +} + +export function permissionDecisionKey(input: PermissionClientCheckInput): string { + const resource = input.resource + ? `${input.resource.type}${PERMISSION_CLIENT_KEY_SEPARATOR}${input.resource.id ?? PERMISSION_CLIENT_KEY_NONE}` + : PERMISSION_CLIENT_KEY_GLOBAL; + return [ + resource, + input.action, + input.context ? stablePermissionStringify(input.context) : PERMISSION_CLIENT_CONTEXT_EMPTY + ].join(PERMISSION_CLIENT_KEY_SEPARATOR); +} diff --git a/src/arts/perm/test/client-http.test.ts b/src/arts/perm/test/client-http.test.ts new file mode 100644 index 0000000..619684a --- /dev/null +++ b/src/arts/perm/test/client-http.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PERMISSION_EFFECT_ALLOW } from '$libs/perm'; +import { + allow, + attr, + createEnginePermissions, + createPermissionClient, + createPermissionHttpHandlers, + definePermSchema, + definePolicies, + permissionDecisionKey +} from '$perm'; + +const schema = definePermSchema({ + actors: { + user: { attributes: { status: 'string' } } + }, + resources: { + post: { actions: ['read'] } + } +}); + +const policies = definePolicies(schema, [ + allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public')) +]); + +function jsonRequest(body: unknown) { + return { + method: 'POST', + async json() { + return body; + } + }; +} + +describe('Permission client + HTTP handlers', () => { + it('checks remotely, caches the decision and keeps the same decision key as batch', async () => { + const runtime = createEnginePermissions({ schema, policies }); + const actor = { type: 'user', id: 'u1', status: 'active' }; + const handlers = createPermissionHttpHandlers(runtime, () => actor); + const calls: string[] = []; + + const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const path = new URL(url).pathname; + calls.push(path); + const body = JSON.parse(String(init?.body)); + const handler = path.endsWith('/batch') ? handlers.batch : handlers.check; + const response = await handler(jsonRequest(body)); + return new Response(JSON.stringify(response.body), { status: response.status }); + }) as typeof fetch; + + const client = createPermissionClient({ + endpoint: 'https://perm.test/permissions', + fetcher + }); + + const input = { + action: 'post.read', + resource: { type: 'post', id: 'p1', visibility: 'public' } + }; + + const first = await client.check(input); + const second = await client.check(input); + + expect(first.effect).toBe(PERMISSION_EFFECT_ALLOW); + expect(second.effect).toBe(PERMISSION_EFFECT_ALLOW); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(calls).toEqual(['/permissions/check']); + + client.invalidate(); + + const batch = await client.batch({ checks: [input] }); + expect(batch[permissionDecisionKey(input)]?.effect).toBe(PERMISSION_EFFECT_ALLOW); + expect(calls).toEqual(['/permissions/check', '/permissions/batch']); + }); +}); diff --git a/src/arts/perm/test/engine-permissions.test.ts b/src/arts/perm/test/engine-permissions.test.ts new file mode 100644 index 0000000..d4e0944 --- /dev/null +++ b/src/arts/perm/test/engine-permissions.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { + PERMISSION_EFFECT_ALLOW, + PERMISSION_EFFECT_DENY, + PERMISSION_EFFECT_INDETERMINATE, + PERMISSION_FALLBACK_DENY +} from '$libs/perm'; +import { + actor, + allow, + attr, + createEnginePermissions, + definePermSchema, + definePolicies, + deny, + rel +} from '$perm'; + +const schema = definePermSchema({ + actors: { + user: { attributes: { status: 'string' } } + }, + resources: { + post: { actions: ['read', 'update'] } + }, + relations: { + 'post.blocked': { from: 'post', to: 'user' }, + 'post.owner': { from: 'post', to: 'user' } + } +}); + +const actorRef = { type: 'user', id: 'u1', status: 'active' }; +const publicPost = { type: 'post', id: 'p1', visibility: 'public', ownerId: 'u1' }; + +describe('EnginePermissions', () => { + it('fails closed when a matching deny policy is indeterminate, even if allow matches', async () => { + const policies = definePolicies(schema, [ + deny('post.*').id('post.deny.blocked').priority(100).when(rel('post.blocked').is(actor())), + allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public')) + ]); + + const Perm = createEnginePermissions({ + schema, + policies, + providers: { + relations: { + hasRelation({ relation }) { + if (relation === 'post.blocked') return 'unknown'; + return false; + } + } + } + }); + + const decision = await Perm.check({ + actor: actorRef, + action: 'post.read', + resource: publicPost + }); + + expect(decision.effect).toBe(PERMISSION_EFFECT_INDETERMINATE); + if (decision.effect === PERMISSION_EFFECT_INDETERMINATE) { + expect(decision.fallback).toBe(PERMISSION_FALLBACK_DENY); + expect(decision.policy).toBe('post.deny.blocked'); + } + await expect( + Perm.can({ actor: actorRef, action: 'post.read', resource: publicPost }) + ).resolves.toBe(false); + }); + + it('keeps explicit deny above allow when both conditions are true', async () => { + const policies = definePolicies(schema, [ + deny('post.read').id('post.deny.suspended').when(attr('actor.status').eq('suspended')), + allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public')) + ]); + + const Perm = createEnginePermissions({ schema, policies }); + const decision = await Perm.check({ + actor: { ...actorRef, status: 'suspended' }, + action: 'post.read', + resource: publicPost + }); + + expect(decision.effect).toBe(PERMISSION_EFFECT_DENY); + if (decision.effect === PERMISSION_EFFECT_DENY) { + expect(decision.policy).toBe('post.deny.suspended'); + } + }); + + it('answers what() with rich decisions per action', async () => { + const policies = definePolicies(schema, [ + allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public')), + allow('post.update').id('post.update.owner').when(rel('post.owner').is(actor())) + ]); + + const Perm = createEnginePermissions({ + schema, + policies, + providers: { + relations: { + hasRelation({ relation, resource, subject }) { + if (relation === 'post.owner') return resource.ownerId === subject.id; + return false; + } + } + } + }); + + const result = await Perm.what({ actor: actorRef, resource: publicPost }); + + expect(result.actions['post.read']?.effect).toBe(PERMISSION_EFFECT_ALLOW); + expect(result.actions['post.update']?.effect).toBe(PERMISSION_EFFECT_ALLOW); + }); +}); diff --git a/src/arts/perm/types.ts b/src/arts/perm/types.ts new file mode 100644 index 0000000..fad6ad2 --- /dev/null +++ b/src/arts/perm/types.ts @@ -0,0 +1,122 @@ +import type { EngineHttp } from '$http'; +import type { EngineLogger } from '$logr'; +import type { + ExplainResult, + PermSchema, + PermissionCheckInput, + PermissionDecision, + PermissionRuntime, + PermissionRuntimeOptions, + PolicyIR, + QueryCompiler, + ResourceRef, + SubjectRef +} from '$libs/perm'; + +export type { + AdviceIR, + AttributeProvider, + DependencyKey, + ExplainResult, + ExprIR, + ObligationIR, + PermSchema, + PermissionCheckInput, + PermissionDecision, + PermissionEffect, + PermissionFallback, + PermissionFilterBuilder, + PermissionProviders, + PermissionRuntime, + PermissionRuntimeOptions, + PolicyIR, + QueryCompiler, + QueryPlan, + RelationProvider, + ResourceRef, + ReverseQueryResult, + SubjectRef +} from '$libs/perm'; + +export interface EnginePermissionsOptions extends PermissionRuntimeOptions { + readonly logger?: EngineLogger; +} + +export interface EnginePermissions extends PermissionRuntime { + readonly schema: PermSchema; + readonly policies: readonly PolicyIR[]; + readonly compilers: readonly QueryCompiler[]; + dispose(): void; +} + +export interface PermissionSnapshot { + readonly actor?: SubjectRef; + readonly version?: string; + readonly decisions?: Record; + readonly global?: Record; + readonly expiresAt?: string; +} + +export interface PermissionClientOptions { + readonly endpoint: string; + readonly fetcher?: typeof fetch; + readonly http?: EngineHttp; + readonly initialSnapshot?: PermissionSnapshot; + readonly cacheTtlMs?: number; + readonly logger?: EngineLogger; + readonly onError?: (error: unknown) => void; +} + +export interface PermissionClientCheckInput { + readonly action: string; + readonly resource?: ResourceRef; + readonly context?: Record; +} + +export interface PermissionClientBatchInput { + readonly checks: readonly PermissionClientCheckInput[]; +} + +export interface PermissionClient { + check(input: PermissionClientCheckInput): Promise; + can(input: PermissionClientCheckInput): Promise; + batch(input: PermissionClientBatchInput): Promise>; + what(input: { + readonly resource?: ResourceRef; + readonly actions?: readonly string[]; + readonly context?: Record; + }): Promise>; + explain(input: PermissionClientCheckInput): Promise; + hydrate(snapshot: PermissionSnapshot): void; + snapshot(): PermissionSnapshot; + invalidate(scope?: string): void; + subscribe(listener: (snapshot: PermissionSnapshot) => void): () => void; + decisionKey(input: PermissionClientCheckInput): string; +} + +export interface ActivePermissions extends PermissionClient { + readonly currentSnapshot: PermissionSnapshot; + readonly decisions: Record; + readonly size: number; + readonly loading: boolean; + readonly lastError: unknown | null; + dispose(): void; +} + +export type ActivePermissionsOptions = PermissionClientOptions; + +export interface PermissionHttpRequestLike { + readonly method: string; + readonly url?: string; + json(): Promise; +} + +export interface PermissionHttpResponse { + readonly status: number; + readonly body: unknown; +} + +export type PermissionActorResolver = ( + request: PermissionHttpRequestLike, + body: unknown +) => Promise | PermissionCheckInput['actor']; diff --git a/src/arts/sess/README.md b/src/arts/sess/README.md index e12cb97..e3fcfad 100644 --- a/src/arts/sess/README.md +++ b/src/arts/sess/README.md @@ -15,47 +15,47 @@ import { createActiveSession, withAutoRefresh } from '$sess'; import { object, string, email } from '$sium/core'; interface JwtCredential { - accessToken: string; - refreshToken: string; + accessToken: string; + refreshToken: string; } const Sess = createActiveSession<{ id: string; email: string }, JwtCredential>({ - schemas: { - user: object({ id: string(), email: email() }) - }, - onRefresh: async (current) => { - const r = await fetch('/api/refresh', { - method: 'POST', - body: JSON.stringify({ refreshToken: current.credential.refreshToken }) - }); - if (!r.ok) return null; // fatal: auto-revokes - return await r.json(); // engine adopts the new Session - }, - onRevoke: async (current) => { - const r = await fetch('/api/logout', { - method: 'POST', - body: JSON.stringify({ refreshToken: current.credential.refreshToken }) - }); - return r.ok; // boolean drives globalRevoked - } + schemas: { + user: object({ id: string(), email: email() }) + }, + onRefresh: async (current) => { + const r = await fetch('/api/refresh', { + method: 'POST', + body: JSON.stringify({ refreshToken: current.credential.refreshToken }) + }); + if (!r.ok) return null; // fatal: auto-revokes + return await r.json(); // engine adopts the new Session + }, + onRevoke: async (current) => { + const r = await fetch('/api/logout', { + method: 'POST', + body: JSON.stringify({ refreshToken: current.credential.refreshToken }) + }); + return r.ok; // boolean drives globalRevoked + } }); withAutoRefresh(Sess); await Sess.adopt({ - user: { id: 'u-1', email: 'a@b.c' }, - credential: { accessToken: 'tok', refreshToken: 'r' }, - issuedAt: Date.now(), - expiresAt: Date.now() + 3_600_000 + user: { id: 'u-1', email: 'a@b.c' }, + credential: { accessToken: 'tok', refreshToken: 'r' }, + issuedAt: Date.now(), + expiresAt: Date.now() + 3_600_000 }); ``` ```svelte {#if Sess.current} -

Hi {Sess.current.user.email}

- +

Hi {Sess.current.user.email}

+ {:else} - Login + Login {/if} ``` @@ -83,7 +83,7 @@ combine the trade-offs this artifact targets: a logout from tab A. - **Tagged result unions, never `void`.** `revoke({scope:'global'})` without an `onRevoke` returns `{globalRevoked: false, reason: - 'missing_revoke_url'}` — the call site can show truthful UI copy. +'missing_revoke_url'}` — the call site can show truthful UI copy. Same shape for `adopt()` (validation/invariant failures) and `refresh()` (skipped/expired/failed). - **Standard Schema first-class.** Per-generic `schemas.{user,credential,data}` @@ -111,8 +111,8 @@ sess/ ├── engine-session.ts createEngineSession() — runes-free core ├── active-session.svelte.ts createActiveSession() — runes wrapper, reactive │ `current` + `generation` + `identity` -├── auto-refresh.ts withAutoRefresh(engine, opts) — ticker + -│ visibilitychange + jitter +├── auto-refresh.ts withAutoRefresh(engine, opts) — keyed timer +│ support + visibilitychange + jitter ├── http-integration.ts createBeforeErrorHook(engine, {applyAuth?}) — │ 401 → refresh → retry, with loop guard ├── jwt.ts extractJwtExp(token) — opt-in JWT exp helper @@ -130,7 +130,9 @@ sess/ ## Alias ```js -alias: { $sess: 'src/arts/sess' } +alias: { + $sess: 'src/arts/sess'; +} ``` --- @@ -166,11 +168,11 @@ alias: { $sess: 'src/arts/sess' } ```ts type Session = { - readonly user: TUser | null; // null when anonymous - readonly issuedAt: number; // strict epoch ms - readonly expiresAt: number; // strict epoch ms -} & SessionCredential // required iff TCredential set - & SessionData; // required iff TData set + readonly user: TUser | null; // null when anonymous + readonly issuedAt: number; // strict epoch ms + readonly expiresAt: number; // strict epoch ms +} & SessionCredential & // required iff TCredential set + SessionData; // required iff TData set ``` Times are **strict epoch milliseconds**. The engine does not normalise @@ -181,9 +183,10 @@ Times are **strict epoch milliseconds**. The engine does not normalise import { toEpochMs } from '$libs/days'; await Sess.adopt({ - user, credential, - issuedAt: toEpochMs(payload.iat * 1000), - expiresAt: toEpochMs(payload.exp * 1000) + user, + credential, + issuedAt: toEpochMs(payload.iat * 1000), + expiresAt: toEpochMs(payload.exp * 1000) }); ``` @@ -206,11 +209,16 @@ answers "what nature does the client driving it have?". A session can be ```ts interface SessionActor { - readonly kind: 'unknown' | 'human' | 'automated'; - readonly source?: 'user_agent' | 'captcha' | 'fingerprint' - | 'api_key' | 'server_assertion' | 'manual' - | (string & {}); // open — keep custom literals - readonly confidence?: number; // [0, 1] + readonly kind: 'unknown' | 'human' | 'automated'; + readonly source?: + | 'user_agent' + | 'captcha' + | 'fingerprint' + | 'api_key' + | 'server_assertion' + | 'manual' + | (string & {}); // open — keep custom literals + readonly confidence?: number; // [0, 1] } ``` @@ -221,20 +229,20 @@ enum, confidence in `[0, 1]`) and runs `schemas.actor` if configured. ```ts // UC-4 — anonymous cart with bot-detection signal: await Sess.adopt({ - user: null, - data: { cartId: 'cart_123' }, - actor: { kind: 'automated', source: 'user_agent', confidence: 0.87 }, - issuedAt: Date.now(), - expiresAt: Date.now() + 60_000 + user: null, + data: { cartId: 'cart_123' }, + actor: { kind: 'automated', source: 'user_agent', confidence: 0.87 }, + issuedAt: Date.now(), + expiresAt: Date.now() + 60_000 }); // Service account / AI agent with API key: await Sess.adopt({ - user: serviceAccount, - credential: { apiKey: 'sk_…' }, - actor: { kind: 'automated', source: 'api_key', confidence: 1 }, - issuedAt: Date.now(), - expiresAt: Date.now() + 86_400_000 + user: serviceAccount, + credential: { apiKey: 'sk_…' }, + actor: { kind: 'automated', source: 'api_key', confidence: 1 }, + issuedAt: Date.now(), + expiresAt: Date.now() + 86_400_000 }); ``` @@ -242,8 +250,8 @@ Actor changes propagate via a regular `adopt()`: ```ts await Sess.adopt({ - ...Sess.current!, - actor: { kind: 'automated', source: 'captcha', confidence: 0.91 } + ...Sess.current!, + actor: { kind: 'automated', source: 'captcha', confidence: 0.91 } }); // Emits ADOPTED — no separate ACTOR_CHANGED event needed. ``` @@ -264,9 +272,9 @@ start leaking product semantics into the runtime layer. ```ts type SessionIdentityState = 'none' | 'anonymous' | 'identified'; -Sess.identity // 'none' when current === null - // 'anonymous' when current.user === null - // 'identified' when current.user !== null +Sess.identity; // 'none' when current === null +// 'anonymous' when current.user === null +// 'identified' when current.user !== null ``` Anonymous sessions are how UC-4 (anonymous cart that becomes a logged-in @@ -310,14 +318,17 @@ type RefreshResult = ```ts type RevokeResult = - | { localRevoked: true; globalRevoked: true; scope: 'global' } - | { localRevoked: true; globalRevoked: false; scope: 'local'; - reason?: 'missing_revoke_url' | 'network_error' - | 'server_rejected' | 'no_session' }; + | { localRevoked: true; globalRevoked: true; scope: 'global' } + | { + localRevoked: true; + globalRevoked: false; + scope: 'local'; + reason?: 'missing_revoke_url' | 'network_error' | 'server_rejected' | 'no_session'; + }; const r = await Sess.revoke({ scope: 'global' }); if (r.globalRevoked) toast('Signed out everywhere.'); -else toast(`Signed out on this device. (${r.reason ?? ''})`); +else toast(`Signed out on this device. (${r.reason ?? ''})`); ``` `REVOKED` fires regardless — locally you ARE signed out. @@ -330,28 +341,28 @@ The single most important rule. Encode it consciously in your `onRefresh`: ```ts const onRefresh: RefreshFn = async (current) => { - let response: Response; - try { - response = await fetch('/api/refresh', { - method: 'POST', - body: JSON.stringify({ refreshToken: current.credential.refreshToken }) - }); - } catch (err) { - // Transient: network failed. Throw → engine preserves the - // session and emits REFRESH_FAILED. Auto-refresh / 401 hook - // will try again later. - throw err; - } - - if (response.status === 401 || response.status === 403) { - // Fatal: refresh credential is dead. Return null → engine - // auto-revokes locally and emits EXPIRED. - return null; - } - - if (!response.ok) throw new Error(`refresh failed: ${response.status}`); - - return await response.json(); // engine adopts as REFRESHED + let response: Response; + try { + response = await fetch('/api/refresh', { + method: 'POST', + body: JSON.stringify({ refreshToken: current.credential.refreshToken }) + }); + } catch (err) { + // Transient: network failed. Throw → engine preserves the + // session and emits REFRESH_FAILED. Auto-refresh / 401 hook + // will try again later. + throw err; + } + + if (response.status === 401 || response.status === 403) { + // Fatal: refresh credential is dead. Return null → engine + // auto-revokes locally and emits EXPIRED. + return null; + } + + if (!response.ok) throw new Error(`refresh failed: ${response.status}`); + + return await response.json(); // engine adopts as REFRESHED }; ``` @@ -362,8 +373,10 @@ Mismatch this contract and a flaky network logs your users out. ## `RevokeFn` contract — boolean answer ```ts -type RevokeFn = - (current: Session, ctx: RevokeContext) => Promise; +type RevokeFn = ( + current: Session, + ctx: RevokeContext +) => Promise; ``` - Resolves `true` → engine sets `scope: 'global'`. @@ -411,16 +424,16 @@ This prevents three race classes: the external listeners use: ```ts -Sess.current // Session | null -Sess.generation // number -Sess.identity // 'none' | 'anonymous' | 'identified' +Sess.current; // Session | null +Sess.generation; // number +Sess.identity; // 'none' | 'anonymous' | 'identified' ``` ```svelte {#if Sess.current} -

Hi {Sess.current.user?.email}

+

Hi {Sess.current.user?.email}

{:else if Sess.identity === 'anonymous'} -

Cart: {Sess.current?.data.cartId}

+

Cart: {Sess.current?.data.cartId}

{/if} @@ -470,10 +483,11 @@ Sess.dispose() ```ts const stop = withAutoRefresh(Sess, { - tickMs: 30_000, - marginMs: 90_000, - jitterMs: 5_000, - refreshOnVisible: true + tickMs: 30_000, + marginMs: 90_000, + jitterMs: 5_000, + refreshOnVisible: true, + timers: App.Timers // optional when using aapp; omit for native interval }); // ... later stop(); @@ -482,18 +496,15 @@ import { createBeforeErrorHook } from '$sess'; import { createEngineHttp } from '$http'; const http = createEngineHttp({ - hooks: { - beforeError: [ - createBeforeErrorHook(Sess, { - applyAuth: (request, session) => { - request.headers.set( - 'authorization', - `Bearer ${session.credential.accessToken}` - ); - } - }) - ] - } + hooks: { + beforeError: [ + createBeforeErrorHook(Sess, { + applyAuth: (request, session) => { + request.headers.set('authorization', `Bearer ${session.credential.accessToken}`); + } + }) + ] + } }); ``` @@ -514,12 +525,13 @@ The 401 hook: ```ts import { extractJwtExp } from '$sess/jwt'; -import { toEpochMs } from '$libs/days'; +import { toEpochMs } from '$libs/days'; await Sess.adopt({ - user, credential, - issuedAt: Date.now(), - expiresAt: extractJwtExp(accessToken) ?? Date.now() + 3_600_000 + user, + credential, + issuedAt: Date.now(), + expiresAt: extractJwtExp(accessToken) ?? Date.now() + 3_600_000 }); import { readSessionFromCookies } from '$sess/ssr'; @@ -532,14 +544,14 @@ event.locals.session = readSessionFromCookies(event.cookies, { key: 'app:sess' } ```ts type SessionEvent = - | 'INITIAL_SESSION' // sync on subscribe; current snapshot or null - | 'ADOPTED' // adopt() succeeded - | 'ADOPTED_SERVER' // adoptServer() was called (SSR hydration) - | 'REFRESHED' // refresh() returned a new session - | 'REFRESH_FAILED' // refresh() threw (transient); session preserved - | 'EXPIRED' // refresh() returned null (fatal); auto-revoked - | 'REVOKED' // revoke() (any scope, including degraded global) - | 'EXTERNAL_CHANGED'; // cross-tab broadcast updated local state + | 'INITIAL_SESSION' // sync on subscribe; current snapshot or null + | 'ADOPTED' // adopt() succeeded + | 'ADOPTED_SERVER' // adoptServer() was called (SSR hydration) + | 'REFRESHED' // refresh() returned a new session + | 'REFRESH_FAILED' // refresh() threw (transient); session preserved + | 'EXPIRED' // refresh() returned null (fatal); auto-revoked + | 'REVOKED' // revoke() (any scope, including degraded global) + | 'EXTERNAL_CHANGED'; // cross-tab broadcast updated local state ``` `onChange()` invokes the listener **synchronously once** with @@ -549,13 +561,13 @@ rich: ```ts interface SessionChange { - readonly event: SessionEvent; - readonly current: Session | null; - readonly previous: Session | null; - readonly generation: number; - readonly identity: { from: SessionIdentityState; to: SessionIdentityState }; - readonly error?: unknown; // populated for REFRESH_FAILED - readonly revoke?: RevokeResult; // populated for REVOKED + readonly event: SessionEvent; + readonly current: Session | null; + readonly previous: Session | null; + readonly generation: number; + readonly identity: { from: SessionIdentityState; to: SessionIdentityState }; + readonly error?: unknown; // populated for REFRESH_FAILED + readonly revoke?: RevokeResult; // populated for REVOKED } ``` @@ -573,10 +585,10 @@ import type { Handle } from '@sveltejs/kit'; import { readSessionFromCookies } from '$sess/ssr'; export const handle: Handle = async ({ event, resolve }) => { - event.locals.session = readSessionFromCookies(event.cookies, { - key: 'app:session' - }); - return resolve(event); + event.locals.session = readSessionFromCookies(event.cookies, { + key: 'app:session' + }); + return resolve(event); }; ``` @@ -588,7 +600,7 @@ invalid payloads (missing required fields, non-finite times, expiresAt ```ts export const load: LayoutServerLoad = ({ locals }) => ({ - session: locals.session + session: locals.session }); ``` @@ -596,14 +608,14 @@ export const load: LayoutServerLoad = ({ locals }) => ({ ```svelte ``` @@ -617,24 +629,26 @@ poisoning the engine state. ## Composition with App ```ts -const App = createActiveApp({ /* ... */ }); +const App = createActiveApp({ + /* ... */ +}); const Sess = App.createActiveSession({ - schemas: { user: UserSchema }, - storage: { adapter: localAdapter, key: 'app:session' }, - onRefresh: async (current) => { - const r = await App.Http.post('/api/refresh', { - body: { refreshToken: current.credential.refreshToken }, - schema: SessionResponseSchema - }); - return r.ok ? r.value : null; - }, - onRevoke: async (current) => { - const r = await App.Http.post('/api/logout', { - body: { refreshToken: current.credential.refreshToken } - }); - return r.ok; - } + schemas: { user: UserSchema }, + storage: { adapter: localAdapter, key: 'app:session' }, + onRefresh: async (current) => { + const r = await App.Http.post('/api/refresh', { + body: { refreshToken: current.credential.refreshToken }, + schema: SessionResponseSchema + }); + return r.ok ? r.value : null; + }, + onRevoke: async (current) => { + const r = await App.Http.post('/api/logout', { + body: { refreshToken: current.credential.refreshToken } + }); + return r.ok; + } }); ``` @@ -664,11 +678,11 @@ add cross-tab sync to any adapter. ## Errors -| Class | When | Behavior | -| --------------------------- | ------------------------------------------ | ------------- | -| `SessDisposedError` | mutator called after `dispose()` | **Thrown** + logged via `logger.error` | -| `SessInvalidSessionError` | `adoptServer()` invariant violation | **Thrown** + logged via `logger.error` | -| `SessAlreadyCreatedError` | `App.createActiveSession()` called twice | **Thrown** by App factory | +| Class | When | Behavior | +| ------------------------- | ---------------------------------------- | -------------------------------------- | +| `SessDisposedError` | mutator called after `dispose()` | **Thrown** + logged via `logger.error` | +| `SessInvalidSessionError` | `adoptServer()` invariant violation | **Thrown** + logged via `logger.error` | +| `SessAlreadyCreatedError` | `App.createActiveSession()` called twice | **Thrown** by App factory | Type guards: `isSessDisposedError`, `isSessInvalidSessionError`, `isSessAlreadyCreatedError`. @@ -692,10 +706,16 @@ import { createEngineSession } from '$sess'; import { createMemoryAdapter } from '$stor'; const sess = createEngineSession({ - schemas: { user: { '~standard': { /* ... */ } } }, - storage: { adapter: createMemoryAdapter(), key: 'sess' }, - onRefresh: async () => null, - onRevoke: async () => true + schemas: { + user: { + '~standard': { + /* ... */ + } + } + }, + storage: { adapter: createMemoryAdapter(), key: 'sess' }, + onRefresh: async () => null, + onRevoke: async () => true }); ``` diff --git a/src/arts/sess/auto-refresh.ts b/src/arts/sess/auto-refresh.ts index 7ed316a..d7606c6 100644 --- a/src/arts/sess/auto-refresh.ts +++ b/src/arts/sess/auto-refresh.ts @@ -1,7 +1,10 @@ import { + AUTO_REFRESH_TIMER_KEY, + BROWSER_EVENT_VISIBILITY_CHANGE, DEFAULT_AUTO_REFRESH_JITTER_MS, DEFAULT_AUTO_REFRESH_MARGIN_MS, - DEFAULT_AUTO_REFRESH_TICK_MS + DEFAULT_AUTO_REFRESH_TICK_MS, + DOCUMENT_VISIBILITY_VISIBLE } from './consts.ts'; import type { AutoRefreshCleanup, AutoRefreshOptions, EngineSession } from './types.ts'; @@ -37,37 +40,51 @@ export function withAutoRefresh | null = null; + let stopCurrentTicker: (() => void) | null = null; function maybeRefresh(): void { if (stopped) return; const current = engine.current; if (current === null) return; - const margin = jitterMs > 0 ? marginMs + Math.random() * jitterMs : marginMs; - const remaining = current.expiresAt - Date.now(); + const margin = jitterMs > 0 ? marginMs + random() * jitterMs : marginMs; + const remaining = current.expiresAt - now(); if (remaining <= margin) { void engine.refresh(); } } function startTicker(): void { - if (timerId !== null || stopped) return; - timerId = setInterval(maybeRefresh, tickMs); - (timerId as unknown as { unref?: () => void }).unref?.(); + if (stopCurrentTicker !== null || stopped) return; + if (options.timers !== undefined) { + const handle = options.timers.interval(timerKey, tickMs, maybeRefresh, { + replace: true, + awaitTask: false + }); + stopCurrentTicker = () => { + handle.cancel(); + }; + } else { + const timerId = setInterval(maybeRefresh, tickMs); + (timerId as unknown as { unref?: () => void }).unref?.(); + stopCurrentTicker = () => clearInterval(timerId); + } maybeRefresh(); } function stopTicker(): void { - if (timerId === null) return; - clearInterval(timerId); - timerId = null; + if (stopCurrentTicker === null) return; + stopCurrentTicker(); + stopCurrentTicker = null; } function onVisibility(): void { if (typeof document === 'undefined') return; - if (document.visibilityState === 'visible') { + if (document.visibilityState === DOCUMENT_VISIBILITY_VISIBLE) { startTicker(); if (refreshOnVisible) maybeRefresh(); } else { @@ -76,8 +93,8 @@ export function withAutoRefresh(parsed); if (promoted !== null) snapshot = promoted; } catch (err) { - logger?.warn(LOGGER_CATEGORY, LOG_MSG_STORED_SESSION_PARSE_FAILED, { + logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_STORED_SESSION_PARSE_FAILED, { error: toError(err) }); storage.adapter.removeItem(storage.key); @@ -140,7 +145,7 @@ export function createEngineSession { + channel.addEventListener(BROWSER_EVENT_MESSAGE, (msg: MessageEvent) => { const data = msg.data as { type?: string }; if (data?.type !== BROADCAST_TYPE) return; if (storage === undefined) return; @@ -217,7 +222,7 @@ export function createEngineSession).includes(value) - ); + return typeof value === 'string' && (ACTOR_KINDS as ReadonlyArray).includes(value); } function commitAdoption( @@ -334,7 +336,7 @@ export function createEngineSession({ @@ -450,7 +452,7 @@ export function createEngineSession = {} ): BeforeErrorHook { return async ({ response, request, url, init }) => { - if (response?.status !== 401) return; + if (response?.status !== HTTP_STATUS_UNAUTHORIZED) return; if (request.headers.get(RETRY_MARKER_HEADER) === RETRY_MARKER_VALUE) return; const result = await engine.refresh(); - if (result.status !== 'refreshed') return; + if (result.status !== REFRESH_STATUS_REFRESHED) return; options.applyAuth?.(request, result.session); request.headers.set(RETRY_MARKER_HEADER, RETRY_MARKER_VALUE); diff --git a/src/arts/sess/jwt.ts b/src/arts/sess/jwt.ts index 2d88450..493db1d 100644 --- a/src/arts/sess/jwt.ts +++ b/src/arts/sess/jwt.ts @@ -1,34 +1 @@ -/** - * Decode a JWT's `exp` claim and return it as **epoch milliseconds**, or - * `null` when the token is malformed / missing the claim. **No signature - * verification, no claim validation** — trust this only for scheduling - * (compute when to refresh), never for authorization decisions. - * - * Opt-in helper, separate file. The artifact does not bundle a JWT - * library; consumers that store opaque tokens never reach this code. - * - * @example - * import { extractJwtExp } from '$sess/jwt'; - * - * await Sess.adopt({ - * user, - * credential: { accessToken, refreshToken }, - * issuedAt: Date.now(), - * expiresAt: extractJwtExp(accessToken) ?? Date.now() + 3_600_000 - * }); - */ -export function extractJwtExp(token: string): number | null { - const parts = token.split('.'); - if (parts.length !== 3) return null; - - try { - const payload = parts[1]; - const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4); - const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/')); - const claims = JSON.parse(json) as { exp?: unknown }; - if (typeof claims.exp === 'number') return claims.exp * 1000; - return null; - } catch { - return null; - } -} +export { extractJwtExp } from '$libs/auth/jwt'; diff --git a/src/arts/sess/types.ts b/src/arts/sess/types.ts index 4a49983..6e89591 100644 --- a/src/arts/sess/types.ts +++ b/src/arts/sess/types.ts @@ -28,12 +28,17 @@ */ import type { StandardSchemaV1 } from '$libs/standard-schema'; -import type { EngineLogger } from '$logr'; import type { SyncStorageAdapter } from '$stor'; import type { ACTOR_KIND_AUTOMATED, ACTOR_KIND_HUMAN, ACTOR_KIND_UNKNOWN, + ACTOR_SOURCE_API_KEY, + ACTOR_SOURCE_CAPTCHA, + ACTOR_SOURCE_FINGERPRINT, + ACTOR_SOURCE_MANUAL, + ACTOR_SOURCE_SERVER_ASSERTION, + ACTOR_SOURCE_USER_AGENT, ADOPT_REASON_INVARIANT_FAILED, ADOPT_REASON_VALIDATION_FAILED, EVENT_ADOPTED, @@ -75,7 +80,6 @@ import type { // SESSION // ============================================================================ - /** * Conditional credential slot. Wrapped in `[T]` to suppress union * distribution: `string | undefined` must NOT be treated as @@ -118,12 +122,12 @@ export type SessionActorKind = * provenance. */ export type SessionActorSource = - | 'user_agent' - | 'captcha' - | 'fingerprint' - | 'api_key' - | 'server_assertion' - | 'manual' + | typeof ACTOR_SOURCE_USER_AGENT + | typeof ACTOR_SOURCE_CAPTCHA + | typeof ACTOR_SOURCE_FINGERPRINT + | typeof ACTOR_SOURCE_API_KEY + | typeof ACTOR_SOURCE_SERVER_ASSERTION + | typeof ACTOR_SOURCE_MANUAL | (string & {}); /** @@ -246,13 +250,22 @@ export type SessionListener = // REFRESH // ============================================================================ +/** + * Minimal logger shape consumed by `sess`. Deliberately structural so + * `EngineLogger` from `$logr`, a test spy, or any compatible app logger + * can be injected without making `sess` depend on the logger artifact. + */ +export interface SessionLogger { + warn?(category: string, message: string, input?: unknown): void; + error?(category: string, message: string, input?: unknown): void; +} + /** * Context passed to the consumer's refresh function. Exposes engine * primitives without forcing the callback to capture App by closure. */ export interface RefreshContext { - readonly logger?: EngineLogger; - readonly signal?: AbortSignal; + readonly logger?: SessionLogger; } /** @@ -283,11 +296,10 @@ export type RefreshFn = ( /** * Context passed to `onRevoke` so the consumer can log through the engine - * logger and short-circuit on cancellation. Mirrors `RefreshContext`. + * logger. Mirrors `RefreshContext`. */ export interface RevokeContext { - readonly logger?: EngineLogger; - readonly signal?: AbortSignal; + readonly logger?: SessionLogger; } /** @@ -426,10 +438,36 @@ export interface AutoRefreshOptions { jitterMs?: number; /** Refresh on `visibilitychange` to `visible`. Default: `true`. */ refreshOnVisible?: boolean; + /** + * Optional scheduler compatible with `arts/timr`. When provided, + * auto-refresh uses a keyed interval instead of native `setInterval`, + * so app-level disposal/debug panels see the timer. + */ + timers?: AutoRefreshScheduler; + /** Timer key used when `timers` is provided. Default: `sess:auto-refresh`. */ + timerKey?: string; + /** Injectable clock for deterministic tests. Default: `Date.now`. */ + now?: () => number; + /** Injectable random source for deterministic jitter. Default: `Math.random`. */ + random?: () => number; } export type AutoRefreshCleanup = () => void; +/** + * Minimal `arts/timr`-compatible scheduler. Kept structural to avoid a hard + * import cycle and to make tests/fakes cheap. + */ +export interface AutoRefreshScheduler { + interval( + key: string, + everyMs: number, + task: () => void | Promise, + options?: { readonly replace?: boolean; readonly awaitTask?: boolean } + ): { cancel(): boolean }; + cancel(key: string): boolean; +} + // ============================================================================ // ENGINE OPTIONS // ============================================================================ @@ -446,7 +484,7 @@ export interface EngineSessionOptions; - logger?: EngineLogger; + logger?: SessionLogger; broadcastChannel?: string; } @@ -465,9 +503,7 @@ export interface EngineSession ): Promise>; - adoptServer( - session: Session - ): Session; + adoptServer(session: Session): Session; refresh(): Promise>; diff --git a/src/arts/stor/active-storage.svelte.ts b/src/arts/stor/active-storage.svelte.ts index f7dc1b9..889180d 100644 --- a/src/arts/stor/active-storage.svelte.ts +++ b/src/arts/stor/active-storage.svelte.ts @@ -1,4 +1,5 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity'; +import { ENTRY_CHANGE_SOURCE_EXTERNAL } from './consts'; import { _engineBuses, _entryBusKeys, createEngineStorage } from './engine-storage'; import type { ActiveStorage, @@ -48,7 +49,7 @@ export function createActiveStorage(options: EngineStorageOptions = {}): ActiveS // and external changes (cross-tab). Re-read through the engine so // envelope/migration/serializer logic stays in one place. const detachBus = bus.subscribe(busKey, (_raw, source) => { - if (source === 'external' && entryOptions.syncTabs === false) return; + if (source === ENTRY_CHANGE_SOURCE_EXTERNAL && entryOptions.syncTabs === false) return; const prev = current; const next = base.get(); if (Object.is(prev, next)) return; diff --git a/src/arts/stor/adapters/broadcast.ts b/src/arts/stor/adapters/broadcast.ts index 77d66c4..ce4b0be 100644 --- a/src/arts/stor/adapters/broadcast.ts +++ b/src/arts/stor/adapters/broadcast.ts @@ -1,4 +1,9 @@ import type { SyncStorageAdapter } from '../types'; +import { + BROWSER_EVENT_MESSAGE, + DEFAULT_BROADCAST_CHANNEL, + STORAGE_ADAPTER_BROADCAST_SUFFIX +} from '../consts'; /** * Options for `withBroadcast()`. @@ -74,12 +79,12 @@ export function withBroadcast( inner: SyncStorageAdapter, options: BroadcastOptions = {} ): BroadcastAdapter { - const channelName = options.channel ?? 'stor'; + const channelName = options.channel ?? DEFAULT_BROADCAST_CHANNEL; const bc: BroadcastChannel | null = isSupported ? new BroadcastChannel(channelName) : null; let closed = false; return { - name: `${inner.name}+broadcast`, + name: `${inner.name}${STORAGE_ADAPTER_BROADCAST_SUFFIX}`, getItem: (key) => inner.getItem(key), setItem: (key, value) => { inner.setItem(key, value); @@ -94,8 +99,8 @@ export function withBroadcast( const listener = (event: MessageEvent): void => { fn(event.data.key, event.data.value); }; - bc.addEventListener('message', listener); - return () => bc.removeEventListener('message', listener); + bc.addEventListener(BROWSER_EVENT_MESSAGE, listener); + return () => bc.removeEventListener(BROWSER_EVENT_MESSAGE, listener); } : undefined, close: () => { diff --git a/src/arts/stor/adapters/cookie.ts b/src/arts/stor/adapters/cookie.ts index fb75f65..3432504 100644 --- a/src/arts/stor/adapters/cookie.ts +++ b/src/arts/stor/adapters/cookie.ts @@ -1,5 +1,6 @@ import type { SyncStorageAdapter } from '../types'; import { STORAGE_ERRORS } from '../errors'; +import { STORAGE_ADAPTER_COOKIE } from '../consts'; /** * Cookie attributes applied on every `setItem`. Match the standard @@ -88,7 +89,7 @@ function clientCookieAdapter(options: CookieAdapterOptions = {}): SyncStorageAda const decode = options.decode ?? decodeURIComponent; return { - name: 'cookie', + name: STORAGE_ADAPTER_COOKIE, getItem(key) { if (!isBrowser) return null; const jar = parseDocumentCookies(document.cookie); @@ -123,7 +124,7 @@ function serverCookieAdapter( // Same name as the client variant — from the consumer's perspective // both are "cookie storage", and `:` would clash with the namespace // separator in logs. Distinguish via the call site, not via name. - name: 'cookie', + name: STORAGE_ADAPTER_COOKIE, getItem(key) { const v = cookies.get(key); return v === undefined ? null : v; diff --git a/src/arts/stor/adapters/local.ts b/src/arts/stor/adapters/local.ts index baf26f1..b89ff61 100644 --- a/src/arts/stor/adapters/local.ts +++ b/src/arts/stor/adapters/local.ts @@ -1,4 +1,5 @@ import type { SyncStorageAdapter } from '../types'; +import { BROWSER_EVENT_STORAGE, STORAGE_ADAPTER_LOCAL } from '../consts'; function getLocalStorage(): Storage | null { if (typeof window === 'undefined') return null; @@ -18,7 +19,7 @@ function getLocalStorage(): Storage | null { * so consumers fall back to defaults. */ export const localAdapter: SyncStorageAdapter = { - name: 'local', + name: STORAGE_ADAPTER_LOCAL, getItem(key) { return getLocalStorage()?.getItem(key) ?? null; }, @@ -45,7 +46,7 @@ export const localAdapter: SyncStorageAdapter = { if (event.key === null) return; fn(event.key, event.newValue); }; - window.addEventListener('storage', listener); - return () => window.removeEventListener('storage', listener); + window.addEventListener(BROWSER_EVENT_STORAGE, listener); + return () => window.removeEventListener(BROWSER_EVENT_STORAGE, listener); } }; diff --git a/src/arts/stor/adapters/memory.ts b/src/arts/stor/adapters/memory.ts index 577aab9..dce2e47 100644 --- a/src/arts/stor/adapters/memory.ts +++ b/src/arts/stor/adapters/memory.ts @@ -1,4 +1,5 @@ import type { SyncStorageAdapter } from '../types'; +import { STORAGE_ADAPTER_MEMORY } from '../consts'; /** * Build an in-memory `SyncStorageAdapter` backed by a private `Map`. @@ -19,7 +20,7 @@ import type { SyncStorageAdapter } from '../types'; export function createMemoryAdapter(seed?: Record): SyncStorageAdapter { const store = new Map(seed ? Object.entries(seed) : undefined); return { - name: 'memory', + name: STORAGE_ADAPTER_MEMORY, getItem: (key) => (store.has(key) ? store.get(key)! : null), setItem: (key, value) => { store.set(key, value); diff --git a/src/arts/stor/adapters/session.ts b/src/arts/stor/adapters/session.ts index 10fff33..536a863 100644 --- a/src/arts/stor/adapters/session.ts +++ b/src/arts/stor/adapters/session.ts @@ -1,4 +1,5 @@ import type { SyncStorageAdapter } from '../types'; +import { STORAGE_ADAPTER_SESSION } from '../consts'; function getSessionStorage(): Storage | null { if (typeof window === 'undefined') return null; @@ -14,7 +15,7 @@ function getSessionStorage(): Storage | null { * SSR-safe: every method no-ops outside the browser; reads return `null`. */ export const sessionAdapter: SyncStorageAdapter = { - name: 'session', + name: STORAGE_ADAPTER_SESSION, getItem(key) { return getSessionStorage()?.getItem(key) ?? null; }, diff --git a/src/arts/stor/consts.ts b/src/arts/stor/consts.ts index 89a9696..e50aff6 100644 --- a/src/arts/stor/consts.ts +++ b/src/arts/stor/consts.ts @@ -5,6 +5,42 @@ */ export const LOGGER_CATEGORY = 'storage'; +export const STORAGE_OP_READ = 'read'; +export const STORAGE_OP_WRITE = 'write'; +export const STORAGE_OP_REMOVE = 'remove'; +export const STORAGE_OP_SERIALIZE = 'serialize'; +export const STORAGE_OP_DESERIALIZE = 'deserialize'; +export const STORAGE_OP_MIGRATE = 'migrate'; +export const STORAGE_OP_VALIDATE = 'validate'; + +export const STORAGE_OPS = [ + STORAGE_OP_READ, + STORAGE_OP_WRITE, + STORAGE_OP_REMOVE, + STORAGE_OP_SERIALIZE, + STORAGE_OP_DESERIALIZE, + STORAGE_OP_MIGRATE, + STORAGE_OP_VALIDATE +] as const; + +export const ENTRY_CHANGE_SOURCE_LOCAL = 'local'; +export const ENTRY_CHANGE_SOURCE_EXTERNAL = 'external'; + +export const ENTRY_CHANGE_SOURCES = [ + ENTRY_CHANGE_SOURCE_LOCAL, + ENTRY_CHANGE_SOURCE_EXTERNAL +] as const; + +export const STORAGE_ADAPTER_LOCAL = 'local'; +export const STORAGE_ADAPTER_SESSION = 'session'; +export const STORAGE_ADAPTER_MEMORY = 'memory'; +export const STORAGE_ADAPTER_COOKIE = 'cookie'; +export const STORAGE_ADAPTER_BROADCAST_SUFFIX = '+broadcast'; + +export const DEFAULT_BROADCAST_CHANNEL = 'stor'; +export const BROWSER_EVENT_STORAGE = 'storage'; +export const BROWSER_EVENT_MESSAGE = 'message'; + /** * Envelope shape — the JSON-serialized object stored when `raw: false`. * @@ -28,3 +64,8 @@ export const DEFAULT_VERSION = 1; /** Namespace separator used to compose `` into `fullKey`. */ export const NAMESPACE_SEPARATOR = ':'; + +export const ENVELOPE_ERROR_VERSION_NOT_NUMBER = 'envelope.v not a number'; + +export const defaultStorageErrorMessage = (op: string, fullKey: string): string => + `[${LOGGER_CATEGORY}] ${op} on "${fullKey}":`; diff --git a/src/arts/stor/engine-storage.ts b/src/arts/stor/engine-storage.ts index c1dd406..c0a01a2 100644 --- a/src/arts/stor/engine-storage.ts +++ b/src/arts/stor/engine-storage.ts @@ -1,5 +1,18 @@ import { createMemoryAdapter } from './adapters/memory'; -import { LOGGER_CATEGORY, NAMESPACE_SEPARATOR, DEFAULT_VERSION } from './consts'; +import { + DEFAULT_VERSION, + ENTRY_CHANGE_SOURCE_EXTERNAL, + ENTRY_CHANGE_SOURCE_LOCAL, + NAMESPACE_SEPARATOR, + STORAGE_OP_DESERIALIZE, + STORAGE_OP_MIGRATE, + STORAGE_OP_READ, + STORAGE_OP_REMOVE, + STORAGE_OP_SERIALIZE, + STORAGE_OP_VALIDATE, + STORAGE_OP_WRITE, + defaultStorageErrorMessage +} from './consts'; import { decodeEnvelope, encodeEnvelope } from './envelope'; import { STORAGE_ERRORS } from './errors'; import { autoSelectSerializer } from './serializers'; @@ -18,7 +31,7 @@ import type { import { isPromiseLike, type StandardSchemaV1 } from '$libs/standard-schema'; const defaultOnError: StorageErrorHandler = (ctx) => { - console.error(`[${LOGGER_CATEGORY}] ${ctx.op} on "${ctx.fullKey}":`, ctx.error); + console.error(defaultStorageErrorMessage(ctx.op, ctx.fullKey), ctx.error); }; function isStandardSchema(v: StorageValidator): v is StandardSchemaV1 { @@ -88,7 +101,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS const id = getAdapterId(target); if (adapterDisposers.has(id)) return; const detach = target.onChange((changedKey, newValue) => { - bus.publish(busKeyFor(target, changedKey), newValue, 'external'); + bus.publish(busKeyFor(target, changedKey), newValue, ENTRY_CHANGE_SOURCE_EXTERNAL); }); adapterDisposers.set(id, detach); } @@ -143,7 +156,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS { key, fullKey, - op: 'read', + op: STORAGE_OP_READ, error: new Error(STORAGE_ERRORS.ENTRY_DEFAULTS_MISMATCH(key)) }, entryAdapter.name @@ -177,7 +190,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS throw new TypeError(STORAGE_ERRORS.ASYNC_VALIDATE_UNSUPPORTED); } if ('issues' in result && result.issues !== undefined) { - throw new Error(`Standard Schema validation failed: ${JSON.stringify(result.issues)}`); + throw new Error(STORAGE_ERRORS.STANDARD_SCHEMA_VALIDATION_FAILED(result.issues)); } return (result as { value: T }).value; } @@ -206,10 +219,10 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS try { entryAdapter.setItem(fullKey, rawString); } catch (error) { - report({ key, fullKey, op: 'write', error }, entryAdapter.name); + report({ key, fullKey, op: STORAGE_OP_WRITE, error }, entryAdapter.name); return; } - bus.publish(busKey, rawString, 'local'); + bus.publish(busKey, rawString, ENTRY_CHANGE_SOURCE_LOCAL); } function writeValue(value: T): void { @@ -217,7 +230,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS try { serialized = serializer.stringify(value); } catch (error) { - report({ key, fullKey, op: 'serialize', error }, entryAdapter.name); + report({ key, fullKey, op: STORAGE_OP_SERIALIZE, error }, entryAdapter.name); return; } const payload = isRaw ? serialized : encodeEnvelope(serialized, version, ttlMs); @@ -230,14 +243,14 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS try { migrated = entryOptions.migrate(prev, fromVersion); } catch (error) { - report({ key, fullKey, op: 'migrate', error }, entryAdapter.name); + report({ key, fullKey, op: STORAGE_OP_MIGRATE, error }, entryAdapter.name); return resolveDefault(); } let validated: T; try { validated = runValidate(migrated); } catch (error) { - report({ key, fullKey, op: 'validate', error }, entryAdapter.name); + report({ key, fullKey, op: STORAGE_OP_VALIDATE, error }, entryAdapter.name); return resolveDefault(); } if (writeMigrated) writeValue(validated); @@ -250,7 +263,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS const parsed = serializer.parse(rawString); return runValidate(parsed); } catch (error) { - report({ key, fullKey, op: 'deserialize', error }, entryAdapter.name); + report({ key, fullKey, op: STORAGE_OP_DESERIALIZE, error }, entryAdapter.name); return resolveDefault(); } } @@ -266,7 +279,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS const validated = runValidate(parsed); return applyMergeDefaults(validated); } catch (error) { - report({ key, fullKey, op: 'deserialize', error }, entryAdapter.name); + report({ key, fullKey, op: STORAGE_OP_DESERIALIZE, error }, entryAdapter.name); return resolveDefault(); } } @@ -275,7 +288,10 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS case 'legacy': return migrateAndReturn(decoded.raw, 0); case 'invalid': - report({ key, fullKey, op: 'deserialize', error: decoded.error }, entryAdapter.name); + report( + { key, fullKey, op: STORAGE_OP_DESERIALIZE, error: decoded.error }, + entryAdapter.name + ); return resolveDefault(); } } @@ -285,7 +301,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS try { stored = entryAdapter.getItem(fullKey); } catch (error) { - report({ key, fullKey, op: 'read', error }, entryAdapter.name); + report({ key, fullKey, op: STORAGE_OP_READ, error }, entryAdapter.name); return resolveDefault(); } if (stored === null) return resolveDefault(); @@ -359,10 +375,10 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS try { entryAdapter.removeItem(fullKey); } catch (error) { - report({ key, fullKey, op: 'remove', error }, entryAdapter.name); + report({ key, fullKey, op: STORAGE_OP_REMOVE, error }, entryAdapter.name); return; } - bus.publish(busKey, null, 'local'); + bus.publish(busKey, null, ENTRY_CHANGE_SOURCE_LOCAL); }, reset() { writeValue(resolveDefault()); @@ -394,10 +410,10 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS try { target.removeItem(fullKey); } catch (error) { - report({ key, fullKey, op: 'remove', error }, target.name); + report({ key, fullKey, op: STORAGE_OP_REMOVE, error }, target.name); continue; } - bus.publish(busKey, null, 'local'); + bus.publish(busKey, null, ENTRY_CHANGE_SOURCE_LOCAL); } } diff --git a/src/arts/stor/entry-bus.ts b/src/arts/stor/entry-bus.ts index ff7c2e8..7f1b6fa 100644 --- a/src/arts/stor/entry-bus.ts +++ b/src/arts/stor/entry-bus.ts @@ -1,3 +1,5 @@ +import type { ENTRY_CHANGE_SOURCES } from './consts'; + /** * Source of a published change. Local writes (`set`, `update`, `remove`, * `reset` from any entry inside this process) publish as `'local'`; values @@ -7,7 +9,7 @@ * Entries that need to react asymmetrically (e.g. avoid re-writing on * external changes to prevent loops) read `source` from the listener. */ -export type ChangeSource = 'local' | 'external'; +export type ChangeSource = (typeof ENTRY_CHANGE_SOURCES)[number]; export type EntryListener = (raw: string | null, source: ChangeSource) => void; diff --git a/src/arts/stor/envelope.ts b/src/arts/stor/envelope.ts index 6530eb6..7c0643b 100644 --- a/src/arts/stor/envelope.ts +++ b/src/arts/stor/envelope.ts @@ -1,5 +1,6 @@ import { DEFAULT_VERSION, + ENVELOPE_ERROR_VERSION_NOT_NUMBER, ENVELOPE_KEY_DATA, ENVELOPE_KEY_TTL, ENVELOPE_KEY_VERSION @@ -39,7 +40,7 @@ export function decodeEnvelope(stored: string, now: number = Date.now()): Envelo let parsed: unknown; try { parsed = JSON.parse(stored); - } catch (error) { + } catch { // Not JSON — could be a legacy raw value like `forest` (no quotes). return { kind: 'legacy', raw: stored }; } @@ -54,7 +55,11 @@ export function decodeEnvelope(stored: string, now: number = Date.now()): Envelo const env = parsed as Record; const version = env[ENVELOPE_KEY_VERSION]; if (typeof version !== 'number') { - return { kind: 'invalid', raw: stored, error: new TypeError('envelope.v not a number') }; + return { + kind: 'invalid', + raw: stored, + error: new TypeError(ENVELOPE_ERROR_VERSION_NOT_NUMBER) + }; } const expiresAt = env[ENVELOPE_KEY_TTL]; if (typeof expiresAt === 'number' && expiresAt <= now) { diff --git a/src/arts/stor/errors.ts b/src/arts/stor/errors.ts index 14543e3..b397d56 100644 --- a/src/arts/stor/errors.ts +++ b/src/arts/stor/errors.ts @@ -15,5 +15,17 @@ export const STORAGE_ERRORS = { '[storage] frontend.persist was requested but no persistent storage adapter is configured. Values will reset on reload. Configure createActiveApp({ storage: { adapter: localAdapter } }) or pass an adapter via persist.adapter.', COOKIE_SERVER_REQUIRED: - '[storage] cookieAdapter() runs in the browser only. For SSR use cookieAdapter.fromCookies(event.cookies, options).' + '[storage] cookieAdapter() runs in the browser only. For SSR use cookieAdapter.fromCookies(event.cookies, options).', + + NUMBER_SERIALIZER_INVALID: (value: string): string => + `[storage] numberSerializer could not parse "${value}" as a number.`, + + BOOLEAN_SERIALIZER_INVALID: (value: string): string => + `[storage] booleanSerializer could not parse "${value}" as a boolean.`, + + DATE_SERIALIZER_INVALID: (value: string): string => + `[storage] dateSerializer could not parse "${value}" as a date.`, + + STANDARD_SCHEMA_VALIDATION_FAILED: (issues: unknown): string => + `[storage] Standard Schema validation failed: ${JSON.stringify(issues)}` } as const; diff --git a/src/arts/stor/serializers.ts b/src/arts/stor/serializers.ts index e7d7cca..b82c405 100644 --- a/src/arts/stor/serializers.ts +++ b/src/arts/stor/serializers.ts @@ -1,4 +1,5 @@ import type { StorageSerializer } from './types'; +import { STORAGE_ERRORS } from './errors'; /** * Identity serializer for plain strings — no quoting, no JSON wrap. @@ -13,7 +14,7 @@ export const numberSerializer: StorageSerializer = { stringify: (v) => String(v), parse: (s) => { const n = Number(s); - if (Number.isNaN(n)) throw new TypeError(`numberSerializer: not a number: "${s}"`); + if (Number.isNaN(n)) throw new TypeError(STORAGE_ERRORS.NUMBER_SERIALIZER_INVALID(s)); return n; } }; @@ -23,7 +24,7 @@ export const booleanSerializer: StorageSerializer = { parse: (s) => { if (s === 'true') return true; if (s === 'false') return false; - throw new TypeError(`booleanSerializer: not a boolean: "${s}"`); + throw new TypeError(STORAGE_ERRORS.BOOLEAN_SERIALIZER_INVALID(s)); } }; @@ -31,7 +32,7 @@ export const dateSerializer: StorageSerializer = { stringify: (v) => v.toISOString(), parse: (s) => { const d = new Date(s); - if (Number.isNaN(d.getTime())) throw new TypeError(`dateSerializer: not a date: "${s}"`); + if (Number.isNaN(d.getTime())) throw new TypeError(STORAGE_ERRORS.DATE_SERIALIZER_INVALID(s)); return d; } }; diff --git a/src/arts/stor/types.ts b/src/arts/stor/types.ts index 244fbe3..489a0b9 100644 --- a/src/arts/stor/types.ts +++ b/src/arts/stor/types.ts @@ -1,4 +1,5 @@ import type { StandardSchemaV1 } from '$libs/standard-schema'; +import type { STORAGE_OPS } from './consts'; // ── Adapter ──────────────────────────────────────────────────────────────── @@ -32,14 +33,7 @@ export interface StorageSerializer { // ── Error context ────────────────────────────────────────────────────────── -export type StorageOp = - | 'read' - | 'write' - | 'remove' - | 'serialize' - | 'deserialize' - | 'migrate' - | 'validate'; +export type StorageOp = (typeof STORAGE_OPS)[number]; export interface StorageErrorContext { key: string; diff --git a/src/arts/timr/README.md b/src/arts/timr/README.md index b984715..efd912e 100644 --- a/src/arts/timr/README.md +++ b/src/arts/timr/README.md @@ -18,7 +18,7 @@ Timers.schedule('demo:hello', 1_000, () => console.log('hello')); // Debounce via replace:true input.addEventListener('input', () => { - Timers.schedule('search:debounce', 300, runSearch, { replace: true }); + Timers.schedule('search:debounce', 300, runSearch, { replace: true }); }); // Heartbeat — awaitTask:true (default) prevents overlap @@ -35,7 +35,7 @@ Timers.cancelAll('conn:main');

{Timers.size} timers, {Timers.scopes.length} scopes

{#each Timers.entriesSnapshot as e (e.key)} -
  • {e.key} — {e.status} (run {e.runCount})
  • +
  • {e.key} — {e.status} (run {e.runCount})
  • {/each} ``` @@ -96,7 +96,9 @@ timr/ ## Alias ```js -alias: { $timr: 'src/arts/timr' } +alias: { + $timr: 'src/arts/timr'; +} ``` --- @@ -154,12 +156,12 @@ Before mutating state, the engine looks up the entry via `getLiveEntry(id, key, Any mismatch makes the callback a silent no-op. This guards against four race classes that `clearTimeout` + `AbortSignal` alone do not cover: -| Race | What happens | Why the guard catches it | -|---|---|---| -| Native timeout fires while `cancel()` runs | `clearTimeout` was called but the callback was already in the event loop | Cancel deletes the entry → `entries.get(key) === undefined` → no-op | -| Async task resolves after `replace:true` | First entry's task continuation runs after a new entry took its key | `entry.id` doesn't match the captured `id` → no-op | -| Interval next-tick scheduled after `dispose()` | Tick was armed in branch A before dispose ran | Cancel-via-dispose increments version → no-op | -| Ack-style timeout firing after reply arrived | Reply called `cancel(ackKey)`; timeout was already queued | Same as #1 | +| Race | What happens | Why the guard catches it | +| ---------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------- | +| Native timeout fires while `cancel()` runs | `clearTimeout` was called but the callback was already in the event loop | Cancel deletes the entry → `entries.get(key) === undefined` → no-op | +| Async task resolves after `replace:true` | First entry's task continuation runs after a new entry took its key | `entry.id` doesn't match the captured `id` → no-op | +| Interval next-tick scheduled after `dispose()` | Tick was armed in branch A before dispose ran | Cancel-via-dispose increments version → no-op | +| Ack-style timeout firing after reply arrived | Reply called `cancel(ackKey)`; timeout was already queued | Same as #1 | There is one subtlety. For finalisation **after** an in-flight task (`finishEntry`), the engine identifies the entry by `(id, key)` only — @@ -195,10 +197,10 @@ Reactive wrapper around the engine. ```ts const Timers = createActiveTimers({ clock, logger }); -Timers.size // reactive -Timers.keysSnapshot // reactive readonly string[] -Timers.entriesSnapshot // reactive readonly TimerEntrySnapshot[] -Timers.scopes // reactive readonly string[] (deduplicated) +Timers.size; // reactive +Timers.keysSnapshot; // reactive readonly string[] +Timers.entriesSnapshot; // reactive readonly TimerEntrySnapshot[] +Timers.scopes; // reactive readonly string[] (deduplicated) ``` ### Scheduling @@ -258,9 +260,9 @@ A `TimerEntrySnapshot` carries the read-only state of one entry — ```ts const off = Timers.onChange((event) => { - // event.type ∈ scheduled | running | completed | failed - // | cancelled | disposed - // event.entry: TimerEntrySnapshot (except for 'disposed') + // event.type ∈ scheduled | running | completed | failed + // | cancelled | disposed + // event.entry: TimerEntrySnapshot (except for 'disposed') }); off(); ``` @@ -271,14 +273,14 @@ off(); import { computeBackoffDelay } from '$timr'; for (let attempt = 0; ; attempt++) { - if (await tryConnect()) break; - const delay = computeBackoffDelay(attempt, { - minDelayMs: 500, - maxDelayMs: 15_000, - factor: 1.8, - jitterMs: 500 - }); - await new Promise(r => setTimeout(r, delay)); + if (await tryConnect()) break; + const delay = computeBackoffDelay(attempt, { + minDelayMs: 500, + maxDelayMs: 15_000, + factor: 1.8, + jitterMs: 500 + }); + await new Promise((r) => setTimeout(r, delay)); } ``` @@ -289,12 +291,12 @@ third arg for deterministic tests. ## `awaitTask: true` vs `awaitTask: false` -| | `awaitTask: true` (default) | `awaitTask: false` | -|---|---|---| -| Next tick scheduled… | …after the current task settles | …before the current task settles | -| Task overlap | Impossible | Possible (each tick fire-and-forget) | -| Failure stops interval? | No, but next tick respects task duration | No | -| Use for | Sequential work (refresh, checkpoint) | Cadence (heartbeat, telemetry flush) | +| | `awaitTask: true` (default) | `awaitTask: false` | +| ----------------------- | ---------------------------------------- | ------------------------------------ | +| Next tick scheduled… | …after the current task settles | …before the current task settles | +| Task overlap | Impossible | Possible (each tick fire-and-forget) | +| Failure stops interval? | No, but next tick respects task duration | No | +| Use for | Sequential work (refresh, checkpoint) | Cadence (heartbeat, telemetry flush) | The semantics of `awaitTask: false continues ticking after task failure` is **frozen** — see DESIGN_TIMR §12.5.1 + the dedicated test in @@ -306,12 +308,12 @@ stop, they cancel explicitly from inside the task or use ## Errors -| Class | Thrown when | Type guard | -|---|---|---| -| `TimrDisposedError` | mutator called after `dispose()` | `isTimrDisposedError` | -| `TimrInvalidKeyError` | key is not a non-empty string | `isTimrInvalidKeyError` | -| `TimrDuplicateKeyError` | key already exists, no `replace:true` | `isTimrDuplicateKeyError` | -| `TimrInvalidDelayError` | `delayMs` is non-finite or negative | `isTimrInvalidDelayError` | +| Class | Thrown when | Type guard | +| ------------------------ | --------------------------------------- | -------------------------- | +| `TimrDisposedError` | mutator called after `dispose()` | `isTimrDisposedError` | +| `TimrInvalidKeyError` | key is not a non-empty string | `isTimrInvalidKeyError` | +| `TimrDuplicateKeyError` | key already exists, no `replace:true` | `isTimrDuplicateKeyError` | +| `TimrInvalidDelayError` | `delayMs` is non-finite or negative | `isTimrInvalidDelayError` | | `TimrInactiveTimerError` | `handle.reschedule()` on inactive entry | `isTimrInactiveTimerError` | Task failures at runtime are **not** thrown — they surface as @@ -328,8 +330,8 @@ scheduling during SSR should be intentional. If a request-scoped EngineTimers schedules anything, the request must `dispose()` it before completion — otherwise the timer leaks across requests. -There is no module-global singleton. `App.Timers` (Fase 3) is owned by -the `ActiveApp` instance and torn down by `App.dispose()`. +There is no module-global singleton. `App.Timers` is owned by the +`ActiveApp` instance and torn down by `App.dispose()`. --- @@ -340,23 +342,33 @@ Use a fake clock for deterministic tests. The canonical shape from ```ts function createFakeClock(start = 0): FakeClock { - let now = start; - const queue = new Map(); - let nextId = 1; - return { - now: () => now, - setTimeout(fn, delayMs) { /* enqueue with dueAt = now + delayMs */ }, - clearTimeout(handle) { /* mark cancelled */ }, - async advanceBy(ms) { /* fire all entries up to now + ms in order */ }, - async advanceTo(t) { /* … */ }, - pendingCount() { /* … */ } - }; + let now = start; + const queue = new Map(); + let nextId = 1; + return { + now: () => now, + setTimeout(fn, delayMs) { + /* enqueue with dueAt = now + delayMs */ + }, + clearTimeout(handle) { + /* mark cancelled */ + }, + async advanceBy(ms) { + /* fire all entries up to now + ms in order */ + }, + async advanceTo(t) { + /* … */ + }, + pendingCount() { + /* … */ + } + }; } const clock = createFakeClock(); const t = createEngineTimers({ clock }); t.schedule('demo', 1_000, () => count++); -await clock.advanceBy(1_000); // fires deterministically +await clock.advanceBy(1_000); // fires deterministically ``` The race-safety tests in `engine-timers.test.ts` cover every @@ -371,9 +383,9 @@ touching any cancellation, replace, reschedule or dispose path. ```ts const stop = withAutoRefresh(Sess, { - timers: App.Timers, - tickMs: 30_000, - marginMs: 90_000 + timers: App.Timers, + tickMs: 30_000, + marginMs: 90_000 }); // internally: timers.interval('sess:auto-refresh', tickMs, …) // stop() → timers.cancel('sess:auto-refresh') @@ -383,17 +395,15 @@ const stop = withAutoRefresh(Sess, { ```ts // reconnect with backoff -timers.schedule(`conn:${name}:reconnect`, - computeBackoffDelay(attempt, opts), - () => connection.reconnect()); +timers.schedule(`conn:${name}:reconnect`, computeBackoffDelay(attempt, opts), () => + connection.reconnect() +); // heartbeat -timers.interval(`conn:${name}:heartbeat`, 25_000, - () => connection.send('conn.ping')); +timers.interval(`conn:${name}:heartbeat`, 25_000, () => connection.send('conn.ping')); // ack timeout per outgoing message -timers.schedule(`conn:${name}:ack:${id}`, 10_000, - () => resolveAckTimeout(id)); +timers.schedule(`conn:${name}:ack:${id}`, 10_000, () => resolveAckTimeout(id)); // when the reply arrives timers.cancel(`conn:${name}:ack:${id}`); @@ -420,13 +430,13 @@ timers.schedule('authn:login-attempt:cooldown', cooldown, unlockUI); ## Bundle profile -| Layer | Approx. size (min) | -|---|---| -| Engine + types + errors + consts | ~3.5 KB | -| Active wrapper (runes) | +0.5 KB | -| Backoff helper | +0.2 KB | -| System clock | +0.1 KB | -| **Total when everything is reached** | **~4.3 KB** | +| Layer | Approx. size (min) | +| ------------------------------------ | ------------------ | +| Engine + types + errors + consts | ~3.5 KB | +| Active wrapper (runes) | +0.5 KB | +| Backoff helper | +0.2 KB | +| System clock | +0.1 KB | +| **Total when everything is reached** | **~4.3 KB** | Zero runtime dependencies. diff --git a/src/arts/timr/backoff.ts b/src/arts/timr/backoff.ts index afdf465..2d9632b 100644 --- a/src/arts/timr/backoff.ts +++ b/src/arts/timr/backoff.ts @@ -1,52 +1,2 @@ -import { - DEFAULT_BACKOFF_FACTOR, - DEFAULT_BACKOFF_JITTER_MS, - DEFAULT_BACKOFF_MAX_DELAY_MS, - DEFAULT_BACKOFF_MIN_DELAY_MS -} from './consts.ts'; -import type { BackoffOptions } from './types.ts'; - -/** - * Exponential backoff with full jitter, returning a delay in - * milliseconds for the given attempt index. - * - * Pure function — no scheduling, no side effects. Pair with - * `EngineTimers.schedule(...)` when you actually want to wait. - * - * Algorithm: - * - * base = min(maxDelayMs, minDelayMs * factor ** attempt) - * jitter = uniform(-jitterMs, +jitterMs) - * delay = clamp(0, maxDelayMs, base + jitter) - * - * `attempt` is **0-indexed** — the first try uses `minDelayMs` (± jitter). - * - * `random` is injectable so tests can pin the result. - * - * @example - * for (let attempt = 0; ; attempt++) { - * const ok = await tryConnect(); - * if (ok) break; - * await new Promise(r => setTimeout(r, computeBackoffDelay(attempt))); - * } - */ -export function computeBackoffDelay( - attempt: number, - options: BackoffOptions = {}, - random: () => number = Math.random -): number { - const minDelayMs = options.minDelayMs ?? DEFAULT_BACKOFF_MIN_DELAY_MS; - const maxDelayMs = options.maxDelayMs ?? DEFAULT_BACKOFF_MAX_DELAY_MS; - const factor = options.factor ?? DEFAULT_BACKOFF_FACTOR; - const jitterMs = options.jitterMs ?? DEFAULT_BACKOFF_JITTER_MS; - - const safeAttempt = Number.isFinite(attempt) && attempt >= 0 ? attempt : 0; - const base = Math.min(maxDelayMs, minDelayMs * Math.pow(factor, safeAttempt)); - // random ∈ [0, 1) → uniform in [-jitterMs, +jitterMs) - const jitter = jitterMs > 0 ? (random() * 2 - 1) * jitterMs : 0; - - const delay = base + jitter; - if (delay < 0) return 0; - if (delay > maxDelayMs) return maxDelayMs; - return delay; -} +export { computeBackoffDelay } from '$libs/timers'; +export type { BackoffOptions } from '$libs/timers'; diff --git a/src/arts/timr/consts.ts b/src/arts/timr/consts.ts index a2ee84b..c3e164a 100644 --- a/src/arts/timr/consts.ts +++ b/src/arts/timr/consts.ts @@ -11,6 +11,11 @@ /** Logger category emitted by the engine. */ export const LOGGER_CATEGORY = 'timr'; +export const ENGINE_METHOD_SCHEDULE = 'schedule'; +export const ENGINE_METHOD_SCHEDULE_AT = 'scheduleAt'; +export const ENGINE_METHOD_CANCEL = 'cancel'; +export const ENGINE_METHOD_CANCEL_ALL = 'cancelAll'; + // ── Timer status ─────────────────────────────────────────────────────────── // // Discrete values of `TimerStatus`. The engine moves an entry through @@ -58,10 +63,12 @@ export const DEFAULT_TIMER_SCOPE_SEPARATOR = ':'; // ── Backoff defaults ─────────────────────────────────────────────────────── -export const DEFAULT_BACKOFF_MIN_DELAY_MS = 500; -export const DEFAULT_BACKOFF_MAX_DELAY_MS = 15_000; -export const DEFAULT_BACKOFF_FACTOR = 1.8; -export const DEFAULT_BACKOFF_JITTER_MS = 500; +export { + DEFAULT_BACKOFF_FACTOR, + DEFAULT_BACKOFF_JITTER_MS, + DEFAULT_BACKOFF_MAX_DELAY_MS, + DEFAULT_BACKOFF_MIN_DELAY_MS +} from '$libs/timers'; // ── Log messages (centralised — no inline literals in the engine) ────────── @@ -76,23 +83,45 @@ export const LOG_MSG_LISTENER_THREW_PREFIX = 'Listener threw on event '; // `arts/http` so log output and stack traces are searchable. export const ERROR_PREFIX = '[timr] '; +export const ERROR_NAME_DISPOSED = 'TimrDisposedError'; +export const ERROR_NAME_INVALID_KEY = 'TimrInvalidKeyError'; +export const ERROR_NAME_DUPLICATE_KEY = 'TimrDuplicateKeyError'; +export const ERROR_NAME_INVALID_DELAY = 'TimrInvalidDelayError'; +export const ERROR_NAME_INACTIVE_TIMER = 'TimrInactiveTimerError'; + +export const ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed engine'; +export const ERROR_MSG_INVALID_KEY_PREFIX = 'invalid key: expected non-empty string, got '; +export const ERROR_MSG_DUPLICATE_KEY_PREFIX = 'duplicate key "'; +export const ERROR_MSG_DUPLICATE_KEY_SUFFIX = '" — pass { replace: true } to overwrite'; +export const ERROR_MSG_INVALID_DELAY_PREFIX = 'invalid delay: expected finite number >= 0, got '; +export const ERROR_MSG_INACTIVE_TIMER_PREFIX = 'reschedule("'; +export const ERROR_MSG_INACTIVE_TIMER_SUFFIX = '") called on an inactive timer'; export function disposedErrorMessage(method: string): string { - return `${ERROR_PREFIX}${method}() called on a disposed engine`; + return `${ERROR_PREFIX}${method}${ERROR_MSG_DISPOSED_SUFFIX}`; } export function invalidKeyErrorMessage(key: unknown): string { - return `${ERROR_PREFIX}invalid key: expected non-empty string, got ${typeof key === 'string' ? `"${key}"` : typeof key}`; + const value = typeof key === 'string' ? `"${key}"` : typeof key; + return `${ERROR_PREFIX}${ERROR_MSG_INVALID_KEY_PREFIX}${value}`; } export function duplicateKeyErrorMessage(key: string): string { - return `${ERROR_PREFIX}duplicate key "${key}" — pass { replace: true } to overwrite`; + return `${ERROR_PREFIX}${ERROR_MSG_DUPLICATE_KEY_PREFIX}${key}${ERROR_MSG_DUPLICATE_KEY_SUFFIX}`; } export function invalidDelayErrorMessage(delayMs: unknown): string { - return `${ERROR_PREFIX}invalid delay: expected finite number >= 0, got ${String(delayMs)}`; + return `${ERROR_PREFIX}${ERROR_MSG_INVALID_DELAY_PREFIX}${String(delayMs)}`; } export function inactiveTimerErrorMessage(key: string): string { - return `${ERROR_PREFIX}reschedule("${key}") called on an inactive timer`; + return `${ERROR_PREFIX}${ERROR_MSG_INACTIVE_TIMER_PREFIX}${key}${ERROR_MSG_INACTIVE_TIMER_SUFFIX}`; +} + +export function listenerThrewMessage(eventType: string): string { + return `${LOG_MSG_LISTENER_THREW_PREFIX}${eventType}`; +} + +export function taskFailedMessage(key: string): string { + return `${LOG_MSG_TASK_FAILED_PREFIX}"${key}"`; } diff --git a/src/arts/timr/engine-timers.ts b/src/arts/timr/engine-timers.ts index 73f8e12..259dc8b 100644 --- a/src/arts/timr/engine-timers.ts +++ b/src/arts/timr/engine-timers.ts @@ -26,12 +26,14 @@ import { createSystemTimerClock } from './clock.ts'; import { disposedErrorMessage, duplicateKeyErrorMessage, + ENGINE_METHOD_CANCEL, + ENGINE_METHOD_CANCEL_ALL, + ENGINE_METHOD_SCHEDULE, + ENGINE_METHOD_SCHEDULE_AT, inactiveTimerErrorMessage, invalidDelayErrorMessage, invalidKeyErrorMessage, - LOG_MSG_LISTENER_THREW_PREFIX, LOG_MSG_SCHEDULE_AT_PAST, - LOG_MSG_TASK_FAILED_PREFIX, LOGGER_CATEGORY, DEFAULT_TIMER_SCOPE_SEPARATOR, TIMER_EVENT_CANCELLED, @@ -46,7 +48,9 @@ import { TIMER_STATUS_COMPLETED, TIMER_STATUS_FAILED, TIMER_STATUS_PENDING, - TIMER_STATUS_RUNNING + TIMER_STATUS_RUNNING, + listenerThrewMessage, + taskFailedMessage } from './consts.ts'; import { TimrDisposedError, @@ -157,11 +161,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim try { listener(event); } catch (err) { - logger?.error?.( - LOGGER_CATEGORY, - `${LOG_MSG_LISTENER_THREW_PREFIX}${event.type}`, - { error: err } - ); + logger?.error?.(LOGGER_CATEGORY, listenerThrewMessage(event.type), { error: err }); } } } @@ -172,11 +172,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim * Look up an entry only if the captured tuple still matches. This is * the core of the race-safety contract — every callback uses it. */ - function getLiveEntry( - id: number, - key: string, - version: number - ): InternalTimerEntry | null { + function getLiveEntry(id: number, key: string, version: number): InternalTimerEntry | null { const current = entries.get(key); if (current === undefined) return null; if (current.id !== id) return null; @@ -232,7 +228,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim // Branch A — interval with awaitTask=false: arm the next tick BEFORE // running so we honour cadence regardless of how long the task // takes. Stale-callback guard still applies to next tick. - if (isInterval && !awaitTask) { + if (isInterval && !awaitTask && !hasReachedMaxRuns(entry)) { scheduleNextIntervalTick(entry); } @@ -271,7 +267,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim } else { entry.lastErrorAt = now; entry.error = error; - logger?.error?.(LOGGER_CATEGORY, `${LOG_MSG_TASK_FAILED_PREFIX}"${entry.key}"`, { + logger?.error?.(LOGGER_CATEGORY, taskFailedMessage(entry.key), { error }); emit({ type: TIMER_EVENT_FAILED, entry: snapshotOf(entry), error }); @@ -281,7 +277,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim // `entry.status` may already be TIMER_STATUS_PENDING if branch A // already armed the next tick; otherwise schedule it now. if (entry.status === TIMER_STATUS_RUNNING) { - if (entry.maxRuns !== undefined && entry.runCount >= entry.maxRuns) { + if (hasReachedMaxRuns(entry)) { entry.status = error === null ? TIMER_STATUS_COMPLETED : TIMER_STATUS_FAILED; entries.delete(entry.key); return; @@ -298,7 +294,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim function scheduleNextIntervalTick(entry: InternalTimerEntry): void { if (entry.intervalMs === null) return; - if (entry.maxRuns !== undefined && entry.runCount >= entry.maxRuns) { + if (hasReachedMaxRuns(entry)) { entry.status = TIMER_STATUS_COMPLETED; entries.delete(entry.key); return; @@ -327,6 +323,10 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim // ── Public scheduling ─────────────────────────────────────────────────── + function hasReachedMaxRuns(entry: InternalTimerEntry): boolean { + return entry.maxRuns !== undefined && entry.runCount >= entry.maxRuns; + } + function scheduleCommon( key: string, delayMs: number, @@ -337,7 +337,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim awaitTask: boolean, maxRuns: number | undefined ): TimerHandle { - ensureLive('schedule'); + ensureLive(ENGINE_METHOD_SCHEDULE); validateKey(key); validateDelay(delayMs); @@ -369,8 +369,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim lastCompletedAt: null, lastErrorAt: null, error: null, - removeOnComplete: - kind === TIMER_KIND_INTERVAL ? false : options?.removeOnComplete !== false, + removeOnComplete: kind === TIMER_KIND_INTERVAL ? false : options?.removeOnComplete !== false, meta: options?.meta }; @@ -440,7 +439,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim }, scheduleAt(key, dueAt, task, opts) { - ensureLive('scheduleAt'); + ensureLive(ENGINE_METHOD_SCHEDULE_AT); const now = clock.now(); let delayMs = dueAt - now; if (delayMs < 0) { @@ -470,7 +469,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim }, cancel(key) { - ensureLive('cancel'); + ensureLive(ENGINE_METHOD_CANCEL); const entry = entries.get(key); if (entry === undefined) return false; cancelEntry(entry); @@ -478,7 +477,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim }, cancelAll(scope) { - ensureLive('cancelAll'); + ensureLive(ENGINE_METHOD_CANCEL_ALL); let count = 0; // Snapshot keys to avoid mutating during iteration. const keys: string[] = []; diff --git a/src/arts/timr/errors.ts b/src/arts/timr/errors.ts index 00dc55b..014e356 100644 --- a/src/arts/timr/errors.ts +++ b/src/arts/timr/errors.ts @@ -7,6 +7,14 @@ * across worker boundaries and is stable to test against. */ +import { + ERROR_NAME_DISPOSED, + ERROR_NAME_DUPLICATE_KEY, + ERROR_NAME_INACTIVE_TIMER, + ERROR_NAME_INVALID_DELAY, + ERROR_NAME_INVALID_KEY +} from './consts.ts'; + abstract class TimrEngineError extends Error { abstract readonly name: string; constructor(message: string, options?: { cause?: unknown }) { @@ -15,15 +23,15 @@ abstract class TimrEngineError extends Error { } export class TimrDisposedError extends TimrEngineError { - readonly name = 'TimrDisposedError' as const; + readonly name = ERROR_NAME_DISPOSED; } export class TimrInvalidKeyError extends TimrEngineError { - readonly name = 'TimrInvalidKeyError' as const; + readonly name = ERROR_NAME_INVALID_KEY; } export class TimrDuplicateKeyError extends TimrEngineError { - readonly name = 'TimrDuplicateKeyError' as const; + readonly name = ERROR_NAME_DUPLICATE_KEY; readonly key: string; constructor(message: string, key: string) { super(message); @@ -32,11 +40,11 @@ export class TimrDuplicateKeyError extends TimrEngineError { } export class TimrInvalidDelayError extends TimrEngineError { - readonly name = 'TimrInvalidDelayError' as const; + readonly name = ERROR_NAME_INVALID_DELAY; } export class TimrInactiveTimerError extends TimrEngineError { - readonly name = 'TimrInactiveTimerError' as const; + readonly name = ERROR_NAME_INACTIVE_TIMER; readonly key: string; constructor(message: string, key: string) { super(message); @@ -47,23 +55,21 @@ export class TimrInactiveTimerError extends TimrEngineError { // ── Type guards ──────────────────────────────────────────────────────────── export function isTimrDisposedError(value: unknown): value is TimrDisposedError { - return value instanceof Error && (value as { name?: string }).name === 'TimrDisposedError'; + return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_DISPOSED; } export function isTimrInvalidKeyError(value: unknown): value is TimrInvalidKeyError { - return value instanceof Error && (value as { name?: string }).name === 'TimrInvalidKeyError'; + return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INVALID_KEY; } export function isTimrDuplicateKeyError(value: unknown): value is TimrDuplicateKeyError { - return value instanceof Error && (value as { name?: string }).name === 'TimrDuplicateKeyError'; + return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_DUPLICATE_KEY; } export function isTimrInvalidDelayError(value: unknown): value is TimrInvalidDelayError { - return value instanceof Error && (value as { name?: string }).name === 'TimrInvalidDelayError'; + return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INVALID_DELAY; } export function isTimrInactiveTimerError(value: unknown): value is TimrInactiveTimerError { - return ( - value instanceof Error && (value as { name?: string }).name === 'TimrInactiveTimerError' - ); + return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INACTIVE_TIMER; } diff --git a/src/arts/timr/types.ts b/src/arts/timr/types.ts index 45c5744..35d4f41 100644 --- a/src/arts/timr/types.ts +++ b/src/arts/timr/types.ts @@ -27,6 +27,8 @@ import type { TIMER_STATUS_RUNNING } from './consts.ts'; +export type { BackoffOptions } from '$libs/timers'; + // ============================================================================ // CLOCK // ============================================================================ @@ -207,19 +209,9 @@ export interface TimerLogger { export interface TimerScheduler { readonly size: number; - schedule( - key: string, - delayMs: number, - task: TimerTask, - options?: TimerOptions - ): TimerHandle; + schedule(key: string, delayMs: number, task: TimerTask, options?: TimerOptions): TimerHandle; - scheduleAt( - key: string, - dueAt: number, - task: TimerTask, - options?: TimerOptions - ): TimerHandle; + scheduleAt(key: string, dueAt: number, task: TimerTask, options?: TimerOptions): TimerHandle; interval( key: string, @@ -251,14 +243,3 @@ export interface EngineTimers extends TimerScheduler { entry(key: string): TimerEntrySnapshot | null; onChange(listener: TimerListener): () => void; } - -// ============================================================================ -// BACKOFF (pure helper, no scheduling) -// ============================================================================ - -export interface BackoffOptions { - readonly minDelayMs?: number; - readonly maxDelayMs?: number; - readonly factor?: number; - readonly jitterMs?: number; -} diff --git a/src/libs/auth/jwt.ts b/src/libs/auth/jwt.ts new file mode 100644 index 0000000..8b03f19 --- /dev/null +++ b/src/libs/auth/jwt.ts @@ -0,0 +1,33 @@ +/** + * Decode a JWT's `exp` claim and return it as **epoch milliseconds**, or + * `null` when the token is malformed / missing the claim. **No signature + * verification, no claim validation** — trust this only for scheduling + * (compute when to refresh), never for authorization decisions. + * + * Opt-in helper, separate file. The artifact does not bundle a JWT + * library; consumers that store opaque tokens never reach this code. + * + * @example + * import { extractJwtExp } from '$sess/jwt'; + * + * await Sess.adopt({ + * user, + * accessToken, + * expiresAt: extractJwtExp(accessToken) ?? Date.now() + 3_600_000 + * }); + */ +export function extractJwtExp(token: string): number | null { + const parts = token.split('.'); + if (parts.length !== 3) return null; + + try { + const payload = parts[1]; + const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4); + const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/')); + const claims = JSON.parse(json) as { exp?: unknown }; + if (typeof claims.exp === 'number') return claims.exp * 1000; + return null; + } catch { + return null; + } +} diff --git a/src/libs/http/body.ts b/src/libs/http/body.ts new file mode 100644 index 0000000..4241b82 --- /dev/null +++ b/src/libs/http/body.ts @@ -0,0 +1,102 @@ +import { + HTTP_CONTENT_TYPE_JSON, + HTTP_HEADER_CONTENT_TYPE, + HTTP_METHOD_HEAD, + HTTP_NULL_BODY_STATUSES +} from './consts'; +import type { HttpBodyInit, HttpHeadersHook, HttpHeadersInit } from './types'; + +const JSON_CONTENT_TYPE_PATTERN = /\/(?:.*[.+-])?json(?:;|$)/i; + +export function isJsonContentType(value: string): boolean { + return JSON_CONTENT_TYPE_PATTERN.test(value); +} + +export function isJSONSerializable(value: unknown): value is Record | unknown[] { + if (value === null || value === undefined) return false; + if (typeof value !== 'object') return false; + if (Array.isArray(value)) return true; + + const obj = value as { buffer?: unknown; constructor?: { name?: string }; toJSON?: unknown }; + + if (obj.buffer !== undefined && obj.buffer instanceof ArrayBuffer) return false; + if ( + value instanceof FormData || + value instanceof URLSearchParams || + value instanceof Blob || + value instanceof ArrayBuffer || + (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream) + ) { + return false; + } + + const ctor = obj.constructor?.name; + if (ctor === 'Object' || ctor === undefined) return true; + if (typeof obj.toJSON === 'function') return true; + + return false; +} + +export function serializeBody(body: HttpBodyInit): { + body: BodyInit | undefined; + contentType?: string; +} { + if (body === null || body === undefined) return { body: undefined }; + if (isJSONSerializable(body)) { + return { body: JSON.stringify(body), contentType: HTTP_CONTENT_TYPE_JSON }; + } + return { body: body as BodyInit }; +} + +export async function parseBody(response: Response, method: string): Promise { + if (HTTP_NULL_BODY_STATUSES.has(response.status)) return undefined; + if (method.toUpperCase() === HTTP_METHOD_HEAD) return undefined; + + const contentType = response.headers.get(HTTP_HEADER_CONTENT_TYPE) ?? ''; + + if (isJsonContentType(contentType)) { + try { + return await response.json(); + } catch { + return undefined; + } + } + + try { + const text = await response.text(); + return text === '' ? undefined : text; + } catch { + return undefined; + } +} + +export async function mergeHeaders( + defaults: HttpHeadersInit | HttpHeadersHook | undefined, + override: HttpHeadersInit | HttpHeadersHook | undefined +): Promise { + const result = new Headers(); + + if (defaults !== undefined) await applyHeaders(result, defaults); + if (override !== undefined) await applyHeaders(result, override); + + return result; +} + +export async function applyHeaders( + target: Headers, + source: HttpHeadersInit | HttpHeadersHook +): Promise { + const resolved = typeof source === 'function' ? await source() : source; + + if (resolved instanceof Headers) { + resolved.forEach((value, key) => target.set(key, value)); + return; + } + + if (Array.isArray(resolved)) { + for (const [key, value] of resolved) target.set(key, value); + return; + } + + for (const [key, value] of Object.entries(resolved)) target.set(key, value); +} diff --git a/src/libs/http/consts.ts b/src/libs/http/consts.ts new file mode 100644 index 0000000..73a3f63 --- /dev/null +++ b/src/libs/http/consts.ts @@ -0,0 +1,83 @@ +export const HTTP_METHOD_GET = 'GET'; +export const HTTP_METHOD_HEAD = 'HEAD'; +export const HTTP_METHOD_POST = 'POST'; +export const HTTP_METHOD_PUT = 'PUT'; +export const HTTP_METHOD_PATCH = 'PATCH'; +export const HTTP_METHOD_DELETE = 'DELETE'; +export const HTTP_METHOD_OPTIONS = 'OPTIONS'; + +export const HTTP_METHODS = [ + HTTP_METHOD_GET, + HTTP_METHOD_HEAD, + HTTP_METHOD_POST, + HTTP_METHOD_PUT, + HTTP_METHOD_PATCH, + HTTP_METHOD_DELETE, + HTTP_METHOD_OPTIONS +] as const; + +export const HTTP_SAFE_METHODS = [HTTP_METHOD_GET, HTTP_METHOD_HEAD, HTTP_METHOD_OPTIONS] as const; + +export const HTTP_IDEMPOTENT_METHODS = [ + HTTP_METHOD_GET, + HTTP_METHOD_HEAD, + HTTP_METHOD_PUT, + HTTP_METHOD_DELETE, + HTTP_METHOD_OPTIONS +] as const; + +export const HTTP_BODY_METHODS = [HTTP_METHOD_POST, HTTP_METHOD_PUT, HTTP_METHOD_PATCH] as const; + +export const HTTP_HEADER_ACCEPT = 'accept'; +export const HTTP_HEADER_AUTHORIZATION = 'authorization'; +export const HTTP_HEADER_CONTENT_TYPE = 'content-type'; +export const HTTP_HEADER_RETRY_AFTER = 'retry-after'; +export const HTTP_HEADER_RATELIMIT_RESET = 'ratelimit-reset'; +export const HTTP_HEADER_X_RATELIMIT_RESET = 'x-ratelimit-reset'; +export const HTTP_HEADER_X_RATE_LIMIT_RESET = 'x-rate-limit-reset'; +export const HTTP_HEADER_X_RATELIMIT_RETRY_AFTER = 'x-ratelimit-retry-after'; + +export const HTTP_RETRY_AFTER_HEADERS = [ + HTTP_HEADER_RETRY_AFTER, + HTTP_HEADER_RATELIMIT_RESET, + HTTP_HEADER_X_RATELIMIT_RESET, + HTTP_HEADER_X_RATE_LIMIT_RESET, + HTTP_HEADER_X_RATELIMIT_RETRY_AFTER +] as const; + +export const HTTP_CONTENT_TYPE_JSON = 'application/json'; + +export const HTTP_STATUS_SWITCHING_PROTOCOLS = 101; +export const HTTP_STATUS_NO_CONTENT = 204; +export const HTTP_STATUS_RESET_CONTENT = 205; +export const HTTP_STATUS_NOT_MODIFIED = 304; +export const HTTP_STATUS_BAD_REQUEST = 400; +export const HTTP_STATUS_UNAUTHORIZED = 401; +export const HTTP_STATUS_FORBIDDEN = 403; +export const HTTP_STATUS_NOT_FOUND = 404; +export const HTTP_STATUS_REQUEST_TIMEOUT = 408; +export const HTTP_STATUS_TOO_EARLY = 425; +export const HTTP_STATUS_TOO_MANY_REQUESTS = 429; +export const HTTP_STATUS_INTERNAL_SERVER_ERROR = 500; +export const HTTP_STATUS_BAD_GATEWAY = 502; +export const HTTP_STATUS_SERVICE_UNAVAILABLE = 503; +export const HTTP_STATUS_GATEWAY_TIMEOUT = 504; + +export const HTTP_NULL_BODY_STATUSES: ReadonlySet = new Set([ + HTTP_STATUS_SWITCHING_PROTOCOLS, + HTTP_STATUS_NO_CONTENT, + HTTP_STATUS_RESET_CONTENT, + HTTP_STATUS_NOT_MODIFIED +]); + +export const HTTP_DEFAULT_RETRY_STATUSES = [ + HTTP_STATUS_REQUEST_TIMEOUT, + HTTP_STATUS_TOO_EARLY, + HTTP_STATUS_TOO_MANY_REQUESTS, + HTTP_STATUS_INTERNAL_SERVER_ERROR, + HTTP_STATUS_BAD_GATEWAY, + HTTP_STATUS_SERVICE_UNAVAILABLE, + HTTP_STATUS_GATEWAY_TIMEOUT +] as const; + +export const RETRY_AFTER_DATE_CEILING_SECONDS = 1_700_000_000; diff --git a/src/libs/http/index.ts b/src/libs/http/index.ts new file mode 100644 index 0000000..e7bc6af --- /dev/null +++ b/src/libs/http/index.ts @@ -0,0 +1,5 @@ +export * from './consts'; +export * from './types'; +export * from './body'; +export * from './search'; +export * from './retry'; diff --git a/src/libs/http/retry.ts b/src/libs/http/retry.ts new file mode 100644 index 0000000..1bd1128 --- /dev/null +++ b/src/libs/http/retry.ts @@ -0,0 +1,24 @@ +import { HTTP_RETRY_AFTER_HEADERS, RETRY_AFTER_DATE_CEILING_SECONDS } from './consts'; + +export function parseRetryAfter(headers: Headers, now: number = Date.now()): number | undefined { + for (const name of HTTP_RETRY_AFTER_HEADERS) { + const raw = headers.get(name); + if (raw === null) continue; + + const trimmed = raw.trim(); + if (trimmed === '') continue; + + const numeric = Number(trimmed); + if (Number.isFinite(numeric)) { + if (numeric < 0) return 0; + if (numeric > RETRY_AFTER_DATE_CEILING_SECONDS) { + return Math.max(0, numeric * 1000 - now); + } + return numeric * 1000; + } + + const parsed = Date.parse(trimmed); + if (Number.isFinite(parsed)) return Math.max(0, parsed - now); + } + return undefined; +} diff --git a/src/libs/http/search.ts b/src/libs/http/search.ts new file mode 100644 index 0000000..f0d4627 --- /dev/null +++ b/src/libs/http/search.ts @@ -0,0 +1,39 @@ +import type { HttpSearchInit } from './types'; + +export function normalizeSearch(input: HttpSearchInit | undefined): URLSearchParams | undefined { + if (input === undefined) return undefined; + + if (input instanceof URLSearchParams) return new URLSearchParams(input); + + if (typeof input === 'string') { + return new URLSearchParams(input.startsWith('?') ? input.slice(1) : input); + } + + const out = new URLSearchParams(); + + if (Array.isArray(input)) { + for (const [key, value] of input) out.append(key, String(value)); + return out; + } + + for (const [key, value] of Object.entries(input)) { + if (value === null || value === undefined) continue; + out.append(key, String(value)); + } + return out; +} + +export function appendSearch(url: string, params: URLSearchParams | undefined): string { + if (params === undefined || params.size === 0) return url; + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}${params.toString()}`; +} + +export function resolveUrl(url: string, baseUrl: string | undefined): string { + if (baseUrl === undefined || baseUrl === '') return url; + if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url)) return url; + + const trimmedBase = baseUrl.split('?')[0].split('#')[0].replace(/\/+$/, ''); + const trimmedUrl = url.replace(/^\/+/, ''); + return `${trimmedBase}/${trimmedUrl}`; +} diff --git a/src/libs/http/types.ts b/src/libs/http/types.ts new file mode 100644 index 0000000..d220e60 --- /dev/null +++ b/src/libs/http/types.ts @@ -0,0 +1,18 @@ +import type { HTTP_METHODS } from './consts'; + +export type HttpMethod = (typeof HTTP_METHODS)[number]; + +export type HttpSearchInit = + | string + | URLSearchParams + | Record + | ReadonlyArray; + +export type HttpHeadersInit = + | Headers + | Record + | ReadonlyArray; + +export type HttpHeadersHook = () => HttpHeadersInit | Promise; + +export type HttpBodyInit = BodyInit | Record | unknown[] | null | undefined; diff --git a/src/libs/perm/builder.ts b/src/libs/perm/builder.ts new file mode 100644 index 0000000..115670b --- /dev/null +++ b/src/libs/perm/builder.ts @@ -0,0 +1,328 @@ +import { + PERMISSION_EFFECT_ALLOW, + PERMISSION_EFFECT_DENY, + PERMISSION_ACTION_WILDCARD_SUFFIX, + PERMISSION_ERROR_MSG_MFA_REQUIRED, + PERMISSION_ERROR_MSG_POLICY_UNKNOWN_ACTION_PREFIX, + PERMISSION_ERROR_MSG_POLICY_UNKNOWN_RESOURCE_PREFIX, + PERMISSION_EXPR_AND, + PERMISSION_EXPR_CONST, + PERMISSION_EXPR_CONTAINS, + PERMISSION_EXPR_EQ, + PERMISSION_EXPR_GT, + PERMISSION_EXPR_GTE, + PERMISSION_EXPR_IN, + PERMISSION_EXPR_LT, + PERMISSION_EXPR_LTE, + PERMISSION_EXPR_NEQ, + PERMISSION_EXPR_NOT, + PERMISSION_EXPR_OR, + PERMISSION_EXPR_REF, + PERMISSION_EXPR_REL, + PERMISSION_MASK_MODE_FULL, + PERMISSION_OBLIGATION_AUDIT, + PERMISSION_OBLIGATION_MASK, + PERMISSION_OBLIGATION_REDACT, + PERMISSION_OBLIGATION_REQUIRE_MFA, + PERMISSION_PATH_SEPARATOR, + PERMISSION_POLICY_DEFAULT_PRIORITY, + PERMISSION_ROOT_ACTOR, + PERMISSION_ROOT_CONTEXT, + PERMISSION_ROOT_RESOURCE, + PERMISSION_SEVERITY_HIGH, + PERMISSION_SEVERITY_LOW, + PERMISSION_SEVERITY_MEDIUM, + permissionAutoPolicyId +} from './consts.ts'; +import { PermissionSchemaError } from './errors.ts'; +import { stripResourcePrefix } from './path.ts'; +import { actionResource } from './schema.ts'; +import type { AdviceIR, ExprIR, ObligationIR, PermSchema, PolicyIR } from './types.ts'; + +let autoPolicyCounter = 0; + +type ExprInput = + | ExprBuilder + | ExprIR + | string + | number + | boolean + | null + | undefined + | Record + | readonly unknown[]; + +function toIR(input: ExprInput): ExprIR { + if (input instanceof ExprBuilder) return input.ir; + if (isExprIR(input)) return input; + return { op: PERMISSION_EXPR_CONST, value: input }; +} + +function isExprIR(input: unknown): input is ExprIR { + return Boolean(input && typeof input === 'object' && 'op' in input); +} + +function refForAttribute(path: string): ExprIR { + const parts = path.split(PERMISSION_PATH_SEPARATOR).filter(Boolean); + if (parts[0] === PERMISSION_ROOT_ACTOR) { + return { + op: PERMISSION_EXPR_REF, + root: PERMISSION_ROOT_ACTOR, + path: parts.slice(1).join(PERMISSION_PATH_SEPARATOR) + }; + } + if (parts[0] === PERMISSION_ROOT_CONTEXT) { + return { + op: PERMISSION_EXPR_REF, + root: PERMISSION_ROOT_CONTEXT, + path: parts.slice(1).join(PERMISSION_PATH_SEPARATOR) + }; + } + if (parts[0] === PERMISSION_ROOT_RESOURCE) { + return { + op: PERMISSION_EXPR_REF, + root: PERMISSION_ROOT_RESOURCE, + path: parts.slice(1).join(PERMISSION_PATH_SEPARATOR) + }; + } + return { + op: PERMISSION_EXPR_REF, + root: PERMISSION_ROOT_RESOURCE, + path: stripResourcePrefix(path) + }; +} + +export class ExprBuilder { + constructor(readonly ir: ExprIR) {} + + eq(value: ExprInput): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_EQ, left: this.ir, right: toIR(value) }); + } + + notEq(value: ExprInput): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_NEQ, left: this.ir, right: toIR(value) }); + } + + gt(value: ExprInput): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_GT, left: this.ir, right: toIR(value) }); + } + + gte(value: ExprInput): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_GTE, left: this.ir, right: toIR(value) }); + } + + lt(value: ExprInput): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_LT, left: this.ir, right: toIR(value) }); + } + + lte(value: ExprInput): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_LTE, left: this.ir, right: toIR(value) }); + } + + in(set: ExprInput): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_IN, value: this.ir, set: toIR(set) }); + } + + contains(value: ExprInput): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_CONTAINS, set: this.ir, value: toIR(value) }); + } + + not(): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_NOT, expr: this.ir }); + } +} + +export class RelationBuilder { + constructor(private readonly path: string) {} + + has(subject: ExprInput): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_REL, path: this.path, subject: toIR(subject) }); + } + + is(subject: ExprInput): ExprBuilder { + return this.has(subject); + } +} + +export class PolicyBuilder { + private conditionIR: ExprIR = { op: PERMISSION_EXPR_CONST, value: true }; + private policyId: string | undefined; + private policyPriority = PERMISSION_POLICY_DEFAULT_PRIORITY; + private policyReason: string | undefined; + private policyCode: string | undefined; + private policyObligations: ObligationIR[] = []; + private policyAdvice: AdviceIR[] = []; + private policyMetadata: Record | undefined; + + constructor( + private readonly policyEffect: typeof PERMISSION_EFFECT_ALLOW | typeof PERMISSION_EFFECT_DENY, + private readonly policyAction: string + ) {} + + id(id: string): this { + this.policyId = id; + return this; + } + + priority(priority: number): this { + this.policyPriority = priority; + return this; + } + + when(expr: ExprInput): this { + this.conditionIR = toIR(expr); + return this; + } + + because(reason: string, code?: string): this { + this.policyReason = reason; + this.policyCode = code; + return this; + } + + oblige(...obligations: ObligationIR[]): this { + this.policyObligations.push(...obligations); + return this; + } + + advise(...advice: AdviceIR[]): this { + this.policyAdvice.push(...advice); + return this; + } + + meta(metadata: Record): this { + this.policyMetadata = metadata; + return this; + } + + build(): PolicyIR { + const resource = actionResource(this.policyAction); + const id = + this.policyId ?? + permissionAutoPolicyId(this.policyEffect, this.policyAction, ++autoPolicyCounter); + + const policy: PolicyIR = { + id, + effect: this.policyEffect, + priority: this.policyPriority, + target: resource ? { action: this.policyAction, resource } : { action: this.policyAction }, + condition: this.conditionIR + }; + + if (this.policyReason !== undefined) (policy as { reason?: string }).reason = this.policyReason; + if (this.policyCode !== undefined) (policy as { code?: string }).code = this.policyCode; + if (this.policyObligations.length) { + (policy as { obligations?: readonly ObligationIR[] }).obligations = this.policyObligations; + } + if (this.policyAdvice.length) { + (policy as { advice?: readonly AdviceIR[] }).advice = this.policyAdvice; + } + if (this.policyMetadata !== undefined) { + (policy as { metadata?: Record }).metadata = this.policyMetadata; + } + + return policy; + } +} + +export function allow(action: string): PolicyBuilder { + return new PolicyBuilder(PERMISSION_EFFECT_ALLOW, action); +} + +export function deny(action: string): PolicyBuilder { + return new PolicyBuilder(PERMISSION_EFFECT_DENY, action); +} + +export function attr(path: string): ExprBuilder { + return new ExprBuilder(refForAttribute(path)); +} + +export function ctx(path: string): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_REF, root: PERMISSION_ROOT_CONTEXT, path }); +} + +export function actor(path = ''): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_REF, root: PERMISSION_ROOT_ACTOR, path }); +} + +export function resource(path = ''): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_REF, root: PERMISSION_ROOT_RESOURCE, path }); +} + +export function val(value: unknown): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_CONST, value }); +} + +export function rel(path: string): RelationBuilder { + return new RelationBuilder(path); +} + +export function and(...args: ExprInput[]): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_AND, args: args.map(toIR) }); +} + +export function or(...args: ExprInput[]): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_OR, args: args.map(toIR) }); +} + +export function not(expr: ExprInput): ExprBuilder { + return new ExprBuilder({ op: PERMISSION_EXPR_NOT, expr: toIR(expr) }); +} + +export function mask(field: string, mode: string = PERMISSION_MASK_MODE_FULL): ObligationIR { + return { type: PERMISSION_OBLIGATION_MASK, field, mode }; +} + +export function redact(field: string): ObligationIR { + return { type: PERMISSION_OBLIGATION_REDACT, field }; +} + +export function requireMfa(reason = PERMISSION_ERROR_MSG_MFA_REQUIRED): ObligationIR { + return { type: PERMISSION_OBLIGATION_REQUIRE_MFA, reason }; +} + +export function audit( + event: string, + severity: + | typeof PERMISSION_SEVERITY_LOW + | typeof PERMISSION_SEVERITY_MEDIUM + | typeof PERMISSION_SEVERITY_HIGH = PERMISSION_SEVERITY_LOW +): ObligationIR { + return { type: PERMISSION_OBLIGATION_AUDIT, event, severity }; +} + +export function definePolicies( + schema: PermSchema, + policies: readonly (PolicyBuilder | PolicyIR)[] +): PolicyIR[] { + const built = policies.map((policy) => + policy instanceof PolicyBuilder ? policy.build() : policy + ); + validatePolicies(schema, built); + return built; +} + +export function validatePolicies(schema: PermSchema, policies: readonly PolicyIR[]): void { + for (const policy of policies) { + const resource = policy.target.resource ?? actionResource(policy.target.action); + if (resource !== undefined && schema.resources[resource] === undefined) { + throw new PermissionSchemaError( + `${PERMISSION_ERROR_MSG_POLICY_UNKNOWN_RESOURCE_PREFIX}${policy.id}` + ); + } + const action = policy.target.action.includes(PERMISSION_PATH_SEPARATOR) + ? policy.target.action.split(PERMISSION_PATH_SEPARATOR)[1] + : policy.target.action; + if ( + resource !== undefined && + !policy.target.action.endsWith(PERMISSION_ACTION_WILDCARD_SUFFIX) && + !schema.resources[resource]?.actions.includes(action) + ) { + throw new PermissionSchemaError( + `${PERMISSION_ERROR_MSG_POLICY_UNKNOWN_ACTION_PREFIX}${policy.id}` + ); + } + } +} + +export type { ExprInput }; diff --git a/src/libs/perm/combiner.ts b/src/libs/perm/combiner.ts new file mode 100644 index 0000000..d538bb4 --- /dev/null +++ b/src/libs/perm/combiner.ts @@ -0,0 +1,143 @@ +import { + PERMISSION_DECISION_CODE_DENIED, + PERMISSION_EFFECT_ALLOW, + PERMISSION_EFFECT_DENY, + PERMISSION_EFFECT_INDETERMINATE, + PERMISSION_EFFECT_NOT_APPLICABLE, + PERMISSION_ERROR_MSG_DENY_INDETERMINATE, + PERMISSION_ERROR_MSG_EVALUATION_FAILED_DECISION, + PERMISSION_ERROR_MSG_EVALUATION_UNKNOWN_DECISION, + PERMISSION_ERROR_MSG_NOT_APPLICABLE, + PERMISSION_EVAL_ERROR, + PERMISSION_EVAL_UNKNOWN, + PERMISSION_FALLBACK_DENY, + permissionDeniedByPolicyReason +} from './consts.ts'; +import type { + EvalResult, + PermissionDecision, + PermissionFallback, + PolicyIR, + TraceEntry +} from './types.ts'; + +export interface EvaluatedPolicy { + readonly policy: PolicyIR; + readonly targetMatched: boolean; + readonly evaluation?: EvalResult; +} + +function byPriorityDesc(a: EvaluatedPolicy, b: EvaluatedPolicy): number { + return b.policy.priority - a.policy.priority; +} + +function matched(entry: EvaluatedPolicy): boolean { + return entry.targetMatched; +} + +function evaluatedTrue(entry: EvaluatedPolicy): boolean { + return entry.evaluation?.value === true; +} + +function evaluatedUnknownOrError(entry: EvaluatedPolicy): boolean { + return ( + entry.evaluation?.value === PERMISSION_EVAL_UNKNOWN || + entry.evaluation?.value === PERMISSION_EVAL_ERROR + ); +} + +export function combineEvaluatedPolicies( + evaluated: readonly EvaluatedPolicy[], + defaultFallback: PermissionFallback = PERMISSION_FALLBACK_DENY +): PermissionDecision { + const applicable = evaluated.filter((entry) => matched(entry) && evaluatedTrue(entry)); + const deny = applicable + .filter((entry) => entry.policy.effect === PERMISSION_EFFECT_DENY) + .sort(byPriorityDesc)[0]; + + if (deny) { + return { + effect: PERMISSION_EFFECT_DENY, + policy: deny.policy.id, + code: deny.policy.code ?? PERMISSION_DECISION_CODE_DENIED, + reason: deny.policy.reason ?? permissionDeniedByPolicyReason(deny.policy.id), + advice: deny.policy.advice + }; + } + + const denyIndeterminate = evaluated + .filter( + (entry) => + matched(entry) && + entry.policy.effect === PERMISSION_EFFECT_DENY && + evaluatedUnknownOrError(entry) + ) + .sort(byPriorityDesc)[0]; + + if (denyIndeterminate?.evaluation) { + return { + effect: PERMISSION_EFFECT_INDETERMINATE, + policy: denyIndeterminate.policy.id, + reason: + denyIndeterminate.evaluation.reason ?? + denyIndeterminate.policy.reason ?? + PERMISSION_ERROR_MSG_DENY_INDETERMINATE, + fallback: PERMISSION_FALLBACK_DENY, + errors: denyIndeterminate.evaluation.error ? [denyIndeterminate.evaluation.error] : undefined + }; + } + + const allow = applicable + .filter((entry) => entry.policy.effect === PERMISSION_EFFECT_ALLOW) + .sort(byPriorityDesc)[0]; + + if (allow) { + return { + effect: PERMISSION_EFFECT_ALLOW, + policy: allow.policy.id, + reason: allow.policy.reason, + obligations: allow.policy.obligations, + advice: allow.policy.advice + }; + } + + const errored = evaluated.find( + (entry) => matched(entry) && entry.evaluation?.value === PERMISSION_EVAL_ERROR + ); + if (errored?.evaluation) { + return { + effect: PERMISSION_EFFECT_INDETERMINATE, + reason: errored.evaluation.reason ?? PERMISSION_ERROR_MSG_EVALUATION_FAILED_DECISION, + fallback: defaultFallback, + errors: errored.evaluation.error ? [errored.evaluation.error] : undefined + }; + } + + const unknown = evaluated.find( + (entry) => matched(entry) && entry.evaluation?.value === PERMISSION_EVAL_UNKNOWN + ); + if (unknown?.evaluation) { + return { + effect: PERMISSION_EFFECT_INDETERMINATE, + reason: unknown.evaluation.reason ?? PERMISSION_ERROR_MSG_EVALUATION_UNKNOWN_DECISION, + fallback: defaultFallback + }; + } + + return { + effect: PERMISSION_EFFECT_NOT_APPLICABLE, + reason: PERMISSION_ERROR_MSG_NOT_APPLICABLE + }; +} + +export function toTrace(evaluated: readonly EvaluatedPolicy[]): TraceEntry[] { + return evaluated.map((entry) => ({ + policy: entry.policy.id, + effect: entry.policy.effect, + targetMatched: entry.targetMatched, + condition: entry.targetMatched ? entry.policy.condition : undefined, + result: entry.evaluation?.value, + reason: entry.evaluation?.reason, + dependencies: entry.evaluation?.dependencies ?? [] + })); +} diff --git a/src/libs/perm/compilers/sql.ts b/src/libs/perm/compilers/sql.ts new file mode 100644 index 0000000..6d07cc4 --- /dev/null +++ b/src/libs/perm/compilers/sql.ts @@ -0,0 +1,234 @@ +import { + PERMISSION_EFFECT_ALLOW, + PERMISSION_EFFECT_DENY, + PERMISSION_ERROR_MSG_NO_SQL_ALLOW, + PERMISSION_ERROR_MSG_SQL_RESIDUAL, + PERMISSION_EXPR_AND, + PERMISSION_EXPR_CONST, + PERMISSION_EXPR_CONTAINS, + PERMISSION_EXPR_EQ, + PERMISSION_EXPR_EXISTS, + PERMISSION_EXPR_GT, + PERMISSION_EXPR_GTE, + PERMISSION_EXPR_IN, + PERMISSION_EXPR_LT, + PERMISSION_EXPR_LTE, + PERMISSION_EXPR_NEQ, + PERMISSION_EXPR_NOT, + PERMISSION_EXPR_OR, + PERMISSION_EXPR_REF, + PERMISSION_EXPR_REL, + PERMISSION_QUERY_STRATEGY_COMPILED, + PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE, + PERMISSION_QUERY_STRATEGY_PARTIAL, + PERMISSION_QUERY_TARGET_SQL, + PERMISSION_ROOT_ACTOR, + PERMISSION_ROOT_CONTEXT, + PERMISSION_ROOT_RESOURCE, + PERMISSION_SQL_AND, + PERMISSION_SQL_DEFAULT_RESOURCE_ALIAS, + PERMISSION_SQL_FALSE, + PERMISSION_SQL_OR, + PERMISSION_SQL_PARAM_PREFIX, + PERMISSION_SQL_TRUE +} from '../consts.ts'; +import { actionMatches } from '../match.ts'; +import type { ExprIR, PolicyIR, QueryCompiler, QueryPlan, SubjectRef } from '../types.ts'; + +export interface SqlCompileResult { + readonly sql: string; + readonly params: Record; +} + +export interface SqlRelationCompilerInput { + readonly relation: string; + readonly resourceAlias: string; + readonly actor: SubjectRef; + param(value: unknown): string; +} + +export type SqlRelationCompiler = (input: SqlRelationCompilerInput) => string | undefined; + +export interface CreateSqlCompilerOptions { + readonly resourceAlias?: string; + readonly relation?: SqlRelationCompiler; + readonly columnName?: (path: string) => string; +} + +function defaultColumnName(path: string): string { + return path.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`); +} + +function relevantPolicies( + policies: readonly PolicyIR[], + action: string, + resourceType: string +): PolicyIR[] { + return policies.filter((policy) => { + const resourceMatches = !policy.target.resource || policy.target.resource === resourceType; + return resourceMatches && actionMatches(policy.target.action, action); + }); +} + +export function createSqlCompiler(options: CreateSqlCompilerOptions = {}): QueryCompiler { + const alias = options.resourceAlias ?? PERMISSION_SQL_DEFAULT_RESOURCE_ALIAS; + const columnName = options.columnName ?? defaultColumnName; + + return { + target: PERMISSION_QUERY_TARGET_SQL, + compile(input): QueryPlan { + const policies = relevantPolicies(input.policies, input.action, input.resourceType); + const allowPolicies = policies.filter((policy) => policy.effect === PERMISSION_EFFECT_ALLOW); + const denyPolicies = policies.filter((policy) => policy.effect === PERMISSION_EFFECT_DENY); + const params: Record = {}; + let counter = 0; + const warnings: string[] = []; + const residualPolicies: PolicyIR[] = []; + + const param = (value: unknown): string => { + const key = `${PERMISSION_SQL_PARAM_PREFIX}${++counter}`; + params[key] = value; + return `:${key}`; + }; + + const compileExpr = (expr: ExprIR): string | undefined => { + switch (expr.op) { + case PERMISSION_EXPR_CONST: + if (typeof expr.value === 'boolean') { + return expr.value ? PERMISSION_SQL_TRUE : PERMISSION_SQL_FALSE; + } + return param(expr.value); + + case PERMISSION_EXPR_REF: + if (expr.root === PERMISSION_ROOT_RESOURCE) return `${alias}.${columnName(expr.path)}`; + if (expr.root === PERMISSION_ROOT_ACTOR) { + const value = expr.path + ? (input.actor as Record)[expr.path] + : input.actor; + return param(value); + } + if (expr.root === PERMISSION_ROOT_CONTEXT) { + const value = expr.path ? input.context?.[expr.path] : input.context; + return param(value); + } + return undefined; + + case PERMISSION_EXPR_EQ: + case PERMISSION_EXPR_NEQ: + case PERMISSION_EXPR_GT: + case PERMISSION_EXPR_GTE: + case PERMISSION_EXPR_LT: + case PERMISSION_EXPR_LTE: { + const left = compileExpr(expr.left); + const right = compileExpr(expr.right); + if (!left || !right) return undefined; + const op = + expr.op === PERMISSION_EXPR_EQ + ? '=' + : expr.op === PERMISSION_EXPR_NEQ + ? '<>' + : expr.op === PERMISSION_EXPR_GT + ? '>' + : expr.op === PERMISSION_EXPR_GTE + ? '>=' + : expr.op === PERMISSION_EXPR_LT + ? '<' + : '<='; + return `(${left} ${op} ${right})`; + } + + case PERMISSION_EXPR_AND: { + const parts = expr.args.map(compileExpr); + if (parts.some((part) => !part)) return undefined; + return `(${parts.join(PERMISSION_SQL_AND)})`; + } + + case PERMISSION_EXPR_OR: { + const parts = expr.args.map(compileExpr); + if (parts.some((part) => !part)) return undefined; + return `(${parts.join(PERMISSION_SQL_OR)})`; + } + + case PERMISSION_EXPR_NOT: { + const inner = compileExpr(expr.expr); + return inner ? `(NOT ${inner})` : undefined; + } + + case PERMISSION_EXPR_IN: { + const value = compileExpr(expr.value); + if (!value) return undefined; + if (expr.set.op === PERMISSION_EXPR_CONST && Array.isArray(expr.set.value)) { + const list = expr.set.value.map(param).join(', '); + return `(${value} IN (${list}))`; + } + const set = compileExpr(expr.set); + return set ? `(${value} IN ${set})` : undefined; + } + + case PERMISSION_EXPR_CONTAINS: { + const value = compileExpr(expr.value); + if (!value) return undefined; + if (expr.set.op === PERMISSION_EXPR_CONST && Array.isArray(expr.set.value)) { + const list = expr.set.value.map(param).join(', '); + return `(${value} IN (${list}))`; + } + return undefined; + } + + case PERMISSION_EXPR_REL: { + const sql = options.relation?.({ + relation: expr.path, + resourceAlias: alias, + actor: input.actor, + param + }); + return sql ? `(${sql})` : undefined; + } + + case PERMISSION_EXPR_EXISTS: + return undefined; + } + }; + + const allowSql: string[] = []; + for (const policy of allowPolicies) { + const compiled = compileExpr(policy.condition); + if (compiled) allowSql.push(compiled); + else residualPolicies.push(policy); + } + + const denySql: string[] = []; + for (const policy of denyPolicies) { + const compiled = compileExpr(policy.condition); + if (compiled) denySql.push(compiled); + else residualPolicies.push(policy); + } + + if (allowSql.length === 0 && residualPolicies.length > 0) { + return { + strategy: PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE, + target: PERMISSION_QUERY_TARGET_SQL, + residualPolicies, + warnings: [PERMISSION_ERROR_MSG_NO_SQL_ALLOW] + }; + } + + let sql = + allowSql.length > 0 ? `(${allowSql.join(PERMISSION_SQL_OR)})` : PERMISSION_SQL_FALSE; + if (denySql.length > 0) sql = `(${sql}) AND NOT (${denySql.join(PERMISSION_SQL_OR)})`; + + if (residualPolicies.length > 0) warnings.push(PERMISSION_ERROR_MSG_SQL_RESIDUAL); + + return { + strategy: + residualPolicies.length > 0 + ? PERMISSION_QUERY_STRATEGY_PARTIAL + : PERMISSION_QUERY_STRATEGY_COMPILED, + target: PERMISSION_QUERY_TARGET_SQL, + predicate: { sql, params } satisfies SqlCompileResult, + residualPolicies: residualPolicies.length ? residualPolicies : undefined, + warnings: warnings.length ? warnings : undefined + }; + } + }; +} diff --git a/src/libs/perm/consts.ts b/src/libs/perm/consts.ts new file mode 100644 index 0000000..da8d06c --- /dev/null +++ b/src/libs/perm/consts.ts @@ -0,0 +1,162 @@ +export const PERMISSION_EFFECT_ALLOW = 'allow'; +export const PERMISSION_EFFECT_DENY = 'deny'; +export const PERMISSION_EFFECT_INDETERMINATE = 'indeterminate'; +export const PERMISSION_EFFECT_NOT_APPLICABLE = 'not_applicable'; + +export const PERMISSION_EFFECTS = [ + PERMISSION_EFFECT_ALLOW, + PERMISSION_EFFECT_DENY, + PERMISSION_EFFECT_INDETERMINATE, + PERMISSION_EFFECT_NOT_APPLICABLE +] as const; + +export const PERMISSION_FALLBACK_DENY = 'deny'; +export const PERMISSION_FALLBACK_ALLOW = 'allow'; + +export const PERMISSION_FALLBACKS = [PERMISSION_FALLBACK_DENY, PERMISSION_FALLBACK_ALLOW] as const; + +export const PERMISSION_EVAL_TRUE = true; +export const PERMISSION_EVAL_FALSE = false; +export const PERMISSION_EVAL_UNKNOWN = 'unknown'; +export const PERMISSION_EVAL_ERROR = 'error'; + +export const PERMISSION_DEP_ACTOR = 'actor'; +export const PERMISSION_DEP_RESOURCE = 'resource'; +export const PERMISSION_DEP_CONTEXT = 'context'; +export const PERMISSION_DEP_RELATION = 'relation'; + +export const PERMISSION_DEPENDENCY_KINDS = [ + PERMISSION_DEP_ACTOR, + PERMISSION_DEP_RESOURCE, + PERMISSION_DEP_CONTEXT, + PERMISSION_DEP_RELATION +] as const; + +export const PERMISSION_ROOT_ACTOR = 'actor'; +export const PERMISSION_ROOT_RESOURCE = 'resource'; +export const PERMISSION_ROOT_CONTEXT = 'context'; + +export const PERMISSION_REF_ROOTS = [ + PERMISSION_ROOT_ACTOR, + PERMISSION_ROOT_RESOURCE, + PERMISSION_ROOT_CONTEXT +] as const; + +export const PERMISSION_EXPR_CONST = 'const'; +export const PERMISSION_EXPR_REF = 'ref'; +export const PERMISSION_EXPR_EQ = 'eq'; +export const PERMISSION_EXPR_NEQ = 'neq'; +export const PERMISSION_EXPR_GT = 'gt'; +export const PERMISSION_EXPR_GTE = 'gte'; +export const PERMISSION_EXPR_LT = 'lt'; +export const PERMISSION_EXPR_LTE = 'lte'; +export const PERMISSION_EXPR_IN = 'in'; +export const PERMISSION_EXPR_CONTAINS = 'contains'; +export const PERMISSION_EXPR_AND = 'and'; +export const PERMISSION_EXPR_OR = 'or'; +export const PERMISSION_EXPR_NOT = 'not'; +export const PERMISSION_EXPR_REL = 'rel'; +export const PERMISSION_EXPR_EXISTS = 'exists'; + +export const PERMISSION_ACTION_WILDCARD_SUFFIX = '.*'; +export const PERMISSION_ACTION_SEPARATOR = '.'; +export const PERMISSION_PATH_SEPARATOR = '.'; +export const PERMISSION_RESOURCE_KEY_SEPARATOR = ':'; +export const PERMISSION_RESOURCE_KEY_NONE = 'none'; +export const PERMISSION_RESOURCE_KEY_UNKNOWN = 'unknown'; +export const PERMISSION_DEFAULT_SUBJECT_TYPE = 'user'; +export const PERMISSION_DEFAULT_RESOURCE_TYPE = 'resource'; + +export const PERMISSION_POLICY_DEFAULT_PRIORITY = 0; +export const PERMISSION_POLICY_AUTO_SEPARATOR = '.'; + +export const PERMISSION_DECISION_CODE_DENIED = 'permission_denied'; +export const PERMISSION_DECISION_CODE_SNAPSHOT_DENIED = 'snapshot_denied'; + +export const PERMISSION_OBLIGATION_MASK = 'mask'; +export const PERMISSION_OBLIGATION_REDACT = 'redact'; +export const PERMISSION_OBLIGATION_REQUIRE_MFA = 'require_mfa'; +export const PERMISSION_OBLIGATION_AUDIT = 'audit'; + +export const PERMISSION_MASK_MODE_FULL = 'full'; +export const PERMISSION_MASK_MODE_PARTIAL = 'partial'; + +export const PERMISSION_SEVERITY_LOW = 'low'; +export const PERMISSION_SEVERITY_MEDIUM = 'medium'; +export const PERMISSION_SEVERITY_HIGH = 'high'; + +export const PERMISSION_QUERY_STRATEGY_COMPILED = 'compiled'; +export const PERMISSION_QUERY_STRATEGY_PARTIAL = 'partial'; +export const PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE = 'not_compilable'; + +export const PERMISSION_QUERY_TARGET_MEMORY = 'memory'; +export const PERMISSION_QUERY_TARGET_SQL = 'sql'; + +export const PERMISSION_REVERSE_SOURCE_RELATION = 'relation'; +export const PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION = 'relationExpansion'; +export const PERMISSION_REVERSE_SOURCE_POLICY = 'policy'; + +export const PERMISSION_REVERSE_CONSTRAINT_CONTEXT = 'context'; +export const PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE = 'attribute'; +export const PERMISSION_REVERSE_CONSTRAINT_UNKNOWN = 'unknown'; + +export const PERMISSION_SQL_DEFAULT_RESOURCE_ALIAS = 'resource'; +export const PERMISSION_SQL_PARAM_PREFIX = 'p'; +export const PERMISSION_SQL_TRUE = 'TRUE'; +export const PERMISSION_SQL_FALSE = 'FALSE'; +export const PERMISSION_SQL_AND = ' AND '; +export const PERMISSION_SQL_OR = ' OR '; + +export const PERMISSION_ERROR_NAME_DENIED = 'PermissionDeniedError'; +export const PERMISSION_ERROR_NAME_SCHEMA = 'PermissionSchemaError'; +export const PERMISSION_ERROR_NAME_RUNTIME = 'PermissionRuntimeError'; + +export const PERMISSION_ERROR_MSG_DENIED_PREFIX = 'Permission denied: '; +export const PERMISSION_ERROR_MSG_SCHEMA_REQUIRES_RESOURCE = + 'permission schema requires at least one resource'; +export const PERMISSION_ERROR_MSG_RESOURCE_REQUIRES_ACTIONS_PREFIX = + 'resource requires actions[]: '; +export const PERMISSION_ERROR_MSG_RELATION_UNKNOWN_FROM_PREFIX = + 'relation points from unknown resource: '; +export const PERMISSION_ERROR_MSG_RELATION_UNKNOWN_TO_PREFIX = + 'relation points to unknown actor/resource: '; +export const PERMISSION_ERROR_MSG_EVALUATION_FAILED = 'Expression evaluation failed'; +export const PERMISSION_ERROR_MSG_COMPARE_PREFIX = 'Cannot compare values using '; +export const PERMISSION_ERROR_MSG_IN_REQUIRES_ARRAY = 'Right side of in() is not an array'; +export const PERMISSION_ERROR_MSG_CONTAINS_REQUIRES_ARRAY = 'contains() target is not an array'; +export const PERMISSION_ERROR_MSG_RELATION_REQUIRES_RESOURCE_PREFIX = + 'Relation requires a resource: '; +export const PERMISSION_ERROR_MSG_RELATION_REQUIRES_SUBJECT_PREFIX = + 'Relation requires a subject: '; +export const PERMISSION_ERROR_MSG_RELATION_PROVIDER_MISSING_PREFIX = + 'No relation provider configured for '; +export const PERMISSION_ERROR_MSG_RELATION_UNKNOWN_PREFIX = 'Relation is unknown: '; +export const PERMISSION_ERROR_MSG_EXISTS_REQUIRES_PROVIDER = + 'exists() requires a query-capable provider'; +export const PERMISSION_ERROR_MSG_EVALUATION_FAILED_DECISION = + 'Authorization policy evaluation failed'; +export const PERMISSION_ERROR_MSG_EVALUATION_UNKNOWN_DECISION = + 'Authorization policy evaluation is unknown'; +export const PERMISSION_ERROR_MSG_DENY_INDETERMINATE = 'Deny policy could not be evaluated safely'; +export const PERMISSION_ERROR_MSG_NOT_APPLICABLE = + 'No matching policy allowed or denied the request'; +export const PERMISSION_ERROR_MSG_FILTER_REQUIRES_ACTOR = + 'filter().for(actor) is required before this operation'; +export const PERMISSION_ERROR_MSG_NO_COMPILER_PREFIX = 'No compiler configured for target: '; +export const PERMISSION_ERROR_MSG_NO_SQL_ALLOW = 'No allow policy could be compiled to SQL.'; +export const PERMISSION_ERROR_MSG_SQL_RESIDUAL = + 'Some policies were not compilable and require residual checks.'; +export const PERMISSION_ERROR_MSG_REVERSE_NO_RELATION = + 'No invertible relation was found. The policy may depend only on attributes or custom predicates.'; +export const PERMISSION_ERROR_MSG_MFA_REQUIRED = 'MFA required'; +export const PERMISSION_ERROR_MSG_POLICY_UNKNOWN_RESOURCE_PREFIX = + 'policy targets unknown resource: '; +export const PERMISSION_ERROR_MSG_POLICY_UNKNOWN_ACTION_PREFIX = 'policy targets unknown action: '; + +export const permissionAutoPolicyId = (effect: string, action: string, index: number): string => + `${effect}${PERMISSION_POLICY_AUTO_SEPARATOR}${action}${PERMISSION_POLICY_AUTO_SEPARATOR}${index}`; + +export const permissionDependencyKey = (kind: string, key: string): string => `${kind}:${key}`; + +export const permissionDeniedByPolicyReason = (policy: string): string => + `Denied by policy ${policy}`; diff --git a/src/libs/perm/errors.ts b/src/libs/perm/errors.ts new file mode 100644 index 0000000..a1d4e59 --- /dev/null +++ b/src/libs/perm/errors.ts @@ -0,0 +1,48 @@ +import { + PERMISSION_EFFECT_DENY, + PERMISSION_ERROR_MSG_DENIED_PREFIX, + PERMISSION_ERROR_NAME_DENIED, + PERMISSION_ERROR_NAME_RUNTIME, + PERMISSION_ERROR_NAME_SCHEMA +} from './consts.ts'; +import type { PermissionDecision } from './types.ts'; + +export class PermissionDeniedError extends Error { + readonly decision: PermissionDecision; + + constructor(decision: PermissionDecision) { + super( + decision.effect === PERMISSION_EFFECT_DENY + ? decision.reason + : `${PERMISSION_ERROR_MSG_DENIED_PREFIX}${decision.effect}` + ); + this.name = PERMISSION_ERROR_NAME_DENIED; + this.decision = decision; + } +} + +export class PermissionSchemaError extends Error { + constructor(message: string) { + super(message); + this.name = PERMISSION_ERROR_NAME_SCHEMA; + } +} + +export class PermissionRuntimeError extends Error { + constructor(message: string) { + super(message); + this.name = PERMISSION_ERROR_NAME_RUNTIME; + } +} + +export function isPermissionDeniedError(error: unknown): error is PermissionDeniedError { + return error instanceof PermissionDeniedError; +} + +export function isPermissionSchemaError(error: unknown): error is PermissionSchemaError { + return error instanceof PermissionSchemaError; +} + +export function isPermissionRuntimeError(error: unknown): error is PermissionRuntimeError { + return error instanceof PermissionRuntimeError; +} diff --git a/src/libs/perm/evaluator.ts b/src/libs/perm/evaluator.ts new file mode 100644 index 0000000..63841dc --- /dev/null +++ b/src/libs/perm/evaluator.ts @@ -0,0 +1,333 @@ +import { + PERMISSION_DEP_RELATION, + PERMISSION_ERROR_MSG_COMPARE_PREFIX, + PERMISSION_ERROR_MSG_CONTAINS_REQUIRES_ARRAY, + PERMISSION_ERROR_MSG_EVALUATION_FAILED, + PERMISSION_ERROR_MSG_EXISTS_REQUIRES_PROVIDER, + PERMISSION_ERROR_MSG_IN_REQUIRES_ARRAY, + PERMISSION_ERROR_MSG_RELATION_PROVIDER_MISSING_PREFIX, + PERMISSION_ERROR_MSG_RELATION_REQUIRES_RESOURCE_PREFIX, + PERMISSION_ERROR_MSG_RELATION_REQUIRES_SUBJECT_PREFIX, + PERMISSION_ERROR_MSG_RELATION_UNKNOWN_PREFIX, + PERMISSION_EVAL_ERROR, + PERMISSION_EVAL_UNKNOWN, + PERMISSION_EXPR_AND, + PERMISSION_EXPR_CONST, + PERMISSION_EXPR_CONTAINS, + PERMISSION_EXPR_EQ, + PERMISSION_EXPR_EXISTS, + PERMISSION_EXPR_GT, + PERMISSION_EXPR_GTE, + PERMISSION_EXPR_IN, + PERMISSION_EXPR_LT, + PERMISSION_EXPR_LTE, + PERMISSION_EXPR_NEQ, + PERMISSION_EXPR_NOT, + PERMISSION_EXPR_OR, + PERMISSION_EXPR_REF, + PERMISSION_EXPR_REL, + PERMISSION_PATH_SEPARATOR, + PERMISSION_ROOT_ACTOR, + PERMISSION_ROOT_RESOURCE, + permissionDependencyKey +} from './consts.ts'; +import { getPath } from './path.ts'; +import type { + DependencyKey, + EvalResult, + EvalValue, + ExprIR, + PermissionProviders, + PermissionRequestContext, + ResourceRef, + SubjectRef +} from './types.ts'; + +function ok( + value: EvalValue, + dependencies: readonly DependencyKey[] = [], + reason?: string, + error?: unknown +): EvalResult { + const result: EvalResult = { value, dependencies }; + if (reason !== undefined) (result as { reason?: string }).reason = reason; + if (error !== undefined) (result as { error?: unknown }).error = error; + return result; +} + +function dep(kind: string, key: string): DependencyKey { + return permissionDependencyKey(kind, key) as DependencyKey; +} + +function dedupeDeps(deps: readonly DependencyKey[]): DependencyKey[] { + return [...new Set(deps)]; +} + +function compare( + op: ExprIR['op'], + left: unknown, + right: unknown +): boolean | typeof PERMISSION_EVAL_UNKNOWN { + switch (op) { + case PERMISSION_EXPR_EQ: + return left === right; + case PERMISSION_EXPR_NEQ: + return left !== right; + case PERMISSION_EXPR_GT: + return typeof left === 'number' && typeof right === 'number' + ? left > right + : PERMISSION_EVAL_UNKNOWN; + case PERMISSION_EXPR_GTE: + return typeof left === 'number' && typeof right === 'number' + ? left >= right + : PERMISSION_EVAL_UNKNOWN; + case PERMISSION_EXPR_LT: + return typeof left === 'number' && typeof right === 'number' + ? left < right + : PERMISSION_EVAL_UNKNOWN; + case PERMISSION_EXPR_LTE: + return typeof left === 'number' && typeof right === 'number' + ? left <= right + : PERMISSION_EVAL_UNKNOWN; + default: + return PERMISSION_EVAL_UNKNOWN; + } +} + +export class DefaultPermissionEvaluator { + constructor(private readonly providers: PermissionProviders = {}) {} + + async evaluate(expr: ExprIR, context: PermissionRequestContext): Promise { + try { + return await this.evaluateInternal(expr, context); + } catch (error) { + return ok(PERMISSION_EVAL_ERROR, [], PERMISSION_ERROR_MSG_EVALUATION_FAILED, error); + } + } + + private async rawValue( + expr: ExprIR, + context: PermissionRequestContext + ): Promise<{ value: unknown; dependencies: readonly DependencyKey[]; unknown?: string }> { + if (expr.op === PERMISSION_EXPR_CONST) return { value: expr.value, dependencies: [] }; + + if (expr.op === PERMISSION_EXPR_REF) { + const key = expr.path ? `${expr.root}${PERMISSION_PATH_SEPARATOR}${expr.path}` : expr.root; + const dependency = dep(expr.root, key); + if (this.providers.attributes) { + const provided = await this.providers.attributes.getAttribute({ + root: expr.root, + path: expr.path, + context + }); + return { value: provided, dependencies: [dependency] }; + } + + const root = + expr.root === PERMISSION_ROOT_ACTOR + ? context.actor + : expr.root === PERMISSION_ROOT_RESOURCE + ? context.resource + : context.context; + return { value: getPath(root, expr.path), dependencies: [dependency] }; + } + + if (expr.op === PERMISSION_EXPR_REL) { + const evaluated = await this.evaluateInternal(expr, context); + const out: { value: unknown; dependencies: readonly DependencyKey[]; unknown?: string } = { + value: evaluated.value === true, + dependencies: evaluated.dependencies + }; + if (evaluated.value === PERMISSION_EVAL_UNKNOWN && evaluated.reason !== undefined) { + out.unknown = evaluated.reason; + } + return out; + } + + const evaluated = await this.evaluateInternal(expr, context); + if (evaluated.value === PERMISSION_EVAL_UNKNOWN || evaluated.value === PERMISSION_EVAL_ERROR) { + return { + value: undefined, + dependencies: evaluated.dependencies, + unknown: evaluated.reason ?? String(evaluated.value) + }; + } + return { value: evaluated.value, dependencies: evaluated.dependencies }; + } + + private async evaluateInternal( + expr: ExprIR, + context: PermissionRequestContext + ): Promise { + switch (expr.op) { + case PERMISSION_EXPR_CONST: + return ok(Boolean(expr.value)); + + case PERMISSION_EXPR_REF: { + const { value, dependencies } = await this.rawValue(expr, context); + return ok(Boolean(value), dependencies); + } + + case PERMISSION_EXPR_EQ: + case PERMISSION_EXPR_NEQ: + case PERMISSION_EXPR_GT: + case PERMISSION_EXPR_GTE: + case PERMISSION_EXPR_LT: + case PERMISSION_EXPR_LTE: { + const left = await this.rawValue(expr.left, context); + const right = await this.rawValue(expr.right, context); + const dependencies = dedupeDeps([...left.dependencies, ...right.dependencies]); + if (left.unknown || right.unknown) { + return ok(PERMISSION_EVAL_UNKNOWN, dependencies, left.unknown ?? right.unknown); + } + const value = compare(expr.op, left.value, right.value); + return value === PERMISSION_EVAL_UNKNOWN + ? ok( + PERMISSION_EVAL_UNKNOWN, + dependencies, + `${PERMISSION_ERROR_MSG_COMPARE_PREFIX}${expr.op}` + ) + : ok(value, dependencies); + } + + case PERMISSION_EXPR_IN: { + const value = await this.rawValue(expr.value, context); + const set = await this.rawValue(expr.set, context); + const dependencies = dedupeDeps([...value.dependencies, ...set.dependencies]); + if (value.unknown || set.unknown) { + return ok(PERMISSION_EVAL_UNKNOWN, dependencies, value.unknown ?? set.unknown); + } + if (!Array.isArray(set.value)) { + return ok(PERMISSION_EVAL_UNKNOWN, dependencies, PERMISSION_ERROR_MSG_IN_REQUIRES_ARRAY); + } + return ok(set.value.includes(value.value), dependencies); + } + + case PERMISSION_EXPR_CONTAINS: { + const set = await this.rawValue(expr.set, context); + const value = await this.rawValue(expr.value, context); + const dependencies = dedupeDeps([...value.dependencies, ...set.dependencies]); + if (value.unknown || set.unknown) { + return ok(PERMISSION_EVAL_UNKNOWN, dependencies, value.unknown ?? set.unknown); + } + if (!Array.isArray(set.value)) { + return ok( + PERMISSION_EVAL_UNKNOWN, + dependencies, + PERMISSION_ERROR_MSG_CONTAINS_REQUIRES_ARRAY + ); + } + return ok(set.value.includes(value.value), dependencies); + } + + case PERMISSION_EXPR_AND: { + const dependencies: DependencyKey[] = []; + let unknownReason: string | undefined; + for (const arg of expr.args) { + const result = await this.evaluateInternal(arg, context); + dependencies.push(...result.dependencies); + if (result.value === false) return ok(false, dedupeDeps(dependencies)); + if (result.value === PERMISSION_EVAL_ERROR) { + return ok(PERMISSION_EVAL_ERROR, dedupeDeps(dependencies), result.reason, result.error); + } + if (result.value === PERMISSION_EVAL_UNKNOWN) unknownReason ??= result.reason; + } + return unknownReason + ? ok(PERMISSION_EVAL_UNKNOWN, dedupeDeps(dependencies), unknownReason) + : ok(true, dedupeDeps(dependencies)); + } + + case PERMISSION_EXPR_OR: { + const dependencies: DependencyKey[] = []; + let unknownReason: string | undefined; + for (const arg of expr.args) { + const result = await this.evaluateInternal(arg, context); + dependencies.push(...result.dependencies); + if (result.value === true) return ok(true, dedupeDeps(dependencies)); + if (result.value === PERMISSION_EVAL_ERROR) { + return ok(PERMISSION_EVAL_ERROR, dedupeDeps(dependencies), result.reason, result.error); + } + if (result.value === PERMISSION_EVAL_UNKNOWN) unknownReason ??= result.reason; + } + return unknownReason + ? ok(PERMISSION_EVAL_UNKNOWN, dedupeDeps(dependencies), unknownReason) + : ok(false, dedupeDeps(dependencies)); + } + + case PERMISSION_EXPR_NOT: { + const result = await this.evaluateInternal(expr.expr, context); + if (result.value === true) return ok(false, result.dependencies); + if (result.value === false) return ok(true, result.dependencies); + return result; + } + + case PERMISSION_EXPR_REL: { + const relationKey = dep(PERMISSION_DEP_RELATION, expr.path); + const resourceValue = expr.resource + ? await this.rawValue(expr.resource, context) + : { value: context.resource, dependencies: [] }; + const subjectValue = await this.rawValue(expr.subject, context); + const dependencies = dedupeDeps([ + relationKey, + ...resourceValue.dependencies, + ...subjectValue.dependencies + ]); + + if (resourceValue.unknown || subjectValue.unknown) { + return ok( + PERMISSION_EVAL_UNKNOWN, + dependencies, + resourceValue.unknown ?? subjectValue.unknown + ); + } + + const resource = resourceValue.value as ResourceRef | undefined; + const subject = subjectValue.value as SubjectRef | undefined; + + if (!resource || typeof resource !== 'object' || !resource.type) { + return ok( + PERMISSION_EVAL_UNKNOWN, + dependencies, + `${PERMISSION_ERROR_MSG_RELATION_REQUIRES_RESOURCE_PREFIX}${expr.path}` + ); + } + if (!subject || typeof subject !== 'object' || !subject.type || !subject.id) { + return ok( + PERMISSION_EVAL_UNKNOWN, + dependencies, + `${PERMISSION_ERROR_MSG_RELATION_REQUIRES_SUBJECT_PREFIX}${expr.path}` + ); + } + if (!this.providers.relations) { + return ok( + PERMISSION_EVAL_UNKNOWN, + dependencies, + `${PERMISSION_ERROR_MSG_RELATION_PROVIDER_MISSING_PREFIX}${expr.path}` + ); + } + + const relationResult = await this.providers.relations.hasRelation({ + relation: expr.path, + resource, + subject, + context + }); + if (relationResult === PERMISSION_EVAL_UNKNOWN) { + return ok( + PERMISSION_EVAL_UNKNOWN, + dependencies, + `${PERMISSION_ERROR_MSG_RELATION_UNKNOWN_PREFIX}${expr.path}` + ); + } + return ok(relationResult, dependencies); + } + + case PERMISSION_EXPR_EXISTS: + return ok( + PERMISSION_EVAL_UNKNOWN, + [dep(PERMISSION_DEP_RELATION, expr.relation)], + PERMISSION_ERROR_MSG_EXISTS_REQUIRES_PROVIDER + ); + } + } +} diff --git a/src/libs/perm/index.ts b/src/libs/perm/index.ts new file mode 100644 index 0000000..f80781a --- /dev/null +++ b/src/libs/perm/index.ts @@ -0,0 +1,12 @@ +export * from './consts.ts'; +export * from './types.ts'; +export * from './errors.ts'; +export * from './path.ts'; +export * from './schema.ts'; +export * from './builder.ts'; +export * from './match.ts'; +export * from './evaluator.ts'; +export * from './combiner.ts'; +export * from './reverse.ts'; +export * from './runtime.ts'; +export * from './compilers/sql.ts'; diff --git a/src/libs/perm/match.ts b/src/libs/perm/match.ts new file mode 100644 index 0000000..02603ef --- /dev/null +++ b/src/libs/perm/match.ts @@ -0,0 +1,28 @@ +import { PERMISSION_ACTION_WILDCARD_SUFFIX, PERMISSION_PATH_SEPARATOR } from './consts.ts'; +import { actionResource } from './schema.ts'; +import type { PermissionRequestContext, PolicyIR } from './types.ts'; + +export function actionMatches(pattern: string, action: string): boolean { + if (pattern === action) return true; + if (pattern.endsWith(PERMISSION_ACTION_WILDCARD_SUFFIX)) { + const prefix = pattern.slice(0, -1); + return action.startsWith(prefix); + } + return false; +} + +export function targetMatches(policy: PolicyIR, context: PermissionRequestContext): boolean { + if (!actionMatches(policy.target.action, context.action)) return false; + const policyResource = policy.target.resource ?? actionResource(policy.target.action); + const requestResource = context.resource?.type ?? actionResource(context.action); + if (!policyResource || !requestResource) return true; + return policyResource === requestResource; +} + +export function actionsForResource(resourceType: string, actions: readonly string[]): string[] { + return actions.map((action) => + action.includes(PERMISSION_PATH_SEPARATOR) + ? action + : `${resourceType}${PERMISSION_PATH_SEPARATOR}${action}` + ); +} diff --git a/src/libs/perm/path.ts b/src/libs/perm/path.ts new file mode 100644 index 0000000..d692975 --- /dev/null +++ b/src/libs/perm/path.ts @@ -0,0 +1,43 @@ +import type { Dict } from './types.ts'; +import { + PERMISSION_PATH_SEPARATOR, + PERMISSION_ROOT_ACTOR, + PERMISSION_ROOT_CONTEXT, + PERMISSION_ROOT_RESOURCE +} from './consts.ts'; + +export function getPath(input: unknown, path: string): unknown { + if (path === '' || path === '.') return input; + const parts = path.split(PERMISSION_PATH_SEPARATOR).filter(Boolean); + let current: unknown = input; + for (const part of parts) { + if (current == null || typeof current !== 'object') return undefined; + current = (current as Dict)[part]; + } + return current; +} + +export function setPath(input: Record, path: string, value: unknown): void { + const parts = path.split(PERMISSION_PATH_SEPARATOR).filter(Boolean); + if (parts.length === 0) return; + let current: Record = input; + for (const part of parts.slice(0, -1)) { + const next = current[part]; + if (!next || typeof next !== 'object') current[part] = {}; + current = current[part] as Record; + } + current[parts[parts.length - 1]!] = value; +} + +export function stripResourcePrefix(path: string): string { + const parts = path.split(PERMISSION_PATH_SEPARATOR).filter(Boolean); + if (parts.length <= 1) return path; + if ( + parts[0] === PERMISSION_ROOT_ACTOR || + parts[0] === PERMISSION_ROOT_RESOURCE || + parts[0] === PERMISSION_ROOT_CONTEXT + ) { + return parts.slice(1).join(PERMISSION_PATH_SEPARATOR); + } + return parts.slice(1).join(PERMISSION_PATH_SEPARATOR); +} diff --git a/src/libs/perm/reverse.ts b/src/libs/perm/reverse.ts new file mode 100644 index 0000000..2bd52cb --- /dev/null +++ b/src/libs/perm/reverse.ts @@ -0,0 +1,111 @@ +import { + PERMISSION_ERROR_MSG_REVERSE_NO_RELATION, + PERMISSION_EFFECT_ALLOW, + PERMISSION_EXPR_AND, + PERMISSION_EXPR_CONTAINS, + PERMISSION_EXPR_CONST, + PERMISSION_EXPR_EQ, + PERMISSION_EXPR_EXISTS, + PERMISSION_EXPR_GT, + PERMISSION_EXPR_GTE, + PERMISSION_EXPR_IN, + PERMISSION_EXPR_LT, + PERMISSION_EXPR_LTE, + PERMISSION_EXPR_NEQ, + PERMISSION_EXPR_NOT, + PERMISSION_EXPR_OR, + PERMISSION_EXPR_REF, + PERMISSION_EXPR_REL, + PERMISSION_PATH_SEPARATOR, + PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE, + PERMISSION_REVERSE_CONSTRAINT_CONTEXT, + PERMISSION_REVERSE_SOURCE_RELATION, + PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION, + PERMISSION_ROOT_CONTEXT +} from './consts.ts'; +import type { ExprIR, PolicyIR, ResourceRef, ReverseQueryResult } from './types.ts'; + +function collect( + expr: ExprIR, + result: ReverseQueryResult, + policy: PolicyIR, + resource: ResourceRef +): void { + switch (expr.op) { + case PERMISSION_EXPR_REL: + result.sources.push({ + type: expr.path.includes(PERMISSION_PATH_SEPARATOR) + ? PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION + : PERMISSION_REVERSE_SOURCE_RELATION, + path: expr.path, + resource + }); + return; + + case PERMISSION_EXPR_AND: + case PERMISSION_EXPR_OR: + for (const arg of expr.args) collect(arg, result, policy, resource); + return; + + case PERMISSION_EXPR_NOT: + collect(expr.expr, result, policy, resource); + return; + + case PERMISSION_EXPR_EQ: + case PERMISSION_EXPR_NEQ: + case PERMISSION_EXPR_GT: + case PERMISSION_EXPR_GTE: + case PERMISSION_EXPR_LT: + case PERMISSION_EXPR_LTE: + if (expr.left.op === PERMISSION_EXPR_REF && expr.right.op === PERMISSION_EXPR_CONST) { + if (expr.left.root === PERMISSION_ROOT_CONTEXT) { + result.constraints.push({ + type: PERMISSION_REVERSE_CONSTRAINT_CONTEXT, + path: expr.left.path, + required: expr.right.value, + policy: policy.id + }); + return; + } + result.constraints.push({ + type: PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE, + path: `${expr.left.root}${PERMISSION_PATH_SEPARATOR}${expr.left.path}`, + required: expr.right.value, + policy: policy.id + }); + } + return; + + case PERMISSION_EXPR_IN: + case PERMISSION_EXPR_CONTAINS: + case PERMISSION_EXPR_EXISTS: + case PERMISSION_EXPR_CONST: + case PERMISSION_EXPR_REF: + return; + } +} + +export function reversePolicies(input: { + readonly policies: readonly PolicyIR[]; + readonly action: string; + readonly resource: ResourceRef; + readonly subjectType: string; +}): ReverseQueryResult { + const result: ReverseQueryResult = { + action: input.action, + resource: input.resource, + subjectType: input.subjectType, + sources: [], + constraints: [], + warnings: [] + }; + + for (const policy of input.policies) { + if (policy.effect !== PERMISSION_EFFECT_ALLOW) continue; + collect(policy.condition, result, policy, input.resource); + } + + if (result.sources.length === 0) result.warnings.push(PERMISSION_ERROR_MSG_REVERSE_NO_RELATION); + + return result; +} diff --git a/src/libs/perm/runtime.ts b/src/libs/perm/runtime.ts new file mode 100644 index 0000000..39f75b5 --- /dev/null +++ b/src/libs/perm/runtime.ts @@ -0,0 +1,199 @@ +import { + PERMISSION_ERROR_MSG_FILTER_REQUIRES_ACTOR, + PERMISSION_ERROR_MSG_NO_COMPILER_PREFIX, + PERMISSION_ACTION_WILDCARD_SUFFIX, + PERMISSION_DEFAULT_RESOURCE_TYPE, + PERMISSION_DEFAULT_SUBJECT_TYPE, + PERMISSION_EFFECT_ALLOW, + PERMISSION_FALLBACK_DENY, + PERMISSION_QUERY_STRATEGY_COMPILED, + PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE, + PERMISSION_QUERY_TARGET_MEMORY +} from './consts.ts'; +import { PermissionDeniedError } from './errors.ts'; +import { DefaultPermissionEvaluator } from './evaluator.ts'; +import { combineEvaluatedPolicies, toTrace, type EvaluatedPolicy } from './combiner.ts'; +import { actionsForResource, targetMatches } from './match.ts'; +import { reversePolicies } from './reverse.ts'; +import { actionResource } from './schema.ts'; +import type { + DependencyKey, + ExplainResult, + PermissionCheckInput, + PermissionFilterBuilder, + PermissionRequestContext, + PermissionRuntime, + PermissionRuntimeOptions, + PermissionWhatResult, + QueryPlan, + ResourceRef, + ReverseQueryResult, + SubjectRef +} from './types.ts'; + +function dedupe(values: readonly T[]): T[] { + return [...new Set(values)]; +} + +function buildContext(input: PermissionCheckInput): PermissionRequestContext { + return { + actor: input.actor, + action: input.action, + resource: input.resource, + context: input.context, + requestId: input.requestId + }; +} + +export function createPermissionRuntime(options: PermissionRuntimeOptions): PermissionRuntime { + const evaluator = new DefaultPermissionEvaluator(options.providers); + const defaultFallback = options.defaultFallback ?? PERMISSION_FALLBACK_DENY; + + async function evaluatePolicies(context: PermissionRequestContext): Promise { + const ordered = [...options.policies].sort((a, b) => b.priority - a.priority); + const entries: EvaluatedPolicy[] = []; + + for (const policy of ordered) { + const matched = targetMatches(policy, context); + if (!matched) { + entries.push({ policy, targetMatched: false }); + continue; + } + const evaluation = await evaluator.evaluate(policy.condition, context); + entries.push({ policy, targetMatched: true, evaluation }); + } + + return entries; + } + + async function explain(input: PermissionCheckInput): Promise { + const context = buildContext(input); + const evaluated = await evaluatePolicies(context); + const decision = combineEvaluatedPolicies(evaluated, defaultFallback); + const trace = toTrace(evaluated); + const dependencies = dedupe(trace.flatMap((entry) => entry.dependencies)) as DependencyKey[]; + + return { + actor: input.actor, + action: input.action, + resource: input.resource, + decision, + trace, + dependencies + }; + } + + async function check(input: PermissionCheckInput) { + return (await explain(input)).decision; + } + + async function can(input: PermissionCheckInput): Promise { + const decision = await check(input); + return decision.effect === PERMISSION_EFFECT_ALLOW; + } + + async function assertAllowed(input: PermissionCheckInput): Promise { + const decision = await check(input); + if (decision.effect !== PERMISSION_EFFECT_ALLOW) throw new PermissionDeniedError(decision); + } + + async function what( + input: Omit & { readonly actions?: readonly string[] } + ): Promise { + const resourceType = input.resource?.type; + const actions = + input.actions ?? + (resourceType && options.schema.resources[resourceType] + ? actionsForResource(resourceType, options.schema.resources[resourceType]!.actions) + : dedupe( + options.policies + .map((policy) => policy.target.action) + .filter((action) => !action.endsWith(PERMISSION_ACTION_WILDCARD_SUFFIX)) + )); + + const decisions: PermissionWhatResult['actions'] = {}; + for (const action of actions) decisions[action] = await check({ ...input, action }); + return { resource: input.resource, actions: decisions }; + } + + async function who( + input: PermissionCheckInput & { readonly resource: ResourceRef; readonly subjectType?: string } + ): Promise { + const context = buildContext(input); + const matchingPolicies = options.policies.filter((policy) => targetMatches(policy, context)); + return reversePolicies({ + policies: matchingPolicies, + action: input.action, + resource: input.resource, + subjectType: input.subjectType ?? PERMISSION_DEFAULT_SUBJECT_TYPE + }); + } + + function filter(action: string): PermissionFilterBuilder { + let actor: SubjectRef | undefined; + let resourceType = actionResource(action) ?? PERMISSION_DEFAULT_RESOURCE_TYPE; + let filterContext: Record | undefined; + + const builder: PermissionFilterBuilder = { + for(nextActor: SubjectRef) { + actor = nextActor; + return builder; + }, + + resource(nextResourceType: string) { + resourceType = nextResourceType; + return builder; + }, + + context(nextContext: Record) { + filterContext = nextContext; + return builder; + }, + + async toPlan(target = PERMISSION_QUERY_TARGET_MEMORY): Promise { + if (!actor) throw new Error(PERMISSION_ERROR_MSG_FILTER_REQUIRES_ACTOR); + if (target === PERMISSION_QUERY_TARGET_MEMORY) { + return { + strategy: PERMISSION_QUERY_STRATEGY_COMPILED, + target: PERMISSION_QUERY_TARGET_MEMORY, + predicate: PERMISSION_QUERY_TARGET_MEMORY + }; + } + const compiler = options.compilers?.find((candidate) => candidate.target === target); + if (!compiler) { + return { + strategy: PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE, + target, + warnings: [`${PERMISSION_ERROR_MSG_NO_COMPILER_PREFIX}${target}`] + }; + } + return compiler.compile({ + schema: options.schema, + policies: options.policies, + action, + actor, + resourceType, + context: filterContext + }); + }, + + async toPredicate(): Promise<(resource: ResourceRef) => Promise> { + if (!actor) throw new Error(PERMISSION_ERROR_MSG_FILTER_REQUIRES_ACTOR); + return async (resource: ResourceRef) => + can({ actor: actor!, action, resource, context: filterContext }); + } + }; + + return builder; + } + + return { + check, + can, + assert: assertAllowed, + explain, + what, + who, + filter + }; +} diff --git a/src/libs/perm/schema.ts b/src/libs/perm/schema.ts new file mode 100644 index 0000000..f0fd234 --- /dev/null +++ b/src/libs/perm/schema.ts @@ -0,0 +1,57 @@ +import { + PERMISSION_ACTION_SEPARATOR, + PERMISSION_ERROR_MSG_RELATION_UNKNOWN_FROM_PREFIX, + PERMISSION_ERROR_MSG_RELATION_UNKNOWN_TO_PREFIX, + PERMISSION_ERROR_MSG_RESOURCE_REQUIRES_ACTIONS_PREFIX, + PERMISSION_ERROR_MSG_SCHEMA_REQUIRES_RESOURCE, + PERMISSION_RESOURCE_KEY_NONE, + PERMISSION_RESOURCE_KEY_SEPARATOR, + PERMISSION_RESOURCE_KEY_UNKNOWN +} from './consts.ts'; +import { PermissionSchemaError } from './errors.ts'; +import type { Action, PermSchema, ResourceRef } from './types.ts'; + +export function definePermSchema(schema: T): T { + validatePermSchema(schema); + return schema; +} + +export function validatePermSchema(schema: PermSchema): void { + if (!schema.resources || Object.keys(schema.resources).length === 0) { + throw new PermissionSchemaError(PERMISSION_ERROR_MSG_SCHEMA_REQUIRES_RESOURCE); + } + + for (const [resource, def] of Object.entries(schema.resources)) { + if (!Array.isArray(def.actions) || def.actions.length === 0) { + throw new PermissionSchemaError( + `${PERMISSION_ERROR_MSG_RESOURCE_REQUIRES_ACTIONS_PREFIX}${resource}` + ); + } + } + + for (const [relation, def] of Object.entries(schema.relations ?? {})) { + if (!schema.resources[def.from]) { + throw new PermissionSchemaError( + `${PERMISSION_ERROR_MSG_RELATION_UNKNOWN_FROM_PREFIX}${relation}` + ); + } + const toIsResource = Boolean(schema.resources[def.to]); + const toIsActor = Boolean(schema.actors?.[def.to]); + if (!toIsResource && !toIsActor) { + throw new PermissionSchemaError( + `${PERMISSION_ERROR_MSG_RELATION_UNKNOWN_TO_PREFIX}${relation}` + ); + } + } +} + +export function actionResource(action: Action): string | undefined { + const [resource] = String(action).split(PERMISSION_ACTION_SEPARATOR); + return resource || undefined; +} + +export function resourceKey(resource?: ResourceRef | string): string { + if (!resource) return PERMISSION_RESOURCE_KEY_NONE; + if (typeof resource === 'string') return resource; + return `${resource.type}${PERMISSION_RESOURCE_KEY_SEPARATOR}${resource.id ?? PERMISSION_RESOURCE_KEY_UNKNOWN}`; +} diff --git a/src/libs/perm/types.ts b/src/libs/perm/types.ts new file mode 100644 index 0000000..3f10d46 --- /dev/null +++ b/src/libs/perm/types.ts @@ -0,0 +1,347 @@ +import type { + PERMISSION_DEPENDENCY_KINDS, + PERMISSION_EFFECT_ALLOW, + PERMISSION_EFFECT_DENY, + PERMISSION_EFFECT_INDETERMINATE, + PERMISSION_EFFECT_NOT_APPLICABLE, + PERMISSION_FALLBACK_ALLOW, + PERMISSION_FALLBACK_DENY, + PERMISSION_QUERY_STRATEGY_COMPILED, + PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE, + PERMISSION_QUERY_STRATEGY_PARTIAL, + PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE, + PERMISSION_REVERSE_CONSTRAINT_CONTEXT, + PERMISSION_REVERSE_CONSTRAINT_UNKNOWN, + PERMISSION_REVERSE_SOURCE_POLICY, + PERMISSION_REVERSE_SOURCE_RELATION, + PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION, + PERMISSION_ROOT_ACTOR, + PERMISSION_ROOT_CONTEXT, + PERMISSION_ROOT_RESOURCE, + PERMISSION_SEVERITY_HIGH, + PERMISSION_SEVERITY_LOW, + PERMISSION_SEVERITY_MEDIUM +} from './consts.ts'; + +export type Primitive = string | number | boolean | null; +export type JsonValue = Primitive | JsonValue[] | { readonly [key: string]: JsonValue }; +export type Dict = Record; + +export type ResourceType = string; +export type ActorType = string; +export type Action = `${string}.${string}` | `${string}.*` | string; + +export interface SubjectRef { + readonly type: ActorType; + readonly id: string; + readonly [key: string]: unknown; +} + +export interface ResourceRef { + readonly type: ResourceType; + readonly id?: string; + readonly [key: string]: unknown; +} + +export interface PermissionRequestContext { + readonly actor: SubjectRef; + readonly action: string; + readonly resource?: ResourceRef; + readonly context?: Dict; + readonly requestId?: string; +} + +export type PermissionEffect = + | typeof PERMISSION_EFFECT_ALLOW + | typeof PERMISSION_EFFECT_DENY + | typeof PERMISSION_EFFECT_INDETERMINATE + | typeof PERMISSION_EFFECT_NOT_APPLICABLE; + +export type PermissionFallback = typeof PERMISSION_FALLBACK_DENY | typeof PERMISSION_FALLBACK_ALLOW; + +export interface ObligationIR { + readonly type: string; + readonly field?: string; + readonly mode?: string; + readonly event?: string; + readonly severity?: + | typeof PERMISSION_SEVERITY_LOW + | typeof PERMISSION_SEVERITY_MEDIUM + | typeof PERMISSION_SEVERITY_HIGH; + readonly reason?: string; + readonly meta?: Dict; +} + +export interface AdviceIR { + readonly type: string; + readonly message?: string; + readonly meta?: Dict; +} + +export type PermissionDecision = + | { + readonly effect: typeof PERMISSION_EFFECT_ALLOW; + readonly policy: string; + readonly reason?: string; + readonly obligations?: readonly ObligationIR[]; + readonly advice?: readonly AdviceIR[]; + readonly ttl?: number; + } + | { + readonly effect: typeof PERMISSION_EFFECT_DENY; + readonly policy?: string; + readonly code: string; + readonly reason: string; + readonly advice?: readonly AdviceIR[]; + } + | { + readonly effect: typeof PERMISSION_EFFECT_INDETERMINATE; + readonly reason: string; + readonly fallback: PermissionFallback; + readonly policy?: string; + readonly errors?: readonly unknown[]; + } + | { + readonly effect: typeof PERMISSION_EFFECT_NOT_APPLICABLE; + readonly reason?: string; + }; + +export type PermissionRefRoot = + | typeof PERMISSION_ROOT_ACTOR + | typeof PERMISSION_ROOT_RESOURCE + | typeof PERMISSION_ROOT_CONTEXT; + +export type ExprIR = + | { readonly op: 'const'; readonly value: unknown } + | { readonly op: 'ref'; readonly root: PermissionRefRoot; readonly path: string } + | { + readonly op: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte'; + readonly left: ExprIR; + readonly right: ExprIR; + } + | { readonly op: 'in'; readonly value: ExprIR; readonly set: ExprIR } + | { readonly op: 'contains'; readonly set: ExprIR; readonly value: ExprIR } + | { readonly op: 'and' | 'or'; readonly args: readonly ExprIR[] } + | { readonly op: 'not'; readonly expr: ExprIR } + | { + readonly op: 'rel'; + readonly path: string; + readonly subject: ExprIR; + readonly resource?: ExprIR; + } + | { readonly op: 'exists'; readonly relation: string; readonly where?: ExprIR }; + +export interface PolicyTargetIR { + readonly action: string; + readonly resource?: string; +} + +export interface PolicyIR { + readonly id: string; + readonly effect: typeof PERMISSION_EFFECT_ALLOW | typeof PERMISSION_EFFECT_DENY; + readonly priority: number; + readonly target: PolicyTargetIR; + readonly condition: ExprIR; + readonly reason?: string; + readonly code?: string; + readonly obligations?: readonly ObligationIR[]; + readonly advice?: readonly AdviceIR[]; + readonly metadata?: Dict; +} + +export type EvalValue = true | false | 'unknown' | 'error'; + +export interface EvalResult { + readonly value: EvalValue; + readonly reason?: string; + readonly error?: unknown; + readonly dependencies: readonly DependencyKey[]; +} + +export type DependencyKind = (typeof PERMISSION_DEPENDENCY_KINDS)[number]; +export type DependencyKey = `${DependencyKind}:${string}`; + +export interface TraceEntry { + readonly policy: string; + readonly effect: typeof PERMISSION_EFFECT_ALLOW | typeof PERMISSION_EFFECT_DENY; + readonly targetMatched: boolean; + readonly condition?: ExprIR; + readonly result?: EvalValue; + readonly reason?: string; + readonly dependencies: readonly DependencyKey[]; +} + +export interface ExplainResult { + readonly actor: SubjectRef; + readonly action: string; + readonly resource?: ResourceRef; + readonly decision: PermissionDecision; + readonly trace: readonly TraceEntry[]; + readonly dependencies: readonly DependencyKey[]; +} + +export interface ResourceSchema { + readonly actions: readonly string[]; + readonly attributes?: Dict; +} + +export interface ActorSchema { + readonly actions?: readonly string[]; + readonly attributes?: Dict; +} + +export interface RelationSchema { + readonly from: string; + readonly to: string; + readonly via?: string; + readonly table?: string; + readonly metadata?: Dict; +} + +export interface PermSchema { + readonly actors?: Record; + readonly resources: Record; + readonly relations?: Record; + readonly context?: Dict; +} + +export type QueryPlanStrategy = + | typeof PERMISSION_QUERY_STRATEGY_COMPILED + | typeof PERMISSION_QUERY_STRATEGY_PARTIAL + | typeof PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE; + +export interface QueryPlan { + readonly strategy: QueryPlanStrategy; + readonly target: string; + readonly predicate?: unknown; + readonly residualPolicies?: readonly PolicyIR[]; + readonly dependencies?: readonly DependencyKey[]; + readonly warnings?: readonly string[]; +} + +export type RelationResult = boolean | 'unknown'; + +export interface RelationProvider { + hasRelation(input: { + readonly relation: string; + readonly resource: ResourceRef; + readonly subject: SubjectRef; + readonly context: PermissionRequestContext; + }): Promise | RelationResult; + + listSubjects?(input: { + readonly relation: string; + readonly resource: ResourceRef; + readonly subjectType?: string; + readonly context: PermissionRequestContext; + }): Promise | readonly SubjectRef[]; + + listResources?(input: { + readonly relation: string; + readonly subject: SubjectRef; + readonly resourceType: string; + readonly context: PermissionRequestContext; + }): Promise | readonly ResourceRef[]; +} + +export interface AttributeProvider { + getAttribute(input: { + readonly root: PermissionRefRoot; + readonly path: string; + readonly context: PermissionRequestContext; + }): Promise | unknown; +} + +export interface SubscriptionProvider { + subscribe(dependencies: readonly DependencyKey[], cb: () => void): () => void; +} + +export interface PermissionProviders { + readonly attributes?: AttributeProvider; + readonly relations?: RelationProvider; + readonly subscriptions?: SubscriptionProvider; +} + +export interface QueryCompiler { + readonly target: string; + compile(input: { + readonly schema: PermSchema; + readonly policies: readonly PolicyIR[]; + readonly action: string; + readonly actor: SubjectRef; + readonly resourceType: string; + readonly context?: Record; + }): Promise | QueryPlan; +} + +export interface PermissionRuntimeOptions { + readonly schema: PermSchema; + readonly policies: readonly PolicyIR[]; + readonly providers?: PermissionProviders; + readonly compilers?: readonly QueryCompiler[]; + readonly defaultFallback?: PermissionFallback; +} + +export interface PermissionEvaluator { + evaluate(expr: ExprIR, context: PermissionRequestContext): Promise; +} + +export interface PermissionCheckInput { + readonly actor: SubjectRef; + readonly action: string; + readonly resource?: ResourceRef; + readonly context?: Record; + readonly requestId?: string; +} + +export interface PermissionWhatResult { + readonly resource?: ResourceRef; + readonly actions: Record; +} + +export interface ReverseQueryResult { + readonly action: string; + readonly resource: ResourceRef; + readonly subjectType: string; + readonly sources: Array<{ + readonly type: + | typeof PERMISSION_REVERSE_SOURCE_RELATION + | typeof PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION + | typeof PERMISSION_REVERSE_SOURCE_POLICY; + readonly path?: string; + readonly policy?: string; + readonly resource?: ResourceRef; + }>; + readonly constraints: Array<{ + readonly type: + | typeof PERMISSION_REVERSE_CONSTRAINT_CONTEXT + | typeof PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE + | typeof PERMISSION_REVERSE_CONSTRAINT_UNKNOWN; + readonly path?: string; + readonly required?: unknown; + readonly policy?: string; + }>; + readonly warnings: string[]; +} + +export interface PermissionFilterBuilder { + for(actor: SubjectRef): PermissionFilterBuilder; + resource(resourceType: string): PermissionFilterBuilder; + context(context: Record): PermissionFilterBuilder; + toPlan(target?: string): Promise; + toPredicate(): Promise<(resource: ResourceRef) => Promise>; +} + +export interface PermissionRuntime { + check(input: PermissionCheckInput): Promise; + can(input: PermissionCheckInput): Promise; + assert(input: PermissionCheckInput): Promise; + explain(input: PermissionCheckInput): Promise; + what( + input: Omit & { readonly actions?: readonly string[] } + ): Promise; + who( + input: PermissionCheckInput & { readonly resource: ResourceRef; readonly subjectType?: string } + ): Promise; + filter(action: string): PermissionFilterBuilder; +} diff --git a/src/libs/timers/backoff.ts b/src/libs/timers/backoff.ts new file mode 100644 index 0000000..155bfda --- /dev/null +++ b/src/libs/timers/backoff.ts @@ -0,0 +1,31 @@ +export const DEFAULT_BACKOFF_MIN_DELAY_MS = 500; +export const DEFAULT_BACKOFF_MAX_DELAY_MS = 15_000; +export const DEFAULT_BACKOFF_FACTOR = 1.8; +export const DEFAULT_BACKOFF_JITTER_MS = 500; + +export interface BackoffOptions { + readonly minDelayMs?: number; + readonly maxDelayMs?: number; + readonly factor?: number; + readonly jitterMs?: number; +} + +export function computeBackoffDelay( + attempt: number, + options: BackoffOptions = {}, + random: () => number = Math.random +): number { + const minDelayMs = options.minDelayMs ?? DEFAULT_BACKOFF_MIN_DELAY_MS; + const maxDelayMs = options.maxDelayMs ?? DEFAULT_BACKOFF_MAX_DELAY_MS; + const factor = options.factor ?? DEFAULT_BACKOFF_FACTOR; + const jitterMs = options.jitterMs ?? DEFAULT_BACKOFF_JITTER_MS; + + const safeAttempt = Number.isFinite(attempt) && attempt >= 0 ? attempt : 0; + const base = Math.min(maxDelayMs, minDelayMs * Math.pow(factor, safeAttempt)); + const jitter = jitterMs > 0 ? (random() * 2 - 1) * jitterMs : 0; + + const delay = base + jitter; + if (delay < 0) return 0; + if (delay > maxDelayMs) return maxDelayMs; + return delay; +} diff --git a/src/libs/timers/index.ts b/src/libs/timers/index.ts index 4add472..7e546c2 100644 --- a/src/libs/timers/index.ts +++ b/src/libs/timers/index.ts @@ -1 +1,2 @@ export * from './debounce.ts'; +export * from './backoff.ts'; diff --git a/src/perm-authz-ts/README.md b/src/perm-authz-ts/README.md new file mode 100644 index 0000000..e5046c9 --- /dev/null +++ b/src/perm-authz-ts/README.md @@ -0,0 +1,54 @@ +# perm-authz-ts + +Implementación base de un sistema de autorización tipado para aplicaciones TypeScript con dos runtimes: + +- `@perm/core`: tipos, schema, builders e IR. No depende de DB, DOM ni framework. +- `@perm/server`: runtime autoritativo para backend: `check`, `assert`, `explain`, `filter`, adapters y endpoints HTTP. +- `@perm/client`: cliente remoto para SPA/SSR: snapshot, cache, batch checks, invalidación. +- `@perm/svelte`: integración Svelte/SvelteKit: context, stores y componente ``. + +La regla de seguridad es: el servidor decide; el cliente refleja. El cliente sirve para UX y reactividad, no como frontera de seguridad. + +## Ejemplo rápido + +```ts +import { allow, and, attr, definePolicies, definePermSchema, rel, actor } from "@perm/core"; +import { createPermissions } from "@perm/server"; + +const schema = definePermSchema({ + resources: { + post: { actions: ["read", "update", "delete"] } + }, + actors: { + user: { actions: [] } + }, + relations: { + "post.owner": { from: "post", to: "user" } + } +}); + +const policies = definePolicies(schema, [ + allow("post.read").when(attr("post.visibility").eq("public")), + allow("post.update").when(rel("post.owner").is(actor())), + allow("post.delete").when(and(rel("post.owner").is(actor()), attr("actor.role").eq("admin"))) +]); + +const Perm = createPermissions({ + schema, + policies, + providers: { + relations: { + async hasRelation({ relation, resource, subject }) { + if (relation === "post.owner") return resource.ownerId === subject.id; + return "unknown"; + } + } + } +}); + +await Perm.assert({ actor: { type: "user", id: "u1" }, action: "post.update", resource: post }); +``` + +## Estado + +Es una primera implementación funcional de arquitectura, no una librería productiva cerrada. Incluye el core, el evaluador, el combinador deny-overrides, cliente remoto, integración Svelte y compilers base. Faltan adapters productivos para Prisma/Drizzle/OpenFGA/SpiceDB, persistencia de auditoría y hardening completo de seguridad. diff --git a/src/perm-authz-ts/examples/backend/src/permissions.ts b/src/perm-authz-ts/examples/backend/src/permissions.ts new file mode 100644 index 0000000..890e7b4 --- /dev/null +++ b/src/perm-authz-ts/examples/backend/src/permissions.ts @@ -0,0 +1,91 @@ +import { actor, allow, and, attr, audit, definePermSchema, definePolicies, deny, rel } from "@perm/core"; +import { createPermissions, createSqlCompiler } from "@perm/server"; + +export const schema = definePermSchema({ + actors: { + user: { attributes: { status: "string", role: "string" } } + }, + resources: { + post: { + actions: ["read", "update", "delete", "publish"], + attributes: { + visibility: "string", + ownerId: "string", + teamId: "string", + status: "string" + } + } + }, + relations: { + "post.owner": { from: "post", to: "user" }, + "post.team.member": { from: "post", to: "user" }, + "post.team.admin": { from: "post", to: "user" } + }, + context: { + risk: { mfa: "boolean" } + } +}); + +export const policies = definePolicies(schema, [ + deny("post.*") + .id("post.deny.suspended") + .priority(1000) + .when(attr("actor.status").eq("suspended")) + .because("Suspended users cannot access posts", "user_suspended"), + + allow("post.read") + .id("post.read.public-or-member") + .when( + // A minimal starter. In production, keep relations backed by DB/OpenFGA/SpiceDB. + attr("post.visibility").eq("public") + ), + + allow("post.update") + .id("post.update.owner") + .when( + and( + rel("post.owner").is(actor()), + attr("post.status").notEq("archived") + ) + ), + + allow("post.publish") + .id("post.publish.admin-with-mfa") + .when( + and( + rel("post.team.admin").has(actor()), + attr("context.risk.mfa").eq(true) + ) + ) + .oblige(audit("post.publish", "medium")) +]); + +export const Perm = createPermissions({ + schema, + policies, + providers: { + relations: { + async hasRelation({ relation, resource, subject }) { + if (relation === "post.owner") return resource.ownerId === subject.id; + if (relation === "post.team.admin") return subject.role === "admin"; + if (relation === "post.team.member") return Array.isArray(subject.teamIds) && subject.teamIds.includes(resource.teamId); + return "unknown"; + } + } + }, + compilers: [ + createSqlCompiler({ + resourceAlias: "post", + relation({ relation, resourceAlias, actor, param }) { + if (relation === "post.owner") return `${resourceAlias}.owner_id = ${param(actor.id)}`; + if (relation === "post.team.member") { + return `EXISTS (SELECT 1 FROM team_members tm WHERE tm.team_id = ${resourceAlias}.team_id AND tm.user_id = ${param(actor.id)})`; + } + if (relation === "post.team.admin") { + return `EXISTS (SELECT 1 FROM team_members tm WHERE tm.team_id = ${resourceAlias}.team_id AND tm.user_id = ${param(actor.id)} AND tm.role = 'admin')`; + } + return undefined; + } + }) + ] +}); diff --git a/src/perm-authz-ts/examples/backend/src/routes.ts b/src/perm-authz-ts/examples/backend/src/routes.ts new file mode 100644 index 0000000..debf93a --- /dev/null +++ b/src/perm-authz-ts/examples/backend/src/routes.ts @@ -0,0 +1,13 @@ +import { Perm } from "./permissions.js"; + +export async function updatePostRoute({ user, post, body }: { user: any; post: any; body: any }) { + await Perm.assert({ actor: user, action: "post.update", resource: post }); + // db.posts.update(post.id, body) + return { ok: true, updated: body }; +} + +export async function listPostsRoute({ user }: { user: any }) { + const plan = await Perm.filter("post.read").for(user).resource("post").toPlan("sql"); + // db.select().from(posts).where(plan.predicate.sql, plan.predicate.params) + return plan; +} diff --git a/src/perm-authz-ts/examples/svelte-spa/src/PostActions.svelte b/src/perm-authz-ts/examples/svelte-spa/src/PostActions.svelte new file mode 100644 index 0000000..a1057b5 --- /dev/null +++ b/src/perm-authz-ts/examples/svelte-spa/src/PostActions.svelte @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/src/perm-authz-ts/examples/svelte-spa/src/permissions.ts b/src/perm-authz-ts/examples/svelte-spa/src/permissions.ts new file mode 100644 index 0000000..b913503 --- /dev/null +++ b/src/perm-authz-ts/examples/svelte-spa/src/permissions.ts @@ -0,0 +1,6 @@ +import { createSveltePermissions } from "@perm/svelte"; + +export const perm = createSveltePermissions({ + endpoint: "/api/authz", + cacheTtlMs: 30_000 +}); diff --git a/src/perm-authz-ts/package.json b/src/perm-authz-ts/package.json new file mode 100644 index 0000000..b629cd9 --- /dev/null +++ b/src/perm-authz-ts/package.json @@ -0,0 +1,18 @@ +{ + "name": "perm-authz-ts-workspace", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Typed authorization core + server runtime + SPA/SSR clients.", + "workspaces": [ + "packages/*", + "examples/*" + ], + "scripts": { + "typecheck": "tsc -b packages/core packages/server packages/client packages/svelte", + "build": "tsc -b packages/core packages/server packages/client packages/svelte" + }, + "devDependencies": { + "typescript": "^5.5.0" + } +} diff --git a/src/perm-authz-ts/packages/client/dist/createPermissionClient.d.ts b/src/perm-authz-ts/packages/client/dist/createPermissionClient.d.ts new file mode 100644 index 0000000..50aaf61 --- /dev/null +++ b/src/perm-authz-ts/packages/client/dist/createPermissionClient.d.ts @@ -0,0 +1,3 @@ +import type { PermissionClient, PermissionClientOptions } from "./types.js"; +export declare function createPermissionClient(options: PermissionClientOptions): PermissionClient; +//# sourceMappingURL=createPermissionClient.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/client/dist/createPermissionClient.d.ts.map b/src/perm-authz-ts/packages/client/dist/createPermissionClient.d.ts.map new file mode 100644 index 0000000..c36360f --- /dev/null +++ b/src/perm-authz-ts/packages/client/dist/createPermissionClient.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"createPermissionClient.d.ts","sourceRoot":"","sources":["../src/createPermissionClient.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAsC,gBAAgB,EAAE,uBAAuB,EAAsB,MAAM,YAAY,CAAC;AA8BpI,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,uBAAuB,GAAG,gBAAgB,CA2IzF"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/client/dist/createPermissionClient.js b/src/perm-authz-ts/packages/client/dist/createPermissionClient.js new file mode 100644 index 0000000..833f14c --- /dev/null +++ b/src/perm-authz-ts/packages/client/dist/createPermissionClient.js @@ -0,0 +1,156 @@ +function now() { + return Date.now(); +} +function joinUrl(base, path) { + return `${base.replace(/\/$/, "")}/${path.replace(/^\//, "")}`; +} +async function postJson(fetcher, url, body) { + const response = await fetcher(url, { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify(body) + }); + if (!response.ok) { + throw new Error(`Authorization request failed: ${response.status} ${response.statusText}`); + } + return await response.json(); +} +export function createPermissionClient(options) { + const fetcher = options.fetcher ?? fetch.bind(globalThis); + const cacheTtlMs = options.cacheTtlMs ?? 30_000; + const cache = new Map(); + const listeners = new Set(); + let currentSnapshot = options.initialSnapshot ?? { decisions: {} }; + function emit() { + for (const listener of listeners) + listener(currentSnapshot); + } + function decisionKey(input) { + const resource = input.resource ? `${input.resource.type}:${input.resource.id ?? "none"}` : "global"; + const contextKey = input.context ? JSON.stringify(input.context) : ""; + return `${resource}:${input.action}:${contextKey}`; + } + function readSnapshotDecision(input) { + const key = decisionKey(input); + const direct = currentSnapshot.decisions?.[key]; + if (direct) + return direct; + const global = currentSnapshot.global?.[input.action]; + if (typeof global === "boolean") + return global ? { effect: "allow", policy: "snapshot.global" } : { effect: "deny", code: "snapshot_denied", reason: "Denied by snapshot" }; + return global; + } + function setCached(input, decision) { + const key = decisionKey(input); + cache.set(key, { decision, expiresAt: now() + cacheTtlMs }); + currentSnapshot = { + ...currentSnapshot, + decisions: { + ...(currentSnapshot.decisions ?? {}), + [key]: decision + } + }; + emit(); + } + async function check(input) { + const key = decisionKey(input); + const cached = cache.get(key); + if (cached && cached.expiresAt > now()) + return cached.decision; + const snapshotDecision = readSnapshotDecision(input); + if (snapshotDecision) { + cache.set(key, { decision: snapshotDecision, expiresAt: now() + cacheTtlMs }); + return snapshotDecision; + } + try { + const decision = await postJson(fetcher, joinUrl(options.endpoint, "/check"), input); + setCached(input, decision); + return decision; + } + catch (error) { + options.onError?.(error); + return { effect: "indeterminate", reason: "Remote authorization check failed", fallback: "deny", errors: [error] }; + } + } + async function can(input) { + return (await check(input)).effect === "allow"; + } + async function batch(input) { + try { + const result = await postJson(fetcher, joinUrl(options.endpoint, "/batch"), input); + for (const item of input.checks) { + const key = decisionKey(item); + const resourceKey = item.resource ? `${item.resource.type}:${item.resource.id ?? "none"}:${item.action}` : `resource:none:${item.action}`; + const decision = result.decisions[key] ?? result.decisions[resourceKey]; + if (decision) + setCached(item, decision); + } + return result.decisions; + } + catch (error) { + options.onError?.(error); + const decisions = {}; + for (const item of input.checks) { + decisions[decisionKey(item)] = { effect: "indeterminate", reason: "Remote batch authorization failed", fallback: "deny", errors: [error] }; + } + return decisions; + } + } + async function what(input) { + try { + const result = await postJson(fetcher, joinUrl(options.endpoint, "/what"), input); + for (const [action, decision] of Object.entries(result.actions)) { + setCached({ action, resource: input.resource, context: input.context }, decision); + } + return result.actions; + } + catch (error) { + options.onError?.(error); + return {}; + } + } + function hydrate(snapshot) { + currentSnapshot = snapshot; + cache.clear(); + for (const [key, decision] of Object.entries(snapshot.decisions ?? {})) { + cache.set(key, { decision, expiresAt: now() + cacheTtlMs }); + } + emit(); + } + function invalidate(scope) { + if (!scope) { + cache.clear(); + currentSnapshot = { ...currentSnapshot, decisions: {} }; + emit(); + return; + } + for (const key of [...cache.keys()]) { + if (key.includes(scope)) + cache.delete(key); + } + const decisions = { ...(currentSnapshot.decisions ?? {}) }; + for (const key of Object.keys(decisions)) { + if (key.includes(scope)) + delete decisions[key]; + } + currentSnapshot = { ...currentSnapshot, decisions }; + emit(); + } + return { + check, + can, + batch, + what, + hydrate, + snapshot: () => currentSnapshot, + invalidate, + subscribe(listener) { + listeners.add(listener); + listener(currentSnapshot); + return () => listeners.delete(listener); + }, + decisionKey + }; +} +//# sourceMappingURL=createPermissionClient.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/client/dist/createPermissionClient.js.map b/src/perm-authz-ts/packages/client/dist/createPermissionClient.js.map new file mode 100644 index 0000000..d5f2f1a --- /dev/null +++ b/src/perm-authz-ts/packages/client/dist/createPermissionClient.js.map @@ -0,0 +1 @@ +{"version":3,"file":"createPermissionClient.js","sourceRoot":"","sources":["../src/createPermissionClient.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/client/dist/index.d.ts b/src/perm-authz-ts/packages/client/dist/index.d.ts new file mode 100644 index 0000000..3a1e33c --- /dev/null +++ b/src/perm-authz-ts/packages/client/dist/index.d.ts @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./createPermissionClient.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/client/dist/index.d.ts.map b/src/perm-authz-ts/packages/client/dist/index.d.ts.map new file mode 100644 index 0000000..8777bf3 --- /dev/null +++ b/src/perm-authz-ts/packages/client/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,6BAA6B,CAAC"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/client/dist/index.js b/src/perm-authz-ts/packages/client/dist/index.js new file mode 100644 index 0000000..66f49f5 --- /dev/null +++ b/src/perm-authz-ts/packages/client/dist/index.js @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./createPermissionClient.js"; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/client/dist/index.js.map b/src/perm-authz-ts/packages/client/dist/index.js.map new file mode 100644 index 0000000..c0218f7 --- /dev/null +++ b/src/perm-authz-ts/packages/client/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,6BAA6B,CAAC"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/client/dist/types.d.ts b/src/perm-authz-ts/packages/client/dist/types.d.ts new file mode 100644 index 0000000..9b30356 --- /dev/null +++ b/src/perm-authz-ts/packages/client/dist/types.d.ts @@ -0,0 +1,39 @@ +import type { PermissionDecision, ResourceRef, SubjectRef } from "@perm/core"; +export interface PermissionSnapshot { + actor?: SubjectRef; + version?: string; + decisions?: Record; + global?: Record; + expiresAt?: string; +} +export interface PermissionClientOptions { + endpoint: string; + fetcher?: typeof fetch; + initialSnapshot?: PermissionSnapshot; + cacheTtlMs?: number; + onError?: (error: unknown) => void; +} +export interface ClientCheckInput { + action: string; + resource?: ResourceRef; + context?: Record; +} +export interface ClientBatchInput { + checks: ClientCheckInput[]; +} +export interface PermissionClient { + check(input: ClientCheckInput): Promise; + can(input: ClientCheckInput): Promise; + batch(input: ClientBatchInput): Promise>; + what(input: { + resource?: ResourceRef; + actions?: string[]; + context?: Record; + }): Promise>; + hydrate(snapshot: PermissionSnapshot): void; + snapshot(): PermissionSnapshot; + invalidate(scope?: string): void; + subscribe(listener: (snapshot: PermissionSnapshot) => void): () => void; + decisionKey(input: ClientCheckInput): string; +} +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/client/dist/types.d.ts.map b/src/perm-authz-ts/packages/client/dist/types.d.ts.map new file mode 100644 index 0000000..1b5c98f --- /dev/null +++ b/src/perm-authz-ts/packages/client/dist/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE9E,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,GAAG,kBAAkB,CAAC,CAAC;IACtD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB,eAAe,CAAC,EAAE,kBAAkB,CAAC;IACrC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,WAAW,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,gBAAgB,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAC5D,GAAG,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC/C,KAAK,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC,CAAC;IAC5E,IAAI,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,EAAE,WAAW,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC,CAAC;IAC5I,OAAO,CAAC,QAAQ,EAAE,kBAAkB,GAAG,IAAI,CAAC;IAC5C,QAAQ,IAAI,kBAAkB,CAAC;IAC/B,UAAU,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,SAAS,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,kBAAkB,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACxE,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAAC;CAC9C"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/client/dist/types.js b/src/perm-authz-ts/packages/client/dist/types.js new file mode 100644 index 0000000..718fd38 --- /dev/null +++ b/src/perm-authz-ts/packages/client/dist/types.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/client/dist/types.js.map b/src/perm-authz-ts/packages/client/dist/types.js.map new file mode 100644 index 0000000..c768b79 --- /dev/null +++ b/src/perm-authz-ts/packages/client/dist/types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/client/package.json b/src/perm-authz-ts/packages/client/package.json new file mode 100644 index 0000000..e876a3d --- /dev/null +++ b/src/perm-authz-ts/packages/client/package.json @@ -0,0 +1,23 @@ +{ + "name": "@perm/client", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -b", + "typecheck": "tsc -b --pretty" + }, + "dependencies": { + "@perm/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.5.0" + } +} diff --git a/src/perm-authz-ts/packages/client/src/createPermissionClient.ts b/src/perm-authz-ts/packages/client/src/createPermissionClient.ts new file mode 100644 index 0000000..d053209 --- /dev/null +++ b/src/perm-authz-ts/packages/client/src/createPermissionClient.ts @@ -0,0 +1,171 @@ +import type { PermissionDecision } from "@perm/core"; +import type { ClientBatchInput, ClientCheckInput, PermissionClient, PermissionClientOptions, PermissionSnapshot } from "./types.js"; + +interface CacheEntry { + decision: PermissionDecision; + expiresAt: number; +} + +function now(): number { + return Date.now(); +} + +function joinUrl(base: string, path: string): string { + return `${base.replace(/\/$/, "")}/${path.replace(/^\//, "")}`; +} + +async function postJson(fetcher: typeof fetch, url: string, body: unknown): Promise { + const response = await fetcher(url, { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify(body) + }); + + if (!response.ok) { + throw new Error(`Authorization request failed: ${response.status} ${response.statusText}`); + } + + return await response.json() as T; +} + +export function createPermissionClient(options: PermissionClientOptions): PermissionClient { + const fetcher = options.fetcher ?? fetch.bind(globalThis); + const cacheTtlMs = options.cacheTtlMs ?? 30_000; + const cache = new Map(); + const listeners = new Set<(snapshot: PermissionSnapshot) => void>(); + let currentSnapshot: PermissionSnapshot = options.initialSnapshot ?? { decisions: {} }; + + function emit(): void { + for (const listener of listeners) listener(currentSnapshot); + } + + function decisionKey(input: ClientCheckInput): string { + const resource = input.resource ? `${input.resource.type}:${input.resource.id ?? "none"}` : "global"; + const contextKey = input.context ? JSON.stringify(input.context) : ""; + return `${resource}:${input.action}:${contextKey}`; + } + + function readSnapshotDecision(input: ClientCheckInput): PermissionDecision | undefined { + const key = decisionKey(input); + const direct = currentSnapshot.decisions?.[key]; + if (direct) return direct; + const global = currentSnapshot.global?.[input.action]; + if (typeof global === "boolean") return global ? { effect: "allow", policy: "snapshot.global" } : { effect: "deny", code: "snapshot_denied", reason: "Denied by snapshot" }; + return global; + } + + function setCached(input: ClientCheckInput, decision: PermissionDecision): void { + const key = decisionKey(input); + cache.set(key, { decision, expiresAt: now() + cacheTtlMs }); + currentSnapshot = { + ...currentSnapshot, + decisions: { + ...(currentSnapshot.decisions ?? {}), + [key]: decision + } + }; + emit(); + } + + async function check(input: ClientCheckInput): Promise { + const key = decisionKey(input); + const cached = cache.get(key); + if (cached && cached.expiresAt > now()) return cached.decision; + + const snapshotDecision = readSnapshotDecision(input); + if (snapshotDecision) { + cache.set(key, { decision: snapshotDecision, expiresAt: now() + cacheTtlMs }); + return snapshotDecision; + } + + try { + const decision = await postJson(fetcher, joinUrl(options.endpoint, "/check"), input); + setCached(input, decision); + return decision; + } catch (error) { + options.onError?.(error); + return { effect: "indeterminate", reason: "Remote authorization check failed", fallback: "deny", errors: [error] }; + } + } + + async function can(input: ClientCheckInput): Promise { + return (await check(input)).effect === "allow"; + } + + async function batch(input: ClientBatchInput): Promise> { + try { + const result = await postJson<{ decisions: Record }>(fetcher, joinUrl(options.endpoint, "/batch"), input); + for (const item of input.checks) { + const key = decisionKey(item); + const resourceKey = item.resource ? `${item.resource.type}:${item.resource.id ?? "none"}:${item.action}` : `resource:none:${item.action}`; + const decision = result.decisions[key] ?? result.decisions[resourceKey]; + if (decision) setCached(item, decision); + } + return result.decisions; + } catch (error) { + options.onError?.(error); + const decisions: Record = {}; + for (const item of input.checks) { + decisions[decisionKey(item)] = { effect: "indeterminate", reason: "Remote batch authorization failed", fallback: "deny", errors: [error] }; + } + return decisions; + } + } + + async function what(input: { resource?: ClientCheckInput["resource"]; actions?: string[]; context?: ClientCheckInput["context"] }): Promise> { + try { + const result = await postJson<{ actions: Record }>(fetcher, joinUrl(options.endpoint, "/what"), input); + for (const [action, decision] of Object.entries(result.actions)) { + setCached({ action, resource: input.resource, context: input.context }, decision); + } + return result.actions; + } catch (error) { + options.onError?.(error); + return {}; + } + } + + function hydrate(snapshot: PermissionSnapshot): void { + currentSnapshot = snapshot; + cache.clear(); + for (const [key, decision] of Object.entries(snapshot.decisions ?? {})) { + cache.set(key, { decision, expiresAt: now() + cacheTtlMs }); + } + emit(); + } + + function invalidate(scope?: string): void { + if (!scope) { + cache.clear(); + currentSnapshot = { ...currentSnapshot, decisions: {} }; + emit(); + return; + } + for (const key of [...cache.keys()]) { + if (key.includes(scope)) cache.delete(key); + } + const decisions = { ...(currentSnapshot.decisions ?? {}) }; + for (const key of Object.keys(decisions)) { + if (key.includes(scope)) delete decisions[key]; + } + currentSnapshot = { ...currentSnapshot, decisions }; + emit(); + } + + return { + check, + can, + batch, + what, + hydrate, + snapshot: () => currentSnapshot, + invalidate, + subscribe(listener) { + listeners.add(listener); + listener(currentSnapshot); + return () => listeners.delete(listener); + }, + decisionKey + }; +} diff --git a/src/perm-authz-ts/packages/client/src/index.ts b/src/perm-authz-ts/packages/client/src/index.ts new file mode 100644 index 0000000..a836eaa --- /dev/null +++ b/src/perm-authz-ts/packages/client/src/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js"; +export * from "./createPermissionClient.js"; diff --git a/src/perm-authz-ts/packages/client/src/types.ts b/src/perm-authz-ts/packages/client/src/types.ts new file mode 100644 index 0000000..11430a6 --- /dev/null +++ b/src/perm-authz-ts/packages/client/src/types.ts @@ -0,0 +1,39 @@ +import type { PermissionDecision, ResourceRef, SubjectRef } from "@perm/core"; + +export interface PermissionSnapshot { + actor?: SubjectRef; + version?: string; + decisions?: Record; + global?: Record; + expiresAt?: string; +} + +export interface PermissionClientOptions { + endpoint: string; + fetcher?: typeof fetch; + initialSnapshot?: PermissionSnapshot; + cacheTtlMs?: number; + onError?: (error: unknown) => void; +} + +export interface ClientCheckInput { + action: string; + resource?: ResourceRef; + context?: Record; +} + +export interface ClientBatchInput { + checks: ClientCheckInput[]; +} + +export interface PermissionClient { + check(input: ClientCheckInput): Promise; + can(input: ClientCheckInput): Promise; + batch(input: ClientBatchInput): Promise>; + what(input: { resource?: ResourceRef; actions?: string[]; context?: Record }): Promise>; + hydrate(snapshot: PermissionSnapshot): void; + snapshot(): PermissionSnapshot; + invalidate(scope?: string): void; + subscribe(listener: (snapshot: PermissionSnapshot) => void): () => void; + decisionKey(input: ClientCheckInput): string; +} diff --git a/src/perm-authz-ts/packages/client/tsconfig.json b/src/perm-authz-ts/packages/client/tsconfig.json new file mode 100644 index 0000000..decb378 --- /dev/null +++ b/src/perm-authz-ts/packages/client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "references": [ + { "path": "../core" } + ], + "include": ["src"] +} diff --git a/src/perm-authz-ts/packages/client/tsconfig.tsbuildinfo b/src/perm-authz-ts/packages/client/tsconfig.tsbuildinfo new file mode 100644 index 0000000..c4b6d50 --- /dev/null +++ b/src/perm-authz-ts/packages/client/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":["../../../../node_modules/typescript/lib/lib.es5.d.ts","../../../../node_modules/typescript/lib/lib.es2015.d.ts","../../../../node_modules/typescript/lib/lib.es2016.d.ts","../../../../node_modules/typescript/lib/lib.es2017.d.ts","../../../../node_modules/typescript/lib/lib.es2018.d.ts","../../../../node_modules/typescript/lib/lib.es2019.d.ts","../../../../node_modules/typescript/lib/lib.es2020.d.ts","../../../../node_modules/typescript/lib/lib.es2021.d.ts","../../../../node_modules/typescript/lib/lib.es2022.d.ts","../../../../node_modules/typescript/lib/lib.dom.d.ts","../../../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../../../node_modules/typescript/lib/lib.dom.asynciterable.d.ts","../../../../node_modules/typescript/lib/lib.webworker.importscripts.d.ts","../../../../node_modules/typescript/lib/lib.scripthost.d.ts","../../../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../../../node_modules/typescript/lib/lib.es2016.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../../../node_modules/typescript/lib/lib.decorators.d.ts","../../../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../../../node_modules/typescript/lib/lib.es2022.full.d.ts","../core/dist/types.d.ts","../core/dist/schema.d.ts","../core/dist/builder.d.ts","../core/dist/path.d.ts","../core/dist/index.d.ts","./src/types.ts","./src/createpermissionclient.ts","./src/index.ts"],"fileIdsList":[[68,69],[69,70],[68],[64],[64,65,66,67]],"fileInfos":[{"version":"bcd24271a113971ba9eb71ff8cb01bc6b0f872a85c23fdbe5d93065b375933cd","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f88bedbeb09c6f5a6645cb24c7c55f1aa22d19ae96c8e6959cbd8b85a707bc6","impliedFormat":1},{"version":"7fe93b39b810eadd916be8db880dd7f0f7012a5cc6ffb62de8f62a2117fa6f1f","impliedFormat":1},{"version":"bb0074cc08b84a2374af33d8bf044b80851ccc9e719a5e202eacf40db2c31600","impliedFormat":1},{"version":"1a7daebe4f45fb03d9ec53d60008fbf9ac45a697fdc89e4ce218bc94b94f94d6","impliedFormat":1},{"version":"f94b133a3cb14a288803be545ac2683e0d0ff6661bcd37e31aaaec54fc382aed","impliedFormat":1},{"version":"f59d0650799f8782fd74cf73c19223730c6d1b9198671b1c5b3a38e1188b5953","impliedFormat":1},{"version":"8a15b4607d9a499e2dbeed9ec0d3c0d7372c850b2d5f1fb259e8f6d41d468a84","impliedFormat":1},{"version":"26e0fe14baee4e127f4365d1ae0b276f400562e45e19e35fd2d4c296684715e6","impliedFormat":1},{"version":"d6b1eba8496bdd0eed6fc8a685768fe01b2da4a0388b5fe7df558290bffcf32f","affectsGlobalScope":true,"impliedFormat":1},{"version":"7f57fc4404ff020bc45b9c620aff2b40f700b95fe31164024c453a5e3c163c54","impliedFormat":1},{"version":"7f57fc4404ff020bc45b9c620aff2b40f700b95fe31164024c453a5e3c163c54","impliedFormat":1},{"version":"2a2de5b9459b3fc44decd9ce6100b72f1b002ef523126c1d3d8b2a4a63d74d78","affectsGlobalScope":true,"impliedFormat":1},{"version":"f13f4b465c99041e912db5c44129a94588e1aafee35a50eab51044833f50b4ee","affectsGlobalScope":true,"impliedFormat":1},{"version":"eadcffda2aa84802c73938e589b9e58248d74c59cb7fcbca6474e3435ac15504","affectsGlobalScope":true,"impliedFormat":1},{"version":"105ba8ff7ba746404fe1a2e189d1d3d2e0eb29a08c18dded791af02f29fb4711","affectsGlobalScope":true,"impliedFormat":1},{"version":"00343ca5b2e3d48fa5df1db6e32ea2a59afab09590274a6cccb1dbae82e60c7c","affectsGlobalScope":true,"impliedFormat":1},{"version":"ebd9f816d4002697cb2864bea1f0b70a103124e18a8cd9645eeccc09bdf80ab4","affectsGlobalScope":true,"impliedFormat":1},{"version":"2c1afac30a01772cd2a9a298a7ce7706b5892e447bb46bdbeef720f7b5da77ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"7b0225f483e4fa685625ebe43dd584bb7973bbd84e66a6ba7bbe175ee1048b4f","affectsGlobalScope":true,"impliedFormat":1},{"version":"c0a4b8ac6ce74679c1da2b3795296f5896e31c38e888469a8e0f99dc3305de60","affectsGlobalScope":true,"impliedFormat":1},{"version":"3084a7b5f569088e0146533a00830e206565de65cae2239509168b11434cd84f","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5079c53f0f141a0698faa903e76cb41cd664e3efb01cc17a5c46ec2eb0bef42","affectsGlobalScope":true,"impliedFormat":1},{"version":"32cafbc484dea6b0ab62cf8473182bbcb23020d70845b406f80b7526f38ae862","affectsGlobalScope":true,"impliedFormat":1},{"version":"fca4cdcb6d6c5ef18a869003d02c9f0fd95df8cfaf6eb431cd3376bc034cad36","affectsGlobalScope":true,"impliedFormat":1},{"version":"b93ec88115de9a9dc1b602291b85baf825c85666bf25985cc5f698073892b467","affectsGlobalScope":true,"impliedFormat":1},{"version":"f5c06dcc3fe849fcb297c247865a161f995cc29de7aa823afdd75aaaddc1419b","affectsGlobalScope":true,"impliedFormat":1},{"version":"b77e16112127a4b169ef0b8c3a4d730edf459c5f25fe52d5e436a6919206c4d7","affectsGlobalScope":true,"impliedFormat":1},{"version":"fbffd9337146eff822c7c00acbb78b01ea7ea23987f6c961eba689349e744f8c","affectsGlobalScope":true,"impliedFormat":1},{"version":"a995c0e49b721312f74fdfb89e4ba29bd9824c770bbb4021d74d2bf560e4c6bd","affectsGlobalScope":true,"impliedFormat":1},{"version":"c7b3542146734342e440a84b213384bfa188835537ddbda50d30766f0593aff9","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce6180fa19b1cccd07ee7f7dbb9a367ac19c0ed160573e4686425060b6df7f57","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f02e2476bccb9dbe21280d6090f0df17d2f66b74711489415a8aa4df73c9675","affectsGlobalScope":true,"impliedFormat":1},{"version":"45e3ab34c1c013c8ab2dc1ba4c80c780744b13b5676800ae2e3be27ae862c40c","affectsGlobalScope":true,"impliedFormat":1},{"version":"805c86f6cca8d7702a62a844856dbaa2a3fd2abef0536e65d48732441dde5b5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"e42e397f1a5a77994f0185fd1466520691456c772d06bf843e5084ceb879a0ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"f4c2b41f90c95b1c532ecc874bd3c111865793b23aebcc1c3cbbabcd5d76ffb0","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab26191cfad5b66afa11b8bf935ef1cd88fabfcb28d30b2dfa6fad877d050332","affectsGlobalScope":true,"impliedFormat":1},{"version":"2088bc26531e38fb05eedac2951480db5309f6be3fa4a08d2221abb0f5b4200d","affectsGlobalScope":true,"impliedFormat":1},{"version":"cb9d366c425fea79716a8fb3af0d78e6b22ebbab3bd64d25063b42dc9f531c1e","affectsGlobalScope":true,"impliedFormat":1},{"version":"500934a8089c26d57ebdb688fc9757389bb6207a3c8f0674d68efa900d2abb34","affectsGlobalScope":true,"impliedFormat":1},{"version":"689da16f46e647cef0d64b0def88910e818a5877ca5379ede156ca3afb780ac3","affectsGlobalScope":true,"impliedFormat":1},{"version":"bc21cc8b6fee4f4c2440d08035b7ea3c06b3511314c8bab6bef7a92de58a2593","affectsGlobalScope":true,"impliedFormat":1},{"version":"7ca53d13d2957003abb47922a71866ba7cb2068f8d154877c596d63c359fed25","affectsGlobalScope":true,"impliedFormat":1},{"version":"54725f8c4df3d900cb4dac84b64689ce29548da0b4e9b7c2de61d41c79293611","affectsGlobalScope":true,"impliedFormat":1},{"version":"e5594bc3076ac29e6c1ebda77939bc4c8833de72f654b6e376862c0473199323","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f3eb332c2d73e729f3364fcc0c2b375e72a121e8157d25a82d67a138c83a95c","affectsGlobalScope":true,"impliedFormat":1},{"version":"6f4427f9642ce8d500970e4e69d1397f64072ab73b97e476b4002a646ac743b1","affectsGlobalScope":true,"impliedFormat":1},{"version":"48915f327cd1dea4d7bd358d9dc7732f58f9e1626a29cc0c05c8c692419d9bb7","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7bf9377723203b5a6a4b920164df22d56a43f593269ba6ae1fdc97774b68855","affectsGlobalScope":true,"impliedFormat":1},{"version":"db9709688f82c9e5f65a119c64d835f906efe5f559d08b11642d56eb85b79357","affectsGlobalScope":true,"impliedFormat":1},{"version":"4b25b8c874acd1a4cf8444c3617e037d444d19080ac9f634b405583fd10ce1f7","affectsGlobalScope":true,"impliedFormat":1},{"version":"37be57d7c90cf1f8112ee2636a068d8fd181289f82b744160ec56a7dc158a9f5","affectsGlobalScope":true,"impliedFormat":1},{"version":"a917a49ac94cd26b754ab84e113369a75d1a47a710661d7cd25e961cc797065f","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d3261badeb7843d157ef3e6f5d1427d0eeb0af0cf9df84a62cfd29fd47ac86e","affectsGlobalScope":true,"impliedFormat":1},{"version":"195daca651dde22f2167ac0d0a05e215308119a3100f5e6268e8317d05a92526","affectsGlobalScope":true,"impliedFormat":1},{"version":"8b11e4285cd2bb164a4dc09248bdec69e9842517db4ca47c1ba913011e44ff2f","affectsGlobalScope":true,"impliedFormat":1},{"version":"0508571a52475e245b02bc50fa1394065a0a3d05277fbf5120c3784b85651799","affectsGlobalScope":true,"impliedFormat":1},{"version":"8f9af488f510c3015af3cc8c267a9e9d96c4dd38a1fdff0e11dc5a544711415b","affectsGlobalScope":true,"impliedFormat":1},{"version":"fc611fea8d30ea72c6bbfb599c9b4d393ce22e2f5bfef2172534781e7d138104","affectsGlobalScope":true,"impliedFormat":1},{"version":"1ce14b81c5cc821994aa8ec1d42b220dd41b27fcc06373bce3958af7421b77d4","affectsGlobalScope":true,"impliedFormat":1},{"version":"b3a048b3e9302ef9a34ef4ebb9aecfb28b66abb3bce577206a79fee559c230da","affectsGlobalScope":true,"impliedFormat":1},{"version":"01a30f9e8582b369075c0808df71121e6855cb06fd8d3d39511d9ebb66405205","impliedFormat":1},"a9637e1b2642463e77fc3b3b6b4d11524cf9f09a74758d5234de0ba5c28264e3","64603d3a21cace27a06a275c25b09db14ca4498fe121578865e29ad21914397e","504c2af83052182afaa2393a26b219324b1c5d044b64ed6c3e710936db12f62d","72cf94a2c467f63fe221dbad5f408b32b8281d3512d4bc02a3dd0fc675a96f55","a6a4f3dfe216ab809ad0c82b2154de582df3b7efd31f23d4f404d64dacb08be5",{"version":"45aeaefd12ff6bdb0b8efb31622baadb8a27ad1d77cfcb856cfa1333b0137f78","signature":"8055dc3560635b3a0f388d50b72be948028ddf3e467fc169d4941349c602b42c"},{"version":"6a4552721d40b964ba4acc7a8294d75e65f310cc19de6da9ef96463237821035","signature":"1702467e18b3d95640021754e24421d350c42095d6a2c635ea6f0eb717e721f1"},"fb88bf4a0dcf9d2a3b84fa64d3f88e1a3640bf39a09f8343c29aadffd75f0d3e"],"root":[[69,71]],"options":{"composite":true,"declaration":true,"declarationMap":true,"exactOptionalPropertyTypes":false,"module":99,"noUncheckedIndexedAccess":true,"outDir":"./dist","rootDir":"./src","skipLibCheck":true,"sourceMap":true,"strict":true,"target":9,"verbatimModuleSyntax":true},"referencedMap":[[70,1],[71,2],[69,3],[66,4],[68,5],[65,4]],"semanticDiagnosticsPerFile":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71],"latestChangedDtsFile":"./dist/index.d.ts","version":"6.0.3"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/builder.d.ts b/src/perm-authz-ts/packages/core/dist/builder.d.ts new file mode 100644 index 0000000..0065a53 --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/builder.d.ts @@ -0,0 +1,60 @@ +import type { AdviceIR, ExprIR, ObligationIR, PermSchema, PolicyIR } from "./types.js"; +type ExprInput = ExprBuilder | ExprIR | string | number | boolean | null | undefined | Record | unknown[]; +export declare class ExprBuilder { + readonly ir: ExprIR; + constructor(ir: ExprIR); + eq(value: ExprInput): ExprBuilder; + notEq(value: ExprInput): ExprBuilder; + gt(value: ExprInput): ExprBuilder; + gte(value: ExprInput): ExprBuilder; + lt(value: ExprInput): ExprBuilder; + lte(value: ExprInput): ExprBuilder; + in(set: ExprInput): ExprBuilder; + contains(value: ExprInput): ExprBuilder; + not(): ExprBuilder; +} +export declare class RelationBuilder { + private readonly path; + constructor(path: string); + has(subject: ExprInput): ExprBuilder; + is(subject: ExprInput): ExprBuilder; +} +export declare class PolicyBuilder { + private readonly policyEffect; + private readonly policyAction; + private conditionIR; + private policyId; + private policyPriority; + private policyReason; + private policyCode; + private policyObligations; + private policyAdvice; + private policyMetadata; + constructor(policyEffect: "allow" | "deny", policyAction: string); + id(id: string): this; + priority(priority: number): this; + when(expr: ExprInput): this; + because(reason: string, code?: string): this; + oblige(...obligations: ObligationIR[]): this; + advise(...advice: AdviceIR[]): this; + meta(metadata: Record): this; + build(): PolicyIR; +} +export declare function allow(action: string): PolicyBuilder; +export declare function deny(action: string): PolicyBuilder; +export declare function attr(path: string): ExprBuilder; +export declare function ctx(path: string): ExprBuilder; +export declare function actor(path?: string): ExprBuilder; +export declare function resource(path?: string): ExprBuilder; +export declare function val(value: unknown): ExprBuilder; +export declare function rel(path: string): RelationBuilder; +export declare function and(...args: ExprInput[]): ExprBuilder; +export declare function or(...args: ExprInput[]): ExprBuilder; +export declare function not(expr: ExprInput): ExprBuilder; +export declare function mask(field: string, mode?: "full" | "partial"): ObligationIR; +export declare function redact(field: string): ObligationIR; +export declare function requireMfa(reason?: string): ObligationIR; +export declare function audit(event: string, severity?: "low" | "medium" | "high"): ObligationIR; +export declare function definePolicies(_schema: PermSchema, policies: Array): PolicyIR[]; +export {}; +//# sourceMappingURL=builder.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/builder.d.ts.map b/src/perm-authz-ts/packages/core/dist/builder.d.ts.map new file mode 100644 index 0000000..f5d848a --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/builder.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/builder.js b/src/perm-authz-ts/packages/core/dist/builder.js new file mode 100644 index 0000000..d13d92f --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/builder.js @@ -0,0 +1,183 @@ +import { stripResourcePrefix } from "./path.js"; +let autoPolicyCounter = 0; +function toIR(input) { + if (input instanceof ExprBuilder) + return input.ir; + if (isExprIR(input)) + return input; + return { op: "const", value: input }; +} +function isExprIR(input) { + return Boolean(input && typeof input === "object" && "op" in input); +} +function refForAttribute(path) { + const parts = path.split(".").filter(Boolean); + if (parts[0] === "actor") + return { op: "ref", root: "actor", path: parts.slice(1).join(".") }; + if (parts[0] === "context") + return { op: "ref", root: "context", path: parts.slice(1).join(".") }; + if (parts[0] === "resource") + return { op: "ref", root: "resource", path: parts.slice(1).join(".") }; + return { op: "ref", root: "resource", path: stripResourcePrefix(path) }; +} +export class ExprBuilder { + ir; + constructor(ir) { + this.ir = ir; + } + eq(value) { + return new ExprBuilder({ op: "eq", left: this.ir, right: toIR(value) }); + } + notEq(value) { + return new ExprBuilder({ op: "neq", left: this.ir, right: toIR(value) }); + } + gt(value) { + return new ExprBuilder({ op: "gt", left: this.ir, right: toIR(value) }); + } + gte(value) { + return new ExprBuilder({ op: "gte", left: this.ir, right: toIR(value) }); + } + lt(value) { + return new ExprBuilder({ op: "lt", left: this.ir, right: toIR(value) }); + } + lte(value) { + return new ExprBuilder({ op: "lte", left: this.ir, right: toIR(value) }); + } + in(set) { + return new ExprBuilder({ op: "in", value: this.ir, set: toIR(set) }); + } + contains(value) { + return new ExprBuilder({ op: "contains", set: this.ir, value: toIR(value) }); + } + not() { + return new ExprBuilder({ op: "not", expr: this.ir }); + } +} +export class RelationBuilder { + path; + constructor(path) { + this.path = path; + } + has(subject) { + return new ExprBuilder({ op: "rel", path: this.path, subject: toIR(subject) }); + } + is(subject) { + return this.has(subject); + } +} +export class PolicyBuilder { + policyEffect; + policyAction; + conditionIR = { op: "const", value: true }; + policyId; + policyPriority = 0; + policyReason; + policyCode; + policyObligations = []; + policyAdvice = []; + policyMetadata; + constructor(policyEffect, policyAction) { + this.policyEffect = policyEffect; + this.policyAction = policyAction; + } + id(id) { + this.policyId = id; + return this; + } + priority(priority) { + this.policyPriority = priority; + return this; + } + when(expr) { + this.conditionIR = toIR(expr); + return this; + } + because(reason, code) { + this.policyReason = reason; + this.policyCode = code; + return this; + } + oblige(...obligations) { + this.policyObligations.push(...obligations); + return this; + } + advise(...advice) { + this.policyAdvice.push(...advice); + return this; + } + meta(metadata) { + this.policyMetadata = metadata; + return this; + } + build() { + const resource = this.policyAction.includes(".") ? this.policyAction.split(".")[0] : undefined; + const id = this.policyId ?? `${this.policyEffect}.${this.policyAction}.${++autoPolicyCounter}`; + const policy = { + id, + effect: this.policyEffect, + priority: this.policyPriority, + target: resource ? { action: this.policyAction, resource } : { action: this.policyAction }, + condition: this.conditionIR + }; + if (this.policyReason !== undefined) + policy.reason = this.policyReason; + if (this.policyCode !== undefined) + policy.code = this.policyCode; + if (this.policyObligations.length) + policy.obligations = this.policyObligations; + if (this.policyAdvice.length) + policy.advice = this.policyAdvice; + if (this.policyMetadata !== undefined) + policy.metadata = this.policyMetadata; + return policy; + } +} +export function allow(action) { + return new PolicyBuilder("allow", action); +} +export function deny(action) { + return new PolicyBuilder("deny", action); +} +export function attr(path) { + return new ExprBuilder(refForAttribute(path)); +} +export function ctx(path) { + return new ExprBuilder({ op: "ref", root: "context", path }); +} +export function actor(path = "") { + return new ExprBuilder({ op: "ref", root: "actor", path }); +} +export function resource(path = "") { + return new ExprBuilder({ op: "ref", root: "resource", path }); +} +export function val(value) { + return new ExprBuilder({ op: "const", value }); +} +export function rel(path) { + return new RelationBuilder(path); +} +export function and(...args) { + return new ExprBuilder({ op: "and", args: args.map(toIR) }); +} +export function or(...args) { + return new ExprBuilder({ op: "or", args: args.map(toIR) }); +} +export function not(expr) { + return new ExprBuilder({ op: "not", expr: toIR(expr) }); +} +export function mask(field, mode = "full") { + return { type: "mask", field, mode }; +} +export function redact(field) { + return { type: "redact", field }; +} +export function requireMfa(reason = "MFA required") { + return { type: "require_mfa", reason }; +} +export function audit(event, severity = "low") { + return { type: "audit", event, severity }; +} +export function definePolicies(_schema, policies) { + return policies.map((policy) => policy instanceof PolicyBuilder ? policy.build() : policy); +} +//# sourceMappingURL=builder.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/builder.js.map b/src/perm-authz-ts/packages/core/dist/builder.js.map new file mode 100644 index 0000000..8629f57 --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/builder.js.map @@ -0,0 +1 @@ +{"version":3,"file":"builder.js","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAEhD,IAAI,iBAAiB,GAAG,CAAC,CAAC;AAI1B,SAAS,IAAI,CAAC,KAAgB;IAC5B,IAAI,KAAK,YAAY,WAAW;QAAE,OAAO,KAAK,CAAC,EAAE,CAAC;IAClD,IAAI,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAClC,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;AACvC,CAAC;AAED,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,IAAI,IAAI,KAAK,CAAC,CAAC;AACtE,CAAC;AAED,SAAS,eAAe,CAAC,IAAY;IACnC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAC9C,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,OAAO;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;IAC9F,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,SAAS;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;IAClG,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,UAAU;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;IACpG,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,mBAAmB,CAAC,IAAI,CAAC,EAAE,CAAC;AAC1E,CAAC;AAED,MAAM,OAAO,WAAW;IACD;IAArB,YAAqB,EAAU;QAAV,OAAE,GAAF,EAAE,CAAQ;IAAG,CAAC;IAEnC,EAAE,CAAC,KAAgB;QACjB,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAC1E,CAAC;IAED,KAAK,CAAC,KAAgB;QACpB,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAC3E,CAAC;IAED,EAAE,CAAC,KAAgB;QACjB,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAC1E,CAAC;IAED,GAAG,CAAC,KAAgB;QAClB,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAC3E,CAAC;IAED,EAAE,CAAC,KAAgB;QACjB,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAC1E,CAAC;IAED,GAAG,CAAC,KAAgB;QAClB,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAC3E,CAAC;IAED,EAAE,CAAC,GAAc;QACf,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACvE,CAAC;IAED,QAAQ,CAAC,KAAgB;QACvB,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,UAAU,EAAE,GAAG,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAC/E,CAAC;IAED,GAAG;QACD,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC;IACvD,CAAC;CACF;AAED,MAAM,OAAO,eAAe;IACG;IAA7B,YAA6B,IAAY;QAAZ,SAAI,GAAJ,IAAI,CAAQ;IAAG,CAAC;IAE7C,GAAG,CAAC,OAAkB;QACpB,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IACjF,CAAC;IAED,EAAE,CAAC,OAAkB;QACnB,OAAO,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAC3B,CAAC;CACF;AAED,MAAM,OAAO,aAAa;IAUK;IAAiD;IATtE,WAAW,GAAW,EAAE,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IACnD,QAAQ,CAAqB;IAC7B,cAAc,GAAG,CAAC,CAAC;IACnB,YAAY,CAAqB;IACjC,UAAU,CAAqB;IAC/B,iBAAiB,GAAmB,EAAE,CAAC;IACvC,YAAY,GAAe,EAAE,CAAC;IAC9B,cAAc,CAAsC;IAE5D,YAA6B,YAA8B,EAAmB,YAAoB;QAArE,iBAAY,GAAZ,YAAY,CAAkB;QAAmB,iBAAY,GAAZ,YAAY,CAAQ;IAAG,CAAC;IAEtG,EAAE,CAAC,EAAU;QACX,IAAI,CAAC,QAAQ,GAAG,EAAE,CAAC;QACnB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,QAAQ,CAAC,QAAgB;QACvB,IAAI,CAAC,cAAc,GAAG,QAAQ,CAAC;QAC/B,OAAO,IAAI,CAAC;IACd,CAAC;IAED,IAAI,CAAC,IAAe;QAClB,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC;QAC9B,OAAO,IAAI,CAAC;IACd,CAAC;IAED,OAAO,CAAC,MAAc,EAAE,IAAa;QACnC,IAAI,CAAC,YAAY,GAAG,MAAM,CAAC;QAC3B,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC;QACvB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,CAAC,GAAG,WAA2B;QACnC,IAAI,CAAC,iBAAiB,CAAC,IAAI,CAAC,GAAG,WAAW,CAAC,CAAC;QAC5C,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,CAAC,GAAG,MAAkB;QAC1B,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,CAAC;QAClC,OAAO,IAAI,CAAC;IACd,CAAC;IAED,IAAI,CAAC,QAAiC;QACpC,IAAI,CAAC,cAAc,GAAG,QAAQ,CAAC;QAC/B,OAAO,IAAI,CAAC;IACd,CAAC;IAED,KAAK;QACH,MAAM,QAAQ,GAAG,IAAI,CAAC,YAAY,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;QAC/F,MAAM,EAAE,GAAG,IAAI,CAAC,QAAQ,IAAI,GAAG,IAAI,CAAC,YAAY,IAAI,IAAI,CAAC,YAAY,IAAI,EAAE,iBAAiB,EAAE,CAAC;QAE/F,MAAM,MAAM,GAAa;YACvB,EAAE;YACF,MAAM,EAAE,IAAI,CAAC,YAAY;YACzB,QAAQ,EAAE,IAAI,CAAC,cAAc;YAC7B,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE;YAC1F,SAAS,EAAE,IAAI,CAAC,WAAW;SAC5B,CAAC;QAEF,IAAI,IAAI,CAAC,YAAY,KAAK,SAAS;YAAE,MAAM,CAAC,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC;QACvE,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS;YAAE,MAAM,CAAC,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC;QACjE,IAAI,IAAI,CAAC,iBAAiB,CAAC,MAAM;YAAE,MAAM,CAAC,WAAW,GAAG,IAAI,CAAC,iBAAiB,CAAC;QAC/E,IAAI,IAAI,CAAC,YAAY,CAAC,MAAM;YAAE,MAAM,CAAC,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC;QAChE,IAAI,IAAI,CAAC,cAAc,KAAK,SAAS;YAAE,MAAM,CAAC,QAAQ,GAAG,IAAI,CAAC,cAAc,CAAC;QAE7E,OAAO,MAAM,CAAC;IAChB,CAAC;CACF;AAED,MAAM,UAAU,KAAK,CAAC,MAAc;IAClC,OAAO,IAAI,aAAa,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,IAAI,CAAC,MAAc;IACjC,OAAO,IAAI,aAAa,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAC3C,CAAC;AAED,MAAM,UAAU,IAAI,CAAC,IAAY;IAC/B,OAAO,IAAI,WAAW,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC;AAChD,CAAC;AAED,MAAM,UAAU,GAAG,CAAC,IAAY;IAC9B,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;AAC/D,CAAC;AAED,MAAM,UAAU,KAAK,CAAC,IAAI,GAAG,EAAE;IAC7B,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;AAC7D,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,IAAI,GAAG,EAAE;IAChC,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC;AAChE,CAAC;AAED,MAAM,UAAU,GAAG,CAAC,KAAc;IAChC,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;AACjD,CAAC;AAED,MAAM,UAAU,GAAG,CAAC,IAAY;IAC9B,OAAO,IAAI,eAAe,CAAC,IAAI,CAAC,CAAC;AACnC,CAAC;AAED,MAAM,UAAU,GAAG,CAAC,GAAG,IAAiB;IACtC,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AAC9D,CAAC;AAED,MAAM,UAAU,EAAE,CAAC,GAAG,IAAiB;IACrC,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AAC7D,CAAC;AAED,MAAM,UAAU,GAAG,CAAC,IAAe;IACjC,OAAO,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AAC1D,CAAC;AAED,MAAM,UAAU,IAAI,CAAC,KAAa,EAAE,OAA2B,MAAM;IACnE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;AACvC,CAAC;AAED,MAAM,UAAU,MAAM,CAAC,KAAa;IAClC,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;AACnC,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,MAAM,GAAG,cAAc;IAChD,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,MAAM,EAAE,CAAC;AACzC,CAAC;AAED,MAAM,UAAU,KAAK,CAAC,KAAa,EAAE,WAAsC,KAAK;IAC9E,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,OAAmB,EAAE,QAAyC;IAC3F,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,YAAY,aAAa,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;AAC7F,CAAC"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/index.d.ts b/src/perm-authz-ts/packages/core/dist/index.d.ts new file mode 100644 index 0000000..013bec8 --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/index.d.ts @@ -0,0 +1,5 @@ +export * from "./types.js"; +export * from "./schema.js"; +export * from "./builder.js"; +export * from "./path.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/index.d.ts.map b/src/perm-authz-ts/packages/core/dist/index.d.ts.map new file mode 100644 index 0000000..8dff8f8 --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/index.js b/src/perm-authz-ts/packages/core/dist/index.js new file mode 100644 index 0000000..8bc1fe8 --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/index.js @@ -0,0 +1,5 @@ +export * from "./types.js"; +export * from "./schema.js"; +export * from "./builder.js"; +export * from "./path.js"; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/index.js.map b/src/perm-authz-ts/packages/core/dist/index.js.map new file mode 100644 index 0000000..f5ff2e0 --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/path.d.ts b/src/perm-authz-ts/packages/core/dist/path.d.ts new file mode 100644 index 0000000..47b4cd5 --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/path.d.ts @@ -0,0 +1,4 @@ +export declare function getPath(input: unknown, path: string): unknown; +export declare function setPath(input: Record, path: string, value: unknown): void; +export declare function stripResourcePrefix(path: string): string; +//# sourceMappingURL=path.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/path.d.ts.map b/src/perm-authz-ts/packages/core/dist/path.d.ts.map new file mode 100644 index 0000000..2346e02 --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/path.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAEA,wBAAgB,OAAO,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAS7D;AAED,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAY1F;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOxD"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/path.js b/src/perm-authz-ts/packages/core/dist/path.js new file mode 100644 index 0000000..ee702ec --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/path.js @@ -0,0 +1,36 @@ +export function getPath(input, path) { + if (path === "" || path === ".") + return input; + const parts = path.split(".").filter(Boolean); + let current = input; + for (const part of parts) { + if (current == null || typeof current !== "object") + return undefined; + current = current[part]; + } + return current; +} +export function setPath(input, path, value) { + const parts = path.split(".").filter(Boolean); + if (parts.length === 0) + return; + let current = input; + for (const part of parts.slice(0, -1)) { + const next = current[part]; + if (!next || typeof next !== "object") { + current[part] = {}; + } + current = current[part]; + } + current[parts[parts.length - 1]] = value; +} +export function stripResourcePrefix(path) { + const parts = path.split(".").filter(Boolean); + if (parts.length <= 1) + return path; + if (parts[0] === "actor" || parts[0] === "resource" || parts[0] === "context") { + return parts.slice(1).join("."); + } + return parts.slice(1).join("."); +} +//# sourceMappingURL=path.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/path.js.map b/src/perm-authz-ts/packages/core/dist/path.js.map new file mode 100644 index 0000000..1080927 --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/path.js.map @@ -0,0 +1 @@ +{"version":3,"file":"path.js","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,OAAO,CAAC,KAAc,EAAE,IAAY;IAClD,IAAI,IAAI,KAAK,EAAE,IAAI,IAAI,KAAK,GAAG;QAAE,OAAO,KAAK,CAAC;IAC9C,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAC9C,IAAI,OAAO,GAAY,KAAK,CAAC;IAC7B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,OAAO,IAAI,IAAI,IAAI,OAAO,OAAO,KAAK,QAAQ;YAAE,OAAO,SAAS,CAAC;QACrE,OAAO,GAAI,OAAgB,CAAC,IAAI,CAAC,CAAC;IACpC,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,KAA8B,EAAE,IAAY,EAAE,KAAc;IAClF,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAC9C,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IAC/B,IAAI,OAAO,GAA4B,KAAK,CAAC;IAC7C,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACtC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC3B,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;YACtC,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;QACrB,CAAC;QACD,OAAO,GAAG,OAAO,CAAC,IAAI,CAA4B,CAAC;IACrD,CAAC;IACD,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAE,CAAC,GAAG,KAAK,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC9C,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAC9C,IAAI,KAAK,CAAC,MAAM,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACnC,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,OAAO,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,UAAU,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,SAAS,EAAE,CAAC;QAC9E,OAAO,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAClC,CAAC;IACD,OAAO,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAClC,CAAC"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/schema.d.ts b/src/perm-authz-ts/packages/core/dist/schema.d.ts new file mode 100644 index 0000000..30a92ca --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/schema.d.ts @@ -0,0 +1,6 @@ +import type { Action, PermSchema, ResourceRef } from "./types.js"; +export declare function definePermSchema(schema: T): T; +export declare function validateSchema(schema: PermSchema): void; +export declare function actionResource(action: Action): string | undefined; +export declare function resourceKey(resource?: ResourceRef | string): string; +//# sourceMappingURL=schema.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/schema.d.ts.map b/src/perm-authz-ts/packages/core/dist/schema.d.ts.map new file mode 100644 index 0000000..12a32bc --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/schema.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAElE,wBAAgB,gBAAgB,CAAC,KAAK,CAAC,CAAC,SAAS,UAAU,EAAE,MAAM,EAAE,CAAC,GAAG,CAAC,CAGzE;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAqBvD;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGjE;AAED,wBAAgB,WAAW,CAAC,QAAQ,CAAC,EAAE,WAAW,GAAG,MAAM,GAAG,MAAM,CAInE"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/schema.js b/src/perm-authz-ts/packages/core/dist/schema.js new file mode 100644 index 0000000..485c5dd --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/schema.js @@ -0,0 +1,36 @@ +export function definePermSchema(schema) { + validateSchema(schema); + return schema; +} +export function validateSchema(schema) { + if (!schema.resources || Object.keys(schema.resources).length === 0) { + throw new Error("perm schema requires at least one resource"); + } + for (const [resource, def] of Object.entries(schema.resources)) { + if (!Array.isArray(def.actions) || def.actions.length === 0) { + throw new Error(`resource ${resource} requires actions[]`); + } + } + for (const [relation, def] of Object.entries(schema.relations ?? {})) { + if (!schema.resources[def.from]) { + throw new Error(`relation ${relation} points from unknown resource ${def.from}`); + } + const toIsResource = Boolean(schema.resources[def.to]); + const toIsActor = Boolean(schema.actors?.[def.to]); + if (!toIsResource && !toIsActor) { + throw new Error(`relation ${relation} points to unknown actor/resource ${def.to}`); + } + } +} +export function actionResource(action) { + const [resource] = String(action).split("."); + return resource || undefined; +} +export function resourceKey(resource) { + if (!resource) + return "none"; + if (typeof resource === "string") + return resource; + return `${resource.type}:${resource.id ?? "unknown"}`; +} +//# sourceMappingURL=schema.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/schema.js.map b/src/perm-authz-ts/packages/core/dist/schema.js.map new file mode 100644 index 0000000..c58c1c9 --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/schema.js.map @@ -0,0 +1 @@ +{"version":3,"file":"schema.js","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,gBAAgB,CAA6B,MAAS;IACpE,cAAc,CAAC,MAAM,CAAC,CAAC;IACvB,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,MAAkB;IAC/C,IAAI,CAAC,MAAM,CAAC,SAAS,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACpE,MAAM,IAAI,KAAK,CAAC,4CAA4C,CAAC,CAAC;IAChE,CAAC;IAED,KAAK,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC;QAC/D,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,GAAG,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC5D,MAAM,IAAI,KAAK,CAAC,YAAY,QAAQ,qBAAqB,CAAC,CAAC;QAC7D,CAAC;IACH,CAAC;IAED,KAAK,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,IAAI,EAAE,CAAC,EAAE,CAAC;QACrE,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;YAChC,MAAM,IAAI,KAAK,CAAC,YAAY,QAAQ,iCAAiC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC;QACnF,CAAC;QACD,MAAM,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;QACvD,MAAM,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;QACnD,IAAI,CAAC,YAAY,IAAI,CAAC,SAAS,EAAE,CAAC;YAChC,MAAM,IAAI,KAAK,CAAC,YAAY,QAAQ,qCAAqC,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC;QACrF,CAAC;IACH,CAAC;AACH,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,MAAc;IAC3C,MAAM,CAAC,QAAQ,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC7C,OAAO,QAAQ,IAAI,SAAS,CAAC;AAC/B,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,QAA+B;IACzD,IAAI,CAAC,QAAQ;QAAE,OAAO,MAAM,CAAC;IAC7B,IAAI,OAAO,QAAQ,KAAK,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAClD,OAAO,GAAG,QAAQ,CAAC,IAAI,IAAI,QAAQ,CAAC,EAAE,IAAI,SAAS,EAAE,CAAC;AACxD,CAAC"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/types.d.ts b/src/perm-authz-ts/packages/core/dist/types.d.ts new file mode 100644 index 0000000..b4f1174 --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/types.d.ts @@ -0,0 +1,173 @@ +export type Primitive = string | number | boolean | null; +export type JsonValue = Primitive | JsonValue[] | { + [key: string]: JsonValue; +}; +export type Dict = Record; +export type ResourceType = string; +export type ActorType = string; +export type Action = `${string}.${string}` | `${string}.*` | string; +export interface SubjectRef { + type: ActorType; + id: string; + [key: string]: unknown; +} +export interface ResourceRef { + type: ResourceType; + id?: string; + [key: string]: unknown; +} +export interface RequestContext { + actor: SubjectRef; + action: string; + resource?: ResourceRef; + context?: Dict; + requestId?: string; +} +export type DecisionEffect = "allow" | "deny" | "indeterminate" | "not_applicable"; +export interface ObligationIR { + type: string; + field?: string; + mode?: string; + event?: string; + severity?: "low" | "medium" | "high"; + reason?: string; + meta?: Dict; +} +export interface AdviceIR { + type: string; + message?: string; + meta?: Dict; +} +export type PermissionDecision = { + effect: "allow"; + policy: string; + reason?: string; + obligations?: ObligationIR[]; + advice?: AdviceIR[]; + ttl?: number; +} | { + effect: "deny"; + policy?: string; + code: string; + reason: string; + advice?: AdviceIR[]; +} | { + effect: "indeterminate"; + reason: string; + fallback: "deny" | "allow"; + errors?: unknown[]; +} | { + effect: "not_applicable"; + reason?: string; +}; +export type ExprIR = { + op: "const"; + value: unknown; +} | { + op: "ref"; + root: "actor" | "resource" | "context"; + path: string; +} | { + op: "eq" | "neq" | "gt" | "gte" | "lt" | "lte"; + left: ExprIR; + right: ExprIR; +} | { + op: "in"; + value: ExprIR; + set: ExprIR; +} | { + op: "contains"; + set: ExprIR; + value: ExprIR; +} | { + op: "and" | "or"; + args: ExprIR[]; +} | { + op: "not"; + expr: ExprIR; +} | { + op: "rel"; + path: string; + subject: ExprIR; + resource?: ExprIR; +} | { + op: "exists"; + relation: string; + where?: ExprIR; +}; +export interface PolicyTargetIR { + action: string; + resource?: string; +} +export interface PolicyIR { + id: string; + effect: "allow" | "deny"; + priority: number; + target: PolicyTargetIR; + condition: ExprIR; + reason?: string; + code?: string; + obligations?: ObligationIR[]; + advice?: AdviceIR[]; + metadata?: Dict; +} +export interface TraceEntry { + policy: string; + effect: "allow" | "deny"; + targetMatched: boolean; + condition?: ExprIR; + result?: EvalValue; + reason?: string; + dependencies: DependencyKey[]; +} +export interface ExplainResult { + actor: SubjectRef; + action: string; + resource?: ResourceRef; + decision: PermissionDecision; + trace: TraceEntry[]; + dependencies: DependencyKey[]; +} +export type EvalValue = true | false | "unknown" | "error"; +export interface EvalResult { + value: EvalValue; + reason?: string; + error?: unknown; + dependencies: DependencyKey[]; +} +export type DependencyKind = "actor" | "resource" | "context" | "relation"; +export type DependencyKey = `${DependencyKind}:${string}`; +export interface ResourceSchema { + actions: readonly string[]; + attributes?: Dict; +} +export interface ActorSchema { + actions?: readonly string[]; + attributes?: Dict; +} +export interface RelationSchema { + from: string; + to: string; + via?: string; + table?: string; + metadata?: Dict; +} +export interface PermSchema { + actors?: Record; + resources: Record; + relations?: Record; + context?: Dict; +} +export interface QueryPlan { + strategy: "compiled" | "partial" | "not_compilable"; + target: string; + predicate?: unknown; + residualPolicies?: PolicyIR[]; + dependencies?: DependencyKey[]; + warnings?: string[]; +} +export declare class PermissionDeniedError extends Error { + readonly decision: PermissionDecision; + constructor(decision: PermissionDecision); +} +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/types.d.ts.map b/src/perm-authz-ts/packages/core/dist/types.d.ts.map new file mode 100644 index 0000000..f5a5097 --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/types.js b/src/perm-authz-ts/packages/core/dist/types.js new file mode 100644 index 0000000..adc4e86 --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/types.js @@ -0,0 +1,9 @@ +export class PermissionDeniedError extends Error { + decision; + constructor(decision) { + super(decision.effect === "deny" ? decision.reason : `Permission denied: ${decision.effect}`); + this.name = "PermissionDeniedError"; + this.decision = decision; + } +} +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/dist/types.js.map b/src/perm-authz-ts/packages/core/dist/types.js.map new file mode 100644 index 0000000..0f9d6bd --- /dev/null +++ b/src/perm-authz-ts/packages/core/dist/types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAwKA,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IACrC,QAAQ,CAAqB;IACtC,YAAY,QAA4B;QACtC,KAAK,CAAC,QAAQ,CAAC,MAAM,KAAK,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,sBAAsB,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;QAC9F,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;QACpC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;IAC3B,CAAC;CACF"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/core/package.json b/src/perm-authz-ts/packages/core/package.json new file mode 100644 index 0000000..91bcce4 --- /dev/null +++ b/src/perm-authz-ts/packages/core/package.json @@ -0,0 +1,20 @@ +{ + "name": "@perm/core", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -b", + "typecheck": "tsc -b --pretty" + }, + "devDependencies": { + "typescript": "^5.5.0" + } +} diff --git a/src/perm-authz-ts/packages/core/src/builder.ts b/src/perm-authz-ts/packages/core/src/builder.ts new file mode 100644 index 0000000..bd0aa0a --- /dev/null +++ b/src/perm-authz-ts/packages/core/src/builder.ts @@ -0,0 +1,210 @@ +import type { AdviceIR, ExprIR, ObligationIR, PermSchema, PolicyIR } from "./types.js"; +import { stripResourcePrefix } from "./path.js"; + +let autoPolicyCounter = 0; + +type ExprInput = ExprBuilder | ExprIR | string | number | boolean | null | undefined | Record | unknown[]; + +function toIR(input: ExprInput): ExprIR { + if (input instanceof ExprBuilder) return input.ir; + if (isExprIR(input)) return input; + return { op: "const", value: input }; +} + +function isExprIR(input: unknown): input is ExprIR { + return Boolean(input && typeof input === "object" && "op" in input); +} + +function refForAttribute(path: string): ExprIR { + const parts = path.split(".").filter(Boolean); + if (parts[0] === "actor") return { op: "ref", root: "actor", path: parts.slice(1).join(".") }; + if (parts[0] === "context") return { op: "ref", root: "context", path: parts.slice(1).join(".") }; + if (parts[0] === "resource") return { op: "ref", root: "resource", path: parts.slice(1).join(".") }; + return { op: "ref", root: "resource", path: stripResourcePrefix(path) }; +} + +export class ExprBuilder { + constructor(readonly ir: ExprIR) {} + + eq(value: ExprInput): ExprBuilder { + return new ExprBuilder({ op: "eq", left: this.ir, right: toIR(value) }); + } + + notEq(value: ExprInput): ExprBuilder { + return new ExprBuilder({ op: "neq", left: this.ir, right: toIR(value) }); + } + + gt(value: ExprInput): ExprBuilder { + return new ExprBuilder({ op: "gt", left: this.ir, right: toIR(value) }); + } + + gte(value: ExprInput): ExprBuilder { + return new ExprBuilder({ op: "gte", left: this.ir, right: toIR(value) }); + } + + lt(value: ExprInput): ExprBuilder { + return new ExprBuilder({ op: "lt", left: this.ir, right: toIR(value) }); + } + + lte(value: ExprInput): ExprBuilder { + return new ExprBuilder({ op: "lte", left: this.ir, right: toIR(value) }); + } + + in(set: ExprInput): ExprBuilder { + return new ExprBuilder({ op: "in", value: this.ir, set: toIR(set) }); + } + + contains(value: ExprInput): ExprBuilder { + return new ExprBuilder({ op: "contains", set: this.ir, value: toIR(value) }); + } + + not(): ExprBuilder { + return new ExprBuilder({ op: "not", expr: this.ir }); + } +} + +export class RelationBuilder { + constructor(private readonly path: string) {} + + has(subject: ExprInput): ExprBuilder { + return new ExprBuilder({ op: "rel", path: this.path, subject: toIR(subject) }); + } + + is(subject: ExprInput): ExprBuilder { + return this.has(subject); + } +} + +export class PolicyBuilder { + private conditionIR: ExprIR = { op: "const", value: true }; + private policyId: string | undefined; + private policyPriority = 0; + private policyReason: string | undefined; + private policyCode: string | undefined; + private policyObligations: ObligationIR[] = []; + private policyAdvice: AdviceIR[] = []; + private policyMetadata: Record | undefined; + + constructor(private readonly policyEffect: "allow" | "deny", private readonly policyAction: string) {} + + id(id: string): this { + this.policyId = id; + return this; + } + + priority(priority: number): this { + this.policyPriority = priority; + return this; + } + + when(expr: ExprInput): this { + this.conditionIR = toIR(expr); + return this; + } + + because(reason: string, code?: string): this { + this.policyReason = reason; + this.policyCode = code; + return this; + } + + oblige(...obligations: ObligationIR[]): this { + this.policyObligations.push(...obligations); + return this; + } + + advise(...advice: AdviceIR[]): this { + this.policyAdvice.push(...advice); + return this; + } + + meta(metadata: Record): this { + this.policyMetadata = metadata; + return this; + } + + build(): PolicyIR { + const resource = this.policyAction.includes(".") ? this.policyAction.split(".")[0] : undefined; + const id = this.policyId ?? `${this.policyEffect}.${this.policyAction}.${++autoPolicyCounter}`; + + const policy: PolicyIR = { + id, + effect: this.policyEffect, + priority: this.policyPriority, + target: resource ? { action: this.policyAction, resource } : { action: this.policyAction }, + condition: this.conditionIR + }; + + if (this.policyReason !== undefined) policy.reason = this.policyReason; + if (this.policyCode !== undefined) policy.code = this.policyCode; + if (this.policyObligations.length) policy.obligations = this.policyObligations; + if (this.policyAdvice.length) policy.advice = this.policyAdvice; + if (this.policyMetadata !== undefined) policy.metadata = this.policyMetadata; + + return policy; + } +} + +export function allow(action: string): PolicyBuilder { + return new PolicyBuilder("allow", action); +} + +export function deny(action: string): PolicyBuilder { + return new PolicyBuilder("deny", action); +} + +export function attr(path: string): ExprBuilder { + return new ExprBuilder(refForAttribute(path)); +} + +export function ctx(path: string): ExprBuilder { + return new ExprBuilder({ op: "ref", root: "context", path }); +} + +export function actor(path = ""): ExprBuilder { + return new ExprBuilder({ op: "ref", root: "actor", path }); +} + +export function resource(path = ""): ExprBuilder { + return new ExprBuilder({ op: "ref", root: "resource", path }); +} + +export function val(value: unknown): ExprBuilder { + return new ExprBuilder({ op: "const", value }); +} + +export function rel(path: string): RelationBuilder { + return new RelationBuilder(path); +} + +export function and(...args: ExprInput[]): ExprBuilder { + return new ExprBuilder({ op: "and", args: args.map(toIR) }); +} + +export function or(...args: ExprInput[]): ExprBuilder { + return new ExprBuilder({ op: "or", args: args.map(toIR) }); +} + +export function not(expr: ExprInput): ExprBuilder { + return new ExprBuilder({ op: "not", expr: toIR(expr) }); +} + +export function mask(field: string, mode: "full" | "partial" = "full"): ObligationIR { + return { type: "mask", field, mode }; +} + +export function redact(field: string): ObligationIR { + return { type: "redact", field }; +} + +export function requireMfa(reason = "MFA required"): ObligationIR { + return { type: "require_mfa", reason }; +} + +export function audit(event: string, severity: "low" | "medium" | "high" = "low"): ObligationIR { + return { type: "audit", event, severity }; +} + +export function definePolicies(_schema: PermSchema, policies: Array): PolicyIR[] { + return policies.map((policy) => policy instanceof PolicyBuilder ? policy.build() : policy); +} diff --git a/src/perm-authz-ts/packages/core/src/index.ts b/src/perm-authz-ts/packages/core/src/index.ts new file mode 100644 index 0000000..d23da43 --- /dev/null +++ b/src/perm-authz-ts/packages/core/src/index.ts @@ -0,0 +1,4 @@ +export * from "./types.js"; +export * from "./schema.js"; +export * from "./builder.js"; +export * from "./path.js"; diff --git a/src/perm-authz-ts/packages/core/src/path.ts b/src/perm-authz-ts/packages/core/src/path.ts new file mode 100644 index 0000000..4cd3fd2 --- /dev/null +++ b/src/perm-authz-ts/packages/core/src/path.ts @@ -0,0 +1,35 @@ +import type { Dict } from "./types.js"; + +export function getPath(input: unknown, path: string): unknown { + if (path === "" || path === ".") return input; + const parts = path.split(".").filter(Boolean); + let current: unknown = input; + for (const part of parts) { + if (current == null || typeof current !== "object") return undefined; + current = (current as Dict)[part]; + } + return current; +} + +export function setPath(input: Record, path: string, value: unknown): void { + const parts = path.split(".").filter(Boolean); + if (parts.length === 0) return; + let current: Record = input; + for (const part of parts.slice(0, -1)) { + const next = current[part]; + if (!next || typeof next !== "object") { + current[part] = {}; + } + current = current[part] as Record; + } + current[parts[parts.length - 1]!] = value; +} + +export function stripResourcePrefix(path: string): string { + const parts = path.split(".").filter(Boolean); + if (parts.length <= 1) return path; + if (parts[0] === "actor" || parts[0] === "resource" || parts[0] === "context") { + return parts.slice(1).join("."); + } + return parts.slice(1).join("."); +} diff --git a/src/perm-authz-ts/packages/core/src/schema.ts b/src/perm-authz-ts/packages/core/src/schema.ts new file mode 100644 index 0000000..791efbd --- /dev/null +++ b/src/perm-authz-ts/packages/core/src/schema.ts @@ -0,0 +1,40 @@ +import type { Action, PermSchema, ResourceRef } from "./types.js"; + +export function definePermSchema(schema: T): T { + validateSchema(schema); + return schema; +} + +export function validateSchema(schema: PermSchema): void { + if (!schema.resources || Object.keys(schema.resources).length === 0) { + throw new Error("perm schema requires at least one resource"); + } + + for (const [resource, def] of Object.entries(schema.resources)) { + if (!Array.isArray(def.actions) || def.actions.length === 0) { + throw new Error(`resource ${resource} requires actions[]`); + } + } + + for (const [relation, def] of Object.entries(schema.relations ?? {})) { + if (!schema.resources[def.from]) { + throw new Error(`relation ${relation} points from unknown resource ${def.from}`); + } + const toIsResource = Boolean(schema.resources[def.to]); + const toIsActor = Boolean(schema.actors?.[def.to]); + if (!toIsResource && !toIsActor) { + throw new Error(`relation ${relation} points to unknown actor/resource ${def.to}`); + } + } +} + +export function actionResource(action: Action): string | undefined { + const [resource] = String(action).split("."); + return resource || undefined; +} + +export function resourceKey(resource?: ResourceRef | string): string { + if (!resource) return "none"; + if (typeof resource === "string") return resource; + return `${resource.type}:${resource.id ?? "unknown"}`; +} diff --git a/src/perm-authz-ts/packages/core/src/types.ts b/src/perm-authz-ts/packages/core/src/types.ts new file mode 100644 index 0000000..811c39c --- /dev/null +++ b/src/perm-authz-ts/packages/core/src/types.ts @@ -0,0 +1,176 @@ +export type Primitive = string | number | boolean | null; +export type JsonValue = Primitive | JsonValue[] | { [key: string]: JsonValue }; + +export type Dict = Record; + +export type ResourceType = string; +export type ActorType = string; +export type Action = `${string}.${string}` | `${string}.*` | string; + +export interface SubjectRef { + type: ActorType; + id: string; + [key: string]: unknown; +} + +export interface ResourceRef { + type: ResourceType; + id?: string; + [key: string]: unknown; +} + +export interface RequestContext { + actor: SubjectRef; + action: string; + resource?: ResourceRef; + context?: Dict; + requestId?: string; +} + +export type DecisionEffect = "allow" | "deny" | "indeterminate" | "not_applicable"; + +export interface ObligationIR { + type: string; + field?: string; + mode?: string; + event?: string; + severity?: "low" | "medium" | "high"; + reason?: string; + meta?: Dict; +} + +export interface AdviceIR { + type: string; + message?: string; + meta?: Dict; +} + +export type PermissionDecision = + | { + effect: "allow"; + policy: string; + reason?: string; + obligations?: ObligationIR[]; + advice?: AdviceIR[]; + ttl?: number; + } + | { + effect: "deny"; + policy?: string; + code: string; + reason: string; + advice?: AdviceIR[]; + } + | { + effect: "indeterminate"; + reason: string; + fallback: "deny" | "allow"; + errors?: unknown[]; + } + | { + effect: "not_applicable"; + reason?: string; + }; + +export type ExprIR = + | { op: "const"; value: unknown } + | { op: "ref"; root: "actor" | "resource" | "context"; path: string } + | { op: "eq" | "neq" | "gt" | "gte" | "lt" | "lte"; left: ExprIR; right: ExprIR } + | { op: "in"; value: ExprIR; set: ExprIR } + | { op: "contains"; set: ExprIR; value: ExprIR } + | { op: "and" | "or"; args: ExprIR[] } + | { op: "not"; expr: ExprIR } + | { op: "rel"; path: string; subject: ExprIR; resource?: ExprIR } + | { op: "exists"; relation: string; where?: ExprIR }; + +export interface PolicyTargetIR { + action: string; + resource?: string; +} + +export interface PolicyIR { + id: string; + effect: "allow" | "deny"; + priority: number; + target: PolicyTargetIR; + condition: ExprIR; + reason?: string; + code?: string; + obligations?: ObligationIR[]; + advice?: AdviceIR[]; + metadata?: Dict; +} + +export interface TraceEntry { + policy: string; + effect: "allow" | "deny"; + targetMatched: boolean; + condition?: ExprIR; + result?: EvalValue; + reason?: string; + dependencies: DependencyKey[]; +} + +export interface ExplainResult { + actor: SubjectRef; + action: string; + resource?: ResourceRef; + decision: PermissionDecision; + trace: TraceEntry[]; + dependencies: DependencyKey[]; +} + +export type EvalValue = true | false | "unknown" | "error"; + +export interface EvalResult { + value: EvalValue; + reason?: string; + error?: unknown; + dependencies: DependencyKey[]; +} + +export type DependencyKind = "actor" | "resource" | "context" | "relation"; +export type DependencyKey = `${DependencyKind}:${string}`; + +export interface ResourceSchema { + actions: readonly string[]; + attributes?: Dict; +} + +export interface ActorSchema { + actions?: readonly string[]; + attributes?: Dict; +} + +export interface RelationSchema { + from: string; + to: string; + via?: string; + table?: string; + metadata?: Dict; +} + +export interface PermSchema { + actors?: Record; + resources: Record; + relations?: Record; + context?: Dict; +} + +export interface QueryPlan { + strategy: "compiled" | "partial" | "not_compilable"; + target: string; + predicate?: unknown; + residualPolicies?: PolicyIR[]; + dependencies?: DependencyKey[]; + warnings?: string[]; +} + +export class PermissionDeniedError extends Error { + readonly decision: PermissionDecision; + constructor(decision: PermissionDecision) { + super(decision.effect === "deny" ? decision.reason : `Permission denied: ${decision.effect}`); + this.name = "PermissionDeniedError"; + this.decision = decision; + } +} diff --git a/src/perm-authz-ts/packages/core/tsconfig.json b/src/perm-authz-ts/packages/core/tsconfig.json new file mode 100644 index 0000000..0496317 --- /dev/null +++ b/src/perm-authz-ts/packages/core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "include": ["src"] +} diff --git a/src/perm-authz-ts/packages/core/tsconfig.tsbuildinfo b/src/perm-authz-ts/packages/core/tsconfig.tsbuildinfo new file mode 100644 index 0000000..1572060 --- /dev/null +++ b/src/perm-authz-ts/packages/core/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":["../../../../node_modules/typescript/lib/lib.es5.d.ts","../../../../node_modules/typescript/lib/lib.es2015.d.ts","../../../../node_modules/typescript/lib/lib.es2016.d.ts","../../../../node_modules/typescript/lib/lib.es2017.d.ts","../../../../node_modules/typescript/lib/lib.es2018.d.ts","../../../../node_modules/typescript/lib/lib.es2019.d.ts","../../../../node_modules/typescript/lib/lib.es2020.d.ts","../../../../node_modules/typescript/lib/lib.es2021.d.ts","../../../../node_modules/typescript/lib/lib.es2022.d.ts","../../../../node_modules/typescript/lib/lib.dom.d.ts","../../../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../../../node_modules/typescript/lib/lib.dom.asynciterable.d.ts","../../../../node_modules/typescript/lib/lib.webworker.importscripts.d.ts","../../../../node_modules/typescript/lib/lib.scripthost.d.ts","../../../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../../../node_modules/typescript/lib/lib.es2016.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../../../node_modules/typescript/lib/lib.decorators.d.ts","../../../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../../../node_modules/typescript/lib/lib.es2022.full.d.ts","./src/types.ts","./src/path.ts","./src/builder.ts","./src/schema.ts","./src/index.ts"],"fileIdsList":[[64,65],[64,65,66,67],[64]],"fileInfos":[{"version":"bcd24271a113971ba9eb71ff8cb01bc6b0f872a85c23fdbe5d93065b375933cd","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f88bedbeb09c6f5a6645cb24c7c55f1aa22d19ae96c8e6959cbd8b85a707bc6","impliedFormat":1},{"version":"7fe93b39b810eadd916be8db880dd7f0f7012a5cc6ffb62de8f62a2117fa6f1f","impliedFormat":1},{"version":"bb0074cc08b84a2374af33d8bf044b80851ccc9e719a5e202eacf40db2c31600","impliedFormat":1},{"version":"1a7daebe4f45fb03d9ec53d60008fbf9ac45a697fdc89e4ce218bc94b94f94d6","impliedFormat":1},{"version":"f94b133a3cb14a288803be545ac2683e0d0ff6661bcd37e31aaaec54fc382aed","impliedFormat":1},{"version":"f59d0650799f8782fd74cf73c19223730c6d1b9198671b1c5b3a38e1188b5953","impliedFormat":1},{"version":"8a15b4607d9a499e2dbeed9ec0d3c0d7372c850b2d5f1fb259e8f6d41d468a84","impliedFormat":1},{"version":"26e0fe14baee4e127f4365d1ae0b276f400562e45e19e35fd2d4c296684715e6","impliedFormat":1},{"version":"d6b1eba8496bdd0eed6fc8a685768fe01b2da4a0388b5fe7df558290bffcf32f","affectsGlobalScope":true,"impliedFormat":1},{"version":"7f57fc4404ff020bc45b9c620aff2b40f700b95fe31164024c453a5e3c163c54","impliedFormat":1},{"version":"7f57fc4404ff020bc45b9c620aff2b40f700b95fe31164024c453a5e3c163c54","impliedFormat":1},{"version":"2a2de5b9459b3fc44decd9ce6100b72f1b002ef523126c1d3d8b2a4a63d74d78","affectsGlobalScope":true,"impliedFormat":1},{"version":"f13f4b465c99041e912db5c44129a94588e1aafee35a50eab51044833f50b4ee","affectsGlobalScope":true,"impliedFormat":1},{"version":"eadcffda2aa84802c73938e589b9e58248d74c59cb7fcbca6474e3435ac15504","affectsGlobalScope":true,"impliedFormat":1},{"version":"105ba8ff7ba746404fe1a2e189d1d3d2e0eb29a08c18dded791af02f29fb4711","affectsGlobalScope":true,"impliedFormat":1},{"version":"00343ca5b2e3d48fa5df1db6e32ea2a59afab09590274a6cccb1dbae82e60c7c","affectsGlobalScope":true,"impliedFormat":1},{"version":"ebd9f816d4002697cb2864bea1f0b70a103124e18a8cd9645eeccc09bdf80ab4","affectsGlobalScope":true,"impliedFormat":1},{"version":"2c1afac30a01772cd2a9a298a7ce7706b5892e447bb46bdbeef720f7b5da77ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"7b0225f483e4fa685625ebe43dd584bb7973bbd84e66a6ba7bbe175ee1048b4f","affectsGlobalScope":true,"impliedFormat":1},{"version":"c0a4b8ac6ce74679c1da2b3795296f5896e31c38e888469a8e0f99dc3305de60","affectsGlobalScope":true,"impliedFormat":1},{"version":"3084a7b5f569088e0146533a00830e206565de65cae2239509168b11434cd84f","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5079c53f0f141a0698faa903e76cb41cd664e3efb01cc17a5c46ec2eb0bef42","affectsGlobalScope":true,"impliedFormat":1},{"version":"32cafbc484dea6b0ab62cf8473182bbcb23020d70845b406f80b7526f38ae862","affectsGlobalScope":true,"impliedFormat":1},{"version":"fca4cdcb6d6c5ef18a869003d02c9f0fd95df8cfaf6eb431cd3376bc034cad36","affectsGlobalScope":true,"impliedFormat":1},{"version":"b93ec88115de9a9dc1b602291b85baf825c85666bf25985cc5f698073892b467","affectsGlobalScope":true,"impliedFormat":1},{"version":"f5c06dcc3fe849fcb297c247865a161f995cc29de7aa823afdd75aaaddc1419b","affectsGlobalScope":true,"impliedFormat":1},{"version":"b77e16112127a4b169ef0b8c3a4d730edf459c5f25fe52d5e436a6919206c4d7","affectsGlobalScope":true,"impliedFormat":1},{"version":"fbffd9337146eff822c7c00acbb78b01ea7ea23987f6c961eba689349e744f8c","affectsGlobalScope":true,"impliedFormat":1},{"version":"a995c0e49b721312f74fdfb89e4ba29bd9824c770bbb4021d74d2bf560e4c6bd","affectsGlobalScope":true,"impliedFormat":1},{"version":"c7b3542146734342e440a84b213384bfa188835537ddbda50d30766f0593aff9","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce6180fa19b1cccd07ee7f7dbb9a367ac19c0ed160573e4686425060b6df7f57","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f02e2476bccb9dbe21280d6090f0df17d2f66b74711489415a8aa4df73c9675","affectsGlobalScope":true,"impliedFormat":1},{"version":"45e3ab34c1c013c8ab2dc1ba4c80c780744b13b5676800ae2e3be27ae862c40c","affectsGlobalScope":true,"impliedFormat":1},{"version":"805c86f6cca8d7702a62a844856dbaa2a3fd2abef0536e65d48732441dde5b5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"e42e397f1a5a77994f0185fd1466520691456c772d06bf843e5084ceb879a0ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"f4c2b41f90c95b1c532ecc874bd3c111865793b23aebcc1c3cbbabcd5d76ffb0","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab26191cfad5b66afa11b8bf935ef1cd88fabfcb28d30b2dfa6fad877d050332","affectsGlobalScope":true,"impliedFormat":1},{"version":"2088bc26531e38fb05eedac2951480db5309f6be3fa4a08d2221abb0f5b4200d","affectsGlobalScope":true,"impliedFormat":1},{"version":"cb9d366c425fea79716a8fb3af0d78e6b22ebbab3bd64d25063b42dc9f531c1e","affectsGlobalScope":true,"impliedFormat":1},{"version":"500934a8089c26d57ebdb688fc9757389bb6207a3c8f0674d68efa900d2abb34","affectsGlobalScope":true,"impliedFormat":1},{"version":"689da16f46e647cef0d64b0def88910e818a5877ca5379ede156ca3afb780ac3","affectsGlobalScope":true,"impliedFormat":1},{"version":"bc21cc8b6fee4f4c2440d08035b7ea3c06b3511314c8bab6bef7a92de58a2593","affectsGlobalScope":true,"impliedFormat":1},{"version":"7ca53d13d2957003abb47922a71866ba7cb2068f8d154877c596d63c359fed25","affectsGlobalScope":true,"impliedFormat":1},{"version":"54725f8c4df3d900cb4dac84b64689ce29548da0b4e9b7c2de61d41c79293611","affectsGlobalScope":true,"impliedFormat":1},{"version":"e5594bc3076ac29e6c1ebda77939bc4c8833de72f654b6e376862c0473199323","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f3eb332c2d73e729f3364fcc0c2b375e72a121e8157d25a82d67a138c83a95c","affectsGlobalScope":true,"impliedFormat":1},{"version":"6f4427f9642ce8d500970e4e69d1397f64072ab73b97e476b4002a646ac743b1","affectsGlobalScope":true,"impliedFormat":1},{"version":"48915f327cd1dea4d7bd358d9dc7732f58f9e1626a29cc0c05c8c692419d9bb7","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7bf9377723203b5a6a4b920164df22d56a43f593269ba6ae1fdc97774b68855","affectsGlobalScope":true,"impliedFormat":1},{"version":"db9709688f82c9e5f65a119c64d835f906efe5f559d08b11642d56eb85b79357","affectsGlobalScope":true,"impliedFormat":1},{"version":"4b25b8c874acd1a4cf8444c3617e037d444d19080ac9f634b405583fd10ce1f7","affectsGlobalScope":true,"impliedFormat":1},{"version":"37be57d7c90cf1f8112ee2636a068d8fd181289f82b744160ec56a7dc158a9f5","affectsGlobalScope":true,"impliedFormat":1},{"version":"a917a49ac94cd26b754ab84e113369a75d1a47a710661d7cd25e961cc797065f","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d3261badeb7843d157ef3e6f5d1427d0eeb0af0cf9df84a62cfd29fd47ac86e","affectsGlobalScope":true,"impliedFormat":1},{"version":"195daca651dde22f2167ac0d0a05e215308119a3100f5e6268e8317d05a92526","affectsGlobalScope":true,"impliedFormat":1},{"version":"8b11e4285cd2bb164a4dc09248bdec69e9842517db4ca47c1ba913011e44ff2f","affectsGlobalScope":true,"impliedFormat":1},{"version":"0508571a52475e245b02bc50fa1394065a0a3d05277fbf5120c3784b85651799","affectsGlobalScope":true,"impliedFormat":1},{"version":"8f9af488f510c3015af3cc8c267a9e9d96c4dd38a1fdff0e11dc5a544711415b","affectsGlobalScope":true,"impliedFormat":1},{"version":"fc611fea8d30ea72c6bbfb599c9b4d393ce22e2f5bfef2172534781e7d138104","affectsGlobalScope":true,"impliedFormat":1},{"version":"1ce14b81c5cc821994aa8ec1d42b220dd41b27fcc06373bce3958af7421b77d4","affectsGlobalScope":true,"impliedFormat":1},{"version":"b3a048b3e9302ef9a34ef4ebb9aecfb28b66abb3bce577206a79fee559c230da","affectsGlobalScope":true,"impliedFormat":1},{"version":"01a30f9e8582b369075c0808df71121e6855cb06fd8d3d39511d9ebb66405205","impliedFormat":1},{"version":"b2785e2861084d0accbc3ee621731f92da54ce00636be94a9e9af7d7ea20dcbf","signature":"a9637e1b2642463e77fc3b3b6b4d11524cf9f09a74758d5234de0ba5c28264e3"},{"version":"dc09fb94bc7349b5c5a7dfab3c1300ac18f2e33731ea0943db5ac60cfc3ce217","signature":"72cf94a2c467f63fe221dbad5f408b32b8281d3512d4bc02a3dd0fc675a96f55"},{"version":"f5f17d8331d6f499e839f25fc5c370628af6e54c1f8931b8ac46539e6028b715","signature":"504c2af83052182afaa2393a26b219324b1c5d044b64ed6c3e710936db12f62d"},{"version":"05a91ac9ee5a67bc41cfad803bebbc5f64fff02494a15c8811121b0474ae2f45","signature":"64603d3a21cace27a06a275c25b09db14ca4498fe121578865e29ad21914397e"},"a6a4f3dfe216ab809ad0c82b2154de582df3b7efd31f23d4f404d64dacb08be5"],"root":[[64,68]],"options":{"composite":true,"declaration":true,"declarationMap":true,"exactOptionalPropertyTypes":false,"module":99,"noUncheckedIndexedAccess":true,"outDir":"./dist","rootDir":"./src","skipLibCheck":true,"sourceMap":true,"strict":true,"target":9,"verbatimModuleSyntax":true},"referencedMap":[[66,1],[68,2],[65,3],[67,3]],"semanticDiagnosticsPerFile":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68],"latestChangedDtsFile":"./dist/index.d.ts","version":"6.0.3"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/combiner.d.ts b/src/perm-authz-ts/packages/server/dist/combiner.d.ts new file mode 100644 index 0000000..89a5580 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/combiner.d.ts @@ -0,0 +1,9 @@ +import type { EvalResult, PermissionDecision, PolicyIR, TraceEntry } from "@perm/core"; +export interface EvaluatedPolicy { + policy: PolicyIR; + targetMatched: boolean; + evaluation?: EvalResult; +} +export declare function combineEvaluatedPolicies(evaluated: EvaluatedPolicy[], defaultFallback?: "deny" | "allow"): PermissionDecision; +export declare function toTrace(evaluated: EvaluatedPolicy[]): TraceEntry[]; +//# sourceMappingURL=combiner.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/combiner.d.ts.map b/src/perm-authz-ts/packages/server/dist/combiner.d.ts.map new file mode 100644 index 0000000..2b68a33 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/combiner.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"combiner.d.ts","sourceRoot":"","sources":["../src/combiner.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,kBAAkB,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEvF,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,QAAQ,CAAC;IACjB,aAAa,EAAE,OAAO,CAAC;IACvB,UAAU,CAAC,EAAE,UAAU,CAAC;CACzB;AAED,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,eAAe,EAAE,EAAE,eAAe,GAAE,MAAM,GAAG,OAAgB,GAAG,kBAAkB,CAqDrI;AAED,wBAAgB,OAAO,CAAC,SAAS,EAAE,eAAe,EAAE,GAAG,UAAU,EAAE,CAUlE"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/combiner.js b/src/perm-authz-ts/packages/server/dist/combiner.js new file mode 100644 index 0000000..192ba3a --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/combiner.js @@ -0,0 +1,60 @@ +export function combineEvaluatedPolicies(evaluated, defaultFallback = "deny") { + const applicable = evaluated.filter((entry) => entry.targetMatched && entry.evaluation?.value === true); + const deny = applicable + .filter((entry) => entry.policy.effect === "deny") + .sort((a, b) => b.policy.priority - a.policy.priority)[0]; + if (deny) { + return { + effect: "deny", + policy: deny.policy.id, + code: deny.policy.code ?? "permission_denied", + reason: deny.policy.reason ?? `Denied by policy ${deny.policy.id}`, + advice: deny.policy.advice + }; + } + const allow = applicable + .filter((entry) => entry.policy.effect === "allow") + .sort((a, b) => b.policy.priority - a.policy.priority)[0]; + if (allow) { + return { + effect: "allow", + policy: allow.policy.id, + reason: allow.policy.reason, + obligations: allow.policy.obligations, + advice: allow.policy.advice + }; + } + const errored = evaluated.find((entry) => entry.targetMatched && entry.evaluation?.value === "error"); + if (errored?.evaluation) { + return { + effect: "indeterminate", + reason: errored.evaluation.reason ?? "Authorization policy evaluation failed", + fallback: defaultFallback, + errors: errored.evaluation.error ? [errored.evaluation.error] : undefined + }; + } + const unknown = evaluated.find((entry) => entry.targetMatched && entry.evaluation?.value === "unknown"); + if (unknown?.evaluation) { + return { + effect: "indeterminate", + reason: unknown.evaluation.reason ?? "Authorization policy evaluation is unknown", + fallback: defaultFallback + }; + } + return { + effect: "not_applicable", + reason: "No matching policy allowed or denied the request" + }; +} +export function toTrace(evaluated) { + return evaluated.map((entry) => ({ + policy: entry.policy.id, + effect: entry.policy.effect, + targetMatched: entry.targetMatched, + condition: entry.targetMatched ? entry.policy.condition : undefined, + result: entry.evaluation?.value, + reason: entry.evaluation?.reason, + dependencies: entry.evaluation?.dependencies ?? [] + })); +} +//# sourceMappingURL=combiner.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/combiner.js.map b/src/perm-authz-ts/packages/server/dist/combiner.js.map new file mode 100644 index 0000000..f2e39f2 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/combiner.js.map @@ -0,0 +1 @@ +{"version":3,"file":"combiner.js","sourceRoot":"","sources":["../src/combiner.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/compilers/sql.d.ts b/src/perm-authz-ts/packages/server/dist/compilers/sql.d.ts new file mode 100644 index 0000000..8ac89cc --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/compilers/sql.d.ts @@ -0,0 +1,20 @@ +import type { SubjectRef } from "@perm/core"; +import type { QueryCompiler } from "../types.js"; +export interface SqlCompileResult { + sql: string; + params: Record; +} +export interface SqlRelationCompilerInput { + relation: string; + resourceAlias: string; + actor: SubjectRef; + param(value: unknown): string; +} +export type SqlRelationCompiler = (input: SqlRelationCompilerInput) => string | undefined; +export interface CreateSqlCompilerOptions { + resourceAlias?: string; + relation?: SqlRelationCompiler; + columnName?: (path: string) => string; +} +export declare function createSqlCompiler(options?: CreateSqlCompilerOptions): QueryCompiler; +//# sourceMappingURL=sql.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/compilers/sql.d.ts.map b/src/perm-authz-ts/packages/server/dist/compilers/sql.d.ts.map new file mode 100644 index 0000000..1d33240 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/compilers/sql.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"sql.d.ts","sourceRoot":"","sources":["../../src/compilers/sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA+B,UAAU,EAAE,MAAM,YAAY,CAAC;AAC1E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAGjD,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,UAAU,CAAC;IAClB,KAAK,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC;CAC/B;AAED,MAAM,MAAM,mBAAmB,GAAG,CAAC,KAAK,EAAE,wBAAwB,KAAK,MAAM,GAAG,SAAS,CAAC;AAE1F,MAAM,WAAW,wBAAwB;IACvC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,mBAAmB,CAAC;IAC/B,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;CACvC;AAaD,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,wBAA6B,GAAG,aAAa,CA4IvF"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/compilers/sql.js b/src/perm-authz-ts/packages/server/dist/compilers/sql.js new file mode 100644 index 0000000..3aac14b --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/compilers/sql.js @@ -0,0 +1,147 @@ +import { actionMatches } from "../match.js"; +function defaultColumnName(path) { + return path.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`); +} +function relevantPolicies(policies, action, resourceType) { + return policies.filter((policy) => { + const resourceMatches = !policy.target.resource || policy.target.resource === resourceType; + return resourceMatches && actionMatches(policy.target.action, action); + }); +} +export function createSqlCompiler(options = {}) { + const alias = options.resourceAlias ?? "resource"; + const columnName = options.columnName ?? defaultColumnName; + return { + target: "sql", + compile(input) { + const policies = relevantPolicies(input.policies, input.action, input.resourceType); + const allowPolicies = policies.filter((policy) => policy.effect === "allow"); + const denyPolicies = policies.filter((policy) => policy.effect === "deny"); + const params = {}; + let counter = 0; + const warnings = []; + const residualPolicies = []; + const param = (value) => { + const key = `p${++counter}`; + params[key] = value; + return `:${key}`; + }; + const compileExpr = (expr) => { + switch (expr.op) { + case "const": + if (typeof expr.value === "boolean") + return expr.value ? "TRUE" : "FALSE"; + return param(expr.value); + case "ref": + if (expr.root === "resource") + return `${alias}.${columnName(expr.path)}`; + if (expr.root === "actor") { + const value = expr.path ? input.actor[expr.path] : input.actor; + return param(value); + } + if (expr.root === "context") { + const value = expr.path ? input.context?.[expr.path] : input.context; + return param(value); + } + return undefined; + case "eq": + case "neq": + case "gt": + case "gte": + case "lt": + case "lte": { + const left = compileExpr(expr.left); + const right = compileExpr(expr.right); + if (!left || !right) + return undefined; + const op = expr.op === "eq" ? "=" : expr.op === "neq" ? "<>" : expr.op === "gt" ? ">" : expr.op === "gte" ? ">=" : expr.op === "lt" ? "<" : "<="; + return `(${left} ${op} ${right})`; + } + case "and": { + const parts = expr.args.map(compileExpr); + if (parts.some((part) => !part)) + return undefined; + return `(${parts.join(" AND ")})`; + } + case "or": { + const parts = expr.args.map(compileExpr); + if (parts.some((part) => !part)) + return undefined; + return `(${parts.join(" OR ")})`; + } + case "not": { + const inner = compileExpr(expr.expr); + return inner ? `(NOT ${inner})` : undefined; + } + case "in": { + const value = compileExpr(expr.value); + if (!value) + return undefined; + if (expr.set.op === "const" && Array.isArray(expr.set.value)) { + const list = expr.set.value.map(param).join(", "); + return `(${value} IN (${list}))`; + } + const set = compileExpr(expr.set); + return set ? `(${value} IN ${set})` : undefined; + } + case "contains": { + const value = compileExpr(expr.value); + if (!value) + return undefined; + if (expr.set.op === "const" && Array.isArray(expr.set.value)) { + const list = expr.set.value.map(param).join(", "); + return `(${value} IN (${list}))`; + } + return undefined; + } + case "rel": { + const sql = options.relation?.({ relation: expr.path, resourceAlias: alias, actor: input.actor, param }); + if (!sql) + return undefined; + return `(${sql})`; + } + case "exists": + return undefined; + } + }; + const allowSql = []; + for (const policy of allowPolicies) { + const compiled = compileExpr(policy.condition); + if (compiled) + allowSql.push(compiled); + else + residualPolicies.push(policy); + } + const denySql = []; + for (const policy of denyPolicies) { + const compiled = compileExpr(policy.condition); + if (compiled) + denySql.push(compiled); + else + residualPolicies.push(policy); + } + if (allowSql.length === 0 && residualPolicies.length > 0) { + return { + strategy: "not_compilable", + target: "sql", + residualPolicies, + warnings: ["No allow policy could be compiled to SQL."] + }; + } + let sql = allowSql.length > 0 ? `(${allowSql.join(" OR ")})` : "FALSE"; + if (denySql.length > 0) + sql = `(${sql}) AND NOT (${denySql.join(" OR ")})`; + if (residualPolicies.length > 0) { + warnings.push("Some policies were not compilable and require residual checks."); + } + return { + strategy: residualPolicies.length > 0 ? "partial" : "compiled", + target: "sql", + predicate: { sql, params }, + residualPolicies: residualPolicies.length ? residualPolicies : undefined, + warnings: warnings.length ? warnings : undefined + }; + } + }; +} +//# sourceMappingURL=sql.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/compilers/sql.js.map b/src/perm-authz-ts/packages/server/dist/compilers/sql.js.map new file mode 100644 index 0000000..b519047 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/compilers/sql.js.map @@ -0,0 +1 @@ +{"version":3,"file":"sql.js","sourceRoot":"","sources":["../../src/compilers/sql.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/createPermissions.d.ts b/src/perm-authz-ts/packages/server/dist/createPermissions.d.ts new file mode 100644 index 0000000..49e35bb --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/createPermissions.d.ts @@ -0,0 +1,3 @@ +import type { CreatePermissionsOptions, PermissionsRuntime } from "./types.js"; +export declare function createPermissions(options: CreatePermissionsOptions): PermissionsRuntime; +//# sourceMappingURL=createPermissions.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/createPermissions.d.ts.map b/src/perm-authz-ts/packages/server/dist/createPermissions.d.ts.map new file mode 100644 index 0000000..b646ba6 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/createPermissions.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"createPermissions.d.ts","sourceRoot":"","sources":["../src/createPermissions.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAEV,wBAAwB,EAExB,kBAAkB,EAGnB,MAAM,YAAY,CAAC;AAgBpB,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,kBAAkB,CA8IvF"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/createPermissions.js b/src/perm-authz-ts/packages/server/dist/createPermissions.js new file mode 100644 index 0000000..226dc3e --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/createPermissions.js @@ -0,0 +1,145 @@ +import { PermissionDeniedError, actionResource, resourceKey } from "@perm/core"; +import { DefaultEvaluator } from "./evaluator.js"; +import { combineEvaluatedPolicies, toTrace } from "./combiner.js"; +import { actionsForResource, targetMatches } from "./match.js"; +import { reversePolicies } from "./reverse.js"; +function dedupe(values) { + return [...new Set(values)]; +} +function buildContext(input) { + return { + actor: input.actor, + action: input.action, + resource: input.resource, + context: input.context, + requestId: input.requestId + }; +} +export function createPermissions(options) { + const evaluator = new DefaultEvaluator(options.providers); + const defaultFallback = options.defaultFallback ?? "deny"; + async function evaluatePolicies(context) { + const ordered = [...options.policies].sort((a, b) => b.priority - a.priority); + const entries = []; + for (const policy of ordered) { + const matched = targetMatches(policy, context); + if (!matched) { + entries.push({ policy, targetMatched: false }); + continue; + } + const evaluation = await evaluator.evaluate(policy.condition, context); + entries.push({ policy, targetMatched: true, evaluation }); + } + return entries; + } + async function explain(input) { + const context = buildContext(input); + const evaluated = await evaluatePolicies(context); + const decision = combineEvaluatedPolicies(evaluated, defaultFallback); + const trace = toTrace(evaluated); + const dependencies = dedupe(trace.flatMap((entry) => entry.dependencies)); + return { + actor: input.actor, + action: input.action, + resource: input.resource, + decision, + trace, + dependencies + }; + } + async function check(input) { + return (await explain(input)).decision; + } + async function can(input) { + const decision = await check(input); + return decision.effect === "allow"; + } + async function assertAllowed(input) { + const decision = await check(input); + if (decision.effect !== "allow") + throw new PermissionDeniedError(decision); + } + async function what(input) { + const resourceType = input.resource?.type; + const actions = input.actions ?? (resourceType && options.schema.resources[resourceType] + ? actionsForResource(resourceType, options.schema.resources[resourceType].actions) + : dedupe(options.policies.map((policy) => policy.target.action).filter((action) => !action.endsWith(".*")))); + const decisions = {}; + for (const action of actions) { + decisions[action] = await check({ ...input, action }); + } + return { resource: input.resource, actions: decisions }; + } + async function who(input) { + const context = buildContext(input); + const matchingPolicies = options.policies.filter((policy) => targetMatches(policy, context)); + return reversePolicies({ + policies: matchingPolicies, + action: input.action, + resource: input.resource, + subjectType: input.subjectType ?? "user" + }); + } + function filter(action) { + let actor; + let resourceType = actionResource(action) ?? "resource"; + let filterContext; + const builder = { + for(nextActor) { + actor = nextActor; + return builder; + }, + resource(nextResourceType) { + resourceType = nextResourceType; + return builder; + }, + context(nextContext) { + filterContext = nextContext; + return builder; + }, + async toPlan(target = "memory") { + if (!actor) + throw new Error("filter().for(actor) is required before toPlan()"); + if (target === "memory") { + return { + strategy: "compiled", + target: "memory", + predicate: "async (resource) => Perm.can({ actor, action, resource })" + }; + } + const compiler = options.compilers?.find((candidate) => candidate.target === target); + if (!compiler) { + return { + strategy: "not_compilable", + target, + warnings: [`No ${target} compiler configured`] + }; + } + return compiler.compile({ + schema: options.schema, + policies: options.policies, + action, + actor, + resourceType, + context: filterContext + }); + }, + async toPredicate() { + if (!actor) + throw new Error("filter().for(actor) is required before toPredicate()"); + return async (resource) => can({ actor: actor, action, resource, context: filterContext }); + } + }; + return builder; + } + return { + check, + can, + assert: assertAllowed, + explain, + what, + who, + filter + }; +} +//# sourceMappingURL=createPermissions.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/createPermissions.js.map b/src/perm-authz-ts/packages/server/dist/createPermissions.js.map new file mode 100644 index 0000000..05a0c32 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/createPermissions.js.map @@ -0,0 +1 @@ +{"version":3,"file":"createPermissions.js","sourceRoot":"","sources":["../src/createPermissions.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/evaluator.d.ts b/src/perm-authz-ts/packages/server/dist/evaluator.d.ts new file mode 100644 index 0000000..98ac761 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/evaluator.d.ts @@ -0,0 +1,11 @@ +import type { EvalResult, ExprIR, RequestContext } from "@perm/core"; +import type { PermissionProviders } from "./types.js"; +export declare class DefaultEvaluator { + private readonly providers; + constructor(providers?: PermissionProviders); + evaluate(expr: ExprIR, context: RequestContext): Promise; + private value; + private rawValue; + private evaluateInternal; +} +//# sourceMappingURL=evaluator.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/evaluator.d.ts.map b/src/perm-authz-ts/packages/server/dist/evaluator.d.ts.map new file mode 100644 index 0000000..8d07d58 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/evaluator.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"evaluator.d.ts","sourceRoot":"","sources":["../src/evaluator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,UAAU,EAAa,MAAM,EAAE,cAAc,EAA2B,MAAM,YAAY,CAAC;AAExH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAoCtD,qBAAa,gBAAgB;IACf,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,GAAE,mBAAwB;IAE1D,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC;YAQ5D,KAAK;YAQL,QAAQ;YA6BR,gBAAgB;CA2G/B"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/evaluator.js b/src/perm-authz-ts/packages/server/dist/evaluator.js new file mode 100644 index 0000000..59281c1 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/evaluator.js @@ -0,0 +1,189 @@ +import { getPath } from "@perm/core"; +function ok(value, dependencies = [], reason, error) { + const result = { value, dependencies }; + if (reason !== undefined) + result.reason = reason; + if (error !== undefined) + result.error = error; + return result; +} +function dep(kind, key) { + return `${kind}:${key}`; +} +function dedupeDeps(deps) { + return [...new Set(deps)]; +} +function compare(op, left, right) { + switch (op) { + case "eq": + return left === right; + case "neq": + return left !== right; + case "gt": + return typeof left === "number" && typeof right === "number" ? left > right : "unknown"; + case "gte": + return typeof left === "number" && typeof right === "number" ? left >= right : "unknown"; + case "lt": + return typeof left === "number" && typeof right === "number" ? left < right : "unknown"; + case "lte": + return typeof left === "number" && typeof right === "number" ? left <= right : "unknown"; + default: + return "unknown"; + } +} +export class DefaultEvaluator { + providers; + constructor(providers = {}) { + this.providers = providers; + } + async evaluate(expr, context) { + try { + return await this.evaluateInternal(expr, context); + } + catch (error) { + return ok("error", [], "Expression evaluation failed", error); + } + } + async value(expr, context) { + const result = await this.evaluateInternal(expr, context); + if (result.value === "unknown" || result.value === "error") { + return { value: undefined, dependencies: result.dependencies, unknown: result.reason ?? String(result.value) }; + } + return { value: result.value, dependencies: result.dependencies }; + } + async rawValue(expr, context) { + if (expr.op === "const") + return { value: expr.value, dependencies: [] }; + if (expr.op === "ref") { + const key = expr.path ? `${expr.root}.${expr.path}` : expr.root; + const dependency = dep(expr.root, key); + if (this.providers.attributes) { + const provided = await this.providers.attributes.getAttribute({ root: expr.root, path: expr.path, context }); + return { value: provided, dependencies: [dependency] }; + } + const root = expr.root === "actor" ? context.actor : expr.root === "resource" ? context.resource : context.context; + return { value: getPath(root, expr.path), dependencies: [dependency] }; + } + if (expr.op === "rel") { + const evaluated = await this.evaluateInternal(expr, context); + const out = { value: evaluated.value === true, dependencies: evaluated.dependencies }; + if (evaluated.value === "unknown" && evaluated.reason !== undefined) + out.unknown = evaluated.reason; + return out; + } + const evaluated = await this.evaluateInternal(expr, context); + if (evaluated.value === "unknown" || evaluated.value === "error") { + return { value: undefined, dependencies: evaluated.dependencies, unknown: evaluated.reason ?? String(evaluated.value) }; + } + return { value: evaluated.value, dependencies: evaluated.dependencies }; + } + async evaluateInternal(expr, context) { + switch (expr.op) { + case "const": + return ok(Boolean(expr.value)); + case "ref": { + const { value, dependencies } = await this.rawValue(expr, context); + return ok(Boolean(value), dependencies); + } + case "eq": + case "neq": + case "gt": + case "gte": + case "lt": + case "lte": { + const left = await this.rawValue(expr.left, context); + const right = await this.rawValue(expr.right, context); + const dependencies = dedupeDeps([...left.dependencies, ...right.dependencies]); + if (left.unknown || right.unknown) + return ok("unknown", dependencies, left.unknown ?? right.unknown); + const value = compare(expr.op, left.value, right.value); + return value === "unknown" ? ok("unknown", dependencies, `Cannot compare values using ${expr.op}`) : ok(value, dependencies); + } + case "in": { + const value = await this.rawValue(expr.value, context); + const set = await this.rawValue(expr.set, context); + const dependencies = dedupeDeps([...value.dependencies, ...set.dependencies]); + if (value.unknown || set.unknown) + return ok("unknown", dependencies, value.unknown ?? set.unknown); + if (!Array.isArray(set.value)) + return ok("unknown", dependencies, "Right side of in() is not an array"); + return ok(set.value.includes(value.value), dependencies); + } + case "contains": { + const set = await this.rawValue(expr.set, context); + const value = await this.rawValue(expr.value, context); + const dependencies = dedupeDeps([...value.dependencies, ...set.dependencies]); + if (value.unknown || set.unknown) + return ok("unknown", dependencies, value.unknown ?? set.unknown); + if (!Array.isArray(set.value)) + return ok("unknown", dependencies, "contains() target is not an array"); + return ok(set.value.includes(value.value), dependencies); + } + case "and": { + const dependencies = []; + let unknownReason; + for (const arg of expr.args) { + const result = await this.evaluateInternal(arg, context); + dependencies.push(...result.dependencies); + if (result.value === false) + return ok(false, dedupeDeps(dependencies)); + if (result.value === "error") + return ok("error", dedupeDeps(dependencies), result.reason, result.error); + if (result.value === "unknown") + unknownReason = unknownReason ?? result.reason; + } + return unknownReason ? ok("unknown", dedupeDeps(dependencies), unknownReason) : ok(true, dedupeDeps(dependencies)); + } + case "or": { + const dependencies = []; + let unknownReason; + for (const arg of expr.args) { + const result = await this.evaluateInternal(arg, context); + dependencies.push(...result.dependencies); + if (result.value === true) + return ok(true, dedupeDeps(dependencies)); + if (result.value === "error") + return ok("error", dedupeDeps(dependencies), result.reason, result.error); + if (result.value === "unknown") + unknownReason = unknownReason ?? result.reason; + } + return unknownReason ? ok("unknown", dedupeDeps(dependencies), unknownReason) : ok(false, dedupeDeps(dependencies)); + } + case "not": { + const result = await this.evaluateInternal(expr.expr, context); + if (result.value === true) + return ok(false, result.dependencies); + if (result.value === false) + return ok(true, result.dependencies); + return result; + } + case "rel": { + const relationKey = dep("relation", expr.path); + const resourceValue = expr.resource ? await this.rawValue(expr.resource, context) : { value: context.resource, dependencies: [] }; + const subjectValue = await this.rawValue(expr.subject, context); + const dependencies = dedupeDeps([relationKey, ...resourceValue.dependencies, ...subjectValue.dependencies]); + if (resourceValue.unknown || subjectValue.unknown) { + return ok("unknown", dependencies, resourceValue.unknown ?? subjectValue.unknown); + } + const resource = resourceValue.value; + const subject = subjectValue.value; + if (!resource || typeof resource !== "object" || !resource.type) { + return ok("unknown", dependencies, `Relation ${expr.path} requires a resource`); + } + if (!subject || typeof subject !== "object" || !subject.type || !subject.id) { + return ok("unknown", dependencies, `Relation ${expr.path} requires a subject`); + } + if (!this.providers.relations) { + return ok("unknown", dependencies, `No relation provider configured for ${expr.path}`); + } + const relationResult = await this.providers.relations.hasRelation({ relation: expr.path, resource, subject, context }); + if (relationResult === "unknown") + return ok("unknown", dependencies, `Relation ${expr.path} is unknown`); + return ok(relationResult, dependencies); + } + case "exists": + return ok("unknown", [dep("relation", expr.relation)], "exists() requires a query-capable provider"); + } + } +} +//# sourceMappingURL=evaluator.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/evaluator.js.map b/src/perm-authz-ts/packages/server/dist/evaluator.js.map new file mode 100644 index 0000000..87d53fc --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/evaluator.js.map @@ -0,0 +1 @@ +{"version":3,"file":"evaluator.js","sourceRoot":"","sources":["../src/evaluator.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/http.d.ts b/src/perm-authz-ts/packages/server/dist/http.d.ts new file mode 100644 index 0000000..1aeb532 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/http.d.ts @@ -0,0 +1,18 @@ +import type { PermissionsRuntime, CheckInput } from "./types.js"; +export interface AuthzHttpRequestLike { + method: string; + url?: string; + json(): Promise; +} +export interface AuthzHttpResponse { + status: number; + body: unknown; +} +export type ActorResolver = (request: AuthzHttpRequestLike, body: unknown) => Promise | CheckInput["actor"]; +export declare function createAuthzHttpHandlers(runtime: PermissionsRuntime, resolveActor: ActorResolver): { + check(request: AuthzHttpRequestLike): Promise; + batch(request: AuthzHttpRequestLike): Promise; + what(request: AuthzHttpRequestLike): Promise; + explain(request: AuthzHttpRequestLike): Promise; +}; +//# sourceMappingURL=http.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/http.d.ts.map b/src/perm-authz-ts/packages/server/dist/http.d.ts.map new file mode 100644 index 0000000..9bdb2be --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/http.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEjE,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,CAAC;CACf;AAED,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,EAAE,oBAAoB,EAAE,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,UAAU,CAAC,OAAO,CAAC,CAAC;AAMjI,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,kBAAkB,EAAE,YAAY,EAAE,aAAa;mBAEvE,oBAAoB,GAAG,OAAO,CAAC,iBAAiB,CAAC;mBAajD,oBAAoB,GAAG,OAAO,CAAC,iBAAiB,CAAC;kBAiBlD,oBAAoB,GAAG,OAAO,CAAC,iBAAiB,CAAC;qBAa9C,oBAAoB,GAAG,OAAO,CAAC,iBAAiB,CAAC;EAa3E"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/http.js b/src/perm-authz-ts/packages/server/dist/http.js new file mode 100644 index 0000000..a4c03e7 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/http.js @@ -0,0 +1,59 @@ +function assertBodyObject(body) { + if (!body || typeof body !== "object") + throw new Error("Request body must be an object"); +} +export function createAuthzHttpHandlers(runtime, resolveActor) { + return { + async check(request) { + const body = await request.json(); + assertBodyObject(body); + const actor = await resolveActor(request, body); + const decision = await runtime.check({ + actor, + action: String(body.action), + resource: body.resource, + context: body.context + }); + return { status: 200, body: decision }; + }, + async batch(request) { + const body = await request.json(); + assertBodyObject(body); + const actor = await resolveActor(request, body); + const checks = Array.isArray(body.checks) ? body.checks : []; + const decisions = {}; + for (const check of checks) { + const resource = check.resource; + const action = String(check.action); + const key = `${resource?.type ?? "resource"}:${resource?.id ?? "none"}:${action}`; + decisions[key] = await runtime.check({ actor, action, resource, context: check.context }); + } + return { status: 200, body: { decisions } }; + }, + async what(request) { + const body = await request.json(); + assertBodyObject(body); + const actor = await resolveActor(request, body); + const result = await runtime.what({ + actor, + resource: body.resource, + context: body.context, + actions: Array.isArray(body.actions) ? body.actions.map(String) : undefined + }); + return { status: 200, body: result }; + }, + async explain(request) { + const body = await request.json(); + assertBodyObject(body); + const actor = await resolveActor(request, body); + const result = await runtime.explain({ + actor, + action: String(body.action), + resource: body.resource, + context: body.context + }); + return { status: 200, body: result }; + } + }; +} +//# sourceMappingURL=http.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/http.js.map b/src/perm-authz-ts/packages/server/dist/http.js.map new file mode 100644 index 0000000..917b43b --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/http.js.map @@ -0,0 +1 @@ +{"version":3,"file":"http.js","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/index.d.ts b/src/perm-authz-ts/packages/server/dist/index.d.ts new file mode 100644 index 0000000..2598f04 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/index.d.ts @@ -0,0 +1,9 @@ +export * from "./types.js"; +export * from "./evaluator.js"; +export * from "./combiner.js"; +export * from "./match.js"; +export * from "./reverse.js"; +export * from "./createPermissions.js"; +export * from "./http.js"; +export * from "./compilers/sql.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/index.d.ts.map b/src/perm-authz-ts/packages/server/dist/index.d.ts.map new file mode 100644 index 0000000..4302cda --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,WAAW,CAAC;AAC1B,cAAc,oBAAoB,CAAC"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/index.js b/src/perm-authz-ts/packages/server/dist/index.js new file mode 100644 index 0000000..9583ebf --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/index.js @@ -0,0 +1,9 @@ +export * from "./types.js"; +export * from "./evaluator.js"; +export * from "./combiner.js"; +export * from "./match.js"; +export * from "./reverse.js"; +export * from "./createPermissions.js"; +export * from "./http.js"; +export * from "./compilers/sql.js"; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/index.js.map b/src/perm-authz-ts/packages/server/dist/index.js.map new file mode 100644 index 0000000..12266f9 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,WAAW,CAAC;AAC1B,cAAc,oBAAoB,CAAC"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/match.d.ts b/src/perm-authz-ts/packages/server/dist/match.d.ts new file mode 100644 index 0000000..56e543d --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/match.d.ts @@ -0,0 +1,5 @@ +import type { PolicyIR, RequestContext } from "@perm/core"; +export declare function actionMatches(pattern: string, action: string): boolean; +export declare function targetMatches(policy: PolicyIR, context: RequestContext): boolean; +export declare function actionsForResource(resourceType: string, actions: readonly string[]): string[]; +//# sourceMappingURL=match.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/match.d.ts.map b/src/perm-authz-ts/packages/server/dist/match.d.ts.map new file mode 100644 index 0000000..d700c3d --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/match.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"match.d.ts","sourceRoot":"","sources":["../src/match.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAG3D,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAOtE;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAMhF;AAED,wBAAgB,kBAAkB,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAE7F"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/match.js b/src/perm-authz-ts/packages/server/dist/match.js new file mode 100644 index 0000000..30c722d --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/match.js @@ -0,0 +1,23 @@ +import { actionResource } from "@perm/core"; +export function actionMatches(pattern, action) { + if (pattern === action) + return true; + if (pattern.endsWith(".*")) { + const prefix = pattern.slice(0, -1); + return action.startsWith(prefix); + } + return false; +} +export function targetMatches(policy, context) { + if (!actionMatches(policy.target.action, context.action)) + return false; + const policyResource = policy.target.resource ?? actionResource(policy.target.action); + const requestResource = context.resource?.type ?? actionResource(context.action); + if (!policyResource || !requestResource) + return true; + return policyResource === requestResource; +} +export function actionsForResource(resourceType, actions) { + return actions.map((action) => action.includes(".") ? action : `${resourceType}.${action}`); +} +//# sourceMappingURL=match.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/match.js.map b/src/perm-authz-ts/packages/server/dist/match.js.map new file mode 100644 index 0000000..f425e55 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/match.js.map @@ -0,0 +1 @@ +{"version":3,"file":"match.js","sourceRoot":"","sources":["../src/match.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAE5C,MAAM,UAAU,aAAa,CAAC,OAAe,EAAE,MAAc;IAC3D,IAAI,OAAO,KAAK,MAAM;QAAE,OAAO,IAAI,CAAC;IACpC,IAAI,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3B,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QACpC,OAAO,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;IACnC,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,MAAgB,EAAE,OAAuB;IACrE,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC;QAAE,OAAO,KAAK,CAAC;IACvE,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,CAAC,QAAQ,IAAI,cAAc,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACtF,MAAM,eAAe,GAAG,OAAO,CAAC,QAAQ,EAAE,IAAI,IAAI,cAAc,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACjF,IAAI,CAAC,cAAc,IAAI,CAAC,eAAe;QAAE,OAAO,IAAI,CAAC;IACrD,OAAO,cAAc,KAAK,eAAe,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,YAAoB,EAAE,OAA0B;IACjF,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,YAAY,IAAI,MAAM,EAAE,CAAC,CAAC;AAC9F,CAAC"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/reverse.d.ts b/src/perm-authz-ts/packages/server/dist/reverse.d.ts new file mode 100644 index 0000000..cbde025 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/reverse.d.ts @@ -0,0 +1,9 @@ +import type { PolicyIR, ResourceRef } from "@perm/core"; +import type { ReverseQueryResult } from "./types.js"; +export declare function reversePolicies(input: { + policies: PolicyIR[]; + action: string; + resource: ResourceRef; + subjectType: string; +}): ReverseQueryResult; +//# sourceMappingURL=reverse.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/reverse.d.ts.map b/src/perm-authz-ts/packages/server/dist/reverse.d.ts.map new file mode 100644 index 0000000..8b36224 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/reverse.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"reverse.d.ts","sourceRoot":"","sources":["../src/reverse.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAU,QAAQ,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AA8CrD,wBAAgB,eAAe,CAAC,KAAK,EAAE;IACrC,QAAQ,EAAE,QAAQ,EAAE,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,WAAW,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;CACrB,GAAG,kBAAkB,CAoBrB"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/reverse.js b/src/perm-authz-ts/packages/server/dist/reverse.js new file mode 100644 index 0000000..cd503bd --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/reverse.js @@ -0,0 +1,61 @@ +function collect(expr, result, policy, resource) { + switch (expr.op) { + case "rel": + result.sources.push({ + type: expr.path.includes(".") ? "relationExpansion" : "relation", + path: expr.path, + resource + }); + return; + case "and": + case "or": + for (const arg of expr.args) + collect(arg, result, policy, resource); + return; + case "not": + collect(expr.expr, result, policy, resource); + return; + case "eq": + case "neq": + case "gt": + case "gte": + case "lt": + case "lte": { + const left = expr.left; + const right = expr.right; + if (left.op === "ref" && left.root === "context" && right.op === "const") { + result.constraints.push({ type: "context", path: left.path, required: right.value, policy: policy.id }); + } + else if (left.op === "ref" && right.op === "const") { + result.constraints.push({ type: "attribute", path: `${left.root}.${left.path}`, required: right.value, policy: policy.id }); + } + return; + } + case "in": + case "contains": + case "exists": + case "const": + case "ref": + return; + } +} +export function reversePolicies(input) { + const result = { + action: input.action, + resource: input.resource, + subjectType: input.subjectType, + sources: [], + constraints: [], + warnings: [] + }; + for (const policy of input.policies) { + if (policy.effect !== "allow") + continue; + collect(policy.condition, result, policy, input.resource); + } + if (result.sources.length === 0) { + result.warnings.push("No invertible relation was found. The policy may depend only on attributes or custom predicates."); + } + return result; +} +//# sourceMappingURL=reverse.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/reverse.js.map b/src/perm-authz-ts/packages/server/dist/reverse.js.map new file mode 100644 index 0000000..89e5d95 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/reverse.js.map @@ -0,0 +1 @@ +{"version":3,"file":"reverse.js","sourceRoot":"","sources":["../src/reverse.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/types.d.ts b/src/perm-authz-ts/packages/server/dist/types.d.ts new file mode 100644 index 0000000..ab41fbd --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/types.d.ts @@ -0,0 +1,112 @@ +import type { DependencyKey, EvalResult, ExprIR, ExplainResult, PermSchema, PermissionDecision, PolicyIR, QueryPlan, RequestContext, ResourceRef, SubjectRef } from "@perm/core"; +export type RelationResult = boolean | "unknown"; +export interface RelationProvider { + hasRelation(input: { + relation: string; + resource: ResourceRef; + subject: SubjectRef; + context: RequestContext; + }): Promise | RelationResult; + listSubjects?(input: { + relation: string; + resource: ResourceRef; + subjectType?: string; + context: RequestContext; + }): Promise | SubjectRef[]; + listResources?(input: { + relation: string; + subject: SubjectRef; + resourceType: string; + context: RequestContext; + }): Promise | ResourceRef[]; +} +export interface AttributeProvider { + getAttribute(input: { + root: "actor" | "resource" | "context"; + path: string; + context: RequestContext; + }): Promise | unknown; +} +export interface SubscriptionProvider { + subscribe(dependencies: DependencyKey[], cb: () => void): () => void; +} +export interface QueryCompiler { + target: string; + compile(input: { + schema: PermSchema; + policies: PolicyIR[]; + action: string; + actor: SubjectRef; + resourceType: string; + context?: Record; + }): Promise | QueryPlan; +} +export interface PermissionProviders { + attributes?: AttributeProvider; + relations?: RelationProvider; + subscriptions?: SubscriptionProvider; +} +export interface CreatePermissionsOptions { + schema: PermSchema; + policies: PolicyIR[]; + providers?: PermissionProviders; + compilers?: QueryCompiler[]; + defaultFallback?: "deny" | "allow"; +} +export interface EvaluateOptions { + providers?: PermissionProviders; +} +export interface Evaluator { + evaluate(expr: ExprIR, context: RequestContext): Promise; +} +export interface CheckInput { + actor: SubjectRef; + action: string; + resource?: ResourceRef; + context?: Record; + requestId?: string; +} +export interface WhatResult { + resource?: ResourceRef; + actions: Record; +} +export interface ReverseQueryResult { + action: string; + resource: ResourceRef; + subjectType: string; + sources: Array<{ + type: "relation" | "relationExpansion" | "policy"; + path?: string; + policy?: string; + resource?: ResourceRef; + }>; + constraints: Array<{ + type: "context" | "attribute" | "unknown"; + path?: string; + required?: unknown; + policy?: string; + }>; + warnings: string[]; +} +export interface PermissionsRuntime { + check(input: CheckInput): Promise; + can(input: CheckInput): Promise; + assert(input: CheckInput): Promise; + explain(input: CheckInput): Promise; + what(input: Omit & { + actions?: string[]; + }): Promise; + who(input: CheckInput & { + resource: ResourceRef; + subjectType?: string; + }): Promise; + filter(action: string): FilterBuilder; +} +export interface FilterBuilder { + for(actor: SubjectRef): FilterBuilder; + resource(resourceType: string): FilterBuilder; + context(context: Record): FilterBuilder; + toPlan(target?: string): Promise; + toPredicate(): Promise<(resource: ResourceRef) => Promise>; +} +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/types.d.ts.map b/src/perm-authz-ts/packages/server/dist/types.d.ts.map new file mode 100644 index 0000000..e94995f --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/types.js b/src/perm-authz-ts/packages/server/dist/types.js new file mode 100644 index 0000000..718fd38 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/types.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/dist/types.js.map b/src/perm-authz-ts/packages/server/dist/types.js.map new file mode 100644 index 0000000..c768b79 --- /dev/null +++ b/src/perm-authz-ts/packages/server/dist/types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/server/package.json b/src/perm-authz-ts/packages/server/package.json new file mode 100644 index 0000000..0cc90be --- /dev/null +++ b/src/perm-authz-ts/packages/server/package.json @@ -0,0 +1,23 @@ +{ + "name": "@perm/server", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -b", + "typecheck": "tsc -b --pretty" + }, + "dependencies": { + "@perm/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.5.0" + } +} diff --git a/src/perm-authz-ts/packages/server/src/combiner.ts b/src/perm-authz-ts/packages/server/src/combiner.ts new file mode 100644 index 0000000..a089c57 --- /dev/null +++ b/src/perm-authz-ts/packages/server/src/combiner.ts @@ -0,0 +1,74 @@ +import type { EvalResult, PermissionDecision, PolicyIR, TraceEntry } from "@perm/core"; + +export interface EvaluatedPolicy { + policy: PolicyIR; + targetMatched: boolean; + evaluation?: EvalResult; +} + +export function combineEvaluatedPolicies(evaluated: EvaluatedPolicy[], defaultFallback: "deny" | "allow" = "deny"): PermissionDecision { + const applicable = evaluated.filter((entry) => entry.targetMatched && entry.evaluation?.value === true); + const deny = applicable + .filter((entry) => entry.policy.effect === "deny") + .sort((a, b) => b.policy.priority - a.policy.priority)[0]; + + if (deny) { + return { + effect: "deny", + policy: deny.policy.id, + code: deny.policy.code ?? "permission_denied", + reason: deny.policy.reason ?? `Denied by policy ${deny.policy.id}`, + advice: deny.policy.advice + }; + } + + const allow = applicable + .filter((entry) => entry.policy.effect === "allow") + .sort((a, b) => b.policy.priority - a.policy.priority)[0]; + + if (allow) { + return { + effect: "allow", + policy: allow.policy.id, + reason: allow.policy.reason, + obligations: allow.policy.obligations, + advice: allow.policy.advice + }; + } + + const errored = evaluated.find((entry) => entry.targetMatched && entry.evaluation?.value === "error"); + if (errored?.evaluation) { + return { + effect: "indeterminate", + reason: errored.evaluation.reason ?? "Authorization policy evaluation failed", + fallback: defaultFallback, + errors: errored.evaluation.error ? [errored.evaluation.error] : undefined + }; + } + + const unknown = evaluated.find((entry) => entry.targetMatched && entry.evaluation?.value === "unknown"); + if (unknown?.evaluation) { + return { + effect: "indeterminate", + reason: unknown.evaluation.reason ?? "Authorization policy evaluation is unknown", + fallback: defaultFallback + }; + } + + return { + effect: "not_applicable", + reason: "No matching policy allowed or denied the request" + }; +} + +export function toTrace(evaluated: EvaluatedPolicy[]): TraceEntry[] { + return evaluated.map((entry) => ({ + policy: entry.policy.id, + effect: entry.policy.effect, + targetMatched: entry.targetMatched, + condition: entry.targetMatched ? entry.policy.condition : undefined, + result: entry.evaluation?.value, + reason: entry.evaluation?.reason, + dependencies: entry.evaluation?.dependencies ?? [] + })); +} diff --git a/src/perm-authz-ts/packages/server/src/compilers/sql.ts b/src/perm-authz-ts/packages/server/src/compilers/sql.ts new file mode 100644 index 0000000..1b52dae --- /dev/null +++ b/src/perm-authz-ts/packages/server/src/compilers/sql.ts @@ -0,0 +1,176 @@ +import type { ExprIR, PolicyIR, QueryPlan, SubjectRef } from "@perm/core"; +import type { QueryCompiler } from "../types.js"; +import { actionMatches } from "../match.js"; + +export interface SqlCompileResult { + sql: string; + params: Record; +} + +export interface SqlRelationCompilerInput { + relation: string; + resourceAlias: string; + actor: SubjectRef; + param(value: unknown): string; +} + +export type SqlRelationCompiler = (input: SqlRelationCompilerInput) => string | undefined; + +export interface CreateSqlCompilerOptions { + resourceAlias?: string; + relation?: SqlRelationCompiler; + columnName?: (path: string) => string; +} + +function defaultColumnName(path: string): string { + return path.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`); +} + +function relevantPolicies(policies: PolicyIR[], action: string, resourceType: string): PolicyIR[] { + return policies.filter((policy) => { + const resourceMatches = !policy.target.resource || policy.target.resource === resourceType; + return resourceMatches && actionMatches(policy.target.action, action); + }); +} + +export function createSqlCompiler(options: CreateSqlCompilerOptions = {}): QueryCompiler { + const alias = options.resourceAlias ?? "resource"; + const columnName = options.columnName ?? defaultColumnName; + + return { + target: "sql", + compile(input): QueryPlan { + const policies = relevantPolicies(input.policies, input.action, input.resourceType); + const allowPolicies = policies.filter((policy) => policy.effect === "allow"); + const denyPolicies = policies.filter((policy) => policy.effect === "deny"); + const params: Record = {}; + let counter = 0; + const warnings: string[] = []; + const residualPolicies: PolicyIR[] = []; + + const param = (value: unknown): string => { + const key = `p${++counter}`; + params[key] = value; + return `:${key}`; + }; + + const compileExpr = (expr: ExprIR): string | undefined => { + switch (expr.op) { + case "const": + if (typeof expr.value === "boolean") return expr.value ? "TRUE" : "FALSE"; + return param(expr.value); + + case "ref": + if (expr.root === "resource") return `${alias}.${columnName(expr.path)}`; + if (expr.root === "actor") { + const value = expr.path ? (input.actor as Record)[expr.path] : input.actor; + return param(value); + } + if (expr.root === "context") { + const value = expr.path ? input.context?.[expr.path] : input.context; + return param(value); + } + return undefined; + + case "eq": + case "neq": + case "gt": + case "gte": + case "lt": + case "lte": { + const left = compileExpr(expr.left); + const right = compileExpr(expr.right); + if (!left || !right) return undefined; + const op = expr.op === "eq" ? "=" : expr.op === "neq" ? "<>" : expr.op === "gt" ? ">" : expr.op === "gte" ? ">=" : expr.op === "lt" ? "<" : "<="; + return `(${left} ${op} ${right})`; + } + + case "and": { + const parts = expr.args.map(compileExpr); + if (parts.some((part) => !part)) return undefined; + return `(${parts.join(" AND ")})`; + } + + case "or": { + const parts = expr.args.map(compileExpr); + if (parts.some((part) => !part)) return undefined; + return `(${parts.join(" OR ")})`; + } + + case "not": { + const inner = compileExpr(expr.expr); + return inner ? `(NOT ${inner})` : undefined; + } + + case "in": { + const value = compileExpr(expr.value); + if (!value) return undefined; + if (expr.set.op === "const" && Array.isArray(expr.set.value)) { + const list = expr.set.value.map(param).join(", "); + return `(${value} IN (${list}))`; + } + const set = compileExpr(expr.set); + return set ? `(${value} IN ${set})` : undefined; + } + + case "contains": { + const value = compileExpr(expr.value); + if (!value) return undefined; + if (expr.set.op === "const" && Array.isArray(expr.set.value)) { + const list = expr.set.value.map(param).join(", "); + return `(${value} IN (${list}))`; + } + return undefined; + } + + case "rel": { + const sql = options.relation?.({ relation: expr.path, resourceAlias: alias, actor: input.actor, param }); + if (!sql) return undefined; + return `(${sql})`; + } + + case "exists": + return undefined; + } + }; + + const allowSql: string[] = []; + for (const policy of allowPolicies) { + const compiled = compileExpr(policy.condition); + if (compiled) allowSql.push(compiled); + else residualPolicies.push(policy); + } + + const denySql: string[] = []; + for (const policy of denyPolicies) { + const compiled = compileExpr(policy.condition); + if (compiled) denySql.push(compiled); + else residualPolicies.push(policy); + } + + if (allowSql.length === 0 && residualPolicies.length > 0) { + return { + strategy: "not_compilable", + target: "sql", + residualPolicies, + warnings: ["No allow policy could be compiled to SQL."] + }; + } + + let sql = allowSql.length > 0 ? `(${allowSql.join(" OR ")})` : "FALSE"; + if (denySql.length > 0) sql = `(${sql}) AND NOT (${denySql.join(" OR ")})`; + + if (residualPolicies.length > 0) { + warnings.push("Some policies were not compilable and require residual checks."); + } + + return { + strategy: residualPolicies.length > 0 ? "partial" : "compiled", + target: "sql", + predicate: { sql, params } satisfies SqlCompileResult, + residualPolicies: residualPolicies.length ? residualPolicies : undefined, + warnings: warnings.length ? warnings : undefined + }; + } + }; +} diff --git a/src/perm-authz-ts/packages/server/src/createPermissions.ts b/src/perm-authz-ts/packages/server/src/createPermissions.ts new file mode 100644 index 0000000..c244a4a --- /dev/null +++ b/src/perm-authz-ts/packages/server/src/createPermissions.ts @@ -0,0 +1,181 @@ +import type { + DependencyKey, + ExplainResult, + PermissionDecision, + PolicyIR, + QueryPlan, + RequestContext, + ResourceRef, + SubjectRef +} from "@perm/core"; +import { PermissionDeniedError, actionResource, resourceKey } from "@perm/core"; +import { DefaultEvaluator } from "./evaluator.js"; +import { combineEvaluatedPolicies, toTrace, type EvaluatedPolicy } from "./combiner.js"; +import { actionsForResource, targetMatches } from "./match.js"; +import { reversePolicies } from "./reverse.js"; +import type { + CheckInput, + CreatePermissionsOptions, + FilterBuilder, + PermissionsRuntime, + ReverseQueryResult, + WhatResult +} from "./types.js"; + +function dedupe(values: T[]): T[] { + return [...new Set(values)]; +} + +function buildContext(input: CheckInput): RequestContext { + return { + actor: input.actor, + action: input.action, + resource: input.resource, + context: input.context, + requestId: input.requestId + }; +} + +export function createPermissions(options: CreatePermissionsOptions): PermissionsRuntime { + const evaluator = new DefaultEvaluator(options.providers); + const defaultFallback = options.defaultFallback ?? "deny"; + + async function evaluatePolicies(context: RequestContext): Promise { + const ordered = [...options.policies].sort((a, b) => b.priority - a.priority); + const entries: EvaluatedPolicy[] = []; + + for (const policy of ordered) { + const matched = targetMatches(policy, context); + if (!matched) { + entries.push({ policy, targetMatched: false }); + continue; + } + const evaluation = await evaluator.evaluate(policy.condition, context); + entries.push({ policy, targetMatched: true, evaluation }); + } + + return entries; + } + + async function explain(input: CheckInput): Promise { + const context = buildContext(input); + const evaluated = await evaluatePolicies(context); + const decision = combineEvaluatedPolicies(evaluated, defaultFallback); + const trace = toTrace(evaluated); + const dependencies = dedupe(trace.flatMap((entry) => entry.dependencies)); + + return { + actor: input.actor, + action: input.action, + resource: input.resource, + decision, + trace, + dependencies + }; + } + + async function check(input: CheckInput): Promise { + return (await explain(input)).decision; + } + + async function can(input: CheckInput): Promise { + const decision = await check(input); + return decision.effect === "allow"; + } + + async function assertAllowed(input: CheckInput): Promise { + const decision = await check(input); + if (decision.effect !== "allow") throw new PermissionDeniedError(decision); + } + + async function what(input: Omit & { actions?: string[] }): Promise { + const resourceType = input.resource?.type; + const actions = input.actions ?? (resourceType && options.schema.resources[resourceType] + ? actionsForResource(resourceType, options.schema.resources[resourceType]!.actions) + : dedupe(options.policies.map((policy) => policy.target.action).filter((action) => !action.endsWith(".*")))); + + const decisions: Record = {}; + for (const action of actions) { + decisions[action] = await check({ ...input, action }); + } + return { resource: input.resource, actions: decisions }; + } + + async function who(input: CheckInput & { resource: ResourceRef; subjectType?: string }): Promise { + const context = buildContext(input); + const matchingPolicies = options.policies.filter((policy) => targetMatches(policy, context)); + return reversePolicies({ + policies: matchingPolicies, + action: input.action, + resource: input.resource, + subjectType: input.subjectType ?? "user" + }); + } + + function filter(action: string): FilterBuilder { + let actor: SubjectRef | undefined; + let resourceType = actionResource(action) ?? "resource"; + let filterContext: Record | undefined; + + const builder: FilterBuilder = { + for(nextActor: SubjectRef) { + actor = nextActor; + return builder; + }, + + resource(nextResourceType: string) { + resourceType = nextResourceType; + return builder; + }, + + context(nextContext: Record) { + filterContext = nextContext; + return builder; + }, + + async toPlan(target = "memory"): Promise { + if (!actor) throw new Error("filter().for(actor) is required before toPlan()"); + if (target === "memory") { + return { + strategy: "compiled", + target: "memory", + predicate: "async (resource) => Perm.can({ actor, action, resource })" + }; + } + const compiler = options.compilers?.find((candidate) => candidate.target === target); + if (!compiler) { + return { + strategy: "not_compilable", + target, + warnings: [`No ${target} compiler configured`] + }; + } + return compiler.compile({ + schema: options.schema, + policies: options.policies, + action, + actor, + resourceType, + context: filterContext + }); + }, + + async toPredicate(): Promise<(resource: ResourceRef) => Promise> { + if (!actor) throw new Error("filter().for(actor) is required before toPredicate()"); + return async (resource: ResourceRef) => can({ actor: actor!, action, resource, context: filterContext }); + } + }; + + return builder; + } + + return { + check, + can, + assert: assertAllowed, + explain, + what, + who, + filter + }; +} diff --git a/src/perm-authz-ts/packages/server/src/evaluator.ts b/src/perm-authz-ts/packages/server/src/evaluator.ts new file mode 100644 index 0000000..d86cdda --- /dev/null +++ b/src/perm-authz-ts/packages/server/src/evaluator.ts @@ -0,0 +1,194 @@ +import type { DependencyKey, EvalResult, EvalValue, ExprIR, RequestContext, ResourceRef, SubjectRef } from "@perm/core"; +import { getPath } from "@perm/core"; +import type { PermissionProviders } from "./types.js"; + +function ok(value: EvalValue, dependencies: DependencyKey[] = [], reason?: string, error?: unknown): EvalResult { + const result: EvalResult = { value, dependencies }; + if (reason !== undefined) result.reason = reason; + if (error !== undefined) result.error = error; + return result; +} + +function dep(kind: "actor" | "resource" | "context" | "relation", key: string): DependencyKey { + return `${kind}:${key}`; +} + +function dedupeDeps(deps: DependencyKey[]): DependencyKey[] { + return [...new Set(deps)]; +} + +function compare(op: ExprIR extends { op: infer O } ? O : never, left: unknown, right: unknown): boolean | "unknown" { + switch (op) { + case "eq": + return left === right; + case "neq": + return left !== right; + case "gt": + return typeof left === "number" && typeof right === "number" ? left > right : "unknown"; + case "gte": + return typeof left === "number" && typeof right === "number" ? left >= right : "unknown"; + case "lt": + return typeof left === "number" && typeof right === "number" ? left < right : "unknown"; + case "lte": + return typeof left === "number" && typeof right === "number" ? left <= right : "unknown"; + default: + return "unknown"; + } +} + +export class DefaultEvaluator { + constructor(private readonly providers: PermissionProviders = {}) {} + + async evaluate(expr: ExprIR, context: RequestContext): Promise { + try { + return await this.evaluateInternal(expr, context); + } catch (error) { + return ok("error", [], "Expression evaluation failed", error); + } + } + + private async value(expr: ExprIR, context: RequestContext): Promise<{ value: unknown; dependencies: DependencyKey[]; unknown?: string }> { + const result = await this.evaluateInternal(expr, context); + if (result.value === "unknown" || result.value === "error") { + return { value: undefined, dependencies: result.dependencies, unknown: result.reason ?? String(result.value) }; + } + return { value: result.value, dependencies: result.dependencies }; + } + + private async rawValue(expr: ExprIR, context: RequestContext): Promise<{ value: unknown; dependencies: DependencyKey[]; unknown?: string }> { + if (expr.op === "const") return { value: expr.value, dependencies: [] }; + + if (expr.op === "ref") { + const key = expr.path ? `${expr.root}.${expr.path}` : expr.root; + const dependency = dep(expr.root, key); + if (this.providers.attributes) { + const provided = await this.providers.attributes.getAttribute({ root: expr.root, path: expr.path, context }); + return { value: provided, dependencies: [dependency] }; + } + + const root = expr.root === "actor" ? context.actor : expr.root === "resource" ? context.resource : context.context; + return { value: getPath(root, expr.path), dependencies: [dependency] }; + } + + if (expr.op === "rel") { + const evaluated = await this.evaluateInternal(expr, context); + const out: { value: unknown; dependencies: DependencyKey[]; unknown?: string } = { value: evaluated.value === true, dependencies: evaluated.dependencies }; + if (evaluated.value === "unknown" && evaluated.reason !== undefined) out.unknown = evaluated.reason; + return out; + } + + const evaluated = await this.evaluateInternal(expr, context); + if (evaluated.value === "unknown" || evaluated.value === "error") { + return { value: undefined, dependencies: evaluated.dependencies, unknown: evaluated.reason ?? String(evaluated.value) }; + } + return { value: evaluated.value, dependencies: evaluated.dependencies }; + } + + private async evaluateInternal(expr: ExprIR, context: RequestContext): Promise { + switch (expr.op) { + case "const": + return ok(Boolean(expr.value)); + + case "ref": { + const { value, dependencies } = await this.rawValue(expr, context); + return ok(Boolean(value), dependencies); + } + + case "eq": + case "neq": + case "gt": + case "gte": + case "lt": + case "lte": { + const left = await this.rawValue(expr.left, context); + const right = await this.rawValue(expr.right, context); + const dependencies = dedupeDeps([...left.dependencies, ...right.dependencies]); + if (left.unknown || right.unknown) return ok("unknown", dependencies, left.unknown ?? right.unknown); + const value = compare(expr.op, left.value, right.value); + return value === "unknown" ? ok("unknown", dependencies, `Cannot compare values using ${expr.op}`) : ok(value, dependencies); + } + + case "in": { + const value = await this.rawValue(expr.value, context); + const set = await this.rawValue(expr.set, context); + const dependencies = dedupeDeps([...value.dependencies, ...set.dependencies]); + if (value.unknown || set.unknown) return ok("unknown", dependencies, value.unknown ?? set.unknown); + if (!Array.isArray(set.value)) return ok("unknown", dependencies, "Right side of in() is not an array"); + return ok(set.value.includes(value.value), dependencies); + } + + case "contains": { + const set = await this.rawValue(expr.set, context); + const value = await this.rawValue(expr.value, context); + const dependencies = dedupeDeps([...value.dependencies, ...set.dependencies]); + if (value.unknown || set.unknown) return ok("unknown", dependencies, value.unknown ?? set.unknown); + if (!Array.isArray(set.value)) return ok("unknown", dependencies, "contains() target is not an array"); + return ok(set.value.includes(value.value), dependencies); + } + + case "and": { + const dependencies: DependencyKey[] = []; + let unknownReason: string | undefined; + for (const arg of expr.args) { + const result = await this.evaluateInternal(arg, context); + dependencies.push(...result.dependencies); + if (result.value === false) return ok(false, dedupeDeps(dependencies)); + if (result.value === "error") return ok("error", dedupeDeps(dependencies), result.reason, result.error); + if (result.value === "unknown") unknownReason = unknownReason ?? result.reason; + } + return unknownReason ? ok("unknown", dedupeDeps(dependencies), unknownReason) : ok(true, dedupeDeps(dependencies)); + } + + case "or": { + const dependencies: DependencyKey[] = []; + let unknownReason: string | undefined; + for (const arg of expr.args) { + const result = await this.evaluateInternal(arg, context); + dependencies.push(...result.dependencies); + if (result.value === true) return ok(true, dedupeDeps(dependencies)); + if (result.value === "error") return ok("error", dedupeDeps(dependencies), result.reason, result.error); + if (result.value === "unknown") unknownReason = unknownReason ?? result.reason; + } + return unknownReason ? ok("unknown", dedupeDeps(dependencies), unknownReason) : ok(false, dedupeDeps(dependencies)); + } + + case "not": { + const result = await this.evaluateInternal(expr.expr, context); + if (result.value === true) return ok(false, result.dependencies); + if (result.value === false) return ok(true, result.dependencies); + return result; + } + + case "rel": { + const relationKey = dep("relation", expr.path); + const resourceValue = expr.resource ? await this.rawValue(expr.resource, context) : { value: context.resource, dependencies: [] }; + const subjectValue = await this.rawValue(expr.subject, context); + const dependencies = dedupeDeps([relationKey, ...resourceValue.dependencies, ...subjectValue.dependencies]); + + if (resourceValue.unknown || subjectValue.unknown) { + return ok("unknown", dependencies, resourceValue.unknown ?? subjectValue.unknown); + } + + const resource = resourceValue.value as ResourceRef | undefined; + const subject = subjectValue.value as SubjectRef | undefined; + + if (!resource || typeof resource !== "object" || !resource.type) { + return ok("unknown", dependencies, `Relation ${expr.path} requires a resource`); + } + if (!subject || typeof subject !== "object" || !subject.type || !subject.id) { + return ok("unknown", dependencies, `Relation ${expr.path} requires a subject`); + } + if (!this.providers.relations) { + return ok("unknown", dependencies, `No relation provider configured for ${expr.path}`); + } + + const relationResult = await this.providers.relations.hasRelation({ relation: expr.path, resource, subject, context }); + if (relationResult === "unknown") return ok("unknown", dependencies, `Relation ${expr.path} is unknown`); + return ok(relationResult, dependencies); + } + + case "exists": + return ok("unknown", [dep("relation", expr.relation)], "exists() requires a query-capable provider"); + } + } +} diff --git a/src/perm-authz-ts/packages/server/src/http.ts b/src/perm-authz-ts/packages/server/src/http.ts new file mode 100644 index 0000000..d615dcc --- /dev/null +++ b/src/perm-authz-ts/packages/server/src/http.ts @@ -0,0 +1,78 @@ +import type { PermissionsRuntime, CheckInput } from "./types.js"; + +export interface AuthzHttpRequestLike { + method: string; + url?: string; + json(): Promise; +} + +export interface AuthzHttpResponse { + status: number; + body: unknown; +} + +export type ActorResolver = (request: AuthzHttpRequestLike, body: unknown) => Promise | CheckInput["actor"]; + +function assertBodyObject(body: unknown): asserts body is Record { + if (!body || typeof body !== "object") throw new Error("Request body must be an object"); +} + +export function createAuthzHttpHandlers(runtime: PermissionsRuntime, resolveActor: ActorResolver) { + return { + async check(request: AuthzHttpRequestLike): Promise { + const body = await request.json(); + assertBodyObject(body); + const actor = await resolveActor(request, body); + const decision = await runtime.check({ + actor, + action: String(body.action), + resource: body.resource as CheckInput["resource"], + context: body.context as CheckInput["context"] + }); + return { status: 200, body: decision }; + }, + + async batch(request: AuthzHttpRequestLike): Promise { + const body = await request.json(); + assertBodyObject(body); + const actor = await resolveActor(request, body); + const checks = Array.isArray(body.checks) ? body.checks as Array> : []; + const decisions: Record = {}; + + for (const check of checks) { + const resource = check.resource as CheckInput["resource"]; + const action = String(check.action); + const key = `${resource?.type ?? "resource"}:${resource?.id ?? "none"}:${action}`; + decisions[key] = await runtime.check({ actor, action, resource, context: check.context as CheckInput["context"] }); + } + + return { status: 200, body: { decisions } }; + }, + + async what(request: AuthzHttpRequestLike): Promise { + const body = await request.json(); + assertBodyObject(body); + const actor = await resolveActor(request, body); + const result = await runtime.what({ + actor, + resource: body.resource as CheckInput["resource"], + context: body.context as CheckInput["context"], + actions: Array.isArray(body.actions) ? body.actions.map(String) : undefined + }); + return { status: 200, body: result }; + }, + + async explain(request: AuthzHttpRequestLike): Promise { + const body = await request.json(); + assertBodyObject(body); + const actor = await resolveActor(request, body); + const result = await runtime.explain({ + actor, + action: String(body.action), + resource: body.resource as CheckInput["resource"], + context: body.context as CheckInput["context"] + }); + return { status: 200, body: result }; + } + }; +} diff --git a/src/perm-authz-ts/packages/server/src/index.ts b/src/perm-authz-ts/packages/server/src/index.ts new file mode 100644 index 0000000..2610598 --- /dev/null +++ b/src/perm-authz-ts/packages/server/src/index.ts @@ -0,0 +1,8 @@ +export * from "./types.js"; +export * from "./evaluator.js"; +export * from "./combiner.js"; +export * from "./match.js"; +export * from "./reverse.js"; +export * from "./createPermissions.js"; +export * from "./http.js"; +export * from "./compilers/sql.js"; diff --git a/src/perm-authz-ts/packages/server/src/match.ts b/src/perm-authz-ts/packages/server/src/match.ts new file mode 100644 index 0000000..24d6056 --- /dev/null +++ b/src/perm-authz-ts/packages/server/src/match.ts @@ -0,0 +1,23 @@ +import type { PolicyIR, RequestContext } from "@perm/core"; +import { actionResource } from "@perm/core"; + +export function actionMatches(pattern: string, action: string): boolean { + if (pattern === action) return true; + if (pattern.endsWith(".*")) { + const prefix = pattern.slice(0, -1); + return action.startsWith(prefix); + } + return false; +} + +export function targetMatches(policy: PolicyIR, context: RequestContext): boolean { + if (!actionMatches(policy.target.action, context.action)) return false; + const policyResource = policy.target.resource ?? actionResource(policy.target.action); + const requestResource = context.resource?.type ?? actionResource(context.action); + if (!policyResource || !requestResource) return true; + return policyResource === requestResource; +} + +export function actionsForResource(resourceType: string, actions: readonly string[]): string[] { + return actions.map((action) => action.includes(".") ? action : `${resourceType}.${action}`); +} diff --git a/src/perm-authz-ts/packages/server/src/reverse.ts b/src/perm-authz-ts/packages/server/src/reverse.ts new file mode 100644 index 0000000..f1d163f --- /dev/null +++ b/src/perm-authz-ts/packages/server/src/reverse.ts @@ -0,0 +1,73 @@ +import type { ExprIR, PolicyIR, ResourceRef } from "@perm/core"; +import type { ReverseQueryResult } from "./types.js"; + +function collect(expr: ExprIR, result: ReverseQueryResult, policy: PolicyIR, resource: ResourceRef): void { + switch (expr.op) { + case "rel": + result.sources.push({ + type: expr.path.includes(".") ? "relationExpansion" : "relation", + path: expr.path, + resource + }); + return; + + case "and": + case "or": + for (const arg of expr.args) collect(arg, result, policy, resource); + return; + + case "not": + collect(expr.expr, result, policy, resource); + return; + + case "eq": + case "neq": + case "gt": + case "gte": + case "lt": + case "lte": { + const left = expr.left; + const right = expr.right; + if (left.op === "ref" && left.root === "context" && right.op === "const") { + result.constraints.push({ type: "context", path: left.path, required: right.value, policy: policy.id }); + } else if (left.op === "ref" && right.op === "const") { + result.constraints.push({ type: "attribute", path: `${left.root}.${left.path}`, required: right.value, policy: policy.id }); + } + return; + } + + case "in": + case "contains": + case "exists": + case "const": + case "ref": + return; + } +} + +export function reversePolicies(input: { + policies: PolicyIR[]; + action: string; + resource: ResourceRef; + subjectType: string; +}): ReverseQueryResult { + const result: ReverseQueryResult = { + action: input.action, + resource: input.resource, + subjectType: input.subjectType, + sources: [], + constraints: [], + warnings: [] + }; + + for (const policy of input.policies) { + if (policy.effect !== "allow") continue; + collect(policy.condition, result, policy, input.resource); + } + + if (result.sources.length === 0) { + result.warnings.push("No invertible relation was found. The policy may depend only on attributes or custom predicates."); + } + + return result; +} diff --git a/src/perm-authz-ts/packages/server/src/types.ts b/src/perm-authz-ts/packages/server/src/types.ts new file mode 100644 index 0000000..7221735 --- /dev/null +++ b/src/perm-authz-ts/packages/server/src/types.ts @@ -0,0 +1,134 @@ +import type { + DependencyKey, + EvalResult, + ExprIR, + ExplainResult, + PermSchema, + PermissionDecision, + PolicyIR, + QueryPlan, + RequestContext, + ResourceRef, + SubjectRef +} from "@perm/core"; + +export type RelationResult = boolean | "unknown"; + +export interface RelationProvider { + hasRelation(input: { + relation: string; + resource: ResourceRef; + subject: SubjectRef; + context: RequestContext; + }): Promise | RelationResult; + + listSubjects?(input: { + relation: string; + resource: ResourceRef; + subjectType?: string; + context: RequestContext; + }): Promise | SubjectRef[]; + + listResources?(input: { + relation: string; + subject: SubjectRef; + resourceType: string; + context: RequestContext; + }): Promise | ResourceRef[]; +} + +export interface AttributeProvider { + getAttribute(input: { + root: "actor" | "resource" | "context"; + path: string; + context: RequestContext; + }): Promise | unknown; +} + +export interface SubscriptionProvider { + subscribe(dependencies: DependencyKey[], cb: () => void): () => void; +} + +export interface QueryCompiler { + target: string; + compile(input: { + schema: PermSchema; + policies: PolicyIR[]; + action: string; + actor: SubjectRef; + resourceType: string; + context?: Record; + }): Promise | QueryPlan; +} + +export interface PermissionProviders { + attributes?: AttributeProvider; + relations?: RelationProvider; + subscriptions?: SubscriptionProvider; +} + +export interface CreatePermissionsOptions { + schema: PermSchema; + policies: PolicyIR[]; + providers?: PermissionProviders; + compilers?: QueryCompiler[]; + defaultFallback?: "deny" | "allow"; +} + +export interface EvaluateOptions { + providers?: PermissionProviders; +} + +export interface Evaluator { + evaluate(expr: ExprIR, context: RequestContext): Promise; +} + +export interface CheckInput { + actor: SubjectRef; + action: string; + resource?: ResourceRef; + context?: Record; + requestId?: string; +} + +export interface WhatResult { + resource?: ResourceRef; + actions: Record; +} + +export interface ReverseQueryResult { + action: string; + resource: ResourceRef; + subjectType: string; + sources: Array<{ + type: "relation" | "relationExpansion" | "policy"; + path?: string; + policy?: string; + resource?: ResourceRef; + }>; + constraints: Array<{ + type: "context" | "attribute" | "unknown"; + path?: string; + required?: unknown; + policy?: string; + }>; + warnings: string[]; +} + +export interface PermissionsRuntime { + check(input: CheckInput): Promise; + can(input: CheckInput): Promise; + assert(input: CheckInput): Promise; + explain(input: CheckInput): Promise; + what(input: Omit & { actions?: string[] }): Promise; + who(input: CheckInput & { resource: ResourceRef; subjectType?: string }): Promise; + filter(action: string): FilterBuilder; +} + +export interface FilterBuilder { + for(actor: SubjectRef): FilterBuilder; + resource(resourceType: string): FilterBuilder; + context(context: Record): FilterBuilder; + toPlan(target?: string): Promise; + toPredicate(): Promise<(resource: ResourceRef) => Promise>; +} diff --git a/src/perm-authz-ts/packages/server/tsconfig.json b/src/perm-authz-ts/packages/server/tsconfig.json new file mode 100644 index 0000000..decb378 --- /dev/null +++ b/src/perm-authz-ts/packages/server/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "references": [ + { "path": "../core" } + ], + "include": ["src"] +} diff --git a/src/perm-authz-ts/packages/server/tsconfig.tsbuildinfo b/src/perm-authz-ts/packages/server/tsconfig.tsbuildinfo new file mode 100644 index 0000000..23f86e4 --- /dev/null +++ b/src/perm-authz-ts/packages/server/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":["../../../../node_modules/typescript/lib/lib.es5.d.ts","../../../../node_modules/typescript/lib/lib.es2015.d.ts","../../../../node_modules/typescript/lib/lib.es2016.d.ts","../../../../node_modules/typescript/lib/lib.es2017.d.ts","../../../../node_modules/typescript/lib/lib.es2018.d.ts","../../../../node_modules/typescript/lib/lib.es2019.d.ts","../../../../node_modules/typescript/lib/lib.es2020.d.ts","../../../../node_modules/typescript/lib/lib.es2021.d.ts","../../../../node_modules/typescript/lib/lib.es2022.d.ts","../../../../node_modules/typescript/lib/lib.dom.d.ts","../../../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../../../node_modules/typescript/lib/lib.dom.asynciterable.d.ts","../../../../node_modules/typescript/lib/lib.webworker.importscripts.d.ts","../../../../node_modules/typescript/lib/lib.scripthost.d.ts","../../../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../../../node_modules/typescript/lib/lib.es2016.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../../../node_modules/typescript/lib/lib.decorators.d.ts","../../../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../../../node_modules/typescript/lib/lib.es2022.full.d.ts","../core/dist/types.d.ts","../core/dist/schema.d.ts","../core/dist/builder.d.ts","../core/dist/path.d.ts","../core/dist/index.d.ts","./src/combiner.ts","./src/types.ts","./src/evaluator.ts","./src/match.ts","./src/reverse.ts","./src/createpermissions.ts","./src/http.ts","./src/compilers/sql.ts","./src/index.ts"],"fileIdsList":[[64],[64,65,66,67],[68],[68,70,72],[68,69,70,71,72,73],[68,70],[70],[69,70,71,72,73,74,75,76]],"fileInfos":[{"version":"bcd24271a113971ba9eb71ff8cb01bc6b0f872a85c23fdbe5d93065b375933cd","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f88bedbeb09c6f5a6645cb24c7c55f1aa22d19ae96c8e6959cbd8b85a707bc6","impliedFormat":1},{"version":"7fe93b39b810eadd916be8db880dd7f0f7012a5cc6ffb62de8f62a2117fa6f1f","impliedFormat":1},{"version":"bb0074cc08b84a2374af33d8bf044b80851ccc9e719a5e202eacf40db2c31600","impliedFormat":1},{"version":"1a7daebe4f45fb03d9ec53d60008fbf9ac45a697fdc89e4ce218bc94b94f94d6","impliedFormat":1},{"version":"f94b133a3cb14a288803be545ac2683e0d0ff6661bcd37e31aaaec54fc382aed","impliedFormat":1},{"version":"f59d0650799f8782fd74cf73c19223730c6d1b9198671b1c5b3a38e1188b5953","impliedFormat":1},{"version":"8a15b4607d9a499e2dbeed9ec0d3c0d7372c850b2d5f1fb259e8f6d41d468a84","impliedFormat":1},{"version":"26e0fe14baee4e127f4365d1ae0b276f400562e45e19e35fd2d4c296684715e6","impliedFormat":1},{"version":"d6b1eba8496bdd0eed6fc8a685768fe01b2da4a0388b5fe7df558290bffcf32f","affectsGlobalScope":true,"impliedFormat":1},{"version":"7f57fc4404ff020bc45b9c620aff2b40f700b95fe31164024c453a5e3c163c54","impliedFormat":1},{"version":"7f57fc4404ff020bc45b9c620aff2b40f700b95fe31164024c453a5e3c163c54","impliedFormat":1},{"version":"2a2de5b9459b3fc44decd9ce6100b72f1b002ef523126c1d3d8b2a4a63d74d78","affectsGlobalScope":true,"impliedFormat":1},{"version":"f13f4b465c99041e912db5c44129a94588e1aafee35a50eab51044833f50b4ee","affectsGlobalScope":true,"impliedFormat":1},{"version":"eadcffda2aa84802c73938e589b9e58248d74c59cb7fcbca6474e3435ac15504","affectsGlobalScope":true,"impliedFormat":1},{"version":"105ba8ff7ba746404fe1a2e189d1d3d2e0eb29a08c18dded791af02f29fb4711","affectsGlobalScope":true,"impliedFormat":1},{"version":"00343ca5b2e3d48fa5df1db6e32ea2a59afab09590274a6cccb1dbae82e60c7c","affectsGlobalScope":true,"impliedFormat":1},{"version":"ebd9f816d4002697cb2864bea1f0b70a103124e18a8cd9645eeccc09bdf80ab4","affectsGlobalScope":true,"impliedFormat":1},{"version":"2c1afac30a01772cd2a9a298a7ce7706b5892e447bb46bdbeef720f7b5da77ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"7b0225f483e4fa685625ebe43dd584bb7973bbd84e66a6ba7bbe175ee1048b4f","affectsGlobalScope":true,"impliedFormat":1},{"version":"c0a4b8ac6ce74679c1da2b3795296f5896e31c38e888469a8e0f99dc3305de60","affectsGlobalScope":true,"impliedFormat":1},{"version":"3084a7b5f569088e0146533a00830e206565de65cae2239509168b11434cd84f","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5079c53f0f141a0698faa903e76cb41cd664e3efb01cc17a5c46ec2eb0bef42","affectsGlobalScope":true,"impliedFormat":1},{"version":"32cafbc484dea6b0ab62cf8473182bbcb23020d70845b406f80b7526f38ae862","affectsGlobalScope":true,"impliedFormat":1},{"version":"fca4cdcb6d6c5ef18a869003d02c9f0fd95df8cfaf6eb431cd3376bc034cad36","affectsGlobalScope":true,"impliedFormat":1},{"version":"b93ec88115de9a9dc1b602291b85baf825c85666bf25985cc5f698073892b467","affectsGlobalScope":true,"impliedFormat":1},{"version":"f5c06dcc3fe849fcb297c247865a161f995cc29de7aa823afdd75aaaddc1419b","affectsGlobalScope":true,"impliedFormat":1},{"version":"b77e16112127a4b169ef0b8c3a4d730edf459c5f25fe52d5e436a6919206c4d7","affectsGlobalScope":true,"impliedFormat":1},{"version":"fbffd9337146eff822c7c00acbb78b01ea7ea23987f6c961eba689349e744f8c","affectsGlobalScope":true,"impliedFormat":1},{"version":"a995c0e49b721312f74fdfb89e4ba29bd9824c770bbb4021d74d2bf560e4c6bd","affectsGlobalScope":true,"impliedFormat":1},{"version":"c7b3542146734342e440a84b213384bfa188835537ddbda50d30766f0593aff9","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce6180fa19b1cccd07ee7f7dbb9a367ac19c0ed160573e4686425060b6df7f57","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f02e2476bccb9dbe21280d6090f0df17d2f66b74711489415a8aa4df73c9675","affectsGlobalScope":true,"impliedFormat":1},{"version":"45e3ab34c1c013c8ab2dc1ba4c80c780744b13b5676800ae2e3be27ae862c40c","affectsGlobalScope":true,"impliedFormat":1},{"version":"805c86f6cca8d7702a62a844856dbaa2a3fd2abef0536e65d48732441dde5b5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"e42e397f1a5a77994f0185fd1466520691456c772d06bf843e5084ceb879a0ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"f4c2b41f90c95b1c532ecc874bd3c111865793b23aebcc1c3cbbabcd5d76ffb0","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab26191cfad5b66afa11b8bf935ef1cd88fabfcb28d30b2dfa6fad877d050332","affectsGlobalScope":true,"impliedFormat":1},{"version":"2088bc26531e38fb05eedac2951480db5309f6be3fa4a08d2221abb0f5b4200d","affectsGlobalScope":true,"impliedFormat":1},{"version":"cb9d366c425fea79716a8fb3af0d78e6b22ebbab3bd64d25063b42dc9f531c1e","affectsGlobalScope":true,"impliedFormat":1},{"version":"500934a8089c26d57ebdb688fc9757389bb6207a3c8f0674d68efa900d2abb34","affectsGlobalScope":true,"impliedFormat":1},{"version":"689da16f46e647cef0d64b0def88910e818a5877ca5379ede156ca3afb780ac3","affectsGlobalScope":true,"impliedFormat":1},{"version":"bc21cc8b6fee4f4c2440d08035b7ea3c06b3511314c8bab6bef7a92de58a2593","affectsGlobalScope":true,"impliedFormat":1},{"version":"7ca53d13d2957003abb47922a71866ba7cb2068f8d154877c596d63c359fed25","affectsGlobalScope":true,"impliedFormat":1},{"version":"54725f8c4df3d900cb4dac84b64689ce29548da0b4e9b7c2de61d41c79293611","affectsGlobalScope":true,"impliedFormat":1},{"version":"e5594bc3076ac29e6c1ebda77939bc4c8833de72f654b6e376862c0473199323","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f3eb332c2d73e729f3364fcc0c2b375e72a121e8157d25a82d67a138c83a95c","affectsGlobalScope":true,"impliedFormat":1},{"version":"6f4427f9642ce8d500970e4e69d1397f64072ab73b97e476b4002a646ac743b1","affectsGlobalScope":true,"impliedFormat":1},{"version":"48915f327cd1dea4d7bd358d9dc7732f58f9e1626a29cc0c05c8c692419d9bb7","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7bf9377723203b5a6a4b920164df22d56a43f593269ba6ae1fdc97774b68855","affectsGlobalScope":true,"impliedFormat":1},{"version":"db9709688f82c9e5f65a119c64d835f906efe5f559d08b11642d56eb85b79357","affectsGlobalScope":true,"impliedFormat":1},{"version":"4b25b8c874acd1a4cf8444c3617e037d444d19080ac9f634b405583fd10ce1f7","affectsGlobalScope":true,"impliedFormat":1},{"version":"37be57d7c90cf1f8112ee2636a068d8fd181289f82b744160ec56a7dc158a9f5","affectsGlobalScope":true,"impliedFormat":1},{"version":"a917a49ac94cd26b754ab84e113369a75d1a47a710661d7cd25e961cc797065f","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d3261badeb7843d157ef3e6f5d1427d0eeb0af0cf9df84a62cfd29fd47ac86e","affectsGlobalScope":true,"impliedFormat":1},{"version":"195daca651dde22f2167ac0d0a05e215308119a3100f5e6268e8317d05a92526","affectsGlobalScope":true,"impliedFormat":1},{"version":"8b11e4285cd2bb164a4dc09248bdec69e9842517db4ca47c1ba913011e44ff2f","affectsGlobalScope":true,"impliedFormat":1},{"version":"0508571a52475e245b02bc50fa1394065a0a3d05277fbf5120c3784b85651799","affectsGlobalScope":true,"impliedFormat":1},{"version":"8f9af488f510c3015af3cc8c267a9e9d96c4dd38a1fdff0e11dc5a544711415b","affectsGlobalScope":true,"impliedFormat":1},{"version":"fc611fea8d30ea72c6bbfb599c9b4d393ce22e2f5bfef2172534781e7d138104","affectsGlobalScope":true,"impliedFormat":1},{"version":"1ce14b81c5cc821994aa8ec1d42b220dd41b27fcc06373bce3958af7421b77d4","affectsGlobalScope":true,"impliedFormat":1},{"version":"b3a048b3e9302ef9a34ef4ebb9aecfb28b66abb3bce577206a79fee559c230da","affectsGlobalScope":true,"impliedFormat":1},{"version":"01a30f9e8582b369075c0808df71121e6855cb06fd8d3d39511d9ebb66405205","impliedFormat":1},"a9637e1b2642463e77fc3b3b6b4d11524cf9f09a74758d5234de0ba5c28264e3","64603d3a21cace27a06a275c25b09db14ca4498fe121578865e29ad21914397e","504c2af83052182afaa2393a26b219324b1c5d044b64ed6c3e710936db12f62d","72cf94a2c467f63fe221dbad5f408b32b8281d3512d4bc02a3dd0fc675a96f55","a6a4f3dfe216ab809ad0c82b2154de582df3b7efd31f23d4f404d64dacb08be5",{"version":"99d129faa46663807f942a83ade448c8b7db1687900ad11e0a9062a5987c4855","signature":"c01845a47cc2d5dcc38a9aeccad8a24626affede09c6e696b66044df63000602"},{"version":"f34b393bb32ca24278ce82da27c598a3fa57d2a927017a732a5b4d1c86b8aad2","signature":"bf169ee41367ea7debe0d87a0c120aec8f12966da64aabaacfb5dfd65ef77bc6"},{"version":"29c162a9c3b810c6518336273c7e975df9a5be2c18b92d7b61047e67a516973d","signature":"75d74c109d0bbc45cc57ea38a9f98d2785966a1414e50ad260990bfa99b76c79"},{"version":"7f9387cd8209bb53844097d495ce66316848453a0c693ecde326abf572d6f7da","signature":"ac6fb4ba91091c8f3f1a39b2590cb870f8cae5822343ce51edb57f1a59e46b4e"},{"version":"ef5be7f3d9b648fe6f845de185a7102125db29527eb80bdc646192bf48638bcc","signature":"e48c6e339a5cf45bc5741f8a0c4c1390325ce3fc63fe9402e02a1d903f53d87e"},{"version":"34b2c841d284ea7c207d88e5fdbd191a35bb46db30e7f44cb8356d88ada315fb","signature":"9438ed5ab5689fc48c2169f41c2b727e9c9cae7ae47ac0ac6530075388126d47"},{"version":"a795215ae7353778e0f7e187d76aa974ce73f31eee59ed9de472656fa8111015","signature":"3e425780904a0c3f44dc6838607a8db7034f07214cdf0fe845f902ad98fb2bbf"},{"version":"d99a3411f1b5dc04b6722d0640760208e5401379aea92089f7fea71ca2b208f1","signature":"2702620d49258484d6e0d825fdef9a25cd70c76373ec93d7e95795900cfe5d80"},"9dee5defb57114d21a25c1d13a401e56a15e6346e7eafeeeddf7f5ebf9e6f93d"],"root":[[69,77]],"options":{"composite":true,"declaration":true,"declarationMap":true,"exactOptionalPropertyTypes":false,"module":99,"noUncheckedIndexedAccess":true,"outDir":"./dist","rootDir":"./src","skipLibCheck":true,"sourceMap":true,"strict":true,"target":9,"verbatimModuleSyntax":true},"referencedMap":[[66,1],[68,2],[65,1],[69,3],[76,4],[74,5],[71,6],[75,7],[77,8],[72,3],[73,6],[70,3]],"semanticDiagnosticsPerFile":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77],"latestChangedDtsFile":"./dist/index.d.ts","version":"6.0.3"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/svelte/dist/context.d.ts b/src/perm-authz-ts/packages/svelte/dist/context.d.ts new file mode 100644 index 0000000..233bd42 --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/dist/context.d.ts @@ -0,0 +1,4 @@ +import type { SveltePermissionClient } from "./createSveltePermissions.js"; +export declare function setPermissionsContext(client: SveltePermissionClient): SveltePermissionClient; +export declare function getPermissionsContext(): SveltePermissionClient; +//# sourceMappingURL=context.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/svelte/dist/context.d.ts.map b/src/perm-authz-ts/packages/svelte/dist/context.d.ts.map new file mode 100644 index 0000000..c244c27 --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/dist/context.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,8BAA8B,CAAC;AAI3E,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,sBAAsB,GAAG,sBAAsB,CAG5F;AAED,wBAAgB,qBAAqB,IAAI,sBAAsB,CAI9D"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/svelte/dist/context.js b/src/perm-authz-ts/packages/svelte/dist/context.js new file mode 100644 index 0000000..fb7baf3 --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/dist/context.js @@ -0,0 +1,13 @@ +import { getContext, setContext } from "svelte"; +const PERM_CONTEXT_KEY = Symbol("perm.svelte.context"); +export function setPermissionsContext(client) { + setContext(PERM_CONTEXT_KEY, client); + return client; +} +export function getPermissionsContext() { + const client = getContext(PERM_CONTEXT_KEY); + if (!client) + throw new Error("No permission client found in Svelte context"); + return client; +} +//# sourceMappingURL=context.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/svelte/dist/context.js.map b/src/perm-authz-ts/packages/svelte/dist/context.js.map new file mode 100644 index 0000000..a56a78a --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/dist/context.js.map @@ -0,0 +1 @@ +{"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAGhD,MAAM,gBAAgB,GAAG,MAAM,CAAC,qBAAqB,CAAC,CAAC;AAEvD,MAAM,UAAU,qBAAqB,CAAC,MAA8B;IAClE,UAAU,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC;IACrC,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,qBAAqB;IACnC,MAAM,MAAM,GAAG,UAAU,CAAyB,gBAAgB,CAAC,CAAC;IACpE,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,KAAK,CAAC,8CAA8C,CAAC,CAAC;IAC7E,OAAO,MAAM,CAAC;AAChB,CAAC"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/svelte/dist/createSveltePermissions.d.ts b/src/perm-authz-ts/packages/svelte/dist/createSveltePermissions.d.ts new file mode 100644 index 0000000..7cc4e53 --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/dist/createSveltePermissions.d.ts @@ -0,0 +1,15 @@ +import type { PermissionDecision, ResourceRef } from "@perm/core"; +import type { ClientCheckInput, PermissionClient, PermissionClientOptions, PermissionSnapshot } from "@perm/client"; +import { type Readable } from "svelte/store"; +export interface SveltePermissionClient extends PermissionClient { + snapshotStore: Readable; + decision(input: ClientCheckInput): Readable; + allowed(input: ClientCheckInput): Readable; + whatStore(input: { + resource?: ResourceRef; + actions?: string[]; + context?: Record; + }): Readable>; +} +export declare function createSveltePermissions(options: PermissionClientOptions): SveltePermissionClient; +//# sourceMappingURL=createSveltePermissions.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/svelte/dist/createSveltePermissions.d.ts.map b/src/perm-authz-ts/packages/svelte/dist/createSveltePermissions.d.ts.map new file mode 100644 index 0000000..f635d8f --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/dist/createSveltePermissions.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"createSveltePermissions.d.ts","sourceRoot":"","sources":["../src/createSveltePermissions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAClE,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEpH,OAAO,EAA+B,KAAK,QAAQ,EAAE,MAAM,cAAc,CAAC;AAE1E,MAAM,WAAW,sBAAuB,SAAQ,gBAAgB;IAC9D,aAAa,EAAE,QAAQ,CAAC,kBAAkB,CAAC,CAAC;IAC5C,QAAQ,CAAC,KAAK,EAAE,gBAAgB,GAAG,QAAQ,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAAC;IAC5E,OAAO,CAAC,KAAK,EAAE,gBAAgB,GAAG,QAAQ,CAAC,OAAO,CAAC,CAAC;IACpD,SAAS,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,EAAE,WAAW,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC,CAAC;CACnJ;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,uBAAuB,GAAG,sBAAsB,CAoChG"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/svelte/dist/createSveltePermissions.js b/src/perm-authz-ts/packages/svelte/dist/createSveltePermissions.js new file mode 100644 index 0000000..29aa64e --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/dist/createSveltePermissions.js @@ -0,0 +1,36 @@ +import { createPermissionClient } from "@perm/client"; +import { derived, readable, writable } from "svelte/store"; +export function createSveltePermissions(options) { + const client = createPermissionClient(options); + const snapshotWritable = writable(client.snapshot()); + client.subscribe((snapshot) => snapshotWritable.set(snapshot)); + function decision(input) { + void client.check(input); + return derived(snapshotWritable, ($snapshot) => { + const key = client.decisionKey(input); + return $snapshot.decisions?.[key]; + }); + } + function allowed(input) { + return derived(decision(input), ($decision) => $decision?.effect === "allow"); + } + function whatStore(input) { + return readable({}, (set) => { + let cancelled = false; + client.what(input).then((actions) => { + if (!cancelled) + set(actions); + }); + return () => { + cancelled = true; + }; + }); + } + return Object.assign(client, { + snapshotStore: { subscribe: snapshotWritable.subscribe }, + decision, + allowed, + whatStore + }); +} +//# sourceMappingURL=createSveltePermissions.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/svelte/dist/createSveltePermissions.js.map b/src/perm-authz-ts/packages/svelte/dist/createSveltePermissions.js.map new file mode 100644 index 0000000..67858bb --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/dist/createSveltePermissions.js.map @@ -0,0 +1 @@ +{"version":3,"file":"createSveltePermissions.js","sourceRoot":"","sources":["../src/createSveltePermissions.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AACtD,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAiB,MAAM,cAAc,CAAC;AAS1E,MAAM,UAAU,uBAAuB,CAAC,OAAgC;IACtE,MAAM,MAAM,GAAG,sBAAsB,CAAC,OAAO,CAAC,CAAC;IAC/C,MAAM,gBAAgB,GAAG,QAAQ,CAAqB,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC;IAEzE,MAAM,CAAC,SAAS,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,gBAAgB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC;IAE/D,SAAS,QAAQ,CAAC,KAAuB;QACvC,KAAK,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACzB,OAAO,OAAO,CAAC,gBAAgB,EAAE,CAAC,SAAS,EAAE,EAAE;YAC7C,MAAM,GAAG,GAAG,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;YACtC,OAAO,SAAS,CAAC,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC;QACpC,CAAC,CAAC,CAAC;IACL,CAAC;IAED,SAAS,OAAO,CAAC,KAAuB;QACtC,OAAO,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC;IAChF,CAAC;IAED,SAAS,SAAS,CAAC,KAAwF;QACzG,OAAO,QAAQ,CAAqC,EAAE,EAAE,CAAC,GAAG,EAAE,EAAE;YAC9D,IAAI,SAAS,GAAG,KAAK,CAAC;YACtB,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE;gBAClC,IAAI,CAAC,SAAS;oBAAE,GAAG,CAAC,OAAO,CAAC,CAAC;YAC/B,CAAC,CAAC,CAAC;YACH,OAAO,GAAG,EAAE;gBACV,SAAS,GAAG,IAAI,CAAC;YACnB,CAAC,CAAC;QACJ,CAAC,CAAC,CAAC;IACL,CAAC;IAED,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE;QAC3B,aAAa,EAAE,EAAE,SAAS,EAAE,gBAAgB,CAAC,SAAS,EAAE;QACxD,QAAQ;QACR,OAAO;QACP,SAAS;KACV,CAAC,CAAC;AACL,CAAC"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/svelte/dist/index.d.ts b/src/perm-authz-ts/packages/svelte/dist/index.d.ts new file mode 100644 index 0000000..e3a4228 --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/dist/index.d.ts @@ -0,0 +1,3 @@ +export * from "./createSveltePermissions.js"; +export * from "./context.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/svelte/dist/index.d.ts.map b/src/perm-authz-ts/packages/svelte/dist/index.d.ts.map new file mode 100644 index 0000000..551c66a --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,8BAA8B,CAAC;AAC7C,cAAc,cAAc,CAAC"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/svelte/dist/index.js b/src/perm-authz-ts/packages/svelte/dist/index.js new file mode 100644 index 0000000..da8ce86 --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/dist/index.js @@ -0,0 +1,3 @@ +export * from "./createSveltePermissions.js"; +export * from "./context.js"; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/src/perm-authz-ts/packages/svelte/dist/index.js.map b/src/perm-authz-ts/packages/svelte/dist/index.js.map new file mode 100644 index 0000000..39e5a41 --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,8BAA8B,CAAC;AAC7C,cAAc,cAAc,CAAC"} \ No newline at end of file diff --git a/src/perm-authz-ts/packages/svelte/package.json b/src/perm-authz-ts/packages/svelte/package.json new file mode 100644 index 0000000..c207986 --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/package.json @@ -0,0 +1,31 @@ +{ + "name": "@perm/svelte", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "svelte": "src/Can.svelte", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./src/index.ts", + "import": "./dist/index.js" + }, + "./Can.svelte": "./src/Can.svelte" + }, + "scripts": { + "build": "tsc -b", + "typecheck": "tsc -b --pretty" + }, + "dependencies": { + "@perm/client": "workspace:*", + "@perm/core": "workspace:*" + }, + "peerDependencies": { + "svelte": ">=4" + }, + "devDependencies": { + "typescript": "^5.5.0", + "svelte": "^5.0.0" + } +} diff --git a/src/perm-authz-ts/packages/svelte/src/Can.svelte b/src/perm-authz-ts/packages/svelte/src/Can.svelte new file mode 100644 index 0000000..d9a10b3 --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/src/Can.svelte @@ -0,0 +1,20 @@ + + +{#if $allowed} + +{:else if loading} + +{:else} + +{/if} diff --git a/src/perm-authz-ts/packages/svelte/src/context.ts b/src/perm-authz-ts/packages/svelte/src/context.ts new file mode 100644 index 0000000..cf16746 --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/src/context.ts @@ -0,0 +1,15 @@ +import { getContext, setContext } from "svelte"; +import type { SveltePermissionClient } from "./createSveltePermissions.js"; + +const PERM_CONTEXT_KEY = Symbol("perm.svelte.context"); + +export function setPermissionsContext(client: SveltePermissionClient): SveltePermissionClient { + setContext(PERM_CONTEXT_KEY, client); + return client; +} + +export function getPermissionsContext(): SveltePermissionClient { + const client = getContext(PERM_CONTEXT_KEY); + if (!client) throw new Error("No permission client found in Svelte context"); + return client; +} diff --git a/src/perm-authz-ts/packages/svelte/src/createSveltePermissions.ts b/src/perm-authz-ts/packages/svelte/src/createSveltePermissions.ts new file mode 100644 index 0000000..d87953f --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/src/createSveltePermissions.ts @@ -0,0 +1,49 @@ +import type { PermissionDecision, ResourceRef } from "@perm/core"; +import type { ClientCheckInput, PermissionClient, PermissionClientOptions, PermissionSnapshot } from "@perm/client"; +import { createPermissionClient } from "@perm/client"; +import { derived, readable, writable, type Readable } from "svelte/store"; + +export interface SveltePermissionClient extends PermissionClient { + snapshotStore: Readable; + decision(input: ClientCheckInput): Readable; + allowed(input: ClientCheckInput): Readable; + whatStore(input: { resource?: ResourceRef; actions?: string[]; context?: Record }): Readable>; +} + +export function createSveltePermissions(options: PermissionClientOptions): SveltePermissionClient { + const client = createPermissionClient(options); + const snapshotWritable = writable(client.snapshot()); + + client.subscribe((snapshot) => snapshotWritable.set(snapshot)); + + function decision(input: ClientCheckInput): Readable { + void client.check(input); + return derived(snapshotWritable, ($snapshot) => { + const key = client.decisionKey(input); + return $snapshot.decisions?.[key]; + }); + } + + function allowed(input: ClientCheckInput): Readable { + return derived(decision(input), ($decision) => $decision?.effect === "allow"); + } + + function whatStore(input: { resource?: ResourceRef; actions?: string[]; context?: Record }): Readable> { + return readable>({}, (set) => { + let cancelled = false; + client.what(input).then((actions) => { + if (!cancelled) set(actions); + }); + return () => { + cancelled = true; + }; + }); + } + + return Object.assign(client, { + snapshotStore: { subscribe: snapshotWritable.subscribe }, + decision, + allowed, + whatStore + }); +} diff --git a/src/perm-authz-ts/packages/svelte/src/index.ts b/src/perm-authz-ts/packages/svelte/src/index.ts new file mode 100644 index 0000000..74f4aeb --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/src/index.ts @@ -0,0 +1,2 @@ +export * from "./createSveltePermissions.js"; +export * from "./context.js"; diff --git a/src/perm-authz-ts/packages/svelte/src/svelte-shim.d.ts b/src/perm-authz-ts/packages/svelte/src/svelte-shim.d.ts new file mode 100644 index 0000000..e2f9c46 --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/src/svelte-shim.d.ts @@ -0,0 +1,17 @@ +declare module "svelte/store" { + export interface Readable { + subscribe(run: (value: T) => void): () => void; + } + export interface Writable extends Readable { + set(value: T): void; + update(updater: (value: T) => T): void; + } + export function writable(value: T): Writable; + export function readable(value: T, start?: (set: (value: T) => void) => void | (() => void)): Readable; + export function derived(store: Readable, fn: (value: A) => B): Readable; +} + +declare module "svelte" { + export function setContext(key: unknown, context: T): T; + export function getContext(key: unknown): T; +} diff --git a/src/perm-authz-ts/packages/svelte/tsconfig.json b/src/perm-authz-ts/packages/svelte/tsconfig.json new file mode 100644 index 0000000..7e209f9 --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "references": [ + { "path": "../core" }, + { "path": "../client" } + ], + "include": ["src/**/*.ts"] +} diff --git a/src/perm-authz-ts/packages/svelte/tsconfig.tsbuildinfo b/src/perm-authz-ts/packages/svelte/tsconfig.tsbuildinfo new file mode 100644 index 0000000..ec541db --- /dev/null +++ b/src/perm-authz-ts/packages/svelte/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":["../../../../node_modules/typescript/lib/lib.es5.d.ts","../../../../node_modules/typescript/lib/lib.es2015.d.ts","../../../../node_modules/typescript/lib/lib.es2016.d.ts","../../../../node_modules/typescript/lib/lib.es2017.d.ts","../../../../node_modules/typescript/lib/lib.es2018.d.ts","../../../../node_modules/typescript/lib/lib.es2019.d.ts","../../../../node_modules/typescript/lib/lib.es2020.d.ts","../../../../node_modules/typescript/lib/lib.es2021.d.ts","../../../../node_modules/typescript/lib/lib.es2022.d.ts","../../../../node_modules/typescript/lib/lib.dom.d.ts","../../../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../../../node_modules/typescript/lib/lib.dom.asynciterable.d.ts","../../../../node_modules/typescript/lib/lib.webworker.importscripts.d.ts","../../../../node_modules/typescript/lib/lib.scripthost.d.ts","../../../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../../../node_modules/typescript/lib/lib.es2016.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../../../node_modules/typescript/lib/lib.decorators.d.ts","../../../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../../../node_modules/typescript/lib/lib.es2022.full.d.ts","../../../../node_modules/esrap/types/index.d.ts","../../../../node_modules/magic-string/dist/magic-string.es.d.mts","../../../../node_modules/@types/estree/index.d.ts","../../../../node_modules/locate-character/types/index.d.ts","../../../../node_modules/svelte/types/index.d.ts","../core/dist/types.d.ts","../core/dist/schema.d.ts","../core/dist/builder.d.ts","../core/dist/path.d.ts","../core/dist/index.d.ts","../client/dist/types.d.ts","../client/dist/createpermissionclient.d.ts","../client/dist/index.d.ts","./src/createsveltepermissions.ts","./src/context.ts","./src/index.ts","./src/svelte-shim.d.ts"],"fileIdsList":[[68,80],[64,68,80],[64,65,66,67,68,80],[68,74,80],[68,74,75,80],[68,73,80],[68,69,80],[68,69,70,71,72,80],[68,77,80],[68,73,76,80],[68,77,78,80],[68]],"fileInfos":[{"version":"bcd24271a113971ba9eb71ff8cb01bc6b0f872a85c23fdbe5d93065b375933cd","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f88bedbeb09c6f5a6645cb24c7c55f1aa22d19ae96c8e6959cbd8b85a707bc6","impliedFormat":1},{"version":"7fe93b39b810eadd916be8db880dd7f0f7012a5cc6ffb62de8f62a2117fa6f1f","impliedFormat":1},{"version":"bb0074cc08b84a2374af33d8bf044b80851ccc9e719a5e202eacf40db2c31600","impliedFormat":1},{"version":"1a7daebe4f45fb03d9ec53d60008fbf9ac45a697fdc89e4ce218bc94b94f94d6","impliedFormat":1},{"version":"f94b133a3cb14a288803be545ac2683e0d0ff6661bcd37e31aaaec54fc382aed","impliedFormat":1},{"version":"f59d0650799f8782fd74cf73c19223730c6d1b9198671b1c5b3a38e1188b5953","impliedFormat":1},{"version":"8a15b4607d9a499e2dbeed9ec0d3c0d7372c850b2d5f1fb259e8f6d41d468a84","impliedFormat":1},{"version":"26e0fe14baee4e127f4365d1ae0b276f400562e45e19e35fd2d4c296684715e6","impliedFormat":1},{"version":"d6b1eba8496bdd0eed6fc8a685768fe01b2da4a0388b5fe7df558290bffcf32f","affectsGlobalScope":true,"impliedFormat":1},{"version":"7f57fc4404ff020bc45b9c620aff2b40f700b95fe31164024c453a5e3c163c54","impliedFormat":1},{"version":"7f57fc4404ff020bc45b9c620aff2b40f700b95fe31164024c453a5e3c163c54","impliedFormat":1},{"version":"2a2de5b9459b3fc44decd9ce6100b72f1b002ef523126c1d3d8b2a4a63d74d78","affectsGlobalScope":true,"impliedFormat":1},{"version":"f13f4b465c99041e912db5c44129a94588e1aafee35a50eab51044833f50b4ee","affectsGlobalScope":true,"impliedFormat":1},{"version":"eadcffda2aa84802c73938e589b9e58248d74c59cb7fcbca6474e3435ac15504","affectsGlobalScope":true,"impliedFormat":1},{"version":"105ba8ff7ba746404fe1a2e189d1d3d2e0eb29a08c18dded791af02f29fb4711","affectsGlobalScope":true,"impliedFormat":1},{"version":"00343ca5b2e3d48fa5df1db6e32ea2a59afab09590274a6cccb1dbae82e60c7c","affectsGlobalScope":true,"impliedFormat":1},{"version":"ebd9f816d4002697cb2864bea1f0b70a103124e18a8cd9645eeccc09bdf80ab4","affectsGlobalScope":true,"impliedFormat":1},{"version":"2c1afac30a01772cd2a9a298a7ce7706b5892e447bb46bdbeef720f7b5da77ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"7b0225f483e4fa685625ebe43dd584bb7973bbd84e66a6ba7bbe175ee1048b4f","affectsGlobalScope":true,"impliedFormat":1},{"version":"c0a4b8ac6ce74679c1da2b3795296f5896e31c38e888469a8e0f99dc3305de60","affectsGlobalScope":true,"impliedFormat":1},{"version":"3084a7b5f569088e0146533a00830e206565de65cae2239509168b11434cd84f","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5079c53f0f141a0698faa903e76cb41cd664e3efb01cc17a5c46ec2eb0bef42","affectsGlobalScope":true,"impliedFormat":1},{"version":"32cafbc484dea6b0ab62cf8473182bbcb23020d70845b406f80b7526f38ae862","affectsGlobalScope":true,"impliedFormat":1},{"version":"fca4cdcb6d6c5ef18a869003d02c9f0fd95df8cfaf6eb431cd3376bc034cad36","affectsGlobalScope":true,"impliedFormat":1},{"version":"b93ec88115de9a9dc1b602291b85baf825c85666bf25985cc5f698073892b467","affectsGlobalScope":true,"impliedFormat":1},{"version":"f5c06dcc3fe849fcb297c247865a161f995cc29de7aa823afdd75aaaddc1419b","affectsGlobalScope":true,"impliedFormat":1},{"version":"b77e16112127a4b169ef0b8c3a4d730edf459c5f25fe52d5e436a6919206c4d7","affectsGlobalScope":true,"impliedFormat":1},{"version":"fbffd9337146eff822c7c00acbb78b01ea7ea23987f6c961eba689349e744f8c","affectsGlobalScope":true,"impliedFormat":1},{"version":"a995c0e49b721312f74fdfb89e4ba29bd9824c770bbb4021d74d2bf560e4c6bd","affectsGlobalScope":true,"impliedFormat":1},{"version":"c7b3542146734342e440a84b213384bfa188835537ddbda50d30766f0593aff9","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce6180fa19b1cccd07ee7f7dbb9a367ac19c0ed160573e4686425060b6df7f57","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f02e2476bccb9dbe21280d6090f0df17d2f66b74711489415a8aa4df73c9675","affectsGlobalScope":true,"impliedFormat":1},{"version":"45e3ab34c1c013c8ab2dc1ba4c80c780744b13b5676800ae2e3be27ae862c40c","affectsGlobalScope":true,"impliedFormat":1},{"version":"805c86f6cca8d7702a62a844856dbaa2a3fd2abef0536e65d48732441dde5b5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"e42e397f1a5a77994f0185fd1466520691456c772d06bf843e5084ceb879a0ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"f4c2b41f90c95b1c532ecc874bd3c111865793b23aebcc1c3cbbabcd5d76ffb0","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab26191cfad5b66afa11b8bf935ef1cd88fabfcb28d30b2dfa6fad877d050332","affectsGlobalScope":true,"impliedFormat":1},{"version":"2088bc26531e38fb05eedac2951480db5309f6be3fa4a08d2221abb0f5b4200d","affectsGlobalScope":true,"impliedFormat":1},{"version":"cb9d366c425fea79716a8fb3af0d78e6b22ebbab3bd64d25063b42dc9f531c1e","affectsGlobalScope":true,"impliedFormat":1},{"version":"500934a8089c26d57ebdb688fc9757389bb6207a3c8f0674d68efa900d2abb34","affectsGlobalScope":true,"impliedFormat":1},{"version":"689da16f46e647cef0d64b0def88910e818a5877ca5379ede156ca3afb780ac3","affectsGlobalScope":true,"impliedFormat":1},{"version":"bc21cc8b6fee4f4c2440d08035b7ea3c06b3511314c8bab6bef7a92de58a2593","affectsGlobalScope":true,"impliedFormat":1},{"version":"7ca53d13d2957003abb47922a71866ba7cb2068f8d154877c596d63c359fed25","affectsGlobalScope":true,"impliedFormat":1},{"version":"54725f8c4df3d900cb4dac84b64689ce29548da0b4e9b7c2de61d41c79293611","affectsGlobalScope":true,"impliedFormat":1},{"version":"e5594bc3076ac29e6c1ebda77939bc4c8833de72f654b6e376862c0473199323","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f3eb332c2d73e729f3364fcc0c2b375e72a121e8157d25a82d67a138c83a95c","affectsGlobalScope":true,"impliedFormat":1},{"version":"6f4427f9642ce8d500970e4e69d1397f64072ab73b97e476b4002a646ac743b1","affectsGlobalScope":true,"impliedFormat":1},{"version":"48915f327cd1dea4d7bd358d9dc7732f58f9e1626a29cc0c05c8c692419d9bb7","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7bf9377723203b5a6a4b920164df22d56a43f593269ba6ae1fdc97774b68855","affectsGlobalScope":true,"impliedFormat":1},{"version":"db9709688f82c9e5f65a119c64d835f906efe5f559d08b11642d56eb85b79357","affectsGlobalScope":true,"impliedFormat":1},{"version":"4b25b8c874acd1a4cf8444c3617e037d444d19080ac9f634b405583fd10ce1f7","affectsGlobalScope":true,"impliedFormat":1},{"version":"37be57d7c90cf1f8112ee2636a068d8fd181289f82b744160ec56a7dc158a9f5","affectsGlobalScope":true,"impliedFormat":1},{"version":"a917a49ac94cd26b754ab84e113369a75d1a47a710661d7cd25e961cc797065f","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d3261badeb7843d157ef3e6f5d1427d0eeb0af0cf9df84a62cfd29fd47ac86e","affectsGlobalScope":true,"impliedFormat":1},{"version":"195daca651dde22f2167ac0d0a05e215308119a3100f5e6268e8317d05a92526","affectsGlobalScope":true,"impliedFormat":1},{"version":"8b11e4285cd2bb164a4dc09248bdec69e9842517db4ca47c1ba913011e44ff2f","affectsGlobalScope":true,"impliedFormat":1},{"version":"0508571a52475e245b02bc50fa1394065a0a3d05277fbf5120c3784b85651799","affectsGlobalScope":true,"impliedFormat":1},{"version":"8f9af488f510c3015af3cc8c267a9e9d96c4dd38a1fdff0e11dc5a544711415b","affectsGlobalScope":true,"impliedFormat":1},{"version":"fc611fea8d30ea72c6bbfb599c9b4d393ce22e2f5bfef2172534781e7d138104","affectsGlobalScope":true,"impliedFormat":1},{"version":"1ce14b81c5cc821994aa8ec1d42b220dd41b27fcc06373bce3958af7421b77d4","affectsGlobalScope":true,"impliedFormat":1},{"version":"b3a048b3e9302ef9a34ef4ebb9aecfb28b66abb3bce577206a79fee559c230da","affectsGlobalScope":true,"impliedFormat":1},{"version":"01a30f9e8582b369075c0808df71121e6855cb06fd8d3d39511d9ebb66405205","impliedFormat":1},{"version":"10b38d61664fe5c2cee4c280b8504c099a7df1b7d793e3709b43938f521ce957","impliedFormat":99},{"version":"2be2227c3810dfd84e46674fd33b8d09a4a28ad9cb633ed536effd411665ea1e","impliedFormat":99},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"ecd7b4429a2b12f50ef619043fca298eb99a9b7f9e8edabd60c9bbb7fa8f036b","impliedFormat":99},{"version":"327a4d13326928f96dd7f2adff6f0a81b78f24213881ab524102f0d495093426","affectsGlobalScope":true,"impliedFormat":99},"a9637e1b2642463e77fc3b3b6b4d11524cf9f09a74758d5234de0ba5c28264e3","64603d3a21cace27a06a275c25b09db14ca4498fe121578865e29ad21914397e","504c2af83052182afaa2393a26b219324b1c5d044b64ed6c3e710936db12f62d","72cf94a2c467f63fe221dbad5f408b32b8281d3512d4bc02a3dd0fc675a96f55","a6a4f3dfe216ab809ad0c82b2154de582df3b7efd31f23d4f404d64dacb08be5","8055dc3560635b3a0f388d50b72be948028ddf3e467fc169d4941349c602b42c","1702467e18b3d95640021754e24421d350c42095d6a2c635ea6f0eb717e721f1","fb88bf4a0dcf9d2a3b84fa64d3f88e1a3640bf39a09f8343c29aadffd75f0d3e",{"version":"c8467f36f664c973e2a1cf6c27e58a2bce221103ba019a0d4eeb4f5605b219ac","signature":"3679091ade7894ba5abc85febd2ff1027527ee1f22f86ffc8ab04a5691447bb6"},{"version":"5ee4ddcc30115fc67ebe5ed556d8706d8fc15c71038654a1b1d668732bc790b1","signature":"3bff21c29c982c83589c4e1d7d3c2d7c5539e9f2664f6507d35cda545ccc84e7"},"7dedbdbf88285f03fe6589fafc4d57b7c485110bc1fd806618ff01a02243362b","7e22d91f59f23881cff2021a96f452bf08f636dc92cec969a248484f24ec314b"],"root":[[77,80]],"options":{"composite":true,"declaration":true,"declarationMap":true,"exactOptionalPropertyTypes":false,"module":99,"noUncheckedIndexedAccess":true,"outDir":"./dist","rootDir":"./src","skipLibCheck":true,"sourceMap":true,"strict":true,"target":9,"verbatimModuleSyntax":true},"referencedMap":[[66,1],[64,2],[67,1],[65,1],[68,3],[61,1],[62,1],[12,1],[10,1],[11,1],[16,1],[15,1],[2,1],[17,1],[18,1],[19,1],[20,1],[21,1],[22,1],[23,1],[24,1],[3,1],[25,1],[26,1],[4,1],[27,1],[31,1],[28,1],[29,1],[30,1],[32,1],[33,1],[34,1],[5,1],[35,1],[36,1],[37,1],[38,1],[6,1],[42,1],[39,1],[40,1],[41,1],[43,1],[7,1],[44,1],[49,1],[50,1],[45,1],[46,1],[47,1],[48,1],[8,1],[54,1],[51,1],[52,1],[53,1],[55,1],[9,1],[56,1],[63,1],[57,1],[58,1],[60,1],[59,1],[1,1],[14,1],[13,1],[75,4],[76,5],[74,6],[71,7],[73,8],[72,1],[70,7],[69,1],[78,9],[77,10],[79,11],[80,12]],"semanticDiagnosticsPerFile":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80],"latestChangedDtsFile":"./dist/index.d.ts","version":"6.0.3"} \ No newline at end of file diff --git a/src/perm-authz-ts/tsconfig.base.json b/src/perm-authz-ts/tsconfig.base.json new file mode 100644 index 0000000..e366832 --- /dev/null +++ b/src/perm-authz-ts/tsconfig.base.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "baseUrl": ".", + "paths": { + "@perm/core": ["packages/core/src/index.ts"], + "@perm/server": ["packages/server/src/index.ts"], + "@perm/client": ["packages/client/src/index.ts"], + "@perm/svelte": ["packages/svelte/src/index.ts"] + }, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true + } +} diff --git a/src/web/routes/test/+page.svelte b/src/web/routes/test/+page.svelte index 6e0afd6..e34974a 100644 --- a/src/web/routes/test/+page.svelte +++ b/src/web/routes/test/+page.svelte @@ -49,21 +49,30 @@
  • /test/http — cliente HTTP con - EngineHttp: GET/POST con validación Sium, retry/Retry-After, - timeout, cancelación, hooks de auth, tagged Result. + EngineHttp: GET/POST con validación Sium, retry/Retry-After, timeout, + cancelación, hooks de auth, tagged Result.
  • /test/sess — ciclo de vida de sesión con - ActiveSession: adopt/refresh/revoke con tagged results, - identidad reactiva (none/anonymous/identified), generation guard, - onRefresh/onRevoke mockeados y captura de eventos. + ActiveSession: adopt/refresh/revoke con tagged results, identidad reactiva + (none/anonymous/identified), generation guard, onRefresh/onRevoke mockeados y captura de + eventos.
  • /test/timr — runtime timer scheduler con ActiveTimers: schedule, debounce vía - replace:true, intervals con awaitTask y maxRuns, - cancelAll por scope, backoff exponencial determinista y tabla - reactiva de entries. + replace:true, intervals con awaitTask y maxRuns, cancelAll por scope, backoff + exponencial determinista y tabla reactiva de entries. +
  • +
  • + /test/conn — mini chat sobre + Connections: WebSocket real, auth, canales, ACK/request, buffer offline, + reconnect, timers y bridge de sesión. +
  • +
  • + /test/perm — authorization con + Permissions: policies allow/deny, handlers HTTP, cache cliente, + what(), explain(), SQL plan y componente <Can />.
  • diff --git a/src/web/routes/test/conn/+page.svelte b/src/web/routes/test/conn/+page.svelte new file mode 100644 index 0000000..54e495c --- /dev/null +++ b/src/web/routes/test/conn/+page.svelte @@ -0,0 +1,838 @@ + + + + Conn — mini chat WebSocket + + +
    + ← volver a tests + +
    +
    +

    arts/conn demo

    +

    Mini chat con WebSocket real

    +

    + La página usa createWebSocketTransport(). Arranca el servidor local con + npm run dev:conn-chat, luego conecta desde aquí. +

    +
    +
    + estado + {Chat.state} + generation {Chat.generation} · intento {Chat.reconnectAttempt} +
    +
    + +
    + + {CONNECT_ERROR_HELP} +
    + +
    +
    +
    +
    +

    Lobby

    +

    {ROOM_TOPIC} · {Connections.connectedNames.length ? 'online' : 'offline'}

    +
    + {#if serverTyping} + {BOT_NAME} escribiendo... + {/if} +
    + +
    + {#each messages as message (message.id)} +
    + {message.user} +

    {message.text}

    + +
    + {/each} + {#if messages.length === 0} +

    + Arranca el servidor, conecta y escribe. Ahora sí, esto cruza un WebSocket real. +

    + {/if} +
    + +
    + + { + if (event.key === 'Enter') void sendMessage(); + }} + /> + +
    +
    + + +
    + +
    +
    +

    Frames reales

    +
    + {#each frames as frame (frame.id)} +
    + {frame.at} + {frame.direction === 'out' ? '→' : '←'} {frame.type} + {frame.topic ?? 'global'} +
    + {/each} +
    +
    + +
    +

    State changes

    +
    + {#each states as state, index (`${state.at}-${index}`)} +
    + {new Date(state.at).toLocaleTimeString()} + {state.from} + → + {state.to} +
    + {/each} +
    +
    +
    +
    + + diff --git a/src/web/routes/test/perm/+page.svelte b/src/web/routes/test/perm/+page.svelte new file mode 100644 index 0000000..c24eabb --- /dev/null +++ b/src/web/routes/test/perm/+page.svelte @@ -0,0 +1,914 @@ + + + + Perm — authorization + + +
    + ← volver a tests + +
    +
    +

    arts/perm

    +

    Authorization lab

    +

    + Prueba createEnginePermissions(), createActivePermissions(), + handlers HTTP, cache de cliente, what(), explain(), SQL plan y el + componente <Can />. El cliente refleja; el engine decide. +

    +
    +
    + última decisión + {effectLabel(lastDecision)} + {decisionReason(lastDecision)} +
    +
    + +
    +
    event.preventDefault()}> +

    Contexto

    + + + + + + + + + + + +
    + + + + + +
    +
    + +
    +

    Can component

    +
    + +
    + Editar visible + post.update permitido para este actor. +
    + {#snippet fallback()} +
    + Editar oculto + post.update denegado o indeterminado. +
    + {/snippet} + {#snippet loading()} +
    + Comprobando edit... +
    + {/snippet} +
    + + +
    + Publicar visible + Requiere team admin + MFA. +
    + {#snippet fallback()} +
    + Publicar oculto + Activa MFA y usa team admin/owner para permitir. +
    + {/snippet} +
    +
    + +
    +
    + remote calls + {callCount} +
    +
    + cache entries + {Permissions.size} +
    +
    + active actor + {currentActor.id} +
    +
    + resource + {currentResource.visibility} · {currentResource.status} +
    +
    +
    +
    + +
    +

    Quick checks

    +
    + {#each [ACTION_READ, ACTION_UPDATE, ACTION_PUBLISH, ACTION_DELETE] as action (action)} + + {/each} +
    +
    + +
    +
    +

    what()

    +
    + {#each Object.entries(whatDecisions) as [action, decision] (action)} +
    + {action} + {effectLabel(decision)} + {decisionReason(decision)} +
    + {/each} + {#if Object.keys(whatDecisions).length === 0} +

    + Pulsa what() para evaluar todas las acciones del recurso. +

    + {/if} +
    +
    + +
    +

    explain()

    +
    + {#if lastExplain} +

    + {effectLabel(lastExplain.decision)} · deps: + {lastExplain.dependencies.join(', ') || 'none'} +

    + {#each lastExplain.trace as entry (entry.policy)} +
    + {entry.policy} + {entry.targetMatched ? 'target matched' : 'target skipped'} + {entry.result ?? 'no eval'} {entry.reason ? `· ${entry.reason}` : ''} +
    + {/each} + {:else} +

    Pulsa explain() para ver traza de policies.

    + {/if} +
    +
    +
    + +
    +
    +

    SQL query plan

    + {#if sqlPlan} +
    {JSON.stringify(sqlPlan.predicate ?? sqlPlan, null, 2)}
    +

    + strategy: {sqlPlan.strategy} + {#if sqlPlan.warnings?.length} + · {sqlPlan.warnings.join(' · ')} + {/if} +

    + {:else} +

    + Pulsa SQL plan. Las policies no compilables quedarían como residual checks. +

    + {/if} +
    + +
    +

    event log

    +
    + {#each eventLog as item (item.id)} +

    {item.text}

    + {/each} +
    +
    +
    +
    + + diff --git a/svelte.config.js b/svelte.config.js index e1408e3..96616d4 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -14,6 +14,7 @@ const config = { alias: { $aapp: 'src/arts/aapp', $adom: 'src/arts/adom', + $conn: 'src/arts/conn', $fend: 'src/arts/fend', $libs: 'src/libs', $locale: 'src/libs/locale', @@ -22,6 +23,7 @@ const config = { $http: 'src/arts/http', $lang: 'src/arts/lang', $logr: 'src/arts/logr', + $perm: 'src/arts/perm', $sess: 'src/arts/sess', $sium: 'src/arts/sium', $stor: 'src/arts/stor', diff --git a/tsconfig.json b/tsconfig.json index 2c2ed3c..333f6ee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,8 @@ "sourceMap": true, "strict": true, "moduleResolution": "bundler" - } + }, + "exclude": ["src/perm-authz-ts/**"] // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files //