diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..6e2293a --- /dev/null +++ b/docs/todo.md @@ -0,0 +1,4 @@ +- permissioned blobs +- websockets/sse/webhooks +- add ingestion logic? +- public spaces? \ No newline at end of file diff --git a/examples/sveltekit-cloudflare-workers/package.json b/examples/sveltekit-cloudflare-workers/package.json index f1d962c..f45b5ab 100644 --- a/examples/sveltekit-cloudflare-workers/package.json +++ b/examples/sveltekit-cloudflare-workers/package.json @@ -59,10 +59,10 @@ "license": "MIT", "dependencies": { "@atcute/jetstream": "^1.1.2", - "@atmo-dev/contrail": "^0.0.3", - "@foxui/core": "^0.8.2", - "@foxui/social": "^0.8.4", - "@foxui/time": "^0.8.2", + "@atmo-dev/contrail": "^0.1.1", + "@foxui/core": "^0.9.1", + "@foxui/social": "^0.8.10", + "@foxui/time": "^0.8.5", "valibot": "^1.3.1" } } diff --git a/lexicons-generated/rsvp/atmo/space/listSpaces.json b/lexicons-generated/rsvp/atmo/space/listSpaces.json index 39f2992..531ab35 100644 --- a/lexicons-generated/rsvp/atmo/space/listSpaces.json +++ b/lexicons-generated/rsvp/atmo/space/listSpaces.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "List spaces the caller has access to. Default scope is 'member' (spaces the caller is a member of, including owned); 'owner' lists only spaces the caller owns.", + "description": "List spaces the caller has access to. Default scope is 'member' (spaces the caller is a member of, including owned); 'owner' lists only spaces the caller owns. When scope='member', the optional 'owner' param narrows to spaces owned by that DID — useful for listing all channels in a specific community the caller can access.", "parameters": { "type": "params", "properties": { @@ -20,6 +20,11 @@ "type": "string", "format": "nsid" }, + "owner": { + "type": "string", + "format": "did", + "description": "With scope=member, filter to spaces owned by this DID. Ignored when scope=owner." + }, "cursor": { "type": "string" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af3fd0c..9ff7d67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,17 +76,17 @@ importers: specifier: ^1.1.2 version: 1.1.2 '@atmo-dev/contrail': - specifier: link:../.. - version: link:../.. + specifier: ^0.1.1 + version: 0.1.1(pg@8.20.0) '@foxui/core': - specifier: ^0.8.2 - version: 0.8.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) + specifier: ^0.9.1 + version: 0.9.1(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) '@foxui/social': - specifier: ^0.8.4 - version: 0.8.6(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(highlight.js@11.11.1)(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) + specifier: ^0.8.10 + version: 0.8.10(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(highlight.js@11.11.1)(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) '@foxui/time': - specifier: ^0.8.2 - version: 0.8.4(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) + specifier: ^0.8.5 + version: 0.8.5(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) valibot: specifier: ^1.3.1 version: 1.3.1(typescript@6.0.2) @@ -295,6 +295,14 @@ packages: '@atcute/xrpc-server@0.1.12': resolution: {integrity: sha512-70KIerQlljp5+s6t0u6YNN9klEboQUZa2hhoi/hmXIO1cIKEORettTMctnyjfcCJaSfAuj42dxPu51GTZBlm8w==} + '@atmo-dev/contrail@0.1.1': + resolution: {integrity: sha512-7BdLYjHHGC2EKlT/p3KszJAE6Pv1FPPuLVG+eYRpXSTF6iwrk/FVA0Dep7lTowbLlo5mQBpbEpo2CrQtUZAPfQ==} + peerDependencies: + pg: ^8.0.0 + peerDependenciesMeta: + pg: + optional: true + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -843,32 +851,26 @@ packages: '@fontsource-variable/geist@5.2.8': resolution: {integrity: sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==} - '@foxui/core@0.8.3': - resolution: {integrity: sha512-dQE+fhwDT4SlUhFFGs8+609S9rBC+gyY3Tjd48jIQtJai3hOAIVxFFB+j/ngNiTzMME3oDV7yc6H/OAghTy8Eg==} + '@foxui/core@0.9.1': + resolution: {integrity: sha512-GnMegqx5gz71U85FK85ayEEHuD3bvpNET8HeNsJU1Gj+OVkKKvUY9q/XqmCY3tsfEqY0Mv+RM1iQSnsZn5LZ6w==} peerDependencies: svelte: '>=5' tailwindcss: '>=3' - '@foxui/core@0.9.0': - resolution: {integrity: sha512-+zzya5pclKhyQYKdAAdpIGa6GG/oipvBi6PM/T/zynXmHEdSm6bWqHtlcL98Ulvp4cmML7VM9OtkvXVk4NeP/w==} + '@foxui/social@0.8.10': + resolution: {integrity: sha512-d9FB1BjWFbvldNlrXogdJU/a90dDdNqvBWGAkLRK2i7iiBAaWkKceWL3xc8N5+79+hfD8Ydg4ptCk3wx3bkguw==} peerDependencies: svelte: '>=5' tailwindcss: '>=3' - '@foxui/social@0.8.6': - resolution: {integrity: sha512-Hk78zsc8/1sHzQTBiy95Kvu05k7YQTLZeqRjk/L0bZsjMItNRik8ocheKHEK4f01L8ZaOmxnREVhf4P2nUm/1w==} + '@foxui/text@0.8.5': + resolution: {integrity: sha512-K7XsJGWdyRdXlRxjD8Z4TQ9xy7T/rVrB/z+U0YGGIjIER1DQGLawPwBQ5WXYQeNCBS1ab8mG67uFZw2sA5aFPQ==} peerDependencies: svelte: '>=5' tailwindcss: '>=3' - '@foxui/text@0.8.4': - resolution: {integrity: sha512-eW5EJE9BSgJa1kx9u95960OTIKcVsO6+nFidpdvdBak1fMoIlZTb/HL2nlPG21HbWewBIys7th0SC5B4wMWfxw==} - peerDependencies: - svelte: '>=5' - tailwindcss: '>=3' - - '@foxui/time@0.8.4': - resolution: {integrity: sha512-HOa2DqHtOMSd5JDv9XZ+AVFnmJ5Oi1wnK+dCfbd/+2LXhuQKqg5kQcclyX9Ufhprwuhx59JO7JUe4Vix8jBmpw==} + '@foxui/time@0.8.5': + resolution: {integrity: sha512-InXhgKZW4URArJWW87hblhHMAoNii0iueKCxei72SCvlABZ/y5QoX/ewhrgk7/47qbfrlgyqBzshDtF60kTqcg==} peerDependencies: svelte: '>=5' tailwindcss: '>=3' @@ -3761,12 +3763,25 @@ snapshots: '@atcute/crypto': 2.4.1 '@atcute/identity': 1.1.4 '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) - '@atcute/lexicons': 1.2.9 + '@atcute/lexicons': 1.3.0 '@atcute/multibase': 1.2.0 '@atcute/uint8array': 1.1.1 '@badrap/valita': 0.4.6 nanoid: 5.1.7 + '@atmo-dev/contrail@0.1.1(pg@8.20.0)': + dependencies: + '@atcute/atproto': 3.1.11 + '@atcute/client': 4.2.1 + '@atcute/identity': 1.1.4 + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) + '@atcute/jetstream': 1.1.2 + '@atcute/lexicons': 1.3.0 + '@atcute/xrpc-server': 0.1.12 + hono: 4.12.8 + optionalDependencies: + pg: 8.20.0 + '@babel/runtime@7.29.2': {} '@badrap/valita@0.4.6': {} @@ -4209,29 +4224,7 @@ snapshots: '@fontsource-variable/geist@5.2.8': {} - '@foxui/core@0.8.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2)': - dependencies: - '@fontsource-variable/geist': 5.2.8 - '@fontsource-variable/geist-mono': 5.2.7 - '@number-flow/svelte': 0.4.0(svelte@5.55.4(@typescript-eslint/types@8.58.2)) - bits-ui: 2.17.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2)) - clsx: 2.1.1 - dompurify: 3.4.0 - mode-watcher: 1.1.0(svelte@5.55.4(@typescript-eslint/types@8.58.2)) - svelte: 5.55.4(@typescript-eslint/types@8.58.2) - svelte-sonner: 1.1.0(svelte@5.55.4(@typescript-eslint/types@8.58.2)) - tailwind-merge: 3.5.0 - tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.2) - tailwindcss: 4.2.2 - web-haptics: 0.0.6(svelte@5.55.4(@typescript-eslint/types@8.58.2)) - transitivePeerDependencies: - - '@internationalized/date' - - '@sveltejs/kit' - - react - - react-dom - - vue - - '@foxui/core@0.9.0(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2)': + '@foxui/core@0.9.1(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2)': dependencies: '@fontsource-variable/geist': 5.2.8 '@fontsource-variable/geist-mono': 5.2.7 @@ -4255,13 +4248,13 @@ snapshots: - react-dom - vue - '@foxui/social@0.8.6(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(highlight.js@11.11.1)(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2)': + '@foxui/social@0.8.10(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(highlight.js@11.11.1)(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2)': dependencies: '@atcute/bluesky': 3.3.2 '@atcute/bluesky-richtext-segmenter': 3.0.0 - '@foxui/core': 0.9.0(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) - '@foxui/text': 0.8.4(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(highlight.js@11.11.1)(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) - '@foxui/time': 0.8.4(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) + '@foxui/core': 0.9.1(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) + '@foxui/text': 0.8.5(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(highlight.js@11.11.1)(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) + '@foxui/time': 0.8.5(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) '@use-gesture/vanilla': 10.3.1 bits-ui: 2.17.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2)) emoji-picker-element: 1.29.1 @@ -4280,10 +4273,10 @@ snapshots: - react-dom - vue - '@foxui/text@0.8.4(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(highlight.js@11.11.1)(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2)': + '@foxui/text@0.8.5(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(highlight.js@11.11.1)(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2)': dependencies: '@floating-ui/dom': 1.7.6 - '@foxui/core': 0.9.0(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) + '@foxui/core': 0.9.1(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) '@tiptap/extension-bubble-menu': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) '@tiptap/extension-code-block-lowlight': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/extension-code-block@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(highlight.js@11.11.1)(lowlight@3.3.0) @@ -4319,9 +4312,9 @@ snapshots: - react-dom - vue - '@foxui/time@0.8.4(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2)': + '@foxui/time@0.8.5(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2)': dependencies: - '@foxui/core': 0.9.0(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) + '@foxui/core': 0.9.1(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(tailwindcss@4.2.2) '@number-flow/svelte': 0.4.0(svelte@5.55.4(@typescript-eslint/types@8.58.2)) bits-ui: 2.17.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.4(@typescript-eslint/types@8.58.2))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2))(typescript@6.0.2)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.4(@typescript-eslint/types@8.58.2)) svelte: 5.55.4(@typescript-eslint/types@8.58.2) diff --git a/spaces-lexicon-templates/listSpaces.json b/spaces-lexicon-templates/listSpaces.json index bc7a306..4a70b9d 100644 --- a/spaces-lexicon-templates/listSpaces.json +++ b/spaces-lexicon-templates/listSpaces.json @@ -4,12 +4,13 @@ "defs": { "main": { "type": "query", - "description": "List spaces the caller has access to. Default scope is 'member' (spaces the caller is a member of, including owned); 'owner' lists only spaces the caller owns.", + "description": "List spaces the caller has access to. Default scope is 'member' (spaces the caller is a member of, including owned); 'owner' lists only spaces the caller owns. When scope='member', the optional 'owner' param narrows to spaces owned by that DID — useful for listing all channels in a specific community the caller can access.", "parameters": { "type": "params", "properties": { "scope": { "type": "string", "knownValues": ["member", "owner"], "default": "member" }, "type": { "type": "string", "format": "nsid" }, + "owner": { "type": "string", "format": "did", "description": "With scope=member, filter to spaces owned by this DID. Ignored when scope=owner." }, "cursor": { "type": "string" }, "limit": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } } diff --git a/src/core/community/acl.ts b/src/core/community/acl.ts index b043f30..5ef23a2 100644 --- a/src/core/community/acl.ts +++ b/src/core/community/acl.ts @@ -79,6 +79,33 @@ export async function flattenEffectiveMembers( return dids; } +/** All space URIs `actorDid` can reach (directly or via delegation). + * Walks the reverse graph: direct grants first, then every space that + * delegates to an already-reachable space. Used for community.list. */ +export async function resolveReachableSpaces( + adapter: CommunityAdapter, + actorDid: string, + opts: EffectiveLevelOptions = {} +): Promise> { + const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH; + const reachable = new Set(); + const directRows = await adapter.listAccessRowsForSubject(actorDid); + const queue: Array<{ spaceUri: string; depth: number }> = directRows.map( + (r) => ({ spaceUri: r.spaceUri, depth: 0 }) + ); + while (queue.length) { + const { spaceUri, depth } = queue.shift()!; + if (reachable.has(spaceUri)) continue; + reachable.add(spaceUri); + if (depth >= maxDepth) continue; + const parents = await adapter.listSpacesDelegatingTo(spaceUri); + for (const parent of parents) { + if (!reachable.has(parent)) queue.push({ spaceUri: parent, depth: depth + 1 }); + } + } + return reachable; +} + /** Check whether the actor would cause a cycle if added as a subject-space of `spaceUri`. */ export async function wouldCycle( adapter: CommunityAdapter, diff --git a/src/core/community/adapter.ts b/src/core/community/adapter.ts index 8ef88c3..ae9e62a 100644 --- a/src/core/community/adapter.ts +++ b/src/core/community/adapter.ts @@ -10,32 +10,13 @@ function mapCommunityRow(row: any): CommunityRow { did: row.did, mode: row.mode as CommunityMode, pdsEndpoint: row.pds_endpoint ?? null, - appPasswordEncrypted: row.app_password_encrypted - ? toBytes(row.app_password_encrypted) - : null, identifier: row.identifier ?? null, - signingKeyEncrypted: row.signing_key_encrypted - ? toBytes(row.signing_key_encrypted) - : null, - rotationKeyEncrypted: row.rotation_key_encrypted - ? toBytes(row.rotation_key_encrypted) - : null, createdBy: row.created_by, createdAt: toNum(row.created_at), deletedAt: row.deleted_at == null ? null : toNum(row.deleted_at), }; } -function toBytes(v: unknown): Uint8Array { - // Encrypted fields are stored base64 as TEXT; passthrough for other code paths. - if (v instanceof Uint8Array) return v; - if (typeof v === "string") { - // Treat as base64 for storage. Caller's cipher handles further decoding. - return new TextEncoder().encode(v); - } - throw new Error("unexpected blob column type"); -} - function mapAccessRow(row: any): AccessLevelRow { const isSpace = row.subject_kind === "space"; return { @@ -96,10 +77,7 @@ export class CommunityAdapter { did: input.did, mode: "adopt", pdsEndpoint: input.pdsEndpoint, - appPasswordEncrypted: new TextEncoder().encode(input.appPasswordEncrypted), identifier: input.identifier, - signingKeyEncrypted: null, - rotationKeyEncrypted: null, createdBy: input.createdBy, createdAt: now, deletedAt: null, @@ -125,10 +103,7 @@ export class CommunityAdapter { did: input.did, mode: "mint", pdsEndpoint: null, - appPasswordEncrypted: null, identifier: null, - signingKeyEncrypted: new TextEncoder().encode(input.signingKeyEncrypted), - rotationKeyEncrypted: new TextEncoder().encode(input.rotationKeyEncrypted), createdBy: input.createdBy, createdAt: now, deletedAt: null, @@ -187,19 +162,31 @@ export class CommunityAdapter { .run(); } - async listCommunitiesForActor(actor: string): Promise { - // Communities where the actor has any access level in any community-owned space. + /** Direct access-level rows held by the given DID subject. Used as the seed + * set for reverse-graph traversal. */ + async listAccessRowsForSubject(subjectDid: string): Promise { + const { results } = await this.db + .prepare( + `SELECT * FROM community_access_levels + WHERE subject_kind = 'did' AND subject = ?` + ) + .bind(subjectDid) + .all(); + return results.map(mapAccessRow); + } + + /** Communities owning any of the given space URIs. */ + async listCommunitiesOwningSpaces(spaceUris: string[]): Promise { + if (spaceUris.length === 0) return []; + const placeholders = spaceUris.map(() => "?").join(","); const { results } = await this.db .prepare( `SELECT DISTINCT c.* FROM communities c JOIN spaces s ON s.owner_did = c.did AND s.deleted_at IS NULL - JOIN community_access_levels cal - ON cal.space_uri = s.uri - AND cal.subject_kind = 'did' AND cal.subject = ? - WHERE c.deleted_at IS NULL + WHERE c.deleted_at IS NULL AND s.uri IN (${placeholders}) ORDER BY c.created_at DESC` ) - .bind(actor) + .bind(...spaceUris) .all(); return results.map(mapCommunityRow); } diff --git a/src/core/community/router.ts b/src/core/community/router.ts index 07be342..c46acc5 100644 --- a/src/core/community/router.ts +++ b/src/core/community/router.ts @@ -14,7 +14,7 @@ import { computeDidPlc, submitGenesisOp, } from "./plc"; -import { resolveEffectiveLevel, wouldCycle } from "./acl"; +import { resolveEffectiveLevel, resolveReachableSpaces, wouldCycle } from "./acl"; import { reconcile } from "./reconcile"; import type { AccessLevel } from "./types"; import { @@ -239,7 +239,10 @@ export function registerCommunityRoutes( app.get(`/xrpc/${NS}.list`, auth, async (c) => { const sa = getAuth(c); const actor = c.req.query("actor") ?? sa.issuer; - const rows = await community.listCommunitiesForActor(actor); + // Walk the delegation graph so communities reached via group memberships + // (not just direct DID grants) are included. + const reachable = await resolveReachableSpaces(community, actor); + const rows = await community.listCommunitiesOwningSpaces([...reachable]); return c.json({ communities: rows.map((r) => ({ did: r.did, @@ -363,6 +366,12 @@ export function registerCommunityRoutes( return c.json({ error: "Forbidden", reason: "owner-or-admin-required" }, 403); } + // Before clearing this space's ACL rows, capture any spaces that delegate + // to it so we can re-reconcile their materialized membership after the + // delete — any DIDs that reached those parents only through this space + // must be removed from their spaces_members. + const delegatingParents = await community.listSpacesDelegatingTo(body.spaceUri); + await spaces.deleteSpace(body.spaceUri); await community.deleteAllAccessForSpace(body.spaceUri); // Drop the materialized membership too. @@ -375,6 +384,9 @@ export function registerCommunityRoutes( sa.issuer ); } + for (const parent of delegatingParents) { + await reconcile(community, spaces, parent, sa.issuer); + } return c.json({ ok: true }); }); @@ -429,6 +441,14 @@ export function registerCommunityRoutes( if (rankOf(body.accessLevel) > rankOf(callerLevel)) { return c.json({ error: "Forbidden", reason: "cannot-grant-higher-than-self" }, 403); } + // grant() upserts; block downgrading a subject who already outranks the caller. + const existingGrant = await community.getAccessRow( + body.spaceUri, + (subjectDid ?? subjectSpaceUri)! + ); + if (existingGrant && rankOf(existingGrant.accessLevel) > rankOf(callerLevel)) { + return c.json({ error: "Forbidden", reason: "cannot-modify-higher-than-self" }, 403); + } // Cycle check when delegating to another space. if (subjectSpaceUri) { diff --git a/src/core/community/types.ts b/src/core/community/types.ts index 5b7bfe1..9cf690f 100644 --- a/src/core/community/types.ts +++ b/src/core/community/types.ts @@ -37,17 +37,14 @@ export interface CommunityConfig { fetch?: typeof fetch; } +/** Public view of a community row. Encrypted credentials are not included here + * — those live only behind CommunityAdapter.getRawCredentials(), which returns + * the base64 envelope string intended to be passed straight to CredentialCipher. */ export interface CommunityRow { did: string; mode: CommunityMode; - // adopt-mode fields pdsEndpoint: string | null; - appPasswordEncrypted: Uint8Array | null; identifier: string | null; - // mint-mode fields (Stage 4) - signingKeyEncrypted: Uint8Array | null; - rotationKeyEncrypted: Uint8Array | null; - // common createdBy: string; createdAt: number; deletedAt: number | null; diff --git a/src/core/spaces/router.ts b/src/core/spaces/router.ts index 0b2e188..c1c102c 100644 --- a/src/core/spaces/router.ts +++ b/src/core/spaces/router.ts @@ -83,12 +83,16 @@ export function registerSpacesRoutes( const sa = getAuth(c); const scope = c.req.query("scope") ?? "member"; // "member" | "owner" const type = c.req.query("type") ?? undefined; + const owner = c.req.query("owner") ?? undefined; const cursor = c.req.query("cursor") ?? undefined; const limit = c.req.query("limit") ? Number(c.req.query("limit")) : undefined; const opts: Parameters[0] = { type, cursor, limit }; if (scope === "owner") opts.ownerDid = sa.issuer; - else opts.memberDid = sa.issuer; + else { + opts.memberDid = sa.issuer; + if (owner) opts.ownerDid = owner; // narrow to spaces owned by this DID + } const result = await adapter.listSpaces(opts); return c.json({ diff --git a/tests/community-delegation.test.ts b/tests/community-delegation.test.ts index e6d366b..7466dac 100644 --- a/tests/community-delegation.test.ts +++ b/tests/community-delegation.test.ts @@ -25,6 +25,7 @@ const CONFIG: ContrailConfig = { }, community: { masterKey: MASTER_KEY, + plcDirectory: "https://plc.test", fetch: mockFetch, resolver: mockResolver(), }, @@ -56,6 +57,10 @@ async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise< { status: 200, headers: { "content-type": "application/json" } } ); } + // PLC directory submissions for community.mint. + if (url.startsWith("https://plc.test/")) { + return new Response("{}", { status: 200 }); + } return new Response("not found", { status: 404 }); } @@ -225,6 +230,24 @@ describe("community delegation — stage 2", () => { expect(await flatMembers(app, ALICE, chat)).not.toContain(DIANA); }); + it("deleting a delegated-from space re-reconciles parents' spaces_members", async () => { + const mods = await createSpace(app, ALICE, "cascade-mods"); + const chat = await createSpace(app, ALICE, "cascade-chat"); + + // Diana reaches chat only via mods. + expect((await grant(app, ALICE, mods, { did: DIANA }, "member")).status).toBe(200); + expect((await grant(app, ALICE, chat, { spaceUri: mods }, "member")).status).toBe(200); + expect(await flatMembers(app, ALICE, chat)).toContain(DIANA); + + // Delete mods; chat should no longer list Diana as a flattened member. + const del = await call(app, "POST", "/xrpc/test.comm.community.space.delete", ALICE, { + spaceUri: mods, + }); + expect(del.status).toBe(200); + + expect(await flatMembers(app, ALICE, chat)).not.toContain(DIANA); + }); + it("resync endpoint requires admin+", async () => { const s = await createSpace(app, ALICE, "resync-space"); // Bob is a plain member @@ -236,6 +259,37 @@ describe("community delegation — stage 2", () => { expect(res.status).toBe(403); }); + it("community.list follows delegation across communities", async () => { + // Mint a second community. DIANA will have a direct grant only in the + // second community's space; the second community delegates a group from + // the first community into one of its own spaces. The actor Alice has no + // direct grant in the second community but reaches it via the delegation + // chain, so community.list must surface both. + const mint = await call(app, "POST", "/xrpc/test.comm.community.mint", DIANA, {}); + expect(mint.status).toBe(200); + const second = ((await mint.json()) as any).communityDid as string; + + // Diana creates a space in the second community and delegates the first + // community's (Alice-owned) $admin in as a member. + const createRes = await call(app, "POST", "/xrpc/test.comm.community.space.create", DIANA, { + communityDid: second, + key: "bridged", + }); + expect(createRes.status).toBe(200); + const bridged = ((await createRes.json()) as any).space.uri as string; + + const firstAdmin = `at://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; + expect((await grant(app, DIANA, bridged, { spaceUri: firstAdmin }, "member")).status).toBe(200); + + // Alice has no direct grant in the second community, but is reachable via + // `bridged` → firstAdmin (where she is owner). + const res = await call(app, "GET", `/xrpc/test.comm.community.list`, ALICE); + expect(res.status).toBe(200); + const dids = ((await res.json()) as any).communities.map((c: any) => c.did); + expect(dids).toContain(COMMUNITY_DID); + expect(dids).toContain(second); + }); + it("resync endpoint works for owner", async () => { const s = await createSpace(app, ALICE, "resync-ok"); const res = await call(app, "POST", "/xrpc/test.comm.community.space.resync", ALICE, { diff --git a/tests/community-e2e.test.ts b/tests/community-e2e.test.ts index 3acb71e..5203871 100644 --- a/tests/community-e2e.test.ts +++ b/tests/community-e2e.test.ts @@ -231,6 +231,19 @@ describe("community e2e — stage 1", () => { expect(((await res.json()) as any).reason).toBe("cannot-grant-higher-than-self"); }); + it("grant cannot downgrade a subject who outranks the caller", async () => { + const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`; + // Bob is currently manager (promoted earlier in this describe block). + // Alice is owner of the space. Bob tries to downgrade Alice to member via grant. + const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", BOB, { + spaceUri, + subject: { did: ALICE }, + accessLevel: "member", + }); + expect(res.status).toBe(403); + expect(((await res.json()) as any).reason).toBe("cannot-modify-higher-than-self"); + }); + it("revokes and reconciler removes from spaces_members", async () => { const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`; const res = await call(app, "POST", "/xrpc/test.comm.community.space.revoke", ALICE, { diff --git a/tests/spaces-e2e.test.ts b/tests/spaces-e2e.test.ts index 1fd965c..3989fa8 100644 --- a/tests/spaces-e2e.test.ts +++ b/tests/spaces-e2e.test.ts @@ -272,4 +272,38 @@ describe("spaces e2e", () => { const body = await asJson(res); expect(body.record.record.address).toBe("123 Main St"); }); + + it("listSpaces?owner= narrows scope=member to spaces owned by that DID", async () => { + // Alice creates a second space she owns. + await call(app, "POST", "/xrpc/test.spaces.space.createSpace", ALICE, { + key: "other-space", + }); + // Charlie owns yet another space and adds Bob as a member. + const charlieSpaceRes = await call(app, "POST", "/xrpc/test.spaces.space.createSpace", CHARLIE, { + key: "charlie-space", + }); + const charlieSpace = (await charlieSpaceRes.json() as any).space.uri; + await call(app, "POST", "/xrpc/test.spaces.space.addMember", CHARLIE, { + spaceUri: charlieSpace, + did: BOB, + }); + + // Without owner filter: Bob sees spaces from both Alice and Charlie. + const allRes = await call(app, "GET", "/xrpc/test.spaces.space.listSpaces?scope=member", BOB); + const all = (await allRes.json() as any).spaces; + const allOwners = new Set(all.map((s: any) => s.ownerDid)); + expect(allOwners.has(ALICE)).toBe(true); + expect(allOwners.has(CHARLIE)).toBe(true); + + // With owner=Alice: Bob sees only Alice-owned spaces. + const narrowedRes = await call( + app, + "GET", + `/xrpc/test.spaces.space.listSpaces?scope=member&owner=${ALICE}`, + BOB + ); + const narrowed = (await narrowedRes.json() as any).spaces; + expect(narrowed.length).toBeGreaterThan(0); + for (const s of narrowed) expect(s.ownerDid).toBe(ALICE); + }); });