From d35e735c8b22c186c2627c0852ee83176cda591e Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Wed, 5 Aug 2026 18:25:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(example):=20une=20app=20d'exemple,=20?= =?UTF-8?q?=C3=A9crite=20comme=20un=20consommateur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le harnais e2e parlait à un sac de méthodes posé sur `window.__sdk`. Il prouvait que les fonctions s'exécutaient, jamais qu'on pouvait écrire une application avec — et cet écart a livré un vrai défaut : l'inbox d'un document était verte en test et inutilisable en vrai, parce que le harnais faisait traverser une adresse d'une identité à l'autre par une variable, ce qu'aucune application ne peut faire. `examples/notebook` est une application minimale en DOM natif, qui résout `@ng-eventually/client` comme un consommateur externe (workspace, dépendance déclarée, aucun import privilégié). Elle ne peut faire que ce qu'une application peut faire. Elle s'est déjà payée deux fois pendant son écriture : - `UnionSubject.subject` et `.graph` étaient typés `string` alors que ce sont toujours des références de document. Un consommateur devait donc caster ce qu'il venait de lire avant de le repasser — un cast à cet endroit précis rouvre la confusion que les types template literal existent pour fermer. - l'écran d'accès normalisait ce que l'utilisateur SAISIT mais pas ce que l'URL porte, si bien qu'un lien `?ng-id=@Erin` ouvrait un espace différent de celui de la même personne tapant `erin`. Une seule normalisation désormais, celle du registre. Le domaine est volontairement mince — des notes — mais suffit à exercer le placement par scope, la possession de caps, le partage dirigé, les inbox par document et la lecture réactive. 170 tests unitaires, typecheck vert sur la lib, l'exemple et le harnais. --- bun.lock | 283 ++++++++++++++++++ examples/notebook/README.md | 19 ++ examples/notebook/app.ts | 255 ++++++++++++++++ examples/notebook/index.html | 58 ++++ examples/notebook/package.json | 10 + examples/notebook/tsconfig.json | 27 ++ package.json | 5 +- packages/client/e2e/run.ts | 15 + packages/client/e2e/sdk-entry.ts | 51 +++- .../client/src/shared-wallet/access-gate.ts | 63 +++- packages/client/src/surface/read-model.ts | 15 +- packages/client/test/access-gate.test.ts | 32 +- 12 files changed, 814 insertions(+), 19 deletions(-) create mode 100644 bun.lock create mode 100644 examples/notebook/README.md create mode 100644 examples/notebook/app.ts create mode 100644 examples/notebook/index.html create mode 100644 examples/notebook/package.json create mode 100644 examples/notebook/tsconfig.json diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..35251f6 --- /dev/null +++ b/bun.lock @@ -0,0 +1,283 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "ng-eventually", + "devDependencies": { + "@types/bun": "latest", + "playwright": "^1.61.1", + "typescript": "^5.6.0", + }, + }, + "examples/notebook": { + "name": "@ng-eventually/example-notebook", + "dependencies": { + "@ng-eventually/client": "workspace:*", + "@ng-org/web": "0.1.2-alpha.13", + }, + }, + "packages/client": { + "name": "@ng-eventually/client", + "version": "0.0.0", + "devDependencies": { + "@ng-org/alien-deepsignals": "0.1.2-alpha.11", + "@ng-org/shex-orm": "0.1.2-alpha.8", + "@ng-org/web": "0.1.2-alpha.13", + }, + "peerDependencies": { + "@ng-org/alien-deepsignals": "*", + "@ng-org/orm": "*", + "@ng-org/shex-orm": "*", + "@ng-org/web": "*", + }, + "optionalPeers": [ + "@ng-org/alien-deepsignals", + "@ng-org/orm", + "@ng-org/shex-orm", + "@ng-org/web", + ], + }, + }, + "packages": { + "@ldo/traverser-shexj": ["@ldo/traverser-shexj@1.0.0-alpha.28", "", { "dependencies": { "@ldo/type-traverser": "1.0.0-alpha.28" } }, "sha512-N06+LOWhv6//unPRLbFMd56MqPf5lO2ihZgle9hNLmxt6QJmNrZM3oXzHCL3TfDu4OT1/NUZp3kj2HmztQIZkg=="], + + "@ldo/type-traverser": ["@ldo/type-traverser@1.0.0-alpha.28", "", { "dependencies": { "uuid": "8.3.2" } }, "sha512-pGMIVxLzoLjYVhADuVhg6r5ZDNleXZ9DcyIvLXo1/ADEocLnysg/Xjk9D/7l/Rw3WtDJrTFOOtBv8OnH+VPgKA=="], + + "@ng-eventually/client": ["@ng-eventually/client@workspace:packages/client"], + + "@ng-eventually/example-notebook": ["@ng-eventually/example-notebook@workspace:examples/notebook"], + + "@ng-org/alien-deepsignals": ["@ng-org/alien-deepsignals@0.1.2-alpha.11", "", { "dependencies": { "alien-signals": "2.0.8" } }, "sha512-nPgqOrheAda/pW5FHgSb45SrSZWuyMyEVqO683ijEsVPpD105bngfh92PPfcRoRnFzGSoKXa3CfuqUHi2+qVIQ=="], + + "@ng-org/shex-orm": ["@ng-org/shex-orm@0.1.2-alpha.8", "", { "dependencies": { "@ldo/traverser-shexj": "1.0.0-alpha.28", "@ldo/type-traverser": "1.0.0-alpha.28", "@shexjs/parser": "1.0.0-alpha.28", "commander": "14.0.3", "dts-dom": "3.6.0", "ejs": "3.1.10", "fs-extra": "10.1.0", "jsonld2graphobject": "0.0.5", "loading-cli": "1.1.2", "prettier": "3.9.5" }, "peerDependencies": { "typescript": "5.9.3" }, "bin": { "rdf-orm": "dist/cli.js" } }, "sha512-jogxiLfUtI3WkuF5O3PPwRBRWylpapk/JlvElGUqZnEavXXlEMmEGbK3VwqMUsbp1pweNA0sEKIFLIk3Lzt2Gg=="], + + "@ng-org/web": ["@ng-org/web@0.1.2-alpha.13", "", { "dependencies": { "async-proxy": "0.4.1" } }, "sha512-/xO0c+3NTphnws5Do2LDqgZWmAf+aNnYdChJKdU0dnp1U1iVSgi/y3yb8AYryf0v9sooj0aYJxt08B6DpirFMQ=="], + + "@rdfjs/types": ["@rdfjs/types@1.1.2", "", { "dependencies": { "@types/node": "26.1.1" } }, "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg=="], + + "@shexjs/parser": ["@shexjs/parser@1.0.0-alpha.28", "", { "dependencies": { "@shexjs/util": "1.0.0-alpha.28", "@ts-jison/parser": "0.4.1-alpha.1" } }, "sha512-eeVeHq/2JG9X+3h7y+7EmuBSWWl2EMj/EQBLk5CTRx4W4hWDdjWczsY8RWwKjkIzLwUS1+G0aiAI1u5LHCZ2Rw=="], + + "@shexjs/term": ["@shexjs/term@1.0.0-alpha.27", "", { "dependencies": { "@types/shexj": "2.1.7", "rdf-data-factory": "1.1.3", "relativize-url": "0.1.0" } }, "sha512-+D7P7pglRPTZC2RkwaQuq+cgBZImx+61JZtcN77uEJVqcGaIscQK5hScsKhAPIo16/I+4jhIUCEFojXqw6otpg=="], + + "@shexjs/util": ["@shexjs/util@1.0.0-alpha.28", "", { "dependencies": { "@shexjs/term": "1.0.0-alpha.27", "@shexjs/visitor": "1.0.0-alpha.27", "@types/shexj": "2.1.7", "hierarchy-closure": "1.2.2", "sync-request": "6.1.0" } }, "sha512-L8pBokTU/5eNRJPkC8R9SIgPw6/JDh/bHKdV5TZzf8/FkOMNJwKIy6UDHXM1I8FJ+c8u2gOOHp2MA+7b+md+0A=="], + + "@shexjs/visitor": ["@shexjs/visitor@1.0.0-alpha.27", "", {}, "sha512-9s67A+f0ZZNw/SNxqoi1483CqUca8dbnHM6WDWsRH4+eXlQpQqwOZDxA8uKEaWeX4VcDrDwzWpr0WvK6EyDWIQ=="], + + "@ts-jison/common": ["@ts-jison/common@0.4.1-alpha.1", "", {}, "sha512-SDbHzq+UMD+V3ciKVBHwCEgVqSeyQPTCjOsd/ZNTGySUVg4x3EauR9ZcEfdVFAsYRR38XWgDI+spq5LDY46KvQ=="], + + "@ts-jison/lexer": ["@ts-jison/lexer@0.4.1-alpha.1", "", { "dependencies": { "@ts-jison/common": "0.4.1-alpha.1" } }, "sha512-5C1Wr+wixAzn2MOFtgy7KbT6N6j9mhmbjAtyvOqZKsikKtNOQj22MM5HxT+ooRexG2NbtxnDSXYdhHR1Lg58ow=="], + + "@ts-jison/parser": ["@ts-jison/parser@0.4.1-alpha.1", "", { "dependencies": { "@ts-jison/common": "0.4.1-alpha.1", "@ts-jison/lexer": "0.4.1-alpha.1" } }, "sha512-xNj+qOez/7dju44LlYiTlCjxMzW5oek9EckUAElfln/GBK9vgMSk0swWcnacMr0TYbGjUQuXvL2wEgmDf5WajQ=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/concat-stream": ["@types/concat-stream@1.6.1", "", { "dependencies": { "@types/node": "8.10.66" } }, "sha512-eHE4cQPoj6ngxBZMvVf6Hw7Mh4jMW4U9lpGmS5GBPB9RYxlFg+CHaVN7ErNY4W9XfLIEn20b4VDYaIrbq0q4uA=="], + + "@types/form-data": ["@types/form-data@0.0.33", "", { "dependencies": { "@types/node": "8.10.66" } }, "sha512-8BSvG1kGm83cyJITQMZSulnl6QV8jqAGreJsc5tPu1Jq0vTSOiY/k24Wx82JRpWwZSqrala6sd5rWi6aNXvqcw=="], + + "@types/http-link-header": ["@types/http-link-header@1.0.7", "", { "dependencies": { "@types/node": "18.19.130" } }, "sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw=="], + + "@types/jsonld": ["@types/jsonld@1.5.15", "", {}, "sha512-PlAFPZjL+AuGYmwlqwKEL0IMP8M8RexH0NIPGfCVWSQ041H2rR/8OlyZSD7KsCVoN8vCfWdtWDBxX8yBVP+xow=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], + + "@types/shexj": ["@types/shexj@2.1.7", "", {}, "sha512-pu/0vIZxFTMPVjTlo5MJKFkBL/EbAuFhtCXpmBB7ZdUiyNpc6pt8GxfyRPqdf6q2SsWu71a/vbhvGK2IZN2Eug=="], + + "alien-signals": ["alien-signals@2.0.8", "", {}, "sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA=="], + + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "async-proxy": ["async-proxy@0.4.1", "", { "dependencies": { "object-path-operator": "3.0.0" } }, "sha512-4e+zNtoGL4+cnqib8v169CnKcRfAsAubp2EsjBhAA5jyW7jjI3t36rVvuqLwmhtliwf8JvSnxinE4ecQN+DK4w=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "26.1.1" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "1.3.0", "function-bind": "1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "get-intrinsic": "1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], + + "colors-cli": ["colors-cli@1.0.33", "", { "bin": { "colors": "bin/colors" } }, "sha512-PWGsmoJFdOB0t+BeHgmtuoRZUQucOLl5ii81NBzOOGVxlgE04muFNHlR5j8i8MKbOPELBl3243AI6lGBTj5ICQ=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "1.1.2", "inherits": "2.0.4", "readable-stream": "2.3.8", "typedarray": "0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "dts-dom": ["dts-dom@3.6.0", "", {}, "sha512-on5jxTgt+A6r0Zyyz6ZRHXaAO7J1VPnOd6+AmvI1vH440AlAZZNc5rUHzgPuTjGlrVr1rOWQYNl7ZJK6rDohbw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-errors": "1.3.0", "gopd": "1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "10.9.4" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "has-tostringtag": "1.0.2", "hasown": "2.0.4" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "5.1.9" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], + + "form-data": ["form-data@2.5.6", "", { "dependencies": { "asynckit": "0.4.0", "combined-stream": "1.0.8", "es-set-tostringtag": "2.1.0", "hasown": "2.0.4", "mime-types": "2.1.35", "safe-buffer": "5.2.1" } }, "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA=="], + + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "4.2.11", "jsonfile": "6.2.1", "universalify": "2.0.1" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-define-property": "1.0.1", "es-errors": "1.3.0", "es-object-atoms": "1.1.2", "function-bind": "1.1.2", "get-proto": "1.0.1", "gopd": "1.2.0", "has-symbols": "1.1.0", "hasown": "2.0.4", "math-intrinsics": "1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-port": ["get-port@3.2.0", "", {}, "sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "1.0.1", "es-object-atoms": "1.1.2" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "1.1.0" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hierarchy-closure": ["hierarchy-closure@1.2.2", "", {}, "sha512-ZqZvsA6HyMqrmm49D3llYA8x8hqdyDDEkaTXcqwyO+fGQlzxoeXws/5ze11M40s4EoTw7GFxdTKIwj5YDOicLQ=="], + + "http-basic": ["http-basic@8.1.3", "", { "dependencies": { "caseless": "0.12.0", "concat-stream": "1.6.2", "http-response-object": "3.0.2", "parse-cache-control": "1.0.1" } }, "sha512-/EcDMwJZh3mABI2NhGfHOGOeOZITqfkEO4p/xK+l3NpyncIHUQBoMvCSF/b5GqvKtySC2srL/GGG3+EtlqlmCw=="], + + "http-link-header": ["http-link-header@1.1.4", "", {}, "sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw=="], + + "http-response-object": ["http-response-object@3.0.2", "", { "dependencies": { "@types/node": "10.17.60" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "jake": ["jake@10.9.4", "", { "dependencies": { "async": "3.2.6", "filelist": "1.0.6", "picocolors": "1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], + + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "2.0.1" }, "optionalDependencies": { "graceful-fs": "4.2.11" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "jsonld-context-parser": ["jsonld-context-parser@2.4.0", "", { "dependencies": { "@types/http-link-header": "1.0.7", "@types/node": "18.19.130", "cross-fetch": "3.2.0", "http-link-header": "1.1.4", "relative-to-absolute-iri": "1.0.8" }, "bin": { "jsonld-context-parse": "bin/jsonld-context-parse.js" } }, "sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA=="], + + "jsonld2graphobject": ["jsonld2graphobject@0.0.5", "", { "dependencies": { "@rdfjs/types": "1.1.2", "@types/jsonld": "1.5.15", "jsonld-context-parser": "2.4.0", "uuid": "8.3.2" } }, "sha512-5BqfXOq96+OBjjiJNG8gQH66pYt6hW88z2SJxdvFJo4XNoVMvqAcUz+JSm/KEWS5NLRnebApEzFrYP3HUiUmYw=="], + + "loading-cli": ["loading-cli@1.1.2", "", { "dependencies": { "colors-cli": "1.0.33" } }, "sha512-M1ntfXHpdGoQxfaqKBOQPwSrTr9EIoTgj664Q9UVSbSnJvAFdribo+Ij//1jvACgrGHaTvfKoD9PG3NOxGj44g=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.2" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "5.0.0" } }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-path-operator": ["object-path-operator@3.0.0", "", {}, "sha512-Z7dlPUeXqRU/lLfGerP24dPC66n7ehyXaTM81k71EFlsaaEjOHkf4/uq1WGicfGfiO7snYShneE1YZZUkyRiLQ=="], + + "parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], + + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + + "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "promise": ["promise@8.3.0", "", { "dependencies": { "asap": "2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="], + + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "1.0.1", "side-channel": "1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + + "rdf-data-factory": ["rdf-data-factory@1.1.3", "", { "dependencies": { "@rdfjs/types": "1.1.2" } }, "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ=="], + + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "1.0.3", "inherits": "2.0.4", "isarray": "1.0.0", "process-nextick-args": "2.0.1", "safe-buffer": "5.1.2", "string_decoder": "1.1.1", "util-deprecate": "1.0.2" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "relative-to-absolute-iri": ["relative-to-absolute-iri@1.0.8", "", {}, "sha512-U1TmhrhCmXKkDL9mI8gBbF5TN6TKcuv28k5+H3gMCAjoz0TyyHAICHlaGDZsTEBSu2Y3HhDKc8e6X9n33qeIqA=="], + + "relativize-url": ["relativize-url@0.1.0", "", {}, "sha512-YXet4a9wQP96Ru9MQSfoRUzsCaeboLPXj+rVG1ulH4t54zqFHiNmW6FPl7V2dTxk9uHlW3yb9+1jWO44AdWisw=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "1.3.0", "object-inspect": "1.13.4", "side-channel-list": "1.0.1", "side-channel-map": "1.0.1", "side-channel-weakmap": "1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "1.3.0", "object-inspect": "1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "object-inspect": "1.13.4" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "object-inspect": "1.13.4", "side-channel-map": "1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "sync-request": ["sync-request@6.1.0", "", { "dependencies": { "http-response-object": "3.0.2", "sync-rpc": "1.3.7", "then-request": "6.0.2" } }, "sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw=="], + + "sync-rpc": ["sync-rpc@1.3.7", "", { "dependencies": { "get-port": "3.2.0" } }, "sha512-YHciI7TUxL8EPqz/bg01sZfwuzQA0odao1wf1Ywdtw7j5vl30aQ6s+bLRTvgPPgzr94cg+WMm6Bxi/P7BJOxgw=="], + + "then-request": ["then-request@6.0.2", "", { "dependencies": { "@types/concat-stream": "1.6.1", "@types/form-data": "0.0.33", "@types/node": "8.10.66", "@types/qs": "6.15.1", "caseless": "0.12.0", "concat-stream": "1.6.2", "form-data": "2.5.6", "http-basic": "8.1.3", "http-response-object": "3.0.2", "promise": "8.3.0", "qs": "6.15.3" } }, "sha512-3ZBiG7JvP3wbDzA9iNY5zJQcHL4jn/0BWtXIkagfz7QgOL/LqjCEOBQuJNZfu0XYnv5JhKh+cDxCPM4ILrqruA=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "0.0.3", "webidl-conversions": "3.0.1" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "@types/concat-stream/@types/node": ["@types/node@8.10.66", "", {}, "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw=="], + + "@types/form-data/@types/node": ["@types/node@8.10.66", "", {}, "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw=="], + + "@types/http-link-header/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "5.26.5" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + + "http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="], + + "jsonld-context-parser/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "5.26.5" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + + "readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "then-request/@types/node": ["@types/node@8.10.66", "", {}, "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw=="], + + "@types/http-link-header/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "jsonld-context-parser/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + } +} diff --git a/examples/notebook/README.md b/examples/notebook/README.md new file mode 100644 index 0000000..50c5194 --- /dev/null +++ b/examples/notebook/README.md @@ -0,0 +1,19 @@ +# Notebook — the library's example application + +A minimal application written against `@ng-eventually/client`, in plain DOM. + +It exists for two reasons, and the second is the one that matters. + +**It shows how to use the library.** Every call in `app.ts` is what a real consumer writes. There is no test scaffolding, no privileged import, no reaching into the library's internals — it resolves `@ng-eventually/client` as an external consumer does. If something reads awkwardly here, it reads awkwardly for everyone. + +**It is what the e2e suite drives.** The suite used to talk to a bag of methods on `window.__sdk`, which proved the functions ran but never that an application could be written with them. That gap shipped a real defect: a document's inbox was green in tests and unusable in practice, because the harness handed an address across an identity boundary through a variable — something no application can do. This app can only do what an application can do. + +It has already paid for itself twice: writing it surfaced that `UnionSubject` returned `string` where the values are always document references (so a consumer had to cast whatever it had just read before passing it back), and that the access gate normalized what a user typed but not what the URL carried. + +## What it exercises + +Signing in, writing notes by scope, listing one's own, reading a note received as a link, handing a reader the key to a private note, opening a note for messages, leaving a message on someone else's note, and reacting to changes. + +## Running it + +The e2e suite builds and serves it (`packages/client/e2e/`). To open it by hand you need a wallet: serve the folder with a bundled `app.js` and a `/shared-wallet.ngw`, and set `__NOTEBOOK_WALLET_PASSWORD__`. diff --git a/examples/notebook/app.ts b/examples/notebook/app.ts new file mode 100644 index 0000000..b34d776 --- /dev/null +++ b/examples/notebook/app.ts @@ -0,0 +1,255 @@ +/** + * Notebook — a minimal application written against `@ng-eventually/client`. + * + * It exists for two reasons, and the second is the one that matters: + * + * 1. **It shows how to use the library.** Every call here is what a real consumer + * writes; there is no test scaffolding, no privileged import, no reaching into the + * library's internals. If something is awkward here, it is awkward for everyone. + * + * 2. **It is what the e2e suite drives.** The suite used to talk to a bag of methods on + * `window.__sdk`, which proved the functions ran but never that an application could + * be written with them — and that gap shipped a real defect: a document's inbox was + * green in tests and unusable in practice, because the harness handed the address + * across an identity boundary through a variable. No application can do that. This + * app can only do what an application can do, so a test that passes here means the + * surface is usable, not merely callable. + * + * ── The domain is deliberately thin ─────────────────────────────────────── + * Notes. Each user writes their own, may publish one, may hand a reader the key to a + * private one, and may leave a message on someone else's note. That is enough to + * exercise placement by scope, capability possession, directed sharing, per-document + * inboxes and reactive reads — without inventing a product. + * + * ── Plain DOM, on purpose ───────────────────────────────────────────────── + * The library imposes no framework, so its example must not adopt one: a consumer + * reading this should see the SDK calls, not a component tree. The UI here is the + * shortest thing that makes each act reachable. + */ + +import { + docs, + ensureIdentity, + inbox, + isNuri, + readUnion, + storeRegistry, + subscribeDoc, + type Nuri, + type Scope, +} from "@ng-eventually/client"; +import { capFor, configure, configureStoreRegistry, setCurrentUser } from "@ng-eventually/client/polyfill"; +import { ng as realNg, init as realInit } from "@ng-org/web"; + +// --- the domain, such as it is --------------------------------------------- + +const TITLE = "urn:notebook:title"; +const BODY = "urn:notebook:body"; + +interface Note { + doc: Nuri; + title: string; + body: string; +} + +// --- bootstrap: the two polyfill-era calls --------------------------------- +// +// Everything else an application calls is SDK surface, preserved at migration. These +// two are the scaffolding: `configure` becomes inert (the app will import the real SDK) +// and the identity will come from the wallet instead of a barrier. + +let session: { session_id: string } | null = null; +const sessionReady = new Promise<{ session_id: string }>((resolve) => { + realInit((event: { status: string; session?: { session_id: string } }) => { + if (event.status === "loggedin" && event.session) { + session = event.session; + resolve(event.session); + } + }, true, []); +}); + +configure({ + ng: realNg, + useShape: (() => {}) as never, // this example reads through `readUnion`, not the ORM + init: realInit, + sharedWallet: { + fileUrl: "/shared-wallet.ngw", + password: (globalThis as { __NOTEBOOK_WALLET_PASSWORD__?: string }).__NOTEBOOK_WALLET_PASSWORD__ ?? "", + }, +}); + +configureStoreRegistry({ + getSession: async () => { + const s = session ?? (await sessionReady); + return { + sessionId: s.session_id, + privateStoreId: (s as Record).private_store_id!, + protectedStoreId: (s as Record).protected_store_id, + publicStoreId: (s as Record).public_store_id, + }; + }, + normalizeId: (id) => id.trim().replace(/^@/, "").toLowerCase(), +}); + +// --- the acts --------------------------------------------------------------- + +/** Write a new note in `scope`. The document is created, then filled. */ +async function writeNote(scope: Scope, title: string, body: string): Promise { + const me = currentIdentity(); + const doc = await storeRegistry.createEntityDoc(me, scope); + const s = await sessionReady; + await docs.sparqlUpdate( + s.session_id, + `INSERT DATA { <${doc}> <${TITLE}> "${escape(title)}" ; <${BODY}> "${escape(body)}" }`, + doc, + ); + return doc; +} + +/** My notes in `scope`, read the way the library intends: list, then read. */ +async function myNotes(scope: Scope): Promise { + const docsOfScope = await storeRegistry.listMyEntityDocs(currentIdentity(), scope); + const subjects = await readUnion(docsOfScope); + return subjects.map((s) => ({ + doc: s.subject, + title: s.props[TITLE]?.[0] ?? "(sans titre)", + body: s.props[BODY]?.[0] ?? "", + })); +} + +/** + * Read someone else's note from its link. + * + * The link is what circulates in this model — you do not discover a note, you are given + * its link. `hasReadCap` is the door an untrusted string goes through. + */ +async function readSharedNote(link: string): Promise { + if (!isNuri(link)) return null; + const [note] = await readUnion([link]); + if (!note) return null; + return { + doc: note.subject, + title: note.props[TITLE]?.[0] ?? "(sans titre)", + body: note.props[BODY]?.[0] ?? "", + }; +} + +/** Hand a reader the key to one of my notes, addressed to their inbox. */ +async function shareNote(doc: Nuri, withUser: string): Promise { + const cap = capFor(doc); + if (!cap) throw new Error("this note is not mine to share"); + const theirInbox = await storeRegistry.userInbox(withUser, "protected"); + await inbox.shareCap(cap, theirInbox); +} + +/** Open a note for messages — only its owner can, and only they will read them. */ +async function openNoteForMessages(doc: Nuri): Promise { + await storeRegistry.openDocumentInbox(doc); +} + +/** Leave a message on someone else's note. One call, naming the NOTE. */ +async function leaveMessage(doc: Nuri, text: string): Promise { + await inbox.postToDocument(doc, { payload: { text } }); +} + +/** The messages left on one of my notes. */ +async function messagesOn(doc: Nuri): Promise { + const address = await storeRegistry.documentInboxAddress(doc); + if (!address) return []; + const deposits = await inbox.read(address); + return deposits.map((d) => String((d.payload as { text?: string })?.text ?? "")); +} + +/** Re-render whenever a note changes — locally or from a peer. */ +function watchNote(doc: Nuri, onChange: () => void): () => void { + return subscribeDoc(doc, onChange); +} + +// --- identity --------------------------------------------------------------- + +let identity = ""; +function currentIdentity(): string { + if (!identity) throw new Error("not signed in yet"); + return identity; +} + +/** + * Sign in. The library shows its access barrier when it needs one; the day the wallet + * supplies the identity, this resolves silently and nothing here changes. + */ +async function signIn(): Promise { + await ensureIdentity(); + await sessionReady; + identity = readIdentityBack(); +} + +/** The library owns the identity; the app asks for it rather than remembering it. */ +function readIdentityBack(): string { + return new URLSearchParams(location.search).get("ng-id") ?? localStorage.getItem("ng-eventually:identity") ?? ""; +} + +function escape(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); +} + +// --- the thinnest UI that makes each act reachable -------------------------- + +const el = (id: string): HTMLElement => document.getElementById(id)!; +const val = (id: string): string => (el(id) as HTMLInputElement).value.trim(); + +async function refresh(): Promise { + const scope = (el("scope") as HTMLSelectElement).value as Scope; + const notes = await myNotes(scope); + el("notes").innerHTML = notes + .map( + (n) => `
  • + ${n.title}${n.body} + + + +
  • `, + ) + .join(""); + el("who").textContent = identity; +} + +function wire(): void { + el("write").addEventListener("click", async () => { + await writeNote((el("scope") as HTMLSelectElement).value as Scope, val("title"), val("body")); + await refresh(); + }); + el("openLink").addEventListener("click", async () => { + const note = await readSharedNote(val("link")); + el("shared").textContent = note ? `${note.title} — ${note.body}` : "(illisible)"; + }); + el("leave").addEventListener("click", async () => { + await leaveMessage(val("onNote") as Nuri, val("message")); + el("left").textContent = "déposé"; + }); + el("notes").addEventListener("click", async (e) => { + const target = e.target as HTMLElement; + const doc = target.dataset.doc as Nuri | undefined; + if (!doc) return; + if (target.classList.contains("share")) { + await shareNote(doc, val("shareWith")); + el("shareResult").textContent = "partagé"; + } else if (target.classList.contains("open")) { + await openNoteForMessages(doc); + el("shareResult").textContent = "ouverte aux messages"; + } else if (target.classList.contains("msgs")) { + el("messages").textContent = (await messagesOn(doc)).join(" | ") || "(aucun)"; + } + }); +} + +async function main(): Promise { + wire(); + await signIn(); + await refresh(); +} + +void main(); + +// The e2e suite drives this app through the DOM. It exposes nothing else: a test that +// needed a back door would be testing something an application cannot do. +(globalThis as { __notebook?: unknown }).__notebook = { watchNote, setCurrentUser }; diff --git a/examples/notebook/index.html b/examples/notebook/index.html new file mode 100644 index 0000000..c18e947 --- /dev/null +++ b/examples/notebook/index.html @@ -0,0 +1,58 @@ + + + + + Notebook — exemple ng-eventually + + + +

    Connecté :

    + +
    + Écrire une note + + + + +
    + +
    + Mes notes + +
      +

      +

      +
      + +
      + Ouvrir une note reçue + + +

      +
      + +
      + Laisser un message sur la note d'un autre + + + +

      +
      + + + + diff --git a/examples/notebook/package.json b/examples/notebook/package.json new file mode 100644 index 0000000..ebea866 --- /dev/null +++ b/examples/notebook/package.json @@ -0,0 +1,10 @@ +{ + "name": "@ng-eventually/example-notebook", + "private": true, + "type": "module", + "description": "A minimal application written against @ng-eventually/client — the library's usage example, and what the e2e suite drives.", + "dependencies": { + "@ng-eventually/client": "workspace:*", + "@ng-org/web": "0.1.2-alpha.13" + } +} diff --git a/examples/notebook/tsconfig.json b/examples/notebook/tsconfig.json new file mode 100644 index 0000000..e8809a9 --- /dev/null +++ b/examples/notebook/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": [ + "bun" + ], + "noEmit": true, + "lib": [ + "ESNext", + "DOM" + ], + "paths": { + "@ng-eventually/client": [ + "../../packages/client/src/index.ts" + ], + "@ng-eventually/client/polyfill": [ + "../../packages/client/src/polyfill.ts" + ], + "@ng-org/web": [ + "../../node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts" + ] + } + }, + "include": [ + "." + ] +} diff --git a/package.json b/package.json index 5d22070..0bf8717 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,10 @@ "private": true, "type": "module", "description": "Generic polyfill layer over the NextGraph JS SDK — makes a single broker behave like the (not-yet-shipped) multi-user NextGraph: emulated capabilities and inbox. Drop-in SDK-shaped client; remove at migration. (A global-index curator package is deferred — see README.)", - "workspaces": ["packages/*"], + "workspaces": [ + "packages/*", + "examples/*" + ], "scripts": { "test": "bun test", "typecheck": "bunx tsc --noEmit -p packages/client/tsconfig.json" diff --git a/packages/client/e2e/run.ts b/packages/client/e2e/run.ts index e2b6786..2785b2c 100644 --- a/packages/client/e2e/run.ts +++ b/packages/client/e2e/run.ts @@ -120,6 +120,21 @@ async function main(): Promise { const info = await sdkGet(frame, "sessionInfo"); check("broker session connected", info?.session_id !== undefined && info?.session_id !== null, `session=${JSON.stringify(info)}`); + // ── access gate ───────────────────────────────────────────────────────── + console.log("\n── access gate ──"); + await step("the gate asks on a first access, and settles the identity normalized", async () => { + const r = await sdk(frame, "accessGateFirstVisit", "@Erin"); + check( + "barrier shown, Entrer disabled while empty, identity normalized, barrier removed", + r.shown === true && r.disabledWhenEmpty === true && r.identity === "erin" && r.stillMounted === false, + `shown=${r.shown} disabledWhenEmpty=${r.disabledWhenEmpty} identity=${r.identity} stillMounted=${r.stillMounted}`, + ); + }); + await step("the gate stays away when the identity is already known", async () => { + const r = await sdk(frame, "accessGateReturningVisit", "erin"); + check("no barrier for a returning user", r.shown === false && r.identity === "erin", `shown=${r.shown}`); + }); + // ── docs primitives ───────────────────────────────────────────────────── console.log("\n── docs primitives ──"); await step("docCreate returns a usable NURI", async () => { diff --git a/packages/client/e2e/sdk-entry.ts b/packages/client/e2e/sdk-entry.ts index b6b1d67..bfa8053 100644 --- a/packages/client/e2e/sdk-entry.ts +++ b/packages/client/e2e/sdk-entry.ts @@ -19,6 +19,7 @@ import { configure, configureStoreRegistry, setCurrentUser, + getCurrentUser, capFor, getCaps, resetCaps, @@ -40,7 +41,7 @@ import { // `storeRegistry` above is the app-facing slice; these are the shim internals. import * as registryInternals from "../src/shared-wallet/account-registry"; import * as virtualUsers from "../src/shared-wallet/virtual-users"; -import { isNuri } from "@ng-eventually/client"; +import { isNuri, ensureIdentity } from "@ng-eventually/client"; import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client"; const { IdentityStore } = virtualUsers; @@ -96,6 +97,10 @@ configure({ ng: realNg, useShape: fakeUseShape, init: realInit, + // The harness already holds an open wallet (the suite imports it itself), so it never + // needs the gate's assisted import. These values exist only so the gate has something + // to render when the access-gate steps exercise it — they are never used to import. + sharedWallet: { fileUrl: "/harness-not-used.ngw", password: "harness" }, }); configureStoreRegistry({ @@ -167,6 +172,50 @@ const identity = new IdentityStore( return { walletName, b64: btoa(bin), len: bytes.length }; }, + /** + * THE ACCESS GATE, in a real browser — ported from the consumer's + * `barriere-acces-identifiant` feature, which the library took over with the flow. + * + * Unit tests pin the resolution ORDER (`test/access-gate.test.ts`); only a real DOM can + * pin the barrier itself: that it appears on a first access, that entering a value + * settles the identity normalized, and — the one that matters most — that it does NOT + * appear when the identity is already known, since a returning user seeing the barrier + * again is the visible face of the silent bug (a second virtual space). + */ + async accessGateFirstVisit(raw: string) { + setCurrentUser(null); + try { window.localStorage.removeItem("ng-eventually:identity"); } catch {} + const done = ensureIdentity(); + const gate = document.querySelector('[data-ng-eventually="access-gate"]'); + const root = gate?.shadowRoot ?? null; + const input = root?.querySelector("input") as HTMLInputElement | null; + const button = root?.querySelector("button.go") as HTMLButtonElement | null; + const shown = input !== null && button !== null; + const disabledWhenEmpty = button?.disabled ?? null; + if (input && button) { + input.value = raw; + input.dispatchEvent(new Event("input")); + button.click(); + } + await done; + return { + shown, + disabledWhenEmpty, + identity: getCurrentUser(), + stillMounted: document.querySelector('[data-ng-eventually="access-gate"]') !== null, + }; + }, + + /** The barrier must stay away once an identity is known. */ + async accessGateReturningVisit(known: string) { + setCurrentUser(known); + await ensureIdentity(); + return { + shown: document.querySelector('[data-ng-eventually="access-gate"]') !== null, + identity: getCurrentUser(), + }; + }, + // ── docs primitives ────────────────────────────────────────────────────── async docCreate() { const s = await sessionReady; diff --git a/packages/client/src/shared-wallet/access-gate.ts b/packages/client/src/shared-wallet/access-gate.ts index a396f6b..c23b207 100644 --- a/packages/client/src/shared-wallet/access-gate.ts +++ b/packages/client/src/shared-wallet/access-gate.ts @@ -39,7 +39,32 @@ * a second virtual user, and the returning user silently lands in an empty space. */ -import { getConfig, getCurrentUser, setCurrentUser } from "./bootstrap"; +import { + getConfig, + getCurrentUser, + getStoreRegistryDeps, + setCurrentUser, +} from "./bootstrap"; + +/** + * Normalize an identifier the SAME way the shim keys accounts on. + * + * Not a detail: the identifier arrives from three places — typed at the gate, read from + * the URL after the broker round-trip, read from storage — and if any of them normalizes + * differently, that path keys onto a DIFFERENT virtual user. `@Erin` from the URL and + * `erin` typed at the gate must be one space, not two. So there is one normalizer, the + * injected one, and the gate borrows it rather than keeping its own `toLowerCase()`. + * + * Falls back to the library's own default when the registry is not configured yet, which + * is possible since the gate can run before anything else. + */ +function normalizeIdentity(raw: string): string { + try { + return getStoreRegistryDeps().normalizeId(raw); + } catch { + return raw.trim().replace(/^@/, "").toLowerCase(); + } +} /** Where the gate stashes the identifier so a plain reload prefills it. */ const STORAGE_KEY = "ng-eventually:identity"; @@ -50,6 +75,11 @@ const URL_PARAM = "ng-id"; * What a deployment must supply for the gate to run. These are not settings a user * tunes: they are the shared wallet this deployment hands out, so they belong to * whoever deploys, and they disappear with the gate. + * + * **The library reads no environment variable, ever.** The application resolves these at + * its own build — copying the `.ngw` into its bundle, injecting the password — and + * passes the VALUES here. A library that read `process.env` would impose its build + * system on every consumer, and would be untestable with other values. */ export interface SharedWalletConfig { /** URL of the `.ngw` file served by the application's own bundle. */ @@ -58,8 +88,6 @@ export interface SharedWalletConfig { password: string; /** The wallet app where the import happens. Defaults to the public one. */ importUrl?: string; - /** Shown as the gate's heading. The deployment's name, not a domain concept. */ - appName?: string; } const DEFAULT_IMPORT_URL = "https://nextgraph.eu/#/wallet/login"; @@ -69,11 +97,15 @@ function storedIdentity(): string | null { try { const fromUrl = new URLSearchParams(globalThis.location?.search ?? "").get(URL_PARAM); if (fromUrl && fromUrl.trim()) { - // Persist it in THIS partition too, so a later reload without the param prefills. - globalThis.localStorage?.setItem(STORAGE_KEY, fromUrl.trim()); - return fromUrl.trim(); + // Normalized on the way IN: the URL carries whatever a user or a link put there + // (`@Erin`), and an un-normalized value keys onto a different virtual user than the + // same identifier typed at the gate. + const normalized = normalizeIdentity(fromUrl); + globalThis.localStorage?.setItem(STORAGE_KEY, normalized); + return normalized; } - return globalThis.localStorage?.getItem(STORAGE_KEY) ?? null; + const stored = globalThis.localStorage?.getItem(STORAGE_KEY); + return stored ? normalizeIdentity(stored) : null; } catch { return null; // storage blocked (private mode, sandboxed iframe) — the gate asks again } @@ -95,11 +127,17 @@ function rememberIdentity(id: string): void { /** * Show the gate and resolve with the identifier the user entered. * + * No prefill parameter, deliberately: the gate is shown ONLY when no identity is known, + * so there is never a value to prefill. The consumer this was moved from did prefill, + * because its screen reappeared after the broker round-trip — here the URL carries the + * identity across that round-trip, so a returning user does not see the barrier at all. + * The need is met one level up rather than papered over in the form. + * * Deliberately plain DOM: this is a technical barrier shown before an application * renders, like a password prompt on a closed beta. Binding it to a UI framework would * make every consumer adopt that framework for a screen that is going away. */ -function askForIdentity(cfg: SharedWalletConfig, prefill: string | null): Promise { +function askForIdentity(cfg: SharedWalletConfig): Promise { const importUrl = cfg.importUrl ?? DEFAULT_IMPORT_URL; return new Promise((resolve) => { const host = document.createElement("div"); @@ -130,8 +168,8 @@ function askForIdentity(cfg: SharedWalletConfig, prefill: string | null): Promis .hint { color: #999; font-size: 12px; margin: 6px 0 0; }
      -

      ${cfg.appName ?? "Accès"}

      -

      Espace de test

      +

      Accès

      +

      Environnement de test

      1
      Télécharger le portefeuille
      Télécharger le fichier @@ -164,7 +202,6 @@ function askForIdentity(cfg: SharedWalletConfig, prefill: string | null): Promis input.addEventListener("input", sync); input.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter") enter(); }); go.addEventListener("click", enter); - if (prefill) { input.value = prefill; } sync(); document.body.appendChild(host); @@ -208,8 +245,8 @@ export async function ensureIdentity(): Promise { ); } - const chosen = await askForIdentity(cfg, null); - const normalized = chosen.trim().toLowerCase(); + const chosen = await askForIdentity(cfg); + const normalized = normalizeIdentity(chosen); rememberIdentity(normalized); setCurrentUser(normalized); } diff --git a/packages/client/src/surface/read-model.ts b/packages/client/src/surface/read-model.ts index ad6faa8..8f37b1d 100644 --- a/packages/client/src/surface/read-model.ts +++ b/packages/client/src/surface/read-model.ts @@ -57,10 +57,19 @@ void sparqlUpdate; /** One subject read from a doc, with its properties (predicate → values). */ export interface UnionSubject { - /** The subject IRI (`?s`) — in the polyfill, the doc's own NURI. */ - subject: string; + /** + * The subject IRI (`?s`) — in the polyfill, the doc's own NURI. + * + * Typed `Nuri`, not `string`: both fields are always document references here (the + * read is anchored per document and the subject is pinned to the anchor), and typing + * them loosely forced a consumer to cast whatever it had just read before it could + * pass it back — `shareNote(note.doc)`, `leaveMessage(note.doc)`. A cast at that + * boundary re-opens exactly the confusion the template literal types exist to close. + * Found by writing the example application (`examples/notebook`). + */ + subject: Nuri; /** The graph (doc NURI) the subject was read from. */ - graph: string; + graph: Nuri; /** predicate IRI → the list of object values (literals or IRIs) for it. */ props: Record; } diff --git a/packages/client/test/access-gate.test.ts b/packages/client/test/access-gate.test.ts index 11cdd21..e6ed38b 100644 --- a/packages/client/test/access-gate.test.ts +++ b/packages/client/test/access-gate.test.ts @@ -6,7 +6,14 @@ * lands in an empty space with no error anywhere. So the order is pinned, not trusted. */ import { test, expect, afterEach } from "bun:test"; -import { configure, resetConfig, setCurrentUser, getCurrentUser } from "../src/polyfill"; +import { + configure, + configureStoreRegistry, + resetConfig, + resetStoreRegistry, + setCurrentUser, + getCurrentUser, +} from "../src/polyfill"; import { ensureIdentity } from "../src/shared-wallet/access-gate"; const KEY = "ng-eventually:identity"; @@ -32,12 +39,17 @@ function inPage(search: string, storage: ReturnType) { afterEach(() => { setCurrentUser(null); resetConfig(); + resetStoreRegistry(); delete (globalThis as any).location; delete (globalThis as any).localStorage; delete (globalThis as any).history; }); function configured() { + configureStoreRegistry({ + getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }), + normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(), + }); configure({ ng: {} as never, useShape: (() => {}) as never, @@ -92,3 +104,21 @@ test("no shared wallet configured → it refuses, rather than inventing a space" inPage("", fakeStorage()); await expect(ensureIdentity()).rejects.toThrow(/no shared wallet configured/i); }); + +test("the URL value is NORMALIZED on the way in — `@Erin` and `erin` are one space", async () => { + // Ported from the consumer's `identifiant-resolution` feature, and it caught a real + // defect here: the gate normalized what a user TYPED but not what the URL carried, so + // a link with `?ng-id=@Erin` keyed onto a different virtual user than the same person + // typing `erin`. One normalizer — the injected one — for all three entry paths. + configured(); + inPage("?ng-id=@Erin", fakeStorage()); + await ensureIdentity(); + expect(getCurrentUser()).toBe("erin"); +}); + +test("a stored value is normalized too — an old entry cannot key onto a second space", async () => { + configured(); + inPage("", fakeStorage({ [KEY]: "@Frank" })); + await ensureIdentity(); + expect(getCurrentUser()).toBe("frank"); +});