From 37c72d03afeddd70001b533914fae0ad1d64d185 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:02:21 +0200 Subject: [PATCH] Collapse Contrail into a single package --- .changeset/config.json | 10 +- README.md | 108 +- apps/cloudflare-workers/README.md | 4 +- .../generated/com/example/permissionSet.json | 26 - .../lexicons/generated/index.ts | 5 +- apps/cloudflare-workers/package.json | 1 - apps/contrail-e2e/.env.example | 29 - apps/contrail-e2e/.gitignore | 2 - apps/contrail-e2e/README.md | 90 - apps/contrail-e2e/config.ts | 51 - apps/contrail-e2e/docker-compose.yml | 55 - apps/contrail-e2e/init-db/01-databases.sql | 5 - apps/contrail-e2e/package.json | 27 - apps/contrail-e2e/scripts/down.sh | 9 - apps/contrail-e2e/scripts/up.sh | 39 - .../tests/community-delete.test.ts | 260 - .../tests/community-invites.test.ts | 268 - .../tests/community-lifecycle.test.ts | 294 - .../community-provision-walkthrough.test.ts | 402 - .../tests/community-publishing.test.ts | 269 - apps/contrail-e2e/tests/cursor-resume.test.ts | 188 - apps/contrail-e2e/tests/health.test.ts | 58 - apps/contrail-e2e/tests/helpers.ts | 330 - .../tests/ingest-roundtrip.test.ts | 147 - apps/contrail-e2e/tests/provision.test.ts | 490 -- .../contrail-e2e/tests/reap-tombstone.test.ts | 178 - apps/contrail-e2e/tests/spaces-auth.test.ts | 123 - .../spaces-firehose-invisibility.test.ts | 197 - .../tests/spaces-table-isolation.test.ts | 133 - apps/contrail-e2e/tsconfig.json | 13 - apps/contrail-e2e/vitest.config.ts | 11 - apps/group-chat/.claude/settings.local.json | 15 - apps/group-chat/.env.example | 22 - apps/group-chat/.gitignore | 26 - apps/group-chat/.npmrc | 1 - apps/group-chat/.prettierignore | 4 - apps/group-chat/.prettierrc | 15 - apps/group-chat/LICENSE | 21 - apps/group-chat/README.md | 117 - apps/group-chat/eslint.config.js | 34 - .../custom/tools/atmo/chat/channel.json | 24 - .../custom/tools/atmo/chat/message.json | 19 - .../custom/tools/atmo/chat/server.json | 25 - apps/group-chat/lexicons/generated/index.ts | 65 - .../tools/atmo/chat/channel/getRecord.json | 185 - .../tools/atmo/chat/channel/listRecords.json | 239 - .../tools/atmo/chat/channel/watchRecords.json | 94 - .../tools/atmo/chat/community/adopt.json | 56 - .../tools/atmo/chat/community/defs.json | 182 - .../tools/atmo/chat/community/delete.json | 47 - .../atmo/chat/community/deleteRecord.json | 65 - .../tools/atmo/chat/community/getHealth.json | 49 - .../tools/atmo/chat/community/list.json | 38 - .../tools/atmo/chat/community/mint.json | 51 - .../tools/atmo/chat/community/putRecord.json | 73 - .../tools/atmo/chat/community/reauth.json | 60 - .../atmo/chat/community/space/create.json | 58 - .../atmo/chat/community/space/delete.json | 46 - .../chat/community/space/deleteRecord.json | 58 - .../atmo/chat/community/space/grant.json | 59 - .../chat/community/space/listMembers.json | 57 - .../atmo/chat/community/space/putRecord.json | 70 - .../atmo/chat/community/space/resync.json | 46 - .../atmo/chat/community/space/revoke.json | 54 - .../chat/community/space/setAccessLevel.json | 59 - .../generated/tools/atmo/chat/getCursor.json | 27 - .../tools/atmo/chat/getOverview.json | 51 - .../generated/tools/atmo/chat/getProfile.json | 138 - .../tools/atmo/chat/invite/create.json | 86 - .../tools/atmo/chat/invite/defs.json | 72 - .../tools/atmo/chat/invite/list.json | 51 - .../tools/atmo/chat/invite/redeem.json | 59 - .../tools/atmo/chat/invite/revoke.json | 50 - .../tools/atmo/chat/message/getRecord.json | 185 - .../tools/atmo/chat/message/listRecords.json | 238 - .../tools/atmo/chat/message/watchRecords.json | 93 - .../tools/atmo/chat/notifyOfUpdate.json | 59 - .../tools/atmo/chat/permissionSet.json | 71 - .../tools/atmo/chat/realtime/subscribe.json | 42 - .../tools/atmo/chat/realtime/ticket.json | 64 - .../tools/atmo/chat/server/getRecord.json | 185 - .../tools/atmo/chat/server/listRecords.json | 234 - .../tools/atmo/chat/server/watchRecords.json | 89 - .../tools/atmo/chat/space/addMember.json | 51 - .../tools/atmo/chat/space/createSpace.json | 55 - .../generated/tools/atmo/chat/space/defs.json | 207 - .../tools/atmo/chat/space/deleteRecord.json | 55 - .../tools/atmo/chat/space/getBlob.json | 41 - .../tools/atmo/chat/space/getRecord.json | 62 - .../tools/atmo/chat/space/getSpace.json | 48 - .../tools/atmo/chat/space/leaveSpace.json | 47 - .../tools/atmo/chat/space/listBlobs.json | 56 - .../tools/atmo/chat/space/listMembers.json | 47 - .../tools/atmo/chat/space/listRecords.json | 73 - .../tools/atmo/chat/space/listSpaces.json | 62 - .../tools/atmo/chat/space/putRecord.json | 67 - .../tools/atmo/chat/space/removeMember.json | 54 - .../tools/atmo/chat/space/uploadBlob.json | 49 - .../tools/atmo/chat/spaceExt/whoami.json | 54 - apps/group-chat/lexicons/pulled/README.md | 5 - .../pulled/app/bsky/actor/profile.json | 67 - apps/group-chat/package.json | 71 - apps/group-chat/pnpm-lock.yaml | 4750 ----------- apps/group-chat/scripts/append-scheduled.ts | 32 - apps/group-chat/scripts/generate.ts | 39 - apps/group-chat/scripts/sync.ts | 68 - apps/group-chat/src/app.css | 9 - apps/group-chat/src/app.d.ts | 40 - apps/group-chat/src/app.html | 12 - apps/group-chat/src/hooks.server.ts | 16 - .../group-chat/src/lib/atproto/auth.svelte.ts | 75 - .../src/lib/atproto/generated-methods.ts | 56 - .../src/lib/atproto/image-helper.ts | 152 - apps/group-chat/src/lib/atproto/index.ts | 20 - apps/group-chat/src/lib/atproto/methods.ts | 373 - apps/group-chat/src/lib/atproto/port.ts | 3 - .../src/lib/atproto/scripts/generate-key.ts | 4 - .../lib/atproto/scripts/generate-secret.ts | 3 - .../src/lib/atproto/scripts/setup-dev.ts | 60 - .../src/lib/atproto/scripts/tunnel.ts | 195 - .../src/lib/atproto/server/kv-store.ts | 38 - .../src/lib/atproto/server/oauth.remote.ts | 56 - .../src/lib/atproto/server/oauth.ts | 97 - .../src/lib/atproto/server/profile.ts | 51 - .../src/lib/atproto/server/repo.remote.ts | 93 - .../src/lib/atproto/server/session.ts | 73 - .../src/lib/atproto/server/signed-cookie.ts | 69 - apps/group-chat/src/lib/atproto/settings.ts | 46 - .../src/lib/atproto/ui/LoginModal.svelte | 22 - apps/group-chat/src/lib/contrail.config.ts | 31 - apps/group-chat/src/lib/contrail/client.ts | 32 - apps/group-chat/src/lib/contrail/index.ts | 109 - apps/group-chat/src/lib/emojis/icons.json | 7255 ----------------- apps/group-chat/src/lib/emojis/index.ts | 23 - apps/group-chat/src/lib/index.ts | 1 - .../src/lib/rooms/channels-context.ts | 21 - .../src/lib/rooms/connection.svelte.ts | 35 - .../src/lib/rooms/invites.remote.ts | 65 - .../src/lib/rooms/profiles.svelte.ts | 70 - .../src/lib/rooms/realtime.svelte.ts | 220 - apps/group-chat/src/lib/rooms/rooms.remote.ts | 240 - apps/group-chat/src/lib/rooms/server.ts | 324 - .../group-chat/src/lib/rooms/unread.svelte.ts | 72 - apps/group-chat/src/lib/rooms/uri.ts | 29 - apps/group-chat/src/lib/rooms/watch.svelte.ts | 289 - .../oauth-client-metadata.json/+server.ts | 8 - .../routes/(oauth)/oauth/callback/+server.ts | 41 - .../routes/(oauth)/oauth/jwks.json/+server.ts | 8 - apps/group-chat/src/routes/+layout.server.ts | 39 - apps/group-chat/src/routes/+layout.svelte | 33 - apps/group-chat/src/routes/+layout.ts | 1 - apps/group-chat/src/routes/+page.server.ts | 70 - apps/group-chat/src/routes/+page.svelte | 76 - .../src/routes/api/blob-upload/+server.ts | 22 - .../group-chat/src/routes/api/blob/+server.ts | 30 - .../group-chat/src/routes/api/cron/+server.ts | 14 - .../src/routes/api/ticket/+server.ts | 19 - .../routes/c/[communityDid]/+layout.server.ts | 82 - .../routes/c/[communityDid]/+layout.svelte | 341 - .../src/routes/c/[communityDid]/+page.svelte | 33 - .../[channelKey]/+page.server.ts | 15 - .../[communityDid]/[channelKey]/+page.svelte | 176 - .../settings/invites/+page.server.ts | 45 - .../settings/invites/+page.svelte | 154 - .../settings/members/+page.server.ts | 49 - .../settings/members/+page.svelte | 142 - .../src/routes/join/[token]/+page.svelte | 56 - apps/group-chat/src/routes/new/+page.svelte | 155 - .../src/routes/xrpc/[...path]/+server.ts | 9 - apps/group-chat/static/favicon.png | Bin 1571 -> 0 bytes apps/group-chat/svelte.config.js | 16 - apps/group-chat/tsconfig.json | 20 - apps/group-chat/vite.config.ts | 13 - apps/group-chat/wrangler.jsonc | 64 - .../lexicons/generated/index.ts | 5 +- .../statusphere/app/permissionSet.json | 26 - .../sveltekit-cloudflare-workers/package.json | 5 +- .../src/lib/contrail/index.ts | 10 +- development.md | 49 +- docs/01-indexing.md | 3 - docs/02-querying.md | 2 +- docs/03-lexicons.md | 100 - docs/04-feeds.md | 4 +- docs/05-auth.md | 231 - docs/06-spaces.md | 153 - docs/07-communities.md | 116 - docs/08-sync.md | 93 - docs/09-labels.md | 4 +- docs/10-deployment-shapes.md | 208 - docs/frameworks/sveltekit-cloudflare.md | 23 +- packages/contrail-appview/CHANGELOG.md | 247 - packages/contrail-appview/package.json | 56 - .../contrail-appview/src/core/backfill.ts | 619 -- packages/contrail-appview/src/core/client.ts | 1 - .../src/core/community-integration.ts | 1 - .../src/core/constellation.ts | 181 - .../contrail-appview/src/core/db/index.ts | 6 - .../contrail-appview/src/core/db/records.ts | 1092 --- .../contrail-appview/src/core/db/schema.ts | 629 -- packages/contrail-appview/src/core/dialect.ts | 1 - .../contrail-appview/src/core/identity.ts | 1 - .../src/core/invite/community-handler.ts | 1 - .../contrail-appview/src/core/invite/index.ts | 3 - .../src/core/invite/router.ts | 1 - .../contrail-appview/src/core/invite/token.ts | 1 - .../contrail-appview/src/core/jetstream.ts | 569 -- .../contrail-appview/src/core/labels/apply.ts | 64 - .../src/core/labels/hydrate.ts | Bin 3547 -> 0 bytes .../src/core/labels/resolve.ts | 155 - .../src/core/labels/schema.ts | 30 - .../src/core/labels/select.ts | 64 - .../src/core/labels/subscribe.ts | 333 - .../contrail-appview/src/core/labels/types.ts | 1 - .../contrail-appview/src/core/persistent.ts | 349 - .../src/core/realtime/durable-object.ts | 1 - .../src/core/realtime/in-memory.ts | 1 - .../src/core/realtime/index.ts | 37 - .../src/core/realtime/merge.ts | 1 - .../src/core/realtime/publishing-adapter.ts | 157 - .../src/core/realtime/query-filter.ts | 1 - .../src/core/realtime/resolve.ts | 92 - .../src/core/realtime/router.ts | 255 - .../contrail-appview/src/core/realtime/sse.ts | 1 - .../src/core/realtime/ticket.ts | 1 - .../src/core/realtime/types.ts | 1 - .../src/core/realtime/websocket.ts | 1 - packages/contrail-appview/src/core/refresh.ts | 267 - .../contrail-appview/src/core/router/admin.ts | 44 - .../src/core/router/collection.ts | 1263 --- .../contrail-appview/src/core/router/feed.ts | 299 - .../src/core/router/helpers.ts | 67 - .../src/core/router/hydrate.ts | 223 - .../contrail-appview/src/core/router/index.ts | 263 - .../src/core/router/notify.ts | 197 - .../src/core/router/profiles.ts | 181 - packages/contrail-appview/src/core/search.ts | 31 - .../contrail-appview/src/core/spaces/acl.ts | 1 - .../src/core/spaces/adapter.ts | 505 -- .../contrail-appview/src/core/spaces/auth.ts | 1 - .../src/core/spaces/binding.ts | 1 - .../src/core/spaces/blob-adapter.ts | 1 - .../src/core/spaces/blob-gc.ts | 38 - .../src/core/spaces/blob-refs.ts | 27 - .../src/core/spaces/credentials.ts | 1 - .../src/core/spaces/in-process.ts | 1 - .../src/core/spaces/router.ts | 85 - .../src/core/spaces/schema.ts | 104 - .../contrail-appview/src/core/spaces/tid.ts | 1 - .../contrail-appview/src/core/spaces/types.ts | 1 - .../contrail-appview/src/core/spaces/uri.ts | 1 - packages/contrail-appview/src/core/types.ts | 1 - packages/contrail-appview/src/index.ts | 79 - packages/contrail-appview/src/sync.ts | 229 - packages/contrail-appview/tsconfig.build.json | 7 - packages/contrail-appview/tsconfig.json | 7 - packages/contrail-appview/tsup.config.ts | 15 - packages/contrail-authority/CHANGELOG.md | 62 - packages/contrail-authority/package.json | 44 - packages/contrail-authority/src/adapter.ts | 317 - packages/contrail-authority/src/index.ts | 23 - .../contrail-authority/src/invite-routes.ts | 240 - packages/contrail-authority/src/routes.ts | 465 -- packages/contrail-authority/src/schema.ts | 52 - .../contrail-authority/tsconfig.build.json | 7 - packages/contrail-authority/tsconfig.json | 7 - packages/contrail-authority/tsup.config.ts | 11 - packages/contrail-base/CHANGELOG.md | 135 - packages/contrail-base/package.json | 67 - .../contrail-base/src/adapters/postgres.ts | 93 - packages/contrail-base/src/adapters/sqlite.ts | 39 - packages/contrail-base/src/client.ts | 254 - .../src/community-integration.ts | 67 - packages/contrail-base/src/dialect.ts | 127 - packages/contrail-base/src/identity.ts | 186 - packages/contrail-base/src/index.ts | 73 - .../src/invite/community-handler.ts | 67 - packages/contrail-base/src/invite/token.ts | 43 - packages/contrail-base/src/labels/types.ts | 56 - .../src/realtime/durable-object.ts | 567 -- .../contrail-base/src/realtime/in-memory.ts | 116 - packages/contrail-base/src/realtime/merge.ts | 77 - .../src/realtime/query-filter.ts | 235 - packages/contrail-base/src/realtime/sse.ts | 98 - packages/contrail-base/src/realtime/ticket.ts | 179 - packages/contrail-base/src/realtime/types.ts | 127 - .../contrail-base/src/realtime/websocket.ts | 84 - packages/contrail-base/src/sinks/types.ts | 55 - packages/contrail-base/src/spaces/acl.ts | 69 - packages/contrail-base/src/spaces/auth.ts | 187 - packages/contrail-base/src/spaces/binding.ts | 280 - .../contrail-base/src/spaces/blob-adapter.ts | 94 - .../contrail-base/src/spaces/credentials.ts | 284 - .../contrail-base/src/spaces/in-process.ts | 34 - packages/contrail-base/src/spaces/manifest.ts | 201 - packages/contrail-base/src/spaces/tid.ts | 20 - packages/contrail-base/src/spaces/types.ts | 283 - packages/contrail-base/src/spaces/uri.ts | 37 - packages/contrail-base/src/types.ts | 785 -- packages/contrail-base/tsconfig.build.json | 7 - packages/contrail-base/tsconfig.json | 7 - packages/contrail-base/tsup.config.ts | 11 - packages/contrail-community/CHANGELOG.md | 208 - packages/contrail-community/package.json | 72 - packages/contrail-community/src/acl.ts | 134 - packages/contrail-community/src/adapter.ts | 656 -- packages/contrail-community/src/cli/index.ts | 49 - packages/contrail-community/src/cli/reap.ts | 397 - .../contrail-community/src/credentials.ts | 117 - packages/contrail-community/src/index.ts | 89 - .../contrail-community/src/integration.ts | 80 - .../contrail-community/src/invite-handler.ts | 155 - packages/contrail-community/src/pds.ts | 344 - packages/contrail-community/src/plc.ts | 498 -- packages/contrail-community/src/provision.ts | 403 - packages/contrail-community/src/reconcile.ts | 47 - packages/contrail-community/src/router.ts | 1372 ---- packages/contrail-community/src/schema.ts | 104 - .../contrail-community/src/service-auth.ts | 51 - packages/contrail-community/src/types.ts | 169 - packages/contrail-community/src/whoami.ts | 26 - .../contrail-community/tests/cli-reap.test.ts | 388 - .../tests/community-delegation.test.ts | 308 - .../tests/community-e2e.test.ts | 287 - .../tests/community-mint.test.ts | 185 - .../community-provision-attempts.test.ts | 247 - .../community-provision-pds-allowlist.test.ts | 301 - .../tests/community-provision-router.test.ts | 336 - ...mmunity-publish-401-clears-session.test.ts | 150 - .../tests/community-publishing.test.ts | 632 -- .../tests/community-sessions-cache.test.ts | 85 - .../tests/invite-unified.test.ts | 221 - .../tests/pds-account-ops.test.ts | 81 - .../tests/pds-create-account.test.ts | 74 - .../tests/plc-log-last.test.ts | 81 - .../tests/plc-update-op.test.ts | 50 - .../tests/provision-orchestrator.test.ts | 259 - .../tests/provision-self-sovereign.test.ts | 353 - .../tests/realtime-community.test.ts | 148 - .../contrail-community/tests/schema.test.ts | 18 - .../tests/service-auth.test.ts | 135 - .../contrail-community/tsconfig.build.json | 7 - packages/contrail-community/tsconfig.json | 7 - packages/contrail-community/tsup.config.ts | 11 - packages/contrail-community/vitest.config.ts | 20 - packages/contrail-record-host/CHANGELOG.md | 80 - packages/contrail-record-host/package.json | 43 - packages/contrail-record-host/src/adapter.ts | 530 -- packages/contrail-record-host/src/blob-gc.ts | 37 - .../contrail-record-host/src/blob-refs.ts | 27 - packages/contrail-record-host/src/index.ts | 30 - packages/contrail-record-host/src/routes.ts | 508 -- packages/contrail-record-host/src/schema.ts | 51 - packages/contrail-record-host/src/sync.ts | 331 - .../contrail-record-host/tsconfig.build.json | 7 - packages/contrail-record-host/tsconfig.json | 7 - packages/contrail-record-host/tsup.config.ts | 11 - packages/contrail/PERMISSIONED_DATA.md | 140 - packages/contrail/README.md | 417 +- packages/contrail/package.json | 7 - packages/contrail/src/adapters/postgres.ts | 94 +- packages/contrail/src/adapters/sqlite.ts | 40 +- packages/contrail/src/cli-config.ts | 5 +- packages/contrail/src/cli.ts | 37 - packages/contrail/src/cli/shared.ts | 5 +- packages/contrail/src/contrail.ts | 126 +- packages/contrail/src/core/backfill.ts | 618 +- packages/contrail/src/core/client.ts | 255 +- .../src/core/community-integration.ts | 1 - packages/contrail/src/core/constellation.ts | 182 +- packages/contrail/src/core/db/index.ts | 7 +- .../src/core/db/meta.ts | 0 .../src/core/db/optimize.ts | 0 packages/contrail/src/core/db/records.ts | 981 ++- packages/contrail/src/core/db/schema.ts | 440 +- packages/contrail/src/core/dialect.ts | 128 +- packages/contrail/src/core/identity.ts | 187 +- .../src/core/invite/community-handler.ts | 1 - packages/contrail/src/core/invite/index.ts | 1 - packages/contrail/src/core/invite/router.ts | 1 - packages/contrail/src/core/invite/token.ts | 1 - packages/contrail/src/core/jetstream.ts | 569 +- packages/contrail/src/core/labels/apply.ts | 65 +- packages/contrail/src/core/labels/hydrate.ts | Bin 44 -> 3547 bytes packages/contrail/src/core/labels/resolve.ts | 156 +- packages/contrail/src/core/labels/schema.ts | 31 +- packages/contrail/src/core/labels/select.ts | 65 +- .../contrail/src/core/labels/subscribe.ts | 334 +- packages/contrail/src/core/labels/types.ts | 56 +- packages/contrail/src/core/persistent.ts | 345 +- .../src/core/realtime/durable-object.ts | 1 - .../contrail/src/core/realtime/in-memory.ts | 1 - packages/contrail/src/core/realtime/index.ts | 1 - packages/contrail/src/core/realtime/merge.ts | 1 - .../src/core/realtime/publishing-adapter.ts | 1 - .../src/core/realtime/query-filter.ts | 1 - .../contrail/src/core/realtime/resolve.ts | 1 - packages/contrail/src/core/realtime/router.ts | 1 - packages/contrail/src/core/realtime/sse.ts | 1 - packages/contrail/src/core/realtime/ticket.ts | 1 - packages/contrail/src/core/realtime/types.ts | 1 - .../contrail/src/core/realtime/websocket.ts | 1 - packages/contrail/src/core/refresh.ts | 268 +- packages/contrail/src/core/router/admin.ts | 45 +- .../contrail/src/core/router/collection.ts | 518 +- packages/contrail/src/core/router/feed.ts | 300 +- packages/contrail/src/core/router/helpers.ts | 67 +- packages/contrail/src/core/router/hydrate.ts | 185 +- packages/contrail/src/core/router/index.ts | 89 +- packages/contrail/src/core/router/notify.ts | 198 +- packages/contrail/src/core/router/profiles.ts | 182 +- packages/contrail/src/core/search.ts | 32 +- packages/contrail/src/core/sinks/types.ts | 31 + packages/contrail/src/core/spaces/acl.ts | 1 - packages/contrail/src/core/spaces/adapter.ts | 1 - packages/contrail/src/core/spaces/auth.ts | 1 - packages/contrail/src/core/spaces/binding.ts | 1 - .../contrail/src/core/spaces/blob-adapter.ts | 1 - packages/contrail/src/core/spaces/blob-gc.ts | 1 - .../contrail/src/core/spaces/blob-refs.ts | 1 - .../contrail/src/core/spaces/credentials.ts | 1 - .../contrail/src/core/spaces/in-process.ts | 1 - packages/contrail/src/core/spaces/router.ts | 1 - packages/contrail/src/core/spaces/schema.ts | 1 - packages/contrail/src/core/spaces/tid.ts | 1 - packages/contrail/src/core/spaces/types.ts | 1 - packages/contrail/src/core/spaces/uri.ts | 1 - packages/contrail/src/core/types.ts | 734 +- packages/contrail/src/index.ts | 234 +- packages/contrail/src/server.ts | 60 +- packages/contrail/src/worker/index.ts | 4 +- packages/contrail/src/workers/backfill.ts | 5 +- packages/contrail/tests/client.test.ts | 2 +- .../contrail/tests/contrail-entry.test.ts | 95 - packages/contrail/tests/dialect.test.ts | 2 +- .../tests/feed-prune-guardrail.test.ts | 8 +- packages/contrail/tests/feed-prune.test.ts | 10 +- .../contrail/tests/get-record-handle.test.ts | 6 +- packages/contrail/tests/helpers.test.ts | 4 +- packages/contrail/tests/helpers.ts | 8 +- packages/contrail/tests/hydrate.test.ts | 6 +- .../contrail/tests/identity-config.test.ts | 4 +- .../contrail/tests/identity-handle.test.ts | 6 +- packages/contrail/tests/ingest-hang.test.ts | 6 +- .../tests/jetstream-rollback-behavior.test.ts | 2 +- .../tests/jetstream-single-url.test.ts | 6 +- .../tests/jetstream-url-option.test.ts | 2 +- .../contrail/tests/labels-resolve.test.ts | 2 +- packages/contrail/tests/labels-router.test.ts | 6 +- packages/contrail/tests/labels.test.ts | 12 +- .../contrail/tests/lexicons-endpoint.test.ts | 2 +- .../tests/maintenance-optimize.test.ts | 6 +- .../tests/network-overrides-appview.test.ts | 8 +- .../contrail/tests/network-overrides.test.ts | 6 +- packages/contrail/tests/notify.test.ts | 12 +- packages/contrail/tests/persistent.test.ts | 15 +- .../tests/postgres-concurrent-init.test.ts | 4 +- packages/contrail/tests/postgres-e2e.test.ts | 10 +- packages/contrail/tests/postgres.test.ts | 6 +- .../tests/realtime-durable-object.test.ts | 247 - packages/contrail/tests/realtime-e2e.test.ts | 249 - .../tests/realtime-foundations.test.ts | 148 - .../contrail/tests/realtime-public.test.ts | 161 - packages/contrail/tests/records.test.ts | 4 +- packages/contrail/tests/refresh.test.ts | 11 +- .../tests/resolve-collection-key.test.ts | 2 +- .../tests/schema-fingerprint-gate.test.ts | 6 +- .../contrail/tests/schema-idempotency.test.ts | 6 +- packages/contrail/tests/schema.test.ts | 2 +- packages/contrail/tests/search.test.ts | 8 +- packages/contrail/tests/sinks.test.ts | 8 +- packages/contrail/tests/spaces-acl.test.ts | 181 - packages/contrail/tests/spaces-auth.test.ts | 65 - .../contrail/tests/spaces-binding.test.ts | 501 -- packages/contrail/tests/spaces-blobs.test.ts | 329 - .../contrail/tests/spaces-credentials.test.ts | 398 - packages/contrail/tests/spaces-e2e.test.ts | 312 - .../contrail/tests/spaces-enrollment.test.ts | 434 - .../contrail/tests/spaces-invites.test.ts | 249 - .../tests/spaces-manifest-appview.test.ts | 210 - .../contrail/tests/spaces-manifest.test.ts | 260 - packages/contrail/tests/sync-e2e.test.ts | 304 - packages/contrail/tests/sync-host.test.ts | 282 - packages/contrail/tests/types.test.ts | 2 +- packages/contrail/tests/worker.test.ts | 2 +- packages/contrail/vitest.config.ts | 20 +- packages/lexicons/CHANGELOG.md | 233 - packages/lexicons/README.md | 39 - .../lexicon-templates/community/adopt.json | 36 - .../lexicon-templates/community/defs.json | 76 - .../lexicon-templates/community/delete.json | 32 - .../community/deleteRecord.json | 37 - .../community/getHealth.json | 34 - .../lexicon-templates/community/list.json | 29 - .../lexicon-templates/community/mint.json | 34 - .../community/putRecord.json | 41 - .../lexicon-templates/community/reauth.json | 36 - .../community/space/create.json | 37 - .../community/space/delete.json | 32 - .../community/space/deleteRecord.json | 35 - .../community/space/grant.json | 35 - .../community/space/listMembers.json | 40 - .../community/space/putRecord.json | 40 - .../community/space/resync.json | 32 - .../community/space/revoke.json | 34 - .../community/space/setAccessLevel.json | 35 - .../lexicon-templates/invite/create.json | 41 - .../lexicon-templates/invite/defs.json | 24 - .../lexicon-templates/invite/list.json | 35 - .../lexicon-templates/invite/redeem.json | 37 - .../lexicon-templates/invite/revoke.json | 35 - .../lexicon-templates/realtime/subscribe.json | 24 - .../lexicon-templates/realtime/ticket.json | 40 - .../lexicon-templates/recordHost/enroll.json | 39 - .../lexicon-templates/recordHost/sync.json | 86 - .../lexicon-templates/spaces/addMember.json | 35 - .../lexicon-templates/spaces/createSpace.json | 35 - .../lexicon-templates/spaces/declaration.json | 31 - .../lexicon-templates/spaces/defs.json | 78 - .../spaces/deleteRecord.json | 34 - .../lexicon-templates/spaces/ext/whoami.json | 32 - .../lexicon-templates/spaces/getBlob.json | 27 - .../spaces/getCredential.json | 42 - .../spaces/getMembershipManifest.json | 41 - .../lexicon-templates/spaces/getRecord.json | 35 - .../lexicon-templates/spaces/getSpace.json | 32 - .../lexicon-templates/spaces/leaveSpace.json | 34 - .../lexicon-templates/spaces/listBlobs.json | 31 - .../lexicon-templates/spaces/listMembers.json | 34 - .../lexicon-templates/spaces/listRecords.json | 40 - .../lexicon-templates/spaces/listSpaces.json | 34 - .../lexicon-templates/spaces/putRecord.json | 39 - .../spaces/refreshCredential.json | 38 - .../spaces/removeMember.json | 36 - .../lexicon-templates/spaces/uploadBlob.json | 33 - packages/lexicons/package.json | 59 - packages/lexicons/src/cli.ts | 285 - packages/lexicons/src/generate.ts | 1137 --- packages/lexicons/src/index.ts | 9 - packages/lexicons/src/publish.ts | 238 - packages/lexicons/tests/generate.test.ts | 456 -- packages/lexicons/tsconfig.build.json | 7 - packages/lexicons/tsconfig.json | 7 - packages/lexicons/tsup.config.ts | 15 - packages/lexicons/vitest.config.ts | 7 - packages/sync/CHANGELOG.md | 188 - packages/sync/README.md | 28 - packages/sync/package.json | 45 - packages/sync/src/cache-idb.ts | 91 - packages/sync/src/index.ts | 593 -- packages/sync/tsconfig.build.json | 7 - packages/sync/tsconfig.json | 7 - packages/sync/tsup.config.ts | 10 - pnpm-lock.yaml | 431 - refs/community-spec-mapping.md | 139 - refs/spaces-later.md | 154 - refs/spaces-spec-mapping.md | 133 - todo/communities-as-labelers.md | 187 - todo/cross-space-watch-records.md | 120 - todo/realtime-do-dev-wiring.md | 104 - 559 files changed, 7523 insertions(+), 64949 deletions(-) delete mode 100644 apps/cloudflare-workers/lexicons/generated/com/example/permissionSet.json delete mode 100644 apps/contrail-e2e/.env.example delete mode 100644 apps/contrail-e2e/.gitignore delete mode 100644 apps/contrail-e2e/README.md delete mode 100644 apps/contrail-e2e/config.ts delete mode 100644 apps/contrail-e2e/docker-compose.yml delete mode 100644 apps/contrail-e2e/init-db/01-databases.sql delete mode 100644 apps/contrail-e2e/package.json delete mode 100755 apps/contrail-e2e/scripts/down.sh delete mode 100755 apps/contrail-e2e/scripts/up.sh delete mode 100644 apps/contrail-e2e/tests/community-delete.test.ts delete mode 100644 apps/contrail-e2e/tests/community-invites.test.ts delete mode 100644 apps/contrail-e2e/tests/community-lifecycle.test.ts delete mode 100644 apps/contrail-e2e/tests/community-provision-walkthrough.test.ts delete mode 100644 apps/contrail-e2e/tests/community-publishing.test.ts delete mode 100644 apps/contrail-e2e/tests/cursor-resume.test.ts delete mode 100644 apps/contrail-e2e/tests/health.test.ts delete mode 100644 apps/contrail-e2e/tests/helpers.ts delete mode 100644 apps/contrail-e2e/tests/ingest-roundtrip.test.ts delete mode 100644 apps/contrail-e2e/tests/provision.test.ts delete mode 100644 apps/contrail-e2e/tests/reap-tombstone.test.ts delete mode 100644 apps/contrail-e2e/tests/spaces-auth.test.ts delete mode 100644 apps/contrail-e2e/tests/spaces-firehose-invisibility.test.ts delete mode 100644 apps/contrail-e2e/tests/spaces-table-isolation.test.ts delete mode 100644 apps/contrail-e2e/tsconfig.json delete mode 100644 apps/contrail-e2e/vitest.config.ts delete mode 100644 apps/group-chat/.claude/settings.local.json delete mode 100644 apps/group-chat/.env.example delete mode 100644 apps/group-chat/.gitignore delete mode 100644 apps/group-chat/.npmrc delete mode 100644 apps/group-chat/.prettierignore delete mode 100644 apps/group-chat/.prettierrc delete mode 100644 apps/group-chat/LICENSE delete mode 100644 apps/group-chat/README.md delete mode 100644 apps/group-chat/eslint.config.js delete mode 100644 apps/group-chat/lexicons/custom/tools/atmo/chat/channel.json delete mode 100644 apps/group-chat/lexicons/custom/tools/atmo/chat/message.json delete mode 100644 apps/group-chat/lexicons/custom/tools/atmo/chat/server.json delete mode 100644 apps/group-chat/lexicons/generated/index.ts delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/channel/getRecord.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/channel/listRecords.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/channel/watchRecords.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/adopt.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/defs.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/delete.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/deleteRecord.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/getHealth.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/list.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/mint.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/putRecord.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/reauth.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/create.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/delete.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/deleteRecord.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/grant.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/listMembers.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/putRecord.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/resync.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/revoke.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/setAccessLevel.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/getCursor.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/getOverview.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/getProfile.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/invite/create.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/invite/defs.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/invite/list.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/invite/redeem.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/invite/revoke.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/message/getRecord.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/message/listRecords.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/message/watchRecords.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/notifyOfUpdate.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/permissionSet.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/realtime/subscribe.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/realtime/ticket.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/server/getRecord.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/server/listRecords.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/server/watchRecords.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/addMember.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/createSpace.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/defs.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/deleteRecord.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/getBlob.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/getRecord.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/getSpace.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/leaveSpace.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/listBlobs.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/listMembers.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/listRecords.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/listSpaces.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/putRecord.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/removeMember.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/space/uploadBlob.json delete mode 100644 apps/group-chat/lexicons/generated/tools/atmo/chat/spaceExt/whoami.json delete mode 100644 apps/group-chat/lexicons/pulled/README.md delete mode 100644 apps/group-chat/lexicons/pulled/app/bsky/actor/profile.json delete mode 100644 apps/group-chat/package.json delete mode 100644 apps/group-chat/pnpm-lock.yaml delete mode 100644 apps/group-chat/scripts/append-scheduled.ts delete mode 100644 apps/group-chat/scripts/generate.ts delete mode 100644 apps/group-chat/scripts/sync.ts delete mode 100644 apps/group-chat/src/app.css delete mode 100644 apps/group-chat/src/app.d.ts delete mode 100644 apps/group-chat/src/app.html delete mode 100644 apps/group-chat/src/hooks.server.ts delete mode 100644 apps/group-chat/src/lib/atproto/auth.svelte.ts delete mode 100644 apps/group-chat/src/lib/atproto/generated-methods.ts delete mode 100644 apps/group-chat/src/lib/atproto/image-helper.ts delete mode 100644 apps/group-chat/src/lib/atproto/index.ts delete mode 100644 apps/group-chat/src/lib/atproto/methods.ts delete mode 100644 apps/group-chat/src/lib/atproto/port.ts delete mode 100644 apps/group-chat/src/lib/atproto/scripts/generate-key.ts delete mode 100644 apps/group-chat/src/lib/atproto/scripts/generate-secret.ts delete mode 100644 apps/group-chat/src/lib/atproto/scripts/setup-dev.ts delete mode 100644 apps/group-chat/src/lib/atproto/scripts/tunnel.ts delete mode 100644 apps/group-chat/src/lib/atproto/server/kv-store.ts delete mode 100644 apps/group-chat/src/lib/atproto/server/oauth.remote.ts delete mode 100644 apps/group-chat/src/lib/atproto/server/oauth.ts delete mode 100644 apps/group-chat/src/lib/atproto/server/profile.ts delete mode 100644 apps/group-chat/src/lib/atproto/server/repo.remote.ts delete mode 100644 apps/group-chat/src/lib/atproto/server/session.ts delete mode 100644 apps/group-chat/src/lib/atproto/server/signed-cookie.ts delete mode 100644 apps/group-chat/src/lib/atproto/settings.ts delete mode 100644 apps/group-chat/src/lib/atproto/ui/LoginModal.svelte delete mode 100644 apps/group-chat/src/lib/contrail.config.ts delete mode 100644 apps/group-chat/src/lib/contrail/client.ts delete mode 100644 apps/group-chat/src/lib/contrail/index.ts delete mode 100644 apps/group-chat/src/lib/emojis/icons.json delete mode 100644 apps/group-chat/src/lib/emojis/index.ts delete mode 100644 apps/group-chat/src/lib/index.ts delete mode 100644 apps/group-chat/src/lib/rooms/channels-context.ts delete mode 100644 apps/group-chat/src/lib/rooms/connection.svelte.ts delete mode 100644 apps/group-chat/src/lib/rooms/invites.remote.ts delete mode 100644 apps/group-chat/src/lib/rooms/profiles.svelte.ts delete mode 100644 apps/group-chat/src/lib/rooms/realtime.svelte.ts delete mode 100644 apps/group-chat/src/lib/rooms/rooms.remote.ts delete mode 100644 apps/group-chat/src/lib/rooms/server.ts delete mode 100644 apps/group-chat/src/lib/rooms/unread.svelte.ts delete mode 100644 apps/group-chat/src/lib/rooms/uri.ts delete mode 100644 apps/group-chat/src/lib/rooms/watch.svelte.ts delete mode 100644 apps/group-chat/src/routes/(oauth)/oauth-client-metadata.json/+server.ts delete mode 100644 apps/group-chat/src/routes/(oauth)/oauth/callback/+server.ts delete mode 100644 apps/group-chat/src/routes/(oauth)/oauth/jwks.json/+server.ts delete mode 100644 apps/group-chat/src/routes/+layout.server.ts delete mode 100644 apps/group-chat/src/routes/+layout.svelte delete mode 100644 apps/group-chat/src/routes/+layout.ts delete mode 100644 apps/group-chat/src/routes/+page.server.ts delete mode 100644 apps/group-chat/src/routes/+page.svelte delete mode 100644 apps/group-chat/src/routes/api/blob-upload/+server.ts delete mode 100644 apps/group-chat/src/routes/api/blob/+server.ts delete mode 100644 apps/group-chat/src/routes/api/cron/+server.ts delete mode 100644 apps/group-chat/src/routes/api/ticket/+server.ts delete mode 100644 apps/group-chat/src/routes/c/[communityDid]/+layout.server.ts delete mode 100644 apps/group-chat/src/routes/c/[communityDid]/+layout.svelte delete mode 100644 apps/group-chat/src/routes/c/[communityDid]/+page.svelte delete mode 100644 apps/group-chat/src/routes/c/[communityDid]/[channelKey]/+page.server.ts delete mode 100644 apps/group-chat/src/routes/c/[communityDid]/[channelKey]/+page.svelte delete mode 100644 apps/group-chat/src/routes/c/[communityDid]/settings/invites/+page.server.ts delete mode 100644 apps/group-chat/src/routes/c/[communityDid]/settings/invites/+page.svelte delete mode 100644 apps/group-chat/src/routes/c/[communityDid]/settings/members/+page.server.ts delete mode 100644 apps/group-chat/src/routes/c/[communityDid]/settings/members/+page.svelte delete mode 100644 apps/group-chat/src/routes/join/[token]/+page.svelte delete mode 100644 apps/group-chat/src/routes/new/+page.svelte delete mode 100644 apps/group-chat/src/routes/xrpc/[...path]/+server.ts delete mode 100644 apps/group-chat/static/favicon.png delete mode 100644 apps/group-chat/svelte.config.js delete mode 100644 apps/group-chat/tsconfig.json delete mode 100644 apps/group-chat/vite.config.ts delete mode 100644 apps/group-chat/wrangler.jsonc delete mode 100644 apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/permissionSet.json delete mode 100644 docs/03-lexicons.md delete mode 100644 docs/05-auth.md delete mode 100644 docs/06-spaces.md delete mode 100644 docs/07-communities.md delete mode 100644 docs/08-sync.md delete mode 100644 docs/10-deployment-shapes.md delete mode 100644 packages/contrail-appview/CHANGELOG.md delete mode 100644 packages/contrail-appview/package.json delete mode 100644 packages/contrail-appview/src/core/backfill.ts delete mode 100644 packages/contrail-appview/src/core/client.ts delete mode 100644 packages/contrail-appview/src/core/community-integration.ts delete mode 100644 packages/contrail-appview/src/core/constellation.ts delete mode 100644 packages/contrail-appview/src/core/db/index.ts delete mode 100644 packages/contrail-appview/src/core/db/records.ts delete mode 100644 packages/contrail-appview/src/core/db/schema.ts delete mode 100644 packages/contrail-appview/src/core/dialect.ts delete mode 100644 packages/contrail-appview/src/core/identity.ts delete mode 100644 packages/contrail-appview/src/core/invite/community-handler.ts delete mode 100644 packages/contrail-appview/src/core/invite/index.ts delete mode 100644 packages/contrail-appview/src/core/invite/router.ts delete mode 100644 packages/contrail-appview/src/core/invite/token.ts delete mode 100644 packages/contrail-appview/src/core/jetstream.ts delete mode 100644 packages/contrail-appview/src/core/labels/apply.ts delete mode 100644 packages/contrail-appview/src/core/labels/hydrate.ts delete mode 100644 packages/contrail-appview/src/core/labels/resolve.ts delete mode 100644 packages/contrail-appview/src/core/labels/schema.ts delete mode 100644 packages/contrail-appview/src/core/labels/select.ts delete mode 100644 packages/contrail-appview/src/core/labels/subscribe.ts delete mode 100644 packages/contrail-appview/src/core/labels/types.ts delete mode 100644 packages/contrail-appview/src/core/persistent.ts delete mode 100644 packages/contrail-appview/src/core/realtime/durable-object.ts delete mode 100644 packages/contrail-appview/src/core/realtime/in-memory.ts delete mode 100644 packages/contrail-appview/src/core/realtime/index.ts delete mode 100644 packages/contrail-appview/src/core/realtime/merge.ts delete mode 100644 packages/contrail-appview/src/core/realtime/publishing-adapter.ts delete mode 100644 packages/contrail-appview/src/core/realtime/query-filter.ts delete mode 100644 packages/contrail-appview/src/core/realtime/resolve.ts delete mode 100644 packages/contrail-appview/src/core/realtime/router.ts delete mode 100644 packages/contrail-appview/src/core/realtime/sse.ts delete mode 100644 packages/contrail-appview/src/core/realtime/ticket.ts delete mode 100644 packages/contrail-appview/src/core/realtime/types.ts delete mode 100644 packages/contrail-appview/src/core/realtime/websocket.ts delete mode 100644 packages/contrail-appview/src/core/refresh.ts delete mode 100644 packages/contrail-appview/src/core/router/admin.ts delete mode 100644 packages/contrail-appview/src/core/router/collection.ts delete mode 100644 packages/contrail-appview/src/core/router/feed.ts delete mode 100644 packages/contrail-appview/src/core/router/helpers.ts delete mode 100644 packages/contrail-appview/src/core/router/hydrate.ts delete mode 100644 packages/contrail-appview/src/core/router/index.ts delete mode 100644 packages/contrail-appview/src/core/router/notify.ts delete mode 100644 packages/contrail-appview/src/core/router/profiles.ts delete mode 100644 packages/contrail-appview/src/core/search.ts delete mode 100644 packages/contrail-appview/src/core/spaces/acl.ts delete mode 100644 packages/contrail-appview/src/core/spaces/adapter.ts delete mode 100644 packages/contrail-appview/src/core/spaces/auth.ts delete mode 100644 packages/contrail-appview/src/core/spaces/binding.ts delete mode 100644 packages/contrail-appview/src/core/spaces/blob-adapter.ts delete mode 100644 packages/contrail-appview/src/core/spaces/blob-gc.ts delete mode 100644 packages/contrail-appview/src/core/spaces/blob-refs.ts delete mode 100644 packages/contrail-appview/src/core/spaces/credentials.ts delete mode 100644 packages/contrail-appview/src/core/spaces/in-process.ts delete mode 100644 packages/contrail-appview/src/core/spaces/router.ts delete mode 100644 packages/contrail-appview/src/core/spaces/schema.ts delete mode 100644 packages/contrail-appview/src/core/spaces/tid.ts delete mode 100644 packages/contrail-appview/src/core/spaces/types.ts delete mode 100644 packages/contrail-appview/src/core/spaces/uri.ts delete mode 100644 packages/contrail-appview/src/core/types.ts delete mode 100644 packages/contrail-appview/src/index.ts delete mode 100644 packages/contrail-appview/src/sync.ts delete mode 100644 packages/contrail-appview/tsconfig.build.json delete mode 100644 packages/contrail-appview/tsconfig.json delete mode 100644 packages/contrail-appview/tsup.config.ts delete mode 100644 packages/contrail-authority/CHANGELOG.md delete mode 100644 packages/contrail-authority/package.json delete mode 100644 packages/contrail-authority/src/adapter.ts delete mode 100644 packages/contrail-authority/src/index.ts delete mode 100644 packages/contrail-authority/src/invite-routes.ts delete mode 100644 packages/contrail-authority/src/routes.ts delete mode 100644 packages/contrail-authority/src/schema.ts delete mode 100644 packages/contrail-authority/tsconfig.build.json delete mode 100644 packages/contrail-authority/tsconfig.json delete mode 100644 packages/contrail-authority/tsup.config.ts delete mode 100644 packages/contrail-base/CHANGELOG.md delete mode 100644 packages/contrail-base/package.json delete mode 100644 packages/contrail-base/src/adapters/postgres.ts delete mode 100644 packages/contrail-base/src/adapters/sqlite.ts delete mode 100644 packages/contrail-base/src/client.ts delete mode 100644 packages/contrail-base/src/community-integration.ts delete mode 100644 packages/contrail-base/src/dialect.ts delete mode 100644 packages/contrail-base/src/identity.ts delete mode 100644 packages/contrail-base/src/index.ts delete mode 100644 packages/contrail-base/src/invite/community-handler.ts delete mode 100644 packages/contrail-base/src/invite/token.ts delete mode 100644 packages/contrail-base/src/labels/types.ts delete mode 100644 packages/contrail-base/src/realtime/durable-object.ts delete mode 100644 packages/contrail-base/src/realtime/in-memory.ts delete mode 100644 packages/contrail-base/src/realtime/merge.ts delete mode 100644 packages/contrail-base/src/realtime/query-filter.ts delete mode 100644 packages/contrail-base/src/realtime/sse.ts delete mode 100644 packages/contrail-base/src/realtime/ticket.ts delete mode 100644 packages/contrail-base/src/realtime/types.ts delete mode 100644 packages/contrail-base/src/realtime/websocket.ts delete mode 100644 packages/contrail-base/src/sinks/types.ts delete mode 100644 packages/contrail-base/src/spaces/acl.ts delete mode 100644 packages/contrail-base/src/spaces/auth.ts delete mode 100644 packages/contrail-base/src/spaces/binding.ts delete mode 100644 packages/contrail-base/src/spaces/blob-adapter.ts delete mode 100644 packages/contrail-base/src/spaces/credentials.ts delete mode 100644 packages/contrail-base/src/spaces/in-process.ts delete mode 100644 packages/contrail-base/src/spaces/manifest.ts delete mode 100644 packages/contrail-base/src/spaces/tid.ts delete mode 100644 packages/contrail-base/src/spaces/types.ts delete mode 100644 packages/contrail-base/src/spaces/uri.ts delete mode 100644 packages/contrail-base/src/types.ts delete mode 100644 packages/contrail-base/tsconfig.build.json delete mode 100644 packages/contrail-base/tsconfig.json delete mode 100644 packages/contrail-base/tsup.config.ts delete mode 100644 packages/contrail-community/CHANGELOG.md delete mode 100644 packages/contrail-community/package.json delete mode 100644 packages/contrail-community/src/acl.ts delete mode 100644 packages/contrail-community/src/adapter.ts delete mode 100644 packages/contrail-community/src/cli/index.ts delete mode 100644 packages/contrail-community/src/cli/reap.ts delete mode 100644 packages/contrail-community/src/credentials.ts delete mode 100644 packages/contrail-community/src/index.ts delete mode 100644 packages/contrail-community/src/integration.ts delete mode 100644 packages/contrail-community/src/invite-handler.ts delete mode 100644 packages/contrail-community/src/pds.ts delete mode 100644 packages/contrail-community/src/plc.ts delete mode 100644 packages/contrail-community/src/provision.ts delete mode 100644 packages/contrail-community/src/reconcile.ts delete mode 100644 packages/contrail-community/src/router.ts delete mode 100644 packages/contrail-community/src/schema.ts delete mode 100644 packages/contrail-community/src/service-auth.ts delete mode 100644 packages/contrail-community/src/types.ts delete mode 100644 packages/contrail-community/src/whoami.ts delete mode 100644 packages/contrail-community/tests/cli-reap.test.ts delete mode 100644 packages/contrail-community/tests/community-delegation.test.ts delete mode 100644 packages/contrail-community/tests/community-e2e.test.ts delete mode 100644 packages/contrail-community/tests/community-mint.test.ts delete mode 100644 packages/contrail-community/tests/community-provision-attempts.test.ts delete mode 100644 packages/contrail-community/tests/community-provision-pds-allowlist.test.ts delete mode 100644 packages/contrail-community/tests/community-provision-router.test.ts delete mode 100644 packages/contrail-community/tests/community-publish-401-clears-session.test.ts delete mode 100644 packages/contrail-community/tests/community-publishing.test.ts delete mode 100644 packages/contrail-community/tests/community-sessions-cache.test.ts delete mode 100644 packages/contrail-community/tests/invite-unified.test.ts delete mode 100644 packages/contrail-community/tests/pds-account-ops.test.ts delete mode 100644 packages/contrail-community/tests/pds-create-account.test.ts delete mode 100644 packages/contrail-community/tests/plc-log-last.test.ts delete mode 100644 packages/contrail-community/tests/plc-update-op.test.ts delete mode 100644 packages/contrail-community/tests/provision-orchestrator.test.ts delete mode 100644 packages/contrail-community/tests/provision-self-sovereign.test.ts delete mode 100644 packages/contrail-community/tests/realtime-community.test.ts delete mode 100644 packages/contrail-community/tests/schema.test.ts delete mode 100644 packages/contrail-community/tests/service-auth.test.ts delete mode 100644 packages/contrail-community/tsconfig.build.json delete mode 100644 packages/contrail-community/tsconfig.json delete mode 100644 packages/contrail-community/tsup.config.ts delete mode 100644 packages/contrail-community/vitest.config.ts delete mode 100644 packages/contrail-record-host/CHANGELOG.md delete mode 100644 packages/contrail-record-host/package.json delete mode 100644 packages/contrail-record-host/src/adapter.ts delete mode 100644 packages/contrail-record-host/src/blob-gc.ts delete mode 100644 packages/contrail-record-host/src/blob-refs.ts delete mode 100644 packages/contrail-record-host/src/index.ts delete mode 100644 packages/contrail-record-host/src/routes.ts delete mode 100644 packages/contrail-record-host/src/schema.ts delete mode 100644 packages/contrail-record-host/src/sync.ts delete mode 100644 packages/contrail-record-host/tsconfig.build.json delete mode 100644 packages/contrail-record-host/tsconfig.json delete mode 100644 packages/contrail-record-host/tsup.config.ts delete mode 100644 packages/contrail/PERMISSIONED_DATA.md delete mode 100644 packages/contrail/src/core/community-integration.ts rename packages/{contrail-appview => contrail}/src/core/db/meta.ts (100%) rename packages/{contrail-appview => contrail}/src/core/db/optimize.ts (100%) delete mode 100644 packages/contrail/src/core/invite/community-handler.ts delete mode 100644 packages/contrail/src/core/invite/index.ts delete mode 100644 packages/contrail/src/core/invite/router.ts delete mode 100644 packages/contrail/src/core/invite/token.ts delete mode 100644 packages/contrail/src/core/realtime/durable-object.ts delete mode 100644 packages/contrail/src/core/realtime/in-memory.ts delete mode 100644 packages/contrail/src/core/realtime/index.ts delete mode 100644 packages/contrail/src/core/realtime/merge.ts delete mode 100644 packages/contrail/src/core/realtime/publishing-adapter.ts delete mode 100644 packages/contrail/src/core/realtime/query-filter.ts delete mode 100644 packages/contrail/src/core/realtime/resolve.ts delete mode 100644 packages/contrail/src/core/realtime/router.ts delete mode 100644 packages/contrail/src/core/realtime/sse.ts delete mode 100644 packages/contrail/src/core/realtime/ticket.ts delete mode 100644 packages/contrail/src/core/realtime/types.ts delete mode 100644 packages/contrail/src/core/realtime/websocket.ts create mode 100644 packages/contrail/src/core/sinks/types.ts delete mode 100644 packages/contrail/src/core/spaces/acl.ts delete mode 100644 packages/contrail/src/core/spaces/adapter.ts delete mode 100644 packages/contrail/src/core/spaces/auth.ts delete mode 100644 packages/contrail/src/core/spaces/binding.ts delete mode 100644 packages/contrail/src/core/spaces/blob-adapter.ts delete mode 100644 packages/contrail/src/core/spaces/blob-gc.ts delete mode 100644 packages/contrail/src/core/spaces/blob-refs.ts delete mode 100644 packages/contrail/src/core/spaces/credentials.ts delete mode 100644 packages/contrail/src/core/spaces/in-process.ts delete mode 100644 packages/contrail/src/core/spaces/router.ts delete mode 100644 packages/contrail/src/core/spaces/schema.ts delete mode 100644 packages/contrail/src/core/spaces/tid.ts delete mode 100644 packages/contrail/src/core/spaces/types.ts delete mode 100644 packages/contrail/src/core/spaces/uri.ts delete mode 100644 packages/contrail/tests/contrail-entry.test.ts delete mode 100644 packages/contrail/tests/realtime-durable-object.test.ts delete mode 100644 packages/contrail/tests/realtime-e2e.test.ts delete mode 100644 packages/contrail/tests/realtime-foundations.test.ts delete mode 100644 packages/contrail/tests/realtime-public.test.ts delete mode 100644 packages/contrail/tests/spaces-acl.test.ts delete mode 100644 packages/contrail/tests/spaces-auth.test.ts delete mode 100644 packages/contrail/tests/spaces-binding.test.ts delete mode 100644 packages/contrail/tests/spaces-blobs.test.ts delete mode 100644 packages/contrail/tests/spaces-credentials.test.ts delete mode 100644 packages/contrail/tests/spaces-e2e.test.ts delete mode 100644 packages/contrail/tests/spaces-enrollment.test.ts delete mode 100644 packages/contrail/tests/spaces-invites.test.ts delete mode 100644 packages/contrail/tests/spaces-manifest-appview.test.ts delete mode 100644 packages/contrail/tests/spaces-manifest.test.ts delete mode 100644 packages/contrail/tests/sync-e2e.test.ts delete mode 100644 packages/contrail/tests/sync-host.test.ts delete mode 100644 packages/lexicons/CHANGELOG.md delete mode 100644 packages/lexicons/README.md delete mode 100644 packages/lexicons/lexicon-templates/community/adopt.json delete mode 100644 packages/lexicons/lexicon-templates/community/defs.json delete mode 100644 packages/lexicons/lexicon-templates/community/delete.json delete mode 100644 packages/lexicons/lexicon-templates/community/deleteRecord.json delete mode 100644 packages/lexicons/lexicon-templates/community/getHealth.json delete mode 100644 packages/lexicons/lexicon-templates/community/list.json delete mode 100644 packages/lexicons/lexicon-templates/community/mint.json delete mode 100644 packages/lexicons/lexicon-templates/community/putRecord.json delete mode 100644 packages/lexicons/lexicon-templates/community/reauth.json delete mode 100644 packages/lexicons/lexicon-templates/community/space/create.json delete mode 100644 packages/lexicons/lexicon-templates/community/space/delete.json delete mode 100644 packages/lexicons/lexicon-templates/community/space/deleteRecord.json delete mode 100644 packages/lexicons/lexicon-templates/community/space/grant.json delete mode 100644 packages/lexicons/lexicon-templates/community/space/listMembers.json delete mode 100644 packages/lexicons/lexicon-templates/community/space/putRecord.json delete mode 100644 packages/lexicons/lexicon-templates/community/space/resync.json delete mode 100644 packages/lexicons/lexicon-templates/community/space/revoke.json delete mode 100644 packages/lexicons/lexicon-templates/community/space/setAccessLevel.json delete mode 100644 packages/lexicons/lexicon-templates/invite/create.json delete mode 100644 packages/lexicons/lexicon-templates/invite/defs.json delete mode 100644 packages/lexicons/lexicon-templates/invite/list.json delete mode 100644 packages/lexicons/lexicon-templates/invite/redeem.json delete mode 100644 packages/lexicons/lexicon-templates/invite/revoke.json delete mode 100644 packages/lexicons/lexicon-templates/realtime/subscribe.json delete mode 100644 packages/lexicons/lexicon-templates/realtime/ticket.json delete mode 100644 packages/lexicons/lexicon-templates/recordHost/enroll.json delete mode 100644 packages/lexicons/lexicon-templates/recordHost/sync.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/addMember.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/createSpace.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/declaration.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/defs.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/deleteRecord.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/ext/whoami.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/getBlob.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/getCredential.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/getMembershipManifest.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/getRecord.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/getSpace.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/leaveSpace.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/listBlobs.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/listMembers.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/listRecords.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/listSpaces.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/putRecord.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/refreshCredential.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/removeMember.json delete mode 100644 packages/lexicons/lexicon-templates/spaces/uploadBlob.json delete mode 100644 packages/lexicons/package.json delete mode 100644 packages/lexicons/src/cli.ts delete mode 100644 packages/lexicons/src/generate.ts delete mode 100644 packages/lexicons/src/index.ts delete mode 100644 packages/lexicons/src/publish.ts delete mode 100644 packages/lexicons/tests/generate.test.ts delete mode 100644 packages/lexicons/tsconfig.build.json delete mode 100644 packages/lexicons/tsconfig.json delete mode 100644 packages/lexicons/tsup.config.ts delete mode 100644 packages/lexicons/vitest.config.ts delete mode 100644 packages/sync/CHANGELOG.md delete mode 100644 packages/sync/README.md delete mode 100644 packages/sync/package.json delete mode 100644 packages/sync/src/cache-idb.ts delete mode 100644 packages/sync/src/index.ts delete mode 100644 packages/sync/tsconfig.build.json delete mode 100644 packages/sync/tsconfig.json delete mode 100644 packages/sync/tsup.config.ts delete mode 100644 refs/community-spec-mapping.md delete mode 100644 refs/spaces-later.md delete mode 100644 refs/spaces-spec-mapping.md delete mode 100644 todo/communities-as-labelers.md delete mode 100644 todo/cross-space-watch-records.md delete mode 100644 todo/realtime-do-dev-wiring.md diff --git a/.changeset/config.json b/.changeset/config.json index 070c0d5..91adfd7 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,15 +2,7 @@ "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, - "fixed": [[ - "@atmo-dev/contrail", - "@atmo-dev/contrail-community", - "@atmo-dev/contrail-sync", - "@atmo-dev/contrail-base", - "@atmo-dev/contrail-authority", - "@atmo-dev/contrail-record-host", - "@atmo-dev/contrail-appview" - ]], + "fixed": [["@atmo-dev/contrail"]], "linked": [], "access": "public", "baseBranch": "main", diff --git a/README.md b/README.md index c4203e0..046da0f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,21 @@ # Contrail -> **pre-alpha.** Expect breaking changes. +> **Pre-alpha.** Expect breaking changes. -a library for easily creating (serverless) atproto backends/appviews. +Contrail turns public AT Protocol records into a queryable AppView. -- declare collections -- get automatic jetstream backfill and ingestion, typed XRPC endpoints -- optional: permissioned spaces and group-controlled communities +It provides: -mostly tested on cloudflare workers with d1 but should run in any node env too -(+ has adapters for node:sqlite and postgres for the db). +- historical backfill from relays and PDSes; +- current updates from Jetstream; +- D1, SQLite, and PostgreSQL storage; +- `getRecord` and `listRecords` HTTP endpoints; +- filters, sorting, search, and pagination; +- custom queries; +- relationship counts and hydration; and +- profile and label hydration. + +Cloudflare Workers with D1 is the primary deployment target. Node.js with SQLite or PostgreSQL is also supported. ## Install @@ -17,88 +23,88 @@ mostly tested on cloudflare workers with d1 but should run in any node env too pnpm add @atmo-dev/contrail ``` -## Minimal example - -a complete cloudflare worker that indexes public calendar events from the atproto network and serves them over a typed XRPC endpoint. two files + config. a runnable version lives in [`apps/cloudflare-workers`](https://github.com/flo-bit/contrail/tree/main/apps/cloudflare-workers) — clone, deploy, `pnpm contrail backfill --remote`, done. - -**`src/contrail.config.ts`** — picked up automatically by the `contrail` CLI: +## Minimal Worker ```ts +// src/contrail.config.ts import type { ContrailConfig } from "@atmo-dev/contrail"; export const config: ContrailConfig = { namespace: "com.example", collections: { event: { - collection: "community.lexicon.calendar.event", // NSID to index - queryable: { startsAt: { type: "range" } }, // ?startsAtMin=... - searchable: ["name", "description"], // ?search=... + collection: "community.lexicon.calendar.event", + queryable: { + mode: {}, + startsAt: { type: "range" }, + }, + searchable: ["name", "description"], }, }, }; ``` -**`src/worker.ts`** — four lines. `createWorker` wires up fetch + scheduled + lazy init: - ```ts +// src/worker.ts import { createWorker } from "@atmo-dev/contrail/worker"; import { config } from "./contrail.config"; -import { lexicons } from "../lexicons/generated"; -export default createWorker(config, { lexicons }); +export default createWorker(config); ``` -`lexicons/generated/` is produced by `contrail-lex generate`; passing `{ lexicons }` exposes them at `/lexicons` so consumer apps can typegen against your deployed service. Drop it if you don't need that. - -and a d1 binding + cron in `wrangler.jsonc`: +Add a D1 binding and one-minute cron to `wrangler.jsonc`: ```jsonc { "main": "src/worker.ts", - "d1_databases": [{ "binding": "DB", "database_name": "contrail", "database_id": "..." }], + "d1_databases": [ + { "binding": "DB", "database_name": "contrail", "database_id": "..." } + ], "triggers": { "crons": ["*/1 * * * *"] } } ``` -then: +Then deploy and backfill: ```bash -npx wrangler d1 create contrail # copy the id into wrangler.jsonc -pnpm wrangler deploy # deploy the worker -pnpm contrail backfill --remote # one-shot historical backfill +npx wrangler d1 create contrail +pnpm wrangler deploy +pnpm contrail backfill --remote ``` -the worker keeps itself fresh from now on via the cron. hit: +Query the resulting AppView: +```text +GET /xrpc/com.example.event.listRecords?startsAtMin=2026-01-01&limit=10 ``` -GET https://.workers.dev/xrpc/com.example.event.listRecords?startsAtMin=2026-01-01&limit=10 + +For ordinary Lexicon parsing, validation, pulling, and TypeScript generation, use [Atcute](https://github.com/mary-ext/atcute) directly. Contrail no longer ships a separate Lexicon toolchain. + +## Other databases + +```ts +import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; +import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; ``` -returns every `community.lexicon.calendar.event` record published anywhere on atproto that matches, as JSON. that's it — no PDS setup, no lexicon publishing, no relay configuration. everything scales from there: add filters, add full-text search, add more collections, turn on [spaces](https://github.com/flo-bit/contrail/blob/main/docs/06-spaces.md) for private records, mount the handler in sveltekit instead, swap the adapter for postgres. +See [Indexing](docs/01-indexing.md) for adapter setup and [Querying](docs/02-querying.md) for the query and hydration model. -**not using workers?** same library, different `db`. see [adapters](https://github.com/flo-bit/contrail/blob/main/docs/01-indexing.md#adapters) for node:sqlite and postgres. +## Documentation -## Docs +- [Indexing](docs/01-indexing.md) +- [Querying](docs/02-querying.md) +- [Feeds](docs/04-feeds.md) +- [Labels](docs/09-labels.md) +- [SvelteKit + Cloudflare](docs/frameworks/sveltekit-cloudflare.md) -- [Indexing](https://github.com/flo-bit/contrail/blob/main/docs/01-indexing.md) — the core: collections, ingestion, adapters -- [Querying](https://github.com/flo-bit/contrail/blob/main/docs/02-querying.md) — filters, sorts, hydration, search, pagination -- [Lexicons](https://github.com/flo-bit/contrail/blob/main/docs/03-lexicons.md) — `contrail-lex` CLI, codegen, publishing -- [Feeds](https://github.com/flo-bit/contrail/blob/main/docs/04-feeds.md) — personalized timelines via follow + target collections -- [Auth](https://github.com/flo-bit/contrail/blob/main/docs/05-auth.md) — service-auth JWTs, invite tokens, watch tickets, OAuth permission sets -- [Spaces](https://github.com/flo-bit/contrail/blob/main/docs/06-spaces.md) — permissioned records stored by the appview -- [Communities](https://github.com/flo-bit/contrail/blob/main/docs/07-communities.md) — group-controlled atproto DIDs -- [Sync](https://github.com/flo-bit/contrail/blob/main/docs/08-sync.md) — reactive client-side store over `watchRecords` -- [Labels](https://github.com/flo-bit/contrail/blob/main/docs/09-labels.md) — atproto-native moderation hydration from external labelers -- [Deployment shapes](https://github.com/flo-bit/contrail/blob/main/docs/10-deployment-shapes.md) — all-in-one vs split-authority vs split-host configurations -- Frameworks: [SvelteKit + Cloudflare](https://github.com/flo-bit/contrail/blob/main/docs/frameworks/sveltekit-cloudflare.md) +## Repository layout -## Packages +There is one published package and one implementation: + +```text +packages/contrail/ @atmo-dev/contrail +``` -| Package | | -|---|---| -| `@atmo-dev/contrail` | Core library — indexing, XRPC server, spaces, realtime | -| `@atmo-dev/contrail-community` | Community module — group-controlled DIDs, access-level ladder. Plugs into core via an integration | -| `@atmo-dev/contrail-sync` | Client-side reactive watch-store with optional IndexedDB cache | -| `@atmo-dev/contrail-lexicons` | Codegen + `contrail-lex` CLI | +The previous AppView, base, community, authority, record-host, sync, and Lexicon packages have been removed. -Working in this repo? See [development.md](https://github.com/flo-bit/contrail/blob/main/development.md) for the monorepo layout and commands. +See [development.md](development.md) for repository commands. diff --git a/apps/cloudflare-workers/README.md b/apps/cloudflare-workers/README.md index 1145387..68b428f 100644 --- a/apps/cloudflare-workers/README.md +++ b/apps/cloudflare-workers/README.md @@ -1,6 +1,6 @@ # cloudflare-workers -minimal, runnable contrail deployment — cloudflare workers + d1, one collection (`community.lexicon.calendar.event`), no spaces / communities / realtime. mirrors the setup shown in the [root README](../../README.md) exactly. +Minimal runnable Contrail deployment: Cloudflare Workers, D1, and one public collection (`community.lexicon.calendar.event`). It mirrors the setup in the [root README](../../README.md). ## layout @@ -41,5 +41,3 @@ pnpm contrail backfill # backfill against the local D1 created by wrangler - **add a collection:** append to `collections` in `src/contrail.config.ts`; redeploy; `pnpm contrail backfill --remote` to backfill the new one. - **add full-text search:** `searchable: ["field1", "field2"]`, redeploy, no backfill needed (fts indexes repopulate on ingest). - **add relations / references:** see [indexing docs](../../docs/01-indexing.md). -- **private records:** see [spaces docs](../../docs/05-spaces.md). -- **group-controlled DIDs:** see [communities docs](../../docs/06-communities.md). diff --git a/apps/cloudflare-workers/lexicons/generated/com/example/permissionSet.json b/apps/cloudflare-workers/lexicons/generated/com/example/permissionSet.json deleted file mode 100644 index 12ccb96..0000000 --- a/apps/cloudflare-workers/lexicons/generated/com/example/permissionSet.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "lexicon": 1, - "id": "com.example.permissionSet", - "defs": { - "main": { - "type": "permission-set", - "title": "com.example", - "description": "All XRPC methods exposed by the com.example service.", - "permissions": [ - { - "type": "permission", - "resource": "rpc", - "aud": "*", - "lxm": [ - "com.example.event.getRecord", - "com.example.event.listRecords", - "com.example.getCursor", - "com.example.getOverview", - "com.example.getProfile", - "com.example.notifyOfUpdate" - ] - } - ] - } - } -} diff --git a/apps/cloudflare-workers/lexicons/generated/index.ts b/apps/cloudflare-workers/lexicons/generated/index.ts index 568ba45..28c9d90 100644 --- a/apps/cloudflare-workers/lexicons/generated/index.ts +++ b/apps/cloudflare-workers/lexicons/generated/index.ts @@ -1,4 +1,4 @@ -// Auto-generated by @atmo-dev/contrail-lexicons. Do not edit. +// Checked-in Lexicon bundle. // Pass `lexicons` to `createWorker(config, { lexicons })` to expose them // at `/xrpc/.lexicons` for consumer apps to typegen against. @@ -14,6 +14,5 @@ import _8 from "./com/example/getCursor.json"; import _9 from "./com/example/getOverview.json"; import _10 from "./com/example/getProfile.json"; import _11 from "./com/example/notifyOfUpdate.json"; -import _12 from "./com/example/permissionSet.json"; -export const lexicons: object[] = [_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12]; +export const lexicons: object[] = [_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11]; diff --git a/apps/cloudflare-workers/package.json b/apps/cloudflare-workers/package.json index 99d2a6b..8dfb4e8 100644 --- a/apps/cloudflare-workers/package.json +++ b/apps/cloudflare-workers/package.json @@ -12,7 +12,6 @@ }, "devDependencies": { "@atcute/lex-cli": "^2.8.1", - "@atmo-dev/contrail-lexicons": "workspace:*", "@cloudflare/workers-types": "^4.20250124.0", "typescript": "^5.7.3", "wrangler": "^4.63.0" diff --git a/apps/contrail-e2e/.env.example b/apps/contrail-e2e/.env.example deleted file mode 100644 index 03bf68a..0000000 --- a/apps/contrail-e2e/.env.example +++ /dev/null @@ -1,29 +0,0 @@ -# Ports (override to avoid conflicts with other local services) -DEVNET_PDS_PORT=4000 -DEVNET_PLC_PORT=2582 -DEVNET_JETSTREAM_PORT=6008 -DEVNET_JETSTREAM_METRICS_PORT=6009 -DEVNET_TAP_PORT=2480 -PG_PORT=5433 -MAILDEV_WEB_PORT=1080 -MAILDEV_SMTP_PORT=1025 - -# Devnet database (PLC server) — uses the shared postgres service -DEVNET_DB_HOST=postgres -DEVNET_DB_PORT=5432 -DEVNET_DB_USER=postgres -DEVNET_DB_PASSWORD=postgres -DEVNET_DB_NAME=plc - -# Devnet SMTP (PDS email verification) — uses the shared maildev service -DEVNET_SMTP_URL=smtp://maildev:1025 - -# Devnet handle config -DEVNET_PDS_HOSTNAME=devnet.test -DEVNET_PDS_ADMIN_PASSWORD=devnet-admin-password -DEVNET_HANDLE_DOMAIN=.devnet.test -DEVNET_SEED_ACCOUNTS=true - -# Contrail runtime (consumed by the e2e suite) -DATABASE_URL=postgresql://postgres:postgres@localhost:5433/contrail -JETSTREAM_URL=ws://localhost:6008/subscribe diff --git a/apps/contrail-e2e/.gitignore b/apps/contrail-e2e/.gitignore deleted file mode 100644 index 6ed48a9..0000000 --- a/apps/contrail-e2e/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.env -node_modules diff --git a/apps/contrail-e2e/README.md b/apps/contrail-e2e/README.md deleted file mode 100644 index 2f7cd09..0000000 --- a/apps/contrail-e2e/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# contrail-e2e — end-to-end tests against a local ATProto stack - -End-to-end test suite for Contrail against a sealed local ATProto stack. -Nothing touches the public ATProto — writes go to a local PDS, the local -Jetstream reads that PDS's firehose, and each test stands up its own -in-process Contrail ingester + XRPC handler to consume it. - -## What's inside - -| Service | Port | Purpose | -|---------|------|---------| -| PDS | 4000 | Personal Data Server (user repos) | -| PLC | 2582 | DID registry | -| Jetstream | 6008 | Firehose relay in JSON | -| TAP | 2480 | Sync/backfill relay | -| PostgreSQL | 5433 | Shared by PLC + Contrail | -| Maildev | 1080 | PDS email verification catcher | - -## Prerequisites - -Clone `atproto-devnet` as a sibling of this contrail repo: - -``` -some-workspace/ -├── contrail/ (this repo) -└── atproto-devnet/ -``` - -```bash -git clone https://github.com/OpenMeet-Team/atproto-devnet.git /path/to/atproto-devnet -``` - -## Running the tests - -```bash -# From the monorepo root (one-time build of @atmo-dev/contrail) -pnpm install && pnpm build - -# Bring the devnet stack up (creates .env from .env.example on first run) -cd apps/contrail-e2e -pnpm stack:up - -# Run the suite -pnpm test:e2e # once -pnpm test:e2e:watch # re-run on change -``` - -> **Note:** `up` and `down` are reserved by pnpm (aliases for `update`), so -> the stack scripts are prefixed `stack:` to disambiguate. The test scripts -> are named `test:e2e` so the monorepo-wide `pnpm test` skips them in CI -> (they need the live docker stack). - -## Tests - -See `tests/` for the current suite. Each test header explains its scope. -Tests spin up their own `runPersistent` in-process against an isolated -postgres schema, so they don't interfere with each other or with a -dogfooding ingester running in another terminal. - -### Gap-probe pattern (`it.fails`) - -Some tests use vitest's `it.fails(...)` to pin currently-known gaps in -contrail behavior — they pass *because* contrail doesn't yet enforce the -condition. The moment the condition is enforced, the test flips from -passing to failing, forcing whoever lands the fix to update the assertion -to the new correct behavior. Each `it.fails` block names the gap inline. - -## Teardown - -```bash -pnpm stack:down # stops containers AND wipes volumes for a clean slate -``` - -## Namespace - -These tests use `rsvp.atmo` as the Contrail namespace — same as -[atmo-events](https://github.com/flo-bit/atmo-events), so the fixtures are -compatible with that frontend. If you're running your own namespace, edit -`config.ts`. - -## Why not use `apps/postgres/`? - -`apps/postgres/` connects Contrail to the public Bluesky Jetstream — useful -for indexing real events, unusable for writing test fixtures. This suite -gives a sealed environment where the tests control every record. - -## Overrides - -Copy `.env.example` to `.env` and edit. All ports, database credentials, and -devnet hostnames are overridable. diff --git a/apps/contrail-e2e/config.ts b/apps/contrail-e2e/config.ts deleted file mode 100644 index 0c94c29..0000000 --- a/apps/contrail-e2e/config.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ContrailConfig } from "@atmo-dev/contrail"; - -// Namespace matches atmo.rsvp so the same atmo-events frontend can point at -// this devnet-backed Contrail without patching its hardcoded namespace. -// See https://github.com/flo-bit/atmo-events/issues/26 for making this -// configurable upstream. -export const config: ContrailConfig = { - namespace: "rsvp.atmo", - jetstreams: [ - process.env.JETSTREAM_URL ?? "ws://localhost:6008/subscribe", - ], - collections: { - event: { - collection: "community.lexicon.calendar.event", - queryable: { - mode: {}, - name: {}, - status: {}, - startsAt: { type: "range" }, - endsAt: { type: "range" }, - createdAt: { type: "range" }, - }, - searchable: ["name", "description"], - relations: { - rsvps: { - collection: "rsvp", - groupBy: "status", - count: true, - groups: { - interested: "community.lexicon.calendar.rsvp#interested", - going: "community.lexicon.calendar.rsvp#going", - notgoing: "community.lexicon.calendar.rsvp#notgoing", - }, - }, - }, - }, - rsvp: { - collection: "community.lexicon.calendar.rsvp", - queryable: { - status: {}, - "subject.uri": {}, - }, - references: { - event: { - collection: "event", - field: "subject.uri", - }, - }, - }, - }, -}; diff --git a/apps/contrail-e2e/docker-compose.yml b/apps/contrail-e2e/docker-compose.yml deleted file mode 100644 index 78d1668..0000000 --- a/apps/contrail-e2e/docker-compose.yml +++ /dev/null @@ -1,55 +0,0 @@ -# contrail-e2e: Contrail + atproto-devnet for local testing. -# -# Brings up a full local ATProto network (PDS, PLC, Jetstream, TAP) alongside -# the PostgreSQL instance Contrail indexes into. No traffic leaves your laptop. -# -# Prerequisites: -# - atproto-devnet cloned as a sibling of this contrail repo: -# some-workspace/ -# ├── contrail/ (this repo) -# └── atproto-devnet/ -# - Copy .env.example to .env first (scripts/up.sh does this automatically) -# -# Usage: -# pnpm up # bring everything up, wait for health -# pnpm down # stop + wipe volumes (clean slate) -# pnpm smoke # verify services are healthy - -include: - - path: ../../../atproto-devnet/docker-compose.yml - -services: - # PostgreSQL — shared by PLC (devnet) and Contrail (this example). - # NOTE: included services like PLC can't be modified via `include:` merge, - # so scripts/up.sh brings postgres up first, then the rest. - postgres: - image: postgres:17 - restart: unless-stopped - environment: - POSTGRES_USER: ${DEVNET_DB_USER:-postgres} - POSTGRES_PASSWORD: ${DEVNET_DB_PASSWORD:-postgres} - ports: - - "${PG_PORT:-5433}:5432" - volumes: - - pgdata:/var/lib/postgresql/data - - ./init-db:/docker-entrypoint-initdb.d:ro - networks: - - atproto-devnet - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DEVNET_DB_USER:-postgres}"] - interval: 3s - timeout: 3s - retries: 20 - - # Maildev — catches PDS email verification messages - maildev: - image: maildev/maildev:latest - restart: unless-stopped - ports: - - "${MAILDEV_WEB_PORT:-1080}:1080" - - "${MAILDEV_SMTP_PORT:-1025}:1025" - networks: - - atproto-devnet - -volumes: - pgdata: diff --git a/apps/contrail-e2e/init-db/01-databases.sql b/apps/contrail-e2e/init-db/01-databases.sql deleted file mode 100644 index ae5a095..0000000 --- a/apps/contrail-e2e/init-db/01-databases.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Create both databases used by this example: --- plc — DID registry state for the devnet PLC server --- contrail — Contrail's own indexed records -CREATE DATABASE plc; -CREATE DATABASE contrail; diff --git a/apps/contrail-e2e/package.json b/apps/contrail-e2e/package.json deleted file mode 100644 index aa17c4f..0000000 --- a/apps/contrail-e2e/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "contrail-e2e", - "version": "0.0.1", - "private": true, - "type": "module", - "scripts": { - "stack:up": "./scripts/up.sh", - "stack:down": "./scripts/down.sh", - "test:e2e": "vitest run", - "test:e2e:watch": "vitest" - }, - "dependencies": { - "@atmo-dev/contrail": "workspace:*", - "@atmo-dev/contrail-community": "workspace:*", - "pg": "^8.20.0" - }, - "devDependencies": { - "@atcute/atproto": "^3.1.10", - "@atcute/client": "^4.2.1", - "@atcute/identity-resolver": "^1.2.2", - "@atcute/jetstream": "^1.1.2", - "@atcute/lexicons": "^1.3.0", - "@types/pg": "^8.20.0", - "typescript": "^5.9.3", - "vitest": "^4.1.0" - } -} diff --git a/apps/contrail-e2e/scripts/down.sh b/apps/contrail-e2e/scripts/down.sh deleted file mode 100755 index ae74736..0000000 --- a/apps/contrail-e2e/scripts/down.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd "$(dirname "$0")/.." - -echo "→ Tearing down (removes volumes — clean slate)..." -docker compose down -v - -echo "✓ Down. All state wiped." diff --git a/apps/contrail-e2e/scripts/up.sh b/apps/contrail-e2e/scripts/up.sh deleted file mode 100755 index cf5341a..0000000 --- a/apps/contrail-e2e/scripts/up.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd "$(dirname "$0")/.." - -if [ ! -f .env ]; then - echo "→ First run: copying .env.example to .env" - cp .env.example .env -fi - -DEVNET_COMPOSE="../../../atproto-devnet/docker-compose.yml" -if [ ! -f "$DEVNET_COMPOSE" ]; then - echo "Error: atproto-devnet not found at $DEVNET_COMPOSE" - echo "" - echo "Clone it as a sibling of the contrail repo:" - echo " git clone https://github.com/OpenMeet-Team/atproto-devnet.git ../../../../atproto-devnet" - exit 1 -fi - -echo "→ Bringing up postgres (PLC depends on it)..." -docker compose up -d --wait postgres maildev - -echo "→ Bringing up devnet (PLC, PDS, Jetstream, TAP, init)..." -docker compose up -d --wait - -# shellcheck disable=SC1091 -source .env - -echo "" -echo "Services:" -echo " PDS: http://localhost:${DEVNET_PDS_PORT:-4000}" -echo " PLC: http://localhost:${DEVNET_PLC_PORT:-2582}" -echo " Jetstream: ws://localhost:${DEVNET_JETSTREAM_PORT:-6008}" -echo " TAP: http://localhost:${DEVNET_TAP_PORT:-2480}" -echo " Postgres: localhost:${PG_PORT:-5433}" -echo " MailDev UI: http://localhost:${MAILDEV_WEB_PORT:-1080}" -echo "" -echo "Next:" -echo " pnpm test # run the e2e suite (ingester + XRPC handler run in-process)" diff --git a/apps/contrail-e2e/tests/community-delete.test.ts b/apps/contrail-e2e/tests/community-delete.test.ts deleted file mode 100644 index 0106c66..0000000 --- a/apps/contrail-e2e/tests/community-delete.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * `.community.delete` end-to-end against real Postgres. - * - * The router endpoint (community/router.ts) and the adapter's - * `softDeleteCommunity` (community/adapter.ts) have no unit-test coverage - * for delete today. Beyond filling that gap, what these tests prove that - * unit tests *can't*: - * - * - Real Postgres writes the `deleted_at` timestamp on both the - * `communities` row AND every space owned by the community ($admin - * plus any child spaces). Verified by direct SQL on the pool, since - * that is the ground-truth representation the rest of the system - * reads from. - * - `.community.list` filtering is enforced by SQL - * (`listCommunitiesOwningSpaces` joins on `c.deleted_at IS NULL AND - * s.deleted_at IS NULL`), not in-memory bookkeeping. - * - Subsequent ops on a deleted community fail at the adapter layer: - * `getCommunity` returns null because of the `deleted_at IS NULL` - * filter, so `.community.space.create` returns 404. - * - Real service-auth JWT verifying through the PDS+PLC chain on the - * `owner-required` enforcement path. - * - * Prereqs: `pnpm stack:up`. - */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import pg from "pg"; -import type { Client } from "@atcute/client"; -import "@atcute/atproto"; -import { createHandler } from "@atmo-dev/contrail/server"; -import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { config as baseConfig } from "../config"; -import { - createTestAccount, - createIsolatedSchema, - createDevnetResolver, - setupCommunityContrail, - createCaller, - login, - jsonOr, - CONTRAIL_SERVICE_DID, - type CallAs, - type TestAccount, -} from "./helpers"; - -const NS = `${baseConfig.namespace}.community`; -const SPACE_TYPE = "rsvp.atmo.event.space"; -const TEST_MASTER_KEY = new Uint8Array(32).fill(7); - -describe("community.delete e2e (soft-delete + cascade, real DB)", () => { - let alice: TestAccount; // owner / creator - let bob: TestAccount; // promoted to admin in $admin (still NOT owner) - - let aliceClient: Client; - let bobClient: Client; - - let pool: pg.Pool; - let cleanupSchema: () => Promise; - let callAs: CallAs; - - beforeAll(async () => { - [alice, bob] = await Promise.all([createTestAccount(), createTestAccount()]); - [aliceClient, bobClient] = await Promise.all([login(alice), login(bob)]); - - const iso = await createIsolatedSchema("test_community_delete"); - pool = iso.pool; - cleanupSchema = iso.cleanup; - const db = createPostgresDatabase(pool); - - const contrail = await setupCommunityContrail({ - db, - baseConfig, - spaceType: SPACE_TYPE, - community: { - serviceDid: CONTRAIL_SERVICE_DID, - masterKey: TEST_MASTER_KEY, - resolver: createDevnetResolver(), - }, - }); - await contrail.init(); - callAs = createCaller(createHandler(contrail)); - }); - - afterAll(async () => { - await cleanupSchema?.(); - }); - - // Each test mints its own community so the soft-delete in one doesn't - // interfere with the next. - async function mintCommunity(): Promise { - const res = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); - expect(res.status, await res.clone().text()).toBe(200); - return ((await res.json()) as { communityDid: string }).communityDid; - } - - async function createChildSpace(communityDid: string, key: string): Promise { - const res = await callAs(aliceClient, "POST", `${NS}.space.create`, { - body: { communityDid, key }, - }); - expect(res.status, await res.clone().text()).toBe(200); - return ((await res.json()) as { space: { uri: string } }).space.uri; - } - - // ----- happy path: owner deletes, both rows + spaces flip deleted_at ------ - - it("owner can soft-delete; communities + all owned spaces get deleted_at set", async () => { - const communityDid = await mintCommunity(); - const channelUri = await createChildSpace(communityDid, "general"); - const adminUri = `ats://${communityDid}/${SPACE_TYPE}/$admin`; - - // Sanity: pre-delete, deleted_at is NULL on both tables. - const preCommunity = await pool.query( - `SELECT deleted_at FROM communities WHERE did = $1`, - [communityDid], - ); - expect(preCommunity.rows[0]?.deleted_at).toBeNull(); - // Bootstrap creates one or more reserved spaces ($admin, $publishers, …) - // in addition to the explicit child space. Don't pin the full set — - // assert the two we care about are present and live, and that every row - // is non-deleted. - const preSpaces = await pool.query( - `SELECT uri, deleted_at FROM spaces WHERE owner_did = $1`, - [communityDid], - ); - const preUris = preSpaces.rows.map((r: { uri: string }) => r.uri); - expect(preUris).toContain(adminUri); - expect(preUris).toContain(channelUri); - for (const row of preSpaces.rows) expect(row.deleted_at).toBeNull(); - - // Delete. - const del = await callAs(aliceClient, "POST", `${NS}.delete`, { - body: { communityDid }, - }); - expect(del.status, await del.clone().text()).toBe(200); - expect((await del.json()) as { ok: boolean }).toEqual({ ok: true }); - - // (1) Community row's deleted_at is now set. - const postCommunity = await pool.query( - `SELECT deleted_at FROM communities WHERE did = $1`, - [communityDid], - ); - expect(postCommunity.rows[0]?.deleted_at).not.toBeNull(); - - // (2) Every space owned by the community has deleted_at set — the - // reserved spaces ($admin etc.) and the child "general" space. - const postSpaces = await pool.query( - `SELECT uri, deleted_at FROM spaces WHERE owner_did = $1`, - [communityDid], - ); - // Same query, same connection — count must match exactly. A regression - // that orphans rows or creates new ones during delete would fail here. - expect(postSpaces.rows.length).toBe(preSpaces.rows.length); - for (const row of postSpaces.rows) expect(row.deleted_at).not.toBeNull(); - }); - - // ----- list filtering is real --------------------------------------------- - - it("post-delete community is excluded from .community.list (DB-filtered)", async () => { - const communityDid = await mintCommunity(); - - // Pre-delete: owner sees the community. - const before = await callAs(aliceClient, "GET", `${NS}.list`); - const beforeDids = ((await jsonOr(before)) as { - communities: Array<{ did: string }>; - }).communities.map((c) => c.did); - expect(beforeDids).toContain(communityDid); - - // Delete. - const del = await callAs(aliceClient, "POST", `${NS}.delete`, { - body: { communityDid }, - }); - expect(del.status).toBe(200); - - // Post-delete: the same listing call no longer returns it. The filter - // lives in `listCommunitiesOwningSpaces` (real SQL), so this round-trip - // proves the join condition fires end-to-end, not just in unit-test - // mocks. - const after = await callAs(aliceClient, "GET", `${NS}.list`); - const afterDids = ((await jsonOr(after)) as { - communities: Array<{ did: string }>; - }).communities.map((c) => c.did); - expect(afterDids).not.toContain(communityDid); - }); - - // ----- subsequent ops fail at the adapter layer --------------------------- - - it("post-delete: community.space.create returns 404 (getCommunity filters deleted)", async () => { - const communityDid = await mintCommunity(); - - const del = await callAs(aliceClient, "POST", `${NS}.delete`, { - body: { communityDid }, - }); - expect(del.status).toBe(200); - - // `community.space.create` calls `getCommunity` which filters - // `deleted_at IS NULL`, so a deleted community looks like a missing - // community to subsequent endpoints — no special "tombstone" path. - const create = await callAs(aliceClient, "POST", `${NS}.space.create`, { - body: { communityDid, key: "afterlife" }, - }); - expect(create.status).toBe(404); - const data = (await jsonOr(create)) as { error: string; reason?: string }; - expect(data.error).toBe("NotFound"); - expect(data.reason).toBe("community-not-found"); - }); - - // ----- owner-required enforcement ----------------------------------------- - // The endpoint requires `owner` specifically (not admin+). An admin in - // $admin should be rejected with `owner-required`. - - it("admin (not owner) cannot delete the community", async () => { - const communityDid = await mintCommunity(); - const adminUri = `ats://${communityDid}/${SPACE_TYPE}/$admin`; - - // Promote bob to admin in $admin — strictly below owner. - const grant = await callAs(aliceClient, "POST", `${NS}.space.grant`, { - body: { - spaceUri: adminUri, - subject: { did: bob.did }, - accessLevel: "admin", - }, - }); - expect(grant.status, await grant.clone().text()).toBe(200); - - const del = await callAs(bobClient, "POST", `${NS}.delete`, { - body: { communityDid }, - }); - expect(del.status).toBe(403); - const data = (await jsonOr(del)) as { error: string; reason: string }; - expect(data.error).toBe("Forbidden"); - expect(data.reason).toBe("owner-required"); - - // And the community is still alive — deleted_at is still NULL. - const row = await pool.query( - `SELECT deleted_at FROM communities WHERE did = $1`, - [communityDid], - ); - expect(row.rows[0]?.deleted_at).toBeNull(); - }); - - // ----- stranger gets the same 403 (not 404) -------------------------------- - // A user with no role on the community still hits the level check — the - // endpoint shape is "Forbidden / owner-required", not "NotFound", which - // matters because it tells the caller the resource exists but they're - // not the owner. Distinct from the post-delete "community-not-found" case - // above. - - it("non-member cannot delete the community", async () => { - const communityDid = await mintCommunity(); - - const stranger = await createTestAccount(); - const strangerClient = await login(stranger); - const del = await callAs(strangerClient, "POST", `${NS}.delete`, { - body: { communityDid }, - }); - expect(del.status).toBe(403); - const data = (await jsonOr(del)) as { error: string; reason: string }; - expect(data.error).toBe("Forbidden"); - expect(data.reason).toBe("owner-required"); - }); -}); diff --git a/apps/contrail-e2e/tests/community-invites.test.ts b/apps/contrail-e2e/tests/community-invites.test.ts deleted file mode 100644 index 58b0b31..0000000 --- a/apps/contrail-e2e/tests/community-invites.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -/** - * Invite end-to-end against a real PDS, real PLC, real Postgres. - * - * Unit tests in `packages/contrail/tests/invite-unified.test.ts` cover the - * router-level dispatch and validation (kind/accessLevel exclusivity, 403s, - * cannot-grant-higher-than-self). What unit tests can't show is: - * - * - Real service-auth JWT verifying through the PDS+PLC chain - * (unit tests use a fakeAuth middleware). - * - The reconcile cascade — community.grant fired by invite.redeem must - * update both the community ACL AND spaces.access_rows. Verified here - * by hitting `community.space.listMembers?flatten=true`, which reads - * from spaces.access_rows directly. - * - Real Postgres enforcement of single-use / revoked invites (the redeem - * query is the source of truth, not in-memory state). - * - * Both invite paths are exercised: - * - * - Community-owned space: alice (admin in $admin) creates a child space, - * mints accessLevel=member invite, bob redeems → grant + reconcile. - * - User-owned space: alice creates her own space, mints kind=join invite, - * bob redeems → addMember. No community module involvement. - * - * Prereqs: `pnpm stack:up`. - */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import pg from "pg"; -import type { Client } from "@atcute/client"; -import "@atcute/atproto"; -import { createHandler } from "@atmo-dev/contrail/server"; -import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { config as baseConfig } from "../config"; -import { - createTestAccount, - createIsolatedSchema, - createDevnetResolver, - setupCommunityContrail, - createCaller, - login, - jsonOr, - CONTRAIL_SERVICE_DID, - type CallAs, - type TestAccount, -} from "./helpers"; - -const NS = baseConfig.namespace; -const SPACE_TYPE = "rsvp.atmo.event.space"; -const TEST_MASTER_KEY = new Uint8Array(32).fill(7); - -describe("invite e2e (community + user-owned, real JWT)", () => { - let alice: TestAccount; - let bob: TestAccount; - let charlie: TestAccount; - - let aliceClient: Client; - let bobClient: Client; - let charlieClient: Client; - - let pool: pg.Pool; - let cleanupSchema: () => Promise; - let callAs: CallAs; - - let communityDid: string; - let channelUri: string; - - beforeAll(async () => { - [alice, bob, charlie] = await Promise.all([ - createTestAccount(), - createTestAccount(), - createTestAccount(), - ]); - [aliceClient, bobClient, charlieClient] = await Promise.all([ - login(alice), - login(bob), - login(charlie), - ]); - - const iso = await createIsolatedSchema("test_community_invites"); - pool = iso.pool; - cleanupSchema = iso.cleanup; - const db = createPostgresDatabase(pool); - - const contrail = await setupCommunityContrail({ - db, - baseConfig, - spaceType: SPACE_TYPE, - community: { - serviceDid: CONTRAIL_SERVICE_DID, - masterKey: TEST_MASTER_KEY, - resolver: createDevnetResolver(), - }, - }); - await contrail.init(); - callAs = createCaller(createHandler(contrail)); - - // Mint a community owned by alice; alice becomes owner of $admin via - // bootstrap and can then create child spaces. - const mint = await callAs(aliceClient, "POST", `${NS}.community.mint`, { body: {} }); - expect(mint.status, await mint.clone().text()).toBe(200); - communityDid = ((await mint.json()) as { communityDid: string }).communityDid; - - // Create a child space for invite tests; alice becomes its owner. - const create = await callAs(aliceClient, "POST", `${NS}.community.space.create`, { - body: { communityDid, key: "general" }, - }); - expect(create.status, await create.clone().text()).toBe(200); - channelUri = ((await create.json()) as { space: { uri: string } }).space.uri; - }); - - afterAll(async () => { - await cleanupSchema?.(); - }); - - // ----- community happy path: grant + reconcile cascade -------------------- - - it("redeem grants in community ACL AND reconciles to spaces.access_rows", async () => { - const create = await callAs(aliceClient, "POST", `${NS}.invite.create`, { - body: { spaceUri: channelUri, accessLevel: "member" }, - }); - expect(create.status, await create.clone().text()).toBe(200); - const { token, invite } = (await create.json()) as { - token: string; - invite: { tokenHash: string; accessLevel: string }; - }; - expect(invite.accessLevel).toBe("member"); - - const redeem = await callAs(bobClient, "POST", `${NS}.invite.redeem`, { - body: { token }, - }); - expect(redeem.status, await redeem.clone().text()).toBe(200); - const redeemed = (await redeem.json()) as { - spaceUri: string; - accessLevel: string; - communityDid: string; - }; - expect(redeemed.spaceUri).toBe(channelUri); - expect(redeemed.accessLevel).toBe("member"); - expect(redeemed.communityDid).toBe(communityDid); - - // (1) Community ACL has bob at member. - const aclList = await callAs(aliceClient, "GET", `${NS}.community.space.listMembers`, { - query: { spaceUri: channelUri }, - }); - expect(aclList.status).toBe(200); - const { rows } = (await jsonOr(aclList)) as { - rows: Array<{ subject: { did?: string }; accessLevel: string }>; - }; - const aclBob = rows.find((r) => r.subject.did === bob.did); - expect(aclBob?.accessLevel).toBe("member"); - - // (2) Reconcile cascade: spaces.access_rows now has bob too. - // `flatten=true` reads from the spaces table, not the community ACL. - const flatList = await callAs(aliceClient, "GET", `${NS}.community.space.listMembers`, { - query: { spaceUri: channelUri, flatten: "true" }, - }); - expect(flatList.status).toBe(200); - const { members } = (await jsonOr(flatList)) as { - members: Array<{ did: string }>; - }; - expect(members.map((m) => m.did)).toContain(bob.did); - - // (3) Bob's whoami resolves to the granted level via the same path that - // app code would use to gate UI/API access. - const whoami = await callAs(bobClient, "GET", `${NS}.spaceExt.whoami`, { - query: { spaceUri: channelUri }, - }); - expect(whoami.status).toBe(200); - const { isMember, accessLevel } = (await jsonOr(whoami)) as { - isMember: boolean; - accessLevel: string; - }; - expect(isMember).toBe(true); - expect(accessLevel).toBe("member"); - }); - - // ----- single-use enforcement at the real-DB layer ------------------------ - - it("maxUses=1 invite rejects a second redemption (DB-enforced, not in-memory)", async () => { - const create = await callAs(aliceClient, "POST", `${NS}.invite.create`, { - body: { spaceUri: channelUri, accessLevel: "member", maxUses: 1 }, - }); - expect(create.status).toBe(200); - const { token } = (await create.json()) as { token: string }; - - // First redemption succeeds (use a fresh outsider so the prior test's - // grant doesn't mask the count). - const dave = await createTestAccount(); - const daveClient = await login(dave); - const first = await callAs(daveClient, "POST", `${NS}.invite.redeem`, { - body: { token }, - }); - expect(first.status, await first.clone().text()).toBe(200); - - // Second redemption by a different account is rejected. - const second = await callAs(charlieClient, "POST", `${NS}.invite.redeem`, { - body: { token }, - }); - expect(second.status).toBe(400); - const data = (await jsonOr(second)) as { error: string }; - expect(data.error).toBe("InvalidInvite"); - }); - - // ----- revoke roundtrip --------------------------------------------------- - - it("revoked invite cannot be redeemed", async () => { - const create = await callAs(aliceClient, "POST", `${NS}.invite.create`, { - body: { spaceUri: channelUri, accessLevel: "member" }, - }); - const { token, invite } = (await create.json()) as { - token: string; - invite: { tokenHash: string }; - }; - - const revoke = await callAs(aliceClient, "POST", `${NS}.invite.revoke`, { - body: { spaceUri: channelUri, tokenHash: invite.tokenHash }, - }); - expect(revoke.status, await revoke.clone().text()).toBe(200); - - const redeem = await callAs(charlieClient, "POST", `${NS}.invite.redeem`, { - body: { token }, - }); - expect(redeem.status).toBe(400); - const data = (await jsonOr(redeem)) as { error: string }; - expect(data.error).toBe("InvalidInvite"); - }); - - // ----- user-owned space: simpler path, no reconcile ----------------------- - // Owner creates a space they personally own; invite confers `kind=join` and - // redeem calls `addMember` — no community module touched. - - it("user-owned space: kind=join invite roundtrip adds redeemer as a member", async () => { - const createSpace = await callAs(aliceClient, "POST", `${NS}.space.createSpace`, { - body: {}, - }); - expect(createSpace.status, await createSpace.clone().text()).toBe(200); - const userSpaceUri = ( - (await createSpace.json()) as { space: { uri: string; ownerDid: string } } - ).space.uri; - - const createInvite = await callAs(aliceClient, "POST", `${NS}.invite.create`, { - body: { spaceUri: userSpaceUri, kind: "join" }, - }); - expect(createInvite.status, await createInvite.clone().text()).toBe(200); - const { token, invite } = (await createInvite.json()) as { - token: string; - invite: { kind: string }; - }; - expect(invite.kind).toBe("join"); - - const redeem = await callAs(bobClient, "POST", `${NS}.invite.redeem`, { - body: { token }, - }); - expect(redeem.status, await redeem.clone().text()).toBe(200); - const redeemed = (await redeem.json()) as { spaceUri: string; kind: string }; - expect(redeemed.spaceUri).toBe(userSpaceUri); - expect(redeemed.kind).toBe("join"); - - // Owner can listMembers; bob shows up. - const list = await callAs(aliceClient, "GET", `${NS}.space.listMembers`, { - query: { spaceUri: userSpaceUri }, - }); - expect(list.status).toBe(200); - const { members } = (await jsonOr(list)) as { - members: Array<{ did: string }>; - }; - expect(members.map((m) => m.did)).toContain(bob.did); - }); -}); diff --git a/apps/contrail-e2e/tests/community-lifecycle.test.ts b/apps/contrail-e2e/tests/community-lifecycle.test.ts deleted file mode 100644 index 7a40f60..0000000 --- a/apps/contrail-e2e/tests/community-lifecycle.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * Community lifecycle end-to-end: mint → bootstrap reserved spaces → grant → - * list → setAccessLevel → revoke → ownership handoff. - * - * The community module exposes a 4-level access ladder (member → manager → - * admin → owner) on the reserved `$admin` space; everything else (granting - * roles, listing members, transferring ownership) is operations on that - * ACL. Ownership handoff is therefore role rotation: promote a successor - * to `owner`, then demote or revoke the departing owner. The community - * DID itself never moves. - * - * Last-owner guard: revoking or demoting the only `owner` on a space is - * refused with 409 / `reason: "last-owner"`. On `$admin` this prevents the - * "alice locks herself out of her own community" footgun; on any other - * space it preserves the only role that can grant `owner`. Hand off - * ownership first by promoting a successor. - * - * Each test mints its own service-auth JWT per call — same pattern as - * spaces-auth.test.ts. No mocks on the auth path; real PDS → real PLC → - * real verifier. - * - * Prereqs: `pnpm stack:up`. - */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import pg from "pg"; -import type { Client } from "@atcute/client"; -import "@atcute/atproto"; -import { createHandler } from "@atmo-dev/contrail/server"; -import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { config as baseConfig } from "../config"; -import { - createTestAccount, - createIsolatedSchema, - createDevnetResolver, - setupCommunityContrail, - createCaller, - login, - jsonOr, - CONTRAIL_SERVICE_DID, - type CallAs, - type TestAccount, -} from "./helpers"; - -const NS = `${baseConfig.namespace}.community`; -const SPACE_TYPE = "rsvp.atmo.event.space"; - -// Deterministic 32-byte key for envelope-encrypting community credentials in -// tests. Production uses a KMS-sourced secret — this is fine for devnet-only. -const TEST_MASTER_KEY = new Uint8Array(32).fill(7); - -describe("community lifecycle (mint → grant → list → revoke, + gap probes)", () => { - let alice: TestAccount; // creator / owner - let bob: TestAccount; // promoted to manager → admin - let carol: TestAccount; // member - - let aliceClient: Client; - let bobClient: Client; - - let pool: pg.Pool; - let cleanupSchema: () => Promise; - let callAs: CallAs; - - let communityDid: string; - let adminSpaceUri: string; - - beforeAll(async () => { - [alice, bob, carol] = await Promise.all([ - createTestAccount(), - createTestAccount(), - createTestAccount(), - ]); - - aliceClient = await login(alice); - bobClient = await login(bob); - - const iso = await createIsolatedSchema("test_community_lifecycle"); - pool = iso.pool; - cleanupSchema = iso.cleanup; - const db = createPostgresDatabase(pool); - - const contrail = await setupCommunityContrail({ - db, - baseConfig, - spaceType: SPACE_TYPE, - community: { - serviceDid: CONTRAIL_SERVICE_DID, - masterKey: TEST_MASTER_KEY, - resolver: createDevnetResolver(), - }, - }); - await contrail.init(); - callAs = createCaller(createHandler(contrail)); - }); - - afterAll(async () => { - await cleanupSchema?.(); - }); - - // ----- create a community with a 4-level ACL ----------------------------- - - it("mints a community and returns a recovery key to the creator", async () => { - const res = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); - expect(res.status, await res.clone().text()).toBe(200); - const data = (await res.json()) as { - communityDid: string; - recoveryKey: unknown; - }; - expect(data.communityDid).toMatch(/^did:plc:/); - expect(data.recoveryKey).toBeTruthy(); - communityDid = data.communityDid; - - // bootstrapReservedSpaces creates the $admin space owned by `communityDid` - // with Alice as the initial owner access-row. - adminSpaceUri = `ats://${communityDid}/${SPACE_TYPE}/$admin`; - }); - - it("grants bob=manager and carol=member on the admin space", async () => { - for (const [subject, level] of [ - [bob.did, "manager"], - [carol.did, "member"], - ] as const) { - const res = await callAs(aliceClient, "POST", `${NS}.space.grant`, { - body: { - spaceUri: adminSpaceUri, - subject: { did: subject }, - accessLevel: level, - }, - }); - expect(res.status, `grant ${subject}=${level}: ${await res.clone().text()}`) - .toBe(200); - } - }); - - it("listMembers reflects the full ACL ladder", async () => { - const res = await callAs(aliceClient, "GET", `${NS}.space.listMembers`, { - query: { spaceUri: adminSpaceUri }, - }); - expect(res.status).toBe(200); - const data = (await jsonOr(res)) as { - rows: Array<{ subject: { did?: string }; accessLevel: string }>; - }; - const byDid = Object.fromEntries( - data.rows - .filter((r) => r.subject.did) - .map((r) => [r.subject.did, r.accessLevel]), - ); - expect(byDid[alice.did]).toBe("owner"); - expect(byDid[bob.did]).toBe("manager"); - expect(byDid[carol.did]).toBe("member"); - }); - - it("community.list returns the community for members but not strangers", async () => { - // Alice is owner — should see the community. - const aliceList = await callAs(aliceClient, "GET", `${NS}.list`); - expect(aliceList.status).toBe(200); - const { communities: aliceCommunities } = (await jsonOr(aliceList)) as { - communities: Array<{ did: string }>; - }; - expect(aliceCommunities.map((c) => c.did)).toContain(communityDid); - - // A fresh account with no grants — should see nothing. - const stranger = await createTestAccount(); - const strangerClient = await login(stranger); - const strangerList = await callAs(strangerClient, "GET", `${NS}.list`); - expect(strangerList.status).toBe(200); - const { communities: strangerCommunities } = (await jsonOr(strangerList)) as { - communities: Array<{ did: string }>; - }; - expect(strangerCommunities.map((c) => c.did)).not.toContain(communityDid); - }); - - it("promotes bob manager → admin via setAccessLevel", async () => { - const res = await callAs(aliceClient, "POST", `${NS}.space.setAccessLevel`, { - body: { - spaceUri: adminSpaceUri, - subject: { did: bob.did }, - accessLevel: "admin", - }, - }); - expect(res.status, await res.clone().text()).toBe(200); - - const list = await callAs(aliceClient, "GET", `${NS}.space.listMembers`, { - query: { spaceUri: adminSpaceUri }, - }); - const { rows } = (await jsonOr(list)) as { - rows: Array<{ subject: { did?: string }; accessLevel: string }>; - }; - expect(rows.find((r) => r.subject.did === bob.did)?.accessLevel).toBe("admin"); - }); - - it("rejects a manager trying to grant admin (cannot-grant-higher-than-self)", async () => { - // Bob is admin now; carol's still a member. Have bob try to promote carol - // to owner — should 403 with cannot-grant-higher-than-self. - const res = await callAs(bobClient, "POST", `${NS}.space.grant`, { - body: { - spaceUri: adminSpaceUri, - subject: { did: carol.did }, - accessLevel: "owner", - }, - }); - expect(res.status).toBe(403); - const data = await jsonOr(res); - expect(data.reason).toBe("cannot-grant-higher-than-self"); - }); - - it("revokes carol cleanly (happy path)", async () => { - const res = await callAs(aliceClient, "POST", `${NS}.space.revoke`, { - body: { spaceUri: adminSpaceUri, subject: { did: carol.did } }, - }); - expect(res.status, await res.clone().text()).toBe(200); - }); - - // ----- last-owner guard ---------------------------------------------------- - // Removing or demoting the only `owner` on a space would leave it - // unmanageable (and on $admin, the whole community ownerless). The router - // refuses with 409 Conflict / reason: "last-owner" on both paths. - - it("space.revoke rejects removing the last owner", async () => { - const res = await callAs(aliceClient, "POST", `${NS}.space.revoke`, { - body: { spaceUri: adminSpaceUri, subject: { did: alice.did } }, - }); - expect(res.status).toBe(409); - const data = (await jsonOr(res)) as { error: string; reason: string }; - expect(data.error).toBe("LastOwner"); - expect(data.reason).toBe("last-owner"); - }); - - it("setAccessLevel rejects demoting the last owner", async () => { - // Fresh community so the previous test's state doesn't interfere. - const mint = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); - const { communityDid: freshDid } = (await mint.json()) as { - communityDid: string; - }; - const freshAdmin = `ats://${freshDid}/${SPACE_TYPE}/$admin`; - - const res = await callAs(aliceClient, "POST", `${NS}.space.setAccessLevel`, { - body: { - spaceUri: freshAdmin, - subject: { did: alice.did }, - accessLevel: "manager", - }, - }); - expect(res.status).toBe(409); - const data = (await jsonOr(res)) as { error: string; reason: string }; - expect(data.error).toBe("LastOwner"); - expect(data.reason).toBe("last-owner"); - }); - - // ----- ownership handoff via role rotation -------------------------------- - // The community DID stays put; ownership moves by promoting a successor to - // `owner` and then demoting (or revoking) the original owner. Uses a fresh - // community so the earlier tests' mutations don't interfere. - - it("hands off ownership by promoting a successor and demoting the original owner", async () => { - const mint = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); - const { communityDid: freshDid } = (await mint.json()) as { - communityDid: string; - }; - const freshAdmin = `ats://${freshDid}/${SPACE_TYPE}/$admin`; - - // 1. Alice promotes Bob to owner (two owners now — no last-owner risk). - const promote = await callAs(aliceClient, "POST", `${NS}.space.grant`, { - body: { - spaceUri: freshAdmin, - subject: { did: bob.did }, - accessLevel: "owner", - }, - }); - expect(promote.status, await promote.clone().text()).toBe(200); - - // 2. Bob demotes Alice to manager — he is now the sole owner. - const demote = await callAs(bobClient, "POST", `${NS}.space.setAccessLevel`, { - body: { - spaceUri: freshAdmin, - subject: { did: alice.did }, - accessLevel: "manager", - }, - }); - expect(demote.status, await demote.clone().text()).toBe(200); - - // 3. Verify final state: Bob=owner, Alice=manager. - const list = await callAs(bobClient, "GET", `${NS}.space.listMembers`, { - query: { spaceUri: freshAdmin }, - }); - const { rows } = (await jsonOr(list)) as { - rows: Array<{ subject: { did?: string }; accessLevel: string }>; - }; - const byDid = Object.fromEntries( - rows.filter((r) => r.subject.did).map((r) => [r.subject.did, r.accessLevel]), - ); - expect(byDid[bob.did]).toBe("owner"); - expect(byDid[alice.did]).toBe("manager"); - }); -}); diff --git a/apps/contrail-e2e/tests/community-provision-walkthrough.test.ts b/apps/contrail-e2e/tests/community-provision-walkthrough.test.ts deleted file mode 100644 index e9d847c..0000000 --- a/apps/contrail-e2e/tests/community-provision-walkthrough.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -/** - * Provisioned-community lifecycle walkthrough — the "happy day" the PR was - * built for, end-to-end on the same handler. - * - * Each previous test pins one slice (provision-only, publishing-only, - * ACL-only, ingest-only). This one chains them so a regression on any seam - * between modules — provision → ACL → proxy publish → ingest of a RSVP from - * a separate PDS repo — surfaces here even when each unit test still passes. - * - * Flow (each step is also asserted, so the test reads top-to-bottom as docs): - * - * 1. PROVISION — Alice calls `community.provision` with a caller-held - * P-256 rotation key (sovereign mode). Asserts: status=activated, - * DID well-formed, PLC log shows the caller's did:key at - * rotationKeys[0] (the sovereignty invariant). - * - * 2. GRANT — Alice grants Bob `member` on the community's `$publishers` - * space via `community.space.grant`. Asserts: listMembers shows Bob - * with accessLevel=member. - * - * 3. PUBLISH — Bob calls `community.putRecord` to write a public - * `community.lexicon.calendar.event` against the community DID's repo - * (proxied through Contrail's credential vault — Bob never holds the - * community's app password). Asserts: returned URI is rooted at the - * community DID; the record is visible via `com.atproto.repo.listRecords` - * against the community's PDS (proves the proxy actually wrote to the - * community repo, not a local index). - * - * 4. RSVP — Carol, a totally separate PDS account with no relationship - * to the community, writes a `community.lexicon.calendar.rsvp` to her - * own repo with `subject.uri = at:///.../`. - * Anyone can RSVP — no grant required, that's the lexicon contract. - * - * 5. INDEX — The in-process ingester (Jetstream → Postgres) picks up both - * Bob's event and Carol's RSVP. Asserts: querying the event by URI - * shows rsvpsGoingCount=1, with the indexed event `did` being the - * community's DID (not Bob's, not Alice's). - * - * Prereqs: `pnpm stack:up` (devnet PDS+PLC + postgres reachable). - */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import pg from "pg"; -import type { Client } from "@atcute/client"; -import "@atcute/atproto"; -import { - Contrail, - generateKeyPair, - runPersistent, -} from "@atmo-dev/contrail"; -import { createHandler } from "@atmo-dev/contrail/server"; -import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { config as baseConfig } from "../config"; -import { - CONTRAIL_SERVICE_DID, - HANDLE_DOMAIN, - PDS_ADMIN_PASSWORD, - PDS_URL, - PLC_URL, - createCaller, - createDevnetResolver, - createIsolatedSchema, - createTestAccount, - devnetRewriteFetch, - getRecordFromPds, - jsonOr, - login, - waitFor, - type CallAs, - type TestAccount, -} from "./helpers"; - -const NS = `${baseConfig.namespace}.community`; -const SPACE_TYPE = "rsvp.atmo.event.space"; -const EVENT_NSID = "community.lexicon.calendar.event"; -const RSVP_NSID = "community.lexicon.calendar.rsvp"; -const TEST_MASTER_KEY = new Uint8Array(32).fill(7); - -describe("community provision → grant → publish → RSVP walkthrough", () => { - // Alice provisions the community (becomes owner of $admin and $publishers). - // Bob is granted member on $publishers and publishes the event on behalf - // of the community via the proxy. Carol is an arm's-length user on the - // same PDS who RSVPs from her own repo — she has no grants on the - // community, which is exactly the open-RSVP contract we want to pin. - let alice: TestAccount; - let bob: TestAccount; - let carol: TestAccount; - - let aliceClient: Client; - let bobClient: Client; - let carolClient: Client; - - let pool: pg.Pool; - let cleanupSchema: () => Promise; - let pdsDid: string; - let handle: (req: Request) => Promise; - let callAs: CallAs; - - let ingestController: AbortController; - let ingestPromise: Promise; - - // Keypair held only by this test process; the public did:key is what we - // pass to provision. The private JWK never leaves the test — that's the - // sovereignty invariant we assert against the PLC log in step 1. - let callerRotation: Awaited>; - - // Carried between tests in declaration order. - let communityDid: string; - let publishersUri: string; - let eventUri: string; - let eventCid: string; - let eventRkey: string; - - beforeAll(async () => { - // Discover the live PDS's DID — the orchestrator uses this as the `aud` - // claim of the service-auth JWT it mints for createAccount, and the - // devnet PDS validates `aud` against its own DID. - const dres = await fetch(`${PDS_URL}/xrpc/com.atproto.server.describeServer`); - if (!dres.ok) { - throw new Error( - `devnet PDS unreachable at ${PDS_URL}: ${dres.status} ${await dres.text()}`, - ); - } - pdsDid = ((await dres.json()) as { did?: string }).did!; - - [alice, bob, carol] = await Promise.all([ - createTestAccount(), - createTestAccount(), - createTestAccount(), - ]); - - aliceClient = await login(alice); - bobClient = await login(bob); - carolClient = await login(carol); - - callerRotation = await generateKeyPair(); - - const iso = await createIsolatedSchema("test_provision_walkthrough"); - pool = iso.pool; - cleanupSchema = iso.cleanup; - const db = createPostgresDatabase(pool); - - const contrail = new Contrail({ - ...baseConfig, - db, - spaces: { - type: SPACE_TYPE, - serviceDid: CONTRAIL_SERVICE_DID, - resolver: createDevnetResolver(), - }, - community: { - // Provision uses serviceDid as the `aud` of its createAccount - // service-auth JWT — must be the live PDS's DID, not the Contrail - // service DID we use for inbound auth verification. - serviceDid: pdsDid, - masterKey: TEST_MASTER_KEY, - plcDirectory: PLC_URL, - resolver: createDevnetResolver(), - // Devnet PDSes publish https://devnet.test in their DID document's - // atproto_pds entry. Rewrite outgoing requests so the proxied - // publish lands on the host-mapped port. - fetch: devnetRewriteFetch, - allowProvisioning: true, - }, - }); - await contrail.init(); - handle = createHandler(contrail); - callAs = createCaller(handle); - - // Run the ingester in-process so step 5 can see Carol's RSVP land in - // the local index after she writes it directly to her PDS repo. - ingestController = new AbortController(); - ingestPromise = runPersistent(db, baseConfig, { - batchSize: 50, - flushIntervalMs: 500, - signal: ingestController.signal, - }); - }, 30_000); - - afterAll(async () => { - ingestController?.abort(); - await ingestPromise?.catch(() => {}); - await cleanupSchema?.(); - }); - - // ----- helpers -------------------------------------------------------------- - - async function getIndexedRecord(uri: string): Promise { - const url = - `http://test/xrpc/${baseConfig.namespace}.event.getRecord?uri=${encodeURIComponent(uri)}`; - const res = await handle(new Request(url)); - if (res.status === 404) return undefined; - if (!res.ok) throw new Error(`getRecord ${uri} → ${res.status}: ${await res.text()}`); - return await res.json(); - } - - async function mintPdsInvite(): Promise { - const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Basic ${Buffer.from( - `admin:${PDS_ADMIN_PASSWORD}`, - ).toString("base64")}`, - }, - body: JSON.stringify({ useCount: 1 }), - }); - if (!res.ok) { - throw new Error(`createInviteCode → ${res.status}: ${await res.text()}`); - } - return ((await res.json()) as { code: string }).code; - } - - // ----- step 1: PROVISION --------------------------------------------------- - // Sovereign provision: the caller passes the public did:key of a rotation - // key they hold. Contrail mints a subordinate rotation key, lays down a - // genesis op with [callerKey, contrailKey] in that order, then runs an - // update op to install the PDS-recommended verification methods. The PLC - // log must end with the caller's key still at rotationKeys[0] — without - // that, recovery authority silently moved to Contrail. - - it("step 1 — provisions a sovereign community via XRPC and the PLC log shows the caller's rotation key first", async () => { - const inviteCode = await mintPdsInvite(); - - // Devnet PDS caps the local handle label at 18 chars. `pw-` prefix + - // 8-char suffix keeps the full label well under that. - const suffix = `${Date.now().toString(36).slice(-6)}${Math.random() - .toString(36) - .slice(2, 4)}`; - const newHandle = `pw-${suffix}${HANDLE_DOMAIN}`; - const email = `${suffix}@devnet.test`; - const password = `pw-${suffix}`; - - const res = await callAs(aliceClient, "POST", `${NS}.provision`, { - body: { - handle: newHandle, - email, - password, - inviteCode, - pdsEndpoint: PDS_URL, - rotationKey: callerRotation.publicDidKey, - }, - }); - expect(res.status, await res.clone().text()).toBe(200); - const body = (await res.json()) as { communityDid: string; status: string }; - expect(body.status).toBe("activated"); - expect(body.communityDid).toMatch(/^did:plc:[a-z2-7]{24}$/); - communityDid = body.communityDid; - - // PLC sovereignty check: latest op (the post-activation update op) keeps - // the caller's did:key at rotationKeys[0]. If this regresses, Contrail's - // subordinate key would silently take rotation priority. - const logRes = await fetch(`${PLC_URL}/${communityDid}/log`); - expect(logRes.ok).toBe(true); - const log = (await logRes.json()) as Array<{ rotationKeys: string[] }>; - expect(log.length).toBeGreaterThanOrEqual(2); - expect(log[log.length - 1]!.rotationKeys[0]).toBe(callerRotation.publicDidKey); - - publishersUri = `ats://${communityDid}/${SPACE_TYPE}/$publishers`; - }, 30_000); - - // ----- step 2: GRANT ------------------------------------------------------- - // bootstrapReservedSpaces seeded $publishers with Alice as owner. To let - // Bob publish on behalf of the community, Alice grants him `member` on - // $publishers — the minimum level the putRecord guard accepts. - - it("step 2 — Alice grants Bob `member` on $publishers and listMembers reflects it", async () => { - expect(communityDid, "step 1 must have provisioned the community").toBeTruthy(); - - const res = await callAs(aliceClient, "POST", `${NS}.space.grant`, { - body: { - spaceUri: publishersUri, - subject: { did: bob.did }, - accessLevel: "member", - }, - }); - expect(res.status, await res.clone().text()).toBe(200); - - const list = await callAs(aliceClient, "GET", `${NS}.space.listMembers`, { - query: { spaceUri: publishersUri }, - }); - expect(list.status).toBe(200); - const { rows } = (await jsonOr(list)) as { - rows: Array<{ subject: { did?: string }; accessLevel: string }>; - }; - const byDid = Object.fromEntries( - rows.filter((r) => r.subject.did).map((r) => [r.subject.did, r.accessLevel]), - ); - expect(byDid[alice.did]).toBe("owner"); - expect(byDid[bob.did]).toBe("member"); - }); - - // ----- step 3: PUBLISH ----------------------------------------------------- - // Bob calls community.putRecord. The router checks his level on - // $publishers (member ≥ member, OK), pulls the community's encrypted - // app password from the credential vault, opens a PDS session as the - // community DID, and proxies a com.atproto.repo.createRecord. The - // returned URI is rooted at the community DID — Bob never holds those - // credentials, and his own DID doesn't appear anywhere in the record. - - it("step 3 — Bob (a $publishers member) publishes a public event as the community via proxy", async () => { - expect(publishersUri, "step 2 must have granted bob").toBeTruthy(); - - const eventName = `walkthrough-event ${Date.now()}`; - const startsAt = new Date(Date.now() + 60 * 60_000).toISOString(); - - const res = await callAs(bobClient, "POST", `${NS}.putRecord`, { - body: { - communityDid, - collection: EVENT_NSID, - record: { - $type: EVENT_NSID, - name: eventName, - createdAt: new Date().toISOString(), - startsAt, - mode: `${EVENT_NSID}#inperson`, - status: `${EVENT_NSID}#scheduled`, - }, - }, - }); - expect(res.status, await res.clone().text()).toBe(200); - const out = (await res.json()) as { uri: string; cid: string }; - eventUri = out.uri; - eventCid = out.cid; - eventRkey = out.uri.split("/").pop()!; - - // URI is rooted at the community DID, not Bob's. - expect(eventUri).toMatch(new RegExp(`^at://${communityDid}/${EVENT_NSID}/`)); - - // The record is visible via the community PDS's listRecords — proves - // the proxy actually wrote to the community repo, not just Contrail's - // local index. listRecords is the lexicon endpoint the prompt named; - // we round it out with a getRecord on the same rkey to confirm payload. - const listUrl = - `${PDS_URL}/xrpc/com.atproto.repo.listRecords` + - `?repo=${encodeURIComponent(communityDid)}` + - `&collection=${encodeURIComponent(EVENT_NSID)}` + - `&limit=10`; - const listRes = await fetch(listUrl); - expect(listRes.ok, `listRecords ${listRes.status}`).toBe(true); - const listed = (await listRes.json()) as { - records: Array<{ uri: string; cid: string; value: { name?: string } }>; - }; - const found = listed.records.find((r) => r.uri === eventUri); - expect(found, `event ${eventUri} not in PDS listRecords`).toBeDefined(); - expect(found!.value.name).toBe(eventName); - - const onPds = await getRecordFromPds(communityDid, EVENT_NSID, eventRkey); - expect(onPds.status).toBe(200); - expect(onPds.record.name).toBe(eventName); - }, 30_000); - - // ----- step 4: RSVP -------------------------------------------------------- - // Carol writes a community.lexicon.calendar.rsvp to her OWN repo on the - // shared devnet PDS, with subject.uri pointing at the community's event. - // She has zero relationship to the community — that's the open-RSVP - // contract: anyone can RSVP, the record lives in the responder's repo. - - it("step 4 — Carol RSVPs from a separate PDS account by writing to her own repo", async () => { - expect(eventUri, "step 3 must have published the event").toBeTruthy(); - - const rsvpRes = await carolClient.post("com.atproto.repo.createRecord", { - input: { - repo: carol.did, - collection: RSVP_NSID, - record: { - $type: RSVP_NSID, - subject: { uri: eventUri, cid: eventCid }, - status: `${RSVP_NSID}#going`, - createdAt: new Date().toISOString(), - }, - }, - }); - expect(rsvpRes.ok, `RSVP createRecord: ${JSON.stringify(rsvpRes.data)}`).toBe(true); - if (!rsvpRes.ok) throw new Error("unreachable"); - expect(rsvpRes.data.uri).toMatch(new RegExp(`^at://${carol.did}/${RSVP_NSID}/`)); - }); - - // ----- step 5: INDEX ------------------------------------------------------- - // Both records hit Jetstream and the in-process ingester. Querying the - // event by URI from the local handler should hydrate rsvpsGoingCount=1 - // (Carol's RSVP referencing it), and the indexed `did` must be the - // community's, not Bob's — proves end-to-end attribution works. - - it("step 5 — the indexer surfaces the event under the community DID with Carol's RSVP counted", async () => { - expect(eventUri, "step 3 must have published the event").toBeTruthy(); - - const indexed = await waitFor( - async () => { - const r = await getIndexedRecord(eventUri); - return r && r.rsvpsGoingCount >= 1 ? r : undefined; - }, - { label: `indexed ${eventUri} with rsvpsGoingCount>=1`, timeoutMs: 20_000 }, - ); - - expect(indexed.uri).toBe(eventUri); - // Attribution: the event belongs to the community, not the publisher. - expect(indexed.did).toBe(communityDid); - expect(indexed.did).not.toBe(bob.did); - expect(indexed.did).not.toBe(alice.did); - expect(indexed.rsvpsGoingCount).toBe(1); - }, 30_000); -}); diff --git a/apps/contrail-e2e/tests/community-publishing.test.ts b/apps/contrail-e2e/tests/community-publishing.test.ts deleted file mode 100644 index 8a11f89..0000000 --- a/apps/contrail-e2e/tests/community-publishing.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Community publishing end-to-end. - * - * Pins the proxy path that's unique to the community module: caller's JWT → - * encrypted app-password decrypt → PDS session → `com.atproto.repo.createRecord` - * with the **community** DID as `repo` → Jetstream propagation → indexer. - * Four real systems on a single hot path. - * - * Each published record gets a 4-way check: - * 1. `community.putRecord` returns 200 with `{ uri, cid }`. - * 2. The record exists at the PDS (`com.atproto.repo.getRecord`) — proves - * the proxy actually wrote, not just contrail's local store. - * 3. The record appears in the contrail index — proves Jetstream + ingest. - * 4. The indexed `did` is the community DID, not the caller's DID. - * - * Plus: minted-community publishing returns NotSupported (no PDS to proxy - * to); a non-publisher gets 403; deleteRecord cleans both PDS and index. - * - * Prereqs: `pnpm stack:up`. - */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import pg from "pg"; -import type { Client } from "@atcute/client"; -import "@atcute/atproto"; -import { runPersistent } from "@atmo-dev/contrail"; -import { createHandler } from "@atmo-dev/contrail/server"; -import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { config as baseConfig } from "../config"; -import { - createTestAccount, - createIsolatedSchema, - createDevnetResolver, - setupCommunityContrail, - createCaller, - createAppPasswordFor, - devnetRewriteFetch, - getRecordFromPds, - login, - CONTRAIL_SERVICE_DID, - waitFor, - type CallAs, - type TestAccount, -} from "./helpers"; - -const NS = `${baseConfig.namespace}.community`; -const SPACE_TYPE = "rsvp.atmo.event.space"; -const EVENT_NSID = "community.lexicon.calendar.event"; -const TEST_MASTER_KEY = new Uint8Array(32).fill(7); - -describe("community publishing (proxy → PDS → Jetstream → index)", () => { - let alice: TestAccount; // owner of community spaces (caller) - let bob: TestAccount; // account adopted as the community - let charlie: TestAccount; // outsider — no grants on the community - - let aliceClient: Client; - let charlieClient: Client; - let bobAppPassword: string; - - let pool: pg.Pool; - let cleanupSchema: () => Promise; - let handle: (req: Request) => Promise; - let callAs: CallAs; - let ingestController: AbortController; - let ingestPromise: Promise; - - let adoptedCommunityDid: string; - let publishedUri: string; - let publishedRkey: string; - - beforeAll(async () => { - [alice, bob, charlie] = await Promise.all([ - createTestAccount(), - createTestAccount(), - createTestAccount(), - ]); - - aliceClient = await login(alice); - charlieClient = await login(charlie); - - // Bob mints an app password — that's what gets stored encrypted in the - // credential vault and used for every proxied write. - bobAppPassword = await createAppPasswordFor(bob); - - const iso = await createIsolatedSchema("test_community_publishing"); - pool = iso.pool; - cleanupSchema = iso.cleanup; - const db = createPostgresDatabase(pool); - - const contrail = await setupCommunityContrail({ - db, - baseConfig, - spaceType: SPACE_TYPE, - community: { - serviceDid: CONTRAIL_SERVICE_DID, - masterKey: TEST_MASTER_KEY, - resolver: createDevnetResolver(), - // Devnet PDS publishes "https://devnet.test" as its public endpoint - // in every DID document, which isn't reachable from the test - // process. Rewrite outgoing requests so the credential check on - // adopt and the proxied createRecord on putRecord both hit the - // host-mapped port instead. - fetch: devnetRewriteFetch, - }, - }); - await contrail.init(); - handle = createHandler(contrail); - callAs = createCaller(handle); - - ingestController = new AbortController(); - ingestPromise = runPersistent(db, baseConfig, { - batchSize: 50, - flushIntervalMs: 500, - signal: ingestController.signal, - }); - - // Alice adopts Bob's account as the community. Alice becomes owner of - // both $admin and $publishers via bootstrapReservedSpaces. Pass Bob's - // DID rather than his handle — devnet handles aren't resolvable via - // the public /.well-known path that resolveIdentity falls back to. - const adopt = await callAs(aliceClient, "POST", `${NS}.adopt`, { - body: { identifier: bob.did, appPassword: bobAppPassword }, - }); - expect(adopt.status, await adopt.clone().text()).toBe(200); - const data = (await adopt.json()) as { communityDid: string }; - adoptedCommunityDid = data.communityDid; - expect(adoptedCommunityDid).toBe(bob.did); - }); - - afterAll(async () => { - ingestController?.abort(); - await ingestPromise?.catch(() => {}); - await cleanupSchema?.(); - }); - - // ----- helpers ------------------------------------------------------------ - - /** Look up the record in the contrail index via the local XRPC handler. */ - async function getIndexedRecord(uri: string): Promise { - const url = `http://test/xrpc/${baseConfig.namespace}.event.getRecord?uri=${encodeURIComponent(uri)}`; - const res = await handle(new Request(url)); - if (res.status === 404) return undefined; - if (!res.ok) throw new Error(`getRecord ${uri} → ${res.status}: ${await res.text()}`); - return await res.json(); - } - - // ----- happy path: adopted community publishes a public event ------------- - - it("publishes via community.putRecord and lands at PDS, Jetstream, and index", async () => { - const eventName = `community-published ${Date.now()}`; - const startsAt = new Date(Date.now() + 60 * 60_000).toISOString(); - - const res = await callAs(aliceClient, "POST", `${NS}.putRecord`, { - body: { - communityDid: adoptedCommunityDid, - collection: EVENT_NSID, - record: { - $type: EVENT_NSID, - name: eventName, - createdAt: new Date().toISOString(), - startsAt, - mode: `${EVENT_NSID}#inperson`, - status: `${EVENT_NSID}#scheduled`, - }, - }, - }); - expect(res.status, await res.clone().text()).toBe(200); - const out = (await res.json()) as { uri: string; cid: string }; - publishedUri = out.uri; - publishedRkey = out.uri.split("/").pop()!; - - // (1) URI is rooted at the community's DID, not Alice's. `at://` is - // intentional here — this is a PDS-issued record URI, distinct from - // the `ats://` scheme used for Contrail-internal space URIs. - expect(publishedUri).toMatch(new RegExp(`^at://${adoptedCommunityDid}/${EVENT_NSID}/`)); - - // (2) PDS actually has it — proves the proxy wrote, not just the local DB. - const pds = await getRecordFromPds(adoptedCommunityDid, EVENT_NSID, publishedRkey); - expect(pds.status).toBe(200); - expect(pds.record.name).toBe(eventName); - - // (3) Index has it — proves Jetstream + ingester. - // (4) Indexed `did` is the community DID, not the caller's DID. - const indexed = await waitFor( - () => getIndexedRecord(publishedUri), - { label: `index ${publishedUri}` }, - ); - expect(indexed.did).toBe(adoptedCommunityDid); - expect(indexed.did).not.toBe(alice.did); - expect(indexed.value.name).toBe(eventName); - }); - - // ----- authorization: non-member can't publish on behalf of community ----- - - it("rejects publishing from a caller not in $publishers", async () => { - const res = await callAs(charlieClient, "POST", `${NS}.putRecord`, { - body: { - communityDid: adoptedCommunityDid, - collection: EVENT_NSID, - record: { - $type: EVENT_NSID, - name: "should never land", - createdAt: new Date().toISOString(), - startsAt: new Date(Date.now() + 60 * 60_000).toISOString(), - mode: `${EVENT_NSID}#inperson`, - status: `${EVENT_NSID}#scheduled`, - }, - }, - }); - expect(res.status).toBe(403); - const data = (await res.json()) as { reason: string }; - expect(data.reason).toBe("not-in-publishers"); - }); - - // ----- delete roundtrip: PDS + index both clear -------------------------- - - it("deletes a published record from both PDS and index", async () => { - expect(publishedRkey, "previous test must have published").toBeTruthy(); - - const res = await callAs(aliceClient, "POST", `${NS}.deleteRecord`, { - body: { - communityDid: adoptedCommunityDid, - collection: EVENT_NSID, - rkey: publishedRkey, - }, - }); - expect(res.status, await res.clone().text()).toBe(200); - - const pds = await getRecordFromPds(adoptedCommunityDid, EVENT_NSID, publishedRkey); - expect(pds.status).toBe(400); // PDS returns 400 RecordNotFound, not 404 - - await waitFor( - async () => ((await getIndexedRecord(publishedUri)) === undefined ? true : undefined), - { label: `index drops ${publishedUri}` }, - ); - }); - - // ----- minted communities have no PDS to proxy to ------------------------ - // A minted community is a contrail-controlled DID with no `atproto_pds` - // service entry, so there's no repo to write into. `community.putRecord` - // returns NotSupported. If minted publishing ever lands (e.g. by routing - // writes to a community-owned repo), update this assertion. - - it("minted communities cannot publish public records", async () => { - const mint = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); - expect(mint.status).toBe(200); - const { communityDid: mintedDid } = (await mint.json()) as { - communityDid: string; - }; - - const res = await callAs(aliceClient, "POST", `${NS}.putRecord`, { - body: { - communityDid: mintedDid, - collection: EVENT_NSID, - record: { - $type: EVENT_NSID, - name: "should never publish", - createdAt: new Date().toISOString(), - startsAt: new Date(Date.now() + 60 * 60_000).toISOString(), - mode: `${EVENT_NSID}#inperson`, - status: `${EVENT_NSID}#scheduled`, - }, - }, - }); - expect(res.status).toBe(400); - const data = (await res.json()) as { error: string; reason: string }; - expect(data.error).toBe("NotSupported"); - expect(data.reason).toBe("publishing-not-supported-for-minted-communities"); - }); -}); diff --git a/apps/contrail-e2e/tests/cursor-resume.test.ts b/apps/contrail-e2e/tests/cursor-resume.test.ts deleted file mode 100644 index 9161f5a..0000000 --- a/apps/contrail-e2e/tests/cursor-resume.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * End-to-end: the ingester must resume from its persisted cursor after a - * restart, capturing events that arrived on the firehose while it was down. - * - * Flow: - * 1. Start runPersistent in-process against an isolated pg schema. - * 2. Publish event A → wait for it to be indexed → abort the ingester and - * await the promise. The finally-block's final flush must persist the - * cursor or this test fails at step 5. - * 3. While the ingester is DOWN: publish event B, update B, delete A. - * 4. Restart runPersistent against the SAME schema (same cursor row). - * 5. Assert: A is gone, B is indexed with its updated name. Those three - * commits can only be seen by replaying from the saved cursor — Jetstream - * is "live tail" without it, and the events already happened. - * - * Why this matters: runPersistent's whole reason to exist is durable state. - * A silently broken cursor would pass the basic roundtrip test because there's - * nothing to replay — every event arrives while the ingester is live. This - * test is the only one that exercises the replay path. - * - * Requires `pnpm stack:up` only. Ingester runs in-process. - */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import pg from "pg"; -import { CredentialManager, Client } from "@atcute/client"; -import "@atcute/atproto"; -import { runPersistent } from "@atmo-dev/contrail"; -import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { config as baseConfig } from "../config"; -import { - createTestAccount, - createIsolatedSchema, - waitFor, - PDS_URL, - type TestAccount, -} from "./helpers"; - -const EVENT_NSID = "community.lexicon.calendar.event"; - -describe("cursor resume (ingester stops, events pile up, ingester restarts)", () => { - let account: TestAccount; - let client: Client; - let pool: pg.Pool; - let cleanupSchema: () => Promise; - - beforeAll(async () => { - account = await createTestAccount(); - const creds = new CredentialManager({ service: PDS_URL }); - await creds.login({ identifier: account.handle, password: account.password }); - client = new Client({ handler: creds }); - - const iso = await createIsolatedSchema("test_cursor"); - pool = iso.pool; - cleanupSchema = iso.cleanup; - }); - - afterAll(async () => { - await cleanupSchema?.(); - }); - - async function queryRecord(uri: string): Promise<{ record: any } | undefined> { - const res = await pool.query(`SELECT uri, record FROM records_event WHERE uri = $1`, [uri]); - if (res.rows.length === 0) return undefined; - const row = res.rows[0]; - return { record: typeof row.record === "string" ? JSON.parse(row.record) : row.record }; - } - - async function getCursor(): Promise { - const res = await pool.query(`SELECT time_us FROM cursor WHERE id = 1`); - if (res.rows.length === 0) return null; - return Number(res.rows[0].time_us); - } - - it("resumes from saved cursor and picks up commits issued while down", async () => { - const db = createPostgresDatabase(pool); - const runOpts = { batchSize: 50, flushIntervalMs: 500 }; - - // ---- Phase 1: start ingester, publish A, verify indexed ---- - const c1 = new AbortController(); - const ingest1 = runPersistent(db, baseConfig, { ...runOpts, signal: c1.signal }); - - const nameA = `A-${Date.now()}`; - const aRes = await client.post("com.atproto.repo.createRecord", { - input: { - repo: account.did, - collection: EVENT_NSID, - record: { - $type: EVENT_NSID, - name: nameA, - createdAt: new Date().toISOString(), - startsAt: new Date(Date.now() + 60 * 60_000).toISOString(), - mode: `${EVENT_NSID}#inperson`, - status: `${EVENT_NSID}#scheduled`, - }, - }, - }); - expect(aRes.ok).toBe(true); - if (!aRes.ok) throw new Error("unreachable"); - const uriA = aRes.data.uri; - - await waitFor(() => queryRecord(uriA), { label: `A indexed (${uriA})` }); - const cursorAfterA = await getCursor(); - expect(cursorAfterA, "cursor must be persisted after first flush").not.toBeNull(); - - // ---- Phase 2: stop ingester ---- - c1.abort(); - await ingest1; - - const cursorAfterStop = await getCursor(); - expect(cursorAfterStop).toBe(cursorAfterA); - - // ---- Phase 3: mutate while ingester is DOWN ---- - const nameB = `B-${Date.now()}`; - const bRes = await client.post("com.atproto.repo.createRecord", { - input: { - repo: account.did, - collection: EVENT_NSID, - record: { - $type: EVENT_NSID, - name: nameB, - createdAt: new Date().toISOString(), - startsAt: new Date(Date.now() + 2 * 60 * 60_000).toISOString(), - mode: `${EVENT_NSID}#inperson`, - status: `${EVENT_NSID}#scheduled`, - }, - }, - }); - expect(bRes.ok).toBe(true); - if (!bRes.ok) throw new Error("unreachable"); - const uriB = bRes.data.uri; - const rkeyB = uriB.split("/").pop()!; - const rkeyA = uriA.split("/").pop()!; - - const nameBUpdated = `${nameB}-renamed`; - const putRes = await client.post("com.atproto.repo.putRecord", { - input: { - repo: account.did, - collection: EVENT_NSID, - rkey: rkeyB, - record: { - $type: EVENT_NSID, - name: nameBUpdated, - createdAt: new Date().toISOString(), - startsAt: new Date(Date.now() + 2 * 60 * 60_000).toISOString(), - mode: `${EVENT_NSID}#inperson`, - status: `${EVENT_NSID}#scheduled`, - }, - }, - }); - expect(putRes.ok).toBe(true); - - const delRes = await client.post("com.atproto.repo.deleteRecord", { - input: { repo: account.did, collection: EVENT_NSID, rkey: rkeyA }, - }); - expect(delRes.ok).toBe(true); - - // Confirm the DB didn't move while the ingester was down. - expect(await queryRecord(uriA), "A must still be indexed — nobody is consuming").toBeDefined(); - expect(await queryRecord(uriB), "B should not be indexed yet — ingester is down").toBeUndefined(); - - // ---- Phase 4: restart ingester, assert replay ---- - const c2 = new AbortController(); - const ingest2 = runPersistent(db, baseConfig, { ...runOpts, signal: c2.signal }); - - try { - const indexedB = await waitFor( - async () => { - const r = await queryRecord(uriB); - return r && r.record.name === nameBUpdated ? r : undefined; - }, - { label: `B replayed with updated name` }, - ); - expect(indexedB.record.name).toBe(nameBUpdated); - - await waitFor( - async () => ((await queryRecord(uriA)) === undefined ? true : undefined), - { label: `A deletion replayed` }, - ); - - const cursorAfterReplay = await getCursor(); - expect(cursorAfterReplay).not.toBeNull(); - expect(cursorAfterReplay!).toBeGreaterThan(cursorAfterStop!); - } finally { - c2.abort(); - await ingest2; - } - }, 60_000); -}); diff --git a/apps/contrail-e2e/tests/health.test.ts b/apps/contrail-e2e/tests/health.test.ts deleted file mode 100644 index c980e27..0000000 --- a/apps/contrail-e2e/tests/health.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Health checks for the running devnet stack. - * - * Assumes `pnpm stack:up` has completed. Reads ports from the environment - * with fallbacks matching `.env.example` so tests work out of the box. - * - * The Contrail handler is covered by ingest-roundtrip.test.ts in-process — - * this file only verifies the external devnet services are reachable. - */ -import { describe, it, expect } from "vitest"; -import net from "node:net"; - -const PDS_PORT = Number(process.env.DEVNET_PDS_PORT ?? 4000); -const PLC_PORT = Number(process.env.DEVNET_PLC_PORT ?? 2582); -const TAP_PORT = Number(process.env.DEVNET_TAP_PORT ?? 2480); -const JS_PORT = Number(process.env.DEVNET_JETSTREAM_PORT ?? 6008); - -async function get(url: string): Promise { - return fetch(url); -} - -function tcpConnect(port: number, host = "127.0.0.1"): Promise { - return new Promise((resolve, reject) => { - const socket = new net.Socket(); - socket.setTimeout(3000); - socket.once("connect", () => { - socket.destroy(); - resolve(); - }); - socket.once("timeout", () => { - socket.destroy(); - reject(new Error(`TCP connect to ${host}:${port} timed out`)); - }); - socket.once("error", reject); - socket.connect(port, host); - }); -} - -describe("devnet services", () => { - it("PLC responds to /_health", async () => { - const res = await get(`http://localhost:${PLC_PORT}/_health`); - expect(res.ok).toBe(true); - }); - - it("PDS responds to /xrpc/_health", async () => { - const res = await get(`http://localhost:${PDS_PORT}/xrpc/_health`); - expect(res.ok).toBe(true); - }); - - it("TAP responds to /health", async () => { - const res = await get(`http://localhost:${TAP_PORT}/health`); - expect(res.ok).toBe(true); - }); - - it("Jetstream accepts TCP connections", async () => { - await expect(tcpConnect(JS_PORT)).resolves.toBeUndefined(); - }); -}); diff --git a/apps/contrail-e2e/tests/helpers.ts b/apps/contrail-e2e/tests/helpers.ts deleted file mode 100644 index 264bf67..0000000 --- a/apps/contrail-e2e/tests/helpers.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Shared test infrastructure for the contrail-e2e suite. - * - * The ingester and XRPC handler run in-process against an isolated pg schema - * per test — no external processes required, and no collision with a - * dogfooding ingester the developer might have running in another terminal. - */ -import pg from "pg"; -import { CredentialManager, Client } from "@atcute/client"; -import { - CompositeDidDocumentResolver, - PlcDidDocumentResolver, -} from "@atcute/identity-resolver"; -import type { Did as AtDid, Nsid } from "@atcute/lexicons"; -import { Contrail, generateAuthoritySigningKey, resolveConfig } from "@atmo-dev/contrail"; -import type { ContrailConfig, Database, SpacesConfig } from "@atmo-dev/contrail"; -import { createCommunityIntegration } from "@atmo-dev/contrail-community"; - -export type Did = `did:${string}:${string}`; - -export const PDS_PORT = Number(process.env.DEVNET_PDS_PORT ?? 4000); -export const PDS_URL = `http://localhost:${PDS_PORT}`; -export const PLC_PORT = Number(process.env.DEVNET_PLC_PORT ?? 2582); -export const PLC_URL = `http://localhost:${PLC_PORT}`; -export const HANDLE_DOMAIN = process.env.DEVNET_HANDLE_DOMAIN ?? ".devnet.test"; -export const PDS_ADMIN_PASSWORD = process.env.DEVNET_PDS_ADMIN_PASSWORD ?? "devnet-admin-password"; -export const DATABASE_URL = - process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5433/contrail"; - -/** - * Arbitrary service DID used for the test Contrail deployment. The only - * requirements: (a) the JWTs we mint via getServiceAuth use this as their - * `aud` claim, and (b) Contrail's SpacesConfig.serviceDid matches. The DID - * itself doesn't need to be resolvable — the verifier only resolves issuers - * (users), not the audience. - */ -export const CONTRAIL_SERVICE_DID = "did:web:contrail-test.devnet.test"; - -/** - * Resolver that points the PLC method at the local devnet PLC on :2582. - * Without this, the default resolver hits plc.directory and 404s on every - * devnet DID. - */ -export function createDevnetResolver() { - return new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver({ apiUrl: PLC_URL }), - }, - }); -} - -/** - * Build a `spaces` config block in the post-PR30 split shape: authority owns - * ACL + credential signing, recordHost owns storage. Tests that previously - * passed the flat `{ type, serviceDid, resolver }` shape now get this — the - * config validator enforces the split, and `community` requires `authority`. - * - * Pass the `type` NSID for the kind of space (e.g. "rsvp.atmo.event.space"). - * A fresh signing key is generated per call so credential issuance works in - * the auth tests without leaking key material across suites. - */ -export async function makeSpacesConfig(type: string): Promise { - return { - authority: { - type, - serviceDid: CONTRAIL_SERVICE_DID, - signing: await generateAuthoritySigningKey(), - resolver: createDevnetResolver(), - }, - recordHost: {}, - }; -} - -/** - * Build a Contrail wired with a community integration. Post-PR30, community - * routes are not registered by passing a `community` config block alone — the - * caller must construct a `CommunityIntegration` from the resolved config and - * pass it as `communityIntegration` to the `Contrail` constructor (the same - * pattern `createApp({ community })` uses in the contrail-community unit - * tests). - * - * `community` is forwarded into the Contrail config so the integration can - * read `masterKey`, `fetch`, etc. through `config.community`. - */ -export async function setupCommunityContrail(opts: { - db: Database; - baseConfig: ContrailConfig; - spaceType: string; - community: Record; -}): Promise { - const fullConfig: ContrailConfig = { - ...opts.baseConfig, - spaces: await makeSpacesConfig(opts.spaceType), - community: opts.community, - }; - const integration = createCommunityIntegration({ - db: opts.db, - config: resolveConfig(fullConfig), - }); - return new Contrail({ - ...fullConfig, - db: opts.db, - communityIntegration: integration, - }); -} - -/** - * Mint an atproto service-auth JWT via the PDS's getServiceAuth endpoint. - * Requires the client to already be authed for a user. Returns the raw JWT - * string suitable for `Authorization: Bearer `. - */ -export async function mintServiceAuthJwt( - client: Client, - opts: { aud: string; lxm?: string; expSeconds?: number }, -): Promise { - const params: { aud: AtDid; lxm?: Nsid; exp?: number } = { - aud: opts.aud as AtDid, - }; - if (opts.lxm) params.lxm = opts.lxm as Nsid; - if (opts.expSeconds) params.exp = Math.floor(Date.now() / 1000) + opts.expSeconds; - - const res = await client.get("com.atproto.server.getServiceAuth", { params }); - if (!res.ok) { - throw new Error(`getServiceAuth → ${res.status}: ${JSON.stringify(res.data)}`); - } - return res.data.token; -} - -export type TestAccount = { handle: string; password: string; did: Did }; - -export async function createTestAccount(): Promise { - const inviteRes = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Basic ${Buffer.from(`admin:${PDS_ADMIN_PASSWORD}`).toString("base64")}`, - }, - body: JSON.stringify({ useCount: 1 }), - }); - if (!inviteRes.ok) { - throw new Error(`createInviteCode → ${inviteRes.status}: ${await inviteRes.text()}`); - } - const { code: inviteCode } = (await inviteRes.json()) as { code: string }; - - const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; - const handle = `test-${suffix}${HANDLE_DOMAIN}`; - const password = `pw-${suffix}`; - - const accountRes = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createAccount`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ handle, email: `${suffix}@devnet.test`, password, inviteCode }), - }); - if (!accountRes.ok) { - throw new Error(`createAccount → ${accountRes.status}: ${await accountRes.text()}`); - } - const { did } = (await accountRes.json()) as { did: string }; - return { handle, password, did: did as Did }; -} - -/** - * Create a fresh pg schema and return a Pool pinned to it via search_path. - * Call the returned `cleanup` in afterAll to drop the schema. - */ -export async function createIsolatedSchema( - prefix = "test", -): Promise<{ pool: pg.Pool; schemaName: string; cleanup: () => Promise }> { - const schemaName = `${prefix}_${Date.now()}${Math.floor(Math.random() * 1000)}`; - const bootstrap = new pg.Pool({ connectionString: DATABASE_URL }); - await bootstrap.query(`CREATE SCHEMA "${schemaName}"`); - await bootstrap.end(); - - const pool = new pg.Pool({ - connectionString: DATABASE_URL, - options: `-c search_path="${schemaName}"`, - }); - - const cleanup = async () => { - await pool.end(); - const c = new pg.Pool({ connectionString: DATABASE_URL }); - await c.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`); - await c.end(); - }; - - return { pool, schemaName, cleanup }; -} - -/** - * Poll `fn` until it returns a defined value, or time out. - * - * Thrown errors count as "not yet" (last is included in the timeout message) - * so transient 404s during startup don't abort. Use `label` to say what was - * missing. Defaults (15s / 250ms) cover Contrail's 500ms test-mode flush plus - * firehose propagation with plenty of headroom. - */ -export async function waitFor( - fn: () => Promise, - { timeoutMs = 15_000, intervalMs = 250, label }: { timeoutMs?: number; intervalMs?: number; label: string }, -): Promise { - const deadline = Date.now() + timeoutMs; - let lastErr: unknown; - let attempts = 0; - while (Date.now() < deadline) { - attempts++; - try { - const v = await fn(); - if (v !== undefined) return v; - } catch (err) { - lastErr = err; - } - await new Promise((r) => setTimeout(r, intervalMs)); - } - throw new Error( - `waitFor(${label}) timed out after ${timeoutMs}ms (${attempts} attempts)` + - (lastErr ? `: ${(lastErr as Error).message}` : ""), - ); -} - -/** - * Log a TestAccount into the devnet PDS and return an authed atcute Client. - */ -export async function login(acct: TestAccount): Promise { - const creds = new CredentialManager({ service: PDS_URL }); - await creds.login({ identifier: acct.handle, password: acct.password }); - return new Client({ handler: creds }); -} - -/** - * A `fetch` shim that rewrites the unreachable `https://devnet.test` host - * (which devnet PDSes publish in every DID document's `atproto_pds` service - * entry) to the host-mapped `PDS_URL`. Pass this as `community.fetch` so the - * credential check on adopt and the proxied createRecord on putRecord both - * land on the local container instead of failing DNS. - */ -export const devnetRewriteFetch: typeof fetch = (input, init) => { - const url = typeof input === "string" ? input : input.toString(); - return fetch(url.replace(/^https:\/\/devnet\.test/, PDS_URL), init); -}; - -/** - * Fetch a record straight from the devnet PDS via `com.atproto.repo.getRecord`. - * Used to confirm a proxied write (e.g. via `community.putRecord`) actually - * landed on the PDS and not just contrail's local index. - */ -export async function getRecordFromPds( - repo: string, - collection: string, - rkey: string, -): Promise<{ status: number; record?: any }> { - const url = - `${PDS_URL}/xrpc/com.atproto.repo.getRecord` + - `?repo=${encodeURIComponent(repo)}` + - `&collection=${encodeURIComponent(collection)}` + - `&rkey=${encodeURIComponent(rkey)}`; - const res = await fetch(url); - if (!res.ok) return { status: res.status }; - const body = (await res.json()) as { value: any }; - return { status: res.status, record: body.value }; -} - -/** - * Mint an app password for `acct` via the PDS. Used by tests that need to - * adopt the account as a community (the community module stores the app - * password encrypted in its credential vault). - */ -export async function createAppPasswordFor(acct: TestAccount): Promise { - const c = await login(acct); - const res = await c.post("com.atproto.server.createAppPassword", { - input: { name: `e2e-${Date.now()}` }, - }); - if (!res.ok) { - throw new Error(`createAppPassword: ${JSON.stringify(res.data)}`); - } - return res.data.password; -} - -/** - * A callAs function makes an XRPC call against the in-process Contrail - * handler with a freshly minted service-auth JWT. Each call mints its own - * token so the `lxm` claim binds to that specific endpoint. - */ -export type CallAs = ( - client: Client, - method: "GET" | "POST", - lxm: string, - opts?: { body?: unknown; query?: Record }, -) => Promise; - -/** - * Create a caller bound to a specific in-process handler. Use one per test - * file's `beforeAll` to avoid passing `handle` through every assertion. - */ -export function createCaller( - handle: (req: Request) => Promise, -): CallAs { - return async (client, method, lxm, opts = {}) => { - const token = await mintServiceAuthJwt(client, { - aud: CONTRAIL_SERVICE_DID, - lxm, - }); - const qs = opts.query - ? "?" + new URLSearchParams(opts.query).toString() - : ""; - const headers: Record = { - authorization: `Bearer ${token}`, - }; - if (opts.body !== undefined) headers["content-type"] = "application/json"; - return handle( - new Request(`http://test/xrpc/${lxm}${qs}`, { - method, - headers, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, - }), - ); - }; -} - -/** - * Parse a Response body as JSON, throwing a clear error (with status + raw - * text) if the body isn't JSON. Saves a `try/catch` in every assertion that - * needs to inspect a 4xx/5xx body. - */ -export async function jsonOr(res: Response): Promise { - const text = await res.text(); - try { - return JSON.parse(text); - } catch { - throw new Error(`non-JSON response ${res.status}: ${text}`); - } -} diff --git a/apps/contrail-e2e/tests/ingest-roundtrip.test.ts b/apps/contrail-e2e/tests/ingest-roundtrip.test.ts deleted file mode 100644 index 13ed2f0..0000000 --- a/apps/contrail-e2e/tests/ingest-roundtrip.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * End-to-end: publish a record via the devnet PDS, verify Contrail indexes it - * via Jetstream. - * - * Flow: - * 1. Stand up an in-process Contrail against an isolated pg schema — no - * collision with a dogfooding `pnpm ingest` and no external processes - * required. - * 2. Create a fresh PDS account + auth (unique handle per run). - * 3. Publish records through `com.atproto.repo.createRecord` on the PDS. - * 4. Poll the in-process XRPC handler's `rsvp.atmo.event.getRecord` until - * the record appears. Flush cadence is 500ms in tests, so publish → - * indexed is bounded by firehose propagation plus one flush. - * 5. Assert indexed fields match what we published. - * - * Requires `pnpm stack:up` only. The ingester and XRPC handler run in-process. - */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import pg from "pg"; -import { CredentialManager, Client } from "@atcute/client"; -import "@atcute/atproto"; -import { Contrail, runPersistent } from "@atmo-dev/contrail"; -import { createHandler } from "@atmo-dev/contrail/server"; -import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { config as baseConfig } from "../config"; -import { - createTestAccount, - createIsolatedSchema, - waitFor, - PDS_URL, - type TestAccount, -} from "./helpers"; - -const EVENT_NSID = "community.lexicon.calendar.event"; -const RSVP_NSID = "community.lexicon.calendar.rsvp"; - -describe("ingest roundtrip (devnet PDS → Jetstream → Contrail)", () => { - let account: TestAccount; - let client: Client; - let pool: pg.Pool; - let cleanupSchema: () => Promise; - let handle: (req: Request) => Promise; - let ingestController: AbortController; - let ingestPromise: Promise; - - let eventUri: string; - let eventCid: string; - const eventName = `devnet roundtrip ${Date.now()}`; - const startsAt = new Date(Date.now() + 60 * 60_000).toISOString(); - - beforeAll(async () => { - // PDS auth - account = await createTestAccount(); - const creds = new CredentialManager({ service: PDS_URL }); - await creds.login({ identifier: account.handle, password: account.password }); - client = new Client({ handler: creds }); - - // Isolated schema + in-process Contrail - const iso = await createIsolatedSchema("test_roundtrip"); - pool = iso.pool; - cleanupSchema = iso.cleanup; - const db = createPostgresDatabase(pool); - const contrail = new Contrail({ ...baseConfig, db }); - await contrail.init(); - handle = createHandler(contrail); - - ingestController = new AbortController(); - ingestPromise = runPersistent(db, baseConfig, { - batchSize: 50, - flushIntervalMs: 500, - signal: ingestController.signal, - }); - }); - - afterAll(async () => { - ingestController?.abort(); - await ingestPromise?.catch(() => {}); - await cleanupSchema?.(); - }); - - async function getIndexedRecord(uri: string): Promise { - const url = `http://test/xrpc/rsvp.atmo.event.getRecord?uri=${encodeURIComponent(uri)}`; - const res = await handle(new Request(url)); - if (res.status === 404) return undefined; - if (!res.ok) throw new Error(`getRecord ${uri} → ${res.status}: ${await res.text()}`); - // Contrail's getRecord returns the formatted row — uri/did/cid/time_us/value - // plus flattened relation counts (e.g. rsvpsGoingCount). - return await res.json(); - } - - it("indexes a community.lexicon.calendar.event published to devnet PDS", async () => { - const res = await client.post("com.atproto.repo.createRecord", { - input: { - repo: account.did, - collection: EVENT_NSID, - record: { - $type: EVENT_NSID, - name: eventName, - createdAt: new Date().toISOString(), - startsAt, - mode: `${EVENT_NSID}#inperson`, - status: `${EVENT_NSID}#scheduled`, - }, - }, - }); - expect(res.ok, `createRecord failed: ${JSON.stringify(res.data)}`).toBe(true); - if (!res.ok) throw new Error("unreachable"); - eventUri = res.data.uri; - eventCid = res.data.cid; - expect(eventUri).toMatch(new RegExp(`^at://${account.did}/${EVENT_NSID}/`)); - - const indexed = await waitFor(() => getIndexedRecord(eventUri), { label: `event ${eventUri}` }); - - expect(indexed.uri).toBe(eventUri); - expect(indexed.did).toBe(account.did); - expect(indexed.value.name).toBe(eventName); - expect(indexed.value.startsAt).toBe(startsAt); - }); - - it("increments rsvpsGoingCount when an RSVP is published", async () => { - expect(eventUri, "event must be published by the previous test").toBeTruthy(); - - const res = await client.post("com.atproto.repo.createRecord", { - input: { - repo: account.did, - collection: RSVP_NSID, - record: { - $type: RSVP_NSID, - subject: { uri: eventUri, cid: eventCid }, - status: `${RSVP_NSID}#going`, - createdAt: new Date().toISOString(), - }, - }, - }); - expect(res.ok, `createRecord failed: ${JSON.stringify(res.data)}`).toBe(true); - - const indexed = await waitFor( - async () => { - const r = await getIndexedRecord(eventUri); - return r && r.rsvpsGoingCount >= 1 ? r : undefined; - }, - { label: `rsvpsGoingCount for ${eventUri}` }, - ); - - expect(indexed.rsvpsGoingCount).toBe(1); - }); -}); diff --git a/apps/contrail-e2e/tests/provision.test.ts b/apps/contrail-e2e/tests/provision.test.ts deleted file mode 100644 index aefed96..0000000 --- a/apps/contrail-e2e/tests/provision.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -/** - * End-to-end test exercising the full ProvisionOrchestrator flow against the - * live devnet stack (PDS on :4000, PLC on :2582). Validates the 5-RPC sequence - * genesis op → createAccount → getRecommendedDidCredentials - * → PLC update op → activateAccount - * lands an activated account. - * - * Catches integration bugs that mocks can't: - * - hand-rolled ES256 service-auth JWT vs real atproto verifier - * - hand-rolled DAG-CBOR encoder output vs real PLC parser - * - genesis-op DID computation matches what PLC expects - * - cidForOp output accepted by PLC as `prev` for the update op - * - low-S signature normalization - * - * Prereqs: `pnpm stack:up` (devnet PDS+PLC + postgres reachable). - */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { randomUUID } from "node:crypto"; -import pg from "pg"; -import type { Client } from "@atcute/client"; -import { - CommunityAdapter, - CredentialCipher, - Contrail, - ProvisionOrchestrator, - initCommunitySchema, - pdsCreateAccount, - pdsGetRecommendedDidCredentials, - pdsActivateAccount, - pdsCreateAppPassword, - generateKeyPair, - createPdsSession, - submitGenesisOp, - type PdsClient, - type PlcClient, -} from "@atmo-dev/contrail"; -import { createHandler } from "@atmo-dev/contrail/server"; -import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { - PDS_URL, - PLC_URL, - HANDLE_DOMAIN, - PDS_ADMIN_PASSWORD, - CONTRAIL_SERVICE_DID, - createCaller, - createDevnetResolver, - createIsolatedSchema, - createTestAccount, - login, - type CallAs, - type TestAccount, -} from "./helpers"; - -describe("ProvisionOrchestrator devnet e2e", () => { - let pool: pg.Pool; - let cleanupSchema: () => Promise; - let adapter: CommunityAdapter; - let cipher: CredentialCipher; - let pdsDid: string; - - beforeAll(async () => { - // Discover the live PDS's DID via describeServer — used as the `aud` - // claim in the service-auth JWT we mint for createAccount. - const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.describeServer`); - if (!res.ok) { - throw new Error( - `devnet PDS unreachable at ${PDS_URL}: ${res.status} ${await res.text()}`, - ); - } - const body = (await res.json()) as { did?: string }; - if (!body.did) { - throw new Error(`describeServer response missing did: ${JSON.stringify(body)}`); - } - pdsDid = body.did; - - const iso = await createIsolatedSchema("test_provision_e2e"); - pool = iso.pool; - cleanupSchema = iso.cleanup; - const db = createPostgresDatabase(pool); - await initCommunitySchema(db); - adapter = new CommunityAdapter(db); - cipher = new CredentialCipher(new Uint8Array(32).fill(7)); - }, 15_000); - - afterAll(async () => { - await cleanupSchema?.(); - }); - - // Adapt our bare module-level functions to the orchestrator's wrapper - // interfaces. Shared by the managed and self-sovereign tests so they hit - // the same live PDS surface (Task 16 added createAppPassword to PdsClient). - const pdsClient: PdsClient = { - createAccount: ({ pdsUrl, serviceAuthJwt, body }) => - pdsCreateAccount(pdsUrl, serviceAuthJwt, body), - getRecommendedDidCredentials: ({ pdsUrl, accessJwt }) => - pdsGetRecommendedDidCredentials(pdsUrl, accessJwt), - activateAccount: ({ pdsUrl, accessJwt }) => - pdsActivateAccount(pdsUrl, accessJwt), - createAppPassword: ({ pdsUrl, accessJwt, name }) => - pdsCreateAppPassword(pdsUrl, accessJwt, name), - }; - - const plcClient: PlcClient = { - submit: (did, op) => submitGenesisOp(PLC_URL, did, op as any), - }; - - /** Mint a single-use invite via the PDS admin API. Shared helper for both - * the managed and self-sovereign tests. */ - async function mintInvite(): Promise { - const inviteRes = await fetch( - `${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, - { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Basic ${Buffer.from( - `admin:${PDS_ADMIN_PASSWORD}`, - ).toString("base64")}`, - }, - body: JSON.stringify({ useCount: 1 }), - }, - ); - if (!inviteRes.ok) { - throw new Error( - `createInviteCode failed (${inviteRes.status}): ${await inviteRes.text()}`, - ); - } - return ((await inviteRes.json()) as { code: string }).code; - } - - it( - "provisions a self-sovereign community: caller holds rotation key, contrail mints app password", - async () => { - // Caller-held rotation keypair. The private JWK never leaves this test — - // only callerRotation.publicDidKey is passed to the orchestrator. That's - // the negative invariant we assert below: no encrypted_* column on the - // persisted row contains the caller's did:key after decrypt. - const callerRotation = await generateKeyPair(); - - const inviteCode = await mintInvite(); - - // Keep handle short — devnet caps the local label at 18 chars. - // `ss-` (3) + 8-char suffix = 11 chars on the local label. - const suffix = `${Date.now().toString(36).slice(-5)}${Math.random() - .toString(36) - .slice(2, 5)}`; - const handle = `ss-${suffix}${HANDLE_DOMAIN}`; - const email = `${suffix}@devnet.test`; - const password = `pw-${suffix}`; - const attemptId = randomUUID(); - - const orch = new ProvisionOrchestrator({ - adapter, - cipher, - plc: plcClient, - pds: pdsClient, - pdsDid, - }); - - const result = await orch.provision({ - attemptId, - pdsEndpoint: PDS_URL, - handle, - email, - password, - inviteCode, - rotationKey: callerRotation.publicDidKey, - }); - - // Result-shape assertions: status activated; rootCredentials returned - // with the user's *root* password, not the minted app password. - expect(result.attemptId).toBe(attemptId); - expect(result.did).toMatch(/^did:plc:[a-z2-7]{24}$/); - expect(result.status).toBe("activated"); - expect(result.rootCredentials).toBeDefined(); - expect(result.rootCredentials!.handle).toBe(handle); - expect(result.rootCredentials!.password).toBe(password); - expect(typeof result.rootCredentials!.recoveryHint).toBe("string"); - expect(result.rootCredentials!.recoveryHint.length).toBeGreaterThan(0); - - // Persisted-row assertions: self-sovereign mode persists an *encrypted - // app password* — never the user's root password. Contrail's rotation - // key is the SUBORDINATE (rotationKeys[1]); the caller's did:key is - // rotationKeys[0] in the genesis op and lives only in PLC, never in - // any encrypted_* column. - const row = await adapter.getProvisionAttempt(attemptId); - expect(row).not.toBeNull(); - expect(row!.status).toBe("activated"); - expect(row!.did).toBe(result.did); - expect(row!.handle).toBe(handle); - expect(row!.encryptedSigningKey).toBeTruthy(); - expect(row!.encryptedRotationKey).toBeTruthy(); - expect(row!.encryptedPassword).toBeTruthy(); - expect(row!.activatedAt).toBeTruthy(); - expect(row!.lastError).toBeNull(); - - // Decrypt the persisted password — it must be the *minted app password*, - // distinct from the user's root password we supplied. - const decryptedAppPassword = await cipher.decryptString( - row!.encryptedPassword!, - ); - expect(decryptedAppPassword).not.toBe(password); - expect(decryptedAppPassword.length).toBeGreaterThan(0); - - // Decrypt the persisted rotation JWK — it must be Contrail's subordinate - // key (a fresh P-256 keypair), NOT the caller's. We assert NOT-equal on - // the JWK shape, including the `d` (private) coordinate which the caller - // never sent. - const decryptedRotationJwk = JSON.parse( - await cipher.decryptString(row!.encryptedRotationKey!), - ) as { kty?: string; crv?: string; x?: string; y?: string; d?: string }; - expect(decryptedRotationJwk.kty).toBe("EC"); - expect(decryptedRotationJwk.crv).toBe("P-256"); - // The caller's private `d` coordinate must never appear in Contrail's - // persistence — the strongest single-bit invariant of self-sovereign mode. - expect(decryptedRotationJwk.d).not.toBe(callerRotation.privateJwk.d); - // The public x/y must also differ — the persisted rotation key is a - // subordinate Contrail-generated key, not a re-derivation of the caller's. - expect(decryptedRotationJwk.x).not.toBe(callerRotation.privateJwk.x); - expect(decryptedRotationJwk.y).not.toBe(callerRotation.privateJwk.y); - - // Negative invariant: the caller's public did:key string must NOT appear - // inside ANY encrypted column after decryption. Encrypted_signing_key - // is a JWK; encrypted_rotation_key is the subordinate JWK; the password - // is opaque — none of them should contain the caller's did:key. - const decryptedSigningKey = await cipher.decryptString( - row!.encryptedSigningKey!, - ); - const callerDidKey = callerRotation.publicDidKey; - expect(decryptedSigningKey.indexOf(callerDidKey)).toBe(-1); - expect( - await cipher - .decryptString(row!.encryptedRotationKey!) - .then((s) => s.indexOf(callerDidKey)), - ).toBe(-1); - expect(decryptedAppPassword.indexOf(callerDidKey)).toBe(-1); - - // Prove the *minted* app password works against the live PDS. - // createPdsSession throws if the PDS rejects. - const appSession = await createPdsSession( - PDS_URL, - handle, - decryptedAppPassword, - ); - expect(appSession.did).toBe(result.did); - expect(appSession.accessJwt).toBeTruthy(); - - // And the user's root password also still works — PDS supports multiple - // credentials per account, so the caller's root creds remain valid. - const rootSession = await createPdsSession(PDS_URL, handle, password); - expect(rootSession.did).toBe(result.did); - expect(rootSession.accessJwt).toBeTruthy(); - - // PLC-log assertion (H2): the post-activation update op must keep the - // caller's did:key at rotationKeys[0]. Without this, contrail's - // subordinate would silently take rotation priority and the caller - // would lose self-sovereign recovery authority. - const logRes = await fetch(`${PLC_URL}/${result.did}/log`); - expect(logRes.ok).toBe(true); - const log = (await logRes.json()) as Array<{ - rotationKeys: string[]; - }>; - expect(log.length).toBeGreaterThanOrEqual(2); - const lastOp = log[log.length - 1]!; - expect(lastOp.rotationKeys[0]).toBe(callerRotation.publicDidKey); - }, - 30_000, - ); -}); - -/** - * Routed end-to-end coverage for the XRPC surface of the provision flow: - * `${NS}.community.provision` then `${NS}.community.putRecord` against the - * same provisioned community. Differs from the orchestrator-only test above - * by exercising the full Hono app — auth middleware, DB persistence, - * bootstrapReservedSpaces, and the credential-proxy publish path that - * Tasks 13/14 added. - * - * Also pins the Task 14 session-cache behavior: two sequential putRecords - * should perform exactly **one** `com.atproto.server.createSession` call to - * the PDS — the second hits the cached session. - */ -describe("community.provision + putRecord via XRPC route (devnet)", () => { - const NS = "rsvp.atmo.community"; - const SPACE_TYPE = "rsvp.atmo.event.space"; - const POST_NSID = "app.bsky.feed.post"; - const TEST_MASTER_KEY = new Uint8Array(32).fill(7); - - let pool: pg.Pool; - let cleanupSchema: () => Promise; - let pdsDid: string; - let alice: TestAccount; - let aliceClient: Client; - let handle: (req: Request) => Promise; - let callAs: CallAs; - - // Counts createSession calls to the live PDS so we can assert that the - // session cache is reused across publishes (Task 14). - let createSessionCount = 0; - - beforeAll(async () => { - // Discover the live PDS's DID — needed as `aud` in the orchestrator's - // service-auth JWT for createAccount. - const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.describeServer`); - if (!res.ok) { - throw new Error( - `devnet PDS unreachable at ${PDS_URL}: ${res.status} ${await res.text()}`, - ); - } - pdsDid = ((await res.json()) as { did?: string }).did!; - - const iso = await createIsolatedSchema("test_provision_router_e2e"); - pool = iso.pool; - cleanupSchema = iso.cleanup; - const db = createPostgresDatabase(pool); - - // Wrap fetch to count createSession calls. Everything else passes - // through unchanged so the orchestrator + publish paths hit real devnet. - const countingFetch: typeof fetch = (input, init) => { - const url = typeof input === "string" ? input : input.toString(); - if (url.includes("/xrpc/com.atproto.server.createSession")) { - createSessionCount++; - } - return fetch(input as any, init); - }; - - const contrail = new Contrail({ - ...{ - namespace: "rsvp.atmo", - collections: { - // Minimal collection set — we only need community routes registered; - // no Jetstream ingestion is required for this test. - post: { collection: POST_NSID }, - }, - }, - db, - spaces: { - type: SPACE_TYPE, - serviceDid: CONTRAIL_SERVICE_DID, - resolver: createDevnetResolver(), - }, - community: { - // The orchestrator uses cfg.serviceDid as the `aud` of the - // createAccount service-auth JWT. The live devnet PDS validates - // `aud` against its own DID, so this must be the PDS's DID — not - // the Contrail service DID used for inbound JWT verification. - serviceDid: pdsDid, - masterKey: TEST_MASTER_KEY, - plcDirectory: PLC_URL, - resolver: createDevnetResolver(), - fetch: countingFetch, - allowProvisioning: true, - }, - }); - await contrail.init(); - handle = createHandler(contrail); - callAs = createCaller(handle); - - // Alice acts as the provisioning caller — she becomes owner of the new - // community's $admin and $publishers spaces, which lets her publish. - alice = await createTestAccount(); - aliceClient = await login(alice); - }, 30_000); - - afterAll(async () => { - await cleanupSchema?.(); - }); - - it( - "provisions via the XRPC route and publishes a record (one cached session across two putRecords)", - async () => { - // Mint an invite code via the PDS admin API — same pattern as the - // orchestrator-only test above. - const inviteRes = await fetch( - `${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, - { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Basic ${Buffer.from( - `admin:${PDS_ADMIN_PASSWORD}`, - ).toString("base64")}`, - }, - body: JSON.stringify({ useCount: 1 }), - }, - ); - if (!inviteRes.ok) { - throw new Error( - `createInviteCode failed (${inviteRes.status}): ${await inviteRes.text()}`, - ); - } - const { code: inviteCode } = (await inviteRes.json()) as { code: string }; - - // Keep total label under PDS's 18-char limit: `r-` (2) + 8-char suffix - // = 10 chars, well under the cap. - const suffix = `${Date.now().toString(36).slice(-6)}${Math.random() - .toString(36) - .slice(2, 4)}`; - const newHandle = `r-${suffix}${HANDLE_DOMAIN}`; - const email = `${suffix}@devnet.test`; - const password = `pw-${suffix}`; - - const callerRotation = await generateKeyPair(); - - const baselineCreateSessionCount = createSessionCount; - - // ---- POST /xrpc/${NS}.provision ----------------------------------- - const provRes = await callAs(aliceClient, "POST", `${NS}.provision`, { - body: { - handle: newHandle, - email, - password, - inviteCode, - pdsEndpoint: PDS_URL, - rotationKey: callerRotation.publicDidKey, - }, - }); - const provText = await provRes.clone().text(); - expect(provRes.status, provText).toBe(200); - const provBody = (await provRes.json()) as { - communityDid: string; - status: string; - }; - expect(provBody.status).toBe("activated"); - expect(provBody.communityDid).toMatch(/^did:plc:[a-z2-7]{24}$/); - const communityDid = provBody.communityDid; - - // The orchestrator never calls createSession during provision — it - // gets accessJwt + refreshJwt directly from the createAccount response - // and seeds the community_sessions cache before returning, so the first - // publish hits a warm cache. - const provisionCreateSessionCount = - createSessionCount - baselineCreateSessionCount; - expect(provisionCreateSessionCount).toBe(0); - - // ---- First putRecord: cache miss → createSession ------------------ - const firstRecord = { - $type: POST_NSID, - text: `routed-e2e first ${suffix}`, - createdAt: new Date().toISOString(), - }; - const put1 = await callAs(aliceClient, "POST", `${NS}.putRecord`, { - body: { - communityDid, - collection: POST_NSID, - record: firstRecord, - }, - }); - const put1Text = await put1.clone().text(); - expect(put1.status, put1Text).toBe(200); - const put1Body = (await put1.json()) as { uri: string; cid: string }; - expect(put1Body.uri).toMatch( - new RegExp(`^at://${communityDid}/${POST_NSID}/`), - ); - expect(put1Body.cid).toBeTruthy(); - - // ---- Second putRecord: cache hit → no createSession --------------- - const secondRecord = { - $type: POST_NSID, - text: `routed-e2e second ${suffix}`, - createdAt: new Date().toISOString(), - }; - const put2 = await callAs(aliceClient, "POST", `${NS}.putRecord`, { - body: { - communityDid, - collection: POST_NSID, - record: secondRecord, - }, - }); - const put2Text = await put2.clone().text(); - expect(put2.status, put2Text).toBe(200); - const put2Body = (await put2.json()) as { uri: string; cid: string }; - expect(put2Body.uri).toMatch( - new RegExp(`^at://${communityDid}/${POST_NSID}/`), - ); - - // Zero createSession across the whole flow: provision pre-warms the - // cache, then both publishes hit the 30s-skew cache. - const publishesCreateSessionCount = - createSessionCount - baselineCreateSessionCount; - expect(publishesCreateSessionCount).toBe(0); - }, - 60_000, - ); - - // TODO: cover stale-session auto-recovery (provision Task 14, ensureSession - // refresh path). Hard to exercise deterministically against live devnet - // without aging out a real `accessExp` past the 30s skew; covered by unit - // tests in packages/contrail/tests/community-publishing.test.ts. -}); diff --git a/apps/contrail-e2e/tests/reap-tombstone.test.ts b/apps/contrail-e2e/tests/reap-tombstone.test.ts deleted file mode 100644 index dfd40af..0000000 --- a/apps/contrail-e2e/tests/reap-tombstone.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Devnet e2e for the tombstone CID derivation used by `contrail reap` (M6). - * - * `cli/commands/reap.ts:146` calls `cidForOp(signed as never)` because the - * helper's declared signed-op union covers genesis/update — not tombstone. - * The DAG-CBOR encoder accepts the smaller tombstone shape, but no other - * test submits a *real* tombstone to live PLC and verifies the CID we - * computed locally matches the one PLC returns from `log/last`. This test - * closes that gap. - * - * The test uses managed-mode provisioning to land a real DID on devnet PLC - * with the rotation key encrypted on the persisted row, then drives the - * tombstone flow (build → sign → cidForOp → submit) with the same helpers - * runReap uses, and asserts the post-submit `log/last` matches. - * - * Tombstones are irrevocable on PLC. This test always operates on a freshly- - * provisioned devnet DID never seen by any other test or user. - * - * Prereqs: `pnpm stack:up` (devnet PDS+PLC + postgres reachable). - */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { randomUUID } from "node:crypto"; -import pg from "pg"; -import { - CommunityAdapter, - CredentialCipher, - ProvisionOrchestrator, - generateKeyPair, - initCommunitySchema, - pdsCreateAccount, - pdsGetRecommendedDidCredentials, - pdsActivateAccount, - pdsCreateAppPassword, - submitGenesisOp, - getLastOpCid, - buildTombstoneOp, - signTombstoneOp, - submitTombstoneOp, - cidForOp, - type PdsClient, - type PlcClient, -} from "@atmo-dev/contrail"; -import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { - PDS_URL, - PLC_URL, - HANDLE_DOMAIN, - PDS_ADMIN_PASSWORD, - createIsolatedSchema, -} from "./helpers"; - -describe("reap tombstone CID matches live PLC log/last (M6)", () => { - let pool: pg.Pool; - let cleanupSchema: () => Promise; - let adapter: CommunityAdapter; - let cipher: CredentialCipher; - let pdsDid: string; - - beforeAll(async () => { - const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.describeServer`); - if (!res.ok) { - throw new Error( - `devnet PDS unreachable at ${PDS_URL}: ${res.status} ${await res.text()}`, - ); - } - pdsDid = ((await res.json()) as { did: string }).did; - - const iso = await createIsolatedSchema("test_reap_tombstone_e2e"); - pool = iso.pool; - cleanupSchema = iso.cleanup; - const db = createPostgresDatabase(pool); - await initCommunitySchema(db); - adapter = new CommunityAdapter(db); - cipher = new CredentialCipher(new Uint8Array(32).fill(7)); - }, 15_000); - - afterAll(async () => { - await cleanupSchema?.(); - }); - - const pdsClient: PdsClient = { - createAccount: ({ pdsUrl, serviceAuthJwt, body }) => - pdsCreateAccount(pdsUrl, serviceAuthJwt, body), - getRecommendedDidCredentials: ({ pdsUrl, accessJwt }) => - pdsGetRecommendedDidCredentials(pdsUrl, accessJwt), - activateAccount: ({ pdsUrl, accessJwt }) => pdsActivateAccount(pdsUrl, accessJwt), - createAppPassword: ({ pdsUrl, accessJwt, name }) => - pdsCreateAppPassword(pdsUrl, accessJwt, name), - }; - - const plcClient: PlcClient = { - submit: (did, op) => submitGenesisOp(PLC_URL, did, op as any), - }; - - async function mintInvite(): Promise { - const inviteRes = await fetch( - `${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, - { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Basic ${Buffer.from( - `admin:${PDS_ADMIN_PASSWORD}`, - ).toString("base64")}`, - }, - body: JSON.stringify({ useCount: 1 }), - }, - ); - if (!inviteRes.ok) { - throw new Error( - `createInviteCode failed (${inviteRes.status}): ${await inviteRes.text()}`, - ); - } - return ((await inviteRes.json()) as { code: string }).code; - } - - it( - "tombstone op submitted to PLC has the CID we computed locally via cidForOp", - async () => { - // Provision a fresh community to land a real DID on devnet PLC. - const inviteCode = await mintInvite(); - const suffix = `${Date.now().toString(36)}${Math.random() - .toString(36) - .slice(2, 6)}`; - const handle = `tomb-${suffix}${HANDLE_DOMAIN}`; - const email = `${suffix}@devnet.test`; - const password = `pw-${suffix}`; - const attemptId = randomUUID(); - - const callerRotation = await generateKeyPair(); - - const orch = new ProvisionOrchestrator({ - adapter, - cipher, - plc: plcClient, - pds: pdsClient, - pdsDid, - }); - const result = await orch.provision({ - attemptId, - pdsEndpoint: PDS_URL, - handle, - email, - password, - inviteCode, - rotationKey: callerRotation.publicDidKey, - }); - expect(result.status).toBe("activated"); - const did = result.did; - - // Pull the encrypted rotation key off the persisted row — same path - // runReap takes. - const row = await adapter.getProvisionAttempt(attemptId); - expect(row).not.toBeNull(); - expect(row!.encryptedRotationKey).toBeTruthy(); - const rotationJwk = JSON.parse( - await cipher.decryptString(row!.encryptedRotationKey!), - ) as { kty: string; crv: string; x: string; y: string; d: string }; - - // Drive the tombstone path the same way reap.ts does, but inline so - // the test pins the cidForOp CID independent of runReap's archival - // bookkeeping. - const prev = await getLastOpCid(PLC_URL, did); - const unsigned = buildTombstoneOp(prev); - const signed = await signTombstoneOp(unsigned, rotationJwk); - const expectedCid = await cidForOp(signed as never); - - await submitTombstoneOp(PLC_URL, did, signed); - - // PLC's log/last for the DID must now report the same CID we computed. - // If cidForOp's tombstone encoding ever drifts from PLC's, this is the - // failure mode that catches it. - const lastCid = await getLastOpCid(PLC_URL, did); - expect(lastCid).toBe(expectedCid); - }, - 45_000, - ); -}); diff --git a/apps/contrail-e2e/tests/spaces-auth.test.ts b/apps/contrail-e2e/tests/spaces-auth.test.ts deleted file mode 100644 index a9981fc..0000000 --- a/apps/contrail-e2e/tests/spaces-auth.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Service-auth JWT end-to-end against spaces XRPCs. - * - * 1. Alice mints a JWT via com.atproto.server.getServiceAuth, calls - * {ns}.space.createSpace → verifier accepts, space is created. - * 2. JWT with wrong audience → verifier rejects, 401. - * 3. JWT with lxm bound to one method, used on a different method → 401. - * - * The full auth path is exercised: PDS signs with Alice's PLC-published - * key, Contrail's resolver reads that key from devnet PLC, real verifier - * checks the signature. No mocks on the auth path. - */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import pg from "pg"; -import { CredentialManager, Client } from "@atcute/client"; -import "@atcute/atproto"; -import { Contrail } from "@atmo-dev/contrail"; -import { createHandler } from "@atmo-dev/contrail/server"; -import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { config as baseConfig } from "../config"; -import { - createTestAccount, - createIsolatedSchema, - makeSpacesConfig, - mintServiceAuthJwt, - CONTRAIL_SERVICE_DID, - PDS_URL, - type TestAccount, -} from "./helpers"; - -const SPACE_TYPE = "rsvp.atmo.event.space"; - -describe("spaces auth (devnet PDS JWT → Contrail verifier)", () => { - let alice: TestAccount; - let aliceClient: Client; - let pool: pg.Pool; - let cleanupSchema: () => Promise; - let handle: (req: Request) => Promise; - - beforeAll(async () => { - alice = await createTestAccount(); - const creds = new CredentialManager({ service: PDS_URL }); - await creds.login({ identifier: alice.handle, password: alice.password }); - aliceClient = new Client({ handler: creds }); - - const iso = await createIsolatedSchema("test_spaces_auth"); - pool = iso.pool; - cleanupSchema = iso.cleanup; - const db = createPostgresDatabase(pool); - - const contrail = new Contrail({ - ...baseConfig, - db, - spaces: await makeSpacesConfig(SPACE_TYPE), - }); - await contrail.init(); - handle = createHandler(contrail); - }); - - afterAll(async () => { - await cleanupSchema?.(); - }); - - async function callXrpc( - method: "GET" | "POST", - path: string, - opts: { token?: string; body?: unknown } = {}, - ): Promise { - const headers: Record = {}; - if (opts.token) headers["authorization"] = `Bearer ${opts.token}`; - if (opts.body !== undefined) headers["content-type"] = "application/json"; - return handle( - new Request(`http://test${path}`, { - method, - headers, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, - }), - ); - } - - it("accepts a real service-auth JWT and creates a space", async () => { - const token = await mintServiceAuthJwt(aliceClient, { - aud: CONTRAIL_SERVICE_DID, - lxm: "rsvp.atmo.space.createSpace", - }); - - const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { - token, - body: {}, - }); - const text = await res.clone().text().catch(() => ""); - expect(res.status, `createSpace → ${res.status}: ${text}`).toBe(200); - - const data = (await res.json()) as { space: { uri: string; ownerDid: string } }; - expect(data.space.uri).toMatch(/^ats:\/\//); - expect(data.space.ownerDid).toBe(alice.did); - }); - - it("rejects a JWT minted with the wrong audience", async () => { - const token = await mintServiceAuthJwt(aliceClient, { - aud: "did:web:not-contrail.devnet.test", - }); - - const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { - token, - body: {}, - }); - expect(res.status).toBe(401); - }); - - it("rejects a JWT whose lxm binding mismatches the route", async () => { - const token = await mintServiceAuthJwt(aliceClient, { - aud: CONTRAIL_SERVICE_DID, - lxm: "rsvp.atmo.space.listSpaces", - }); - - const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { - token, - body: {}, - }); - expect(res.status).toBe(401); - }); -}); diff --git a/apps/contrail-e2e/tests/spaces-firehose-invisibility.test.ts b/apps/contrail-e2e/tests/spaces-firehose-invisibility.test.ts deleted file mode 100644 index 4d81933..0000000 --- a/apps/contrail-e2e/tests/spaces-firehose-invisibility.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Records put into a private space must not appear on the ATProto firehose. - * - * Protocol: - * 1. Subscribe to Jetstream filtered to Alice's DID + the event collection. - * 2. Alice publishes a control record directly to her PDS → MUST appear - * on the firehose (proves the subscriber works). - * 3. Alice puts a record into a space via {ns}.space.putRecord → MUST NOT - * appear on the firehose. - * - * Without (2) as a positive control, a silently broken subscriber would - * make (3) vacuously true. - */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import pg from "pg"; -import { CredentialManager, Client } from "@atcute/client"; -import "@atcute/atproto"; -import { JetstreamSubscription, type JetstreamEvent } from "@atcute/jetstream"; -import type { Did as AtDid } from "@atcute/lexicons"; -import { Contrail } from "@atmo-dev/contrail"; -import { createHandler } from "@atmo-dev/contrail/server"; -import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { config as baseConfig } from "../config"; -import { - createTestAccount, - createIsolatedSchema, - makeSpacesConfig, - mintServiceAuthJwt, - CONTRAIL_SERVICE_DID, - PDS_URL, - type TestAccount, -} from "./helpers"; - -const EVENT_NSID = "community.lexicon.calendar.event"; -const SPACE_TYPE = "rsvp.atmo.event.space"; -const JETSTREAM_URL = process.env.JETSTREAM_URL ?? "ws://localhost:6008/subscribe"; -const PROPAGATION_MS = 2_500; - -describe("spaces firehose invisibility", () => { - let alice: TestAccount; - let aliceClient: Client; - let pool: pg.Pool; - let cleanupSchema: () => Promise; - let handle: (req: Request) => Promise; - - beforeAll(async () => { - alice = await createTestAccount(); - const creds = new CredentialManager({ service: PDS_URL }); - await creds.login({ identifier: alice.handle, password: alice.password }); - aliceClient = new Client({ handler: creds }); - - const iso = await createIsolatedSchema("test_firehose_invisibility"); - pool = iso.pool; - cleanupSchema = iso.cleanup; - const db = createPostgresDatabase(pool); - - const contrail = new Contrail({ - ...baseConfig, - db, - spaces: await makeSpacesConfig(SPACE_TYPE), - }); - await contrail.init(); - handle = createHandler(contrail); - }); - - afterAll(async () => { - await cleanupSchema?.(); - }); - - async function callXrpc( - method: "GET" | "POST", - path: string, - opts: { token?: string; body?: unknown } = {}, - ): Promise { - const headers: Record = {}; - if (opts.token) headers["authorization"] = `Bearer ${opts.token}`; - if (opts.body !== undefined) headers["content-type"] = "application/json"; - return handle( - new Request(`http://test${path}`, { - method, - headers, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, - }), - ); - } - - it("publishes PDS writes to firehose; space writes stay invisible", async () => { - const observed = new Set(); - const ac = new AbortController(); - const opened = deferred(); - - const sub = new JetstreamSubscription({ - url: JETSTREAM_URL, - wantedCollections: [EVENT_NSID], - wantedDids: [alice.did as unknown as AtDid], - onConnectionOpen: () => opened.resolve(), - }); - - const collector = (async () => { - const iterator = sub[Symbol.asyncIterator](); - try { - while (!ac.signal.aborted) { - const result = await Promise.race([ - iterator.next(), - new Promise>((resolve) => { - ac.signal.addEventListener( - "abort", - () => resolve({ value: undefined, done: true }), - { once: true }, - ); - }), - ]); - if (result.done) break; - const ev = result.value; - if (ev.kind === "commit" && ev.did === alice.did) { - observed.add(ev.commit.rkey); - } - } - } finally { - await iterator.return?.(); - } - })(); - - await opened.promise; - - // Control: direct PDS write must land on the firehose. - const controlRes = await aliceClient.post("com.atproto.repo.createRecord", { - input: { - repo: alice.did, - collection: EVENT_NSID as never, - record: eventRecord("firehose-control"), - }, - }); - expect(controlRes.ok, `control createRecord: ${JSON.stringify(controlRes.data)}`).toBe(true); - if (!controlRes.ok) throw new Error("unreachable"); - const controlRkey = controlRes.data.uri.split("/").pop()!; - - // Private: space write must not. - const createToken = await mintServiceAuthJwt(aliceClient, { - aud: CONTRAIL_SERVICE_DID, - lxm: "rsvp.atmo.space.createSpace", - }); - const createRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { - token: createToken, - body: {}, - }); - expect(createRes.status).toBe(200); - const { space } = (await createRes.json()) as { space: { uri: string } }; - - const putToken = await mintServiceAuthJwt(aliceClient, { - aud: CONTRAIL_SERVICE_DID, - lxm: "rsvp.atmo.space.putRecord", - }); - const putRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.putRecord", { - token: putToken, - body: { - spaceUri: space.uri, - collection: EVENT_NSID, - record: eventRecord("private-in-space"), - }, - }); - const putText = await putRes.clone().text().catch(() => ""); - expect(putRes.status, `space.putRecord → ${putRes.status}: ${putText}`).toBe(200); - const { rkey: spaceRkey } = (await putRes.json()) as { rkey: string }; - - await new Promise((r) => setTimeout(r, PROPAGATION_MS)); - - ac.abort(); - await collector; - - expect( - observed.has(controlRkey), - `control PDS record ${controlRkey} must appear on firehose; observed: ${[...observed].join(",") || "(none)"}`, - ).toBe(true); - expect( - observed.has(spaceRkey), - `space record ${spaceRkey} must NOT appear on firehose`, - ).toBe(false); - }); -}); - -function eventRecord(name: string) { - return { - $type: EVENT_NSID, - name, - createdAt: new Date().toISOString(), - startsAt: new Date(Date.now() + 60_000).toISOString(), - mode: `${EVENT_NSID}#inperson`, - status: `${EVENT_NSID}#scheduled`, - }; -} - -function deferred(): { promise: Promise; resolve: (value: T) => void } { - let resolve!: (value: T) => void; - const promise = new Promise((r) => (resolve = r)); - return { promise, resolve }; -} diff --git a/apps/contrail-e2e/tests/spaces-table-isolation.test.ts b/apps/contrail-e2e/tests/spaces-table-isolation.test.ts deleted file mode 100644 index c61aed5..0000000 --- a/apps/contrail-e2e/tests/spaces-table-isolation.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Records written via {ns}.space.putRecord land in `spaces_records_`, - * not `records_`. The public table must stay empty for the caller. - * - * 1. Alice creates a space and puts an event into it. - * 2. Query postgres directly: - * - records_event → 0 rows for alice.did - * - spaces_records_event → 1 row with matching rkey and space_uri - * - * A leak between these tables would silently expose private records to any - * public {ns}.event.getRecord / listRecords call. - */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import pg from "pg"; -import { CredentialManager, Client } from "@atcute/client"; -import "@atcute/atproto"; -import { Contrail } from "@atmo-dev/contrail"; -import { createHandler } from "@atmo-dev/contrail/server"; -import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { config as baseConfig } from "../config"; -import { - createTestAccount, - createIsolatedSchema, - makeSpacesConfig, - mintServiceAuthJwt, - CONTRAIL_SERVICE_DID, - PDS_URL, - type TestAccount, -} from "./helpers"; - -const EVENT_NSID = "community.lexicon.calendar.event"; -const SPACE_TYPE = "rsvp.atmo.event.space"; - -describe("spaces table isolation", () => { - let alice: TestAccount; - let aliceClient: Client; - let pool: pg.Pool; - let cleanupSchema: () => Promise; - let handle: (req: Request) => Promise; - - beforeAll(async () => { - alice = await createTestAccount(); - const creds = new CredentialManager({ service: PDS_URL }); - await creds.login({ identifier: alice.handle, password: alice.password }); - aliceClient = new Client({ handler: creds }); - - const iso = await createIsolatedSchema("test_table_isolation"); - pool = iso.pool; - cleanupSchema = iso.cleanup; - const db = createPostgresDatabase(pool); - - const contrail = new Contrail({ - ...baseConfig, - db, - spaces: await makeSpacesConfig(SPACE_TYPE), - }); - await contrail.init(); - handle = createHandler(contrail); - }); - - afterAll(async () => { - await cleanupSchema?.(); - }); - - async function callXrpc( - method: "GET" | "POST", - path: string, - opts: { token?: string; body?: unknown } = {}, - ): Promise { - const headers: Record = {}; - if (opts.token) headers["authorization"] = `Bearer ${opts.token}`; - if (opts.body !== undefined) headers["content-type"] = "application/json"; - return handle( - new Request(`http://test${path}`, { - method, - headers, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, - }), - ); - } - - it("space records live in spaces_records_event, not records_event", async () => { - const createToken = await mintServiceAuthJwt(aliceClient, { - aud: CONTRAIL_SERVICE_DID, - lxm: "rsvp.atmo.space.createSpace", - }); - const createRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { - token: createToken, - body: {}, - }); - expect(createRes.status).toBe(200); - const { space } = (await createRes.json()) as { space: { uri: string } }; - - const putToken = await mintServiceAuthJwt(aliceClient, { - aud: CONTRAIL_SERVICE_DID, - lxm: "rsvp.atmo.space.putRecord", - }); - const putRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.putRecord", { - token: putToken, - body: { - spaceUri: space.uri, - collection: EVENT_NSID, - record: { - $type: EVENT_NSID, - name: "isolation-target", - createdAt: new Date().toISOString(), - startsAt: new Date(Date.now() + 60_000).toISOString(), - mode: `${EVENT_NSID}#inperson`, - status: `${EVENT_NSID}#scheduled`, - }, - }, - }); - expect(putRes.status, await putRes.clone().text().catch(() => "")).toBe(200); - const { rkey } = (await putRes.json()) as { rkey: string }; - - const publicRows = await pool.query( - "SELECT COUNT(*)::int AS n FROM records_event WHERE did = $1", - [alice.did], - ); - expect(publicRows.rows[0].n, "records_event must not contain the space record").toBe(0); - - const spaceRows = await pool.query( - "SELECT rkey, space_uri, did FROM spaces_records_event WHERE did = $1", - [alice.did], - ); - expect(spaceRows.rows).toHaveLength(1); - expect(spaceRows.rows[0]).toMatchObject({ - rkey, - space_uri: space.uri, - did: alice.did, - }); - }); -}); diff --git a/apps/contrail-e2e/tsconfig.json b/apps/contrail-e2e/tsconfig.json deleted file mode 100644 index 9b7d9df..0000000 --- a/apps/contrail-e2e/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "lib": ["ES2022"], - "strict": true, - "noEmit": true, - "skipLibCheck": true, - "isolatedModules": true - }, - "include": ["."] -} diff --git a/apps/contrail-e2e/vitest.config.ts b/apps/contrail-e2e/vitest.config.ts deleted file mode 100644 index 164b0c7..0000000 --- a/apps/contrail-e2e/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["tests/**/*.test.ts"], - // Live-stack tests mutate a shared devnet — can't run in parallel. - fileParallelism: false, - // Network round-trips can be slow on a cold stack. - testTimeout: 30_000, - }, -}); diff --git a/apps/group-chat/.claude/settings.local.json b/apps/group-chat/.claude/settings.local.json deleted file mode 100644 index b9ddfaa..0000000 --- a/apps/group-chat/.claude/settings.local.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(npx tsc:*)", - "mcp__plugin_svelte_svelte__svelte-autofixer", - "mcp__plugin_svelte_svelte__get-documentation", - "Bash(pnpm check:*)", - "Bash(pnpm env:generate-secret:*)", - "Bash(pnpm build:agent-setup:*)", - "Read(//Users/florian/contrail/**)", - "Bash(pnpm typecheck *)", - "Bash(pnpm test *)" - ] - } -} diff --git a/apps/group-chat/.env.example b/apps/group-chat/.env.example deleted file mode 100644 index 865a865..0000000 --- a/apps/group-chat/.env.example +++ /dev/null @@ -1,22 +0,0 @@ -# Generate both with: pnpm env:setup-dev -CLIENT_ASSERTION_KEY= -COOKIE_SECRET= - -# Set to your tunnel URL to use a confidential client in dev -# OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com - -# 32-byte base64 secret used to envelope-encrypt community signing keys. -# Generate with: openssl rand -base64 32 -COMMUNITY_MASTER_KEY= - -# 32-byte base64 secret used to HMAC-sign realtime subscription tickets. -# Generate with: openssl rand -base64 32 -REALTIME_TICKET_SECRET= - -# Service DID for JWT verification (aud claim). did:web: for prod. -SERVICE_DID=did:web:localhost - -# Dev-only: trust the HMAC-signed session cookie instead of atproto service-auth -# JWTs, so the loopback OAuth client can reach contrail without a tunnel. NEVER -# set this in production — it turns the cookie into the only auth signal. -DEV_AUTH=1 diff --git a/apps/group-chat/.gitignore b/apps/group-chat/.gitignore deleted file mode 100644 index 1c43954..0000000 --- a/apps/group-chat/.gitignore +++ /dev/null @@ -1,26 +0,0 @@ -node_modules - -# Output -.output -.vercel -.netlify -.wrangler -/.svelte-kit -/build - -# OS -.DS_Store -Thumbs.db - -# Env -.env -.env.* -!.env.example -!.env.test - -# Vite -vite.config.js.timestamp-* -vite.config.ts.timestamp-* - -# Generated types (regenerated via pnpm generate:pull) -src/lexicon-types/ diff --git a/apps/group-chat/.npmrc b/apps/group-chat/.npmrc deleted file mode 100644 index b6f27f1..0000000 --- a/apps/group-chat/.npmrc +++ /dev/null @@ -1 +0,0 @@ -engine-strict=true diff --git a/apps/group-chat/.prettierignore b/apps/group-chat/.prettierignore deleted file mode 100644 index ab78a95..0000000 --- a/apps/group-chat/.prettierignore +++ /dev/null @@ -1,4 +0,0 @@ -# Package Managers -package-lock.json -pnpm-lock.yaml -yarn.lock diff --git a/apps/group-chat/.prettierrc b/apps/group-chat/.prettierrc deleted file mode 100644 index 7ebb855..0000000 --- a/apps/group-chat/.prettierrc +++ /dev/null @@ -1,15 +0,0 @@ -{ - "useTabs": true, - "singleQuote": true, - "trailingComma": "none", - "printWidth": 100, - "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], - "overrides": [ - { - "files": "*.svelte", - "options": { - "parser": "svelte" - } - } - ] -} diff --git a/apps/group-chat/LICENSE b/apps/group-chat/LICENSE deleted file mode 100644 index 696e4f6..0000000 --- a/apps/group-chat/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License Copyright (c) 2025 flo-bit - -Permission is hereby granted, free of -charge, to any person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to the -following conditions: - -The above copyright notice and this permission notice -(including the next paragraph) shall be included in all copies or substantial -portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. \ No newline at end of file diff --git a/apps/group-chat/README.md b/apps/group-chat/README.md deleted file mode 100644 index e1b1169..0000000 --- a/apps/group-chat/README.md +++ /dev/null @@ -1,117 +0,0 @@ -# sveltekit-group-chat - -A Discord-like demo built on top of [contrail](https://github.com/flo-bit/contrail) — SvelteKit + Cloudflare Workers + D1, OAuth, spaces, communities, and realtime (SSE). - -## What this exercises - -- **`community`** — mint a fresh `did:plc` per server, tiered access levels (member / manager / admin / owner), space → space delegation. -- **`spaces`** — permissioned per-channel record tables, membership pushed in by the community reconciler. -- **`realtime`** — single SSE connection subscribed to `community:`, fanning out per-channel events with unread dots derived client-side. -- Three record collections — `tools.atmo.chat.server`, `tools.atmo.chat.channel`, `tools.atmo.chat.message` — with unified `listRecords` for "every server / channel I can see" in one call. - -## Setup - -```sh -pnpm install -cp .env.example .env -# fill in COMMUNITY_MASTER_KEY and REALTIME_TICKET_SECRET with -# openssl rand -base64 32 -pnpm generate:pull # emits lexicons + src/lib/atproto/generated-methods.ts -pnpm dev -``` - -Re-run `pnpm generate` whenever you add/remove collections or toggle contrail -modules in `src/lib/contrail.config.ts`: the OAuth scope list is derived from -it, so the consent screen only asks for permissions you actually use. - -Dev mode uses a loopback OAuth client — no Cloudflare setup needed. The realtime Durable Object runs locally via miniflare. - -### Auth in dev vs prod - -Contrail normally auths every write with an atproto **service-auth JWT**. The browser delegates minting those JWTs to the SvelteKit worker, which calls `com.atproto.server.getServiceAuth` on the user's PDS. - -bsky.social **refuses** that call for loopback OAuth clients, so out of the box a plain `pnpm dev` couldn't talk to contrail without a [cloudflared tunnel](#with-a-tunnel). - -To skip the tunnel this example ships a **dev auth bypass** gated on `DEV_AUTH=1` in `.env`: - -- Contrail's auth middleware trusts the HMAC-signed `did` session cookie the OAuth flow already sets, and uses its DID as the authenticated caller. -- The cookie is HMAC-signed with `COOKIE_SECRET`, so only this worker can mint a valid one. -- All access-level checks (admin+, manager+, etc.) still apply — the bypass only replaces the JWT step. - -**Never set `DEV_AUTH=1` in production** — that would make the cookie the sole auth signal for third-party clients. - -### With a tunnel - -If you want to exercise the real JWT path in dev, run a tunnel and drop `DEV_AUTH`: - -```sh -pnpm tunnel # prints https://xxx.trycloudflare.com -# in .env: set OAUTH_PUBLIC_URL to the tunnel URL and remove DEV_AUTH -pnpm dev -# open the tunnel URL (not localhost) -``` - -## How the pieces connect - -``` -┌────────────┐ service-auth JWT ┌─────────────────┐ contrail XRPCs ┌───────────┐ -│ browser │ ─────────────────► │ SvelteKit worker │ ─────────────────► │ contrail │ -└────────────┘ └─────────────────┘ └───────────┘ - │ │ │ - │ EventSource /xrpc/.../realtime.subscribe?ticket=... ◄──────── realtime DO │ - └───────────────────────────────────────────────────────────────────────────┘ -``` - -- The browser can't mint service-auth JWTs itself — it delegates to the SvelteKit worker, which uses the user's OAuth session to call `com.atproto.server.getServiceAuth` on their PDS. The resulting JWT is scoped to one `lxm`. -- Realtime tickets are minted the same way, then the browser holds an `EventSource` open against the subscribe endpoint. - -## Data model - -| Record | Lives in | Author | -|---|---|---| -| `tools.atmo.chat.server` | community's `members` space, rkey `self` | community DID (via `community.space.putRecord`) | -| `tools.atmo.chat.channel` | channel's own space, rkey `self` | community DID | -| `tools.atmo.chat.message` | channel's own space, rkey = TID | user DID | - -Only admins (`admin+` in the target space) can write the server and channel records, and the community DID is the author. Reads filter by `actor=` so spoof records from random members are silently ignored. - -## Feature map - -| Route | What it does | -|---|---| -| `/` | "My servers" — unified `server.listRecords` over every space I'm in | -| `/new` | Mint community, bootstrap `members` role-space, write server record, show recovery key once | -| `/c/[communityDid]` | Redirects to the first channel you can see | -| `/c/[communityDid]/[channelKey]` | Chat view — messages via `space.listRecords` + realtime | -| `/c/[communityDid]/settings/members` | Add, promote, demote, revoke members | - -## Private channels - -New-channel modal offers a visibility toggle: - -- **Public** — grants the `members` role-space `member` access on the new channel. Reconciler fans out to everyone. -- **Private** — grants each picked DID directly. Also grants `members` (so they can see the server header) but not automatic access to other channels. - -## Deploy - -```sh -npx wrangler d1 create group-chat -# add the database_id to wrangler.jsonc -npx wrangler secret put COMMUNITY_MASTER_KEY -npx wrangler secret put REALTIME_TICKET_SECRET - -pnpm build -npx wrangler deploy -``` - -## Known gaps - -This is a demo. It deliberately skips: - -- **`adopt` mode** — only `mint` is wired up. -- **`$publishers`** / community-authored public posts. -- Threads, reactions, image uploads, rich text, typing indicators. - -## License - -MIT diff --git a/apps/group-chat/eslint.config.js b/apps/group-chat/eslint.config.js deleted file mode 100644 index aa5987f..0000000 --- a/apps/group-chat/eslint.config.js +++ /dev/null @@ -1,34 +0,0 @@ -import prettier from 'eslint-config-prettier'; -import js from '@eslint/js'; -import { includeIgnoreFile } from '@eslint/compat'; -import svelte from 'eslint-plugin-svelte'; -import globals from 'globals'; -import { fileURLToPath } from 'node:url'; -import ts from 'typescript-eslint'; -const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); - -export default ts.config( - includeIgnoreFile(gitignorePath), - js.configs.recommended, - ...ts.configs.recommended, - ...svelte.configs['flat/recommended'], - prettier, - ...svelte.configs['flat/prettier'], - { - languageOptions: { - globals: { - ...globals.browser, - ...globals.node - } - } - }, - { - files: ['**/*.svelte'], - - languageOptions: { - parserOptions: { - parser: ts.parser - } - } - } -); diff --git a/apps/group-chat/lexicons/custom/tools/atmo/chat/channel.json b/apps/group-chat/lexicons/custom/tools/atmo/chat/channel.json deleted file mode 100644 index adff23a..0000000 --- a/apps/group-chat/lexicons/custom/tools/atmo/chat/channel.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.channel", - "defs": { - "main": { - "type": "record", - "key": "literal:self", - "record": { - "type": "object", - "required": ["communityDid", "name", "visibility", "createdAt"], - "properties": { - "communityDid": { "type": "string", "format": "did" }, - "name": { "type": "string", "maxLength": 128 }, - "topic": { "type": "string", "maxLength": 512 }, - "visibility": { - "type": "string", - "knownValues": ["public", "private"] - }, - "createdAt": { "type": "string", "format": "datetime" } - } - } - } - } -} diff --git a/apps/group-chat/lexicons/custom/tools/atmo/chat/message.json b/apps/group-chat/lexicons/custom/tools/atmo/chat/message.json deleted file mode 100644 index a0c8b8d..0000000 --- a/apps/group-chat/lexicons/custom/tools/atmo/chat/message.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.message", - "defs": { - "main": { - "type": "record", - "key": "tid", - "record": { - "type": "object", - "required": ["text", "createdAt"], - "properties": { - "text": { "type": "string", "maxLength": 4000 }, - "createdAt": { "type": "string", "format": "datetime" }, - "replyTo": { "type": "string", "description": "rkey of the parent message in the same channel" } - } - } - } - } -} diff --git a/apps/group-chat/lexicons/custom/tools/atmo/chat/server.json b/apps/group-chat/lexicons/custom/tools/atmo/chat/server.json deleted file mode 100644 index 582117c..0000000 --- a/apps/group-chat/lexicons/custom/tools/atmo/chat/server.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.server", - "defs": { - "main": { - "type": "record", - "key": "literal:self", - "record": { - "type": "object", - "required": ["communityDid", "name", "createdAt"], - "properties": { - "communityDid": { "type": "string", "format": "did" }, - "name": { "type": "string", "maxLength": 128 }, - "description": { "type": "string", "maxLength": 2000 }, - "icon": { - "type": "blob", - "accept": ["image/png", "image/jpeg", "image/webp", "image/gif"], - "maxSize": 1000000 - }, - "createdAt": { "type": "string", "format": "datetime" } - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/index.ts b/apps/group-chat/lexicons/generated/index.ts deleted file mode 100644 index aaa46b9..0000000 --- a/apps/group-chat/lexicons/generated/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Auto-generated by @atmo-dev/contrail-lexicons. Do not edit. -// Pass `lexicons` to `createWorker(config, { lexicons })` to expose them -// at `/xrpc/.lexicons` for consumer apps to typegen against. - -import _0 from "../custom/tools/atmo/chat/channel.json"; -import _1 from "../custom/tools/atmo/chat/message.json"; -import _2 from "../custom/tools/atmo/chat/server.json"; -import _3 from "../pulled/app/bsky/actor/profile.json"; -import _4 from "./tools/atmo/chat/channel/getRecord.json"; -import _5 from "./tools/atmo/chat/channel/listRecords.json"; -import _6 from "./tools/atmo/chat/channel/watchRecords.json"; -import _7 from "./tools/atmo/chat/community/adopt.json"; -import _8 from "./tools/atmo/chat/community/defs.json"; -import _9 from "./tools/atmo/chat/community/delete.json"; -import _10 from "./tools/atmo/chat/community/deleteRecord.json"; -import _11 from "./tools/atmo/chat/community/getHealth.json"; -import _12 from "./tools/atmo/chat/community/list.json"; -import _13 from "./tools/atmo/chat/community/mint.json"; -import _14 from "./tools/atmo/chat/community/putRecord.json"; -import _15 from "./tools/atmo/chat/community/reauth.json"; -import _16 from "./tools/atmo/chat/community/space/create.json"; -import _17 from "./tools/atmo/chat/community/space/delete.json"; -import _18 from "./tools/atmo/chat/community/space/deleteRecord.json"; -import _19 from "./tools/atmo/chat/community/space/grant.json"; -import _20 from "./tools/atmo/chat/community/space/listMembers.json"; -import _21 from "./tools/atmo/chat/community/space/putRecord.json"; -import _22 from "./tools/atmo/chat/community/space/resync.json"; -import _23 from "./tools/atmo/chat/community/space/revoke.json"; -import _24 from "./tools/atmo/chat/community/space/setAccessLevel.json"; -import _25 from "./tools/atmo/chat/getCursor.json"; -import _26 from "./tools/atmo/chat/getOverview.json"; -import _27 from "./tools/atmo/chat/getProfile.json"; -import _28 from "./tools/atmo/chat/invite/create.json"; -import _29 from "./tools/atmo/chat/invite/defs.json"; -import _30 from "./tools/atmo/chat/invite/list.json"; -import _31 from "./tools/atmo/chat/invite/redeem.json"; -import _32 from "./tools/atmo/chat/invite/revoke.json"; -import _33 from "./tools/atmo/chat/message/getRecord.json"; -import _34 from "./tools/atmo/chat/message/listRecords.json"; -import _35 from "./tools/atmo/chat/message/watchRecords.json"; -import _36 from "./tools/atmo/chat/notifyOfUpdate.json"; -import _37 from "./tools/atmo/chat/permissionSet.json"; -import _38 from "./tools/atmo/chat/realtime/subscribe.json"; -import _39 from "./tools/atmo/chat/realtime/ticket.json"; -import _40 from "./tools/atmo/chat/server/getRecord.json"; -import _41 from "./tools/atmo/chat/server/listRecords.json"; -import _42 from "./tools/atmo/chat/server/watchRecords.json"; -import _43 from "./tools/atmo/chat/space/addMember.json"; -import _44 from "./tools/atmo/chat/space/createSpace.json"; -import _45 from "./tools/atmo/chat/space/defs.json"; -import _46 from "./tools/atmo/chat/space/deleteRecord.json"; -import _47 from "./tools/atmo/chat/space/getBlob.json"; -import _48 from "./tools/atmo/chat/space/getRecord.json"; -import _49 from "./tools/atmo/chat/space/getSpace.json"; -import _50 from "./tools/atmo/chat/space/leaveSpace.json"; -import _51 from "./tools/atmo/chat/space/listBlobs.json"; -import _52 from "./tools/atmo/chat/space/listMembers.json"; -import _53 from "./tools/atmo/chat/space/listRecords.json"; -import _54 from "./tools/atmo/chat/space/listSpaces.json"; -import _55 from "./tools/atmo/chat/space/putRecord.json"; -import _56 from "./tools/atmo/chat/space/removeMember.json"; -import _57 from "./tools/atmo/chat/space/uploadBlob.json"; -import _58 from "./tools/atmo/chat/spaceExt/whoami.json"; - -export const lexicons: object[] = [_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19, _20, _21, _22, _23, _24, _25, _26, _27, _28, _29, _30, _31, _32, _33, _34, _35, _36, _37, _38, _39, _40, _41, _42, _43, _44, _45, _46, _47, _48, _49, _50, _51, _52, _53, _54, _55, _56, _57, _58]; diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/getRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/getRecord.json deleted file mode 100644 index 9a6e2f3..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/getRecord.json +++ /dev/null @@ -1,185 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.channel.getRecord", - "defs": { - "main": { - "type": "query", - "description": "Get a single tools.atmo.chat.channel record by AT URI", - "parameters": { - "type": "params", - "required": [ - "uri" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT URI of the record" - }, - "profiles": { - "type": "boolean", - "description": "Include profile + identity info keyed by DID" - }, - "spaceUri": { - "type": "string", - "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)." - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "uri", - "value", - "did", - "collection", - "rkey", - "time_us" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "value": { - "type": "ref", - "ref": "tools.atmo.chat.channel#main" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "time_us": { - "type": "integer" - }, - "space": { - "type": "string", - "description": "Present when the record was read from a permissioned space; its value is the space URI." - }, - "profiles": { - "type": "array", - "items": { - "type": "ref", - "ref": "#profileEntry" - } - } - } - } - } - }, - "profileEntry": { - "type": "object", - "required": [ - "did" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "handle": { - "type": "string" - }, - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "value": { - "type": "ref", - "ref": "#appBskyActorProfile" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - } - } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/listRecords.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/listRecords.json deleted file mode 100644 index bbdcb42..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/listRecords.json +++ /dev/null @@ -1,239 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.channel.listRecords", - "defs": { - "main": { - "type": "query", - "description": "Query tools.atmo.chat.channel records with filters", - "parameters": { - "type": "params", - "properties": { - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 - }, - "cursor": { - "type": "string" - }, - "actor": { - "type": "string", - "format": "at-identifier", - "description": "Filter by DID or handle (triggers on-demand backfill)" - }, - "profiles": { - "type": "boolean", - "description": "Include profile + identity info keyed by DID" - }, - "spaceUri": { - "type": "string", - "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." - }, - "byUser": { - "type": "string", - "format": "did", - "description": "Only used with spaceUri \u2014 filter to records authored by this DID." - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." - }, - "communityDid": { - "type": "string", - "description": "Filter by communityDid" - }, - "visibility": { - "type": "string", - "description": "Filter by visibility" - }, - "sort": { - "type": "string", - "knownValues": [ - "communityDid", - "visibility" - ], - "description": "Field to sort by (default: time_us)" - }, - "order": { - "type": "string", - "knownValues": [ - "asc", - "desc" - ], - "description": "Sort direction (default: desc for dates/numbers/counts, asc for strings)" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "records" - ], - "properties": { - "records": { - "type": "array", - "items": { - "type": "ref", - "ref": "#record" - } - }, - "cursor": { - "type": "string" - }, - "profiles": { - "type": "array", - "items": { - "type": "ref", - "ref": "#profileEntry" - } - } - } - } - } - }, - "record": { - "type": "object", - "required": [ - "uri", - "cid", - "value", - "did", - "collection", - "rkey", - "time_us" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "value": { - "type": "ref", - "ref": "tools.atmo.chat.channel#main" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "time_us": { - "type": "integer" - }, - "space": { - "type": "string", - "description": "Present when the record was read from a permissioned space; its value is the space URI." - } - } - }, - "profileEntry": { - "type": "object", - "required": [ - "did" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "handle": { - "type": "string" - }, - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "value": { - "type": "ref", - "ref": "#appBskyActorProfile" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - } - } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/watchRecords.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/watchRecords.json deleted file mode 100644 index 2ac1bed..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/watchRecords.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.channel.watchRecords", - "defs": { - "main": { - "type": "query", - "description": "Subscribe to a live tools.atmo.chat.channel query. Returns Server-Sent Events: `snapshot.start`, `snapshot.record` (N of), `snapshot.end`, then `record.created` / `record.deleted` as records enter/leave the result, plus a final `member.removed` if the caller loses access mid-stream. v1 requires spaceUri; cross-space watch is deferred.", - "parameters": { - "type": "params", - "properties": { - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 - }, - "cursor": { - "type": "string" - }, - "actor": { - "type": "string", - "format": "at-identifier", - "description": "Filter by DID or handle (triggers on-demand backfill)" - }, - "profiles": { - "type": "boolean", - "description": "Include profile + identity info keyed by DID" - }, - "spaceUri": { - "type": "string", - "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." - }, - "byUser": { - "type": "string", - "format": "did", - "description": "Only used with spaceUri \u2014 filter to records authored by this DID." - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." - }, - "communityDid": { - "type": "string", - "description": "Filter by communityDid" - }, - "visibility": { - "type": "string", - "description": "Filter by visibility" - }, - "sort": { - "type": "string", - "knownValues": [ - "communityDid", - "visibility" - ], - "description": "Field to sort by (default: time_us)" - }, - "order": { - "type": "string", - "knownValues": [ - "asc", - "desc" - ], - "description": "Sort direction (default: desc for dates/numbers/counts, asc for strings)" - } - } - }, - "output": { - "encoding": "text/event-stream", - "schema": { - "type": "object", - "properties": {} - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "AuthRequired" - }, - { - "name": "Forbidden" - }, - { - "name": "NotFound" - }, - { - "name": "NotSupported" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/adopt.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/adopt.json deleted file mode 100644 index 6f73301..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/adopt.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.adopt", - "defs": { - "main": { - "type": "procedure", - "description": "Adopt an existing ATProto account as a community identity. The provided app password is verified by creating a session, then stored encrypted. Creates the reserved `$admin` and `$publishers` spaces with the caller as `owner` in both.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "identifier", - "appPassword" - ], - "properties": { - "identifier": { - "type": "string", - "description": "Handle or DID of the account to adopt." - }, - "appPassword": { - "type": "string", - "description": "App password for the account; verified by createSession and stored encrypted." - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communityDid" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "Unauthorized" - }, - { - "name": "AlreadyExists" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/defs.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/defs.json deleted file mode 100644 index a7f1f1d..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/defs.json +++ /dev/null @@ -1,182 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.defs", - "description": "Shared types for community-module XRPC methods.", - "defs": { - "accessLevel": { - "type": "string", - "knownValues": [ - "member", - "manager", - "admin", - "owner" - ], - "description": "Module-internal access levels that govern community operations on a given space. Ordered: member < manager < admin < owner." - }, - "communityView": { - "type": "object", - "required": [ - "did", - "mode", - "createdAt" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "mode": { - "type": "string", - "knownValues": [ - "adopt", - "mint" - ] - }, - "identifier": { - "type": "string", - "description": "Handle or DID at adoption time (adopt only)." - }, - "createdAt": { - "type": "integer" - } - } - }, - "spaceView": { - "type": "object", - "required": [ - "uri", - "ownerDid", - "type", - "key", - "serviceDid", - "createdAt" - ], - "properties": { - "uri": { - "type": "string" - }, - "ownerDid": { - "type": "string", - "format": "did" - }, - "type": { - "type": "string", - "format": "nsid" - }, - "key": { - "type": "string" - }, - "serviceDid": { - "type": "string" - }, - "createdAt": { - "type": "integer" - } - } - }, - "subject": { - "type": "object", - "description": "Exactly one of `did` or `spaceUri` must be set.", - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "spaceUri": { - "type": "string" - } - } - }, - "memberRow": { - "type": "object", - "required": [ - "subject", - "accessLevel", - "grantedBy", - "grantedAt" - ], - "properties": { - "subject": { - "type": "ref", - "ref": "#subject" - }, - "accessLevel": { - "type": "ref", - "ref": "#accessLevel" - }, - "grantedBy": { - "type": "string", - "format": "did" - }, - "grantedAt": { - "type": "integer" - } - } - }, - "flatMember": { - "type": "object", - "required": [ - "did", - "addedAt" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "addedAt": { - "type": "integer" - } - } - }, - "inviteView": { - "type": "object", - "required": [ - "tokenHash", - "spaceUri", - "accessLevel", - "createdBy", - "createdAt", - "usedCount" - ], - "properties": { - "tokenHash": { - "type": "string", - "description": "SHA-256 of the raw token. Stable id for list/revoke; never grants access on its own." - }, - "spaceUri": { - "type": "string" - }, - "accessLevel": { - "type": "ref", - "ref": "#accessLevel" - }, - "createdBy": { - "type": "string", - "format": "did" - }, - "createdAt": { - "type": "integer" - }, - "expiresAt": { - "type": "integer", - "description": "Unix ms. Omitted for no expiry." - }, - "maxUses": { - "type": "integer", - "description": "Omitted for unlimited." - }, - "usedCount": { - "type": "integer" - }, - "revokedAt": { - "type": "integer", - "description": "Unix ms. Omitted if not revoked." - }, - "note": { - "type": "string" - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/delete.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/delete.json deleted file mode 100644 index a8d7a1b..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/delete.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.delete", - "defs": { - "main": { - "type": "procedure", - "description": "Soft-delete a community. Also soft-deletes all spaces owned by the community. Caller must have `owner` in the community's `$admin` space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communityDid" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/deleteRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/deleteRecord.json deleted file mode 100644 index a2dbfe4..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/deleteRecord.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.deleteRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Delete a public record authored by the community from the community's PDS. Adopted communities only. Caller must be `member` or higher in the community's `$publishers` space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communityDid", - "collection", - "rkey" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - }, - { - "name": "NotSupported" - }, - { - "name": "UpstreamFailure" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/getHealth.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/getHealth.json deleted file mode 100644 index 0122d19..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/getHealth.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.getHealth", - "defs": { - "main": { - "type": "query", - "description": "Check whether stored credentials for the community are still usable. Adopted: attempts a live session creation. Minted: verifies the signing key can be decrypted. Caller must have at least `member` in the community's `$admin` space.", - "parameters": { - "type": "params", - "required": [ - "communityDid" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "status" - ], - "properties": { - "status": { - "type": "string", - "knownValues": [ - "healthy", - "degraded", - "expired" - ] - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/list.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/list.json deleted file mode 100644 index cc8d1ae..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/list.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.list", - "defs": { - "main": { - "type": "query", - "description": "List communities where the actor has any access level in any community-owned space. Defaults to the JWT issuer when `actor` is omitted.", - "parameters": { - "type": "params", - "properties": { - "actor": { - "type": "string", - "format": "did", - "description": "DID to query (defaults to the JWT issuer)." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communities" - ], - "properties": { - "communities": { - "type": "array", - "items": { - "type": "ref", - "ref": "tools.atmo.chat.community.defs#communityView" - } - } - } - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/mint.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/mint.json deleted file mode 100644 index 1323bc0..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/mint.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.mint", - "defs": { - "main": { - "type": "procedure", - "description": "Mint a fresh did:plc for a new community. Contrail generates three P-256 keypairs (signing, contrail rotation, creator rotation), submits a genesis op to the PLC directory, and stores the signing + contrail-rotation keys encrypted. The creator's rotation key is returned once as a recovery secret and never stored.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "handle": { - "type": "string", - "description": "Optional handle to include in alsoKnownAs (as at://)." - }, - "pdsEndpoint": { - "type": "string", - "description": "Optional PDS endpoint to include as the atproto_pds service." - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communityDid", - "recoveryKey" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - }, - "recoveryKey": { - "type": "unknown", - "description": "Creator's rotation key as a private JWK. Shown once \u2014 cannot be retrieved later." - } - } - } - }, - "errors": [ - { - "name": "UpstreamFailure" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/putRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/putRecord.json deleted file mode 100644 index ead6778..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/putRecord.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.putRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Publish a public record authored by the community via the community's PDS. Adopted communities only. Caller must be `member` or higher in the community's `$publishers` space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communityDid", - "collection", - "record" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "record": { - "type": "unknown" - }, - "validate": { - "type": "boolean" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - }, - { - "name": "NotSupported" - }, - { - "name": "UpstreamFailure" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/reauth.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/reauth.json deleted file mode 100644 index a4e4fa3..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/reauth.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.reauth", - "defs": { - "main": { - "type": "procedure", - "description": "Replace the stored app password for an adopted community. Caller must have `owner` access in the community's `$admin` space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communityDid", - "appPassword" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - }, - "appPassword": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - }, - { - "name": "Unauthorized" - }, - { - "name": "NotSupported" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/create.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/create.json deleted file mode 100644 index 1b55699..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/create.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.space.create", - "defs": { - "main": { - "type": "procedure", - "description": "Create a community-owned space (group/role/channel). Caller needs `admin` or higher in the community's `$admin` space. Reserved keys (`$admin`, `$publishers`, \u2026) are rejected here.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communityDid" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - }, - "key": { - "type": "string", - "description": "Space key. Auto-generated if omitted." - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "space" - ], - "properties": { - "space": { - "type": "ref", - "ref": "tools.atmo.chat.community.defs#spaceView" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - }, - { - "name": "AlreadyExists" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/delete.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/delete.json deleted file mode 100644 index d4daadb..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/delete.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.space.delete", - "defs": { - "main": { - "type": "procedure", - "description": "Soft-delete a community-owned space. Caller must be `owner` on the space, or `admin`+ in the community's `$admin` space. Reserved spaces cannot be deleted.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/deleteRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/deleteRecord.json deleted file mode 100644 index d937e1a..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/deleteRecord.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.space.deleteRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Delete an in-space record authored by the community DID. Caller must be `admin` or higher in the target space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "collection", - "rkey" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/grant.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/grant.json deleted file mode 100644 index 19125fc..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/grant.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.space.grant", - "defs": { - "main": { - "type": "procedure", - "description": "Grant or upsert a subject's access level on a space. Caller needs `manager` or higher; the granted level cannot exceed the caller's own. A subject can be a DID (member) or another space (delegated membership).", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "subject", - "accessLevel" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "subject": { - "type": "ref", - "ref": "tools.atmo.chat.community.defs#subject" - }, - "accessLevel": { - "type": "ref", - "ref": "tools.atmo.chat.community.defs#accessLevel" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/listMembers.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/listMembers.json deleted file mode 100644 index 9544af8..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/listMembers.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.space.listMembers", - "defs": { - "main": { - "type": "query", - "description": "List members of a community-owned space. By default returns the raw access-level rows; with `flatten=true` returns the flattened DID list (delegated memberships resolved).", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "flatten": { - "type": "boolean", - "description": "If true, return the flat DID list instead of raw rows." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "rows": { - "type": "array", - "items": { - "type": "ref", - "ref": "tools.atmo.chat.community.defs#memberRow" - }, - "description": "Present when flatten=false." - }, - "members": { - "type": "array", - "items": { - "type": "ref", - "ref": "tools.atmo.chat.community.defs#flatMember" - }, - "description": "Present when flatten=true." - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/putRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/putRecord.json deleted file mode 100644 index 0c733f5..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/putRecord.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.space.putRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Write an in-space record authored by the community DID (rather than the caller). Caller must be `admin` or higher in the target space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "collection", - "record" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "record": { - "type": "unknown" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "rkey", - "authorDid", - "createdAt" - ], - "properties": { - "rkey": { - "type": "string" - }, - "authorDid": { - "type": "string", - "format": "did" - }, - "createdAt": { - "type": "integer" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/resync.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/resync.json deleted file mode 100644 index b876e57..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/resync.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.space.resync", - "defs": { - "main": { - "type": "procedure", - "description": "Manually recompute `spaces_members` for a community-owned space. Useful after a crash mid-reconcile leaves stale membership. Caller must have `admin` or higher.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/revoke.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/revoke.json deleted file mode 100644 index a319868..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/revoke.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.space.revoke", - "defs": { - "main": { - "type": "procedure", - "description": "Remove a subject's access grant from a space. Caller must have `manager` or higher and outrank the subject's current level.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "subject" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "subject": { - "type": "ref", - "ref": "tools.atmo.chat.community.defs#subject" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/setAccessLevel.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/setAccessLevel.json deleted file mode 100644 index 705cf68..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/setAccessLevel.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.space.setAccessLevel", - "defs": { - "main": { - "type": "procedure", - "description": "Change the access level of an existing grant. Caller must outrank both the old and new level.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "subject", - "accessLevel" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "subject": { - "type": "ref", - "ref": "tools.atmo.chat.community.defs#subject" - }, - "accessLevel": { - "type": "ref", - "ref": "tools.atmo.chat.community.defs#accessLevel" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/getCursor.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/getCursor.json deleted file mode 100644 index 59418d2..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/getCursor.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.getCursor", - "defs": { - "main": { - "type": "query", - "description": "Get the current cursor position", - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "time_us": { - "type": "integer" - }, - "date": { - "type": "string" - }, - "seconds_ago": { - "type": "integer" - } - } - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/getOverview.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/getOverview.json deleted file mode 100644 index e391c82..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/getOverview.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.getOverview", - "defs": { - "main": { - "type": "query", - "description": "Get an overview of all indexed collections", - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "total_records", - "collections" - ], - "properties": { - "total_records": { - "type": "integer" - }, - "collections": { - "type": "array", - "items": { - "type": "ref", - "ref": "#collectionStats" - } - } - } - } - } - }, - "collectionStats": { - "type": "object", - "required": [ - "collection", - "records", - "unique_users" - ], - "properties": { - "collection": { - "type": "string" - }, - "records": { - "type": "integer" - }, - "unique_users": { - "type": "integer" - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/getProfile.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/getProfile.json deleted file mode 100644 index 8f8ed24..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/getProfile.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.getProfile", - "defs": { - "main": { - "type": "query", - "description": "Get a user's profiles by DID or handle", - "parameters": { - "type": "params", - "required": [ - "actor" - ], - "properties": { - "actor": { - "type": "string", - "format": "at-identifier", - "description": "DID or handle of the user" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "profiles" - ], - "properties": { - "profiles": { - "type": "array", - "items": { - "type": "ref", - "ref": "#profileEntry" - } - } - } - } - } - }, - "profileEntry": { - "type": "object", - "required": [ - "did" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "handle": { - "type": "string" - }, - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "value": { - "type": "ref", - "ref": "#appBskyActorProfile" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - } - } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/create.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/create.json deleted file mode 100644 index d2ab9bc..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/create.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.invite.create", - "defs": { - "main": { - "type": "procedure", - "description": "Create an invite for a space. The service dispatches on space ownership: user-owned spaces take `kind` (default `join`); community-owned spaces take `accessLevel`. Exactly one of `kind` / `accessLevel` must be set. Returns the raw token once; only the hash is stored.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "kind": { - "type": "string", - "knownValues": [ - "join", - "read", - "read-join" - ], - "description": "For user-owned spaces. join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join." - }, - "accessLevel": { - "type": "string", - "knownValues": [ - "member", - "manager", - "admin", - "owner" - ], - "description": "For community-owned spaces. The access level granted on redemption \u2014 the creator's own level caps what they can grant." - }, - "expiresAt": { - "type": "integer", - "description": "Unix ms timestamp. Omit for no expiry." - }, - "maxUses": { - "type": "integer", - "minimum": 1 - }, - "note": { - "type": "string", - "maxLength": 500 - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "token", - "invite" - ], - "properties": { - "token": { - "type": "string", - "description": "Raw token. Shown once \u2014 cannot be retrieved later." - }, - "invite": { - "type": "ref", - "ref": "tools.atmo.chat.invite.defs#inviteView" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - }, - { - "name": "InvalidRequest" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/defs.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/defs.json deleted file mode 100644 index 0662a2d..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/defs.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.invite.defs", - "defs": { - "inviteView": { - "type": "object", - "description": "An invite row as exposed to clients. Either `kind` (user-owned space) or `accessLevel` (community-owned space) is set, never both.", - "required": [ - "tokenHash", - "spaceUri", - "createdBy", - "createdAt", - "usedCount" - ], - "properties": { - "tokenHash": { - "type": "string", - "description": "Stable identifier for list/revoke operations." - }, - "spaceUri": { - "type": "string" - }, - "kind": { - "type": "string", - "knownValues": [ - "join", - "read", - "read-join" - ], - "description": "Set for user-owned spaces. Absent for community-owned." - }, - "accessLevel": { - "type": "string", - "knownValues": [ - "member", - "manager", - "admin", - "owner" - ], - "description": "Set for community-owned spaces. Absent for user-owned." - }, - "createdBy": { - "type": "string", - "format": "did" - }, - "createdAt": { - "type": "integer", - "description": "Unix ms." - }, - "expiresAt": { - "type": "integer", - "description": "Unix ms. Omitted for no expiry." - }, - "maxUses": { - "type": "integer", - "minimum": 1 - }, - "usedCount": { - "type": "integer" - }, - "revokedAt": { - "type": "integer", - "description": "Unix ms. Omitted if not revoked." - }, - "note": { - "type": "string", - "maxLength": 500 - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/list.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/list.json deleted file mode 100644 index 08a77f1..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/list.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.invite.list", - "defs": { - "main": { - "type": "query", - "description": "List invites for a space. User-owned spaces: owner-only. Community-owned spaces: manager+.", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "includeRevoked": { - "type": "boolean", - "default": false - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "invites" - ], - "properties": { - "invites": { - "type": "array", - "items": { - "type": "ref", - "ref": "tools.atmo.chat.invite.defs#inviteView" - } - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/redeem.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/redeem.json deleted file mode 100644 index 68b4be4..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/redeem.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.invite.redeem", - "defs": { - "main": { - "type": "procedure", - "description": "Redeem an invite token. User-owned spaces: caller becomes a member. Community-owned spaces: caller is granted the invite's access level.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "token" - ], - "properties": { - "token": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "kind": { - "type": "string", - "description": "Set for user-owned spaces \u2014 echoes the invite kind consumed." - }, - "accessLevel": { - "type": "string", - "description": "Set for community-owned spaces \u2014 the level granted." - }, - "communityDid": { - "type": "string", - "format": "did", - "description": "Set for community-owned spaces." - } - } - } - }, - "errors": [ - { - "name": "InvalidInvite" - }, - { - "name": "NotFound" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/revoke.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/revoke.json deleted file mode 100644 index 0a76ec5..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/revoke.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.invite.revoke", - "defs": { - "main": { - "type": "procedure", - "description": "Revoke an invite by tokenHash. User-owned spaces: owner-only. Community-owned spaces: invite creator OR manager+ on the target space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "tokenHash" - ], - "properties": { - "spaceUri": { - "type": "string", - "description": "Optional \u2014 ownership is inferred from the invite row; required for user-owned spaces for a sanity check." - }, - "tokenHash": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/message/getRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/message/getRecord.json deleted file mode 100644 index 89722af..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/message/getRecord.json +++ /dev/null @@ -1,185 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.message.getRecord", - "defs": { - "main": { - "type": "query", - "description": "Get a single tools.atmo.chat.message record by AT URI", - "parameters": { - "type": "params", - "required": [ - "uri" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT URI of the record" - }, - "profiles": { - "type": "boolean", - "description": "Include profile + identity info keyed by DID" - }, - "spaceUri": { - "type": "string", - "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)." - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "uri", - "value", - "did", - "collection", - "rkey", - "time_us" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "value": { - "type": "ref", - "ref": "tools.atmo.chat.message#main" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "time_us": { - "type": "integer" - }, - "space": { - "type": "string", - "description": "Present when the record was read from a permissioned space; its value is the space URI." - }, - "profiles": { - "type": "array", - "items": { - "type": "ref", - "ref": "#profileEntry" - } - } - } - } - } - }, - "profileEntry": { - "type": "object", - "required": [ - "did" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "handle": { - "type": "string" - }, - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "value": { - "type": "ref", - "ref": "#appBskyActorProfile" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - } - } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/message/listRecords.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/message/listRecords.json deleted file mode 100644 index 33046f1..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/message/listRecords.json +++ /dev/null @@ -1,238 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.message.listRecords", - "defs": { - "main": { - "type": "query", - "description": "Query tools.atmo.chat.message records with filters", - "parameters": { - "type": "params", - "properties": { - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 - }, - "cursor": { - "type": "string" - }, - "actor": { - "type": "string", - "format": "at-identifier", - "description": "Filter by DID or handle (triggers on-demand backfill)" - }, - "profiles": { - "type": "boolean", - "description": "Include profile + identity info keyed by DID" - }, - "spaceUri": { - "type": "string", - "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." - }, - "byUser": { - "type": "string", - "format": "did", - "description": "Only used with spaceUri \u2014 filter to records authored by this DID." - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." - }, - "createdAtMin": { - "type": "string", - "description": "Minimum value for createdAt" - }, - "createdAtMax": { - "type": "string", - "description": "Maximum value for createdAt" - }, - "sort": { - "type": "string", - "knownValues": [ - "createdAt" - ], - "description": "Field to sort by (default: time_us)" - }, - "order": { - "type": "string", - "knownValues": [ - "asc", - "desc" - ], - "description": "Sort direction (default: desc for dates/numbers/counts, asc for strings)" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "records" - ], - "properties": { - "records": { - "type": "array", - "items": { - "type": "ref", - "ref": "#record" - } - }, - "cursor": { - "type": "string" - }, - "profiles": { - "type": "array", - "items": { - "type": "ref", - "ref": "#profileEntry" - } - } - } - } - } - }, - "record": { - "type": "object", - "required": [ - "uri", - "cid", - "value", - "did", - "collection", - "rkey", - "time_us" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "value": { - "type": "ref", - "ref": "tools.atmo.chat.message#main" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "time_us": { - "type": "integer" - }, - "space": { - "type": "string", - "description": "Present when the record was read from a permissioned space; its value is the space URI." - } - } - }, - "profileEntry": { - "type": "object", - "required": [ - "did" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "handle": { - "type": "string" - }, - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "value": { - "type": "ref", - "ref": "#appBskyActorProfile" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - } - } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/message/watchRecords.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/message/watchRecords.json deleted file mode 100644 index 28e476e..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/message/watchRecords.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.message.watchRecords", - "defs": { - "main": { - "type": "query", - "description": "Subscribe to a live tools.atmo.chat.message query. Returns Server-Sent Events: `snapshot.start`, `snapshot.record` (N of), `snapshot.end`, then `record.created` / `record.deleted` as records enter/leave the result, plus a final `member.removed` if the caller loses access mid-stream. v1 requires spaceUri; cross-space watch is deferred.", - "parameters": { - "type": "params", - "properties": { - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 - }, - "cursor": { - "type": "string" - }, - "actor": { - "type": "string", - "format": "at-identifier", - "description": "Filter by DID or handle (triggers on-demand backfill)" - }, - "profiles": { - "type": "boolean", - "description": "Include profile + identity info keyed by DID" - }, - "spaceUri": { - "type": "string", - "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." - }, - "byUser": { - "type": "string", - "format": "did", - "description": "Only used with spaceUri \u2014 filter to records authored by this DID." - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." - }, - "createdAtMin": { - "type": "string", - "description": "Minimum value for createdAt" - }, - "createdAtMax": { - "type": "string", - "description": "Maximum value for createdAt" - }, - "sort": { - "type": "string", - "knownValues": [ - "createdAt" - ], - "description": "Field to sort by (default: time_us)" - }, - "order": { - "type": "string", - "knownValues": [ - "asc", - "desc" - ], - "description": "Sort direction (default: desc for dates/numbers/counts, asc for strings)" - } - } - }, - "output": { - "encoding": "text/event-stream", - "schema": { - "type": "object", - "properties": {} - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "AuthRequired" - }, - { - "name": "Forbidden" - }, - { - "name": "NotFound" - }, - { - "name": "NotSupported" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/notifyOfUpdate.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/notifyOfUpdate.json deleted file mode 100644 index 7dc40fa..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/notifyOfUpdate.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.notifyOfUpdate", - "defs": { - "main": { - "type": "procedure", - "description": "Notify of a record change for immediate indexing. Fetches the record from the user's PDS and indexes (or deletes) it.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "Single AT URI to fetch and index" - }, - "uris": { - "type": "array", - "items": { - "type": "string", - "format": "at-uri" - }, - "maxLength": 25, - "description": "Batch of AT URIs to fetch and index (max 25)" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "indexed", - "deleted" - ], - "properties": { - "indexed": { - "type": "integer", - "description": "Number of records created or updated" - }, - "deleted": { - "type": "integer", - "description": "Number of records deleted (not found on PDS)" - }, - "errors": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Errors for individual URIs that could not be processed" - } - } - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/permissionSet.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/permissionSet.json deleted file mode 100644 index 89a6a5a..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/permissionSet.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.permissionSet", - "defs": { - "main": { - "type": "permission-set", - "title": "tools.atmo.chat", - "description": "All XRPC methods exposed by the tools.atmo.chat service.", - "permissions": [ - { - "type": "permission", - "resource": "rpc", - "aud": "*", - "lxm": [ - "tools.atmo.chat.channel.getRecord", - "tools.atmo.chat.channel.listRecords", - "tools.atmo.chat.channel.watchRecords", - "tools.atmo.chat.community.adopt", - "tools.atmo.chat.community.delete", - "tools.atmo.chat.community.deleteRecord", - "tools.atmo.chat.community.getHealth", - "tools.atmo.chat.community.list", - "tools.atmo.chat.community.mint", - "tools.atmo.chat.community.putRecord", - "tools.atmo.chat.community.reauth", - "tools.atmo.chat.community.space.create", - "tools.atmo.chat.community.space.delete", - "tools.atmo.chat.community.space.deleteRecord", - "tools.atmo.chat.community.space.grant", - "tools.atmo.chat.community.space.listMembers", - "tools.atmo.chat.community.space.putRecord", - "tools.atmo.chat.community.space.resync", - "tools.atmo.chat.community.space.revoke", - "tools.atmo.chat.community.space.setAccessLevel", - "tools.atmo.chat.getCursor", - "tools.atmo.chat.getOverview", - "tools.atmo.chat.getProfile", - "tools.atmo.chat.invite.create", - "tools.atmo.chat.invite.list", - "tools.atmo.chat.invite.redeem", - "tools.atmo.chat.invite.revoke", - "tools.atmo.chat.message.getRecord", - "tools.atmo.chat.message.listRecords", - "tools.atmo.chat.message.watchRecords", - "tools.atmo.chat.notifyOfUpdate", - "tools.atmo.chat.realtime.subscribe", - "tools.atmo.chat.realtime.ticket", - "tools.atmo.chat.server.getRecord", - "tools.atmo.chat.server.listRecords", - "tools.atmo.chat.server.watchRecords", - "tools.atmo.chat.space.addMember", - "tools.atmo.chat.space.createSpace", - "tools.atmo.chat.space.deleteRecord", - "tools.atmo.chat.space.getBlob", - "tools.atmo.chat.space.getRecord", - "tools.atmo.chat.space.getSpace", - "tools.atmo.chat.space.leaveSpace", - "tools.atmo.chat.space.listBlobs", - "tools.atmo.chat.space.listMembers", - "tools.atmo.chat.space.listRecords", - "tools.atmo.chat.space.listSpaces", - "tools.atmo.chat.space.putRecord", - "tools.atmo.chat.space.removeMember", - "tools.atmo.chat.space.uploadBlob", - "tools.atmo.chat.spaceExt.whoami" - ] - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/realtime/subscribe.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/realtime/subscribe.json deleted file mode 100644 index 0e2b783..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/realtime/subscribe.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.realtime.subscribe", - "defs": { - "main": { - "type": "query", - "description": "Open a streaming subscription. Responds with Server-Sent Events by default, or upgrades to WebSocket when the request carries `Upgrade: websocket`. Auth via `?ticket=` (browser) or `Authorization: Bearer ` (server-side). The stream closes with a final `member.removed` event if the caller loses access mid-stream.", - "parameters": { - "type": "params", - "properties": { - "ticket": { - "type": "string", - "description": "Subscription ticket from `.ticket`. Required if no Authorization header." - }, - "topic": { - "type": "string", - "description": "Optional: restrict the stream to a single topic (must be in the ticket). Required on the JWT path." - }, - "collection": { - "type": "string", - "format": "nsid", - "description": "Optional: drop `record.*` events whose collection doesn't match." - } - } - }, - "errors": [ - { - "name": "AuthRequired" - }, - { - "name": "Forbidden" - }, - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/realtime/ticket.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/realtime/ticket.json deleted file mode 100644 index 6f5d8ba..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/realtime/ticket.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.realtime.ticket", - "defs": { - "main": { - "type": "procedure", - "description": "Mint a short-lived subscription ticket for the given topic. The caller's access is checked and \u2014 for `community:` topics \u2014 expanded to the concrete list of `space:` topics the caller can see. Server-side callers may skip tickets and use their JWT directly on `.subscribe`.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "topic" - ], - "properties": { - "topic": { - "type": "string", - "description": "Requested topic. One of `space:`, `community:`, `actor:` (self-only in v1)." - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ticket", - "topics", - "expiresAt" - ], - "properties": { - "ticket": { - "type": "string", - "description": "HMAC-signed opaque token. Passed as `?ticket=` on `.subscribe`." - }, - "topics": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Concrete delivery topics authorized by this ticket." - }, - "expiresAt": { - "type": "integer", - "description": "Unix ms." - } - } - } - }, - "errors": [ - { - "name": "Forbidden" - }, - { - "name": "NotFound" - }, - { - "name": "NotSupported" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/server/getRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/server/getRecord.json deleted file mode 100644 index dfdb92e..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/server/getRecord.json +++ /dev/null @@ -1,185 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.server.getRecord", - "defs": { - "main": { - "type": "query", - "description": "Get a single tools.atmo.chat.server record by AT URI", - "parameters": { - "type": "params", - "required": [ - "uri" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT URI of the record" - }, - "profiles": { - "type": "boolean", - "description": "Include profile + identity info keyed by DID" - }, - "spaceUri": { - "type": "string", - "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)." - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "uri", - "value", - "did", - "collection", - "rkey", - "time_us" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "value": { - "type": "ref", - "ref": "tools.atmo.chat.server#main" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "time_us": { - "type": "integer" - }, - "space": { - "type": "string", - "description": "Present when the record was read from a permissioned space; its value is the space URI." - }, - "profiles": { - "type": "array", - "items": { - "type": "ref", - "ref": "#profileEntry" - } - } - } - } - } - }, - "profileEntry": { - "type": "object", - "required": [ - "did" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "handle": { - "type": "string" - }, - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "value": { - "type": "ref", - "ref": "#appBskyActorProfile" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - } - } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/server/listRecords.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/server/listRecords.json deleted file mode 100644 index 52619c1..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/server/listRecords.json +++ /dev/null @@ -1,234 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.server.listRecords", - "defs": { - "main": { - "type": "query", - "description": "Query tools.atmo.chat.server records with filters", - "parameters": { - "type": "params", - "properties": { - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 - }, - "cursor": { - "type": "string" - }, - "actor": { - "type": "string", - "format": "at-identifier", - "description": "Filter by DID or handle (triggers on-demand backfill)" - }, - "profiles": { - "type": "boolean", - "description": "Include profile + identity info keyed by DID" - }, - "spaceUri": { - "type": "string", - "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." - }, - "byUser": { - "type": "string", - "format": "did", - "description": "Only used with spaceUri \u2014 filter to records authored by this DID." - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." - }, - "communityDid": { - "type": "string", - "description": "Filter by communityDid" - }, - "sort": { - "type": "string", - "knownValues": [ - "communityDid" - ], - "description": "Field to sort by (default: time_us)" - }, - "order": { - "type": "string", - "knownValues": [ - "asc", - "desc" - ], - "description": "Sort direction (default: desc for dates/numbers/counts, asc for strings)" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "records" - ], - "properties": { - "records": { - "type": "array", - "items": { - "type": "ref", - "ref": "#record" - } - }, - "cursor": { - "type": "string" - }, - "profiles": { - "type": "array", - "items": { - "type": "ref", - "ref": "#profileEntry" - } - } - } - } - } - }, - "record": { - "type": "object", - "required": [ - "uri", - "cid", - "value", - "did", - "collection", - "rkey", - "time_us" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "value": { - "type": "ref", - "ref": "tools.atmo.chat.server#main" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "time_us": { - "type": "integer" - }, - "space": { - "type": "string", - "description": "Present when the record was read from a permissioned space; its value is the space URI." - } - } - }, - "profileEntry": { - "type": "object", - "required": [ - "did" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "handle": { - "type": "string" - }, - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "value": { - "type": "ref", - "ref": "#appBskyActorProfile" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - } - } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/server/watchRecords.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/server/watchRecords.json deleted file mode 100644 index aa418fc..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/server/watchRecords.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.server.watchRecords", - "defs": { - "main": { - "type": "query", - "description": "Subscribe to a live tools.atmo.chat.server query. Returns Server-Sent Events: `snapshot.start`, `snapshot.record` (N of), `snapshot.end`, then `record.created` / `record.deleted` as records enter/leave the result, plus a final `member.removed` if the caller loses access mid-stream. v1 requires spaceUri; cross-space watch is deferred.", - "parameters": { - "type": "params", - "properties": { - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 - }, - "cursor": { - "type": "string" - }, - "actor": { - "type": "string", - "format": "at-identifier", - "description": "Filter by DID or handle (triggers on-demand backfill)" - }, - "profiles": { - "type": "boolean", - "description": "Include profile + identity info keyed by DID" - }, - "spaceUri": { - "type": "string", - "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." - }, - "byUser": { - "type": "string", - "format": "did", - "description": "Only used with spaceUri \u2014 filter to records authored by this DID." - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." - }, - "communityDid": { - "type": "string", - "description": "Filter by communityDid" - }, - "sort": { - "type": "string", - "knownValues": [ - "communityDid" - ], - "description": "Field to sort by (default: time_us)" - }, - "order": { - "type": "string", - "knownValues": [ - "asc", - "desc" - ], - "description": "Sort direction (default: desc for dates/numbers/counts, asc for strings)" - } - } - }, - "output": { - "encoding": "text/event-stream", - "schema": { - "type": "object", - "properties": {} - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "AuthRequired" - }, - { - "name": "Forbidden" - }, - { - "name": "NotFound" - }, - { - "name": "NotSupported" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/addMember.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/addMember.json deleted file mode 100644 index 146ed6e..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/addMember.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.addMember", - "defs": { - "main": { - "type": "procedure", - "description": "Add a member to a space. Caller must be the space owner.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "did" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "did": { - "type": "string", - "format": "did" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/createSpace.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/createSpace.json deleted file mode 100644 index 7894679..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/createSpace.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.createSpace", - "defs": { - "main": { - "type": "procedure", - "description": "Create a new space owned by the JWT issuer. The caller is added as an owner-perm member.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "format": "nsid", - "description": "Space type NSID. Defaults to the service's configured type." - }, - "key": { - "type": "string", - "description": "Space key. Auto-generated (TID) if omitted." - }, - "appPolicyRef": { - "type": "string", - "format": "at-uri" - }, - "appPolicy": { - "type": "ref", - "ref": "tools.atmo.chat.space.defs#appPolicy" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "space" - ], - "properties": { - "space": { - "type": "ref", - "ref": "tools.atmo.chat.space.defs#spaceView" - } - } - } - }, - "errors": [ - { - "name": "AlreadyExists" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/defs.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/defs.json deleted file mode 100644 index 33f9650..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/defs.json +++ /dev/null @@ -1,207 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.defs", - "description": "Shared types for permissioned-space XRPC methods.", - "defs": { - "spaceView": { - "type": "object", - "required": [ - "uri", - "ownerDid", - "type", - "key", - "serviceDid", - "createdAt" - ], - "properties": { - "uri": { - "type": "string" - }, - "ownerDid": { - "type": "string", - "format": "did" - }, - "type": { - "type": "string", - "format": "nsid" - }, - "key": { - "type": "string" - }, - "serviceDid": { - "type": "string" - }, - "appPolicyRef": { - "type": "string", - "format": "at-uri" - }, - "createdAt": { - "type": "integer" - }, - "appPolicy": { - "type": "ref", - "ref": "#appPolicy", - "description": "Owner-only" - } - } - }, - "memberView": { - "type": "object", - "required": [ - "did", - "addedAt" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "addedAt": { - "type": "integer" - }, - "addedBy": { - "type": "string", - "format": "did" - } - } - }, - "recordView": { - "type": "object", - "required": [ - "spaceUri", - "collection", - "authorDid", - "rkey", - "record", - "createdAt" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "authorDid": { - "type": "string", - "format": "did" - }, - "rkey": { - "type": "string" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "record": { - "type": "unknown" - }, - "createdAt": { - "type": "integer" - } - } - }, - "appPolicy": { - "type": "object", - "required": [ - "mode", - "apps" - ], - "properties": { - "mode": { - "type": "string", - "knownValues": [ - "allow", - "deny" - ], - "description": "'allow' = default-allow with apps[] as denylist; 'deny' = default-deny with apps[] as allowlist." - }, - "apps": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "blobInfo": { - "type": "object", - "required": [ - "cid", - "mimeType", - "size", - "authorDid", - "createdAt" - ], - "properties": { - "cid": { - "type": "string", - "format": "cid" - }, - "mimeType": { - "type": "string" - }, - "size": { - "type": "integer" - }, - "authorDid": { - "type": "string", - "format": "did" - }, - "createdAt": { - "type": "integer" - } - } - }, - "inviteView": { - "type": "object", - "required": [ - "tokenHash", - "spaceUri", - "kind", - "usedCount", - "createdBy", - "createdAt" - ], - "properties": { - "tokenHash": { - "type": "string" - }, - "spaceUri": { - "type": "string" - }, - "kind": { - "type": "string", - "knownValues": [ - "join", - "read", - "read-join" - ] - }, - "expiresAt": { - "type": "integer" - }, - "maxUses": { - "type": "integer" - }, - "usedCount": { - "type": "integer" - }, - "createdBy": { - "type": "string", - "format": "did" - }, - "createdAt": { - "type": "integer" - }, - "revokedAt": { - "type": "integer" - }, - "note": { - "type": "string" - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/deleteRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/deleteRecord.json deleted file mode 100644 index 0bcab67..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/deleteRecord.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.deleteRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Delete a record from a space. Callers can delete their own records; the space owner can delete any record (via a separate admin path).", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "collection", - "rkey" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/getBlob.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/getBlob.json deleted file mode 100644 index cbda6f2..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/getBlob.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.getBlob", - "defs": { - "main": { - "type": "query", - "description": "Read a blob from a space. Requires read access via a service-auth JWT or a read-grant invite token.", - "parameters": { - "type": "params", - "required": [ - "spaceUri", - "cid" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access." - } - } - }, - "output": { - "encoding": "*/*" - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/getRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/getRecord.json deleted file mode 100644 index de6b8f9..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/getRecord.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.getRecord", - "defs": { - "main": { - "type": "query", - "description": "Get a single record from a space.", - "parameters": { - "type": "params", - "required": [ - "spaceUri", - "collection", - "author", - "rkey" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "author": { - "type": "string", - "format": "did" - }, - "rkey": { - "type": "string" - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "record" - ], - "properties": { - "record": { - "type": "ref", - "ref": "tools.atmo.chat.space.defs#recordView" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/getSpace.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/getSpace.json deleted file mode 100644 index d62d6a8..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/getSpace.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.getSpace", - "defs": { - "main": { - "type": "query", - "description": "Get metadata for a single space. Caller must be a member, the owner, or hold a read-grant invite token.", - "parameters": { - "type": "params", - "required": [ - "uri" - ], - "properties": { - "uri": { - "type": "string" - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "space" - ], - "properties": { - "space": { - "type": "ref", - "ref": "tools.atmo.chat.space.defs#spaceView" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/leaveSpace.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/leaveSpace.json deleted file mode 100644 index 591dabf..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/leaveSpace.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.leaveSpace", - "defs": { - "main": { - "type": "procedure", - "description": "Remove the caller from a space's member list. The owner cannot leave \u2014 they must delete the space instead.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "InvalidRequest", - "description": "Raised if the caller is the space owner." - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listBlobs.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listBlobs.json deleted file mode 100644 index 4c23c81..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listBlobs.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.listBlobs", - "defs": { - "main": { - "type": "query", - "description": "List blob metadata for a space. Members only.", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "byUser": { - "type": "string", - "format": "did", - "description": "Only blobs uploaded by this DID." - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 - }, - "cursor": { - "type": "string" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "blobs" - ], - "properties": { - "blobs": { - "type": "array", - "items": { - "type": "ref", - "ref": "tools.atmo.chat.space.defs#blobInfo" - } - }, - "cursor": { - "type": "string" - } - } - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listMembers.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listMembers.json deleted file mode 100644 index bb44d3a..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listMembers.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.listMembers", - "defs": { - "main": { - "type": "query", - "description": "List members of a space. Caller must be a member or the owner.", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "members" - ], - "properties": { - "members": { - "type": "array", - "items": { - "type": "ref", - "ref": "tools.atmo.chat.space.defs#memberView" - } - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listRecords.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listRecords.json deleted file mode 100644 index 7a71886..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listRecords.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.listRecords", - "defs": { - "main": { - "type": "query", - "description": "List records of a given collection within a space. Access is governed by the space's collection policy.", - "parameters": { - "type": "params", - "required": [ - "spaceUri", - "collection" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "byUser": { - "type": "string", - "format": "did", - "description": "Only return records authored by this DID." - }, - "cursor": { - "type": "string" - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "records" - ], - "properties": { - "records": { - "type": "array", - "items": { - "type": "ref", - "ref": "tools.atmo.chat.space.defs#recordView" - } - }, - "cursor": { - "type": "string" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listSpaces.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listSpaces.json deleted file mode 100644 index 2bedc04..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listSpaces.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.listSpaces", - "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. When scope='member', the optional 'owner' param narrows to spaces owned by that DID \u2014 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 - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaces" - ], - "properties": { - "spaces": { - "type": "array", - "items": { - "type": "ref", - "ref": "tools.atmo.chat.space.defs#spaceView" - } - }, - "cursor": { - "type": "string" - } - } - } - } - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/putRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/putRecord.json deleted file mode 100644 index 2e12570..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/putRecord.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.putRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Write a record into a space. The author is always the JWT issuer. If rkey is omitted, a TID is generated.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "collection", - "record" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "record": { - "type": "unknown" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "rkey", - "authorDid", - "createdAt" - ], - "properties": { - "rkey": { - "type": "string" - }, - "authorDid": { - "type": "string", - "format": "did" - }, - "createdAt": { - "type": "integer" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/removeMember.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/removeMember.json deleted file mode 100644 index 92f6924..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/removeMember.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.removeMember", - "defs": { - "main": { - "type": "procedure", - "description": "Remove a member from a space. Owner only. Cannot remove the owner.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "did" - ], - "properties": { - "spaceUri": { - "type": "string" - }, - "did": { - "type": "string", - "format": "did" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - }, - { - "name": "InvalidRequest" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/uploadBlob.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/uploadBlob.json deleted file mode 100644 index c70c567..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/space/uploadBlob.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.space.uploadBlob", - "defs": { - "main": { - "type": "procedure", - "description": "Upload a blob into a space. Returns a standard atproto BlobRef that records in this space can then reference. The record will be rejected at putRecord time if it references a blob that was not uploaded to the same space.", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string" - } - } - }, - "input": { - "encoding": "*/*" - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "blob" - ], - "properties": { - "blob": { - "type": "blob" - } - } - } - }, - "errors": [ - { - "name": "Forbidden" - }, - { - "name": "BlobTooLarge" - }, - { - "name": "InvalidMimeType" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/spaceExt/whoami.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/spaceExt/whoami.json deleted file mode 100644 index 4e7744e..0000000 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/spaceExt/whoami.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.spaceExt.whoami", - "defs": { - "main": { - "type": "query", - "description": "Caller's relationship to a space. For user-owned spaces membership is binary (`isMember`). For community-owned spaces `accessLevel` is also returned, resolved through the community's access-level ladder (including delegated grants).", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "isOwner", - "isMember" - ], - "properties": { - "isOwner": { - "type": "boolean" - }, - "isMember": { - "type": "boolean" - }, - "accessLevel": { - "type": "string", - "knownValues": [ - "member", - "manager", - "admin", - "owner" - ], - "description": "Only set for community-owned spaces. Null when the caller has no resolvable access." - } - } - } - }, - "errors": [ - { - "name": "NotFound" - } - ] - } - } -} diff --git a/apps/group-chat/lexicons/pulled/README.md b/apps/group-chat/lexicons/pulled/README.md deleted file mode 100644 index c6fc63b..0000000 --- a/apps/group-chat/lexicons/pulled/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# lexicon sources - -this directory contains lexicon documents pulled from the following sources: - -- atproto (nsids: app.bsky.actor.profile, tools.atmo.chat.channel, tools.atmo.chat.message, tools.atmo.chat.server) diff --git a/apps/group-chat/lexicons/pulled/app/bsky/actor/profile.json b/apps/group-chat/lexicons/pulled/app/bsky/actor/profile.json deleted file mode 100644 index 1d22cc4..0000000 --- a/apps/group-chat/lexicons/pulled/app/bsky/actor/profile.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "id": "app.bsky.actor.profile", - "defs": { - "main": { - "key": "literal:self", - "type": "record", - "record": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": ["image/png", "image/jpeg"], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": ["image/png", "image/jpeg"], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": ["com.atproto.label.defs#selfLabels"], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } - }, - "description": "A declaration of a Bluesky account profile." - } - }, - "$type": "com.atproto.lexicon.schema", - "lexicon": 1 -} diff --git a/apps/group-chat/package.json b/apps/group-chat/package.json deleted file mode 100644 index a773f5f..0000000 --- a/apps/group-chat/package.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "name": "sveltekit-group-chat", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite dev", - "build": "tsx scripts/generate.ts && lex-cli generate && vite build && tsx scripts/append-scheduled.ts", - "generate": "tsx scripts/generate.ts", - "generate:pull": "tsx scripts/generate.ts && lex-cli pull && tsx scripts/generate.ts && lex-cli pull && lex-cli generate", - "sync": "tsx scripts/sync.ts", - "sync:remote": "tsx scripts/sync.ts --remote", - "preview": "vite preview", - "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "format": "prettier --write .", - "lint": "prettier --check . && eslint .", - "env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts", - "env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts", - "env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts", - "tunnel": "npx tsx src/lib/atproto/scripts/tunnel.ts" - }, - "devDependencies": { - "@atcute/atproto": "^3.1.10", - "@atcute/bluesky": "^3.3.0", - "@atcute/client": "^4.2.1", - "@atcute/identity-resolver": "^1.2.2", - "@atcute/lex-cli": "^2.5.3", - "@atcute/lexicon-doc": "^2.1.2", - "@atcute/lexicons": "^1.2.9", - "@atcute/oauth-node-client": "^1.1.0", - "@atcute/tid": "^1.1.2", - "@cloudflare/workers-types": "^4.20260317.1", - "@eslint/compat": "^2.0.3", - "@eslint/js": "^10.0.1", - "@sveltejs/adapter-cloudflare": "^7.2.8", - "@sveltejs/kit": "^2.55.0", - "@sveltejs/vite-plugin-svelte": "^7.0.0", - "@tailwindcss/forms": "^0.5.11", - "@tailwindcss/vite": "^4.2.2", - "bits-ui": "^2.16.4", - "eslint": "^10.1.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-svelte": "^3.16.0", - "globals": "^17.4.0", - "prettier": "^3.8.1", - "prettier-plugin-svelte": "^3.5.1", - "prettier-plugin-tailwindcss": "^0.7.2", - "svelte": "^5.55.0", - "svelte-check": "^4.4.5", - "tailwindcss": "^4.2.2", - "tsx": "^4.21.0", - "typescript": "^6.0.2", - "typescript-eslint": "^8.57.2", - "vite": "^8.0.3", - "wrangler": "^4.78.0" - }, - "license": "MIT", - "dependencies": { - "@atcute/jetstream": "^1.1.2", - "@atmo-dev/contrail": "workspace:*", - "@atmo-dev/contrail-community": "workspace:*", - "@atmo-dev/contrail-sync": "workspace:*", - "@atmo-dev/contrail-lexicons": "workspace:*", - "@foxui/core": "^0.9.1", - "@foxui/social": "^0.8.10", - "@foxui/time": "^0.8.5", - "valibot": "^1.3.1" - } -} diff --git a/apps/group-chat/pnpm-lock.yaml b/apps/group-chat/pnpm-lock.yaml deleted file mode 100644 index 35f6a60..0000000 --- a/apps/group-chat/pnpm-lock.yaml +++ /dev/null @@ -1,4750 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@atcute/jetstream': - specifier: ^1.1.2 - version: 1.1.2 - '@foxui/core': - specifier: ^0.8.2 - version: 0.8.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(tailwindcss@4.2.2) - '@foxui/social': - specifier: ^0.8.4 - version: 0.8.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))(@tiptap/extensions@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))(highlight.js@11.11.1)(svelte@5.55.0)(tailwindcss@4.2.2) - '@foxui/time': - specifier: ^0.8.2 - version: 0.8.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(tailwindcss@4.2.2) - contrail: - specifier: github:flo-bit/contrail - version: https://codeload.github.com/flo-bit/contrail/tar.gz/ee24d4faa5d5e1515fa71398bca035b191880dbf(@atcute/identity@1.1.4) - valibot: - specifier: ^1.3.1 - version: 1.3.1(typescript@6.0.2) - devDependencies: - '@atcute/atproto': - specifier: ^3.1.10 - version: 3.1.10 - '@atcute/bluesky': - specifier: ^3.3.0 - version: 3.3.0 - '@atcute/client': - specifier: ^4.2.1 - version: 4.2.1 - '@atcute/identity-resolver': - specifier: ^1.2.2 - version: 1.2.2(@atcute/identity@1.1.4) - '@atcute/lex-cli': - specifier: ^2.5.3 - version: 2.5.3 - '@atcute/lexicon-doc': - specifier: ^2.1.2 - version: 2.1.2 - '@atcute/lexicons': - specifier: ^1.2.9 - version: 1.2.9 - '@atcute/oauth-node-client': - specifier: ^1.1.0 - version: 1.1.0 - '@atcute/tid': - specifier: ^1.1.2 - version: 1.1.2 - '@cloudflare/workers-types': - specifier: ^4.20260317.1 - version: 4.20260317.1 - '@eslint/compat': - specifier: ^2.0.3 - version: 2.0.3(eslint@10.1.0(jiti@2.6.1)) - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.1.0(jiti@2.6.1)) - '@sveltejs/adapter-cloudflare': - specifier: ^7.2.8 - version: 7.2.8(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(wrangler@4.78.0(@cloudflare/workers-types@4.20260317.1)) - '@sveltejs/kit': - specifier: ^2.55.0 - version: 2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) - '@sveltejs/vite-plugin-svelte': - specifier: ^7.0.0 - version: 7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) - '@tailwindcss/forms': - specifier: ^0.5.11 - version: 0.5.11(tailwindcss@4.2.2) - '@tailwindcss/vite': - specifier: ^4.2.2 - version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) - bits-ui: - specifier: ^2.16.4 - version: 2.16.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0) - eslint: - specifier: ^10.1.0 - version: 10.1.0(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@10.1.0(jiti@2.6.1)) - eslint-plugin-svelte: - specifier: ^3.16.0 - version: 3.16.0(eslint@10.1.0(jiti@2.6.1))(svelte@5.55.0) - globals: - specifier: ^17.4.0 - version: 17.4.0 - prettier: - specifier: ^3.8.1 - version: 3.8.1 - prettier-plugin-svelte: - specifier: ^3.5.1 - version: 3.5.1(prettier@3.8.1)(svelte@5.55.0) - prettier-plugin-tailwindcss: - specifier: ^0.7.2 - version: 0.7.2(prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.55.0))(prettier@3.8.1) - svelte: - specifier: ^5.55.0 - version: 5.55.0 - svelte-check: - specifier: ^4.4.5 - version: 4.4.5(picomatch@4.0.4)(svelte@5.55.0)(typescript@6.0.2) - tailwindcss: - specifier: ^4.2.2 - version: 4.2.2 - tsx: - specifier: ^4.21.0 - version: 4.21.0 - typescript: - specifier: ^6.0.2 - version: 6.0.2 - typescript-eslint: - specifier: ^8.57.2 - version: 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - vite: - specifier: ^8.0.3 - version: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) - wrangler: - specifier: ^4.78.0 - version: 4.78.0(@cloudflare/workers-types@4.20260317.1) - -packages: - - '@atcute/atproto@3.1.10': - resolution: {integrity: sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ==, tarball: https://registry.npmjs.org/@atcute/atproto/-/atproto-3.1.10.tgz} - - '@atcute/bluesky-richtext-segmenter@3.0.0': - resolution: {integrity: sha512-NhZTUKtFpeBBbILwAcxj5u4RobIoHOmGw3CAaaEFNebKYSvmTecrXJ7XufHw5DFOUdr8SiKXQVRQxGAxulMNWg==, tarball: https://registry.npmjs.org/@atcute/bluesky-richtext-segmenter/-/bluesky-richtext-segmenter-3.0.0.tgz} - - '@atcute/bluesky@3.3.0': - resolution: {integrity: sha512-TrLnlxuD6F/D2ZYzJ3aCiRD0yiFuhmVsd6oULNzzr8V9Xzlufg0yxkRiGmbMiF2iI508y/MFi6vzo625301c5A==, tarball: https://registry.npmjs.org/@atcute/bluesky/-/bluesky-3.3.0.tgz} - - '@atcute/car@5.1.1': - resolution: {integrity: sha512-MeRUJNXYgAHrJZw7mMoZJb9xIqv3LZLQw90rRRAVAo8SGNdICwyqe6Bf2LGesX73QM04MBuYO6Kqhvold3TFfg==, tarball: https://registry.npmjs.org/@atcute/car/-/car-5.1.1.tgz} - - '@atcute/cbor@2.3.2': - resolution: {integrity: sha512-xP2SORSau/VVI00x2V4BjwIkHr6EQ7l/MXEOPaa4LGYtePFc4gnD4L1yN10dT5NEuUnvGEuCh6arLB7gz1smVQ==, tarball: https://registry.npmjs.org/@atcute/cbor/-/cbor-2.3.2.tgz} - - '@atcute/cid@2.4.1': - resolution: {integrity: sha512-bwhna69RCv7yetXudtj+2qrMPYvhhIQqvJz6YUpUS98v7OdF3X2dnye9Nig2NDrklZcuyOsu7sQo7GOykJXRLQ==, tarball: https://registry.npmjs.org/@atcute/cid/-/cid-2.4.1.tgz} - - '@atcute/client@4.2.1': - resolution: {integrity: sha512-ZBFM2pW075JtgGFu5g7HHZBecrClhlcNH8GVP9Zz1aViWR+cjjBsTpeE63rJs+FCOHFYlirUyo5L8SGZ4kMINw==, tarball: https://registry.npmjs.org/@atcute/client/-/client-4.2.1.tgz} - - '@atcute/crypto@2.4.1': - resolution: {integrity: sha512-tJ3Pi/XYcAsABKtqSlSOTKfO5YiQ4XdqlTuPS8HiRZSezOPcXBFFzAFWpSIJPURbVPFQL3LLrrK0Ea24wl5qeQ==, tarball: https://registry.npmjs.org/@atcute/crypto/-/crypto-2.4.1.tgz} - - '@atcute/identity-resolver@1.2.2': - resolution: {integrity: sha512-eUh/UH4bFvuXS0X7epYCeJC/kj4rbBXfSRumLEH4smMVwNOgTo7cL/0Srty+P/qVPoZEyXdfEbS0PHJyzoXmHw==, tarball: https://registry.npmjs.org/@atcute/identity-resolver/-/identity-resolver-1.2.2.tgz} - peerDependencies: - '@atcute/identity': ^1.0.0 - - '@atcute/identity@1.1.4': - resolution: {integrity: sha512-RCw1IqflfuSYCxK5m0lZCm0UnvIzcUnuhngiBhJEJb9a9Mc2SEf1xP3H8N5r8pvEH1LoAYd6/zrvCNU+uy9esw==, tarball: https://registry.npmjs.org/@atcute/identity/-/identity-1.1.4.tgz} - - '@atcute/jetstream@1.1.2': - resolution: {integrity: sha512-u6p/h2xppp7LE6W/9xErAJ6frfN60s8adZuCKtfAaaBBiiYbb1CfpzN8Uc+2qtJZNorqGvuuDb5572Jmh7yHBQ==, tarball: https://registry.npmjs.org/@atcute/jetstream/-/jetstream-1.1.2.tgz} - - '@atcute/lex-cli@2.5.3': - resolution: {integrity: sha512-829rvezMOfRkJQRKvupNT8TWT/YYffJ2QsB80D9aPjkXSogrETZA7xZcPaMZBXg+mJaVbLO9S4ThPQmlF0L4UQ==, tarball: https://registry.npmjs.org/@atcute/lex-cli/-/lex-cli-2.5.3.tgz} - hasBin: true - - '@atcute/lexicon-doc@2.1.2': - resolution: {integrity: sha512-jTLcOka7b8BIn2SnIZm2m7l6unlJ0gpgW1MnRpSqNbly/AvyRUR/GREduh/QmjT4SGasDm8vdhrM0kOSPFpDLQ==, tarball: https://registry.npmjs.org/@atcute/lexicon-doc/-/lexicon-doc-2.1.2.tgz} - - '@atcute/lexicon-resolver@0.1.6': - resolution: {integrity: sha512-wJC/ChmpP7k+ywpOd07CMvioXjIGaFpF3bDwXLi/086LYjSWHOvtW6pyC+mqP5wLhjyH2hn4wmi77Buew1l1aw==, tarball: https://registry.npmjs.org/@atcute/lexicon-resolver/-/lexicon-resolver-0.1.6.tgz} - peerDependencies: - '@atcute/identity': ^1.1.0 - '@atcute/identity-resolver': ^1.1.3 - - '@atcute/lexicons@1.2.9': - resolution: {integrity: sha512-/RRHm2Cw9o8Mcsrq0eo8fjS9okKYLGfuFwrQ0YoP/6sdSDsXshaTLJsvLlcUcaDaSJ1YFOuHIo3zr2Om2F/16g==, tarball: https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.2.9.tgz} - - '@atcute/mst@1.0.0': - resolution: {integrity: sha512-pMce2efib+dmKtnGnIvJZitVncJkpr3AmhyfgfYllni8KzsaDGsJmuGavSVpuojAhQe+6jYwHFtpm/beiiH4uw==, tarball: https://registry.npmjs.org/@atcute/mst/-/mst-1.0.0.tgz} - - '@atcute/multibase@1.2.0': - resolution: {integrity: sha512-ZK2GRra+qIYq9nNuQB52m2ul0hOmCQEtPobGfTSUxm7pF0OGEkWGkWHugFhNEDVzHzTwPxHp6VGotdZFue4lYQ==, tarball: https://registry.npmjs.org/@atcute/multibase/-/multibase-1.2.0.tgz} - - '@atcute/oauth-crypto@0.1.0': - resolution: {integrity: sha512-qZYDCNLF/4B6AndYT1rsQelN8621AC5u/sL5PHvlr/qqAbmmUwCBGjEgRSyZtHE1AqD60VNiSMlOgAuEQTSl3w==, tarball: https://registry.npmjs.org/@atcute/oauth-crypto/-/oauth-crypto-0.1.0.tgz} - - '@atcute/oauth-keyset@0.1.0': - resolution: {integrity: sha512-+wqT/+I5Lg9VzKnKY3g88+N45xbq+wsdT6bHDGqCVa2u57gRvolFF4dY+weMfc/OX641BIZO6/o+zFtKBsMQnQ==, tarball: https://registry.npmjs.org/@atcute/oauth-keyset/-/oauth-keyset-0.1.0.tgz} - - '@atcute/oauth-node-client@1.1.0': - resolution: {integrity: sha512-xCp/VfjtvTeKscKR/oI2hdMTp1/DaF/7ll8b6yZOCgbKlVDDfhCn5mmKNVARGTNaoywxrXG3XffbWCIx3/E87w==, tarball: https://registry.npmjs.org/@atcute/oauth-node-client/-/oauth-node-client-1.1.0.tgz} - - '@atcute/oauth-types@0.1.1': - resolution: {integrity: sha512-u+3KMjse3Uc/9hDyilu1QVN7IpcnjVXgRzhddzBB8Uh6wePHNVBDdi9wQvFTVVA3zmxtMJVptXRyLLg6Ou9bqg==, tarball: https://registry.npmjs.org/@atcute/oauth-types/-/oauth-types-0.1.1.tgz} - - '@atcute/repo@0.1.3': - resolution: {integrity: sha512-kN4gkrkQgJwI5xkVQ9zSdI8ULR47uH48EdJdjMvKAWsSDu8zKYd8ZQ6n778qa6o3WYEq02v9QVf7pzeU+3fYVA==, tarball: https://registry.npmjs.org/@atcute/repo/-/repo-0.1.3.tgz} - - '@atcute/tid@1.1.2': - resolution: {integrity: sha512-bmPuOX/TOfcm/vsK9vM98spjkcx2wgd9S2PeK5oLgEr8IbNRPq7iMCAPzOL1nu5XAW3LlkOYQEbYRcw5vcQ37w==, tarball: https://registry.npmjs.org/@atcute/tid/-/tid-1.1.2.tgz} - - '@atcute/time-ms@1.3.2': - resolution: {integrity: sha512-F+qOyR9pO55g1d/QmN+Gr+fimoUQQLusdGSB6pjV0wW5KPILR4oQ4e2ZhWzqUbeHLAgWvgoTTMsMDdz62Xa2tg==, tarball: https://registry.npmjs.org/@atcute/time-ms/-/time-ms-1.3.2.tgz} - - '@atcute/uint8array@1.1.1': - resolution: {integrity: sha512-3LsC8XB8TKe9q/5hOA5sFuzGaIFdJZJNewC5OKa3o/eU6+K7JR6see9Zy2JbQERNVnRl11EzbNov1efgLMAs4g==, tarball: https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.1.1.tgz} - - '@atcute/util-fetch@1.0.5': - resolution: {integrity: sha512-qjHj01BGxjSjIFdPiAjSARnodJIIyKxnCMMEcXMESo9TAyND6XZQqrie5fia+LlYWVXdpsTds8uFQwc9jdKTig==, tarball: https://registry.npmjs.org/@atcute/util-fetch/-/util-fetch-1.0.5.tgz} - - '@atcute/util-text@1.2.0': - resolution: {integrity: sha512-b8WSh+Z7K601eUFFmTFj8QPKDO8Ic0VDDj63sdKzpkm+ySQKsYT5nXekViGqFVKbyKj1V5FyvZvgXad6/aI4QQ==, tarball: https://registry.npmjs.org/@atcute/util-text/-/util-text-1.2.0.tgz} - - '@atcute/varint@2.0.0': - resolution: {integrity: sha512-CEY/oVK/nVpL4e5y3sdenLETDL6/Xu5xsE/0TupK+f0Yv8jcD60t2gD8SHROWSvUwYLdkjczLCSA7YrtnjCzWw==, tarball: https://registry.npmjs.org/@atcute/varint/-/varint-2.0.0.tgz} - - '@badrap/valita@0.4.6': - resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==, tarball: https://registry.npmjs.org/@badrap/valita/-/valita-0.4.6.tgz} - engines: {node: '>= 18'} - - '@cloudflare/kv-asset-handler@0.4.2': - resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==, tarball: https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz} - engines: {node: '>=18.0.0'} - - '@cloudflare/unenv-preset@2.16.0': - resolution: {integrity: sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==, tarball: https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.0.tgz} - peerDependencies: - unenv: 2.0.0-rc.24 - workerd: 1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0 - peerDependenciesMeta: - workerd: - optional: true - - '@cloudflare/workerd-darwin-64@1.20260317.1': - resolution: {integrity: sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260317.1.tgz} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - - '@cloudflare/workerd-darwin-arm64@1.20260317.1': - resolution: {integrity: sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260317.1.tgz} - engines: {node: '>=16'} - cpu: [arm64] - os: [darwin] - - '@cloudflare/workerd-linux-64@1.20260317.1': - resolution: {integrity: sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260317.1.tgz} - engines: {node: '>=16'} - cpu: [x64] - os: [linux] - - '@cloudflare/workerd-linux-arm64@1.20260317.1': - resolution: {integrity: sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260317.1.tgz} - engines: {node: '>=16'} - cpu: [arm64] - os: [linux] - - '@cloudflare/workerd-windows-64@1.20260317.1': - resolution: {integrity: sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260317.1.tgz} - engines: {node: '>=16'} - cpu: [x64] - os: [win32] - - '@cloudflare/workers-types@4.20260317.1': - resolution: {integrity: sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==, tarball: https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260317.1.tgz} - - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==, tarball: https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz} - engines: {node: '>=12'} - - '@emnapi/core@1.9.1': - resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==, tarball: https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz} - - '@emnapi/runtime@1.9.1': - resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==, tarball: https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz} - - '@emnapi/wasi-threads@1.2.0': - resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==, tarball: https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz} - - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.27.4': - resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.27.4': - resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.27.4': - resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.27.4': - resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.27.4': - resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.4': - resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.27.4': - resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.4': - resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.27.4': - resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.27.4': - resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.27.4': - resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.27.4': - resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.27.4': - resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.27.4': - resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.4': - resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.27.4': - resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.27.4': - resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-arm64@0.27.4': - resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.4': - resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-arm64@0.27.4': - resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.4': - resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/openharmony-arm64@0.27.4': - resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.27.4': - resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.27.4': - resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.27.4': - resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.27.4': - resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==, tarball: https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==, tarball: https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/compat@2.0.3': - resolution: {integrity: sha512-SjIJhGigp8hmd1YGIBwh7Ovri7Kisl42GYFjrOyHhtfYGGoLW6teYi/5p8W50KSsawUPpuLOSmsq1bD0NGQLBw==, tarball: https://registry.npmjs.org/@eslint/compat/-/compat-2.0.3.tgz} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: ^8.40 || 9 || 10 - peerDependenciesMeta: - eslint: - optional: true - - '@eslint/config-array@0.23.3': - resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==, tarball: https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/config-helpers@0.5.3': - resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==, tarball: https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/core@1.1.1': - resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==, tarball: https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/js@10.0.1': - resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==, tarball: https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: ^10.0.0 - peerDependenciesMeta: - eslint: - optional: true - - '@eslint/object-schema@3.0.3': - resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==, tarball: https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/plugin-kit@0.6.1': - resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==, tarball: https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==, tarball: https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz} - - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==, tarball: https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz} - - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==, tarball: https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz} - - '@foxui/core@0.8.2': - resolution: {integrity: sha512-cVfFCQX4SfL7mezfQHAHln8M+ZiEZlwa4lIXMFUATLuhl4YCcwv7IvvtU+hMvq2ky+vsCEEry1XvXOLdSe6zAA==, tarball: https://registry.npmjs.org/@foxui/core/-/core-0.8.2.tgz} - peerDependencies: - svelte: '>=5' - tailwindcss: '>=3' - - '@foxui/social@0.8.4': - resolution: {integrity: sha512-+5pV402gpEwxSdWb0EMr2SKG/kutCEXzQI930oGA6Q8O/Dnk14noCf/hYAtZvBlAd0cwa6Grlgt7/VEwzU5NDg==, tarball: https://registry.npmjs.org/@foxui/social/-/social-0.8.4.tgz} - peerDependencies: - svelte: '>=5' - tailwindcss: '>=3' - - '@foxui/text@0.8.2': - resolution: {integrity: sha512-f1WotRAzZszqcg/c0RBs9Wuy7KdyHhsZRJNBaufFLl+hzMhCSJQq11lQPdRCUITKP2rBfeguZfqkAcqi0PuI8w==, tarball: https://registry.npmjs.org/@foxui/text/-/text-0.8.2.tgz} - peerDependencies: - svelte: '>=5' - tailwindcss: '>=3' - - '@foxui/time@0.8.2': - resolution: {integrity: sha512-w60jJij44bcle905AL/vBijyI/ilY4cSA21yxYg8C6swm5uH5wooW1fSFaTuC/Lg0GfhwTkW963kz66vVsYbiw==, tarball: https://registry.npmjs.org/@foxui/time/-/time-0.8.2.tgz} - peerDependencies: - svelte: '>=5' - tailwindcss: '>=3' - - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==, tarball: https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==, tarball: https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==, tarball: https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==, tarball: https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz} - engines: {node: '>=18.18'} - - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==, tarball: https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==, tarball: https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==, tarball: https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==, tarball: https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==, tarball: https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz} - cpu: [arm] - os: [linux] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz} - cpu: [ppc64] - os: [linux] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz} - cpu: [riscv64] - os: [linux] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz} - cpu: [s390x] - os: [linux] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz} - cpu: [x64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz} - cpu: [x64] - os: [linux] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==, tarball: https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==, tarball: https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==, tarball: https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==, tarball: https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==, tarball: https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==, tarball: https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==, tarball: https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==, tarball: https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==, tarball: https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==, tarball: https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==, tarball: https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==, tarball: https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@internationalized/date@3.10.1': - resolution: {integrity: sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==, tarball: https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==, tarball: https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==, tarball: https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, tarball: https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==, tarball: https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz} - - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz} - - '@mary-ext/event-iterator@1.0.0': - resolution: {integrity: sha512-l6gCPsWJ8aRCe/s7/oCmero70kDHgIK5m4uJvYgwEYTqVxoBOIXbKr5tnkLqUHEg6mNduB4IWvms3h70Hp9ADQ==, tarball: https://registry.npmjs.org/@mary-ext/event-iterator/-/event-iterator-1.0.0.tgz} - - '@mary-ext/simple-event-emitter@1.0.1': - resolution: {integrity: sha512-9+VvZisxZ/gSg+JJH7hmXaA8Qj42Qjz3O58RSB+INYc8iLA0icATZxHB9vKbj59ojDGZjO3hCKzMXocx3L0H8w==, tarball: https://registry.npmjs.org/@mary-ext/simple-event-emitter/-/simple-event-emitter-1.0.1.tgz} - - '@napi-rs/wasm-runtime@1.1.2': - resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==, tarball: https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@noble/secp256k1@3.0.0': - resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==, tarball: https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz} - - '@number-flow/svelte@0.4.0': - resolution: {integrity: sha512-9tnowrlZlBV3IVe3Gm1V7yXSf4Ugag2k7iW45xqb04HXSa1ApEImopvGWAjJpHDvS849o+UCb0YH461Mtde9lA==, tarball: https://registry.npmjs.org/@number-flow/svelte/-/svelte-0.4.0.tgz} - peerDependencies: - svelte: ^4 || ^5 - - '@optique/core@0.6.11': - resolution: {integrity: sha512-GVLFihzBA1j78NFlkU5N1Lu0jRqET0k6Z66WK8VQKG/a3cxmCInVGSKMIdQG8i6pgC8wD5OizF6Y3QMztmhAxg==, tarball: https://registry.npmjs.org/@optique/core/-/core-0.6.11.tgz} - engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} - - '@optique/run@0.6.11': - resolution: {integrity: sha512-tsXBEygGSzNpFK2gjsRlXBn7FiScUeLFWIZNpoAZ8iG85Km0/3K9xgqlQAXoQ+uEZBe4XplnzyCDvmEgbyNT8w==, tarball: https://registry.npmjs.org/@optique/run/-/run-0.6.11.tgz} - engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} - - '@oxc-project/types@0.122.0': - resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==, tarball: https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz} - - '@polka/url@1.0.0-next.29': - resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==, tarball: https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz} - - '@poppinss/colors@4.1.6': - resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==, tarball: https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz} - - '@poppinss/dumper@0.6.5': - resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==, tarball: https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz} - - '@poppinss/exception@1.2.3': - resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==, tarball: https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz} - - '@remirror/core-constants@3.0.0': - resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==, tarball: https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz} - - '@rolldown/binding-android-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==, tarball: https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==, tarball: https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.0-rc.12': - resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==, tarball: https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.0.0-rc.12': - resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==, tarball: https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': - resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': - resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': - resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==, tarball: https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': - resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==, tarball: https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': - resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==, tarball: https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': - resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==, tarball: https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.0-rc.12': - resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==, tarball: https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz} - - '@sindresorhus/is@7.2.0': - resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==, tarball: https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz} - engines: {node: '>=18'} - - '@speed-highlight/core@1.2.15': - resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==, tarball: https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, tarball: https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz} - - '@sveltejs/acorn-typescript@1.0.9': - resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==, tarball: https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz} - peerDependencies: - acorn: ^8.9.0 - - '@sveltejs/adapter-cloudflare@7.2.8': - resolution: {integrity: sha512-bIdhY/Fi4AQmqiBdQVKnafH1h9Gw+xbCvHyUu4EouC8rJOU02zwhi14k/FDhQ0mJF1iblIu3m8UNQ8GpGIvIOQ==, tarball: https://registry.npmjs.org/@sveltejs/adapter-cloudflare/-/adapter-cloudflare-7.2.8.tgz} - peerDependencies: - '@sveltejs/kit': ^2.0.0 - wrangler: ^4.0.0 - - '@sveltejs/kit@2.55.0': - resolution: {integrity: sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==, tarball: https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz} - engines: {node: '>=18.13'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.0.0 - '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 - svelte: ^4.0.0 || ^5.0.0-next.0 - typescript: ^5.3.3 - vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - typescript: - optional: true - - '@sveltejs/vite-plugin-svelte@7.0.0': - resolution: {integrity: sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==, tarball: https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz} - engines: {node: ^20.19 || ^22.12 || >=24} - peerDependencies: - svelte: ^5.46.4 - vite: ^8.0.0-beta.7 || ^8.0.0 - - '@swc/helpers@0.5.20': - resolution: {integrity: sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==, tarball: https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz} - - '@tailwindcss/forms@0.5.11': - resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==, tarball: https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz} - peerDependencies: - tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1' - - '@tailwindcss/node@4.2.2': - resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==, tarball: https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz} - - '@tailwindcss/oxide-android-arm64@4.2.2': - resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.2.2': - resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz} - engines: {node: '>= 20'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.2.2': - resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz} - engines: {node: '>= 20'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.2.2': - resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz} - engines: {node: '>= 20'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': - resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz} - engines: {node: '>= 20'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': - resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': - resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': - resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-musl@4.2.2': - resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-wasm32-wasi@4.2.2': - resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': - resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': - resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==, tarball: https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz} - engines: {node: '>= 20'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.2.2': - resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==, tarball: https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz} - engines: {node: '>= 20'} - - '@tailwindcss/vite@4.2.2': - resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==, tarball: https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz} - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 || ^8 - - '@tiptap/core@3.21.0': - resolution: {integrity: sha512-IfnQiuEeabDSPr1C/zHFTbnvlTf5z0DE/d/xz4C6bkL4ZBDJ3rr99h2qsaV0l8F+kbNswZMlQdM8rxNlMy95fQ==, tarball: https://registry.npmjs.org/@tiptap/core/-/core-3.21.0.tgz} - peerDependencies: - '@tiptap/pm': ^3.21.0 - - '@tiptap/extension-blockquote@3.21.0': - resolution: {integrity: sha512-JDM/RR6rM0dMCZ1UnEf7eqmN6pAdIa2llhN+E24HdTGNJCklMFhLAGE/OT8/1r7M0WWA9GVO7/PTe4EdGh6+lQ==, tarball: https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - - '@tiptap/extension-bold@3.21.0': - resolution: {integrity: sha512-iyEJRzG7XTCPlHwEDzUw3HnuYYCfL7lNpcCHmxcpYMrIUA8rv7EUxerIwApT6xY8hQ/07ljuJKgOyPvnJOOzuA==, tarball: https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - - '@tiptap/extension-bubble-menu@3.21.0': - resolution: {integrity: sha512-/fabRRhhf8i4LAx9e8xz9ppqN5KgdJk3TxMuxAD5vAWGsejvhSoPa8O8H/QwwyntXm1Vue8aQiMHsUk48b2hGQ==, tarball: https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - '@tiptap/pm': ^3.21.0 - - '@tiptap/extension-bullet-list@3.21.0': - resolution: {integrity: sha512-PWNF+xwxgOeXYGD88sCQLKL0eBoQqjUnZNALxBjN3Y7x4llalh42rHOp2Nt2t6UbQgqTBtBzU/uFcussTpxreQ==, tarball: https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.21.0.tgz} - peerDependencies: - '@tiptap/extension-list': ^3.21.0 - - '@tiptap/extension-code-block-lowlight@3.21.0': - resolution: {integrity: sha512-79sS0tqoGVX6wq30ejzohpUVLeGOkTTUn5hCqjsniyYPTEtrn4tHyBnS4Du2TbrDV4SqcwgisWExhuB8pfEdYQ==, tarball: https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - '@tiptap/extension-code-block': ^3.21.0 - '@tiptap/pm': ^3.21.0 - highlight.js: ^11 - lowlight: ^2 || ^3 - - '@tiptap/extension-code-block@3.21.0': - resolution: {integrity: sha512-zrVOcOzDCjHQ8NJcC+qHmZZKiwnP/NMSb3qVJlSMN8TzuHept1MZCDa2Mbo70O6I0txo456SGuXB9sqV1vHmGg==, tarball: https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - '@tiptap/pm': ^3.21.0 - - '@tiptap/extension-code@3.21.0': - resolution: {integrity: sha512-D7wA9jp+4X2r1f3FIoga73s6Rn4rmZY57Jes6a4rK3HY+3yHk1r057pPIZSY8Drfs97jxHQVFdfUYUomLSFYBA==, tarball: https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - - '@tiptap/extension-document@3.21.0': - resolution: {integrity: sha512-7oCyzXI9ChvJQUlr23AURdfVar4OIsrYUvqdhEwo3bjcI/Q/j0KJiXfuh6ZzL5eVaINSailH53sZaGg4THQtUg==, tarball: https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - - '@tiptap/extension-dropcursor@3.21.0': - resolution: {integrity: sha512-6fsDSVAM2iz7eElvT6iivMrGBGjIP/oPigVZ/SPm6f31phaYhz6TIOEgV/Lr2jaPIOgyK4U0cU4Yd4KUBCmhzQ==, tarball: https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.21.0.tgz} - peerDependencies: - '@tiptap/extensions': ^3.21.0 - - '@tiptap/extension-floating-menu@3.21.0': - resolution: {integrity: sha512-n2HzTB+I/5rAl8R/1sKMv92JiY1oDK1hroXizxEKYa6dskJcAMW0CfYyPcPOZWQQEe7qoeOvQISr2ooLAKW+Mw==, tarball: https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.21.0.tgz} - peerDependencies: - '@floating-ui/dom': ^1.0.0 - '@tiptap/core': ^3.21.0 - '@tiptap/pm': ^3.21.0 - - '@tiptap/extension-gapcursor@3.21.0': - resolution: {integrity: sha512-wGjgAoYBTvPAe9QYMI5px355XcNeMkaUrMY9IHbMqgqdmHcDxqooxM4H6sYVX2CRcHwXy4I8NQAoOhSYrQJDMg==, tarball: https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.21.0.tgz} - peerDependencies: - '@tiptap/extensions': ^3.21.0 - - '@tiptap/extension-hard-break@3.21.0': - resolution: {integrity: sha512-6JFVSAOQ1qhQHi9mVcdn2/XO8YIMgYV8zjarzNUzP6Sf2waeE5BLXjlg6rIH/945sY1J+FndTojLru6gQ07a5A==, tarball: https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - - '@tiptap/extension-heading@3.21.0': - resolution: {integrity: sha512-ji6VJmoRnDzAHYflEYEZohMHRi77UGLW1o3ua7UhI32iJ9nuYssbPNuzEeE4SvENMQwZRszad5+a+dKAa+NC7g==, tarball: https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - - '@tiptap/extension-history@3.21.0': - resolution: {integrity: sha512-aYUxm0fLXGfn4XAsRq53lOX1cB+GRF8rScVuDHmQfEaJHNo09DSRtvgWnDHVdqIS8FvKkBcTO8eyPOBPMvkWiA==, tarball: https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-3.21.0.tgz} - peerDependencies: - '@tiptap/extensions': ^3.21.0 - - '@tiptap/extension-horizontal-rule@3.21.0': - resolution: {integrity: sha512-vNBnOfFEY62CoJPGo4nonRM7RiOvhII1vhoO+WFr1GxDqCAfmEFjToflt7JT1UJdo6lMVcD+aaaAgOiuSz5p6g==, tarball: https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - '@tiptap/pm': ^3.21.0 - - '@tiptap/extension-image@3.21.0': - resolution: {integrity: sha512-W9786a2K4LSZJMPeRLmoDulJeXOsM0ueRV2MHjTol7ikPRauROB7GUbAz9DyPAJHA2AGUfpswnGAYPO3tz5CLg==, tarball: https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - - '@tiptap/extension-italic@3.21.0': - resolution: {integrity: sha512-2I8oPvwyXhRn1k8lbDFIutzvhtLEjoO5mmQCNX4TnT4PdxxaSrK9+ihYg12VeqhUeO7dg1MKiFqws0HVBrwzWg==, tarball: https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - - '@tiptap/extension-link@3.21.0': - resolution: {integrity: sha512-oMU7Yve1sbgBsaFAUc2R0GPf4d3ZPVJeMUFC6b6X9rJIvx/IhEUEn9toQcSBGfp02uWK9NdQyIFYFdWlVXH++w==, tarball: https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - '@tiptap/pm': ^3.21.0 - - '@tiptap/extension-list-item@3.21.0': - resolution: {integrity: sha512-1ZymZmlQVbAoC4q5x3cro0v5+3I6l+BHqbhIMQLjQFlAOJfcE0pvqRzAFW7PduxUj41tXEtsYqp2NREvO9F5Fg==, tarball: https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.21.0.tgz} - peerDependencies: - '@tiptap/extension-list': ^3.21.0 - - '@tiptap/extension-list-keymap@3.21.0': - resolution: {integrity: sha512-EzrfW3ASNFPWKhR8sNOq7Kqw4hvaTAOn4dlI7chB8HIANSrlyPOUn+eKAnO6HQgsUgsbjg2GbTUrGrxcoLykUg==, tarball: https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.21.0.tgz} - peerDependencies: - '@tiptap/extension-list': ^3.21.0 - - '@tiptap/extension-list@3.21.0': - resolution: {integrity: sha512-KeBlEtLrGce2d3dgL89hmwWEtREuzlW4XY5bYWpKNvCbFqvdSb3n7vkdkw32YclZmMWxAcABgW6ucCStkE0rsQ==, tarball: https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - '@tiptap/pm': ^3.21.0 - - '@tiptap/extension-ordered-list@3.21.0': - resolution: {integrity: sha512-+d+0orokMfqaBfvr9tUBgGvo2ZCV+fR3JzsJTmnLBWOkhBSJN7H4pnfXPTue0qwspUwRmkLJxdIlU+J7HkMrng==, tarball: https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.21.0.tgz} - peerDependencies: - '@tiptap/extension-list': ^3.21.0 - - '@tiptap/extension-paragraph@3.21.0': - resolution: {integrity: sha512-cMPG/jCoZ9NmLZ5ctFziILaxJGfDtMTb5OLBhifMFZeMVwF1pEJIygDEfnX/HSruv507weZSQG4pERO2tRszMg==, tarball: https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - - '@tiptap/extension-placeholder@3.21.0': - resolution: {integrity: sha512-fs+cQqMh1d1naV6OgOhP/0qbRJwtw8DpQMj3/oqGKbaRRKIeecEaZPXYRd7MYa4e9K0Cfk5Bm0MNs9lwu/BYsw==, tarball: https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.21.0.tgz} - peerDependencies: - '@tiptap/extensions': ^3.21.0 - - '@tiptap/extension-strike@3.21.0': - resolution: {integrity: sha512-easnVaN11Wl+5fOtfvzJ10J762S9TRXZaMj5rLBGavgf82DCYHqhGhBqpLQrJ41r4nPABGlYvTRoxfvBLB74Lg==, tarball: https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - - '@tiptap/extension-text@3.21.0': - resolution: {integrity: sha512-Zx8QdB8a5iBuE4uO21c3BjmpBfaJEr2Jd1QFnsdgx11fm6P7dGgZaGko1FaINhfOPRGTN6O/kiF02cDMdOHa/w==, tarball: https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - - '@tiptap/extension-typography@3.21.0': - resolution: {integrity: sha512-/nHtkQTtBPmQPe4JnEGBxxCKIv2FToSB3PUfh7b4cccNkbs7yJZO30ySNJcMzNfn8pfSQPkJxZJcFuSm9RjZdg==, tarball: https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - - '@tiptap/extension-underline@3.21.0': - resolution: {integrity: sha512-gGmBEymbWnr8AIS8bI/bPw5rcwo7wAFcBw/TsLd1nAanu1dDqSRNDBrit3m02Ru+D88u2SfNvmbOPI1pz+1f5w==, tarball: https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - - '@tiptap/extensions@3.21.0': - resolution: {integrity: sha512-MN1uh5PmHT1F2BNsbc21MIS0AMFFA73oODlp/4ckpBR4o5AxRwV+8f43Cd52UL4MgMkKj/A+QfZ7iK9IDb0h5A==, tarball: https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - '@tiptap/pm': ^3.21.0 - - '@tiptap/markdown@3.21.0': - resolution: {integrity: sha512-VhnHhB9LeEfB2xnbNZUV2itK0ae4iERbr+a4/ZrlZB9auPtggW4fIob/bQGOaeU+GZtk1xDLyKUDyKEEwgTXMw==, tarball: https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - '@tiptap/pm': ^3.21.0 - - '@tiptap/pm@3.21.0': - resolution: {integrity: sha512-I3sNo7oMMsR6FFz1ecvPb9uCF0VQuS2WV67j8Io2M7DJicRWCE/GM5DaiYjTeWBbnByk6BuG0txoJATAqPVliQ==, tarball: https://registry.npmjs.org/@tiptap/pm/-/pm-3.21.0.tgz} - - '@tiptap/starter-kit@3.21.0': - resolution: {integrity: sha512-w7fWxglDtqXFBgRYH+LforJyUboSAQllnWQbGVSTyX4rsICqZjkb3f6CTSUWpGoGKmlmbb2ZpEuoik7tur9d8Q==, tarball: https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.21.0.tgz} - - '@tiptap/suggestion@3.21.0': - resolution: {integrity: sha512-ShgbaH2hbCRLV+AaVuSx7a/nzPOSdUeW0TNeuxZfWim62MBcx33qtllja1zOoY2tgGXokEihuzFODx83IyvGfQ==, tarball: https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.21.0.tgz} - peerDependencies: - '@tiptap/core': ^3.21.0 - '@tiptap/pm': ^3.21.0 - - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==, tarball: https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz} - - '@types/cookie@0.6.0': - resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==, tarball: https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz} - - '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==, tarball: https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==, tarball: https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz} - - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==, tarball: https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==, tarball: https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz} - - '@types/linkify-it@5.0.0': - resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==, tarball: https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz} - - '@types/markdown-it@14.1.2': - resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==, tarball: https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz} - - '@types/mdurl@2.0.0': - resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==, tarball: https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz} - - '@types/node@25.0.10': - resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==, tarball: https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz} - - '@types/trusted-types@2.0.7': - resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==, tarball: https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz} - - '@types/unist@3.0.3': - resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==, tarball: https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz} - - '@typescript-eslint/eslint-plugin@8.57.2': - resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==, tarball: https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.57.2 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/parser@8.57.2': - resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==, tarball: https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/project-service@8.57.2': - resolution: {integrity: sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==, tarball: https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/scope-manager@8.57.2': - resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==, tarball: https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.57.2': - resolution: {integrity: sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==, tarball: https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/type-utils@8.57.2': - resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==, tarball: https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/types@8.57.2': - resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==, tarball: https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.57.2': - resolution: {integrity: sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==, tarball: https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.57.2': - resolution: {integrity: sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==, tarball: https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/visitor-keys@8.57.2': - resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==, tarball: https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@use-gesture/core@10.3.1': - resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==, tarball: https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz} - - '@use-gesture/vanilla@10.3.1': - resolution: {integrity: sha512-lT4scGLu59ovA3zmtUonukAGcA0AdOOh+iwNDS05Bsu7Lq9aZToDHhI6D8Q2qvsVraovtsLLYwPrWdG/noMAKw==, tarball: https://registry.npmjs.org/@use-gesture/vanilla/-/vanilla-10.3.1.tgz} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==, tarball: https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==, tarball: https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==, tarball: https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==, tarball: https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz} - - aria-query@5.3.1: - resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==, tarball: https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz} - engines: {node: '>= 0.4'} - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==, tarball: https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz} - engines: {node: '>= 0.4'} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==, tarball: https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz} - engines: {node: 18 || 20 || >=22} - - bits-ui@2.16.4: - resolution: {integrity: sha512-76dKbW0DGplG7qnVM3kljE2CmCXqh9tj9OfVo0DvC/ZGRkZNJ/auU/aQnMKBxDnSqRvsz2mZBccBkUKyF4/SoQ==, tarball: https://registry.npmjs.org/bits-ui/-/bits-ui-2.16.4.tgz} - engines: {node: '>=20'} - peerDependencies: - '@internationalized/date': ^3.8.1 - svelte: ^5.33.0 - - blake3-wasm@2.1.5: - resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==, tarball: https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz} - - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==, tarball: https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz} - engines: {node: 18 || 20 || >=22} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==, tarball: https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz} - engines: {node: '>= 14.16.0'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==, tarball: https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz} - engines: {node: '>=6'} - - contrail@https://codeload.github.com/flo-bit/contrail/tar.gz/ee24d4faa5d5e1515fa71398bca035b191880dbf: - resolution: {tarball: https://codeload.github.com/flo-bit/contrail/tar.gz/ee24d4faa5d5e1515fa71398bca035b191880dbf} - version: 0.0.2 - - cookie@0.6.0: - resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==, tarball: https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz} - engines: {node: '>= 0.6'} - - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==, tarball: https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz} - engines: {node: '>=18'} - - core-js@3.49.0: - resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==, tarball: https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz} - - crelt@1.0.6: - resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==, tarball: https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, tarball: https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz} - engines: {node: '>= 8'} - - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==, tarball: https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz} - engines: {node: '>=4'} - hasBin: true - - custom-event-polyfill@1.0.7: - resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==, tarball: https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==, tarball: https://registry.npmjs.org/debug/-/debug-4.4.3.tgz} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, tarball: https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz} - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==, tarball: https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz} - engines: {node: '>=0.10.0'} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==, tarball: https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz} - engines: {node: '>=6'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==, tarball: https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz} - engines: {node: '>=8'} - - devalue@5.6.4: - resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==, tarball: https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz} - - devlop@1.1.0: - resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==, tarball: https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz} - - dompurify@3.3.3: - resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==, tarball: https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz} - - emoji-picker-element@1.29.1: - resolution: {integrity: sha512-TOiHzu9Dqib3x4MwcAi3wi3RdyT4SoeB4b15AvH1ks4SBwTl7DeebhZ0d3x6dNi4XfNU7IGRZ7NBQllj0RqwrQ==, tarball: https://registry.npmjs.org/emoji-picker-element/-/emoji-picker-element-1.29.1.tgz} - - enhanced-resolve@5.20.1: - resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==, tarball: https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz} - engines: {node: '>=10.13.0'} - - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==, tarball: https://registry.npmjs.org/entities/-/entities-4.5.0.tgz} - engines: {node: '>=0.12'} - - error-stack-parser-es@1.0.5: - resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==, tarball: https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz} - - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz} - engines: {node: '>=18'} - hasBin: true - - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz} - engines: {node: '>=18'} - hasBin: true - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==, tarball: https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz} - engines: {node: '>=10'} - - eslint-config-prettier@10.1.8: - resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==, tarball: https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - - eslint-plugin-svelte@3.16.0: - resolution: {integrity: sha512-DJXxqpYZUxcE0SfYo8EJzV2ZC+zAD7fJp1n1HwcEMRR1cOEUYvjT9GuzJeNghMjgb7uxuK3IJAzI+x6zzUxO5A==, tarball: https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.16.0.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.1 || ^9.0.0 || ^10.0.0 - svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 - peerDependenciesMeta: - svelte: - optional: true - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==, tarball: https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-scope@9.1.2: - resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==, tarball: https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==, tarball: https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==, tarball: https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==, tarball: https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@10.1.0: - resolution: {integrity: sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==, tarball: https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - esm-env@1.2.2: - resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==, tarball: https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz} - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==, tarball: https://registry.npmjs.org/espree/-/espree-10.4.0.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - espree@11.2.0: - resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==, tarball: https://registry.npmjs.org/espree/-/espree-11.2.0.tgz} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==, tarball: https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz} - engines: {node: '>=0.10'} - - esrap@2.2.4: - resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==, tarball: https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==, tarball: https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==, tarball: https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz} - engines: {node: '>=4.0'} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==, tarball: https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz} - engines: {node: '>=0.10.0'} - - event-target-polyfill@0.0.4: - resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==, tarball: https://registry.npmjs.org/event-target-polyfill/-/event-target-polyfill-0.0.4.tgz} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, tarball: https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==, tarball: https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, tarball: https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, tarball: https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==, tarball: https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz} - engines: {node: '>=16.0.0'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, tarball: https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==, tarball: https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz} - engines: {node: '>=16'} - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==, tarball: https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - get-tsconfig@4.13.7: - resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==, tarball: https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==, tarball: https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz} - engines: {node: '>=10.13.0'} - - globals@16.5.0: - resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==, tarball: https://registry.npmjs.org/globals/-/globals-16.5.0.tgz} - engines: {node: '>=18'} - - globals@17.4.0: - resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==, tarball: https://registry.npmjs.org/globals/-/globals-17.4.0.tgz} - engines: {node: '>=18'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, tarball: https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz} - - highlight.js@11.11.1: - resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==, tarball: https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz} - engines: {node: '>=12.0.0'} - - hls.js@1.6.15: - resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==, tarball: https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz} - - hono@4.12.9: - resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==, tarball: https://registry.npmjs.org/hono/-/hono-4.12.9.tgz} - engines: {node: '>=16.9.0'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, tarball: https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz} - engines: {node: '>= 4'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==, tarball: https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz} - engines: {node: '>= 4'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==, tarball: https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz} - engines: {node: '>=0.8.19'} - - inline-style-parser@0.2.7: - resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==, tarball: https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz} - - is-emoji-supported@0.0.5: - resolution: {integrity: sha512-WOlXUhDDHxYqcSmFZis+xWhhqXiK2SU0iYiqmth5Ip0FHLZQAt9rKL5ahnilE8/86WH8tZ3bmNNNC+bTzamqlw==, tarball: https://registry.npmjs.org/is-emoji-supported/-/is-emoji-supported-0.0.5.tgz} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, tarball: https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, tarball: https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz} - engines: {node: '>=0.10.0'} - - is-reference@3.0.3: - resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==, tarball: https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, tarball: https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz} - - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==, tarball: https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==, tarball: https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, tarball: https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, tarball: https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, tarball: https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz} - - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==, tarball: https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz} - engines: {node: '>=6'} - - known-css-properties@0.37.0: - resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==, tarball: https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==, tarball: https://registry.npmjs.org/levn/-/levn-0.4.1.tgz} - engines: {node: '>= 0.8.0'} - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==, tarball: https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==, tarball: https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==, tarball: https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==, tarball: https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==, tarball: https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==, tarball: https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==, tarball: https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==, tarball: https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==, tarball: https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==, tarball: https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==, tarball: https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==, tarball: https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz} - engines: {node: '>= 12.0.0'} - - lilconfig@2.1.0: - resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==, tarball: https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz} - engines: {node: '>=10'} - - linkify-it@5.0.0: - resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==, tarball: https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz} - - linkifyjs@4.3.2: - resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==, tarball: https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz} - - loadjs@4.3.0: - resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==, tarball: https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz} - - locate-character@3.0.0: - resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==, tarball: https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, tarball: https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz} - engines: {node: '>=10'} - - lowlight@3.3.0: - resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==, tarball: https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz} - - lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==, tarball: https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz} - hasBin: true - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, tarball: https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz} - - markdown-it@14.1.1: - resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==, tarball: https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz} - hasBin: true - - marked@17.0.5: - resolution: {integrity: sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==, tarball: https://registry.npmjs.org/marked/-/marked-17.0.5.tgz} - engines: {node: '>= 20'} - hasBin: true - - mdurl@2.0.0: - resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==, tarball: https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz} - - mini-svg-data-uri@1.4.4: - resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==, tarball: https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz} - hasBin: true - - miniflare@4.20260317.3: - resolution: {integrity: sha512-tK78D3X4q30/SXqVwMhWrUfH+ffRou9dJLC+jkhNy5zh1I7i7T4JH6xihOvYxdCSBavJ5fQXaaxDJz6orh09BA==, tarball: https://registry.npmjs.org/miniflare/-/miniflare-4.20260317.3.tgz} - engines: {node: '>=18.0.0'} - hasBin: true - - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==, tarball: https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz} - engines: {node: 18 || 20 || >=22} - - mode-watcher@1.1.0: - resolution: {integrity: sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==, tarball: https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz} - peerDependencies: - svelte: ^5.27.0 - - mri@1.2.0: - resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==, tarball: https://registry.npmjs.org/mri/-/mri-1.2.0.tgz} - engines: {node: '>=4'} - - mrmime@2.0.1: - resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==, tarball: https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz} - engines: {node: '>=10'} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, tarball: https://registry.npmjs.org/ms/-/ms-2.1.3.tgz} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - nanoid@5.1.7: - resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz} - engines: {node: ^18 || >=20} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, tarball: https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz} - - number-flow@0.6.0: - resolution: {integrity: sha512-K8flNq2Wqus53vjp/btVo3qXFkagF8dIdYavreBfE7hlvFFG/b1HMGEH6nZL+mlrJ+4lbLP9OmPv3t2rmRkpSQ==, tarball: https://registry.npmjs.org/number-flow/-/number-flow-0.6.0.tgz} - - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==, tarball: https://registry.npmjs.org/obug/-/obug-2.1.1.tgz} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, tarball: https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz} - engines: {node: '>= 0.8.0'} - - orderedmap@2.1.1: - resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==, tarball: https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, tarball: https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, tarball: https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz} - engines: {node: '>=10'} - - partysocket@1.1.16: - resolution: {integrity: sha512-d7xFv+ZC7x0p/DAHWJ5FhxQhimIx+ucyZY+kxL0cKddLBmK9c4p2tEA/L+dOOrWm6EYrRwrBjKQV0uSzOY9x1w==, tarball: https://registry.npmjs.org/partysocket/-/partysocket-1.1.16.tgz} - peerDependencies: - react: '>=17' - peerDependenciesMeta: - react: - optional: true - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, tarball: https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, tarball: https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz} - engines: {node: '>=8'} - - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==, tarball: https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, tarball: https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, tarball: https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz} - engines: {node: '>=12'} - - plyr@3.8.4: - resolution: {integrity: sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==, tarball: https://registry.npmjs.org/plyr/-/plyr-3.8.4.tgz} - - postcss-load-config@3.1.4: - resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==, tarball: https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz} - engines: {node: '>= 10'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - - postcss-safe-parser@7.0.1: - resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==, tarball: https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz} - engines: {node: '>=18.0'} - peerDependencies: - postcss: ^8.4.31 - - postcss-scss@4.0.9: - resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==, tarball: https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.4.29 - - postcss-selector-parser@7.1.1: - resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==, tarball: https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz} - engines: {node: '>=4'} - - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==, tarball: https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, tarball: https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz} - engines: {node: '>= 0.8.0'} - - prettier-plugin-svelte@3.5.1: - resolution: {integrity: sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==, tarball: https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.1.tgz} - peerDependencies: - prettier: ^3.0.0 - svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 - - prettier-plugin-tailwindcss@0.7.2: - resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==, tarball: https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz} - engines: {node: '>=20.19'} - peerDependencies: - '@ianvs/prettier-plugin-sort-imports': '*' - '@prettier/plugin-hermes': '*' - '@prettier/plugin-oxc': '*' - '@prettier/plugin-pug': '*' - '@shopify/prettier-plugin-liquid': '*' - '@trivago/prettier-plugin-sort-imports': '*' - '@zackad/prettier-plugin-twig': '*' - prettier: ^3.0 - prettier-plugin-astro: '*' - prettier-plugin-css-order: '*' - prettier-plugin-jsdoc: '*' - prettier-plugin-marko: '*' - prettier-plugin-multiline-arrays: '*' - prettier-plugin-organize-attributes: '*' - prettier-plugin-organize-imports: '*' - prettier-plugin-sort-imports: '*' - prettier-plugin-svelte: '*' - peerDependenciesMeta: - '@ianvs/prettier-plugin-sort-imports': - optional: true - '@prettier/plugin-hermes': - optional: true - '@prettier/plugin-oxc': - optional: true - '@prettier/plugin-pug': - optional: true - '@shopify/prettier-plugin-liquid': - optional: true - '@trivago/prettier-plugin-sort-imports': - optional: true - '@zackad/prettier-plugin-twig': - optional: true - prettier-plugin-astro: - optional: true - prettier-plugin-css-order: - optional: true - prettier-plugin-jsdoc: - optional: true - prettier-plugin-marko: - optional: true - prettier-plugin-multiline-arrays: - optional: true - prettier-plugin-organize-attributes: - optional: true - prettier-plugin-organize-imports: - optional: true - prettier-plugin-sort-imports: - optional: true - prettier-plugin-svelte: - optional: true - - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==, tarball: https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz} - engines: {node: '>=14'} - hasBin: true - - prosemirror-changeset@2.4.0: - resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==, tarball: https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz} - - prosemirror-collab@1.3.1: - resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==, tarball: https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz} - - prosemirror-commands@1.7.1: - resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==, tarball: https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz} - - prosemirror-dropcursor@1.8.2: - resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==, tarball: https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz} - - prosemirror-gapcursor@1.4.1: - resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==, tarball: https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz} - - prosemirror-history@1.5.0: - resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==, tarball: https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz} - - prosemirror-inputrules@1.5.1: - resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==, tarball: https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz} - - prosemirror-keymap@1.2.3: - resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==, tarball: https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz} - - prosemirror-markdown@1.13.4: - resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==, tarball: https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz} - - prosemirror-menu@1.3.0: - resolution: {integrity: sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==, tarball: https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz} - - prosemirror-model@1.25.4: - resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==, tarball: https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz} - - prosemirror-schema-basic@1.2.4: - resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==, tarball: https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz} - - prosemirror-schema-list@1.5.1: - resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==, tarball: https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz} - - prosemirror-state@1.4.4: - resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==, tarball: https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz} - - prosemirror-tables@1.8.5: - resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==, tarball: https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz} - - prosemirror-trailing-node@3.0.0: - resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==, tarball: https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz} - peerDependencies: - prosemirror-model: ^1.22.1 - prosemirror-state: ^1.4.2 - prosemirror-view: ^1.33.8 - - prosemirror-transform@1.11.0: - resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==, tarball: https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz} - - prosemirror-view@1.41.7: - resolution: {integrity: sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==, tarball: https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.7.tgz} - - punycode.js@2.3.1: - resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==, tarball: https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz} - engines: {node: '>=6'} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, tarball: https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz} - engines: {node: '>=6'} - - rangetouch@2.0.1: - resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==, tarball: https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz} - - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz} - engines: {node: '>= 14.18.0'} - - regexparam@3.0.0: - resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==, tarball: https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz} - engines: {node: '>=8'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==, tarball: https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz} - - rolldown@1.0.0-rc.12: - resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==, tarball: https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - rope-sequence@1.3.4: - resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==, tarball: https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz} - - runed@0.23.4: - resolution: {integrity: sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==, tarball: https://registry.npmjs.org/runed/-/runed-0.23.4.tgz} - peerDependencies: - svelte: ^5.7.0 - - runed@0.25.0: - resolution: {integrity: sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==, tarball: https://registry.npmjs.org/runed/-/runed-0.25.0.tgz} - peerDependencies: - svelte: ^5.7.0 - - runed@0.28.0: - resolution: {integrity: sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==, tarball: https://registry.npmjs.org/runed/-/runed-0.28.0.tgz} - peerDependencies: - svelte: ^5.7.0 - - runed@0.35.1: - resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==, tarball: https://registry.npmjs.org/runed/-/runed-0.35.1.tgz} - peerDependencies: - '@sveltejs/kit': ^2.21.0 - svelte: ^5.7.0 - peerDependenciesMeta: - '@sveltejs/kit': - optional: true - - sade@1.8.1: - resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==, tarball: https://registry.npmjs.org/sade/-/sade-1.8.1.tgz} - engines: {node: '>=6'} - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==, tarball: https://registry.npmjs.org/semver/-/semver-7.7.4.tgz} - engines: {node: '>=10'} - hasBin: true - - set-cookie-parser@3.1.0: - resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==, tarball: https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz} - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==, tarball: https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, tarball: https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, tarball: https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz} - engines: {node: '>=8'} - - sirv@3.0.2: - resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==, tarball: https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz} - engines: {node: '>=18'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, tarball: https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz} - engines: {node: '>=0.10.0'} - - style-to-object@1.0.14: - resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==, tarball: https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz} - - supports-color@10.2.2: - resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==, tarball: https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz} - engines: {node: '>=18'} - - svelte-check@4.4.5: - resolution: {integrity: sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==, tarball: https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz} - engines: {node: '>= 18.0.0'} - hasBin: true - peerDependencies: - svelte: ^4.0.0 || ^5.0.0-next.0 - typescript: '>=5.0.0' - - svelte-eslint-parser@1.6.0: - resolution: {integrity: sha512-qoB1ehychT6OxEtQAqc/guSqLS20SlA53Uijl7x375s8nlUT0lb9ol/gzraEEatQwsyPTJo87s2CmKL9Xab+Uw==, tarball: https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.0.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.30.3} - peerDependencies: - svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 - peerDependenciesMeta: - svelte: - optional: true - - svelte-sonner@1.1.0: - resolution: {integrity: sha512-3lYM6ZIqWe+p9vwwWHGWP/ZdvHiUtzURsud2quIxivrX4rvpXh6i+geBGn0m3JS6KwW6W8VgbOl3xQMcDuh6gg==, tarball: https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.1.0.tgz} - peerDependencies: - svelte: ^5.0.0 - - svelte-tiptap@3.0.1: - resolution: {integrity: sha512-Vi3kVGOd01f7mslOxGbJB7z2QavdvH+6WffhB+Y5fleTiZaW0YWqIboyO2u/uh4BQeosiINmmuRJ+Qwb7mYP+A==, tarball: https://registry.npmjs.org/svelte-tiptap/-/svelte-tiptap-3.0.1.tgz} - peerDependencies: - '@floating-ui/dom': ^1.0.0 - '@tiptap/core': ^3.0.0 - '@tiptap/extension-bubble-menu': ^3.0.0 - '@tiptap/extension-floating-menu': ^3.0.0 - '@tiptap/pm': ^3.0.0 - svelte: ^5.0.0 - - svelte-toolbelt@0.10.6: - resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==, tarball: https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz} - engines: {node: '>=18', pnpm: '>=8.7.0'} - peerDependencies: - svelte: ^5.30.2 - - svelte-toolbelt@0.7.1: - resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==, tarball: https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz} - engines: {node: '>=18', pnpm: '>=8.7.0'} - peerDependencies: - svelte: ^5.0.0 - - svelte@5.55.0: - resolution: {integrity: sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==, tarball: https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz} - engines: {node: '>=18'} - - tabbable@6.4.0: - resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==, tarball: https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz} - - tailwind-merge@3.5.0: - resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==, tarball: https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz} - - tailwind-variants@3.2.2: - resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==, tarball: https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz} - engines: {node: '>=16.x', pnpm: '>=7.x'} - peerDependencies: - tailwind-merge: '>=3.0.0' - tailwindcss: '*' - peerDependenciesMeta: - tailwind-merge: - optional: true - - tailwindcss@4.2.2: - resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==, tarball: https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz} - - tapable@2.3.2: - resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==, tarball: https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz} - engines: {node: '>=6'} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz} - engines: {node: '>=12.0.0'} - - totalist@3.0.1: - resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==, tarball: https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz} - engines: {node: '>=6'} - - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==, tarball: https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz} - - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==, tarball: https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz} - engines: {node: '>=18.0.0'} - hasBin: true - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==, tarball: https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz} - engines: {node: '>= 0.8.0'} - - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==, tarball: https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz} - engines: {node: '>=16'} - - typescript-eslint@8.57.2: - resolution: {integrity: sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==, tarball: https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - typescript@6.0.2: - resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==, tarball: https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz} - engines: {node: '>=14.17'} - hasBin: true - - uc.micro@2.1.0: - resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==, tarball: https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz} - - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz} - - undici@7.24.4: - resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==, tarball: https://registry.npmjs.org/undici/-/undici-7.24.4.tgz} - engines: {node: '>=20.18.1'} - - unenv@2.0.0-rc.24: - resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==, tarball: https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz} - - unicode-segmenter@0.14.5: - resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==, tarball: https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz} - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, tarball: https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz} - - url-polyfill@1.1.14: - resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==, tarball: https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, tarball: https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz} - - valibot@1.3.1: - resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==, tarball: https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz} - peerDependencies: - typescript: '>=5' - peerDependenciesMeta: - typescript: - optional: true - - vite@8.0.3: - resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==, tarball: https://registry.npmjs.org/vite/-/vite-8.0.3.tgz} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 - esbuild: ^0.27.0 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitefu@1.1.2: - resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==, tarball: https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz} - peerDependencies: - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0 - peerDependenciesMeta: - vite: - optional: true - - w3c-keyname@2.2.8: - resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==, tarball: https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz} - - web-haptics@0.0.6: - resolution: {integrity: sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw==, tarball: https://registry.npmjs.org/web-haptics/-/web-haptics-0.0.6.tgz} - peerDependencies: - react: '>=18' - react-dom: '>=18' - svelte: '>=4' - vue: '>=3' - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - svelte: - optional: true - vue: - optional: true - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, tarball: https://registry.npmjs.org/which/-/which-2.0.2.tgz} - engines: {node: '>= 8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==, tarball: https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz} - engines: {node: '>=0.10.0'} - - workerd@1.20260317.1: - resolution: {integrity: sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g==, tarball: https://registry.npmjs.org/workerd/-/workerd-1.20260317.1.tgz} - engines: {node: '>=16'} - hasBin: true - - worktop@0.8.0-next.18: - resolution: {integrity: sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw==, tarball: https://registry.npmjs.org/worktop/-/worktop-0.8.0-next.18.tgz} - engines: {node: '>=12'} - - wrangler@4.78.0: - resolution: {integrity: sha512-He/vUhk4ih0D0eFmtNnlbT6Od8j+BEokaSR+oYjbVsH0SWIrIch+eHqfLRSBjBQaOoh6HCNxcafcIkBm2u0Hag==, tarball: https://registry.npmjs.org/wrangler/-/wrangler-4.78.0.tgz} - engines: {node: '>=20.3.0'} - hasBin: true - peerDependencies: - '@cloudflare/workers-types': ^4.20260317.1 - peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - - ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==, tarball: https://registry.npmjs.org/ws/-/ws-8.18.0.tgz} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - yaml@1.10.3: - resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==, tarball: https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz} - engines: {node: '>= 6'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, tarball: https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz} - engines: {node: '>=10'} - - yocto-queue@1.2.2: - resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==, tarball: https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz} - engines: {node: '>=12.20'} - - youch-core@0.3.3: - resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==, tarball: https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz} - - youch@4.1.0-beta.10: - resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==, tarball: https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz} - - zimmerframe@1.1.4: - resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==, tarball: https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz} - -snapshots: - - '@atcute/atproto@3.1.10': - dependencies: - '@atcute/lexicons': 1.2.9 - - '@atcute/bluesky-richtext-segmenter@3.0.0': {} - - '@atcute/bluesky@3.3.0': - dependencies: - '@atcute/atproto': 3.1.10 - '@atcute/lexicons': 1.2.9 - - '@atcute/car@5.1.1': - dependencies: - '@atcute/cbor': 2.3.2 - '@atcute/cid': 2.4.1 - '@atcute/uint8array': 1.1.1 - '@atcute/varint': 2.0.0 - - '@atcute/cbor@2.3.2': - dependencies: - '@atcute/cid': 2.4.1 - '@atcute/multibase': 1.2.0 - '@atcute/uint8array': 1.1.1 - - '@atcute/cid@2.4.1': - dependencies: - '@atcute/multibase': 1.2.0 - '@atcute/uint8array': 1.1.1 - - '@atcute/client@4.2.1': - dependencies: - '@atcute/identity': 1.1.4 - '@atcute/lexicons': 1.2.9 - - '@atcute/crypto@2.4.1': - dependencies: - '@atcute/multibase': 1.2.0 - '@atcute/uint8array': 1.1.1 - '@noble/secp256k1': 3.0.0 - - '@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.4)': - dependencies: - '@atcute/identity': 1.1.4 - '@atcute/lexicons': 1.2.9 - '@atcute/util-fetch': 1.0.5 - '@badrap/valita': 0.4.6 - - '@atcute/identity@1.1.4': - dependencies: - '@atcute/lexicons': 1.2.9 - '@badrap/valita': 0.4.6 - - '@atcute/jetstream@1.1.2': - dependencies: - '@atcute/lexicons': 1.2.9 - '@badrap/valita': 0.4.6 - '@mary-ext/event-iterator': 1.0.0 - '@mary-ext/simple-event-emitter': 1.0.1 - partysocket: 1.1.16 - type-fest: 4.41.0 - yocto-queue: 1.2.2 - transitivePeerDependencies: - - react - - '@atcute/lex-cli@2.5.3': - dependencies: - '@atcute/identity': 1.1.4 - '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) - '@atcute/lexicon-doc': 2.1.2 - '@atcute/lexicon-resolver': 0.1.6(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.4))(@atcute/identity@1.1.4) - '@atcute/lexicons': 1.2.9 - '@badrap/valita': 0.4.6 - '@optique/core': 0.6.11 - '@optique/run': 0.6.11 - picocolors: 1.1.1 - prettier: 3.8.1 - - '@atcute/lexicon-doc@2.1.2': - dependencies: - '@atcute/identity': 1.1.4 - '@atcute/lexicons': 1.2.9 - '@atcute/uint8array': 1.1.1 - '@atcute/util-text': 1.2.0 - '@badrap/valita': 0.4.6 - - '@atcute/lexicon-resolver@0.1.6(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.4))(@atcute/identity@1.1.4)': - dependencies: - '@atcute/crypto': 2.4.1 - '@atcute/identity': 1.1.4 - '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) - '@atcute/lexicon-doc': 2.1.2 - '@atcute/lexicons': 1.2.9 - '@atcute/repo': 0.1.3 - '@atcute/util-fetch': 1.0.5 - '@badrap/valita': 0.4.6 - - '@atcute/lexicons@1.2.9': - dependencies: - '@atcute/uint8array': 1.1.1 - '@atcute/util-text': 1.2.0 - '@standard-schema/spec': 1.1.0 - esm-env: 1.2.2 - - '@atcute/mst@1.0.0': - dependencies: - '@atcute/cbor': 2.3.2 - '@atcute/cid': 2.4.1 - '@atcute/uint8array': 1.1.1 - - '@atcute/multibase@1.2.0': - dependencies: - '@atcute/uint8array': 1.1.1 - - '@atcute/oauth-crypto@0.1.0': - dependencies: - '@atcute/multibase': 1.2.0 - '@atcute/uint8array': 1.1.1 - '@badrap/valita': 0.4.6 - nanoid: 5.1.7 - - '@atcute/oauth-keyset@0.1.0': - dependencies: - '@atcute/oauth-crypto': 0.1.0 - - '@atcute/oauth-node-client@1.1.0': - dependencies: - '@atcute/client': 4.2.1 - '@atcute/identity': 1.1.4 - '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) - '@atcute/lexicons': 1.2.9 - '@atcute/oauth-crypto': 0.1.0 - '@atcute/oauth-keyset': 0.1.0 - '@atcute/oauth-types': 0.1.1 - '@atcute/util-fetch': 1.0.5 - '@badrap/valita': 0.4.6 - nanoid: 5.1.7 - - '@atcute/oauth-types@0.1.1': - dependencies: - '@atcute/identity': 1.1.4 - '@atcute/lexicons': 1.2.9 - '@atcute/oauth-keyset': 0.1.0 - '@badrap/valita': 0.4.6 - - '@atcute/repo@0.1.3': - dependencies: - '@atcute/car': 5.1.1 - '@atcute/cbor': 2.3.2 - '@atcute/cid': 2.4.1 - '@atcute/crypto': 2.4.1 - '@atcute/lexicons': 1.2.9 - '@atcute/mst': 1.0.0 - '@atcute/uint8array': 1.1.1 - - '@atcute/tid@1.1.2': - dependencies: - '@atcute/time-ms': 1.3.2 - - '@atcute/time-ms@1.3.2': {} - - '@atcute/uint8array@1.1.1': {} - - '@atcute/util-fetch@1.0.5': - dependencies: - '@badrap/valita': 0.4.6 - - '@atcute/util-text@1.2.0': - dependencies: - unicode-segmenter: 0.14.5 - - '@atcute/varint@2.0.0': {} - - '@badrap/valita@0.4.6': {} - - '@cloudflare/kv-asset-handler@0.4.2': {} - - '@cloudflare/unenv-preset@2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260317.1)': - dependencies: - unenv: 2.0.0-rc.24 - optionalDependencies: - workerd: 1.20260317.1 - - '@cloudflare/workerd-darwin-64@1.20260317.1': - optional: true - - '@cloudflare/workerd-darwin-arm64@1.20260317.1': - optional: true - - '@cloudflare/workerd-linux-64@1.20260317.1': - optional: true - - '@cloudflare/workerd-linux-arm64@1.20260317.1': - optional: true - - '@cloudflare/workerd-windows-64@1.20260317.1': - optional: true - - '@cloudflare/workers-types@4.20260317.1': {} - - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - - '@emnapi/core@1.9.1': - dependencies: - '@emnapi/wasi-threads': 1.2.0 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.9.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.27.3': - optional: true - - '@esbuild/aix-ppc64@0.27.4': - optional: true - - '@esbuild/android-arm64@0.27.3': - optional: true - - '@esbuild/android-arm64@0.27.4': - optional: true - - '@esbuild/android-arm@0.27.3': - optional: true - - '@esbuild/android-arm@0.27.4': - optional: true - - '@esbuild/android-x64@0.27.3': - optional: true - - '@esbuild/android-x64@0.27.4': - optional: true - - '@esbuild/darwin-arm64@0.27.3': - optional: true - - '@esbuild/darwin-arm64@0.27.4': - optional: true - - '@esbuild/darwin-x64@0.27.3': - optional: true - - '@esbuild/darwin-x64@0.27.4': - optional: true - - '@esbuild/freebsd-arm64@0.27.3': - optional: true - - '@esbuild/freebsd-arm64@0.27.4': - optional: true - - '@esbuild/freebsd-x64@0.27.3': - optional: true - - '@esbuild/freebsd-x64@0.27.4': - optional: true - - '@esbuild/linux-arm64@0.27.3': - optional: true - - '@esbuild/linux-arm64@0.27.4': - optional: true - - '@esbuild/linux-arm@0.27.3': - optional: true - - '@esbuild/linux-arm@0.27.4': - optional: true - - '@esbuild/linux-ia32@0.27.3': - optional: true - - '@esbuild/linux-ia32@0.27.4': - optional: true - - '@esbuild/linux-loong64@0.27.3': - optional: true - - '@esbuild/linux-loong64@0.27.4': - optional: true - - '@esbuild/linux-mips64el@0.27.3': - optional: true - - '@esbuild/linux-mips64el@0.27.4': - optional: true - - '@esbuild/linux-ppc64@0.27.3': - optional: true - - '@esbuild/linux-ppc64@0.27.4': - optional: true - - '@esbuild/linux-riscv64@0.27.3': - optional: true - - '@esbuild/linux-riscv64@0.27.4': - optional: true - - '@esbuild/linux-s390x@0.27.3': - optional: true - - '@esbuild/linux-s390x@0.27.4': - optional: true - - '@esbuild/linux-x64@0.27.3': - optional: true - - '@esbuild/linux-x64@0.27.4': - optional: true - - '@esbuild/netbsd-arm64@0.27.3': - optional: true - - '@esbuild/netbsd-arm64@0.27.4': - optional: true - - '@esbuild/netbsd-x64@0.27.3': - optional: true - - '@esbuild/netbsd-x64@0.27.4': - optional: true - - '@esbuild/openbsd-arm64@0.27.3': - optional: true - - '@esbuild/openbsd-arm64@0.27.4': - optional: true - - '@esbuild/openbsd-x64@0.27.3': - optional: true - - '@esbuild/openbsd-x64@0.27.4': - optional: true - - '@esbuild/openharmony-arm64@0.27.3': - optional: true - - '@esbuild/openharmony-arm64@0.27.4': - optional: true - - '@esbuild/sunos-x64@0.27.3': - optional: true - - '@esbuild/sunos-x64@0.27.4': - optional: true - - '@esbuild/win32-arm64@0.27.3': - optional: true - - '@esbuild/win32-arm64@0.27.4': - optional: true - - '@esbuild/win32-ia32@0.27.3': - optional: true - - '@esbuild/win32-ia32@0.27.4': - optional: true - - '@esbuild/win32-x64@0.27.3': - optional: true - - '@esbuild/win32-x64@0.27.4': - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.6.1))': - dependencies: - eslint: 10.1.0(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/compat@2.0.3(eslint@10.1.0(jiti@2.6.1))': - dependencies: - '@eslint/core': 1.1.1 - optionalDependencies: - eslint: 10.1.0(jiti@2.6.1) - - '@eslint/config-array@0.23.3': - dependencies: - '@eslint/object-schema': 3.0.3 - debug: 4.4.3 - minimatch: 10.2.4 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.5.3': - dependencies: - '@eslint/core': 1.1.1 - - '@eslint/core@1.1.1': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/js@10.0.1(eslint@10.1.0(jiti@2.6.1))': - optionalDependencies: - eslint: 10.1.0(jiti@2.6.1) - - '@eslint/object-schema@3.0.3': {} - - '@eslint/plugin-kit@0.6.1': - dependencies: - '@eslint/core': 1.1.1 - levn: 0.4.1 - - '@floating-ui/core@1.7.5': - dependencies: - '@floating-ui/utils': 0.2.11 - - '@floating-ui/dom@1.7.6': - dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 - - '@floating-ui/utils@0.2.11': {} - - '@foxui/core@0.8.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(tailwindcss@4.2.2)': - dependencies: - '@number-flow/svelte': 0.4.0(svelte@5.55.0) - bits-ui: 2.16.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0) - clsx: 2.1.1 - dompurify: 3.3.3 - mode-watcher: 1.1.0(svelte@5.55.0) - svelte: 5.55.0 - svelte-sonner: 1.1.0(svelte@5.55.0) - 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.0) - transitivePeerDependencies: - - '@internationalized/date' - - '@sveltejs/kit' - - react - - react-dom - - vue - - '@foxui/social@0.8.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))(@tiptap/extensions@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))(highlight.js@11.11.1)(svelte@5.55.0)(tailwindcss@4.2.2)': - dependencies: - '@atcute/bluesky': 3.3.0 - '@atcute/bluesky-richtext-segmenter': 3.0.0 - '@foxui/core': 0.8.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(tailwindcss@4.2.2) - '@foxui/text': 0.8.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))(@tiptap/extensions@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))(highlight.js@11.11.1)(svelte@5.55.0)(tailwindcss@4.2.2) - '@foxui/time': 0.8.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(tailwindcss@4.2.2) - '@use-gesture/vanilla': 10.3.1 - bits-ui: 2.16.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0) - emoji-picker-element: 1.29.1 - hls.js: 1.6.15 - is-emoji-supported: 0.0.5 - plyr: 3.8.4 - svelte: 5.55.0 - tailwindcss: 4.2.2 - transitivePeerDependencies: - - '@internationalized/date' - - '@sveltejs/kit' - - '@tiptap/extension-code-block' - - '@tiptap/extensions' - - highlight.js - - react - - react-dom - - vue - - '@foxui/text@0.8.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))(@tiptap/extensions@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))(highlight.js@11.11.1)(svelte@5.55.0)(tailwindcss@4.2.2)': - dependencies: - '@floating-ui/dom': 1.7.6 - '@foxui/core': 0.8.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(tailwindcss@4.2.2) - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - '@tiptap/extension-bubble-menu': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - '@tiptap/extension-code-block-lowlight': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/extension-code-block@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)(highlight.js@11.11.1)(lowlight@3.3.0) - '@tiptap/extension-document': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-dropcursor': 3.21.0(@tiptap/extensions@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)) - '@tiptap/extension-floating-menu': 3.21.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - '@tiptap/extension-history': 3.21.0(@tiptap/extensions@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)) - '@tiptap/extension-image': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-link': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - '@tiptap/extension-paragraph': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-placeholder': 3.21.0(@tiptap/extensions@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)) - '@tiptap/extension-strike': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-text': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-typography': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-underline': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/markdown': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - '@tiptap/pm': 3.21.0 - '@tiptap/starter-kit': 3.21.0 - '@tiptap/suggestion': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - bits-ui: 2.16.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0) - lowlight: 3.3.0 - state: link:@tiptap/pm/state - svelte: 5.55.0 - svelte-tiptap: 3.0.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/extension-bubble-menu@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))(@tiptap/extension-floating-menu@3.21.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)(svelte@5.55.0) - tailwindcss: 4.2.2 - transitivePeerDependencies: - - '@internationalized/date' - - '@sveltejs/kit' - - '@tiptap/extension-code-block' - - '@tiptap/extensions' - - highlight.js - - react - - react-dom - - vue - - '@foxui/time@0.8.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(tailwindcss@4.2.2)': - dependencies: - '@foxui/core': 0.8.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(tailwindcss@4.2.2) - '@number-flow/svelte': 0.4.0(svelte@5.55.0) - bits-ui: 2.16.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0) - svelte: 5.55.0 - tailwindcss: 4.2.2 - transitivePeerDependencies: - - '@internationalized/date' - - '@sveltejs/kit' - - react - - react-dom - - vue - - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': - dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.4.3 - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@img/colour@1.1.0': {} - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.9.1 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@internationalized/date@3.10.1': - dependencies: - '@swc/helpers': 0.5.20 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@mary-ext/event-iterator@1.0.0': - dependencies: - yocto-queue: 1.2.2 - - '@mary-ext/simple-event-emitter@1.0.1': {} - - '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': - dependencies: - '@emnapi/core': 1.9.1 - '@emnapi/runtime': 1.9.1 - '@tybys/wasm-util': 0.10.1 - optional: true - - '@noble/secp256k1@3.0.0': {} - - '@number-flow/svelte@0.4.0(svelte@5.55.0)': - dependencies: - esm-env: 1.2.2 - number-flow: 0.6.0 - svelte: 5.55.0 - - '@optique/core@0.6.11': {} - - '@optique/run@0.6.11': - dependencies: - '@optique/core': 0.6.11 - - '@oxc-project/types@0.122.0': {} - - '@polka/url@1.0.0-next.29': {} - - '@poppinss/colors@4.1.6': - dependencies: - kleur: 4.1.5 - - '@poppinss/dumper@0.6.5': - dependencies: - '@poppinss/colors': 4.1.6 - '@sindresorhus/is': 7.2.0 - supports-color: 10.2.2 - - '@poppinss/exception@1.2.3': {} - - '@remirror/core-constants@3.0.0': {} - - '@rolldown/binding-android-arm64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-darwin-x64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': - optional: true - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': - dependencies: - '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': - optional: true - - '@rolldown/pluginutils@1.0.0-rc.12': {} - - '@sindresorhus/is@7.2.0': {} - - '@speed-highlight/core@1.2.15': {} - - '@standard-schema/spec@1.1.0': {} - - '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': - dependencies: - acorn: 8.16.0 - - '@sveltejs/adapter-cloudflare@7.2.8(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(wrangler@4.78.0(@cloudflare/workers-types@4.20260317.1))': - dependencies: - '@cloudflare/workers-types': 4.20260317.1 - '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) - worktop: 0.8.0-next.18 - wrangler: 4.78.0(@cloudflare/workers-types@4.20260317.1) - - '@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': - dependencies: - '@standard-schema/spec': 1.1.0 - '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) - '@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) - '@types/cookie': 0.6.0 - acorn: 8.16.0 - cookie: 0.6.0 - devalue: 5.6.4 - esm-env: 1.2.2 - kleur: 4.1.5 - magic-string: 0.30.21 - mrmime: 2.0.1 - set-cookie-parser: 3.1.0 - sirv: 3.0.2 - svelte: 5.55.0 - vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) - optionalDependencies: - typescript: 6.0.2 - - '@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': - dependencies: - deepmerge: 4.3.1 - magic-string: 0.30.21 - obug: 2.1.1 - svelte: 5.55.0 - vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) - vitefu: 1.1.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) - - '@swc/helpers@0.5.20': - dependencies: - tslib: 2.8.1 - - '@tailwindcss/forms@0.5.11(tailwindcss@4.2.2)': - dependencies: - mini-svg-data-uri: 1.4.4 - tailwindcss: 4.2.2 - - '@tailwindcss/node@4.2.2': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.20.1 - jiti: 2.6.1 - lightningcss: 1.32.0 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.2.2 - - '@tailwindcss/oxide-android-arm64@4.2.2': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.2.2': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.2.2': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.2.2': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.2.2': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': - optional: true - - '@tailwindcss/oxide@4.2.2': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-x64': 4.2.2 - '@tailwindcss/oxide-freebsd-x64': 4.2.2 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-x64-musl': 4.2.2 - '@tailwindcss/oxide-wasm32-wasi': 4.2.2 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - - '@tailwindcss/vite@4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': - dependencies: - '@tailwindcss/node': 4.2.2 - '@tailwindcss/oxide': 4.2.2 - tailwindcss: 4.2.2 - vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) - - '@tiptap/core@3.21.0(@tiptap/pm@3.21.0)': - dependencies: - '@tiptap/pm': 3.21.0 - - '@tiptap/extension-blockquote@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - - '@tiptap/extension-bold@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - - '@tiptap/extension-bubble-menu@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)': - dependencies: - '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - '@tiptap/pm': 3.21.0 - - '@tiptap/extension-bullet-list@3.21.0(@tiptap/extension-list@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/extension-list': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - - '@tiptap/extension-code-block-lowlight@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/extension-code-block@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)(highlight.js@11.11.1)(lowlight@3.3.0)': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - '@tiptap/extension-code-block': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - '@tiptap/pm': 3.21.0 - highlight.js: 11.11.1 - lowlight: 3.3.0 - - '@tiptap/extension-code-block@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - '@tiptap/pm': 3.21.0 - - '@tiptap/extension-code@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - - '@tiptap/extension-document@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - - '@tiptap/extension-dropcursor@3.21.0(@tiptap/extensions@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/extensions': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - - '@tiptap/extension-floating-menu@3.21.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)': - dependencies: - '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - '@tiptap/pm': 3.21.0 - - '@tiptap/extension-gapcursor@3.21.0(@tiptap/extensions@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/extensions': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - - '@tiptap/extension-hard-break@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - - '@tiptap/extension-heading@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - - '@tiptap/extension-history@3.21.0(@tiptap/extensions@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/extensions': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - - '@tiptap/extension-horizontal-rule@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - '@tiptap/pm': 3.21.0 - - '@tiptap/extension-image@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - - '@tiptap/extension-italic@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - - '@tiptap/extension-link@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - '@tiptap/pm': 3.21.0 - linkifyjs: 4.3.2 - - '@tiptap/extension-list-item@3.21.0(@tiptap/extension-list@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/extension-list': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - - '@tiptap/extension-list-keymap@3.21.0(@tiptap/extension-list@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/extension-list': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - - '@tiptap/extension-list@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - '@tiptap/pm': 3.21.0 - - '@tiptap/extension-ordered-list@3.21.0(@tiptap/extension-list@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/extension-list': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - - '@tiptap/extension-paragraph@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - - '@tiptap/extension-placeholder@3.21.0(@tiptap/extensions@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/extensions': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - - '@tiptap/extension-strike@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - - '@tiptap/extension-text@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - - '@tiptap/extension-typography@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - - '@tiptap/extension-underline@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - - '@tiptap/extensions@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - '@tiptap/pm': 3.21.0 - - '@tiptap/markdown@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - '@tiptap/pm': 3.21.0 - marked: 17.0.5 - - '@tiptap/pm@3.21.0': - dependencies: - prosemirror-changeset: 2.4.0 - prosemirror-collab: 1.3.1 - prosemirror-commands: 1.7.1 - prosemirror-dropcursor: 1.8.2 - prosemirror-gapcursor: 1.4.1 - prosemirror-history: 1.5.0 - prosemirror-inputrules: 1.5.1 - prosemirror-keymap: 1.2.3 - prosemirror-markdown: 1.13.4 - prosemirror-menu: 1.3.0 - prosemirror-model: 1.25.4 - prosemirror-schema-basic: 1.2.4 - prosemirror-schema-list: 1.5.1 - prosemirror-state: 1.4.4 - prosemirror-tables: 1.8.5 - prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7) - prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.7 - - '@tiptap/starter-kit@3.21.0': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - '@tiptap/extension-blockquote': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-bold': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-bullet-list': 3.21.0(@tiptap/extension-list@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)) - '@tiptap/extension-code': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-code-block': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - '@tiptap/extension-document': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-dropcursor': 3.21.0(@tiptap/extensions@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)) - '@tiptap/extension-gapcursor': 3.21.0(@tiptap/extensions@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)) - '@tiptap/extension-hard-break': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-heading': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-horizontal-rule': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - '@tiptap/extension-italic': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-link': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - '@tiptap/extension-list': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - '@tiptap/extension-list-item': 3.21.0(@tiptap/extension-list@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)) - '@tiptap/extension-list-keymap': 3.21.0(@tiptap/extension-list@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)) - '@tiptap/extension-ordered-list': 3.21.0(@tiptap/extension-list@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)) - '@tiptap/extension-paragraph': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-strike': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-text': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extension-underline': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0)) - '@tiptap/extensions': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - '@tiptap/pm': 3.21.0 - - '@tiptap/suggestion@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)': - dependencies: - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - '@tiptap/pm': 3.21.0 - - '@tybys/wasm-util@0.10.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/cookie@0.6.0': {} - - '@types/esrecurse@4.3.1': {} - - '@types/estree@1.0.8': {} - - '@types/hast@3.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/json-schema@7.0.15': {} - - '@types/linkify-it@5.0.0': {} - - '@types/markdown-it@14.1.2': - dependencies: - '@types/linkify-it': 5.0.0 - '@types/mdurl': 2.0.0 - - '@types/mdurl@2.0.0': {} - - '@types/node@25.0.10': - dependencies: - undici-types: 7.16.0 - optional: true - - '@types/trusted-types@2.0.7': {} - - '@types/unist@3.0.3': {} - - '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/type-utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/visitor-keys': 8.57.2 - eslint: 10.1.0(jiti@2.6.1) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': - dependencies: - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@6.0.2) - '@typescript-eslint/visitor-keys': 8.57.2 - debug: 4.4.3 - eslint: 10.1.0(jiti@2.6.1) - typescript: 6.0.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.57.2(typescript@6.0.2)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@6.0.2) - '@typescript-eslint/types': 8.57.2 - debug: 4.4.3 - typescript: 6.0.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.57.2': - dependencies: - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/visitor-keys': 8.57.2 - - '@typescript-eslint/tsconfig-utils@8.57.2(typescript@6.0.2)': - dependencies: - typescript: 6.0.2 - - '@typescript-eslint/type-utils@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': - dependencies: - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@6.0.2) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - debug: 4.4.3 - eslint: 10.1.0(jiti@2.6.1) - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.57.2': {} - - '@typescript-eslint/typescript-estree@8.57.2(typescript@6.0.2)': - dependencies: - '@typescript-eslint/project-service': 8.57.2(typescript@6.0.2) - '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@6.0.2) - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/visitor-keys': 8.57.2 - debug: 4.4.3 - minimatch: 10.2.4 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) - typescript: 6.0.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.57.2': - dependencies: - '@typescript-eslint/types': 8.57.2 - eslint-visitor-keys: 5.0.1 - - '@use-gesture/core@10.3.1': {} - - '@use-gesture/vanilla@10.3.1': - dependencies: - '@use-gesture/core': 10.3.1 - - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - ajv@6.14.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - argparse@2.0.1: {} - - aria-query@5.3.1: {} - - axobject-query@4.1.0: {} - - balanced-match@4.0.4: {} - - bits-ui@2.16.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0): - dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/dom': 1.7.6 - '@internationalized/date': 3.10.1 - esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0) - svelte: 5.55.0 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0) - tabbable: 6.4.0 - transitivePeerDependencies: - - '@sveltejs/kit' - - blake3-wasm@2.1.5: {} - - brace-expansion@5.0.5: - dependencies: - balanced-match: 4.0.4 - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - clsx@2.1.1: {} - - contrail@https://codeload.github.com/flo-bit/contrail/tar.gz/ee24d4faa5d5e1515fa71398bca035b191880dbf(@atcute/identity@1.1.4): - dependencies: - '@atcute/atproto': 3.1.10 - '@atcute/client': 4.2.1 - '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) - '@atcute/jetstream': 1.1.2 - '@atcute/lexicons': 1.2.9 - hono: 4.12.9 - transitivePeerDependencies: - - '@atcute/identity' - - react - - cookie@0.6.0: {} - - cookie@1.1.1: {} - - core-js@3.49.0: {} - - crelt@1.0.6: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - cssesc@3.0.0: {} - - custom-event-polyfill@1.0.7: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-is@0.1.4: {} - - deepmerge@4.3.1: {} - - dequal@2.0.3: {} - - detect-libc@2.1.2: {} - - devalue@5.6.4: {} - - devlop@1.1.0: - dependencies: - dequal: 2.0.3 - - dompurify@3.3.3: - optionalDependencies: - '@types/trusted-types': 2.0.7 - - emoji-picker-element@1.29.1: {} - - enhanced-resolve@5.20.1: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.2 - - entities@4.5.0: {} - - error-stack-parser-es@1.0.5: {} - - esbuild@0.27.3: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 - - esbuild@0.27.4: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.4 - '@esbuild/android-arm': 0.27.4 - '@esbuild/android-arm64': 0.27.4 - '@esbuild/android-x64': 0.27.4 - '@esbuild/darwin-arm64': 0.27.4 - '@esbuild/darwin-x64': 0.27.4 - '@esbuild/freebsd-arm64': 0.27.4 - '@esbuild/freebsd-x64': 0.27.4 - '@esbuild/linux-arm': 0.27.4 - '@esbuild/linux-arm64': 0.27.4 - '@esbuild/linux-ia32': 0.27.4 - '@esbuild/linux-loong64': 0.27.4 - '@esbuild/linux-mips64el': 0.27.4 - '@esbuild/linux-ppc64': 0.27.4 - '@esbuild/linux-riscv64': 0.27.4 - '@esbuild/linux-s390x': 0.27.4 - '@esbuild/linux-x64': 0.27.4 - '@esbuild/netbsd-arm64': 0.27.4 - '@esbuild/netbsd-x64': 0.27.4 - '@esbuild/openbsd-arm64': 0.27.4 - '@esbuild/openbsd-x64': 0.27.4 - '@esbuild/openharmony-arm64': 0.27.4 - '@esbuild/sunos-x64': 0.27.4 - '@esbuild/win32-arm64': 0.27.4 - '@esbuild/win32-ia32': 0.27.4 - '@esbuild/win32-x64': 0.27.4 - - escape-string-regexp@4.0.0: {} - - eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.6.1)): - dependencies: - eslint: 10.1.0(jiti@2.6.1) - - eslint-plugin-svelte@3.16.0(eslint@10.1.0(jiti@2.6.1))(svelte@5.55.0): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) - '@jridgewell/sourcemap-codec': 1.5.5 - eslint: 10.1.0(jiti@2.6.1) - esutils: 2.0.3 - globals: 16.5.0 - known-css-properties: 0.37.0 - postcss: 8.5.8 - postcss-load-config: 3.1.4(postcss@8.5.8) - postcss-safe-parser: 7.0.1(postcss@8.5.8) - semver: 7.7.4 - svelte-eslint-parser: 1.6.0(svelte@5.55.0) - optionalDependencies: - svelte: 5.55.0 - transitivePeerDependencies: - - ts-node - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-scope@9.1.2: - dependencies: - '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.8 - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@10.1.0(jiti@2.6.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.3 - '@eslint/config-helpers': 0.5.3 - '@eslint/core': 1.1.1 - '@eslint/plugin-kit': 0.6.1 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 9.1.2 - eslint-visitor-keys: 5.0.1 - espree: 11.2.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.4 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 - transitivePeerDependencies: - - supports-color - - esm-env@1.2.2: {} - - espree@10.4.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 4.2.1 - - espree@11.2.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 5.0.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrap@2.2.4: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@typescript-eslint/types': 8.57.2 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - esutils@2.0.3: {} - - event-target-polyfill@0.0.4: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flatted@3.4.2: {} - - fsevents@2.3.3: - optional: true - - get-tsconfig@4.13.7: - dependencies: - resolve-pkg-maps: 1.0.0 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - globals@16.5.0: {} - - globals@17.4.0: {} - - graceful-fs@4.2.11: {} - - highlight.js@11.11.1: {} - - hls.js@1.6.15: {} - - hono@4.12.9: {} - - ignore@5.3.2: {} - - ignore@7.0.5: {} - - imurmurhash@0.1.4: {} - - inline-style-parser@0.2.7: {} - - is-emoji-supported@0.0.5: {} - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-reference@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - isexe@2.0.0: {} - - jiti@2.6.1: {} - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - kleur@4.1.5: {} - - known-css-properties@0.37.0: {} - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - lilconfig@2.1.0: {} - - linkify-it@5.0.0: - dependencies: - uc.micro: 2.1.0 - - linkifyjs@4.3.2: {} - - loadjs@4.3.0: {} - - locate-character@3.0.0: {} - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lowlight@3.3.0: - dependencies: - '@types/hast': 3.0.4 - devlop: 1.1.0 - highlight.js: 11.11.1 - - lz-string@1.5.0: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - markdown-it@14.1.1: - dependencies: - argparse: 2.0.1 - entities: 4.5.0 - linkify-it: 5.0.0 - mdurl: 2.0.0 - punycode.js: 2.3.1 - uc.micro: 2.1.0 - - marked@17.0.5: {} - - mdurl@2.0.0: {} - - mini-svg-data-uri@1.4.4: {} - - miniflare@4.20260317.3: - dependencies: - '@cspotcode/source-map-support': 0.8.1 - sharp: 0.34.5 - undici: 7.24.4 - workerd: 1.20260317.1 - ws: 8.18.0 - youch: 4.1.0-beta.10 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.5 - - mode-watcher@1.1.0(svelte@5.55.0): - dependencies: - runed: 0.25.0(svelte@5.55.0) - svelte: 5.55.0 - svelte-toolbelt: 0.7.1(svelte@5.55.0) - - mri@1.2.0: {} - - mrmime@2.0.1: {} - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - nanoid@5.1.7: {} - - natural-compare@1.4.0: {} - - number-flow@0.6.0: - dependencies: - esm-env: 1.2.2 - - obug@2.1.1: {} - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - orderedmap@2.1.1: {} - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - partysocket@1.1.16: - dependencies: - event-target-polyfill: 0.0.4 - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - path-to-regexp@6.3.0: {} - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@4.0.4: {} - - plyr@3.8.4: - dependencies: - core-js: 3.49.0 - custom-event-polyfill: 1.0.7 - loadjs: 4.3.0 - rangetouch: 2.0.1 - url-polyfill: 1.1.14 - - postcss-load-config@3.1.4(postcss@8.5.8): - dependencies: - lilconfig: 2.1.0 - yaml: 1.10.3 - optionalDependencies: - postcss: 8.5.8 - - postcss-safe-parser@7.0.1(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - - postcss-scss@4.0.9(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - - postcss-selector-parser@7.1.1: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss@8.5.8: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prelude-ls@1.2.1: {} - - prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.55.0): - dependencies: - prettier: 3.8.1 - svelte: 5.55.0 - - prettier-plugin-tailwindcss@0.7.2(prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.55.0))(prettier@3.8.1): - dependencies: - prettier: 3.8.1 - optionalDependencies: - prettier-plugin-svelte: 3.5.1(prettier@3.8.1)(svelte@5.55.0) - - prettier@3.8.1: {} - - prosemirror-changeset@2.4.0: - dependencies: - prosemirror-transform: 1.11.0 - - prosemirror-collab@1.3.1: - dependencies: - prosemirror-state: 1.4.4 - - prosemirror-commands@1.7.1: - dependencies: - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-transform: 1.11.0 - - prosemirror-dropcursor@1.8.2: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.7 - - prosemirror-gapcursor@1.4.1: - dependencies: - prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-view: 1.41.7 - - prosemirror-history@1.5.0: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.7 - rope-sequence: 1.3.4 - - prosemirror-inputrules@1.5.1: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-transform: 1.11.0 - - prosemirror-keymap@1.2.3: - dependencies: - prosemirror-state: 1.4.4 - w3c-keyname: 2.2.8 - - prosemirror-markdown@1.13.4: - dependencies: - '@types/markdown-it': 14.1.2 - markdown-it: 14.1.1 - prosemirror-model: 1.25.4 - - prosemirror-menu@1.3.0: - dependencies: - crelt: 1.0.6 - prosemirror-commands: 1.7.1 - prosemirror-history: 1.5.0 - prosemirror-state: 1.4.4 - - prosemirror-model@1.25.4: - dependencies: - orderedmap: 2.1.1 - - prosemirror-schema-basic@1.2.4: - dependencies: - prosemirror-model: 1.25.4 - - prosemirror-schema-list@1.5.1: - dependencies: - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-transform: 1.11.0 - - prosemirror-state@1.4.4: - dependencies: - prosemirror-model: 1.25.4 - prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.7 - - prosemirror-tables@1.8.5: - dependencies: - prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.7 - - prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7): - dependencies: - '@remirror/core-constants': 3.0.0 - escape-string-regexp: 4.0.0 - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-view: 1.41.7 - - prosemirror-transform@1.11.0: - dependencies: - prosemirror-model: 1.25.4 - - prosemirror-view@1.41.7: - dependencies: - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-transform: 1.11.0 - - punycode.js@2.3.1: {} - - punycode@2.3.1: {} - - rangetouch@2.0.1: {} - - readdirp@4.1.2: {} - - regexparam@3.0.0: {} - - resolve-pkg-maps@1.0.0: {} - - rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): - dependencies: - '@oxc-project/types': 0.122.0 - '@rolldown/pluginutils': 1.0.0-rc.12 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.12 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 - '@rolldown/binding-darwin-x64': 1.0.0-rc.12 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - - rope-sequence@1.3.4: {} - - runed@0.23.4(svelte@5.55.0): - dependencies: - esm-env: 1.2.2 - svelte: 5.55.0 - - runed@0.25.0(svelte@5.55.0): - dependencies: - esm-env: 1.2.2 - svelte: 5.55.0 - - runed@0.28.0(svelte@5.55.0): - dependencies: - esm-env: 1.2.2 - svelte: 5.55.0 - - runed@0.35.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0): - dependencies: - dequal: 2.0.3 - esm-env: 1.2.2 - lz-string: 1.5.0 - svelte: 5.55.0 - optionalDependencies: - '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) - - sade@1.8.1: - dependencies: - mri: 1.2.0 - - semver@7.7.4: {} - - set-cookie-parser@3.1.0: {} - - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.7.4 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - sirv@3.0.2: - dependencies: - '@polka/url': 1.0.0-next.29 - mrmime: 2.0.1 - totalist: 3.0.1 - - source-map-js@1.2.1: {} - - style-to-object@1.0.14: - dependencies: - inline-style-parser: 0.2.7 - - supports-color@10.2.2: {} - - svelte-check@4.4.5(picomatch@4.0.4)(svelte@5.55.0)(typescript@6.0.2): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - chokidar: 4.0.3 - fdir: 6.5.0(picomatch@4.0.4) - picocolors: 1.1.1 - sade: 1.8.1 - svelte: 5.55.0 - typescript: 6.0.2 - transitivePeerDependencies: - - picomatch - - svelte-eslint-parser@1.6.0(svelte@5.55.0): - dependencies: - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - postcss: 8.5.8 - postcss-scss: 4.0.9(postcss@8.5.8) - postcss-selector-parser: 7.1.1 - semver: 7.7.4 - optionalDependencies: - svelte: 5.55.0 - - svelte-sonner@1.1.0(svelte@5.55.0): - dependencies: - runed: 0.28.0(svelte@5.55.0) - svelte: 5.55.0 - - svelte-tiptap@3.0.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/extension-bubble-menu@3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))(@tiptap/extension-floating-menu@3.21.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0)(svelte@5.55.0): - dependencies: - '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.21.0(@tiptap/pm@3.21.0) - '@tiptap/extension-bubble-menu': 3.21.0(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - '@tiptap/extension-floating-menu': 3.21.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.21.0(@tiptap/pm@3.21.0))(@tiptap/pm@3.21.0) - '@tiptap/pm': 3.21.0 - svelte: 5.55.0 - - svelte-toolbelt@0.10.6(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0): - dependencies: - clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0) - style-to-object: 1.0.14 - svelte: 5.55.0 - transitivePeerDependencies: - - '@sveltejs/kit' - - svelte-toolbelt@0.7.1(svelte@5.55.0): - dependencies: - clsx: 2.1.1 - runed: 0.23.4(svelte@5.55.0) - style-to-object: 1.0.14 - svelte: 5.55.0 - - svelte@5.55.0: - dependencies: - '@jridgewell/remapping': 2.3.5 - '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) - '@types/estree': 1.0.8 - '@types/trusted-types': 2.0.7 - acorn: 8.16.0 - aria-query: 5.3.1 - axobject-query: 4.1.0 - clsx: 2.1.1 - devalue: 5.6.4 - esm-env: 1.2.2 - esrap: 2.2.4 - is-reference: 3.0.3 - locate-character: 3.0.0 - magic-string: 0.30.21 - zimmerframe: 1.1.4 - - tabbable@6.4.0: {} - - tailwind-merge@3.5.0: {} - - tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.2): - dependencies: - tailwindcss: 4.2.2 - optionalDependencies: - tailwind-merge: 3.5.0 - - tailwindcss@4.2.2: {} - - tapable@2.3.2: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - totalist@3.0.1: {} - - ts-api-utils@2.5.0(typescript@6.0.2): - dependencies: - typescript: 6.0.2 - - tslib@2.8.1: {} - - tsx@4.21.0: - dependencies: - esbuild: 0.27.4 - get-tsconfig: 4.13.7 - optionalDependencies: - fsevents: 2.3.3 - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - type-fest@4.41.0: {} - - typescript-eslint@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2): - dependencies: - '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/parser': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/typescript-estree': 8.57.2(typescript@6.0.2) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) - typescript: 6.0.2 - transitivePeerDependencies: - - supports-color - - typescript@6.0.2: {} - - uc.micro@2.1.0: {} - - undici-types@7.16.0: - optional: true - - undici@7.24.4: {} - - unenv@2.0.0-rc.24: - dependencies: - pathe: 2.0.3 - - unicode-segmenter@0.14.5: {} - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - url-polyfill@1.1.14: {} - - util-deprecate@1.0.2: {} - - valibot@1.3.1(typescript@6.0.2): - optionalDependencies: - typescript: 6.0.2 - - vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.8 - rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 25.0.10 - esbuild: 0.27.4 - fsevents: 2.3.3 - jiti: 2.6.1 - tsx: 4.21.0 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - - vitefu@1.1.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)): - optionalDependencies: - vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) - - w3c-keyname@2.2.8: {} - - web-haptics@0.0.6(svelte@5.55.0): - optionalDependencies: - svelte: 5.55.0 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - word-wrap@1.2.5: {} - - workerd@1.20260317.1: - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260317.1 - '@cloudflare/workerd-darwin-arm64': 1.20260317.1 - '@cloudflare/workerd-linux-64': 1.20260317.1 - '@cloudflare/workerd-linux-arm64': 1.20260317.1 - '@cloudflare/workerd-windows-64': 1.20260317.1 - - worktop@0.8.0-next.18: - dependencies: - mrmime: 2.0.1 - regexparam: 3.0.0 - - wrangler@4.78.0(@cloudflare/workers-types@4.20260317.1): - dependencies: - '@cloudflare/kv-asset-handler': 0.4.2 - '@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260317.1) - blake3-wasm: 2.1.5 - esbuild: 0.27.3 - miniflare: 4.20260317.3 - path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.24 - workerd: 1.20260317.1 - optionalDependencies: - '@cloudflare/workers-types': 4.20260317.1 - fsevents: 2.3.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - ws@8.18.0: {} - - yaml@1.10.3: {} - - yocto-queue@0.1.0: {} - - yocto-queue@1.2.2: {} - - youch-core@0.3.3: - dependencies: - '@poppinss/exception': 1.2.3 - error-stack-parser-es: 1.0.5 - - youch@4.1.0-beta.10: - dependencies: - '@poppinss/colors': 4.1.6 - '@poppinss/dumper': 0.6.5 - '@speed-highlight/core': 1.2.15 - cookie: 1.1.1 - youch-core: 0.3.3 - - zimmerframe@1.1.4: {} diff --git a/apps/group-chat/scripts/append-scheduled.ts b/apps/group-chat/scripts/append-scheduled.ts deleted file mode 100644 index 549821e..0000000 --- a/apps/group-chat/scripts/append-scheduled.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Post-build script: - * - appends a `scheduled` handler to the SvelteKit worker output - * (adapter-cloudflare doesn't support scheduled exports natively: - * https://github.com/sveltejs/kit/issues/4841) - * - re-exports the realtime Durable Object class so wrangler's class_name binding resolves. - */ -import { readFileSync, writeFileSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -const workerPath = join(root, '.svelte-kit', 'cloudflare', '_worker.js'); - -let code = readFileSync(workerPath, 'utf-8'); - -code += ` -// --- Appended by scripts/append-scheduled.ts --- -import { RealtimePubSubDO as __RealtimePubSubDO } from "@atmo-dev/contrail"; -export { __RealtimePubSubDO as RealtimePubSubDO }; - -worker_default.scheduled = async function (event, env, ctx) { - const req = new Request('http://localhost/api/cron', { - method: 'POST', - headers: { 'X-Cron-Secret': env.CRON_SECRET || '' } - }); - ctx.waitUntil(this.fetch(req, env, ctx)); -}; -`; - -writeFileSync(workerPath, code); -console.log('Appended scheduled handler + RealtimePubSubDO re-export to _worker.js'); diff --git a/apps/group-chat/scripts/generate.ts b/apps/group-chat/scripts/generate.ts deleted file mode 100644 index e6cf38f..0000000 --- a/apps/group-chat/scripts/generate.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { writeFileSync } from 'fs'; -import { baseConfig } from '../src/lib/contrail.config'; -import { generateLexicons, extractXrpcMethods } from '@atmo-dev/contrail-lexicons'; - -const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..'); - -// Use a config that has spaces/community/realtime declared so the generator -// emits the full surface (space.*, community.*, realtime.*) alongside the -// per-collection endpoints. Values are stubs — only the shape matters for codegen. -const configForGen = { - ...baseConfig, - spaces: { - authority: { type: 'tools.atmo.chat.space', serviceDid: 'did:web:localhost' }, - recordHost: {} - }, - community: { masterKey: new Uint8Array(32), serviceDid: 'did:web:localhost' }, - realtime: { ticketSecret: new Uint8Array(32) } -}; - -const generated = generateLexicons({ - config: configForGen, - rootDir: ROOT_DIR, - outputDir: join(ROOT_DIR, 'lexicons', 'generated'), - writeRuntimeFiles: true -}); - -// Emit the sorted XRPC method list as a TS module. settings.ts imports it and -// feeds it into `scope.rpc({ lxm: [...], aud })` so the OAuth consent screen -// requests exactly the methods contrail exposes (no more, no less). -const methods = extractXrpcMethods(generated); -const out = `// Auto-generated by scripts/generate.ts. Do not edit by hand. -// Source of truth: the set of XRPC methods contrail emits for this namespace. - -export const xrpcMethods = ${JSON.stringify(methods, null, '\t')} as const; -`; -writeFileSync(join(ROOT_DIR, 'src/lib/atproto/generated-methods.ts'), out); -console.log(`wrote generated-methods.ts (${methods.length} methods)`); diff --git a/apps/group-chat/scripts/sync.ts b/apps/group-chat/scripts/sync.ts deleted file mode 100644 index c1f672f..0000000 --- a/apps/group-chat/scripts/sync.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Discover users from relays and backfill their records from PDS. - * - * Usage: - * pnpm sync # local D1 - * pnpm sync:remote # prod D1 - */ -import { Contrail } from '@atmo-dev/contrail'; -import { config } from '../src/lib/contrail.config'; -import { getPlatformProxy } from 'wrangler'; - -function elapsed(start: number): string { - const ms = Date.now() - start; - if (ms < 1000) return `${ms}ms`; - if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; - const mins = Math.floor(ms / 60_000); - const secs = ((ms % 60_000) / 1000).toFixed(0); - return `${mins}m ${secs}s`; -} - -async function main() { - const remote = process.argv.includes('--remote'); - const syncStart = Date.now(); - - console.log(`=== Sync (${remote ? 'remote/prod' : 'local'} D1) ===\n`); - - const { env, dispose } = await getPlatformProxy<{ DB: D1Database }>({ - environment: remote ? 'production' : undefined - }); - - const contrail = new Contrail({ ...config, db: env.DB }); - - try { - await contrail.init(); - - console.log('--- Discovery ---'); - const discoveryStart = Date.now(); - const discovered = await contrail.discover(); - console.log(` Done: ${discovered.length} users in ${elapsed(discoveryStart)}\n`); - - console.log('--- Backfill ---'); - const backfillStart = Date.now(); - const total = await contrail.backfill({ - concurrency: 100, - onProgress: ({ records, usersComplete, usersTotal, usersFailed }) => { - const secs = (Date.now() - backfillStart) / 1000; - const rate = secs > 0 ? Math.round(records / secs) : 0; - const failStr = usersFailed > 0 ? ` | ${usersFailed} failed` : ''; - process.stdout.write( - `\r ${records} records | ${usersComplete}/${usersTotal} users | ${rate}/s | ${elapsed(backfillStart)}${failStr} ` - ); - } - }); - process.stdout.write('\n'); - console.log(` Done: ${total} records in ${elapsed(backfillStart)}\n`); - - console.log(`=== Finished in ${elapsed(syncStart)} ===`); - console.log(` Discovered: ${discovered.length} users`); - console.log(` Backfilled: ${total} records`); - } finally { - await dispose(); - } -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/apps/group-chat/src/app.css b/apps/group-chat/src/app.css deleted file mode 100644 index 6289b7f..0000000 --- a/apps/group-chat/src/app.css +++ /dev/null @@ -1,9 +0,0 @@ -@import 'tailwindcss'; - -@plugin '@tailwindcss/forms'; - -@custom-variant dark (&:where(.dark, .dark *)); - -@import '@foxui/core/theme.css'; - -@source "../node_modules/@foxui"; diff --git a/apps/group-chat/src/app.d.ts b/apps/group-chat/src/app.d.ts deleted file mode 100644 index 9190b9d..0000000 --- a/apps/group-chat/src/app.d.ts +++ /dev/null @@ -1,40 +0,0 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces -import type { OAuthSession } from '@atcute/oauth-node-client'; -import type { Client } from '@atcute/client'; -import type { Did } from '@atcute/lexicons'; -import type { DurableObjectNamespace } from '@atmo-dev/contrail'; - -declare global { - namespace App { - // interface Error {} - interface Locals { - session: OAuthSession | null; - client: Client | null; - did: Did | null; - } - // interface PageData {} - // interface PageState {} - interface Platform { - env: { - OAUTH_SESSIONS: KVNamespace; - OAUTH_STATES: KVNamespace; - CLIENT_ASSERTION_KEY: string; - COOKIE_SECRET: string; - OAUTH_PUBLIC_URL: string; - PROFILE_CACHE?: KVNamespace; - DB: D1Database; - CRON_SECRET: string; - COMMUNITY_MASTER_KEY: string; - REALTIME_TICKET_SECRET: string; - SERVICE_DID: string; - REALTIME: DurableObjectNamespace; - BLOBS?: R2Bucket; - }; - } - } -} -import type {} from '@atcute/atproto'; -import type {} from '@atcute/bluesky'; - -export {}; diff --git a/apps/group-chat/src/app.html b/apps/group-chat/src/app.html deleted file mode 100644 index 03c33ff..0000000 --- a/apps/group-chat/src/app.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - %sveltekit.head% - - -
%sveltekit.body%
- - diff --git a/apps/group-chat/src/hooks.server.ts b/apps/group-chat/src/hooks.server.ts deleted file mode 100644 index 0c28a5d..0000000 --- a/apps/group-chat/src/hooks.server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Handle } from '@sveltejs/kit'; -import './lexicon-types'; // register XRPCQueries/XRPCProcedures augmentations -import { restoreSession } from '$lib/atproto/server/session'; - -export const handle: Handle = async ({ event, resolve }) => { - const { session, client, did } = await restoreSession( - event.cookies, - event.platform?.env - ); - - event.locals.session = session; - event.locals.client = client; - event.locals.did = did; - - return resolve(event); -}; diff --git a/apps/group-chat/src/lib/atproto/auth.svelte.ts b/apps/group-chat/src/lib/atproto/auth.svelte.ts deleted file mode 100644 index 2859236..0000000 --- a/apps/group-chat/src/lib/atproto/auth.svelte.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { AppBskyActorDefs } from '@atcute/bluesky'; -import type { ActorIdentifier, Did } from '@atcute/lexicons'; -import { page } from '$app/state'; -import { ALLOW_SIGNUP, REDIRECT_TO_LAST_PAGE_ON_LOGIN } from './settings'; - -export const user = { - get profile() { - return (page.data?.profile as AppBskyActorDefs.ProfileViewDetailed | null) ?? null; - }, - get isLoggedIn() { - return !!page.data?.did; - }, - get did() { - return (page.data?.did as Did | null) ?? null; - } -}; - -function saveReturnTo() { - if (REDIRECT_TO_LAST_PAGE_ON_LOGIN) { - document.cookie = `oauth_return_to=${encodeURIComponent(window.location.pathname + window.location.search)};path=/;max-age=600;samesite=lax`; - } -} - -export async function login(handle: string) { - if (handle.startsWith('did:')) { - if (handle.length < 6) throw new Error('DID must be at least 6 characters'); - } else if (handle.includes('.') && handle.length > 3) { - handle = (handle.startsWith('@') ? handle.slice(1) : handle) as ActorIdentifier; - if (handle.length < 4) throw new Error('Handle must be at least 4 characters'); - } else if (handle.length > 3) { - handle = ((handle.startsWith('@') ? handle.slice(1) : handle) + - '.bsky.social') as ActorIdentifier; - } else { - throw new Error('Please provide a valid handle or DID.'); - } - - const { oauthLogin } = await import('./server/oauth.remote'); - const { url } = await oauthLogin({ handle }); - saveReturnTo(); - window.location.assign(url); - - // Wait for navigation (prevents UI flash) - await new Promise((_resolve, reject) => { - window.addEventListener('pageshow', () => reject(new Error('user aborted the login request')), { - once: true - }); - }); -} - -export async function signup() { - if (!ALLOW_SIGNUP) throw new Error('Signup is not enabled'); - - const { oauthLogin } = await import('./server/oauth.remote'); - const { url } = await oauthLogin({ signup: true }); - saveReturnTo(); - window.location.assign(url); - - await new Promise((_resolve, reject) => { - window.addEventListener('pageshow', () => reject(new Error('user aborted the signup request')), { - once: true - }); - }); -} - -export async function logout() { - try { - const { oauthLogout } = await import('./server/oauth.remote'); - await oauthLogout(); - } catch (e) { - console.error('Error logging out:', e); - } - - // Full reload to clear server session state - window.location.href = '/'; -} diff --git a/apps/group-chat/src/lib/atproto/generated-methods.ts b/apps/group-chat/src/lib/atproto/generated-methods.ts deleted file mode 100644 index 0458f77..0000000 --- a/apps/group-chat/src/lib/atproto/generated-methods.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Auto-generated by scripts/generate.ts. Do not edit by hand. -// Source of truth: the set of XRPC methods contrail emits for this namespace. - -export const xrpcMethods = [ - "tools.atmo.chat.channel.getRecord", - "tools.atmo.chat.channel.listRecords", - "tools.atmo.chat.channel.watchRecords", - "tools.atmo.chat.community.adopt", - "tools.atmo.chat.community.delete", - "tools.atmo.chat.community.deleteRecord", - "tools.atmo.chat.community.getHealth", - "tools.atmo.chat.community.list", - "tools.atmo.chat.community.mint", - "tools.atmo.chat.community.putRecord", - "tools.atmo.chat.community.reauth", - "tools.atmo.chat.community.space.create", - "tools.atmo.chat.community.space.delete", - "tools.atmo.chat.community.space.deleteRecord", - "tools.atmo.chat.community.space.grant", - "tools.atmo.chat.community.space.listMembers", - "tools.atmo.chat.community.space.putRecord", - "tools.atmo.chat.community.space.resync", - "tools.atmo.chat.community.space.revoke", - "tools.atmo.chat.community.space.setAccessLevel", - "tools.atmo.chat.getCursor", - "tools.atmo.chat.getOverview", - "tools.atmo.chat.getProfile", - "tools.atmo.chat.invite.create", - "tools.atmo.chat.invite.list", - "tools.atmo.chat.invite.redeem", - "tools.atmo.chat.invite.revoke", - "tools.atmo.chat.message.getRecord", - "tools.atmo.chat.message.listRecords", - "tools.atmo.chat.message.watchRecords", - "tools.atmo.chat.notifyOfUpdate", - "tools.atmo.chat.realtime.subscribe", - "tools.atmo.chat.realtime.ticket", - "tools.atmo.chat.server.getRecord", - "tools.atmo.chat.server.listRecords", - "tools.atmo.chat.server.watchRecords", - "tools.atmo.chat.space.addMember", - "tools.atmo.chat.space.createSpace", - "tools.atmo.chat.space.deleteRecord", - "tools.atmo.chat.space.getBlob", - "tools.atmo.chat.space.getRecord", - "tools.atmo.chat.space.getSpace", - "tools.atmo.chat.space.leaveSpace", - "tools.atmo.chat.space.listBlobs", - "tools.atmo.chat.space.listMembers", - "tools.atmo.chat.space.listRecords", - "tools.atmo.chat.space.listSpaces", - "tools.atmo.chat.space.putRecord", - "tools.atmo.chat.space.removeMember", - "tools.atmo.chat.space.uploadBlob", - "tools.atmo.chat.spaceExt.whoami" -] as const; diff --git a/apps/group-chat/src/lib/atproto/image-helper.ts b/apps/group-chat/src/lib/atproto/image-helper.ts deleted file mode 100644 index 08ddf93..0000000 --- a/apps/group-chat/src/lib/atproto/image-helper.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { getCDNImageBlobUrl, uploadBlob } from './methods'; - -export function compressImage( - file: File | Blob, - maxSize: number = 900 * 1024, - maxDimension: number = 2048 -): Promise<{ - blob: Blob; - aspectRatio: { - width: number; - height: number; - }; -}> { - return new Promise((resolve, reject) => { - const img = new Image(); - const reader = new FileReader(); - - reader.onload = (e) => { - if (!e.target?.result) { - return reject(new Error('Failed to read file.')); - } - img.src = e.target.result as string; - }; - - reader.onerror = (err) => reject(err); - reader.readAsDataURL(file); - - img.onload = () => { - let width = img.width; - let height = img.height; - - // If image is already small enough, return original - if (file.size <= maxSize) { - console.log('skipping compression+resizing, already small enough'); - return resolve({ - blob: file, - aspectRatio: { - width, - height - } - }); - } - - if (width > maxDimension || height > maxDimension) { - if (width > height) { - height = Math.round((maxDimension / width) * height); - width = maxDimension; - } else { - width = Math.round((maxDimension / height) * width); - height = maxDimension; - } - } - - // Create a canvas to draw the image - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d'); - if (!ctx) return reject(new Error('Failed to get canvas context.')); - ctx.drawImage(img, 0, 0, width, height); - - // Use WebP for both compression and transparency support - let quality = 0.9; - - function attemptCompression() { - canvas.toBlob( - (blob) => { - if (!blob) { - return reject(new Error('Compression failed.')); - } - if (blob.size <= maxSize || quality < 0.3) { - resolve({ - blob, - aspectRatio: { - width, - height - } - }); - } else { - quality -= 0.1; - attemptCompression(); - } - }, - 'image/webp', - quality - ); - } - - attemptCompression(); - }; - - img.onerror = (err) => reject(err); - }); -} - -export async function checkAndUploadImage( - recordWithImage: Record, - key: string = 'image', - // e.g. /api/image-proxy?url= - imageProxy?: string -) { - if (!recordWithImage[key]) return; - - // Already uploaded as blob - if (typeof recordWithImage[key] === 'object' && recordWithImage[key].$type === 'blob') { - return; - } - - if (typeof recordWithImage[key] === 'string' && imageProxy) { - const proxyUrl = imageProxy + encodeURIComponent(recordWithImage[key]); - const response = await fetch(proxyUrl); - if (!response.ok) { - throw Error('failed to get image from image proxy'); - } - - const blob = await response.blob(); - const { blob: compressed, aspectRatio } = await compressImage(blob); - - recordWithImage[key] = await uploadBlob({ blob: compressed, aspectRatio }); - - return; - } - - if (recordWithImage[key]?.blob) { - if (recordWithImage[key].objectUrl) { - URL.revokeObjectURL(recordWithImage[key].objectUrl); - } - const { blob: compressed, aspectRatio } = await compressImage(recordWithImage[key].blob); - recordWithImage[key] = await uploadBlob({ blob: compressed, aspectRatio }); - } -} - -export function getImageFromRecord( - recordWithImage: Record | undefined, - did: string, - key: string = 'image' -): string | undefined { - if (!recordWithImage?.[key]) return; - - if (typeof recordWithImage[key] === 'object' && recordWithImage[key].$type === 'blob') { - return getCDNImageBlobUrl({ did, blob: recordWithImage[key] }); - } - - if (recordWithImage[key].objectUrl) return recordWithImage[key].objectUrl; - - if (recordWithImage[key].blob) { - recordWithImage[key].objectUrl = URL.createObjectURL(recordWithImage[key].blob); - return recordWithImage[key].objectUrl; - } - - return recordWithImage[key]; -} diff --git a/apps/group-chat/src/lib/atproto/index.ts b/apps/group-chat/src/lib/atproto/index.ts deleted file mode 100644 index 8d3232a..0000000 --- a/apps/group-chat/src/lib/atproto/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export { user, login, signup, logout } from './auth.svelte'; - -export { - parseUri, - resolveHandle, - actorToDid, - getPDS, - getDetailedProfile, - getClient, - listRecords, - getRecord, - putRecord, - deleteRecord, - uploadBlob, - describeRepo, - getBlobURL, - getCDNImageBlobUrl, - searchActorsTypeahead, - createTID -} from './methods'; diff --git a/apps/group-chat/src/lib/atproto/methods.ts b/apps/group-chat/src/lib/atproto/methods.ts deleted file mode 100644 index 4a84fd5..0000000 --- a/apps/group-chat/src/lib/atproto/methods.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons'; -import { isDid } from '@atcute/lexicons/syntax'; -import { user } from './auth.svelte'; -import { DOH_RESOLVER, type AllowedCollection } from './settings'; -import { - CompositeDidDocumentResolver, - CompositeHandleResolver, - DohJsonHandleResolver, - PlcDidDocumentResolver, - WebDidDocumentResolver, - WellKnownHandleResolver -} from '@atcute/identity-resolver'; -import { Client, simpleFetchHandler } from '@atcute/client'; -import { type AppBskyActorDefs } from '@atcute/bluesky'; - -export type Collection = `${string}.${string}.${string}`; -import * as TID from '@atcute/tid'; - -/** - * Parses an AT Protocol URI into its components. - */ -export function parseUri(uri: string) { - const parts = parseResourceUri(uri); - if (!parts.ok) return; - return parts.value; -} - -/** - * Resolves a handle to a DID using DNS and HTTP methods. - */ -export async function resolveHandle({ handle }: { handle: Handle }) { - const handleResolver = new CompositeHandleResolver({ - methods: { - dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }), - http: new WellKnownHandleResolver() - } - }); - - const data = await handleResolver.resolve(handle); - return data; -} - -/** - * Returns a DID given a handle or DID string. - */ -export async function actorToDid(actor: string): Promise { - if (isDid(actor)) return actor; - return await resolveHandle({ handle: actor as Handle }); -} - -const didResolver = new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver() - } -}); - -/** - * Gets the PDS (Personal Data Server) URL for a given DID. - */ -export async function getPDS(did: Did) { - const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>); - if (!doc.service) throw new Error('No PDS found'); - for (const service of doc.service) { - if (service.id === '#atproto_pds') { - return service.serviceEndpoint.toString(); - } - } -} - -/** - * Fetches a detailed Bluesky profile for a user. - */ -export async function getDetailedProfile(data?: { did?: Did; client?: Client }) { - data ??= {}; - data.did ??= user.did ?? undefined; - - if (!data.did) throw new Error('Error getting detailed profile: no did'); - - data.client ??= new Client({ - handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) - }); - - const response = await data.client.get('app.bsky.actor.getProfile', { - params: { actor: data.did } - }); - - if (!response.ok) return; - - return response.data; -} - -/** - * Creates an AT Protocol client for a user's PDS. - */ -export async function getClient({ did }: { did: Did }) { - const pds = await getPDS(did); - if (!pds) throw new Error('PDS not found'); - - const client = new Client({ - handler: simpleFetchHandler({ service: pds }) - }); - - return client; -} - -/** - * Lists records from a repository collection with pagination support. - */ -export async function listRecords({ - did, - collection, - cursor, - limit = 100, - client -}: { - did?: Did; - collection: `${string}.${string}.${string}`; - cursor?: string; - limit?: number; - client?: Client; -}) { - did ??= user.did ?? undefined; - if (!collection) { - throw new Error('Missing parameters for listRecords'); - } - if (!did) { - throw new Error('Missing did for listRecords'); - } - - client ??= await getClient({ did }); - - const allRecords = []; - - let currentCursor = cursor; - do { - const response = await client.get('com.atproto.repo.listRecords', { - params: { - repo: did, - collection, - limit: !limit || limit > 100 ? 100 : limit, - cursor: currentCursor - } - }); - - if (!response.ok) { - return allRecords; - } - - allRecords.push(...response.data.records); - currentCursor = response.data.cursor; - } while (currentCursor && (!limit || allRecords.length < limit)); - - return allRecords; -} - -/** - * Fetches a single record from a repository. - */ -export async function getRecord({ - did, - collection, - rkey = 'self', - client -}: { - did?: Did; - collection: Collection; - rkey?: string; - client?: Client; -}) { - did ??= user.did ?? undefined; - - if (!collection) { - throw new Error('Missing parameters for getRecord'); - } - if (!did) { - throw new Error('Missing did for getRecord'); - } - - client ??= await getClient({ did }); - - const record = await client.get('com.atproto.repo.getRecord', { - params: { - repo: did, - collection, - rkey - } - }); - - return JSON.parse(JSON.stringify(record.data)); -} - -/** - * Creates or updates a record via remote function. - */ -export async function putRecord({ - collection, - rkey = 'self', - record -}: { - collection: AllowedCollection; - rkey?: string; - record: Record; -}) { - if (!user.did) throw new Error('Not logged in'); - - const { putRecord: putRecordRemote } = await import('./server/repo.remote'); - const data = await putRecordRemote({ collection, rkey, record }); - return { ok: true, data }; -} - -/** - * Deletes a record via remote function. - */ -export async function deleteRecord({ - collection, - rkey = 'self' -}: { - collection: AllowedCollection; - rkey: string; -}) { - if (!user.did) throw new Error('Not logged in'); - - const { deleteRecord: deleteRecordRemote } = await import('./server/repo.remote'); - const data = await deleteRecordRemote({ collection, rkey }); - return data.ok; -} - -/** - * Gets the dimensions of an image blob. - */ -function getImageDimensions(blob: Blob): Promise<{ width: number; height: number }> { - return new Promise((resolve, reject) => { - const img = new Image(); - const url = URL.createObjectURL(blob); - img.onload = () => { - URL.revokeObjectURL(url); - resolve({ width: img.naturalWidth, height: img.naturalHeight }); - }; - img.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error('Failed to load image for dimensions')); - }; - img.src = url; - }); -} - -/** - * Uploads a blob via remote function. - * Converts the Blob to a byte array for serialization across the remote boundary. - * For image blobs, automatically includes aspectRatio with width and height. - */ -export async function uploadBlob({ - blob, - aspectRatio -}: { - blob: Blob; - aspectRatio?: { width: number; height: number }; -}) { - if (!user.did) throw new Error("Can't upload blob: Not logged in"); - - // Auto-detect dimensions for image blobs if not provided - if (!aspectRatio && blob.type.startsWith('image/')) { - try { - aspectRatio = await getImageDimensions(blob); - } catch { - // Non-critical — proceed without aspectRatio - } - } - - const arrayBuffer = await blob.arrayBuffer(); - const bytes = Array.from(new Uint8Array(arrayBuffer)); - - const { uploadBlob: uploadBlobRemote } = await import('./server/repo.remote'); - const result = await uploadBlobRemote({ bytes, mimeType: blob.type || 'application/octet-stream' }); - - if (aspectRatio) { - return { ...result, aspectRatio }; - } - return result; -} - -/** - * Gets metadata about a repository. - */ -export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { - did ??= user.did ?? undefined; - if (!did) { - throw new Error('Error describeRepo: No did'); - } - client ??= await getClient({ did }); - - const repo = await client.get('com.atproto.repo.describeRepo', { - params: { - repo: did - } - }); - if (!repo.ok) return; - - return repo.data; -} - -/** - * Constructs a URL to fetch a blob directly from a user's PDS. - */ -export async function getBlobURL({ - did, - blob -}: { - did: Did; - blob: { - $type: 'blob'; - ref: { - $link: string; - }; - }; -}) { - const pds = await getPDS(did); - return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; -} - -/** - * Constructs a Bluesky CDN URL for an image blob. - */ -export function getCDNImageBlobUrl({ - did, - blob -}: { - did?: string; - blob: { - $type: 'blob'; - ref: { - $link: string; - }; - }; -}) { - did ??= user.did ?? undefined; - - return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; -} - -/** - * Searches for actors with typeahead/autocomplete functionality. - */ -export async function searchActorsTypeahead( - q: string, - limit: number = 10, - host?: string -): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> { - host ??= 'https://public.api.bsky.app'; - - const client = new Client({ - handler: simpleFetchHandler({ service: host }) - }); - - const response = await client.get('app.bsky.actor.searchActorsTypeahead', { - params: { - q, - limit - } - }); - - if (!response.ok) return { actors: [], q }; - - return { actors: response.data.actors, q }; -} - -/** - * Return a TID based on current time - */ -export function createTID() { - return TID.now(); -} diff --git a/apps/group-chat/src/lib/atproto/port.ts b/apps/group-chat/src/lib/atproto/port.ts deleted file mode 100644 index 4d6686b..0000000 --- a/apps/group-chat/src/lib/atproto/port.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Dev server port — generated by setup-dev, shared by vite, oauth, and tunnel. -// Each project gets a unique random port (5200–7200) so multiple projects can run simultaneously. -export const DEV_PORT = 5291; diff --git a/apps/group-chat/src/lib/atproto/scripts/generate-key.ts b/apps/group-chat/src/lib/atproto/scripts/generate-key.ts deleted file mode 100644 index 3d1cc8a..0000000 --- a/apps/group-chat/src/lib/atproto/scripts/generate-key.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { generateClientAssertionKey } from '@atcute/oauth-node-client'; - -const key = await generateClientAssertionKey('main-key'); -console.log(JSON.stringify(key)); diff --git a/apps/group-chat/src/lib/atproto/scripts/generate-secret.ts b/apps/group-chat/src/lib/atproto/scripts/generate-secret.ts deleted file mode 100644 index 9092066..0000000 --- a/apps/group-chat/src/lib/atproto/scripts/generate-secret.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { randomBytes } from 'node:crypto'; - -console.log(randomBytes(32).toString('base64url')); diff --git a/apps/group-chat/src/lib/atproto/scripts/setup-dev.ts b/apps/group-chat/src/lib/atproto/scripts/setup-dev.ts deleted file mode 100644 index bdc8ccf..0000000 --- a/apps/group-chat/src/lib/atproto/scripts/setup-dev.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { existsSync } from 'node:fs'; -import { copyFile, readFile, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { randomBytes, randomInt } from 'node:crypto'; - -import { generateClientAssertionKey } from '@atcute/oauth-node-client'; - -const cwd = process.cwd(); -const examplePath = resolve(cwd, '.env.example'); -const envPath = resolve(cwd, '.env'); - -if (!existsSync(envPath)) { - if (!existsSync(examplePath)) { - throw new Error(`missing .env.example (expected at ${examplePath})`); - } - await copyFile(examplePath, envPath); - console.log(`created ${envPath}`); -} - -const upsertVar = (input: string, key: string, value: string): string => { - const line = `${key}=${value}`; - const re = new RegExp(`^${key}=.*$`, 'm'); - - if (re.test(input)) { - const match = input.match(re); - const current = match ? match[0].slice(key.length + 1).trim() : ''; - // Only overwrite if empty/placeholder - if (current === '' || current === "''" || current === '""' || current.includes('...')) { - return input.replace(re, line); - } - return input; - } - - const suffix = input.endsWith('\n') || input.length === 0 ? '' : '\n'; - return `${input}${suffix}${line}\n`; -}; - -let vars = await readFile(envPath, 'utf8'); - -const secret = randomBytes(32).toString('base64url'); -vars = upsertVar(vars, 'COOKIE_SECRET', secret); - -const jwk = await generateClientAssertionKey('main-key'); -vars = upsertVar(vars, 'CLIENT_ASSERTION_KEY', JSON.stringify(jwk)); - -await writeFile(envPath, vars); -console.log(`updated ${envPath}`); - -// Generate a random dev port (5200–7200) so multiple projects can run simultaneously -const portPath = resolve(cwd, 'src/lib/atproto/port.ts'); -const portFile = await readFile(portPath, 'utf8'); -const currentPort = portFile.match(/DEV_PORT\s*=\s*(\d+)/); -if (currentPort && parseInt(currentPort[1]) === 5183) { - const port = randomInt(5200, 7201); - const updated = portFile.replace(/DEV_PORT\s*=\s*\d+/, `DEV_PORT = ${port}`); - await writeFile(portPath, updated); - console.log(`set DEV_PORT to ${port} in port.ts`); -} else { - console.log(`DEV_PORT already customized (${currentPort?.[1]}), skipping`); -} diff --git a/apps/group-chat/src/lib/atproto/scripts/tunnel.ts b/apps/group-chat/src/lib/atproto/scripts/tunnel.ts deleted file mode 100644 index 9c37d5f..0000000 --- a/apps/group-chat/src/lib/atproto/scripts/tunnel.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { readFileSync, writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { spawn } from 'node:child_process'; -import { DEV_PORT } from '../port'; - -const cwd = process.cwd(); -const envPath = resolve(cwd, '.env'); -const vitePath = resolve(cwd, 'vite.config.ts'); - -let tunnelUrl: string | null = null; -let statusBarActive = false; - -// ── ANSI status bar ────────────────────────────────────────────── -// Reserves the bottom row of the terminal for a persistent status line. -// Logs scroll in the region above it. - -function getColumns(): number { - return process.stdout.columns || 80; -} - -function getRows(): number { - return process.stdout.rows || 24; -} - -function setupScrollRegion(): void { - if (!process.stdout.isTTY) return; - statusBarActive = true; - const rows = getRows(); - // Set scroll region to all rows except the last - process.stdout.write(`\x1b[1;${rows - 1}r`); - // Move cursor into scroll region - process.stdout.write(`\x1b[${rows - 1};1H`); -} - -function drawStatusBar(text: string): void { - if (!process.stdout.isTTY) { - process.stdout.write(text + '\n'); - return; - } - const rows = getRows(); - const cols = getColumns(); - // Save cursor, move to bottom row, clear it, write status, restore cursor - process.stdout.write('\x1b7'); - process.stdout.write(`\x1b[${rows};1H`); - process.stdout.write('\x1b[2K'); - // Inverse video for the bar - process.stdout.write(`\x1b[7m ${text.padEnd(cols - 1)}\x1b[0m`); - process.stdout.write('\x1b8'); -} - -function clearStatusBar(): void { - if (!process.stdout.isTTY || !statusBarActive) return; - const rows = getRows(); - // Reset scroll region to full terminal - process.stdout.write(`\x1b[1;${rows}r`); - // Clear the bottom row - process.stdout.write(`\x1b[${rows};1H\x1b[2K`); - // Move cursor up - process.stdout.write(`\x1b[${rows - 1};1H`); - statusBarActive = false; -} - -function writeLog(text: string): void { - if (statusBarActive && process.stdout.isTTY) { - // Write inside the scroll region, which auto-scrolls above the bar - process.stdout.write(text); - } else { - process.stdout.write(text); - } -} - -// Redraw on terminal resize -process.stdout.on('resize', () => { - if (!statusBarActive || !tunnelUrl) return; - setupScrollRegion(); - drawStatusBar(`Tunnel: ${tunnelUrl} | Ctrl+C to stop`); -}); - -// ── .env helpers ───────────────────────────────────────────────── - -function readEnv(): string { - return readFileSync(envPath, 'utf8'); -} - -function writeEnv(content: string): void { - writeFileSync(envPath, content); -} - -function setEnvVar(key: string, value: string): void { - let env = readEnv(); - const re = new RegExp(`^(#\\s*)?${key}=.*$`, 'm'); - const line = `${key}=${value}`; - - if (re.test(env)) { - env = env.replace(re, line); - } else { - env = env.trimEnd() + '\n' + line + '\n'; - } - writeEnv(env); -} - -function clearEnvVar(key: string): void { - let env = readEnv(); - const re = new RegExp(`^${key}=.*$`, 'm'); - - if (re.test(env)) { - env = env.replace(re, `# ${key}=`); - writeEnv(env); - } -} - -// ── vite config helpers ────────────────────────────────────────── - -function setViteAllowedHosts(hostname: string): void { - let vite = readFileSync(vitePath, 'utf8'); - - if (/allowedHosts\s*:/.test(vite)) { - vite = vite.replace(/allowedHosts\s*:\s*\[.*?\]/s, `allowedHosts: ['${hostname}']`); - } else if (/server\s*:\s*\{/.test(vite)) { - vite = vite.replace(/server\s*:\s*\{/, `server: {\n\t\tallowedHosts: ['${hostname}'],`); - } - - writeFileSync(vitePath, vite); -} - -function clearViteAllowedHosts(): void { - let vite = readFileSync(vitePath, 'utf8'); - - if (/allowedHosts\s*:/.test(vite)) { - vite = vite.replace(/allowedHosts\s*:\s*\[.*?\]/s, 'allowedHosts: []'); - } - - writeFileSync(vitePath, vite); -} - -// ── cleanup ────────────────────────────────────────────────────── - -function cleanup(): void { - clearStatusBar(); - console.log('\nCleaning up...'); - if (tunnelUrl) { - clearEnvVar('OAUTH_PUBLIC_URL'); - console.log(' Cleared OAUTH_PUBLIC_URL from .env'); - clearViteAllowedHosts(); - console.log(' Cleared allowedHosts from vite.config.ts'); - } -} - -// ── main ───────────────────────────────────────────────────────── - -const child = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${DEV_PORT}`], { - stdio: ['ignore', 'pipe', 'pipe'] -}); - -child.stderr.on('data', (data: Buffer) => { - const output = data.toString(); - - if (!tunnelUrl) { - const match = output.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/); - if (match) { - tunnelUrl = match[0]; - const hostname = new URL(tunnelUrl).hostname; - - setEnvVar('OAUTH_PUBLIC_URL', tunnelUrl); - setViteAllowedHosts(hostname); - - writeLog(`\n Set OAUTH_PUBLIC_URL=${tunnelUrl}\n`); - writeLog(` Set vite allowedHosts to [${hostname}]\n`); - writeLog(` Tunnel is ready! Restart your dev server to pick up the new URL.\n\n`); - - setupScrollRegion(); - drawStatusBar(`Tunnel: ${tunnelUrl} | Ctrl+C to stop`); - return; - } - } - - writeLog(output); -}); - -child.stdout.on('data', (data: Buffer) => { - writeLog(data.toString()); -}); - -child.on('close', (code) => { - cleanup(); - process.exit(code ?? 0); -}); - -process.on('SIGINT', () => { - child.kill('SIGINT'); -}); - -process.on('SIGTERM', () => { - child.kill('SIGTERM'); -}); diff --git a/apps/group-chat/src/lib/atproto/server/kv-store.ts b/apps/group-chat/src/lib/atproto/server/kv-store.ts deleted file mode 100644 index 35c3dcb..0000000 --- a/apps/group-chat/src/lib/atproto/server/kv-store.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Store } from '@atcute/oauth-node-client'; - -export class KVStore implements Store { - private kv: KVNamespace; - private expirationTtl?: number; - - constructor(kv: KVNamespace, options?: { expirationTtl?: number }) { - this.kv = kv; - this.expirationTtl = options?.expirationTtl; - } - - async get(key: K): Promise { - const value = await this.kv.get(key, 'text'); - if (value === null) return undefined; - return JSON.parse(value) as V; - } - - async set(key: K, value: V): Promise { - await this.kv.put(key, JSON.stringify(value), { - expirationTtl: this.expirationTtl - }); - } - - async delete(key: K): Promise { - await this.kv.delete(key); - } - - async clear(): Promise { - let cursor: string | undefined; - do { - const result = await this.kv.list({ cursor }); - for (const key of result.keys) { - await this.kv.delete(key.name); - } - cursor = result.list_complete ? undefined : result.cursor; - } while (cursor); - } -} diff --git a/apps/group-chat/src/lib/atproto/server/oauth.remote.ts b/apps/group-chat/src/lib/atproto/server/oauth.remote.ts deleted file mode 100644 index f087d53..0000000 --- a/apps/group-chat/src/lib/atproto/server/oauth.remote.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as v from 'valibot'; -import { error } from '@sveltejs/kit'; -import { command, getRequestEvent } from '$app/server'; -import { createOAuthClient } from './oauth'; -import { getSignedCookie } from './signed-cookie'; -import { scopes, signUpPDS } from '../settings'; -import type { ActorIdentifier, Did } from '@atcute/lexicons'; - -export const oauthLogin = command( - v.object({ - handle: v.optional(v.pipe(v.string(), v.minLength(3))), - signup: v.optional(v.boolean()) - }), - async (input) => { - const { platform } = getRequestEvent(); - - try { - const oauth = createOAuthClient(platform?.env); - - const target = input.signup - ? ({ type: 'pds', serviceUrl: signUpPDS } as const) - : ({ type: 'account', identifier: input.handle as ActorIdentifier } as const); - - const { url } = await oauth.authorize({ - target, - scope: scopes.join(' '), - prompt: input.signup ? 'create' : undefined - }); - - return { url: url.toString() }; - } catch (e) { - if (e && typeof e === 'object' && 'status' in e) throw e; // re-throw SvelteKit errors - const message = e instanceof Error ? e.message : 'Login failed'; - error(400, message); - } - } -); - -export const oauthLogout = command(async () => { - const { cookies, platform } = getRequestEvent(); - const did = getSignedCookie(cookies, 'did') as Did | null; - - if (did) { - try { - const oauth = createOAuthClient(platform?.env); - await oauth.revoke(did); - } catch (e) { - console.error('Error revoking session:', e); - } - } - - cookies.delete('did', { path: '/' }); - cookies.delete('scope', { path: '/' }); - - return { ok: true }; -}); diff --git a/apps/group-chat/src/lib/atproto/server/oauth.ts b/apps/group-chat/src/lib/atproto/server/oauth.ts deleted file mode 100644 index e0d0f95..0000000 --- a/apps/group-chat/src/lib/atproto/server/oauth.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - OAuthClient, - MemoryStore, - type ClientAssertionPrivateJwk, - type OAuthClientStores, - type OAuthSession, - type StoredSession, - type StoredState -} from '@atcute/oauth-node-client'; -import type { Did } from '@atcute/lexicons'; -import { - CompositeDidDocumentResolver, - CompositeHandleResolver, - DohJsonHandleResolver, - LocalActorResolver, - PlcDidDocumentResolver, - WebDidDocumentResolver, - WellKnownHandleResolver -} from '@atcute/identity-resolver'; -import { KVStore } from './kv-store'; -import { DOH_RESOLVER, REDIRECT_PATH, scopes } from '../settings'; -import { DEV_PORT } from '../port'; -import { dev } from '$app/environment'; - -function createActorResolver() { - return new LocalActorResolver({ - handleResolver: new CompositeHandleResolver({ - methods: { - dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }), - http: new WellKnownHandleResolver() - } - }), - didDocumentResolver: new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver() - } - }) - }); -} - -function createStores(env?: App.Platform['env']): OAuthClientStores { - if (env?.OAUTH_SESSIONS && env?.OAUTH_STATES) { - return { - sessions: new KVStore(env.OAUTH_SESSIONS), - states: new KVStore(env.OAUTH_STATES, { expirationTtl: 600 }) - }; - } - // Fallback to in-memory stores (dev without wrangler) - return { - sessions: new MemoryStore(), - states: new MemoryStore({ ttl: 600_000 }) - }; -} - -export function createOAuthClient(env?: App.Platform['env']): OAuthClient { - const actorResolver = createActorResolver(); - const stores = createStores(env); - - if (dev && !env?.OAUTH_PUBLIC_URL) { - // Dev without tunnel: loopback public client (no keyset). - // Omit client_id — the library builds it automatically from redirect_uris + scope. - // redirect_uris must use 127.0.0.1 (not localhost). - return new OAuthClient({ - metadata: { - redirect_uris: [`http://127.0.0.1:${DEV_PORT}${REDIRECT_PATH}`], - scope: scopes - }, - actorResolver, - stores - }); - } - - // Confidential client (production, or dev with tunnel via OAUTH_PUBLIC_URL) - if (!env?.OAUTH_PUBLIC_URL) { - throw new Error('OAUTH_PUBLIC_URL is not set'); - } - if (!env.CLIENT_ASSERTION_KEY) { - throw new Error('CLIENT_ASSERTION_KEY secret is not set. Run: pnpm env:generate-key'); - } - const site = env.OAUTH_PUBLIC_URL; - const key: ClientAssertionPrivateJwk = JSON.parse(env.CLIENT_ASSERTION_KEY); - - return new OAuthClient({ - metadata: { - client_id: site + '/oauth-client-metadata.json', - redirect_uris: [site + REDIRECT_PATH], - scope: scopes, - jwks_uri: site + '/oauth/jwks.json' - }, - keyset: [key], - actorResolver, - stores - }); -} - -export type { OAuthSession }; diff --git a/apps/group-chat/src/lib/atproto/server/profile.ts b/apps/group-chat/src/lib/atproto/server/profile.ts deleted file mode 100644 index 20521fc..0000000 --- a/apps/group-chat/src/lib/atproto/server/profile.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Did } from '@atcute/lexicons'; -import { getDetailedProfile, describeRepo } from '../methods'; - -const PROFILE_CACHE_TTL = 60 * 60; // 1 hour - -/** - * Loads a user's profile, with optional KV caching. - * Falls back to a fresh fetch if the cache KV doesn't exist or on cache miss. - * Returns undefined if the profile can't be loaded. - */ -export async function loadProfile(did: Did, profileCache?: KVNamespace) { - // Try cache first - if (profileCache) { - try { - const cached = await profileCache.get(did, 'json'); - if (cached) return cached as Record; - } catch { - // Cache read failed, continue to fresh fetch - } - } - - const profile = await fetchProfile(did); - - // Write to cache (fire-and-forget) - if (profileCache && profile) { - profileCache - .put(did, JSON.stringify(profile), { expirationTtl: PROFILE_CACHE_TTL }) - .catch(() => {}); - } - - return profile; -} - -async function fetchProfile(did: Did) { - try { - let profile = await getDetailedProfile({ did }); - - if (!profile || profile.handle === 'handle.invalid') { - const repo = await describeRepo({ did }); - profile = { - did, - handle: repo?.handle || 'handle.invalid' - } as typeof profile; - } - - return profile; - } catch (e) { - console.error('Failed to load profile:', e); - return undefined; - } -} diff --git a/apps/group-chat/src/lib/atproto/server/repo.remote.ts b/apps/group-chat/src/lib/atproto/server/repo.remote.ts deleted file mode 100644 index 6d4c52c..0000000 --- a/apps/group-chat/src/lib/atproto/server/repo.remote.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { command, getRequestEvent } from '$app/server'; -import * as v from 'valibot'; -import { collections } from '../settings'; -import { getContrail } from '$lib/contrail'; - -// Validate collection format and check against allowed list from settings -const collectionSchema = v.pipe( - v.string(), - v.regex(/^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/), - v.check((c) => collections.includes(c as (typeof collections)[number]), 'Collection not in allowed list') -); - -// AT Protocol rkey: TID, 'self', or other valid record keys (alphanumeric, dash, underscore, dot) -const rkeySchema = v.optional(v.pipe(v.string(), v.regex(/^[a-zA-Z0-9._:~-]{1,512}$/))); - -export const putRecord = command( - v.object({ - collection: collectionSchema, - rkey: rkeySchema, - record: v.record(v.string(), v.unknown()) - }), - async (input) => { - const { locals } = getRequestEvent(); - if (!locals.client || !locals.did) error(401, 'Not authenticated'); - - const response = await locals.client.post('com.atproto.repo.putRecord', { - input: { - collection: input.collection as `${string}.${string}.${string}`, - repo: locals.did, - rkey: input.rkey || 'self', - record: input.record - } - }); - - // Immediately index the new/updated record in contrail - const { platform } = getRequestEvent(); - if (platform?.env) { - const contrail = await getContrail(platform.env); - await contrail.notify(response.data.uri, platform.env.DB).catch(() => {}); - } - - return response.data; - } -); - -export const deleteRecord = command( - v.object({ - collection: collectionSchema, - rkey: rkeySchema - }), - async (input) => { - const { locals } = getRequestEvent(); - if (!locals.client || !locals.did) error(401, 'Not authenticated'); - - const response = await locals.client.post('com.atproto.repo.deleteRecord', { - input: { - collection: input.collection as `${string}.${string}.${string}`, - repo: locals.did, - rkey: input.rkey || 'self' - } - }); - - return { ok: response.ok }; - } -); - -export const uploadBlob = command( - v.object({ - bytes: v.array(v.number()), - mimeType: v.string() - }), - async (input) => { - const { locals } = getRequestEvent(); - if (!locals.client || !locals.did) error(401, 'Not authenticated'); - - const blob = new Blob([new Uint8Array(input.bytes)], { type: input.mimeType }); - - const response = await locals.client.post('com.atproto.repo.uploadBlob', { - params: { repo: locals.did }, - input: blob - }); - - if (!response.ok) error(500, 'Upload failed'); - - return response.data.blob as { - $type: 'blob'; - ref: { $link: string }; - mimeType: string; - size: number; - }; - } -); diff --git a/apps/group-chat/src/lib/atproto/server/session.ts b/apps/group-chat/src/lib/atproto/server/session.ts deleted file mode 100644 index 2c1fe8f..0000000 --- a/apps/group-chat/src/lib/atproto/server/session.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Cookies } from '@sveltejs/kit'; -import { Client } from '@atcute/client'; -import type { Did } from '@atcute/lexicons'; -import { - type OAuthSession, - TokenInvalidError, - TokenRefreshError, - TokenRevokedError, - AuthMethodUnsatisfiableError -} from '@atcute/oauth-node-client'; -import { createOAuthClient } from './oauth'; -import { getSignedCookie } from './signed-cookie'; -import { scopes } from '../settings'; - -export type SessionLocals = { - session: OAuthSession | null; - client: Client | null; - did: Did | null; -}; - -/** - * Restores an OAuth session from the signed `did` cookie. - * Returns session locals to be assigned to `event.locals`. - * Deletes the cookie only if the session is genuinely unrecoverable. - * Transient failures (network, KV) preserve the cookie for retry. - */ -export async function restoreSession( - cookies: Cookies, - env?: App.Platform['env'] -): Promise { - const did = getSignedCookie(cookies, 'did') as Did | null; - - if (!did) { - return { session: null, client: null, did: null }; - } - - // If permissions changed since login, invalidate the session - const savedScope = getSignedCookie(cookies, 'scope'); - if (savedScope !== null && savedScope !== scopes.join(' ')) { - cookies.delete('did', { path: '/' }); - cookies.delete('scope', { path: '/' }); - return { session: null, client: null, did: null }; - } - - try { - const oauth = createOAuthClient(env); - const session = await oauth.restore(did); - - return { - session, - client: new Client({ handler: session }), - did - }; - } catch (e) { - console.error('Failed to restore session:', e); - - // Only delete cookies when the session is genuinely unrecoverable. - // Transient errors (network issues, KV hiccups) should preserve the - // cookie so the next request can retry without forcing a full re-login. - const isSessionGone = - e instanceof TokenInvalidError || - e instanceof TokenRevokedError || - e instanceof TokenRefreshError || - e instanceof AuthMethodUnsatisfiableError; - - if (isSessionGone) { - cookies.delete('did', { path: '/' }); - cookies.delete('scope', { path: '/' }); - } - - return { session: null, client: null, did: null }; - } -} diff --git a/apps/group-chat/src/lib/atproto/server/signed-cookie.ts b/apps/group-chat/src/lib/atproto/server/signed-cookie.ts deleted file mode 100644 index 6cde19c..0000000 --- a/apps/group-chat/src/lib/atproto/server/signed-cookie.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; - -import type { Cookies } from '@sveltejs/kit'; - -import { env } from '$env/dynamic/private'; -import { dev } from '$app/environment'; - -const SEPARATOR = '.'; - -function getSecret(): string { - const secret = env.COOKIE_SECRET; - if (secret) return secret; - if (dev) return 'dev-cookie-secret-not-for-production'; - throw new Error('COOKIE_SECRET is not set'); -} - -function toBase64Url(bytes: Uint8Array): string { - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -function fromBase64Url(str: string): Uint8Array { - const padded = str + '='.repeat((4 - (str.length % 4)) % 4); - const base64 = padded.replace(/-/g, '+').replace(/_/g, '/'); - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -function hmacSha256(data: string): Uint8Array { - return createHmac('sha256', getSecret()).update(data).digest(); -} - -export function getSignedCookie(cookies: Cookies, name: string): string | null { - const signed = cookies.get(name); - if (!signed) return null; - - const idx = signed.lastIndexOf(SEPARATOR); - if (idx === -1) return null; - - const value = signed.slice(0, idx); - const sig = signed.slice(idx + 1); - - let expected: Uint8Array; - let got: Uint8Array; - try { - expected = hmacSha256(value); - got = fromBase64Url(sig); - } catch { - return null; - } - - if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null; - - return value; -} - -export function setSignedCookie( - cookies: Cookies, - name: string, - value: string, - options: Parameters[2] -): void { - const sig = toBase64Url(hmacSha256(value)); - const signed = `${value}${SEPARATOR}${sig}`; - cookies.set(name, signed, options); -} diff --git a/apps/group-chat/src/lib/atproto/settings.ts b/apps/group-chat/src/lib/atproto/settings.ts deleted file mode 100644 index 1d81672..0000000 --- a/apps/group-chat/src/lib/atproto/settings.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { dev } from '$app/environment'; -import { scope } from '@atcute/oauth-node-client'; -import { xrpcMethods } from './generated-methods'; - -// Writable collections on the USER's PDS. This app writes nothing to the user's -// PDS — every record lives in contrail spaces, authored by the user DID via -// space.putRecord or by the community DID via community.space.putRecord. -// Keep the list empty; repo scope is omitted below. -export const collections = [] as const; - -export type AllowedCollection = (typeof collections)[number]; - -// OAuth scope. -// -// We ask for: -// - `atproto` — baseline identity scope. -// - `rpc` for every XRPC method contrail emits in this namespace. The list -// is auto-generated by `pnpm generate` (source: contrail's permissionSet), -// so adding/removing collections or toggling modules automatically updates -// the consent prompt. -// - `aud: '*'` — we don't couple the scope to a specific service DID so one -// OAuth client metadata works in dev and prod. The PDS still only mints -// JWTs for the exact `aud` the app asks for at getServiceAuth time. -export const scopes = [ - 'atproto', - scope.rpc({ - aud: '*', - lxm: [...xrpcMethods] as `${string}.${string}.${string}`[] - }) -]; - -// set to false to disable signup -export const ALLOW_SIGNUP = true; - -// which PDS to use for signup (change to your preferred PDS) -const devPDS = 'https://pds.rip/'; -const prodPDS = 'https://selfhosted.social/'; -export const signUpPDS = dev ? devPDS : prodPDS; - -// where to redirect after oauth login/signup -export const REDIRECT_PATH = '/oauth/callback'; - -// redirect the user back to the page they were on before login -export const REDIRECT_TO_LAST_PAGE_ON_LOGIN = true; - -export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query'; diff --git a/apps/group-chat/src/lib/atproto/ui/LoginModal.svelte b/apps/group-chat/src/lib/atproto/ui/LoginModal.svelte deleted file mode 100644 index b3bad3a..0000000 --- a/apps/group-chat/src/lib/atproto/ui/LoginModal.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - - - { - await login(handle); - return true; - }} - signup={async () => { - signup(); - return true; - }} -/> diff --git a/apps/group-chat/src/lib/contrail.config.ts b/apps/group-chat/src/lib/contrail.config.ts deleted file mode 100644 index a01c02f..0000000 --- a/apps/group-chat/src/lib/contrail.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ContrailConfig } from '@atmo-dev/contrail'; - -/** Env-independent contrail config. Spaces/community/realtime are enabled per-request - * from `getContrail(env)` in `./index.ts`, since those modules need bindings or secrets - * that live on `platform.env`. */ -export const namespace = 'tools.atmo.chat'; - -export const baseConfig: ContrailConfig = { - namespace, - collections: { - server: { - collection: 'tools.atmo.chat.server', - queryable: { - communityDid: {} - } - }, - channel: { - collection: 'tools.atmo.chat.channel', - queryable: { - communityDid: {}, - visibility: {} - } - }, - message: { - collection: 'tools.atmo.chat.message', - queryable: { - createdAt: { type: 'range' } - } - } - } -}; diff --git a/apps/group-chat/src/lib/contrail/client.ts b/apps/group-chat/src/lib/contrail/client.ts deleted file mode 100644 index 13f8f51..0000000 --- a/apps/group-chat/src/lib/contrail/client.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Client, simpleFetchHandler } from '@atcute/client'; - -export interface Profile { - handle: string; - displayName?: string; - avatar?: string; -} - -/** - * Extract a simple profile from a contrail profile entry. - * Contrail returns { did, handle, value: { displayName, avatar, ... } } - * while components expect { handle, displayName?, avatar? }. - */ -export function extractProfile(entry: { - did: string; - handle?: string; - value?: unknown; -}): Profile { - const value = entry.value as { displayName?: string; avatar?: string } | undefined; - return { - handle: entry.handle ?? entry.did, - displayName: value?.displayName, - avatar: value?.avatar - }; -} - -/** - * Client-side: fully typed @atcute/client that queries the app's own /xrpc/ endpoints. - */ -export function getClient() { - return new Client({ handler: simpleFetchHandler({ service: '' }) }); -} diff --git a/apps/group-chat/src/lib/contrail/index.ts b/apps/group-chat/src/lib/contrail/index.ts deleted file mode 100644 index 8baf918..0000000 --- a/apps/group-chat/src/lib/contrail/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { - Contrail, - DurableObjectPubSub, - InMemoryPubSub, - MemoryBlobAdapter, - R2BlobAdapter, - resolveConfig, - type BlobAdapter, - type ContrailConfig, - type DurableObjectNamespace, - type PubSub, - type R2BucketLike -} from '@atmo-dev/contrail'; -import { createCommunityIntegration } from '@atmo-dev/contrail-community'; -import { createHandler, createServerClient } from '@atmo-dev/contrail/server'; -import type { Client } from '@atcute/client'; -import { dev } from '$app/environment'; -import { baseConfig } from '../contrail.config'; - -type Env = App.Platform['env']; - -interface Bundle { - contrail: Contrail; - handle: (req: Request, db?: unknown) => unknown; - ready: Promise; -} - -let cached: { env: Env; bundle: Bundle } | null = null; - -function build(env: Env): Bundle { - // In dev the DO class isn't wired (vite runs the worker directly, skipping - // the post-build re-export), so fall back to InMemoryPubSub. Single-isolate - // is fine for one-user dev; production uses the DO so publishes fan out - // across isolates. - const pubsub: PubSub = dev - ? new InMemoryPubSub() - : new DurableObjectPubSub(env.REALTIME as DurableObjectNamespace); - - // Blob storage: in dev, an in-isolate memory adapter (bytes reset on - // restart). In prod, bind an R2 bucket as `env.BLOBS` and it'll be used. - const blobAdapter: BlobAdapter = env.BLOBS - ? new R2BlobAdapter(env.BLOBS as R2BucketLike) - : new MemoryBlobAdapter(); - - const config: ContrailConfig = { - ...baseConfig, - spaces: { - authority: { - type: 'tools.atmo.chat.space', - serviceDid: env.SERVICE_DID - }, - recordHost: { - blobs: { - adapter: blobAdapter, - maxSize: 2 * 1024 * 1024, - accept: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] - } - } - }, - community: { - masterKey: env.COMMUNITY_MASTER_KEY, - serviceDid: env.SERVICE_DID - }, - realtime: { - ticketSecret: env.REALTIME_TICKET_SECRET, - pubsub - } - }; - - const resolved = resolveConfig(config); - const communityIntegration = createCommunityIntegration({ db: env.DB, config: resolved }); - const contrail = new Contrail({ ...config, db: env.DB, communityIntegration }); - const handle = createHandler(contrail); - const ready = contrail.init(env.DB); - - return { contrail, handle: handle as Bundle['handle'], ready }; -} - -function getBundle(env: Env): Bundle { - if (cached && cached.env === env) return cached.bundle; - const bundle = build(env); - cached = { env, bundle }; - return bundle; -} - -/** Get the shared contrail instance, bound to this worker's env. */ -export async function getContrail(env: Env): Promise { - const b = getBundle(env); - await b.ready; - return b.contrail; -} - -/** Dispatch a Request through contrail's XRPC handler. */ -export async function dispatch(req: Request, env: Env): Promise { - const b = getBundle(env); - await b.ready; - return (await b.handle(req, env.DB)) as Response; -} - -/** Typed `@atcute/client` that calls contrail in-process. Pass `did` to act - * as that user (server-side principal via in-process WeakMap — no JWT, no - * PDS roundtrip). Omit for anonymous calls against public endpoints. */ -export function getServerClient(env: Env, did?: string): Client { - return createServerClient(async (req) => { - const b = getBundle(env); - await b.ready; - return (await b.handle(req, env.DB)) as Response; - }, did); -} diff --git a/apps/group-chat/src/lib/emojis/icons.json b/apps/group-chat/src/lib/emojis/icons.json deleted file mode 100644 index 6266e50..0000000 --- a/apps/group-chat/src/lib/emojis/icons.json +++ /dev/null @@ -1,7255 +0,0 @@ -{ - "host": "", - "asset_url_pattern": "", - "families": ["Animated Emoji"], - "icons": [ - { - "name": "emoji_u1f600", - "version": 1, - "popularity": 720, - "codepoint": "1f600", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":smile:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f603", - "version": 1, - "popularity": 719, - "codepoint": "1f603", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":smile-with-big-eyes:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f604", - "version": 1, - "popularity": 718, - "codepoint": "1f604", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":grin:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f601", - "version": 1, - "popularity": 717, - "codepoint": "1f601", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":grinning:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f606", - "version": 1, - "popularity": 716, - "codepoint": "1f606", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":laughing:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f605", - "version": 1, - "popularity": 715, - "codepoint": "1f605", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":grin-sweat:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f602", - "version": 1, - "popularity": 714, - "codepoint": "1f602", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":joy:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f923", - "version": 1, - "popularity": 713, - "codepoint": "1f923", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":rofl:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f62d", - "version": 1, - "popularity": 712, - "codepoint": "1f62d", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":loudly-crying:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f609", - "version": 1, - "popularity": 711, - "codepoint": "1f609", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":wink:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f617", - "version": 1, - "popularity": 710, - "codepoint": "1f617", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":kissing:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f619", - "version": 1, - "popularity": 709, - "codepoint": "1f619", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":kissing-smiling-eyes:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f61a", - "version": 1, - "popularity": 708, - "codepoint": "1f61a", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":kissing-closed-eyes:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f618", - "version": 1, - "popularity": 707, - "codepoint": "1f618", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":kissing-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f970", - "version": 1, - "popularity": 706, - "codepoint": "1f970", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":heart-face:", ":3-hearts:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f60d", - "version": 1, - "popularity": 705, - "codepoint": "1f60d", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":heart-eyes:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f929", - "version": 1, - "popularity": 704, - "codepoint": "1f929", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":star-struck:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f973", - "version": 1, - "popularity": 703, - "codepoint": "1f973", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":partying-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fae0", - "version": 1, - "popularity": 702, - "codepoint": "1fae0", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":melting:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f643", - "version": 1, - "popularity": 701, - "codepoint": "1f643", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":upside-down-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f642", - "version": 1, - "popularity": 700, - "codepoint": "1f642", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":slightly-happy:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f972", - "version": 1, - "popularity": 699, - "codepoint": "1f972", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":happy-cry:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f979", - "version": 1, - "popularity": 698, - "codepoint": "1f979", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":holding-back-tears:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f60a", - "version": 1, - "popularity": 697, - "codepoint": "1f60a", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":blush:"], - "sizes_px": [] - }, - { - "name": "emoji_u263a_fe0f", - "version": 1, - "popularity": 696, - "codepoint": "263a_fe0f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":warm-smile:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f60c", - "version": 1, - "popularity": 695, - "codepoint": "1f60c", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":relieved:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f642_200d_2195_fe0f", - "version": 1, - "popularity": 694, - "codepoint": "1f642_200d_2195_fe0f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":head-nod:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f642_200d_2194_fe0f", - "version": 1, - "popularity": 693, - "codepoint": "1f642_200d_2194_fe0f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":head-shake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f60f", - "version": 1, - "popularity": 692, - "codepoint": "1f60f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":smirk:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f924", - "version": 1, - "popularity": 691, - "codepoint": "1f924", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":drool:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f60b", - "version": 1, - "popularity": 690, - "codepoint": "1f60b", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":yum:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f61b", - "version": 1, - "popularity": 689, - "codepoint": "1f61b", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":stuck-out-tongue:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f61d", - "version": 1, - "popularity": 688, - "codepoint": "1f61d", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":squinting-tongue:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f61c", - "version": 1, - "popularity": 687, - "codepoint": "1f61c", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":winky-tongue:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f92a", - "version": 1, - "popularity": 686, - "codepoint": "1f92a", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":zany-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f974", - "version": 1, - "popularity": 685, - "codepoint": "1f974", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":woozy:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f614", - "version": 1, - "popularity": 684, - "codepoint": "1f614", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pensive:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f97a", - "version": 1, - "popularity": 683, - "codepoint": "1f97a", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pleading:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f62c", - "version": 1, - "popularity": 682, - "codepoint": "1f62c", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":grimacing:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f611", - "version": 1, - "popularity": 681, - "codepoint": "1f611", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":expressionless:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f610", - "version": 1, - "popularity": 680, - "codepoint": "1f610", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":neutral-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f636", - "version": 1, - "popularity": 679, - "codepoint": "1f636", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":mouth-none:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f636_200d_1f32b_fe0f", - "version": 1, - "popularity": 678, - "codepoint": "1f636_200d_1f32b_fe0f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":face-in-clouds:", ":lost:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fae5", - "version": 1, - "popularity": 677, - "codepoint": "1fae5", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":dotted-line-face:", ":invisible:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f910", - "version": 1, - "popularity": 676, - "codepoint": "1f910", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":zipper-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fae1", - "version": 1, - "popularity": 675, - "codepoint": "1fae1", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":salute:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f914", - "version": 1, - "popularity": 674, - "codepoint": "1f914", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":thinking-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f92b", - "version": 1, - "popularity": 673, - "codepoint": "1f92b", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":shushing-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fae2", - "version": 1, - "popularity": 672, - "codepoint": "1fae2", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hand-over-mouth:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f92d", - "version": 1, - "popularity": 671, - "codepoint": "1f92d", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":smiling-eyes-with-hand-over-mouth:", ":chuckling:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f971", - "version": 1, - "popularity": 670, - "codepoint": "1f971", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":yawn:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f917", - "version": 1, - "popularity": 669, - "codepoint": "1f917", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hug-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fae3", - "version": 1, - "popularity": 668, - "codepoint": "1fae3", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":peeking:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f631", - "version": 1, - "popularity": 667, - "codepoint": "1f631", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":screaming:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f928", - "version": 1, - "popularity": 666, - "codepoint": "1f928", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raised-eyebrow:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9d0", - "version": 1, - "popularity": 665, - "codepoint": "1f9d0", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":monocle:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f612", - "version": 1, - "popularity": 664, - "codepoint": "1f612", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":unamused:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f644", - "version": 1, - "popularity": 663, - "codepoint": "1f644", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":rolling-eyes:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f62e_200d_1f4a8", - "version": 1, - "popularity": 662, - "codepoint": "1f62e_200d_1f4a8", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":exhale:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f624", - "version": 1, - "popularity": 661, - "codepoint": "1f624", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":triumph:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f620", - "version": 1, - "popularity": 660, - "codepoint": "1f620", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":angry:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f621", - "version": 1, - "popularity": 659, - "codepoint": "1f621", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":rage:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f92c", - "version": 1, - "popularity": 658, - "codepoint": "1f92c", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":cursing:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f61e", - "version": 1, - "popularity": 657, - "codepoint": "1f61e", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":sad:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f613", - "version": 1, - "popularity": 656, - "codepoint": "1f613", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":sweat:", ":downcast:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f61f", - "version": 1, - "popularity": 655, - "codepoint": "1f61f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":worried:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f625", - "version": 1, - "popularity": 654, - "codepoint": "1f625", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":concerned:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f622", - "version": 1, - "popularity": 653, - "codepoint": "1f622", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":cry:"], - "sizes_px": [] - }, - { - "name": "emoji_u2639_fe0f", - "version": 1, - "popularity": 652, - "codepoint": "2639_fe0f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":big-frown:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f641", - "version": 1, - "popularity": 651, - "codepoint": "1f641", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":frown:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fae4", - "version": 1, - "popularity": 650, - "codepoint": "1fae4", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":diagonal-mouth:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f615", - "version": 1, - "popularity": 649, - "codepoint": "1f615", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":slightly-frowning:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f630", - "version": 1, - "popularity": 648, - "codepoint": "1f630", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":anxious-with-sweat:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f628", - "version": 1, - "popularity": 647, - "codepoint": "1f628", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":scared:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f627", - "version": 1, - "popularity": 646, - "codepoint": "1f627", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":anguished:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f626", - "version": 1, - "popularity": 645, - "codepoint": "1f626", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":gasp:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f62e", - "version": 1, - "popularity": 644, - "codepoint": "1f62e", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":mouth-open:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f62f", - "version": 1, - "popularity": 643, - "codepoint": "1f62f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":surprised:", ":hushed:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f632", - "version": 1, - "popularity": 642, - "codepoint": "1f632", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":astonished:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f633", - "version": 1, - "popularity": 641, - "codepoint": "1f633", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":flushed:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f92f", - "version": 1, - "popularity": 640, - "codepoint": "1f92f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":mind-blown:", ":exploding-head:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f616", - "version": 1, - "popularity": 639, - "codepoint": "1f616", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":scrunched-mouth:", ":confounded:", ":zigzag-mouth:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f623", - "version": 1, - "popularity": 638, - "codepoint": "1f623", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":scrunched-eyes:", ":persevering:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f629", - "version": 1, - "popularity": 637, - "codepoint": "1f629", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":weary:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f62b", - "version": 1, - "popularity": 636, - "codepoint": "1f62b", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":distraught:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f635", - "version": 1, - "popularity": 635, - "codepoint": "1f635", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":x-eyes:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f635_200d_1f4ab", - "version": 1, - "popularity": 634, - "codepoint": "1f635_200d_1f4ab", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":dizzy-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fae8", - "version": 1, - "popularity": 633, - "codepoint": "1fae8", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":shaking-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f976", - "version": 1, - "popularity": 632, - "codepoint": "1f976", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":cold-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f975", - "version": 1, - "popularity": 631, - "codepoint": "1f975", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hot-face:", ":sweat-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f922", - "version": 1, - "popularity": 630, - "codepoint": "1f922", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":sick:", ":nauseated:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f92e", - "version": 1, - "popularity": 629, - "codepoint": "1f92e", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":vomit:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fae9", - "version": 1, - "popularity": 628, - "codepoint": "1fae9", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":tired:", ":bags-under-eyes:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f634", - "version": 1, - "popularity": 627, - "codepoint": "1f634", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":sleep:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f62a", - "version": 1, - "popularity": 626, - "codepoint": "1f62a", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":sleepy:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f927", - "version": 1, - "popularity": 625, - "codepoint": "1f927", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":sneeze:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f912", - "version": 1, - "popularity": 624, - "codepoint": "1f912", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":thermometer-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f915", - "version": 1, - "popularity": 623, - "codepoint": "1f915", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":bandage-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f637", - "version": 1, - "popularity": 622, - "codepoint": "1f637", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":mask:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f925", - "version": 1, - "popularity": 621, - "codepoint": "1f925", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":liar:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f607", - "version": 1, - "popularity": 620, - "codepoint": "1f607", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":halo:", ":innocent:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f920", - "version": 1, - "popularity": 619, - "codepoint": "1f920", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":cowboy:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f911", - "version": 1, - "popularity": 618, - "codepoint": "1f911", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":money-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f913", - "version": 1, - "popularity": 617, - "codepoint": "1f913", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":nerd-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f60e", - "version": 1, - "popularity": 616, - "codepoint": "1f60e", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":sunglasses-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f978", - "version": 1, - "popularity": 615, - "codepoint": "1f978", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":disguise:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f921", - "version": 1, - "popularity": 614, - "codepoint": "1f921", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":clown:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4a9", - "version": 1, - "popularity": 613, - "codepoint": "1f4a9", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":poop:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f608", - "version": 1, - "popularity": 612, - "codepoint": "1f608", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":imp-smile:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f47f", - "version": 1, - "popularity": 611, - "codepoint": "1f47f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":imp-frown:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f47b", - "version": 1, - "popularity": 610, - "codepoint": "1f47b", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":ghost:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f480", - "version": 1, - "popularity": 609, - "codepoint": "1f480", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":skull:"], - "sizes_px": [] - }, - { - "name": "emoji_u2603_fe0f", - "version": 1, - "popularity": 608, - "codepoint": "2603_fe0f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":snowman-with-snow:"], - "sizes_px": [] - }, - { - "name": "emoji_u26c4", - "version": 1, - "popularity": 607, - "codepoint": "26c4", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":snowman:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f383", - "version": 1, - "popularity": 606, - "codepoint": "1f383", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":jack-o-lantern:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f916", - "version": 1, - "popularity": 605, - "codepoint": "1f916", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":robot:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f47d", - "version": 1, - "popularity": 604, - "codepoint": "1f47d", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":alien:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f47e", - "version": 1, - "popularity": 603, - "codepoint": "1f47e", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":alien-monster:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f31e", - "version": 1, - "popularity": 602, - "codepoint": "1f31e", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":sun-with-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f31b", - "version": 1, - "popularity": 601, - "codepoint": "1f31b", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":moon-face-first-quarter:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f31c", - "version": 1, - "popularity": 600, - "codepoint": "1f31c", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":moon-face-last-quarter:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f63a", - "version": 1, - "popularity": 599, - "codepoint": "1f63a", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":smiley-cat:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f638", - "version": 1, - "popularity": 598, - "codepoint": "1f638", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":smile-cat:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f639", - "version": 1, - "popularity": 597, - "codepoint": "1f639", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":joy-cat:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f63b", - "version": 1, - "popularity": 596, - "codepoint": "1f63b", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":heart-eyes-cat:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f63c", - "version": 1, - "popularity": 595, - "codepoint": "1f63c", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":smirk-cat:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f63d", - "version": 1, - "popularity": 594, - "codepoint": "1f63d", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":kissing-cat:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f640", - "version": 1, - "popularity": 593, - "codepoint": "1f640", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":scream-cat:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f63f", - "version": 1, - "popularity": 592, - "codepoint": "1f63f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":crying-cat-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f63e", - "version": 1, - "popularity": 591, - "codepoint": "1f63e", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pouting-cat:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f648", - "version": 1, - "popularity": 590, - "codepoint": "1f648", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":see-no-evil-monkey:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f649", - "version": 1, - "popularity": 589, - "codepoint": "1f649", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hear-no-evil-monkey:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f64a", - "version": 1, - "popularity": 588, - "codepoint": "1f64a", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":speak-no-evil-monkey:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f31f", - "version": 1, - "popularity": 587, - "codepoint": "1f31f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":glowing-star:"], - "sizes_px": [] - }, - { - "name": "emoji_u2728", - "version": 1, - "popularity": 586, - "codepoint": "2728", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":sparkles:"], - "sizes_px": [] - }, - { - "name": "emoji_u26a1", - "version": 1, - "popularity": 585, - "codepoint": "26a1", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":electricity:", ":zap:", ":lightning:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4a5", - "version": 1, - "popularity": 584, - "codepoint": "1f4a5", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":collision:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f525", - "version": 1, - "popularity": 583, - "codepoint": "1f525", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fire:", ":burn:", ":lit:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4af", - "version": 1, - "popularity": 582, - "codepoint": "1f4af", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":100:", ":one-hundred:", ":hundred:", ":points:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f389", - "version": 1, - "popularity": 581, - "codepoint": "1f389", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":party-popper:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f38a", - "version": 1, - "popularity": 580, - "codepoint": "1f38a", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":confetti-ball:"], - "sizes_px": [] - }, - { - "name": "emoji_u2764_fe0f", - "version": 1, - "popularity": 579, - "codepoint": "2764_fe0f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":red-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9e1", - "version": 1, - "popularity": 578, - "codepoint": "1f9e1", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":orange-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f49b", - "version": 1, - "popularity": 577, - "codepoint": "1f49b", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":yellow-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f49a", - "version": 1, - "popularity": 576, - "codepoint": "1f49a", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":green-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fa75", - "version": 1, - "popularity": 575, - "codepoint": "1fa75", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":light-blue-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f499", - "version": 1, - "popularity": 574, - "codepoint": "1f499", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":blue-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f49c", - "version": 1, - "popularity": 573, - "codepoint": "1f49c", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":purple-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f90e", - "version": 1, - "popularity": 572, - "codepoint": "1f90e", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":brown-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f5a4", - "version": 1, - "popularity": 571, - "codepoint": "1f5a4", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":black-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fa76", - "version": 1, - "popularity": 570, - "codepoint": "1fa76", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":grey-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f90d", - "version": 1, - "popularity": 569, - "codepoint": "1f90d", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":white-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fa77", - "version": 1, - "popularity": 568, - "codepoint": "1fa77", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pink-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f498", - "version": 1, - "popularity": 567, - "codepoint": "1f498", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":cupid:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f49d", - "version": 1, - "popularity": 566, - "codepoint": "1f49d", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":gift-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f496", - "version": 1, - "popularity": 565, - "codepoint": "1f496", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":sparkling-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f497", - "version": 1, - "popularity": 564, - "codepoint": "1f497", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":heart-grow:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f493", - "version": 1, - "popularity": 563, - "codepoint": "1f493", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":beating-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f49e", - "version": 1, - "popularity": 562, - "codepoint": "1f49e", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":revolving-hearts:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f495", - "version": 1, - "popularity": 561, - "codepoint": "1f495", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":two-hearts:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f48c", - "version": 1, - "popularity": 560, - "codepoint": "1f48c", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":love-letter:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f49f", - "version": 1, - "popularity": 559, - "codepoint": "1f49f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":heart-box:"], - "sizes_px": [] - }, - { - "name": "emoji_u2763_fe0f", - "version": 1, - "popularity": 558, - "codepoint": "2763_fe0f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":heart-exclamation-point:"], - "sizes_px": [] - }, - { - "name": "emoji_u2764_fe0f_200d_1fa79", - "version": 1, - "popularity": 557, - "codepoint": "2764_fe0f_200d_1fa79", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":bandaged-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f494", - "version": 1, - "popularity": 556, - "codepoint": "1f494", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":broken-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u2764_fe0f_200d_1f525", - "version": 1, - "popularity": 555, - "codepoint": "2764_fe0f_200d_1f525", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fire-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f48b", - "version": 1, - "popularity": 554, - "codepoint": "1f48b", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":kiss:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f463", - "version": 1, - "popularity": 553, - "codepoint": "1f463", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":footprints:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fac6", - "version": 1, - "popularity": 552, - "codepoint": "1fac6", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fingerprint:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fac0", - "version": 1, - "popularity": 551, - "codepoint": "1fac0", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":anatomical-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fa78", - "version": 1, - "popularity": 550, - "codepoint": "1fa78", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":blood:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9a0", - "version": 1, - "popularity": 549, - "codepoint": "1f9a0", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":microbe:", ":virus:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f440", - "version": 1, - "popularity": 548, - "codepoint": "1f440", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":eyes:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f441_fe0f", - "version": 1, - "popularity": 547, - "codepoint": "1f441_fe0f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":eye:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fae6", - "version": 1, - "popularity": 546, - "codepoint": "1fae6", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":biting-lip:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f443", - "version": 1, - "popularity": 545, - "codepoint": "1f443", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":nose:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f443_1f3fb", - "version": 1, - "popularity": 544, - "codepoint": "1f443_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":nose:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f443_1f3fc", - "version": 1, - "popularity": 543, - "codepoint": "1f443_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":nose:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f443_1f3fd", - "version": 1, - "popularity": 542, - "codepoint": "1f443_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":nose:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f443_1f3fe", - "version": 1, - "popularity": 541, - "codepoint": "1f443_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":nose:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f443_1f3ff", - "version": 1, - "popularity": 540, - "codepoint": "1f443_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":nose:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f442", - "version": 1, - "popularity": 539, - "codepoint": "1f442", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":ear:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f442_1f3fb", - "version": 1, - "popularity": 538, - "codepoint": "1f442_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":ear:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f442_1f3fc", - "version": 1, - "popularity": 537, - "codepoint": "1f442_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":ear:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f442_1f3fd", - "version": 1, - "popularity": 536, - "codepoint": "1f442_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":ear:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f442_1f3fe", - "version": 1, - "popularity": 535, - "codepoint": "1f442_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":ear:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f442_1f3ff", - "version": 1, - "popularity": 534, - "codepoint": "1f442_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":ear:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9bb", - "version": 1, - "popularity": 533, - "codepoint": "1f9bb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hearing-aid:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9bb_1f3fb", - "version": 1, - "popularity": 532, - "codepoint": "1f9bb_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hearing-aid:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9bb_1f3fc", - "version": 1, - "popularity": 531, - "codepoint": "1f9bb_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hearing-aid:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9bb_1f3fd", - "version": 1, - "popularity": 530, - "codepoint": "1f9bb_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hearing-aid:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9bb_1f3fe", - "version": 1, - "popularity": 529, - "codepoint": "1f9bb_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hearing-aid:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9bb_1f3ff", - "version": 1, - "popularity": 528, - "codepoint": "1f9bb_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hearing-aid:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9b6", - "version": 1, - "popularity": 527, - "codepoint": "1f9b6", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":foot:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9b6_1f3fb", - "version": 1, - "popularity": 526, - "codepoint": "1f9b6_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":foot:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9b6_1f3fc", - "version": 1, - "popularity": 525, - "codepoint": "1f9b6_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":foot:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9b6_1f3fd", - "version": 1, - "popularity": 524, - "codepoint": "1f9b6_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":foot:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9b6_1f3fe", - "version": 1, - "popularity": 523, - "codepoint": "1f9b6_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":foot:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9b6_1f3ff", - "version": 1, - "popularity": 522, - "codepoint": "1f9b6_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":foot:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9b5", - "version": 1, - "popularity": 521, - "codepoint": "1f9b5", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":leg:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9b5_1f3fb", - "version": 1, - "popularity": 520, - "codepoint": "1f9b5_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":leg:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9b5_1f3fc", - "version": 1, - "popularity": 519, - "codepoint": "1f9b5_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":leg:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9b5_1f3fd", - "version": 1, - "popularity": 518, - "codepoint": "1f9b5_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":leg:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9b5_1f3fe", - "version": 1, - "popularity": 517, - "codepoint": "1f9b5_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":leg:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9b5_1f3ff", - "version": 1, - "popularity": 516, - "codepoint": "1f9b5_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":leg:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9bf", - "version": 1, - "popularity": 515, - "codepoint": "1f9bf", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":leg-mechanical:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9be", - "version": 1, - "popularity": 514, - "codepoint": "1f9be", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":arm-mechanical:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4aa", - "version": 1, - "popularity": 513, - "codepoint": "1f4aa", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":muscle:", ":flex:", ":bicep:", ":strong:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4aa_1f3fb", - "version": 1, - "popularity": 512, - "codepoint": "1f4aa_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":muscle:", ":flex:", ":bicep:", ":strong:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4aa_1f3fc", - "version": 1, - "popularity": 511, - "codepoint": "1f4aa_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":muscle:", ":flex:", ":bicep:", ":strong:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4aa_1f3fd", - "version": 1, - "popularity": 510, - "codepoint": "1f4aa_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":muscle:", ":flex:", ":bicep:", ":strong:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4aa_1f3fe", - "version": 1, - "popularity": 509, - "codepoint": "1f4aa_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":muscle:", ":flex:", ":bicep:", ":strong:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4aa_1f3ff", - "version": 1, - "popularity": 508, - "codepoint": "1f4aa_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":muscle:", ":flex:", ":bicep:", ":strong:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44f", - "version": 1, - "popularity": 507, - "codepoint": "1f44f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":clap:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44f_1f3fb", - "version": 1, - "popularity": 506, - "codepoint": "1f44f_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":clap:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44f_1f3fc", - "version": 1, - "popularity": 505, - "codepoint": "1f44f_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":clap:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44f_1f3fd", - "version": 1, - "popularity": 504, - "codepoint": "1f44f_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":clap:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44f_1f3fe", - "version": 1, - "popularity": 503, - "codepoint": "1f44f_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":clap:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44f_1f3ff", - "version": 1, - "popularity": 502, - "codepoint": "1f44f_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":clap:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44d", - "version": 1, - "popularity": 501, - "codepoint": "1f44d", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":thumbs-up:", ":+1:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44d_1f3fb", - "version": 1, - "popularity": 500, - "codepoint": "1f44d_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":thumbs-up:", ":+1:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44d_1f3fc", - "version": 1, - "popularity": 499, - "codepoint": "1f44d_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":thumbs-up:", ":+1:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44d_1f3fd", - "version": 1, - "popularity": 498, - "codepoint": "1f44d_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":thumbs-up:", ":+1:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44d_1f3fe", - "version": 1, - "popularity": 497, - "codepoint": "1f44d_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":thumbs-up:", ":+1:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44d_1f3ff", - "version": 1, - "popularity": 496, - "codepoint": "1f44d_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":thumbs-up:", ":+1:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44e", - "version": 1, - "popularity": 495, - "codepoint": "1f44e", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":thumbs-down:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44e_1f3fb", - "version": 1, - "popularity": 494, - "codepoint": "1f44e_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":thumbs-down:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44e_1f3fc", - "version": 1, - "popularity": 493, - "codepoint": "1f44e_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":thumbs-down:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44e_1f3fd", - "version": 1, - "popularity": 492, - "codepoint": "1f44e_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":thumbs-down:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44e_1f3fe", - "version": 1, - "popularity": 491, - "codepoint": "1f44e_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":thumbs-down:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44e_1f3ff", - "version": 1, - "popularity": 490, - "codepoint": "1f44e_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":thumbs-down:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf6", - "version": 1, - "popularity": 489, - "codepoint": "1faf6", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":heart-hands:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf6_1f3fb", - "version": 1, - "popularity": 488, - "codepoint": "1faf6_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":heart-hands:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf6_1f3fc", - "version": 1, - "popularity": 487, - "codepoint": "1faf6_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":heart-hands:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf6_1f3fd", - "version": 1, - "popularity": 486, - "codepoint": "1faf6_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":heart-hands:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf6_1f3fe", - "version": 1, - "popularity": 485, - "codepoint": "1faf6_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":heart-hands:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf6_1f3ff", - "version": 1, - "popularity": 484, - "codepoint": "1faf6_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":heart-hands:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f64c", - "version": 1, - "popularity": 483, - "codepoint": "1f64c", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raising-hands:", ":hooray:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f64c_1f3fb", - "version": 1, - "popularity": 482, - "codepoint": "1f64c_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raising-hands:", ":hooray:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f64c_1f3fc", - "version": 1, - "popularity": 481, - "codepoint": "1f64c_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raising-hands:", ":hooray:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f64c_1f3fd", - "version": 1, - "popularity": 480, - "codepoint": "1f64c_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raising-hands:", ":hooray:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f64c_1f3fe", - "version": 1, - "popularity": 479, - "codepoint": "1f64c_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raising-hands:", ":hooray:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f64c_1f3ff", - "version": 1, - "popularity": 478, - "codepoint": "1f64c_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raising-hands:", ":hooray:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f450", - "version": 1, - "popularity": 477, - "codepoint": "1f450", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":open-hands:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f450_1f3fb", - "version": 1, - "popularity": 476, - "codepoint": "1f450_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":open-hands:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f450_1f3fc", - "version": 1, - "popularity": 475, - "codepoint": "1f450_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":open-hands:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f450_1f3fd", - "version": 1, - "popularity": 474, - "codepoint": "1f450_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":open-hands:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f450_1f3fe", - "version": 1, - "popularity": 473, - "codepoint": "1f450_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":open-hands:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f450_1f3ff", - "version": 1, - "popularity": 472, - "codepoint": "1f450_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":open-hands:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f932", - "version": 1, - "popularity": 471, - "codepoint": "1f932", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palms-up:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f932_1f3fb", - "version": 1, - "popularity": 470, - "codepoint": "1f932_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palms-up:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f932_1f3fc", - "version": 1, - "popularity": 469, - "codepoint": "1f932_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palms-up:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f932_1f3fd", - "version": 1, - "popularity": 468, - "codepoint": "1f932_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palms-up:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f932_1f3fe", - "version": 1, - "popularity": 467, - "codepoint": "1f932_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palms-up:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f932_1f3ff", - "version": 1, - "popularity": 466, - "codepoint": "1f932_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palms-up:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91c", - "version": 1, - "popularity": 465, - "codepoint": "1f91c", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist-rightwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91c_1f3fb", - "version": 1, - "popularity": 464, - "codepoint": "1f91c_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist-rightwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91c_1f3fc", - "version": 1, - "popularity": 463, - "codepoint": "1f91c_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist-rightwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91c_1f3fd", - "version": 1, - "popularity": 462, - "codepoint": "1f91c_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist-rightwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91c_1f3fe", - "version": 1, - "popularity": 461, - "codepoint": "1f91c_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist-rightwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91c_1f3ff", - "version": 1, - "popularity": 460, - "codepoint": "1f91c_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist-rightwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91b", - "version": 1, - "popularity": 459, - "codepoint": "1f91b", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist-leftwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91b_1f3fb", - "version": 1, - "popularity": 458, - "codepoint": "1f91b_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist-leftwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91b_1f3fc", - "version": 1, - "popularity": 457, - "codepoint": "1f91b_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist-leftwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91b_1f3fd", - "version": 1, - "popularity": 456, - "codepoint": "1f91b_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist-leftwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91b_1f3fe", - "version": 1, - "popularity": 455, - "codepoint": "1f91b_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist-leftwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91b_1f3ff", - "version": 1, - "popularity": 454, - "codepoint": "1f91b_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist-leftwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u270a", - "version": 1, - "popularity": 453, - "codepoint": "270a", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raised-fist:"], - "sizes_px": [] - }, - { - "name": "emoji_u270a_1f3fb", - "version": 1, - "popularity": 452, - "codepoint": "270a_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raised-fist:"], - "sizes_px": [] - }, - { - "name": "emoji_u270a_1f3fc", - "version": 1, - "popularity": 451, - "codepoint": "270a_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raised-fist:"], - "sizes_px": [] - }, - { - "name": "emoji_u270a_1f3fd", - "version": 1, - "popularity": 450, - "codepoint": "270a_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raised-fist:"], - "sizes_px": [] - }, - { - "name": "emoji_u270a_1f3fe", - "version": 1, - "popularity": 449, - "codepoint": "270a_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raised-fist:"], - "sizes_px": [] - }, - { - "name": "emoji_u270a_1f3ff", - "version": 1, - "popularity": 448, - "codepoint": "270a_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raised-fist:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44a", - "version": 1, - "popularity": 447, - "codepoint": "1f44a", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist:", ":bump:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44a_1f3fb", - "version": 1, - "popularity": 446, - "codepoint": "1f44a_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist:", ":bump:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44a_1f3fc", - "version": 1, - "popularity": 445, - "codepoint": "1f44a_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist:", ":bump:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44a_1f3fd", - "version": 1, - "popularity": 444, - "codepoint": "1f44a_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist:", ":bump:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44a_1f3fe", - "version": 1, - "popularity": 443, - "codepoint": "1f44a_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist:", ":bump:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44a_1f3ff", - "version": 1, - "popularity": 442, - "codepoint": "1f44a_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":fist:", ":bump:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf3", - "version": 1, - "popularity": 441, - "codepoint": "1faf3", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm-down:", ":drop:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf3_1f3fb", - "version": 1, - "popularity": 440, - "codepoint": "1faf3_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm-down:", ":drop:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf3_1f3fc", - "version": 1, - "popularity": 439, - "codepoint": "1faf3_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm-down:", ":drop:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf3_1f3fd", - "version": 1, - "popularity": 438, - "codepoint": "1faf3_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm-down:", ":drop:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf3_1f3fe", - "version": 1, - "popularity": 437, - "codepoint": "1faf3_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm-down:", ":drop:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf3_1f3ff", - "version": 1, - "popularity": 436, - "codepoint": "1faf3_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm-down:", ":drop:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf4", - "version": 1, - "popularity": 435, - "codepoint": "1faf4", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm-up:", ":throw:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf4_1f3fb", - "version": 1, - "popularity": 434, - "codepoint": "1faf4_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm-up:", ":throw:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf4_1f3fc", - "version": 1, - "popularity": 433, - "codepoint": "1faf4_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm-up:", ":throw:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf4_1f3fd", - "version": 1, - "popularity": 432, - "codepoint": "1faf4_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm-up:", ":throw:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf4_1f3fe", - "version": 1, - "popularity": 431, - "codepoint": "1faf4_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm-up:", ":throw:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf4_1f3ff", - "version": 1, - "popularity": 430, - "codepoint": "1faf4_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm-up:", ":throw:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1", - "version": 1, - "popularity": 429, - "codepoint": "1faf1", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":rightwards-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fb", - "version": 1, - "popularity": 428, - "codepoint": "1faf1_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":rightwards-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fc", - "version": 1, - "popularity": 427, - "codepoint": "1faf1_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":rightwards-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fd", - "version": 1, - "popularity": 426, - "codepoint": "1faf1_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":rightwards-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fe", - "version": 1, - "popularity": 425, - "codepoint": "1faf1_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":rightwards-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3ff", - "version": 1, - "popularity": 424, - "codepoint": "1faf1_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":rightwards-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf2", - "version": 1, - "popularity": 423, - "codepoint": "1faf2", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":leftwards-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf2_1f3fb", - "version": 1, - "popularity": 422, - "codepoint": "1faf2_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":leftwards-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf2_1f3fc", - "version": 1, - "popularity": 421, - "codepoint": "1faf2_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":leftwards-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf2_1f3fd", - "version": 1, - "popularity": 420, - "codepoint": "1faf2_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":leftwards-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf2_1f3fe", - "version": 1, - "popularity": 419, - "codepoint": "1faf2_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":leftwards-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf2_1f3ff", - "version": 1, - "popularity": 418, - "codepoint": "1faf2_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":leftwards-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf8", - "version": 1, - "popularity": 417, - "codepoint": "1faf8", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":push-rightwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf8_1f3fb", - "version": 1, - "popularity": 416, - "codepoint": "1faf8_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":push-rightwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf8_1f3fc", - "version": 1, - "popularity": 415, - "codepoint": "1faf8_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":push-rightwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf8_1f3fd", - "version": 1, - "popularity": 414, - "codepoint": "1faf8_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":push-rightwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf8_1f3fe", - "version": 1, - "popularity": 413, - "codepoint": "1faf8_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":push-rightwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf8_1f3ff", - "version": 1, - "popularity": 412, - "codepoint": "1faf8_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":push-rightwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf7", - "version": 1, - "popularity": 411, - "codepoint": "1faf7", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":push-leftwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf7_1f3fb", - "version": 1, - "popularity": 410, - "codepoint": "1faf7_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":push-leftwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf7_1f3fc", - "version": 1, - "popularity": 409, - "codepoint": "1faf7_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":push-leftwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf7_1f3fd", - "version": 1, - "popularity": 408, - "codepoint": "1faf7_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":push-leftwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf7_1f3fe", - "version": 1, - "popularity": 407, - "codepoint": "1faf7_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":push-leftwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf7_1f3ff", - "version": 1, - "popularity": 406, - "codepoint": "1faf7_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":push-leftwards:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44b", - "version": 1, - "popularity": 405, - "codepoint": "1f44b", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":wave:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44b_1f3fb", - "version": 1, - "popularity": 404, - "codepoint": "1f44b_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":wave:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44b_1f3fc", - "version": 1, - "popularity": 403, - "codepoint": "1f44b_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":wave:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44b_1f3fd", - "version": 1, - "popularity": 402, - "codepoint": "1f44b_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":wave:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44b_1f3fe", - "version": 1, - "popularity": 401, - "codepoint": "1f44b_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":wave:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44b_1f3ff", - "version": 1, - "popularity": 400, - "codepoint": "1f44b_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":wave:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91a", - "version": 1, - "popularity": 399, - "codepoint": "1f91a", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":back-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91a_1f3fb", - "version": 1, - "popularity": 398, - "codepoint": "1f91a_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":back-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91a_1f3fc", - "version": 1, - "popularity": 397, - "codepoint": "1f91a_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":back-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91a_1f3fd", - "version": 1, - "popularity": 396, - "codepoint": "1f91a_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":back-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91a_1f3fe", - "version": 1, - "popularity": 395, - "codepoint": "1f91a_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":back-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91a_1f3ff", - "version": 1, - "popularity": 394, - "codepoint": "1f91a_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":back-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f590_fe0f", - "version": 1, - "popularity": 393, - "codepoint": "1f590_fe0f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f590_1f3fb", - "version": 1, - "popularity": 392, - "codepoint": "1f590_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f590_1f3fc", - "version": 1, - "popularity": 391, - "codepoint": "1f590_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f590_1f3fd", - "version": 1, - "popularity": 390, - "codepoint": "1f590_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f590_1f3fe", - "version": 1, - "popularity": 389, - "codepoint": "1f590_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f590_1f3ff", - "version": 1, - "popularity": 388, - "codepoint": "1f590_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":palm:"], - "sizes_px": [] - }, - { - "name": "emoji_u270b", - "version": 1, - "popularity": 387, - "codepoint": "270b", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raised-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270b_1f3fb", - "version": 1, - "popularity": 386, - "codepoint": "270b_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raised-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270b_1f3fc", - "version": 1, - "popularity": 385, - "codepoint": "270b_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raised-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270b_1f3fd", - "version": 1, - "popularity": 384, - "codepoint": "270b_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raised-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270b_1f3fe", - "version": 1, - "popularity": 383, - "codepoint": "270b_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raised-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270b_1f3ff", - "version": 1, - "popularity": 382, - "codepoint": "270b_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":raised-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f596", - "version": 1, - "popularity": 381, - "codepoint": "1f596", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":vulcan:", ":prosper:", ":spock:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f596_1f3fb", - "version": 1, - "popularity": 380, - "codepoint": "1f596_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":vulcan:", ":prosper:", ":spock:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f596_1f3fc", - "version": 1, - "popularity": 379, - "codepoint": "1f596_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":vulcan:", ":prosper:", ":spock:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f596_1f3fd", - "version": 1, - "popularity": 378, - "codepoint": "1f596_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":vulcan:", ":prosper:", ":spock:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f596_1f3fe", - "version": 1, - "popularity": 377, - "codepoint": "1f596_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":vulcan:", ":prosper:", ":spock:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f596_1f3ff", - "version": 1, - "popularity": 376, - "codepoint": "1f596_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":vulcan:", ":prosper:", ":spock:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91f", - "version": 1, - "popularity": 375, - "codepoint": "1f91f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":love-you-gesture:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91f_1f3fb", - "version": 1, - "popularity": 374, - "codepoint": "1f91f_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":love-you-gesture:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91f_1f3fc", - "version": 1, - "popularity": 373, - "codepoint": "1f91f_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":love-you-gesture:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91f_1f3fd", - "version": 1, - "popularity": 372, - "codepoint": "1f91f_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":love-you-gesture:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91f_1f3fe", - "version": 1, - "popularity": 371, - "codepoint": "1f91f_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":love-you-gesture:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91f_1f3ff", - "version": 1, - "popularity": 370, - "codepoint": "1f91f_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":love-you-gesture:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f918", - "version": 1, - "popularity": 369, - "codepoint": "1f918", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":metal:", ":horns:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f918_1f3fb", - "version": 1, - "popularity": 368, - "codepoint": "1f918_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":metal:", ":horns:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f918_1f3fc", - "version": 1, - "popularity": 367, - "codepoint": "1f918_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":metal:", ":horns:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f918_1f3fd", - "version": 1, - "popularity": 366, - "codepoint": "1f918_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":metal:", ":horns:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f918_1f3fe", - "version": 1, - "popularity": 365, - "codepoint": "1f918_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":metal:", ":horns:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f918_1f3ff", - "version": 1, - "popularity": 364, - "codepoint": "1f918_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":metal:", ":horns:"], - "sizes_px": [] - }, - { - "name": "emoji_u270c_fe0f", - "version": 1, - "popularity": 363, - "codepoint": "270c_fe0f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":victory:", ":v:", ":peace-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270c_1f3fb", - "version": 1, - "popularity": 362, - "codepoint": "270c_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":victory:", ":v:", ":peace-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270c_1f3fc", - "version": 1, - "popularity": 361, - "codepoint": "270c_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":victory:", ":v:", ":peace-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270c_1f3fd", - "version": 1, - "popularity": 360, - "codepoint": "270c_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":victory:", ":v:", ":peace-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270c_1f3fe", - "version": 1, - "popularity": 359, - "codepoint": "270c_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":victory:", ":v:", ":peace-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270c_1f3ff", - "version": 1, - "popularity": 358, - "codepoint": "270c_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":victory:", ":v:", ":peace-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91e", - "version": 1, - "popularity": 357, - "codepoint": "1f91e", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":crossed-fingers:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91e_1f3fb", - "version": 1, - "popularity": 356, - "codepoint": "1f91e_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":crossed-fingers:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91e_1f3fc", - "version": 1, - "popularity": 355, - "codepoint": "1f91e_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":crossed-fingers:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91e_1f3fd", - "version": 1, - "popularity": 354, - "codepoint": "1f91e_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":crossed-fingers:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91e_1f3fe", - "version": 1, - "popularity": 353, - "codepoint": "1f91e_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":crossed-fingers:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91e_1f3ff", - "version": 1, - "popularity": 352, - "codepoint": "1f91e_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":crossed-fingers:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf0", - "version": 1, - "popularity": 351, - "codepoint": "1faf0", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hand-with-index-finger-and-thumb-crossed:", ":snap:", ":finger-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf0_1f3fb", - "version": 1, - "popularity": 350, - "codepoint": "1faf0_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hand-with-index-finger-and-thumb-crossed:", ":snap:", ":finger-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf0_1f3fc", - "version": 1, - "popularity": 349, - "codepoint": "1faf0_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hand-with-index-finger-and-thumb-crossed:", ":snap:", ":finger-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf0_1f3fd", - "version": 1, - "popularity": 348, - "codepoint": "1faf0_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hand-with-index-finger-and-thumb-crossed:", ":snap:", ":finger-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf0_1f3fe", - "version": 1, - "popularity": 347, - "codepoint": "1faf0_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hand-with-index-finger-and-thumb-crossed:", ":snap:", ":finger-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf0_1f3ff", - "version": 1, - "popularity": 346, - "codepoint": "1faf0_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":hand-with-index-finger-and-thumb-crossed:", ":snap:", ":finger-heart:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f919", - "version": 1, - "popularity": 345, - "codepoint": "1f919", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":call-me-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f919_1f3fb", - "version": 1, - "popularity": 344, - "codepoint": "1f919_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":call-me-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f919_1f3fc", - "version": 1, - "popularity": 343, - "codepoint": "1f919_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":call-me-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f919_1f3fd", - "version": 1, - "popularity": 342, - "codepoint": "1f919_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":call-me-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f919_1f3fe", - "version": 1, - "popularity": 341, - "codepoint": "1f919_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":call-me-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f919_1f3ff", - "version": 1, - "popularity": 340, - "codepoint": "1f919_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":call-me-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f90c", - "version": 1, - "popularity": 339, - "codepoint": "1f90c", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pinched-fingers:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f90c_1f3fb", - "version": 1, - "popularity": 338, - "codepoint": "1f90c_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pinched-fingers:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f90c_1f3fc", - "version": 1, - "popularity": 337, - "codepoint": "1f90c_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pinched-fingers:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f90c_1f3fd", - "version": 1, - "popularity": 336, - "codepoint": "1f90c_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pinched-fingers:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f90c_1f3fe", - "version": 1, - "popularity": 335, - "codepoint": "1f90c_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pinched-fingers:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f90c_1f3ff", - "version": 1, - "popularity": 334, - "codepoint": "1f90c_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pinched-fingers:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f90f", - "version": 1, - "popularity": 333, - "codepoint": "1f90f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pinch:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f90f_1f3fb", - "version": 1, - "popularity": 332, - "codepoint": "1f90f_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pinch:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f90f_1f3fc", - "version": 1, - "popularity": 331, - "codepoint": "1f90f_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pinch:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f90f_1f3fd", - "version": 1, - "popularity": 330, - "codepoint": "1f90f_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pinch:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f90f_1f3fe", - "version": 1, - "popularity": 329, - "codepoint": "1f90f_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pinch:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f90f_1f3ff", - "version": 1, - "popularity": 328, - "codepoint": "1f90f_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pinch:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44c", - "version": 1, - "popularity": 327, - "codepoint": "1f44c", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":ok:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44c_1f3fb", - "version": 1, - "popularity": 326, - "codepoint": "1f44c_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":ok:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44c_1f3fc", - "version": 1, - "popularity": 325, - "codepoint": "1f44c_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":ok:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44c_1f3fd", - "version": 1, - "popularity": 324, - "codepoint": "1f44c_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":ok:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44c_1f3fe", - "version": 1, - "popularity": 323, - "codepoint": "1f44c_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":ok:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f44c_1f3ff", - "version": 1, - "popularity": 322, - "codepoint": "1f44c_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":ok:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf5", - "version": 1, - "popularity": 321, - "codepoint": "1faf5", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pointing:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf5_1f3fb", - "version": 1, - "popularity": 320, - "codepoint": "1faf5_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pointing:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf5_1f3fc", - "version": 1, - "popularity": 319, - "codepoint": "1faf5_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pointing:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf5_1f3fd", - "version": 1, - "popularity": 318, - "codepoint": "1faf5_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pointing:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf5_1f3fe", - "version": 1, - "popularity": 317, - "codepoint": "1faf5_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pointing:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf5_1f3ff", - "version": 1, - "popularity": 316, - "codepoint": "1faf5_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":pointing:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f449", - "version": 1, - "popularity": 315, - "codepoint": "1f449", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-right:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f449_1f3fb", - "version": 1, - "popularity": 314, - "codepoint": "1f449_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-right:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f449_1f3fc", - "version": 1, - "popularity": 313, - "codepoint": "1f449_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-right:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f449_1f3fd", - "version": 1, - "popularity": 312, - "codepoint": "1f449_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-right:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f449_1f3fe", - "version": 1, - "popularity": 311, - "codepoint": "1f449_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-right:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f449_1f3ff", - "version": 1, - "popularity": 310, - "codepoint": "1f449_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-right:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f448", - "version": 1, - "popularity": 309, - "codepoint": "1f448", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-left:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f448_1f3fb", - "version": 1, - "popularity": 308, - "codepoint": "1f448_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-left:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f448_1f3fc", - "version": 1, - "popularity": 307, - "codepoint": "1f448_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-left:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f448_1f3fd", - "version": 1, - "popularity": 306, - "codepoint": "1f448_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-left:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f448_1f3fe", - "version": 1, - "popularity": 305, - "codepoint": "1f448_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-left:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f448_1f3ff", - "version": 1, - "popularity": 304, - "codepoint": "1f448_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-left:"], - "sizes_px": [] - }, - { - "name": "emoji_u261d_fe0f", - "version": 1, - "popularity": 303, - "codepoint": "261d_fe0f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":index-finger:"], - "sizes_px": [] - }, - { - "name": "emoji_u261d_1f3fb", - "version": 1, - "popularity": 302, - "codepoint": "261d_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":index-finger:"], - "sizes_px": [] - }, - { - "name": "emoji_u261d_1f3fc", - "version": 1, - "popularity": 301, - "codepoint": "261d_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":index-finger:"], - "sizes_px": [] - }, - { - "name": "emoji_u261d_1f3fd", - "version": 1, - "popularity": 300, - "codepoint": "261d_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":index-finger:"], - "sizes_px": [] - }, - { - "name": "emoji_u261d_1f3fe", - "version": 1, - "popularity": 299, - "codepoint": "261d_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":index-finger:"], - "sizes_px": [] - }, - { - "name": "emoji_u261d_1f3ff", - "version": 1, - "popularity": 298, - "codepoint": "261d_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":index-finger:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f446", - "version": 1, - "popularity": 297, - "codepoint": "1f446", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-up:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f446_1f3fb", - "version": 1, - "popularity": 296, - "codepoint": "1f446_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-up:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f446_1f3fc", - "version": 1, - "popularity": 295, - "codepoint": "1f446_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-up:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f446_1f3fd", - "version": 1, - "popularity": 294, - "codepoint": "1f446_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-up:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f446_1f3fe", - "version": 1, - "popularity": 293, - "codepoint": "1f446_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-up:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f446_1f3ff", - "version": 1, - "popularity": 292, - "codepoint": "1f446_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-up:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f447", - "version": 1, - "popularity": 291, - "codepoint": "1f447", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-down:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f447_1f3fb", - "version": 1, - "popularity": 290, - "codepoint": "1f447_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-down:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f447_1f3fc", - "version": 1, - "popularity": 289, - "codepoint": "1f447_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-down:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f447_1f3fd", - "version": 1, - "popularity": 288, - "codepoint": "1f447_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-down:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f447_1f3fe", - "version": 1, - "popularity": 287, - "codepoint": "1f447_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-down:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f447_1f3ff", - "version": 1, - "popularity": 286, - "codepoint": "1f447_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":point-down:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f595", - "version": 1, - "popularity": 285, - "codepoint": "1f595", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":middle-finger:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f595_1f3fb", - "version": 1, - "popularity": 284, - "codepoint": "1f595_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":middle-finger:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f595_1f3fc", - "version": 1, - "popularity": 283, - "codepoint": "1f595_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":middle-finger:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f595_1f3fd", - "version": 1, - "popularity": 282, - "codepoint": "1f595_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":middle-finger:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f595_1f3fe", - "version": 1, - "popularity": 281, - "codepoint": "1f595_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":middle-finger:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f595_1f3ff", - "version": 1, - "popularity": 280, - "codepoint": "1f595_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":middle-finger:"], - "sizes_px": [] - }, - { - "name": "emoji_u270d_fe0f", - "version": 1, - "popularity": 279, - "codepoint": "270d_fe0f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":writing-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270d_1f3fb", - "version": 1, - "popularity": 278, - "codepoint": "270d_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":writing-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270d_1f3fc", - "version": 1, - "popularity": 277, - "codepoint": "270d_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":writing-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270d_1f3fd", - "version": 1, - "popularity": 276, - "codepoint": "270d_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":writing-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270d_1f3fe", - "version": 1, - "popularity": 275, - "codepoint": "270d_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":writing-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u270d_1f3ff", - "version": 1, - "popularity": 274, - "codepoint": "270d_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":writing-hand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f933", - "version": 1, - "popularity": 273, - "codepoint": "1f933", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":selfie:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f933_1f3fb", - "version": 1, - "popularity": 272, - "codepoint": "1f933_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":selfie:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f933_1f3fc", - "version": 1, - "popularity": 271, - "codepoint": "1f933_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":selfie:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f933_1f3fd", - "version": 1, - "popularity": 270, - "codepoint": "1f933_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":selfie:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f933_1f3fe", - "version": 1, - "popularity": 269, - "codepoint": "1f933_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":selfie:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f933_1f3ff", - "version": 1, - "popularity": 268, - "codepoint": "1f933_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":selfie:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f64f", - "version": 1, - "popularity": 267, - "codepoint": "1f64f", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [ - ":folded-hands:", - ":please:", - ":pray:", - ":hope:", - ":wish:", - ":thank-you:", - ":high-five:" - ], - "sizes_px": [] - }, - { - "name": "emoji_u1f64f_1f3fb", - "version": 1, - "popularity": 266, - "codepoint": "1f64f_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [ - ":folded-hands:", - ":please:", - ":pray:", - ":hope:", - ":wish:", - ":thank-you:", - ":high-five:" - ], - "sizes_px": [] - }, - { - "name": "emoji_u1f64f_1f3fc", - "version": 1, - "popularity": 265, - "codepoint": "1f64f_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [ - ":folded-hands:", - ":please:", - ":pray:", - ":hope:", - ":wish:", - ":thank-you:", - ":high-five:" - ], - "sizes_px": [] - }, - { - "name": "emoji_u1f64f_1f3fd", - "version": 1, - "popularity": 264, - "codepoint": "1f64f_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [ - ":folded-hands:", - ":please:", - ":pray:", - ":hope:", - ":wish:", - ":thank-you:", - ":high-five:" - ], - "sizes_px": [] - }, - { - "name": "emoji_u1f64f_1f3fe", - "version": 1, - "popularity": 263, - "codepoint": "1f64f_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [ - ":folded-hands:", - ":please:", - ":pray:", - ":hope:", - ":wish:", - ":thank-you:", - ":high-five:" - ], - "sizes_px": [] - }, - { - "name": "emoji_u1f64f_1f3ff", - "version": 1, - "popularity": 262, - "codepoint": "1f64f_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [ - ":folded-hands:", - ":please:", - ":pray:", - ":hope:", - ":wish:", - ":thank-you:", - ":high-five:" - ], - "sizes_px": [] - }, - { - "name": "emoji_u1f485", - "version": 1, - "popularity": 261, - "codepoint": "1f485", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":nail-care:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f485_1f3fb", - "version": 1, - "popularity": 260, - "codepoint": "1f485_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":nail-care:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f485_1f3fc", - "version": 1, - "popularity": 259, - "codepoint": "1f485_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":nail-care:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f485_1f3fd", - "version": 1, - "popularity": 258, - "codepoint": "1f485_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":nail-care:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f485_1f3fe", - "version": 1, - "popularity": 257, - "codepoint": "1f485_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":nail-care:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f485_1f3ff", - "version": 1, - "popularity": 256, - "codepoint": "1f485_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":nail-care:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91d", - "version": 1, - "popularity": 255, - "codepoint": "1f91d", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91d_1f3fb", - "version": 1, - "popularity": 254, - "codepoint": "1f91d_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fb_200d_1faf2_1f3fc", - "version": 1, - "popularity": 253, - "codepoint": "1faf1_1f3fb_200d_1faf2_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fb_200d_1faf2_1f3fd", - "version": 1, - "popularity": 252, - "codepoint": "1faf1_1f3fb_200d_1faf2_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fb_200d_1faf2_1f3fe", - "version": 1, - "popularity": 251, - "codepoint": "1faf1_1f3fb_200d_1faf2_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fb_200d_1faf2_1f3ff", - "version": 1, - "popularity": 250, - "codepoint": "1faf1_1f3fb_200d_1faf2_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fc_200d_1faf2_1f3fb", - "version": 1, - "popularity": 249, - "codepoint": "1faf1_1f3fc_200d_1faf2_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91d_1f3fc", - "version": 1, - "popularity": 248, - "codepoint": "1f91d_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fc_200d_1faf2_1f3fd", - "version": 1, - "popularity": 247, - "codepoint": "1faf1_1f3fc_200d_1faf2_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fc_200d_1faf2_1f3fe", - "version": 1, - "popularity": 246, - "codepoint": "1faf1_1f3fc_200d_1faf2_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fc_200d_1faf2_1f3ff", - "version": 1, - "popularity": 245, - "codepoint": "1faf1_1f3fc_200d_1faf2_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fd_200d_1faf2_1f3fb", - "version": 1, - "popularity": 244, - "codepoint": "1faf1_1f3fd_200d_1faf2_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fd_200d_1faf2_1f3fc", - "version": 1, - "popularity": 243, - "codepoint": "1faf1_1f3fd_200d_1faf2_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91d_1f3fd", - "version": 1, - "popularity": 242, - "codepoint": "1f91d_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fd_200d_1faf2_1f3fe", - "version": 1, - "popularity": 241, - "codepoint": "1faf1_1f3fd_200d_1faf2_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fd_200d_1faf2_1f3ff", - "version": 1, - "popularity": 240, - "codepoint": "1faf1_1f3fd_200d_1faf2_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fe_200d_1faf2_1f3fb", - "version": 1, - "popularity": 239, - "codepoint": "1faf1_1f3fe_200d_1faf2_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fe_200d_1faf2_1f3fc", - "version": 1, - "popularity": 238, - "codepoint": "1faf1_1f3fe_200d_1faf2_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fe_200d_1faf2_1f3fd", - "version": 1, - "popularity": 237, - "codepoint": "1faf1_1f3fe_200d_1faf2_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91d_1f3fe", - "version": 1, - "popularity": 236, - "codepoint": "1f91d_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3fe_200d_1faf2_1f3ff", - "version": 1, - "popularity": 235, - "codepoint": "1faf1_1f3fe_200d_1faf2_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3ff_200d_1faf2_1f3fb", - "version": 1, - "popularity": 234, - "codepoint": "1faf1_1f3ff_200d_1faf2_1f3fb", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3ff_200d_1faf2_1f3fc", - "version": 1, - "popularity": 233, - "codepoint": "1faf1_1f3ff_200d_1faf2_1f3fc", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3ff_200d_1faf2_1f3fd", - "version": 1, - "popularity": 232, - "codepoint": "1faf1_1f3ff_200d_1faf2_1f3fd", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faf1_1f3ff_200d_1faf2_1f3fe", - "version": 1, - "popularity": 231, - "codepoint": "1faf1_1f3ff_200d_1faf2_1f3fe", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f91d_1f3ff", - "version": 1, - "popularity": 230, - "codepoint": "1f91d_1f3ff", - "unsupported_families": [], - "categories": ["Smileys and emotions"], - "tags": [":handshake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f483", - "version": 1, - "popularity": 229, - "codepoint": "1f483", - "unsupported_families": [], - "categories": ["People"], - "tags": [":dancer-woman:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f483_1f3fb", - "version": 1, - "popularity": 228, - "codepoint": "1f483_1f3fb", - "unsupported_families": [], - "categories": ["People"], - "tags": [":dancer-woman:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f483_1f3fc", - "version": 1, - "popularity": 227, - "codepoint": "1f483_1f3fc", - "unsupported_families": [], - "categories": ["People"], - "tags": [":dancer-woman:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f483_1f3fd", - "version": 1, - "popularity": 226, - "codepoint": "1f483_1f3fd", - "unsupported_families": [], - "categories": ["People"], - "tags": [":dancer-woman:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f483_1f3fe", - "version": 1, - "popularity": 225, - "codepoint": "1f483_1f3fe", - "unsupported_families": [], - "categories": ["People"], - "tags": [":dancer-woman:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f483_1f3ff", - "version": 1, - "popularity": 224, - "codepoint": "1f483_1f3ff", - "unsupported_families": [], - "categories": ["People"], - "tags": [":dancer-woman:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f490", - "version": 1, - "popularity": 223, - "codepoint": "1f490", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":bouquet:", ":flowers:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f339", - "version": 1, - "popularity": 222, - "codepoint": "1f339", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":rose:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f940", - "version": 1, - "popularity": 221, - "codepoint": "1f940", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":wilted-flower:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f342", - "version": 1, - "popularity": 220, - "codepoint": "1f342", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":fallen-leaf:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f331", - "version": 1, - "popularity": 219, - "codepoint": "1f331", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":plant:", ":seed:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f343", - "version": 1, - "popularity": 218, - "codepoint": "1f343", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":leaves:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f340", - "version": 1, - "popularity": 217, - "codepoint": "1f340", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":luck:", ":four-leaf-clover:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fabe", - "version": 1, - "popularity": 216, - "codepoint": "1fabe", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":leafless-tree:"], - "sizes_px": [] - }, - { - "name": "emoji_u2744_fe0f", - "version": 1, - "popularity": 215, - "codepoint": "2744_fe0f", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":snowflake:", ":winter:", ":cold:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f30b", - "version": 1, - "popularity": 214, - "codepoint": "1f30b", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":volcano:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f305", - "version": 1, - "popularity": 213, - "codepoint": "1f305", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":sunrise:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f304", - "version": 1, - "popularity": 212, - "codepoint": "1f304", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":sunrise-over-mountains:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f308", - "version": 1, - "popularity": 211, - "codepoint": "1f308", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":rainbow:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fae7", - "version": 1, - "popularity": 210, - "codepoint": "1fae7", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":bubbles:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f30a", - "version": 1, - "popularity": 209, - "codepoint": "1f30a", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":ocean:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f32c_fe0f", - "version": 1, - "popularity": 208, - "codepoint": "1f32c_fe0f", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":wind-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f32a_fe0f", - "version": 1, - "popularity": 207, - "codepoint": "1f32a_fe0f", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":tornado:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4a7", - "version": 1, - "popularity": 206, - "codepoint": "1f4a7", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":droplet:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f327_fe0f", - "version": 1, - "popularity": 205, - "codepoint": "1f327_fe0f", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":rain-cloud:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f329_fe0f", - "version": 1, - "popularity": 204, - "codepoint": "1f329_fe0f", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":cloud-with-lightning:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f30d", - "version": 1, - "popularity": 203, - "codepoint": "1f30d", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":globe-showing-Europe-Africa:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f30e", - "version": 1, - "popularity": 202, - "codepoint": "1f30e", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":globe-showing-Americas:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f30f", - "version": 1, - "popularity": 201, - "codepoint": "1f30f", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":globe-showing-Asia-Australia:"], - "sizes_px": [] - }, - { - "name": "emoji_u2604_fe0f", - "version": 1, - "popularity": 200, - "codepoint": "2604_fe0f", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":comet:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f42e", - "version": 1, - "popularity": 199, - "codepoint": "1f42e", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":cow-face:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f984", - "version": 1, - "popularity": 198, - "codepoint": "1f984", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":unicorn:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f98e", - "version": 1, - "popularity": 197, - "codepoint": "1f98e", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":lizard:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f409", - "version": 1, - "popularity": 196, - "codepoint": "1f409", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":dragon:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f996", - "version": 1, - "popularity": 195, - "codepoint": "1f996", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":t-rex:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f995", - "version": 1, - "popularity": 194, - "codepoint": "1f995", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":dinosaur:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f422", - "version": 1, - "popularity": 193, - "codepoint": "1f422", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":turtle:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f40a", - "version": 1, - "popularity": 192, - "codepoint": "1f40a", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":crocodile:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f40d", - "version": 1, - "popularity": 191, - "codepoint": "1f40d", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":snake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f438", - "version": 1, - "popularity": 190, - "codepoint": "1f438", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":frog:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f407", - "version": 1, - "popularity": 189, - "codepoint": "1f407", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":rabbit:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f400", - "version": 1, - "popularity": 188, - "codepoint": "1f400", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":rat:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f429", - "version": 1, - "popularity": 187, - "codepoint": "1f429", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":poodle:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f415", - "version": 1, - "popularity": 186, - "codepoint": "1f415", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":dog:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9ae", - "version": 1, - "popularity": 185, - "codepoint": "1f9ae", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":guide-dog:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f415_200d_1f9ba", - "version": 1, - "popularity": 184, - "codepoint": "1f415_200d_1f9ba", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":service-dog:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f416", - "version": 1, - "popularity": 183, - "codepoint": "1f416", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":pig:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f40e", - "version": 1, - "popularity": 182, - "codepoint": "1f40e", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":racehorse:"], - "sizes_px": [] - }, - { - "name": "emoji_u1facf", - "version": 1, - "popularity": 181, - "codepoint": "1facf", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":donkey:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f402", - "version": 1, - "popularity": 180, - "codepoint": "1f402", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":ox:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f410", - "version": 1, - "popularity": 179, - "codepoint": "1f410", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":goat:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f998", - "version": 1, - "popularity": 178, - "codepoint": "1f998", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":kangaroo:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f405", - "version": 1, - "popularity": 177, - "codepoint": "1f405", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":tiger:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f412", - "version": 1, - "popularity": 176, - "codepoint": "1f412", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":monkey:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f98d", - "version": 1, - "popularity": 175, - "codepoint": "1f98d", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":gorilla:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9a7", - "version": 1, - "popularity": 174, - "codepoint": "1f9a7", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":orangutan:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f43f_fe0f", - "version": 1, - "popularity": 173, - "codepoint": "1f43f_fe0f", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":chipmunk:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9a6", - "version": 1, - "popularity": 172, - "codepoint": "1f9a6", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":otter:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f987", - "version": 1, - "popularity": 171, - "codepoint": "1f987", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":bat:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f426", - "version": 1, - "popularity": 170, - "codepoint": "1f426", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":bird:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f426_200d_2b1b", - "version": 1, - "popularity": 169, - "codepoint": "1f426_200d_2b1b", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":black-bird:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f413", - "version": 1, - "popularity": 168, - "codepoint": "1f413", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":rooster:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f423", - "version": 1, - "popularity": 167, - "codepoint": "1f423", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":hatching-chick:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f424", - "version": 1, - "popularity": 166, - "codepoint": "1f424", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":baby-chick:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f425", - "version": 1, - "popularity": 165, - "codepoint": "1f425", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":hatched-chick:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f985", - "version": 1, - "popularity": 164, - "codepoint": "1f985", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":eagle:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f989", - "version": 1, - "popularity": 163, - "codepoint": "1f989", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":owl:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f54a_fe0f", - "version": 1, - "popularity": 162, - "codepoint": "1f54a_fe0f", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":peace:", ":dove:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fabf", - "version": 1, - "popularity": 161, - "codepoint": "1fabf", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":goose:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f99a", - "version": 1, - "popularity": 160, - "codepoint": "1f99a", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":peacock:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f426_200d_1f525", - "version": 1, - "popularity": 159, - "codepoint": "1f426_200d_1f525", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":phoenix:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f9ad", - "version": 1, - "popularity": 158, - "codepoint": "1f9ad", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":seal:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f988", - "version": 1, - "popularity": 157, - "codepoint": "1f988", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":shark:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f42c", - "version": 1, - "popularity": 156, - "codepoint": "1f42c", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":dolphin:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f433", - "version": 1, - "popularity": 155, - "codepoint": "1f433", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":whale:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f41f", - "version": 1, - "popularity": 154, - "codepoint": "1f41f", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":fish:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f421", - "version": 1, - "popularity": 153, - "codepoint": "1f421", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":blowfish:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f99e", - "version": 1, - "popularity": 152, - "codepoint": "1f99e", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":lobster:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f980", - "version": 1, - "popularity": 151, - "codepoint": "1f980", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":crab:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f419", - "version": 1, - "popularity": 150, - "codepoint": "1f419", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":octopus:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fabc", - "version": 1, - "popularity": 149, - "codepoint": "1fabc", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":jellyfish:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f982", - "version": 1, - "popularity": 148, - "codepoint": "1f982", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":scorpion:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f577_fe0f", - "version": 1, - "popularity": 147, - "codepoint": "1f577_fe0f", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":spider:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f40c", - "version": 1, - "popularity": 146, - "codepoint": "1f40c", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":snail:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f41c", - "version": 1, - "popularity": 145, - "codepoint": "1f41c", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":ant:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f99f", - "version": 1, - "popularity": 144, - "codepoint": "1f99f", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":mosquito:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fab3", - "version": 1, - "popularity": 143, - "codepoint": "1fab3", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":cockroach:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fab0", - "version": 1, - "popularity": 142, - "codepoint": "1fab0", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":fly:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f41d", - "version": 1, - "popularity": 141, - "codepoint": "1f41d", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":bee:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f41e", - "version": 1, - "popularity": 140, - "codepoint": "1f41e", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":lady-bug:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f98b", - "version": 1, - "popularity": 139, - "codepoint": "1f98b", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":butterfly:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f41b", - "version": 1, - "popularity": 138, - "codepoint": "1f41b", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":bug:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fab1", - "version": 1, - "popularity": 137, - "codepoint": "1fab1", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":worm:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f43e", - "version": 1, - "popularity": 136, - "codepoint": "1f43e", - "unsupported_families": [], - "categories": ["Animals and nature"], - "tags": [":paw prints:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f345", - "version": 1, - "popularity": 135, - "codepoint": "1f345", - "unsupported_families": [], - "categories": ["Food and drink"], - "tags": [":tomato:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fadc", - "version": 1, - "popularity": 134, - "codepoint": "1fadc", - "unsupported_families": [], - "categories": ["Food and drink"], - "tags": [":root-vegetable:", ":beet:", ":turnip:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f373", - "version": 1, - "popularity": 133, - "codepoint": "1f373", - "unsupported_families": [], - "categories": ["Food and drink"], - "tags": [":cooking:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f32f", - "version": 1, - "popularity": 132, - "codepoint": "1f32f", - "unsupported_families": [], - "categories": ["Food and drink"], - "tags": [":burrito:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f35d", - "version": 1, - "popularity": 131, - "codepoint": "1f35d", - "unsupported_families": [], - "categories": ["Food and drink"], - "tags": [":spaghetti:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f35c", - "version": 1, - "popularity": 130, - "codepoint": "1f35c", - "unsupported_families": [], - "categories": ["Food and drink"], - "tags": [":steaming-bowl:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f37f", - "version": 1, - "popularity": 129, - "codepoint": "1f37f", - "unsupported_families": [], - "categories": ["Food and drink"], - "tags": [":popcorn:"], - "sizes_px": [] - }, - { - "name": "emoji_u2615", - "version": 1, - "popularity": 128, - "codepoint": "2615", - "unsupported_families": [], - "categories": ["Food and drink"], - "tags": [":hot-beverage:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f37b", - "version": 1, - "popularity": 127, - "codepoint": "1f37b", - "unsupported_families": [], - "categories": ["Food and drink"], - "tags": [":clinking-beer-mugs:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f942", - "version": 1, - "popularity": 126, - "codepoint": "1f942", - "unsupported_families": [], - "categories": ["Food and drink"], - "tags": [":clinking-glasses:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f37e", - "version": 1, - "popularity": 125, - "codepoint": "1f37e", - "unsupported_families": [], - "categories": ["Food and drink"], - "tags": [":bottle-with-popping-cork:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f377", - "version": 1, - "popularity": 124, - "codepoint": "1f377", - "unsupported_families": [], - "categories": ["Food and drink"], - "tags": [":wine-glass:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fad7", - "version": 1, - "popularity": 123, - "codepoint": "1fad7", - "unsupported_families": [], - "categories": ["Food and drink"], - "tags": [":pour:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f379", - "version": 1, - "popularity": 122, - "codepoint": "1f379", - "unsupported_families": [], - "categories": ["Food and drink"], - "tags": [":tropical-drink:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f6a7", - "version": 1, - "popularity": 121, - "codepoint": "1f6a7", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":construction:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f6a8", - "version": 1, - "popularity": 120, - "codepoint": "1f6a8", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":police-car-light:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f6b2", - "version": 1, - "popularity": 119, - "codepoint": "1f6b2", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":bicycle:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f697", - "version": 1, - "popularity": 118, - "codepoint": "1f697", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":automobile:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3ce_fe0f", - "version": 1, - "popularity": 117, - "codepoint": "1f3ce_fe0f", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":racing-car:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f695", - "version": 1, - "popularity": 116, - "codepoint": "1f695", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":taxi:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f68c", - "version": 1, - "popularity": 115, - "codepoint": "1f68c", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":bus:"], - "sizes_px": [] - }, - { - "name": "emoji_u26f5", - "version": 1, - "popularity": 114, - "codepoint": "26f5", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":sailboat:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f6f6", - "version": 1, - "popularity": 113, - "codepoint": "1f6f6", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":canoe:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f6f8", - "version": 1, - "popularity": 112, - "codepoint": "1f6f8", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":flying-saucer:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f680", - "version": 1, - "popularity": 111, - "codepoint": "1f680", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":rocket:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f6eb", - "version": 1, - "popularity": 110, - "codepoint": "1f6eb", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":airplane-departure:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f6ec", - "version": 1, - "popularity": 109, - "codepoint": "1f6ec", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":airplane-arrival:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3a2", - "version": 1, - "popularity": 108, - "codepoint": "1f3a2", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":roller-coaster:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3a1", - "version": 1, - "popularity": 107, - "codepoint": "1f3a1", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":ferris-wheel:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3d5_fe0f", - "version": 1, - "popularity": 106, - "codepoint": "1f3d5_fe0f", - "unsupported_families": [], - "categories": ["Travel and places"], - "tags": [":camping:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f388", - "version": 1, - "popularity": 105, - "codepoint": "1f388", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":balloon:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f382", - "version": 1, - "popularity": 104, - "codepoint": "1f382", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":birthday-cake:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f381", - "version": 1, - "popularity": 103, - "codepoint": "1f381", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":wrapped-gift:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f386", - "version": 1, - "popularity": 102, - "codepoint": "1f386", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":fireworks:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fa85", - "version": 1, - "popularity": 101, - "codepoint": "1fa85", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":piñata:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faa9", - "version": 1, - "popularity": 100, - "codepoint": "1faa9", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":mirror-ball:", ":disco-ball:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f947", - "version": 1, - "popularity": 99, - "codepoint": "1f947", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":gold-medal:", ":1st-place-medal:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f948", - "version": 1, - "popularity": 98, - "codepoint": "1f948", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":silver-medal:", ":2nd-place-medal:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f949", - "version": 1, - "popularity": 97, - "codepoint": "1f949", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":bronze-medal:", ":3rd-place-medal:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3c6", - "version": 1, - "popularity": 96, - "codepoint": "1f3c6", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":trophy:"], - "sizes_px": [] - }, - { - "name": "emoji_u26bd", - "version": 1, - "popularity": 95, - "codepoint": "26bd", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":soccer-ball:"], - "sizes_px": [] - }, - { - "name": "emoji_u26be", - "version": 1, - "popularity": 94, - "codepoint": "26be", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":baseball:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f94e", - "version": 1, - "popularity": 93, - "codepoint": "1f94e", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":softball:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3be", - "version": 1, - "popularity": 92, - "codepoint": "1f3be", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":tennis:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3f8", - "version": 1, - "popularity": 91, - "codepoint": "1f3f8", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":badminton:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f94d", - "version": 1, - "popularity": 90, - "codepoint": "1f94d", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":lacrosse:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3cf", - "version": 1, - "popularity": 89, - "codepoint": "1f3cf", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":cricket-game:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3d1", - "version": 1, - "popularity": 88, - "codepoint": "1f3d1", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":field-hockey:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3d2", - "version": 1, - "popularity": 87, - "codepoint": "1f3d2", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":ice-hockey:"], - "sizes_px": [] - }, - { - "name": "emoji_u26f8_fe0f", - "version": 1, - "popularity": 86, - "codepoint": "26f8_fe0f", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":ice-skate:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f6fc", - "version": 1, - "popularity": 85, - "codepoint": "1f6fc", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":roller-skates:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fa70", - "version": 1, - "popularity": 84, - "codepoint": "1fa70", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":ballet-shoes:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f6f9", - "version": 1, - "popularity": 83, - "codepoint": "1f6f9", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":skateboard:"], - "sizes_px": [] - }, - { - "name": "emoji_u26f3", - "version": 1, - "popularity": 82, - "codepoint": "26f3", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":flag-in-hole:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3af", - "version": 1, - "popularity": 81, - "codepoint": "1f3af", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":direct-hit:", ":target:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f94f", - "version": 1, - "popularity": 80, - "codepoint": "1f94f", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":flying-disc:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fa83", - "version": 1, - "popularity": 79, - "codepoint": "1fa83", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":boomerang:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fa81", - "version": 1, - "popularity": 78, - "codepoint": "1fa81", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":kite:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3a3", - "version": 1, - "popularity": 77, - "codepoint": "1f3a3", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":fishing-pole:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f94b", - "version": 1, - "popularity": 76, - "codepoint": "1f94b", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":martial-arts-uniform:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3b1", - "version": 1, - "popularity": 75, - "codepoint": "1f3b1", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":8-ball:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3d3", - "version": 1, - "popularity": 74, - "codepoint": "1f3d3", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":ping-pong:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3b3", - "version": 1, - "popularity": 73, - "codepoint": "1f3b3", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":bowling:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3b2", - "version": 1, - "popularity": 72, - "codepoint": "1f3b2", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":die:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3b0", - "version": 1, - "popularity": 71, - "codepoint": "1f3b0", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":slot-machine:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fa84", - "version": 1, - "popularity": 70, - "codepoint": "1fa84", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":wand:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4f8", - "version": 1, - "popularity": 69, - "codepoint": "1f4f8", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":camera-flash:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fadf", - "version": 1, - "popularity": 68, - "codepoint": "1fadf", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":splatter:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3b7", - "version": 1, - "popularity": 67, - "codepoint": "1f3b7", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":saxophone:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3ba", - "version": 1, - "popularity": 66, - "codepoint": "1f3ba", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":trumpet:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3bb", - "version": 1, - "popularity": 65, - "codepoint": "1f3bb", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":violin:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fa89", - "version": 1, - "popularity": 64, - "codepoint": "1fa89", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":harp:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f941", - "version": 1, - "popularity": 63, - "codepoint": "1f941", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":drum:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fa87", - "version": 1, - "popularity": 62, - "codepoint": "1fa87", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":maracas:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3ac", - "version": 1, - "popularity": 61, - "codepoint": "1f3ac", - "unsupported_families": [], - "categories": ["Activities and events"], - "tags": [":clapper:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f50b", - "version": 1, - "popularity": 60, - "codepoint": "1f50b", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":battery-full:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faab", - "version": 1, - "popularity": 59, - "codepoint": "1faab", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":battery-low:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fa99", - "version": 1, - "popularity": 58, - "codepoint": "1fa99", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":coin:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4b8", - "version": 1, - "popularity": 57, - "codepoint": "1f4b8", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":money-with-wings:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f48e", - "version": 1, - "popularity": 56, - "codepoint": "1f48e", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":gem-stone:"], - "sizes_px": [] - }, - { - "name": "emoji_u2696_fe0f", - "version": 1, - "popularity": 55, - "codepoint": "2696_fe0f", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":balance-scale:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4a1", - "version": 1, - "popularity": 54, - "codepoint": "1f4a1", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":light-bulb:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f393", - "version": 1, - "popularity": 53, - "codepoint": "1f393", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":graduation-cap:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f48d", - "version": 1, - "popularity": 52, - "codepoint": "1f48d", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":ring:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faad", - "version": 1, - "popularity": 51, - "codepoint": "1faad", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":fan:"], - "sizes_px": [] - }, - { - "name": "emoji_u2602_fe0f", - "version": 1, - "popularity": 50, - "codepoint": "2602_fe0f", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":umbrella:"], - "sizes_px": [] - }, - { - "name": "emoji_u1fa8f", - "version": 1, - "popularity": 49, - "codepoint": "1fa8f", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":shovel:", ":dig:"], - "sizes_px": [] - }, - { - "name": "emoji_u2699_fe0f", - "version": 1, - "popularity": 48, - "codepoint": "2699_fe0f", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":gear:"], - "sizes_px": [] - }, - { - "name": "emoji_u26d3_fe0f_200d_1f4a5", - "version": 1, - "popularity": 47, - "codepoint": "26d3_fe0f_200d_1f4a5", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":broken-chain:"], - "sizes_px": [] - }, - { - "name": "emoji_u270f_fe0f", - "version": 1, - "popularity": 46, - "codepoint": "270f_fe0f", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":pencil:"], - "sizes_px": [] - }, - { - "name": "emoji_u23f0", - "version": 1, - "popularity": 45, - "codepoint": "23f0", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":alarm-clock:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f6ce_fe0f", - "version": 1, - "popularity": 44, - "codepoint": "1f6ce_fe0f", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":bellhop-bell:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f514", - "version": 1, - "popularity": 43, - "codepoint": "1f514", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":bell:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f52e", - "version": 1, - "popularity": 42, - "codepoint": "1f52e", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":crystal-ball:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4a3", - "version": 1, - "popularity": 41, - "codepoint": "1f4a3", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":bomb:"], - "sizes_px": [] - }, - { - "name": "emoji_u1faa4", - "version": 1, - "popularity": 40, - "codepoint": "1faa4", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":mouse-trap:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f512", - "version": 1, - "popularity": 39, - "codepoint": "1f512", - "unsupported_families": [], - "categories": ["Objects"], - "tags": [":locked:"], - "sizes_px": [] - }, - { - "name": "emoji_u2648", - "version": 1, - "popularity": 38, - "codepoint": "2648", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":Aries:"], - "sizes_px": [] - }, - { - "name": "emoji_u2649", - "version": 1, - "popularity": 37, - "codepoint": "2649", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":Taurus:"], - "sizes_px": [] - }, - { - "name": "emoji_u264a", - "version": 1, - "popularity": 36, - "codepoint": "264a", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":Gemini:"], - "sizes_px": [] - }, - { - "name": "emoji_u264b", - "version": 1, - "popularity": 35, - "codepoint": "264b", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":Cancer:"], - "sizes_px": [] - }, - { - "name": "emoji_u264c", - "version": 1, - "popularity": 34, - "codepoint": "264c", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":Leo:"], - "sizes_px": [] - }, - { - "name": "emoji_u264d", - "version": 1, - "popularity": 33, - "codepoint": "264d", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":Virgo:"], - "sizes_px": [] - }, - { - "name": "emoji_u264e", - "version": 1, - "popularity": 32, - "codepoint": "264e", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":Libra:"], - "sizes_px": [] - }, - { - "name": "emoji_u264f", - "version": 1, - "popularity": 31, - "codepoint": "264f", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":Scorpio:"], - "sizes_px": [] - }, - { - "name": "emoji_u2650", - "version": 1, - "popularity": 30, - "codepoint": "2650", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":Sagittarius:"], - "sizes_px": [] - }, - { - "name": "emoji_u2651", - "version": 1, - "popularity": 29, - "codepoint": "2651", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":Capricorn:"], - "sizes_px": [] - }, - { - "name": "emoji_u2652", - "version": 1, - "popularity": 28, - "codepoint": "2652", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":Aquarius:"], - "sizes_px": [] - }, - { - "name": "emoji_u2653", - "version": 1, - "popularity": 27, - "codepoint": "2653", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":Pisces:"], - "sizes_px": [] - }, - { - "name": "emoji_u26ce", - "version": 1, - "popularity": 26, - "codepoint": "26ce", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":Ophiuchus:"], - "sizes_px": [] - }, - { - "name": "emoji_u2757", - "version": 1, - "popularity": 25, - "codepoint": "2757", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":exclamation:", ":exclamation-mark:"], - "sizes_px": [] - }, - { - "name": "emoji_u2753", - "version": 1, - "popularity": 24, - "codepoint": "2753", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":question:", ":question-mark:", ":?:"], - "sizes_px": [] - }, - { - "name": "emoji_u2049_fe0f", - "version": 1, - "popularity": 23, - "codepoint": "2049_fe0f", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":exclamation-question-mark:", ":!?:"], - "sizes_px": [] - }, - { - "name": "emoji_u203c_fe0f", - "version": 1, - "popularity": 22, - "codepoint": "203c_fe0f", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":exclamation-double:", ":!!:"], - "sizes_px": [] - }, - { - "name": "emoji_u274c", - "version": 1, - "popularity": 21, - "codepoint": "274c", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":cross-mark:", ":x:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f198", - "version": 1, - "popularity": 20, - "codepoint": "1f198", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":sos:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f4f4", - "version": 1, - "popularity": 19, - "codepoint": "1f4f4", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":phone-off:"], - "sizes_px": [] - }, - { - "name": "emoji_u2622_fe0f", - "version": 1, - "popularity": 18, - "codepoint": "2622_fe0f", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":radioactive:"], - "sizes_px": [] - }, - { - "name": "emoji_u2623_fe0f", - "version": 1, - "popularity": 17, - "codepoint": "2623_fe0f", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":biohazard:"], - "sizes_px": [] - }, - { - "name": "emoji_u26a0_fe0f", - "version": 1, - "popularity": 16, - "codepoint": "26a0_fe0f", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":warning:"], - "sizes_px": [] - }, - { - "name": "emoji_u2705", - "version": 1, - "popularity": 15, - "codepoint": "2705", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":check-mark:", ":check-mark-green:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f195", - "version": 1, - "popularity": 14, - "codepoint": "1f195", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":new:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f193", - "version": 1, - "popularity": 13, - "codepoint": "1f193", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":free:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f199", - "version": 1, - "popularity": 12, - "codepoint": "1f199", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":up!:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f192", - "version": 1, - "popularity": 11, - "codepoint": "1f192", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":cool:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f6ae", - "version": 1, - "popularity": 10, - "codepoint": "1f6ae", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":litter:"], - "sizes_px": [] - }, - { - "name": "emoji_u262e_fe0f", - "version": 1, - "popularity": 9, - "codepoint": "262e_fe0f", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":peace-symbol:"], - "sizes_px": [] - }, - { - "name": "emoji_u262f_fe0f", - "version": 1, - "popularity": 8, - "codepoint": "262f_fe0f", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":yin-yang:"], - "sizes_px": [] - }, - { - "name": "emoji_u267e_fe0f", - "version": 1, - "popularity": 7, - "codepoint": "267e_fe0f", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":infinity:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3b6", - "version": 1, - "popularity": 6, - "codepoint": "1f3b6", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":musical-notes:"], - "sizes_px": [] - }, - { - "name": "emoji_u2795", - "version": 1, - "popularity": 5, - "codepoint": "2795", - "unsupported_families": [], - "categories": ["Symbols"], - "tags": [":plus-sign:", ":+:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3c1", - "version": 1, - "popularity": 4, - "codepoint": "1f3c1", - "unsupported_families": [], - "categories": ["Flags"], - "tags": [":chequered-flag:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f6a9", - "version": 1, - "popularity": 3, - "codepoint": "1f6a9", - "unsupported_families": [], - "categories": ["Flags"], - "tags": [":triangular-flag:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3f4", - "version": 1, - "popularity": 2, - "codepoint": "1f3f4", - "unsupported_families": [], - "categories": ["Flags"], - "tags": [":black-flag:"], - "sizes_px": [] - }, - { - "name": "emoji_u1f3f3_fe0f", - "version": 1, - "popularity": 1, - "codepoint": "1f3f3_fe0f", - "unsupported_families": [], - "categories": ["Flags"], - "tags": [":white-flag:"], - "sizes_px": [] - } - ] -} diff --git a/apps/group-chat/src/lib/emojis/index.ts b/apps/group-chat/src/lib/emojis/index.ts deleted file mode 100644 index c9c843f..0000000 --- a/apps/group-chat/src/lib/emojis/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -// animated emojis from -// https://googlefonts.github.io/noto-emoji-animation/ - -import icons from './icons.json'; - -export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined { - if (!emoji) return; - // Convert emoji to lowercase hex codepoints joined by "_" - const codepoints: string[] = []; - for (const char of emoji) { - codepoints.push(char.codePointAt(0)!.toString(16).toLowerCase()); - } - - let key = codepoints.join('_'); - - if (icons.icons.find((v) => v.codepoint == key)) { - return `https://fonts.gstatic.com/s/e/notoemoji/latest/${key}/512.webp`; - } - - key = codepoints.filter((cp) => cp !== 'fe0f' && cp !== 'fe0e').join('_'); - if (icons.icons.find((v) => v.codepoint == key)) - return `https://fonts.gstatic.com/s/e/notoemoji/latest/${key}/512.webp`; -} diff --git a/apps/group-chat/src/lib/index.ts b/apps/group-chat/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/apps/group-chat/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/apps/group-chat/src/lib/rooms/channels-context.ts b/apps/group-chat/src/lib/rooms/channels-context.ts deleted file mode 100644 index fe350f8..0000000 --- a/apps/group-chat/src/lib/rooms/channels-context.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** Context key + type for the community channel list. - * - * The community layout (`+layout.svelte`) maintains a live channel list via - * a cross-space `createWatchQuery` on `tools.atmo.chat.channel`. Child pages - * read the list through Svelte context so they don't each run their own - * watch query. The getter ensures reads stay reactive. */ - -export interface ChannelMeta { - spaceUri: string; - key: string; - name: string; - topic?: string; - visibility: 'public' | 'private'; - createdAt: string; -} - -export interface ChannelsContext { - readonly list: readonly ChannelMeta[]; -} - -export const CHANNELS_CTX = Symbol('community-channels'); diff --git a/apps/group-chat/src/lib/rooms/connection.svelte.ts b/apps/group-chat/src/lib/rooms/connection.svelte.ts deleted file mode 100644 index 95c56d9..0000000 --- a/apps/group-chat/src/lib/rooms/connection.svelte.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** Cross-component connection status for the realtime subscription powering - * the current view. The channel page updates this as its WatchQuery status - * changes; the server navbar renders a tiny colored dot for it. */ - -import type { WatchStoreStatus } from '@atmo-dev/contrail-sync'; - -export const connection = $state<{ status: WatchStoreStatus }>({ status: 'idle' }); - -export function setConnectionStatus(status: WatchStoreStatus): void { - connection.status = status; -} - -export function resetConnectionStatus(): void { - connection.status = 'idle'; -} - -/** Map status → (color, label) for the UI dot. */ -export function connectionIndicator(status: WatchStoreStatus): { - color: 'green' | 'orange' | 'red' | 'gray'; - label: string; -} { - switch (status) { - case 'live': - return { color: 'green', label: 'Connected' }; - case 'connecting': - case 'snapshot': - case 'reconnecting': - return { color: 'orange', label: 'Connecting…' }; - case 'closed': - return { color: 'red', label: 'Disconnected' }; - case 'idle': - default: - return { color: 'gray', label: 'Idle' }; - } -} diff --git a/apps/group-chat/src/lib/rooms/invites.remote.ts b/apps/group-chat/src/lib/rooms/invites.remote.ts deleted file mode 100644 index 9a13b7f..0000000 --- a/apps/group-chat/src/lib/rooms/invites.remote.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** Thin SvelteKit remote-command wrappers around contrail's community.invite.* - * XRPCs. Contrail owns the token storage, atomic redemption, and access-level - * authorization checks. */ - -import { error } from '@sveltejs/kit'; -import { command, getRequestEvent } from '$app/server'; -import * as v from 'valibot'; -import type { Client } from '@atcute/client'; -import { - createCommunityInvite, - redeemCommunityInvite, - revokeCommunityInvite -} from './server'; - -function requireAuthed() { - const event = getRequestEvent(); - const env = event.platform?.env; - const client = event.locals.client; - const did = event.locals.did; - if (!env) error(500, 'platform env unavailable'); - if (!client || !did) error(401, 'Not authenticated'); - return { env, client: client as Client, did: did as string }; -} - -const AccessLevel = v.picklist(['member', 'manager', 'admin', 'owner']); - -const CreateInviteInput = v.object({ - spaceUri: v.pipe(v.string(), v.minLength(1)), - accessLevel: v.optional(AccessLevel), - expiresInMinutes: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(60 * 24 * 90))), - maxUses: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(10000))), - note: v.optional(v.pipe(v.string(), v.maxLength(500))) -}); - -export const createInvite = command(CreateInviteInput, async (input) => { - const ctx = requireAuthed(); - const expiresAt = - input.expiresInMinutes != null ? Date.now() + input.expiresInMinutes * 60_000 : undefined; - const res = await createCommunityInvite(ctx, { - spaceUri: input.spaceUri, - accessLevel: input.accessLevel ?? 'member', - expiresAt, - maxUses: input.maxUses, - note: input.note - }); - return { token: res.token, tokenHash: res.tokenHash }; -}); - -const RevokeInviteInput = v.object({ - tokenHash: v.pipe(v.string(), v.minLength(1)) -}); - -export const revokeInvite = command(RevokeInviteInput, async (input) => { - const ctx = requireAuthed(); - return revokeCommunityInvite(ctx, { tokenHash: input.tokenHash }); -}); - -const RedeemInviteInput = v.object({ - token: v.pipe(v.string(), v.minLength(8)) -}); - -export const redeemInvite = command(RedeemInviteInput, async (input) => { - const ctx = requireAuthed(); - return redeemCommunityInvite(ctx, { token: input.token }); -}); diff --git a/apps/group-chat/src/lib/rooms/profiles.svelte.ts b/apps/group-chat/src/lib/rooms/profiles.svelte.ts deleted file mode 100644 index c2999ab..0000000 --- a/apps/group-chat/src/lib/rooms/profiles.svelte.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** Browser-side profile cache. DIDs → handle / displayName / avatar. - * Lazy: first read triggers a `.getProfile` fetch in the background; - * the reactive store updates when the fetch resolves, so templates reading - * the display string rerender automatically. - * - * Kept dead simple — no eviction, no refresh. A single user session touches - * at most a handful of DIDs, so a plain record is fine. */ - -import { untrack } from 'svelte'; - -export interface ProfileEntry { - did: string; - handle?: string; - displayName?: string; - avatar?: string; - /** true if we tried and failed — stops us from retrying forever. */ - failed?: boolean; -} - -export const profiles = $state>({}); -const inFlight = new Set(); - -/** Request a profile fetch if we don't have it. Safe to call from templates. */ -export function ensureProfile(did: string): void { - if (!did) return; - if (untrack(() => profiles[did] || inFlight.has(did))) return; - inFlight.add(did); - void (async () => { - try { - const res = await fetch( - `/xrpc/tools.atmo.chat.getProfile?actor=${encodeURIComponent(did)}` - ); - if (!res.ok) throw new Error(`getProfile ${res.status}`); - const data = (await res.json()) as { - profiles?: Array<{ - did: string; - handle?: string | null; - value?: { displayName?: string; avatar?: string }; - }>; - }; - const entry = data.profiles?.[0]; - profiles[did] = { - did, - handle: entry?.handle ?? undefined, - displayName: entry?.value?.displayName, - avatar: entry?.value?.avatar - }; - } catch { - profiles[did] = { did, failed: true }; - } finally { - inFlight.delete(did); - } - })(); -} - -/** Display name for a DID. Returns handle if known, else a truncated DID. - * Calling this from a template also kicks off a background fetch. */ -export function displayName(did: string): string { - ensureProfile(did); - const p = profiles[did]; - if (p?.displayName) return p.displayName; - if (p?.handle) return p.handle; - return did.length > 14 ? `${did.slice(0, 14)}…` : did; -} - -/** Handle for a DID, if known. Returns null when we don't have it yet. */ -export function handleOf(did: string): string | null { - ensureProfile(did); - return profiles[did]?.handle ?? null; -} diff --git a/apps/group-chat/src/lib/rooms/realtime.svelte.ts b/apps/group-chat/src/lib/rooms/realtime.svelte.ts deleted file mode 100644 index e6f41c7..0000000 --- a/apps/group-chat/src/lib/rooms/realtime.svelte.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** Browser-side realtime: single EventSource against - * `.realtime.subscribe?ticket=…`, keyed on `community:`. - * - auto-renews the ticket before expiry - * - forwards `record.created` into `channelMessages` - * - bumps `unread` counts for channels that aren't open - * - invalidates the current page (channel list) when new channel records arrive - */ - -import { untrack } from 'svelte'; -import { invalidateAll } from '$app/navigation'; -import { bumpUnread } from './unread.svelte'; - -async function fetchTicket( - topic: string -): Promise<{ ticket: string; topics: string[]; expiresAt: number }> { - const res = await fetch('/api/ticket', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic }) - }); - if (!res.ok) throw new Error(`ticket mint failed: ${res.status}`); - return res.json() as Promise<{ ticket: string; topics: string[]; expiresAt: number }>; -} - -export interface ChatMessage { - rkey: string; - authorDid: string; - text: string; - createdAt: string; - replyTo?: string; -} - -// ---- message store per-space --------------------------------------------- - -const byChannel = $state>({}); - -export const channelMessages = { - /** Reactive — safe to read in templates / $derived. */ - get(spaceUri: string): ChatMessage[] { - return byChannel[spaceUri] ?? []; - }, - /** Seed the store from a server-rendered initial message list. Merges with - * any realtime events that arrived before hydration. Fully untracked so - * calling this from a `$effect` doesn't re-enter the effect. */ - seed(spaceUri: string, messages: ChatMessage[]) { - untrack(() => { - const existing = byChannel[spaceUri]; - if (!existing) { - byChannel[spaceUri] = [...messages]; - return; - } - const seen = new Set(existing.map((m) => m.rkey)); - const merged = [...existing]; - for (const m of messages) { - if (!seen.has(m.rkey)) { - seen.add(m.rkey); - merged.push(m); - } - } - merged.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)); - byChannel[spaceUri] = merged; - }); - }, - append(spaceUri: string, m: ChatMessage) { - untrack(() => { - const arr = byChannel[spaceUri] ?? []; - if (arr.some((x) => x.rkey === m.rkey)) return; - byChannel[spaceUri] = [...arr, m].sort((a, b) => - a.createdAt < b.createdAt ? -1 : 1 - ); - }); - }, - remove(spaceUri: string, rkey: string) { - untrack(() => { - const arr = byChannel[spaceUri]; - if (!arr) return; - byChannel[spaceUri] = arr.filter((m) => m.rkey !== rkey); - }); - } -}; - -// ---- connection ---------------------------------------------------------- - -interface Connection { - close(): void; -} - -let current: { key: string; conn: Connection } | null = null; - -export function connectCommunityRealtime(communityDid: string): () => void { - const key = `community:${communityDid}`; - // Re-use if already connected. - if (current?.key === key) return current.conn.close; - - current?.conn.close(); - - let closed = false; - let es: EventSource | null = null; - let renewTimer: ReturnType | null = null; - - const handleEvent = (rawData: string, kind: string) => { - let ev: { - topic: string; - kind: string; - payload: Record; - ts: number; - }; - try { - ev = JSON.parse(rawData); - } catch { - return; - } - // Payload shape mirrors listRecords output (uri/cid/value/did/collection/rkey/time_us/space?). - // `space` is only set for space records — which is what we filter this community stream to. - if (kind === 'record.created') { - const p = ev.payload as { - uri: string; - did: string; - collection: string; - rkey: string; - value: Record; - time_us: number; - space?: string; - }; - if (!p.space) return; - if (p.collection === 'tools.atmo.chat.message') { - const rec = p.value as { text?: string; createdAt?: string; replyTo?: string }; - if (rec.text && rec.createdAt) { - channelMessages.append(p.space, { - rkey: p.rkey, - authorDid: p.did, - text: rec.text, - createdAt: rec.createdAt, - replyTo: rec.replyTo - }); - // Bump unread if we're not currently on this channel. - if (!isCurrentChannel(p.space)) { - bumpUnread(p.space, rec.createdAt); - } - } - } - // Channel record events are handled by the layout's channel - // watch query now — no server-loader reinvoke needed. - } else if (kind === 'record.deleted') { - const p = ev.payload as { uri: string; did: string; collection: string; rkey: string; space?: string }; - if (!p.space) return; - if (p.collection === 'tools.atmo.chat.message') { - channelMessages.remove(p.space, p.rkey); - } - // Channel deletions: handled by the layout's channel watch query. - } else if (kind === 'member.added' || kind === 'member.removed') { - void invalidateAll(); - } - }; - - async function open() { - if (closed) return; - let ticketRes: { ticket: string; topics: string[]; expiresAt: number }; - try { - ticketRes = await fetchTicket(key); - } catch (err) { - console.warn('realtime ticket mint failed', err); - // backoff + retry - renewTimer = setTimeout(open, 5000); - return; - } - - const url = `/xrpc/tools.atmo.chat.realtime.subscribe?ticket=${encodeURIComponent( - ticketRes.ticket - )}`; - es = new EventSource(url); - - es.addEventListener('record.created', (e) => handleEvent((e as MessageEvent).data, 'record.created')); - es.addEventListener('record.deleted', (e) => handleEvent((e as MessageEvent).data, 'record.deleted')); - es.addEventListener('member.added', (e) => handleEvent((e as MessageEvent).data, 'member.added')); - es.addEventListener('member.removed', (e) => handleEvent((e as MessageEvent).data, 'member.removed')); - - es.addEventListener('error', () => { - // Browser retries automatically; only act if the stream is closed. - if (es?.readyState === EventSource.CLOSED && !closed) { - renewTimer = setTimeout(open, 3000); - } - }); - - // Renew ~20s before expiry. - const ttl = Math.max(ticketRes.expiresAt - Date.now() - 20_000, 30_000); - renewTimer = setTimeout(() => { - es?.close(); - es = null; - void open(); - }, ttl); - } - - void open(); - - const conn: Connection = { - close() { - closed = true; - if (renewTimer) clearTimeout(renewTimer); - es?.close(); - if (current?.key === key) current = null; - } - }; - - current = { key, conn }; - return () => conn.close(); -} - -// ---- helpers ------------------------------------------------------------- - -function isCurrentChannel(spaceUri: string): boolean { - if (typeof window === 'undefined') return false; - return currentChannelSpaceUri === spaceUri; -} - -let currentChannelSpaceUri: string | null = null; - -export function setCurrentChannel(spaceUri: string | null) { - currentChannelSpaceUri = spaceUri; -} diff --git a/apps/group-chat/src/lib/rooms/rooms.remote.ts b/apps/group-chat/src/lib/rooms/rooms.remote.ts deleted file mode 100644 index 44ae8ce..0000000 --- a/apps/group-chat/src/lib/rooms/rooms.remote.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** SvelteKit remote functions the browser can invoke directly. They run on - * the server, so they have access to `locals.client` (OAuth session) and - * `platform.env` (D1, DO, secrets). */ - -import { error } from '@sveltejs/kit'; -import { command, getRequestEvent } from '$app/server'; -import * as v from 'valibot'; -import type { Client } from '@atcute/client'; -import { - createSpace, - communityPutRecord, - grantAccess, - mintCommunity, - mintWatchTicket, - spacePutRecord, - revokeAccess, - setAccessLevel, - uploadBlob -} from './server'; -import { buildMembersUri } from './uri'; - -function requireAuthed() { - const event = getRequestEvent(); - const env = event.platform?.env; - const client = event.locals.client; - const did = event.locals.did; - if (!env) error(500, 'platform env unavailable'); - if (!client || !did) error(401, 'Not authenticated'); - return { env, client: client as Client, did: did as string }; -} - -// --------------------------------------------------------------------------- -// createCommunity — mint + bootstrap members space + write server record. -// Returns { communityDid, recoveryKey }. The UI must show recoveryKey once. -// --------------------------------------------------------------------------- - -const CreateCommunityInput = v.object({ - name: v.pipe(v.string(), v.minLength(1), v.maxLength(128)), - description: v.optional(v.pipe(v.string(), v.maxLength(2000))), - handle: v.optional(v.pipe(v.string(), v.maxLength(256))), - /** Optional icon blob ref. The bytes must already have been uploaded to - * the community's members space via /api/blob-upload before this call — - * but the members space doesn't exist yet at mint time, so the upload - * flow is: createCommunity first, then separately upload+update. For - * convenience, this endpoint also accepts an icon that was uploaded - * *during* the same request — it stitches it in by reuploading into the - * members space after creation. */ - icon: v.optional( - v.object({ - /** Raw bytes as a base64 string — small enough to inline. */ - bytes: v.string(), - mimeType: v.string() - }) - ) -}); - -export const createCommunity = command(CreateCommunityInput, async (input) => { - const ctx = requireAuthed(); - - const mint = await mintCommunity(ctx, input.handle ? { handle: input.handle } : {}); - const communityDid = mint.communityDid; - - const space = await createSpace(ctx, { communityDid, key: 'members' }); - const membersUri = space.space.uri; - - // If an icon was provided, upload it to the members space first, then - // include its blob ref on the server record. - let iconRef: import('./server').BlobRef | undefined; - if (input.icon) { - const bytes = Uint8Array.from(atob(input.icon.bytes), (c) => c.charCodeAt(0)); - iconRef = await uploadBlob(ctx, { - spaceUri: membersUri, - mimeType: input.icon.mimeType, - bytes - }); - } - - await communityPutRecord(ctx, { - spaceUri: membersUri, - collection: 'tools.atmo.chat.server', - rkey: 'self', - record: { - communityDid, - name: input.name, - description: input.description, - ...(iconRef ? { icon: iconRef } : {}), - createdAt: new Date().toISOString() - } - }); - - return { - communityDid, - membersUri, - recoveryKey: mint.recoveryKey - }; -}); - -// --------------------------------------------------------------------------- -// createChannel — new channel space + optional members-delegation (public) or -// direct DID grants (private) + channel record. -// --------------------------------------------------------------------------- - -const CreateChannelInput = v.object({ - communityDid: v.pipe(v.string(), v.minLength(1)), - name: v.pipe(v.string(), v.minLength(1), v.maxLength(128)), - topic: v.optional(v.pipe(v.string(), v.maxLength(512))), - visibility: v.picklist(['public', 'private']), - /** For private channels: DIDs to grant direct member access. */ - memberDids: v.optional(v.array(v.pipe(v.string(), v.minLength(1)))) -}); - -export const createChannel = command(CreateChannelInput, async (input) => { - const ctx = requireAuthed(); - - const created = await createSpace(ctx, { communityDid: input.communityDid }); - const channelUri = created.space.uri; - const channelKey = created.space.key; - - if (input.visibility === 'public') { - await grantAccess(ctx, { - spaceUri: channelUri, - subject: { spaceUri: buildMembersUri(input.communityDid) }, - accessLevel: 'member' - }); - } else { - for (const did of input.memberDids ?? []) { - // Ensure the user is in `members` too so they get the server header. - // Ignore duplicates; grant is upsert. - await grantAccess(ctx, { - spaceUri: buildMembersUri(input.communityDid), - subject: { did }, - accessLevel: 'member' - }).catch(() => {}); - await grantAccess(ctx, { - spaceUri: channelUri, - subject: { did }, - accessLevel: 'member' - }); - } - } - - await communityPutRecord(ctx, { - spaceUri: channelUri, - collection: 'tools.atmo.chat.channel', - rkey: 'self', - record: { - communityDid: input.communityDid, - name: input.name, - topic: input.topic, - visibility: input.visibility, - createdAt: new Date().toISOString() - } - }); - - return { channelUri, channelKey }; -}); - -// --------------------------------------------------------------------------- -// postMessage — append a chat message to a channel. -// --------------------------------------------------------------------------- - -const PostMessageInput = v.object({ - spaceUri: v.pipe(v.string(), v.minLength(1)), - text: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)), - replyTo: v.optional(v.pipe(v.string(), v.minLength(1))), - /** Optional client-generated rkey — lets the browser match an optimistic - * record to the stream's `record.created` event by identity. */ - rkey: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))) -}); - -export const postMessage = command(PostMessageInput, async (input) => { - const ctx = requireAuthed(); - const res = await spacePutRecord(ctx, { - spaceUri: input.spaceUri, - collection: 'tools.atmo.chat.message', - ...(input.rkey ? { rkey: input.rkey } : {}), - record: { - text: input.text, - createdAt: new Date().toISOString(), - ...(input.replyTo ? { replyTo: input.replyTo } : {}) - } - }); - return res; -}); - -// --------------------------------------------------------------------------- -// mintWatchTicket — browser calls on reconnect to get a fresh watch-scoped -// ticket. The initial ticket is pre-minted in +page.server.ts. -// --------------------------------------------------------------------------- - -const MintWatchTicketInput = v.object({ - spaceUri: v.optional(v.pipe(v.string(), v.minLength(1))), - actor: v.optional(v.pipe(v.string(), v.minLength(1))), - watchRecordsNsid: v.pipe(v.string(), v.minLength(1)), - limit: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(200))) -}); - -export const mintWatchTicketCmd = command(MintWatchTicketInput, async (input) => { - const ctx = requireAuthed(); - return mintWatchTicket(ctx, input); -}); - -// --------------------------------------------------------------------------- -// grantMember / revokeMember / setMemberLevel — used in settings UI. -// --------------------------------------------------------------------------- - -const GrantMemberInput = v.object({ - spaceUri: v.pipe(v.string(), v.minLength(1)), - did: v.pipe(v.string(), v.minLength(1)), - accessLevel: v.picklist(['member', 'manager', 'admin', 'owner']) -}); - -export const grantMember = command(GrantMemberInput, async (input) => { - const ctx = requireAuthed(); - return grantAccess(ctx, { - spaceUri: input.spaceUri, - subject: { did: input.did }, - accessLevel: input.accessLevel - }); -}); - -const RevokeMemberInput = v.object({ - spaceUri: v.pipe(v.string(), v.minLength(1)), - did: v.pipe(v.string(), v.minLength(1)) -}); - -export const revokeMember = command(RevokeMemberInput, async (input) => { - const ctx = requireAuthed(); - return revokeAccess(ctx, { spaceUri: input.spaceUri, subject: { did: input.did } }); -}); - -export const setMemberLevel = command(GrantMemberInput, async (input) => { - const ctx = requireAuthed(); - return setAccessLevel(ctx, { - spaceUri: input.spaceUri, - subject: { did: input.did }, - accessLevel: input.accessLevel - }); -}); - diff --git a/apps/group-chat/src/lib/rooms/server.ts b/apps/group-chat/src/lib/rooms/server.ts deleted file mode 100644 index 0d42c98..0000000 --- a/apps/group-chat/src/lib/rooms/server.ts +++ /dev/null @@ -1,324 +0,0 @@ -/** Server-side helpers for calling contrail's authenticated XRPCs. - * - * All calls route through contrail in-process using the WeakMap-backed - * in-process auth marker (see `@atmo-dev/contrail/server` → `markInProcess`). - * No JWT minting, no PDS roundtrip — the principal is stamped onto the - * Request by `ctx.did` and the auth middleware reads it directly. */ - -import { markInProcess } from '@atmo-dev/contrail/server'; -import { dispatch } from '$lib/contrail'; - -type Env = App.Platform['env']; - -interface AuthedCallContext { - env: Env; - /** Caller's DID — the principal attributed to each request. */ - did: string; -} - -/** Authenticated fetch for page loaders. Returns the parsed JSON body, - * or throws with status + payload on non-2xx. */ -export async function authedFetch( - ctx: AuthedCallContext, - method: string, - opts: { query?: Record; body?: unknown } = {} -): Promise { - return callContrail(ctx, method, opts); -} - -async function callContrail( - ctx: AuthedCallContext, - method: string, - opts: { body?: unknown; query?: Record; httpMethod?: 'GET' | 'POST' } -): Promise { - const url = new URL(`http://localhost/xrpc/${method}`); - for (const [k, v] of Object.entries(opts.query ?? {})) { - url.searchParams.set(k, v); - } - const httpMethod = opts.httpMethod ?? (opts.body === undefined ? 'GET' : 'POST'); - const req = new Request(url, { - method: httpMethod, - headers: opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined - }); - markInProcess(req, ctx.did); - const res = await dispatch(req, ctx.env); - const text = await res.text(); - const json = text ? JSON.parse(text) : {}; - if (!res.ok) { - const err = new Error(`${method} failed (${res.status}): ${text}`); - (err as Error & { status: number; payload: unknown }).status = res.status; - (err as Error & { status: number; payload: unknown }).payload = json; - throw err; - } - return json as T; -} - -// --- community lifecycle ---------------------------------------------------- - -export async function mintCommunity( - ctx: AuthedCallContext, - input: { handle?: string } = {} -): Promise<{ communityDid: string; recoveryKey: unknown }> { - return callContrail(ctx, 'tools.atmo.chat.community.mint', { body: input }); -} - -// --- space (channel / role) lifecycle -------------------------------------- - -export async function createSpace( - ctx: AuthedCallContext, - input: { communityDid: string; key?: string } -): Promise<{ space: { uri: string; key: string; ownerDid: string } }> { - return callContrail(ctx, 'tools.atmo.chat.community.space.create', { body: input }); -} - -export async function grantAccess( - ctx: AuthedCallContext, - input: { - spaceUri: string; - subject: { did?: string; spaceUri?: string }; - accessLevel: 'member' | 'manager' | 'admin' | 'owner'; - } -): Promise<{ ok: true }> { - return callContrail(ctx, 'tools.atmo.chat.community.space.grant', { body: input }); -} - -export async function revokeAccess( - ctx: AuthedCallContext, - input: { spaceUri: string; subject: { did?: string; spaceUri?: string } } -): Promise<{ ok: true }> { - return callContrail(ctx, 'tools.atmo.chat.community.space.revoke', { body: input }); -} - -export async function setAccessLevel( - ctx: AuthedCallContext, - input: { - spaceUri: string; - subject: { did?: string; spaceUri?: string }; - accessLevel: 'member' | 'manager' | 'admin' | 'owner'; - } -): Promise<{ ok: true }> { - return callContrail(ctx, 'tools.atmo.chat.community.space.setAccessLevel', { body: input }); -} - -// --- community records (authored by community DID) ------------------------- - -export async function communityPutRecord( - ctx: AuthedCallContext, - input: { - spaceUri: string; - collection: string; - rkey?: string; - record: Record; - } -): Promise<{ rkey: string; authorDid: string; createdAt: number }> { - return callContrail(ctx, 'tools.atmo.chat.community.space.putRecord', { body: input }); -} - -// --- user records in a space ----------------------------------------------- - -export async function spacePutRecord( - ctx: AuthedCallContext, - input: { - spaceUri: string; - collection: string; - rkey?: string; - record: Record; - } -): Promise<{ rkey: string; authorDid: string; createdAt: number }> { - return callContrail(ctx, 'tools.atmo.chat.space.putRecord', { body: input }); -} - -export async function spaceListRecords( - ctx: AuthedCallContext, - query: { spaceUri: string; collection: string; limit?: number; cursor?: string } -): Promise<{ - records: Array<{ - spaceUri: string; - collection: string; - authorDid: string; - rkey: string; - cid: string | null; - record: Record; - createdAt: number; - }>; - cursor?: string; -}> { - const q: Record = { - spaceUri: query.spaceUri, - collection: query.collection - }; - if (query.limit) q.limit = String(query.limit); - if (query.cursor) q.cursor = query.cursor; - return callContrail(ctx, 'tools.atmo.chat.space.listRecords', { query: q }); -} - -// --- members --------------------------------------------------------------- - -export async function listSpaceMembers( - ctx: AuthedCallContext, - query: { spaceUri: string; flatten?: boolean } -): Promise< - | { members: Array<{ did: string; addedAt: number }> } - | { - rows: Array<{ - subject: { did?: string; spaceUri?: string }; - accessLevel: string; - grantedBy: string; - grantedAt: number; - }>; - } -> { - const q: Record = { spaceUri: query.spaceUri }; - if (query.flatten) q.flatten = 'true'; - return callContrail(ctx, 'tools.atmo.chat.community.space.listMembers', { query: q }); -} - -export async function whoami( - ctx: AuthedCallContext, - query: { spaceUri: string } -): Promise<{ spaceUri: string; accessLevel: string | null }> { - return callContrail(ctx, 'tools.atmo.chat.spaceExt.whoami', { query }); -} - -// --- realtime -------------------------------------------------------------- - -export async function getRealtimeTicket( - ctx: AuthedCallContext, - input: { topic: string } -): Promise<{ ticket: string; topics: string[]; expiresAt: number }> { - return callContrail(ctx, 'tools.atmo.chat.realtime.ticket', { body: input }); -} - -/** Mint a watch-scoped ticket for a `.watchRecords` stream by - * running the `mode=ws` handshake in-process. The response's `ticket` carries - * the (did, topic, querySpec) binding that watchRecords verifies on SSE or - * WS connect, so the browser can attach it as `?ticket=...` with no other - * auth. The snapshot body is discarded — the sync engine will run its own - * snapshot on the ticketed connection. */ -export async function mintWatchTicket( - ctx: AuthedCallContext, - input: { - watchRecordsNsid: string; - /** Per-space watch. Mutually exclusive with `actor`. */ - spaceUri?: string; - /** Cross-space watch — actor must currently be a community DID. */ - actor?: string; - limit?: number; - } -): Promise<{ ticket: string; expiresAt: number }> { - if (!input.spaceUri && !input.actor) { - throw new Error('mintWatchTicket: spaceUri or actor required'); - } - const url = new URL(`http://localhost/xrpc/${input.watchRecordsNsid}`); - if (input.spaceUri) url.searchParams.set('spaceUri', input.spaceUri); - if (input.actor) url.searchParams.set('actor', input.actor); - url.searchParams.set('mode', 'ws'); - if (input.limit) url.searchParams.set('limit', String(input.limit)); - const req = new Request(url, { headers: { accept: 'application/json' } }); - markInProcess(req, ctx.did); - const res = await dispatch(req, ctx.env); - const text = await res.text(); - if (!res.ok) throw new Error(`mintWatchTicket failed (${res.status}): ${text}`); - const data = JSON.parse(text) as { ticket?: string; ticketTtlMs?: number }; - if (!data.ticket) throw new Error('mintWatchTicket: handshake did not return a ticket'); - return { - ticket: data.ticket, - expiresAt: Date.now() + (data.ticketTtlMs ?? 120_000) - }; -} - -// --- invites --------------------------------------------------------------- - -export interface InviteView { - tokenHash: string; - spaceUri: string; - accessLevel: 'member' | 'manager' | 'admin' | 'owner'; - createdBy: string; - createdAt: number; - expiresAt: number | null; - maxUses: number | null; - usedCount: number; - revokedAt: number | null; - note: string | null; -} - -export async function createCommunityInvite( - ctx: AuthedCallContext, - input: { - spaceUri: string; - accessLevel: 'member' | 'manager' | 'admin' | 'owner'; - expiresAt?: number; - maxUses?: number; - note?: string; - } -): Promise<{ - token: string; - tokenHash: string; - spaceUri: string; - accessLevel: string; - expiresAt: number | null; - maxUses: number | null; - createdAt: number; -}> { - return callContrail(ctx, 'tools.atmo.chat.invite.create', { body: input }); -} - -export async function listCommunityInvites( - ctx: AuthedCallContext, - query: { spaceUri: string; includeRevoked?: boolean } -): Promise<{ invites: InviteView[] }> { - const q: Record = { spaceUri: query.spaceUri }; - if (query.includeRevoked) q.includeRevoked = 'true'; - return callContrail(ctx, 'tools.atmo.chat.invite.list', { query: q }); -} - -export async function revokeCommunityInvite( - ctx: AuthedCallContext, - input: { tokenHash: string } -): Promise<{ ok: true }> { - return callContrail(ctx, 'tools.atmo.chat.invite.revoke', { body: input }); -} - -export async function redeemCommunityInvite( - ctx: AuthedCallContext, - input: { token: string } -): Promise<{ spaceUri: string; accessLevel: string; communityDid: string }> { - return callContrail(ctx, 'tools.atmo.chat.invite.redeem', { body: input }); -} - -// --- blobs ----------------------------------------------------------------- - -export interface BlobRef { - $type: 'blob'; - ref: { $link: string }; - mimeType: string; - size: number; -} - -/** Upload raw bytes to a space's blob store. Unlike the other helpers this one - * uses a binary body (the raw bytes) rather than JSON, so we build the - * Request ourselves. Returns the blob ref for use in a record field. */ -export async function uploadBlob( - ctx: AuthedCallContext, - input: { spaceUri: string; mimeType: string; bytes: Uint8Array } -): Promise { - const method = 'tools.atmo.chat.space.uploadBlob'; - const url = new URL(`http://localhost/xrpc/${method}`); - url.searchParams.set('spaceUri', input.spaceUri); - - const req = new Request(url, { - method: 'POST', - headers: { - 'Content-Type': input.mimeType, - 'Content-Length': String(input.bytes.byteLength) - }, - body: new Blob([new Uint8Array(input.bytes)], { type: input.mimeType }) - }); - markInProcess(req, ctx.did); - const res = await dispatch(req, ctx.env); - const text = await res.text(); - const json = text ? JSON.parse(text) : {}; - if (!res.ok) throw new Error(`${method} failed (${res.status}): ${text}`); - return (json as { blob: BlobRef }).blob; -} diff --git a/apps/group-chat/src/lib/rooms/unread.svelte.ts b/apps/group-chat/src/lib/rooms/unread.svelte.ts deleted file mode 100644 index 2370b04..0000000 --- a/apps/group-chat/src/lib/rooms/unread.svelte.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** Per-space unread tracker. - * - `counts[spaceUri]` is the number of record.created events received since - * the space was last opened. - * - `lastRead[spaceUri]` is the iso timestamp of the last "open" event, - * persisted to localStorage so cold loads can compare against existing - * messages' createdAt to derive initial dot state. - * - * Reset counts call on channel open via `markLastRead()` / `resetUnread()`. */ - -import { untrack } from 'svelte'; - -const LS_KEY = 'rooms:lastRead:v1'; - -function loadLastRead(): Record { - if (typeof localStorage === 'undefined') return {}; - try { - const raw = localStorage.getItem(LS_KEY); - if (!raw) return {}; - const parsed = JSON.parse(raw); - return typeof parsed === 'object' && parsed ? parsed : {}; - } catch { - return {}; - } -} - -function persistLastRead(v: Record) { - if (typeof localStorage === 'undefined') return; - try { - localStorage.setItem(LS_KEY, JSON.stringify(v)); - } catch { - // ignore quota errors - } -} - -export const unread = $state<{ - counts: Record; - lastRead: Record; -}>({ - counts: {}, - lastRead: loadLastRead() -}); - -/** Called when a record.created event for a space arrives while the space is - * not the active one. Increments the unread counter. - * - * Whole body is wrapped in `untrack` so that a caller running inside a - * `$effect` does NOT subscribe to any of the `unread.*` state we read here. - * Writes still notify real dependents — we just stop the current effect from - * subscribing to state it's about to mutate and re-reading it afterwards. */ -export function bumpUnread(spaceUri: string, createdAt?: string) { - untrack(() => { - const last = unread.lastRead[spaceUri]; - if (createdAt && last && createdAt <= last) return; - unread.counts[spaceUri] = (unread.counts[spaceUri] ?? 0) + 1; - }); -} - -/** Called when a channel view mounts or receives new messages while focused. */ -export function markLastRead(spaceUri: string) { - untrack(() => { - const next = { ...unread.lastRead, [spaceUri]: new Date().toISOString() }; - unread.counts[spaceUri] = 0; - unread.lastRead = next; - persistLastRead(next); - }); -} - -export function resetUnread(spaceUri: string) { - untrack(() => { - if (unread.counts[spaceUri]) unread.counts[spaceUri] = 0; - }); -} diff --git a/apps/group-chat/src/lib/rooms/uri.ts b/apps/group-chat/src/lib/rooms/uri.ts deleted file mode 100644 index 46fc0a4..0000000 --- a/apps/group-chat/src/lib/rooms/uri.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** Helpers for assembling space URIs in the `tools.atmo.chat` namespace. - * - * The URIs are returned as `ResourceUri` (branded) so they flow into - * lexicon-typed XRPC params without per-call casts. We apply the brand - * once here — structurally these are just `ats://…` strings. */ - -import type { ResourceUri } from '@atcute/lexicons'; - -const SPACE_TYPE = 'tools.atmo.chat.space'; - -export function buildSpaceUri(communityDid: string, key: string): ResourceUri { - return `ats://${communityDid}/${SPACE_TYPE}/${key}` as ResourceUri; -} - -export function buildMembersUri(communityDid: string): ResourceUri { - return buildSpaceUri(communityDid, 'members'); -} - -export function buildAdminUri(communityDid: string): ResourceUri { - return buildSpaceUri(communityDid, '$admin'); -} - -const SPACE_URI_RE = new RegExp(`^ats://([^/]+)/${SPACE_TYPE.replace(/\./g, '\\.')}/([^/]+)$`); - -export function parseSpaceUri(uri: string): { communityDid: string; key: string } | null { - const m = SPACE_URI_RE.exec(uri); - if (!m) return null; - return { communityDid: m[1]!, key: m[2]! }; -} diff --git a/apps/group-chat/src/lib/rooms/watch.svelte.ts b/apps/group-chat/src/lib/rooms/watch.svelte.ts deleted file mode 100644 index 33f9cf2..0000000 --- a/apps/group-chat/src/lib/rooms/watch.svelte.ts +++ /dev/null @@ -1,289 +0,0 @@ -/** Svelte 5 binding over `@atmo-dev/contrail-sync`. - * - * Usage: - * const messagesQuery = $derived( - * createWatchQuery({ - * endpoint: 'tools.atmo.chat.message', - * params: { spaceUri, limit: 50 } - * }) - * ); - * // template: {#each messagesQuery.records as r (r.rkey)} {r.value.text} - * - * Records and params are typed via the app's generated `XRPCQueries` / - * `Records` ambient declarations (from `src/lexicon-types/`). Reading - * `.records` auto-subscribes; when no component is reading anymore - * (e.g. a $derived query instance is replaced on prop change), the - * underlying stream is torn down via `createSubscriber`. - * - * Authentication: tickets are minted on demand. By default the wrapper - * calls the `mintWatchTicketCmd` remote function; override with - * `setContrailTicketMinter(fn)` if your app uses a different route. - */ - -import { createSubscriber } from 'svelte/reactivity'; -import { browser } from '$app/environment'; -import { - createWatchStore, - type WatchCache, - type WatchRecord, - type WatchStore, - type WatchStoreStatus -} from '@atmo-dev/contrail-sync'; -import { createIndexedDBCache } from '@atmo-dev/contrail-sync/cache-idb'; -import type { InferInput } from '@atcute/lexicons'; -import type { BaseSchema } from '@atcute/lexicons/validations'; -import type { Records, XRPCQueries } from '@atcute/lexicons/ambient'; -import { dev } from '$app/environment'; -import { mintWatchTicketCmd } from './rooms.remote'; - -// --------------------------------------------------------------------------- -// Type machinery — derives params + record types from the app's lexicons. -// --------------------------------------------------------------------------- - -type WatchRecordsOf = `${K}.watchRecords`; - -/** Params type for `.watchRecords`, from ambient XRPCQueries. */ -type WatchParamsOf = - WatchRecordsOf extends keyof XRPCQueries - ? XRPCQueries[WatchRecordsOf] extends { params: infer P } - ? P extends BaseSchema - ? InferInput

- : Record - : Record - : Record; - -/** Record value-shape for a collection, from ambient Records. */ -export type RecordShapeOf = K extends keyof Records - ? Records[K] extends BaseSchema - ? InferInput - : Record - : Record; - -/** WatchRecord with a typed `value` payload. Mirrors WatchRecord's explicit - * fields but without the `[k: string]: unknown` catchall (which would - * poison property access under `Omit`). */ -export interface TypedWatchRecord { - uri: string; - did: string; - rkey: string; - collection: string; - value: R; - time_us?: number; - indexed_at?: number; - cid?: string | null; - space?: string; - /** Present on optimistic entries added via `addOptimistic`. */ - optimistic?: 'pending' | 'failed'; - /** Error attached via `markFailed`. */ - optimisticError?: Error; -} - -// --------------------------------------------------------------------------- -// Configurable ticket minter — called when the wrapper needs a fresh ticket. -// Default: uses `mintWatchTicketCmd`. Override via `setContrailTicketMinter`. -// --------------------------------------------------------------------------- - -export interface TicketMintContext { - endpoint: string; - params: Record; -} - -export type TicketMinter = (ctx: TicketMintContext) => Promise; - -let ticketMinter: TicketMinter | null = async ({ endpoint, params }) => { - const spaceUri = params.spaceUri ? String(params.spaceUri) : undefined; - const actor = params.actor ? String(params.actor) : undefined; - const res = await mintWatchTicketCmd({ - watchRecordsNsid: `${endpoint}.watchRecords`, - ...(spaceUri ? { spaceUri } : {}), - ...(actor ? { actor } : {}), - limit: typeof params.limit === 'number' ? params.limit : 50 - }); - return res.ticket; -}; - -export function setContrailTicketMinter(fn: TicketMinter | null): void { - ticketMinter = fn; -} - -// --------------------------------------------------------------------------- -// Configurable cache — persists last-seen records for instant first paint. -// Default: IndexedDB in the browser, no-op during SSR. Override via -// `setContrailCache(cache)` or opt out with `setContrailCache(null)`. -// --------------------------------------------------------------------------- - -let cache: WatchCache | null = browser ? createIndexedDBCache() : null; - -export function setContrailCache(next: WatchCache | null): void { - cache = next; -} - -// --------------------------------------------------------------------------- -// createWatchQuery -// --------------------------------------------------------------------------- - -export interface WatchQueryOptions { - /** Collection NSID. The library appends `.watchRecords` to build the URL. */ - endpoint: K; - /** Query params for the watchRecords endpoint. Typed per-endpoint. */ - params?: WatchParamsOf; - /** Optional pre-minted ticket (typically from SSR `+page.server.ts`). - * Used once on the first connect; reconnects fall through to the - * configured ticket minter. */ - initialTicket?: string; - /** Per-query override for ticket minting. If omitted, uses the minter - * configured via `setContrailTicketMinter` (or the default). */ - mintTicket?: string | (() => Promise); - /** Transport. Default: 'ws' in prod, 'sse' in dev (Vite dev's DO wiring - * is limited — see `todo/realtime-do-dev-wiring.md`). */ - transport?: 'sse' | 'ws'; - /** Sort comparator. Default: newest first by `time_us`. */ - compare?: ( - a: TypedWatchRecord>, - b: TypedWatchRecord> - ) => number; - /** Auto-reconnect on error. Default: true. */ - reconnect?: boolean; - /** Per-query cache override. If omitted, uses the wrapper's configured - * cache (see `setContrailCache`). Pass `null` to disable caching for - * this query specifically. */ - cache?: WatchCache | null; - /** Custom cache key. Defaults to the built watchRecords URL. */ - cacheKey?: string; - /** Max records retained in cache. Default: 200. */ - cacheMaxRecords?: number; -} - -export class WatchQuery { - #records = $state>[]>([]); - #status = $state('idle'); - #error = $state(null); - #subscribe: () => void; - #store: WatchStore; - #collection: K; - - constructor(opts: WatchQueryOptions) { - const endpoint = opts.endpoint; - const params = (opts.params ?? {}) as Record; - const url = buildWatchUrl(endpoint, params); - this.#collection = endpoint; - - // Ticket source resolution: initialTicket (one-shot) → per-call mintTicket → - // module-level default. Undefined if none available (public endpoints). - let pendingInitial = opts.initialTicket; - const hasSource = !!(opts.initialTicket || opts.mintTicket || ticketMinter); - - const mintTicket: string | (() => Promise) | undefined = hasSource - ? async () => { - if (pendingInitial) { - const t = pendingInitial; - pendingInitial = undefined; - return t; - } - if (typeof opts.mintTicket === 'string') return opts.mintTicket; - if (typeof opts.mintTicket === 'function') return opts.mintTicket(); - if (ticketMinter) return ticketMinter({ endpoint, params }); - throw new Error('createWatchQuery: no ticket source available'); - } - : undefined; - - // Store is created eagerly so optimistic mutations (add/markFailed/remove) - // can target it from non-reactive contexts (e.g. a submit handler) even - // before any template reader has activated the subscriber. The actual - // network connection only opens when `createSubscriber` fires `start()` - // on first read, and closes when no readers remain. - // Cache: explicit override (including `null`) wins over the module-level - // default. `opts.cache === undefined` means "use whatever's configured." - const effectiveCache = opts.cache === undefined ? cache : opts.cache; - - this.#store = createWatchStore({ - url, - transport: opts.transport ?? (dev ? 'sse' : 'ws'), - reconnect: opts.reconnect, - mintTicket, - compareRecords: opts.compare as - | ((a: WatchRecord, b: WatchRecord) => number) - | undefined, - cache: effectiveCache ?? undefined, - cacheKey: opts.cacheKey, - cacheMaxRecords: opts.cacheMaxRecords - }); - - this.#subscribe = createSubscriber((update) => { - const unsub = this.#store.subscribe((s) => { - this.#records = s.records as unknown as readonly TypedWatchRecord>[]; - this.#status = s.status; - this.#error = s.error; - update(); - }); - this.#store.start(); - return () => { - unsub(); - this.#store.stop(); - }; - }); - } - - get records(): readonly TypedWatchRecord>[] { - this.#subscribe(); - return this.#records; - } - get status(): WatchStoreStatus { - this.#subscribe(); - return this.#status; - } - get error(): Error | null { - this.#subscribe(); - return this.#error; - } - - /** Insert an optimistic record into the query. It shows in `.records` - * immediately with `optimistic: 'pending'` and is auto-dropped when a - * server-confirmed record with the same rkey arrives via the stream. */ - addOptimistic(input: { - rkey: string; - did: string; - value: RecordShapeOf; - time_us?: number; - }): void { - this.#store.addOptimistic({ - rkey: input.rkey, - did: input.did, - collection: this.#collection, - value: input.value as Record, - time_us: input.time_us - }); - } - - /** Flip an optimistic entry's state to `'failed'` and attach an error. */ - markFailed(rkey: string, err: Error): void { - this.#store.markFailed(rkey, err); - } - - /** Remove an optimistic entry (explicit rollback). */ - removeOptimistic(rkey: string): void { - this.#store.removeOptimistic(rkey); - } -} - -export function createWatchQuery( - opts: WatchQueryOptions -): WatchQuery { - return new WatchQuery(opts); -} - -// --------------------------------------------------------------------------- - -function buildWatchUrl(endpoint: string, params: Record): string { - const qs = new URLSearchParams(); - for (const [k, v] of Object.entries(params)) { - if (v == null) continue; - if (Array.isArray(v)) { - for (const item of v) qs.append(k, String(item)); - } else { - qs.set(k, String(v)); - } - } - const query = qs.toString(); - return `/xrpc/${endpoint}.watchRecords${query ? `?${query}` : ''}`; -} diff --git a/apps/group-chat/src/routes/(oauth)/oauth-client-metadata.json/+server.ts b/apps/group-chat/src/routes/(oauth)/oauth-client-metadata.json/+server.ts deleted file mode 100644 index 44794e4..0000000 --- a/apps/group-chat/src/routes/(oauth)/oauth-client-metadata.json/+server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { createOAuthClient } from '$lib/atproto/server/oauth'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ platform }) => { - const oauth = createOAuthClient(platform?.env); - return json(oauth.metadata); -}; diff --git a/apps/group-chat/src/routes/(oauth)/oauth/callback/+server.ts b/apps/group-chat/src/routes/(oauth)/oauth/callback/+server.ts deleted file mode 100644 index f6a71e8..0000000 --- a/apps/group-chat/src/routes/(oauth)/oauth/callback/+server.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import { createOAuthClient } from '$lib/atproto/server/oauth'; -import { setSignedCookie } from '$lib/atproto/server/signed-cookie'; -import { scopes } from '$lib/atproto/settings'; -import { dev } from '$app/environment'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ url, platform, cookies }) => { - const oauth = createOAuthClient(platform?.env); - - // oauth.callback() validates the state parameter (CSRF protection) and - // exchanges the authorization code for tokens via the token endpoint. - try { - const { session } = await oauth.callback(url.searchParams); - - const cookieOpts = { - path: '/', - httpOnly: true, - secure: !dev, - sameSite: 'lax' as const, - maxAge: 60 * 60 * 24 * 180 // 180 days - }; - - setSignedCookie(cookies, 'did', session.did, cookieOpts); - setSignedCookie(cookies, 'scope', scopes.join(' '), cookieOpts); - } catch (e) { - console.error('OAuth callback failed:', e); - redirect(303, '/?error=auth_failed'); - } - - const returnTo = cookies.get('oauth_return_to'); - if (returnTo) { - cookies.delete('oauth_return_to', { path: '/' }); - const decoded = decodeURIComponent(returnTo); - if (decoded.startsWith('/') && !decoded.startsWith('//')) { - redirect(303, decoded); - } - } - - redirect(303, '/'); -}; diff --git a/apps/group-chat/src/routes/(oauth)/oauth/jwks.json/+server.ts b/apps/group-chat/src/routes/(oauth)/oauth/jwks.json/+server.ts deleted file mode 100644 index ae4dd84..0000000 --- a/apps/group-chat/src/routes/(oauth)/oauth/jwks.json/+server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { createOAuthClient } from '$lib/atproto/server/oauth'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ platform }) => { - const oauth = createOAuthClient(platform?.env); - return json(oauth.jwks ?? { keys: [] }); -}; diff --git a/apps/group-chat/src/routes/+layout.server.ts b/apps/group-chat/src/routes/+layout.server.ts deleted file mode 100644 index 5e32dc9..0000000 --- a/apps/group-chat/src/routes/+layout.server.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { LayoutServerLoad } from './$types'; -import { dispatch } from '$lib/contrail'; - -interface Profile { - handle?: string; - displayName?: string; - avatar?: string; -} - -export const load: LayoutServerLoad = async ({ locals, platform }) => { - if (!locals.did || !locals.client) { - return { did: null, profile: null }; - } - - try { - // tools.atmo.chat.getProfile is unauthenticated. - const req = new Request( - `http://localhost/xrpc/tools.atmo.chat.getProfile?actor=${encodeURIComponent(locals.did)}` - ); - const res = await dispatch(req, platform!.env); - if (!res.ok) return { did: locals.did, profile: null }; - const data = (await res.json()) as { - profiles?: Array<{ - did: string; - handle?: string | null; - value?: { displayName?: string; avatar?: string }; - }>; - }; - const entry = data.profiles?.[0]; - const profile: Profile = { - handle: entry?.handle ?? undefined, - displayName: entry?.value?.displayName, - avatar: entry?.value?.avatar - }; - return { did: locals.did, profile }; - } catch { - return { did: locals.did, profile: null }; - } -}; diff --git a/apps/group-chat/src/routes/+layout.svelte b/apps/group-chat/src/routes/+layout.svelte deleted file mode 100644 index 4b5779d..0000000 --- a/apps/group-chat/src/routes/+layout.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - -{@render children()} - - - - - - diff --git a/apps/group-chat/src/routes/+layout.ts b/apps/group-chat/src/routes/+layout.ts deleted file mode 100644 index 77ab0a0..0000000 --- a/apps/group-chat/src/routes/+layout.ts +++ /dev/null @@ -1 +0,0 @@ -export const ssr = true; diff --git a/apps/group-chat/src/routes/+page.server.ts b/apps/group-chat/src/routes/+page.server.ts deleted file mode 100644 index 1ddb58c..0000000 --- a/apps/group-chat/src/routes/+page.server.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { PageServerLoad } from './$types'; -import type { Client } from '@atcute/client'; -import { authedFetch } from '$lib/rooms/server'; -import { parseSpaceUri } from '$lib/rooms/uri'; - -interface ServerEntry { - communityDid: string; - name: string; - description?: string; - iconUrl?: string; - createdAt: string; - membersUri: string; -} - -export const load: PageServerLoad = async ({ locals, platform }) => { - if (!locals.did || !locals.client) { - return { loggedIn: false, servers: [] as ServerEntry[] }; - } - - const ctx = { - env: platform!.env, - client: locals.client as Client, - did: locals.did as string - }; - - try { - const data = await authedFetch<{ - records: Array<{ - did: string; - rkey: string; - value: { - communityDid?: string; - name?: string; - description?: string; - createdAt?: string; - icon?: { ref?: { $link?: string } }; - }; - space?: string; - }>; - }>(ctx, 'tools.atmo.chat.server.listRecords', { query: { limit: '50' } }); - - const servers: ServerEntry[] = []; - for (const r of data.records) { - if (!r.space) continue; - const parsed = parseSpaceUri(r.space); - if (!parsed) continue; - if (r.did !== parsed.communityDid) continue; - if (parsed.key !== 'members') continue; - if (r.rkey !== 'self') continue; - const rec = r.value; - if (!rec?.communityDid || !rec.name || !rec.createdAt) continue; - const iconCid = rec.icon?.ref?.$link; - servers.push({ - communityDid: rec.communityDid, - name: rec.name, - description: rec.description, - iconUrl: iconCid - ? `/api/blob?spaceUri=${encodeURIComponent(r.space)}&cid=${encodeURIComponent(iconCid)}` - : undefined, - createdAt: rec.createdAt, - membersUri: r.space - }); - } - servers.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)); - - return { loggedIn: true, servers }; - } catch { - return { loggedIn: true, servers: [] as ServerEntry[] }; - } -}; diff --git a/apps/group-chat/src/routes/+page.svelte b/apps/group-chat/src/routes/+page.svelte deleted file mode 100644 index 0e30c4e..0000000 --- a/apps/group-chat/src/routes/+page.svelte +++ /dev/null @@ -1,76 +0,0 @@ - - -

-
- group chat - {#if user.isLoggedIn} -
- {user.profile?.handle ?? user.did} - -
- {:else} - - {/if} -
- - {#if !user.isLoggedIn} -

- Sign in with your atproto account to create a server or join one you've been invited to. -

- {:else} -
-

Your servers

- -
- - {#if data.servers.length === 0} -
- No servers yet. Create one to get started. -
- {:else} - - {/if} - {/if} -
diff --git a/apps/group-chat/src/routes/api/blob-upload/+server.ts b/apps/group-chat/src/routes/api/blob-upload/+server.ts deleted file mode 100644 index de5b9ee..0000000 --- a/apps/group-chat/src/routes/api/blob-upload/+server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { error, json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { uploadBlob } from '$lib/rooms/server'; - -/** POST raw bytes (body = bytes) with ?spaceUri=&mimeType=. - * Returns the atproto blob ref. - * Plain +server.ts so we can pass binary without remote-function JSON schema, - * and so there's no auto-revalidation. */ -export const POST: RequestHandler = async ({ request, url, locals, platform }) => { - if (!locals.did) error(401, 'Not authenticated'); - const spaceUri = url.searchParams.get('spaceUri'); - const mimeType = url.searchParams.get('mimeType') ?? request.headers.get('content-type'); - if (!spaceUri) error(400, 'spaceUri required'); - if (!mimeType) error(400, 'mimeType required'); - - const bytes = new Uint8Array(await request.arrayBuffer()); - const blob = await uploadBlob( - { env: platform!.env, did: locals.did as string }, - { spaceUri, mimeType, bytes } - ); - return json(blob); -}; diff --git a/apps/group-chat/src/routes/api/blob/+server.ts b/apps/group-chat/src/routes/api/blob/+server.ts deleted file mode 100644 index 387f29f..0000000 --- a/apps/group-chat/src/routes/api/blob/+server.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { markInProcess } from '@atmo-dev/contrail/server'; -import { dispatch } from '$lib/contrail'; - -/** GET ?spaceUri=&cid= → blob bytes. - * Proxies through contrail's space.getBlob endpoint, attributing the call - * to the session's DID via the in-process auth marker. Browsers can't - * attach headers to ``, which is why this proxy exists. */ -export const GET: RequestHandler = async ({ url, locals, platform }) => { - if (!locals.did) error(401, 'Not authenticated'); - const spaceUri = url.searchParams.get('spaceUri'); - const cid = url.searchParams.get('cid'); - if (!spaceUri || !cid) error(400, 'spaceUri and cid required'); - - const target = new URL('http://localhost/xrpc/tools.atmo.chat.space.getBlob'); - target.searchParams.set('spaceUri', spaceUri); - target.searchParams.set('cid', cid); - - const req = new Request(target); - markInProcess(req, locals.did); - const proxied = await dispatch(req, platform!.env); - return new Response(proxied.body, { - status: proxied.status, - headers: { - 'content-type': proxied.headers.get('content-type') ?? 'application/octet-stream', - 'cache-control': 'private, max-age=3600' - } - }); -}; diff --git a/apps/group-chat/src/routes/api/cron/+server.ts b/apps/group-chat/src/routes/api/cron/+server.ts deleted file mode 100644 index 22740e3..0000000 --- a/apps/group-chat/src/routes/api/cron/+server.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getContrail } from '$lib/contrail'; -import type { RequestHandler } from './$types'; - -export const POST: RequestHandler = async ({ request, platform }) => { - const secret = request.headers.get('X-Cron-Secret'); - if (secret !== platform!.env.CRON_SECRET) { - return new Response('Unauthorized', { status: 401 }); - } - - const contrail = await getContrail(platform!.env); - await contrail.ingest({}, platform!.env.DB); - - return new Response('OK'); -}; diff --git a/apps/group-chat/src/routes/api/ticket/+server.ts b/apps/group-chat/src/routes/api/ticket/+server.ts deleted file mode 100644 index f28d926..0000000 --- a/apps/group-chat/src/routes/api/ticket/+server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { error, json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getRealtimeTicket } from '$lib/rooms/server'; - -/** POST { topic } → realtime ticket. - * Kept as a plain +server.ts endpoint rather than a remote command so that - * calling it doesn't trigger SvelteKit's auto-revalidation (which would - * cycle with the layout effect that opens the EventSource). */ -export const POST: RequestHandler = async ({ request, locals, platform }) => { - if (!locals.did) error(401, 'Not authenticated'); - const body = (await request.json().catch(() => null)) as { topic?: string } | null; - if (!body?.topic) error(400, 'topic required'); - - const res = await getRealtimeTicket( - { env: platform!.env, did: locals.did as string }, - { topic: body.topic } - ); - return json(res); -}; diff --git a/apps/group-chat/src/routes/c/[communityDid]/+layout.server.ts b/apps/group-chat/src/routes/c/[communityDid]/+layout.server.ts deleted file mode 100644 index 50435da..0000000 --- a/apps/group-chat/src/routes/c/[communityDid]/+layout.server.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { LayoutServerLoad } from './$types'; -import { redirect } from '@sveltejs/kit'; -import { authedFetch } from '$lib/rooms/server'; -import { buildAdminUri } from '$lib/rooms/uri'; - -interface ServerMeta { - communityDid: string; - name: string; - description?: string; - iconUrl?: string; - membersUri: string; -} - -export const load: LayoutServerLoad = async ({ locals, params, platform }) => { - if (!locals.did) { - throw redirect(302, '/'); - } - const communityDid = decodeURIComponent(params.communityDid); - const ctx = { env: platform!.env, did: locals.did as string }; - - // --- fetch server record ------------------------------------------------- - let server: ServerMeta | null = null; - try { - const data = await authedFetch<{ - records: Array<{ - did: string; - rkey: string; - value: { - communityDid?: string; - name?: string; - description?: string; - icon?: { ref?: { $link?: string } }; - }; - space?: string; - }>; - }>(ctx, 'tools.atmo.chat.server.listRecords', { - query: { actor: communityDid, limit: '1' } - }); - const row = data.records.find( - (r) => - r.space && - r.did === communityDid && - r.rkey === 'self' && - r.value?.communityDid === communityDid - ); - if (row?.value?.name) { - const iconCid = row.value.icon?.ref?.$link; - server = { - communityDid, - name: row.value.name, - description: row.value.description, - iconUrl: iconCid - ? `/api/blob?spaceUri=${encodeURIComponent(row.space!)}&cid=${encodeURIComponent(iconCid)}` - : undefined, - membersUri: row.space! - }; - } - } catch { - // fallthrough — server stays null, UI shows "server" fallback - } - - // Channels are no longer fetched here — the layout now derives them from - // a live `createWatchQuery` against `tools.atmo.chat.channel` scoped by - // actor=. New/renamed/deleted channels reflect instantly - // without an `invalidateAll()` → server-loader roundtrip. - - // --- caller's access level on $admin ----------------------------------- - let isAdmin = false; - try { - const adminUri = buildAdminUri(communityDid); - const d = await authedFetch<{ accessLevel: string | null }>( - ctx, - 'tools.atmo.chat.spaceExt.whoami', - { query: { spaceUri: adminUri } } - ); - isAdmin = d.accessLevel === 'admin' || d.accessLevel === 'owner'; - } catch { - // stay false - } - - return { communityDid, server, isAdmin, myDid: locals.did }; -}; diff --git a/apps/group-chat/src/routes/c/[communityDid]/+layout.svelte b/apps/group-chat/src/routes/c/[communityDid]/+layout.svelte deleted file mode 100644 index 9241d9f..0000000 --- a/apps/group-chat/src/routes/c/[communityDid]/+layout.svelte +++ /dev/null @@ -1,341 +0,0 @@ - - - -
- - - {#if currentChannel} -
- {currentChannel.visibility === 'private' ? '🔒' : '#'} - {currentChannel.name} - {#if currentChannel.topic} - - {/if} -
- {:else} - {data.server?.name ?? 'server'} - {/if} -
- - {@const ind = connectionIndicator(connection.status)} - - - - -
- - -
-
- - ← all servers - -
- {#if data.server?.iconUrl} - - {/if} -
{data.server?.name ?? 'server'}
-
- {#if data.server?.description} -
{data.server.description}
- {/if} -
- - - -
- {#if data.isAdmin} - - Members - - - Invites - - {/if} -
- {user.profile?.handle ?? user.did} -
-
-
-
- -
- {@render children?.()} -
- - -
- new channel -
- - - - {#if newChannelPrivate} - - {/if} - {#if createError} -
{createError}
- {/if} -
- - -
-
-
-
diff --git a/apps/group-chat/src/routes/c/[communityDid]/+page.svelte b/apps/group-chat/src/routes/c/[communityDid]/+page.svelte deleted file mode 100644 index f6fb160..0000000 --- a/apps/group-chat/src/routes/c/[communityDid]/+page.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - -
- {#if channelsCtx.list.length === 0} -
-

No channels yet.

- {#if data.isAdmin} -

Click + in the sidebar to create one.

- {:else} -

Waiting for an admin to create a channel.

- {/if} -
- {:else} - loading… - {/if} -
diff --git a/apps/group-chat/src/routes/c/[communityDid]/[channelKey]/+page.server.ts b/apps/group-chat/src/routes/c/[communityDid]/[channelKey]/+page.server.ts deleted file mode 100644 index ed88f76..0000000 --- a/apps/group-chat/src/routes/c/[communityDid]/[channelKey]/+page.server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { error } from '@sveltejs/kit'; -import { buildSpaceUri } from '$lib/rooms/uri'; - -export const load: PageServerLoad = async ({ locals, params }) => { - if (!locals.did) error(401, 'Not authenticated'); - const communityDid = decodeURIComponent(params.communityDid); - const spaceUri = buildSpaceUri(communityDid, params.channelKey); - - return { - spaceUri, - channelKey: params.channelKey, - myDid: locals.did - }; -}; diff --git a/apps/group-chat/src/routes/c/[communityDid]/[channelKey]/+page.svelte b/apps/group-chat/src/routes/c/[communityDid]/[channelKey]/+page.svelte deleted file mode 100644 index 1d8aedb..0000000 --- a/apps/group-chat/src/routes/c/[communityDid]/[channelKey]/+page.svelte +++ /dev/null @@ -1,176 +0,0 @@ - - -
- {#if messages.length > 0 && (messagesQuery.status === 'connecting' || messagesQuery.status === 'snapshot')} -
- - - - - updating -
- {/if} -
- {#if messages.length === 0 && (messagesQuery.status === 'connecting' || messagesQuery.status === 'snapshot' || messagesQuery.status === 'idle')} -
Loading…
- {:else if messages.length === 0} -
No messages yet. Say hi.
- {:else} -
    - {#each messages as m (m.rkey)} -
  • -
    - {displayName(m.authorDid)} - · - - {#if m.pending} - · sending… - {:else if m.failed} - · failed{m.error ? ` (${m.error.message})` : ''} - {/if} -
    -
    - {m.text} -
    -
  • - {/each} -
- {/if} -
-
- - -
- - -
-
- -{#if sendErr} -
- {sendErr} -
-{/if} diff --git a/apps/group-chat/src/routes/c/[communityDid]/settings/invites/+page.server.ts b/apps/group-chat/src/routes/c/[communityDid]/settings/invites/+page.server.ts deleted file mode 100644 index 97059c0..0000000 --- a/apps/group-chat/src/routes/c/[communityDid]/settings/invites/+page.server.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { error } from '@sveltejs/kit'; -import { listCommunityInvites } from '$lib/rooms/server'; -import { buildMembersUri } from '$lib/rooms/uri'; - -export const load: PageServerLoad = async ({ locals, params, platform }) => { - if (!locals.did) error(401, 'Not authenticated'); - const communityDid = decodeURIComponent(params.communityDid); - const membersUri = buildMembersUri(communityDid); - - let invites: Array<{ - id: string; - tokenHash: string; - accessLevel: string; - createdBy: string; - createdAt: number; - expiresAt: number | null; - maxUses: number | null; - usedCount: number; - revoked: boolean; - note: string | null; - }> = []; - try { - const res = await listCommunityInvites( - { env: platform!.env, did: locals.did as string }, - { spaceUri: membersUri, includeRevoked: true } - ); - invites = res.invites.map((r) => ({ - id: r.tokenHash.slice(0, 12), - tokenHash: r.tokenHash, - accessLevel: r.accessLevel, - createdBy: r.createdBy, - createdAt: r.createdAt, - expiresAt: r.expiresAt, - maxUses: r.maxUses, - usedCount: r.usedCount, - revoked: r.revokedAt != null, - note: r.note - })); - } catch { - // ignore — page renders with empty list - } - - return { communityDid, membersUri, invites }; -}; diff --git a/apps/group-chat/src/routes/c/[communityDid]/settings/invites/+page.svelte b/apps/group-chat/src/routes/c/[communityDid]/settings/invites/+page.svelte deleted file mode 100644 index dcd6898..0000000 --- a/apps/group-chat/src/routes/c/[communityDid]/settings/invites/+page.svelte +++ /dev/null @@ -1,154 +0,0 @@ - - -
-
- Invites -

- Share an invite link to let new members join. They'll be added to this server's - members role on redemption. -

- -
- - - - {#if err} -
- {err} -
- {/if} -
- -
-
- - {#if lastCreated} -
-
-
New invite
-
{lastCreated.url}
-
- -
- {/if} - -
-

Active invites

- {#if data.invites.length === 0} -

No invites yet.

- {:else} -
    - {#each data.invites as inv (inv.id)} -
  • -
    -
    - {inv.id} - {#if inv.revoked} - - revoked - - {:else if inv.expiresAt && inv.expiresAt < Date.now()} - - expired - - {:else if inv.maxUses != null && inv.usedCount >= inv.maxUses} - - used up - - {/if} -
    -
    - {inv.usedCount} - {inv.maxUses != null ? `/ ${inv.maxUses}` : ''} used - {#if inv.expiresAt} - · expires - {/if} - {#if inv.note}· {inv.note}{/if} -
    -
    - {#if !inv.revoked} - - {/if} -
  • - {/each} -
- {/if} -
-
-
diff --git a/apps/group-chat/src/routes/c/[communityDid]/settings/members/+page.server.ts b/apps/group-chat/src/routes/c/[communityDid]/settings/members/+page.server.ts deleted file mode 100644 index 6b68a46..0000000 --- a/apps/group-chat/src/routes/c/[communityDid]/settings/members/+page.server.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { error } from '@sveltejs/kit'; -import type { Client } from '@atcute/client'; -import { authedFetch } from '$lib/rooms/server'; -import { buildMembersUri } from '$lib/rooms/uri'; - -interface MemberRow { - did?: string; - spaceUri?: string; - accessLevel: string; - grantedBy: string; - grantedAt: number; -} - -export const load: PageServerLoad = async ({ locals, params, platform }) => { - if (!locals.did || !locals.client) error(401, 'Not authenticated'); - const communityDid = decodeURIComponent(params.communityDid); - const membersUri = buildMembersUri(communityDid); - const ctx = { - env: platform!.env, - client: locals.client as Client, - did: locals.did as string - }; - - let members: MemberRow[] = []; - try { - const data = await authedFetch<{ - rows?: Array<{ - subject: { did?: string; spaceUri?: string }; - accessLevel: string; - grantedBy: string; - grantedAt: number; - }>; - }>(ctx, 'tools.atmo.chat.community.space.listMembers', { - query: { spaceUri: membersUri } - }); - members = (data.rows ?? []).map((r) => ({ - did: r.subject.did, - spaceUri: r.subject.spaceUri, - accessLevel: r.accessLevel, - grantedBy: r.grantedBy, - grantedAt: r.grantedAt - })); - } catch { - // empty list - } - - return { communityDid, membersUri, members }; -}; diff --git a/apps/group-chat/src/routes/c/[communityDid]/settings/members/+page.svelte b/apps/group-chat/src/routes/c/[communityDid]/settings/members/+page.svelte deleted file mode 100644 index cb32af8..0000000 --- a/apps/group-chat/src/routes/c/[communityDid]/settings/members/+page.svelte +++ /dev/null @@ -1,142 +0,0 @@ - - -
-
- Members -

- Add members by handle (alice.bsky.social) or DID. Membership here gives access - to all public channels. -

- -
- - -
- {#if addingErr} -
{addingErr}
- {/if} - -
- {#if data.members.length === 0} -

No members yet.

- {:else} -
    - {#each data.members as m (m.did ?? m.spaceUri)} -
  • -
    - {#if m.did} -
    {displayName(m.did)}
    - {#if handleOf(m.did) && handleOf(m.did) !== displayName(m.did)} -
    @{handleOf(m.did)}
    - {:else} -
    {m.did}
    - {/if} - {:else if m.spaceUri} -
    - Role: {m.spaceUri} -
    - {/if} -
    - granted -
    -
    -
    - {#if m.did} - - - {:else} - {m.accessLevel} - {/if} -
    -
  • - {/each} -
- {/if} -
-
-
diff --git a/apps/group-chat/src/routes/join/[token]/+page.svelte b/apps/group-chat/src/routes/join/[token]/+page.svelte deleted file mode 100644 index 0b321a3..0000000 --- a/apps/group-chat/src/routes/join/[token]/+page.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - -
- Join server - - {#if !user.isLoggedIn} -

- Sign in with your atproto account to accept this invite. -

-
- -
- {:else} -

- You're signed in as {user.profile?.handle ?? user.did}. - Accepting will add you to the server's members. -

-
- - -
- {#if err} -
{err}
- {/if} - {/if} -
diff --git a/apps/group-chat/src/routes/new/+page.svelte b/apps/group-chat/src/routes/new/+page.svelte deleted file mode 100644 index ba1c63a..0000000 --- a/apps/group-chat/src/routes/new/+page.svelte +++ /dev/null @@ -1,155 +0,0 @@ - - -
- {#if !user.isLoggedIn} -

Please sign in first.

- {:else if result} - Server created -

- This is the one and only chance to save your recovery key. If lost, ownership of this server - cannot be recovered. -

- -
{JSON.stringify(
-				result.recoveryKey,
-				null,
-				2
-			)}
- -
- - -
- {:else} - New server -
- - -
- -
- Server icon (optional) - PNG, JPG, WEBP, or GIF. Up to 1 MB. - {#if iconFile} - - {/if} -
-
- {#if errorMsg} -
{errorMsg}
- {/if} -
- - -
-
- {/if} -
diff --git a/apps/group-chat/src/routes/xrpc/[...path]/+server.ts b/apps/group-chat/src/routes/xrpc/[...path]/+server.ts deleted file mode 100644 index 7af293f..0000000 --- a/apps/group-chat/src/routes/xrpc/[...path]/+server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { dispatch } from '$lib/contrail'; -import type { RequestHandler } from './$types'; - -async function handler(request: Request, platform: App.Platform | undefined) { - return dispatch(request, platform!.env); -} - -export const GET: RequestHandler = async ({ request, platform }) => handler(request, platform); -export const POST: RequestHandler = async ({ request, platform }) => handler(request, platform); diff --git a/apps/group-chat/static/favicon.png b/apps/group-chat/static/favicon.png deleted file mode 100644 index 825b9e65af7c104cfb07089bb28659393b4f2097..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1571 zcmeAS@N?(olHy`uVBq!ia0y~yU}ykg4i*LmhD`I74h#$ojKx9jP7LeL$-HD>V7=?< z;uunK>uv1q+}9QYp`3aLRhP6(c_0+yMGvW%`8w9Z4+AO$r=LiV^}WjuyMS z-`u@3yY$$SKDCAN>H9tj&u}g}XS4E2pY^*PkNZ3lroTLubH?oMf4#J9vzMpzCckct zowYnCeNLl>-tC#oR|mW7^83!__-WF*w@N!}ZnWO)*sV9e?K!tS_p_50%zk=doa_6a z9Ze7`Oq(1d_U2FyZ`xGx-xFfEE={d84w$f9rS697HscGE-dGl;{V=G?`XscL>&++e z>-U#%oq6{6`Q0U4b%OVNS0!jYj1*aM;g7Z{*A0W>9o{wqst=zQyeQ;ij+$byPs!T7 zP$;gE@43N(Z#KN^`%g~qnDC*fQ@9u5T z`010b%(4WBW*Igy)jqkB43?QUzqIP?pR?n;uR)>M{Z!UJzVqjLRQx?D9Xlud=JZMH zM4Dt}4@n0eu2Ixgk=cKk=N7x!uIU#Qr)QK{iRNADxA(7D>^{r+(1Q28%d+>r`1fM! z3!#PnEzgzrWhp(JYGANY`@x@o);^8RydN}I>CasHT;#z0|7n-*i5nc8wD_Qhgrn&1 z_=)HIc7)82SrZn{n5O;yq=&Wj!=(QfoT>{MZZVX#Y(9Ll{di*M8HGwlu2VN3EWtQvP&QN2PVR8N7!YC#E{m*$kY8i3^zQiv$JVzqYQu{#r;icEl z=UhCnkiGRom%yd1b*>!Dbz(=$xA=OCK9sWW?kjHnIi=(8N8<^v!`m)TWlU#3bX%EA zWW|L?Hd)uCpL{-aJW{t>_loq&RRZ@V7u56iZFu;9u7AgewTA2m_dYzmg!LfHN%jio z6)Tz8tu4PtytF=2_HyzK35Her8CehRz9{eceYP>8gyZOsf1e+R9eAyLo&U`AQ!>jL z>i##s-geN-*K&%b<6Sll}2Yvce^~*xG<6XX?1V3gp#~N zkiz|sN2i}Ey4w@5>yt(|hpKnhq#5tcY68zPo5(r8-F4X1_Ok2&?FGW>zrU(?x;Q#c?bx4-5dPgKtZr*@mZ=`3%X zy6%Z`^e5Hr^Y+>AzJm9>p2Z^BF4@_(4b1il6W?8xzhU;SdYz;2?R&rX$y{t=F!Y`E zR$}w<@9ihF9(X_5v`6rxTqRqD=!eToZeQ5@{QQC0#~w>6ukx<+wTnGF|CY_6xJ%zz zHr<`$x>nEcQ1ZHi?IAA|7rHVEt3Nz@U3=2vrr*f|MGoxkLSkxhhdzJg^6Z@Zq2`!6 zN9X(x&t6IHdUbyJoZ{}h2h3KD+a6rvIIkc7=B91g%$DcR#pk?L*qd_b^D~z6tu4>l zcb}QJe%gmWZyk5-zxe)Gtrh1xH}U_oBV*EcOaCc*(s}2g+Fj}Irl)7WovQcub7w{E upWAk8&OVF%_w#jD-*4ISJB6Y@xbv5oeJi^*U66r+fx*+&&t;ucLK6TBI`Z29 diff --git a/apps/group-chat/svelte.config.js b/apps/group-chat/svelte.config.js deleted file mode 100644 index eb8101d..0000000 --- a/apps/group-chat/svelte.config.js +++ /dev/null @@ -1,16 +0,0 @@ -import adapter from '@sveltejs/adapter-cloudflare'; -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; - -/** @type {import('@sveltejs/kit').Config} */ -const config = { - preprocess: vitePreprocess(), - - kit: { - adapter: adapter(), - experimental: { - remoteFunctions: true - } - } -}; - -export default config; diff --git a/apps/group-chat/tsconfig.json b/apps/group-chat/tsconfig.json deleted file mode 100644 index d1af521..0000000 --- a/apps/group-chat/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "./.svelte-kit/tsconfig.json", - "compilerOptions": { - "allowJs": true, - "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, - "strict": true, - "moduleResolution": "bundler", - "types": ["@cloudflare/workers-types"] - } - // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias - // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files - // - // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes - // from the referenced tsconfig.json - TypeScript does not merge them in -} diff --git a/apps/group-chat/vite.config.ts b/apps/group-chat/vite.config.ts deleted file mode 100644 index 87b3f88..0000000 --- a/apps/group-chat/vite.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import tailwindcss from '@tailwindcss/vite'; -import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; -import { DEV_PORT } from './src/lib/atproto/port'; - -export default defineConfig({ - plugins: [sveltekit(), tailwindcss()], - server: { - host: '127.0.0.1', - port: DEV_PORT, - allowedHosts: [] - } -}); diff --git a/apps/group-chat/wrangler.jsonc b/apps/group-chat/wrangler.jsonc deleted file mode 100644 index d59d3ce..0000000 --- a/apps/group-chat/wrangler.jsonc +++ /dev/null @@ -1,64 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "sveltekit-group-chat", - "main": ".svelte-kit/cloudflare/_worker.js", - "compatibility_date": "2025-12-25", - "compatibility_flags": [ - "nodejs_compat_v2" - ], - "assets": { - "binding": "ASSETS", - "directory": ".svelte-kit/cloudflare" - }, - "observability": { - "enabled": true - }, - "vars": { - "OAUTH_PUBLIC_URL": "https://chat.atmo.tools", - "SERVICE_DID": "did:web:chat.atmo.tools" - }, - "d1_databases": [ - { - "binding": "DB", - "database_name": "group-chat", - "database_id": "576a1971-7b78-4bc2-95f0-f83b360d3279" - } - ], - "triggers": { - "crons": [ - "*/1 * * * *" - ] - }, - "kv_namespaces": [ - { - "binding": "OAUTH_SESSIONS", - "id": "852448049fd74f0bb61448f0e1dd2d77" - }, - { - "binding": "OAUTH_STATES", - "id": "3ef76633a67d46f3aa3a7b619e0730de" - } - ], - "durable_objects": { - "bindings": [ - { - "name": "REALTIME", - "class_name": "RealtimePubSubDO" - } - ] - }, - "migrations": [ - { - "tag": "v1", - "new_sqlite_classes": [ - "RealtimePubSubDO" - ] - } - ], - "r2_buckets": [ - { - "binding": "BLOBS", - "bucket_name": "group-chat-blobs" - } - ] -} \ No newline at end of file diff --git a/apps/sveltekit-cloudflare-workers/lexicons/generated/index.ts b/apps/sveltekit-cloudflare-workers/lexicons/generated/index.ts index e1faba6..7ffad08 100644 --- a/apps/sveltekit-cloudflare-workers/lexicons/generated/index.ts +++ b/apps/sveltekit-cloudflare-workers/lexicons/generated/index.ts @@ -1,4 +1,4 @@ -// Auto-generated by @atmo-dev/contrail-lexicons. Do not edit. +// Checked-in Lexicon bundle. // Pass `lexicons` to `createWorker(config, { lexicons })` to expose them // at `/xrpc/.lexicons` for consumer apps to typegen against. @@ -8,8 +8,7 @@ import _2 from "./statusphere/app/getCursor.json"; import _3 from "./statusphere/app/getOverview.json"; import _4 from "./statusphere/app/getProfile.json"; import _5 from "./statusphere/app/notifyOfUpdate.json"; -import _6 from "./statusphere/app/permissionSet.json"; import _7 from "./statusphere/app/status/getRecord.json"; import _8 from "./statusphere/app/status/listRecords.json"; -export const lexicons: object[] = [_0, _1, _2, _3, _4, _5, _6, _7, _8]; +export const lexicons: object[] = [_0, _1, _2, _3, _4, _5, _7, _8]; diff --git a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/permissionSet.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/permissionSet.json deleted file mode 100644 index c40af0f..0000000 --- a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/permissionSet.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "lexicon": 1, - "id": "statusphere.app.permissionSet", - "defs": { - "main": { - "type": "permission-set", - "title": "statusphere.app", - "description": "All XRPC methods exposed by the statusphere.app service.", - "permissions": [ - { - "type": "permission", - "resource": "rpc", - "aud": "*", - "lxm": [ - "statusphere.app.getCursor", - "statusphere.app.getOverview", - "statusphere.app.getProfile", - "statusphere.app.notifyOfUpdate", - "statusphere.app.status.getRecord", - "statusphere.app.status.listRecords" - ] - } - ] - } - } -} diff --git a/apps/sveltekit-cloudflare-workers/package.json b/apps/sveltekit-cloudflare-workers/package.json index 150c246..388f55c 100644 --- a/apps/sveltekit-cloudflare-workers/package.json +++ b/apps/sveltekit-cloudflare-workers/package.json @@ -5,9 +5,7 @@ "type": "module", "scripts": { "dev": "vite dev", - "build": "contrail-lex generate && contrail-lex types && vite build && contrail append-scheduled", - "generate": "contrail-lex generate", - "generate:pull": "contrail-lex all", + "build": "vite build && contrail append-scheduled", "backfill": "contrail backfill", "backfill:remote": "contrail backfill --remote", "refresh": "contrail refresh", @@ -62,7 +60,6 @@ "dependencies": { "@atcute/jetstream": "^1.1.2", "@atmo-dev/contrail": "workspace:*", - "@atmo-dev/contrail-lexicons": "workspace:*", "@foxui/core": "^0.9.1", "@foxui/social": "^0.8.10", "@foxui/time": "^0.8.5", diff --git a/apps/sveltekit-cloudflare-workers/src/lib/contrail/index.ts b/apps/sveltekit-cloudflare-workers/src/lib/contrail/index.ts index 29f4474..3a166b1 100644 --- a/apps/sveltekit-cloudflare-workers/src/lib/contrail/index.ts +++ b/apps/sveltekit-cloudflare-workers/src/lib/contrail/index.ts @@ -16,14 +16,10 @@ export async function ensureInit(db: D1Database) { const handle = createHandler(contrail); -/** - * Typed `@atcute/client` that calls contrail in-process. Pass `did` to act - * as that user (server-side principal via WeakMap marker — no JWT, no PDS - * roundtrip). Omit for anonymous calls against public endpoints. - */ -export function getServerClient(db: D1Database, did?: string): Client { +/** Typed `@atcute/client` that calls Contrail in-process. */ +export function getServerClient(db: D1Database): Client { return createServerClient(async (req) => { await ensureInit(db); return handle(req, db) as Promise; - }, did); + }); } diff --git a/development.md b/development.md index 0f711a7..606934a 100644 --- a/development.md +++ b/development.md @@ -1,51 +1,48 @@ -# Contrail +# Contrail development -> **Work in progress!** Pre-alpha, expect breaking changes. +> Pre-alpha. Expect breaking changes. -Contrail is a library (and a small set of sibling packages) for building AT Protocol appviews. Define collections, get automatic Jetstream ingestion, PDS backfill, typed XRPC endpoints, permissioned spaces for private records, group-controlled communities, and a client-side reactive sync layer. +## Layout -## Packages +Contrail now has one package and one implementation: -| Package | Description | -|---|---| -| [`@atmo-dev/contrail`](./packages/contrail) | Core library — indexing, XRPC server, spaces, communities, realtime publishing. | -| [`@atmo-dev/contrail-sync`](./packages/sync) | Client-side reactive watch-store over `watchRecords`. SSE + WebSocket, IndexedDB cache. | -| [`@atmo-dev/contrail-lexicons`](./packages/lexicons) | Lexicon codegen from a Contrail config + CLI (`contrail-lex`) wrapping `@atcute/lex-cli`. | +```text +packages/contrail/ + src/core/ ingestion, storage, queries, hydration + src/adapters/ SQLite and PostgreSQL adapters + src/worker/ Cloudflare Worker helper + src/cli/ operational commands +``` -## Apps (reference deployments — `workspace:*`-linked) +Reference deployments live under `apps/`: -| App | Description | -|---|---| -| [`rsvp-atmo`](./apps/rsvp-atmo) | Cloudflare Workers + D1 indexer for `community.lexicon.calendar.*`. | -| [`group-chat`](./apps/group-chat) | Full-featured SvelteKit + Workers group chat using spaces, communities, and realtime. | -| [`postgres`](./apps/postgres) | Node + PostgreSQL minimal indexer. | -| [`cloudflare-workers`](./apps/cloudflare-workers) | Minimal Worker example. | -| [`sveltekit-cloudflare-workers`](./apps/sveltekit-cloudflare-workers) | SvelteKit Statusphere-style example. | +- `cloudflare-workers` — minimal Worker + D1; +- `postgres` — minimal Node.js + PostgreSQL; and +- `sveltekit-cloudflare-workers` — SvelteKit + D1. ## Setup ```bash pnpm install pnpm build +pnpm typecheck pnpm test ``` -Per-package commands run through Turbo: +Focused commands: ```bash -pnpm build # turbo run build -pnpm typecheck # turbo run typecheck -pnpm test # turbo run test pnpm --filter @atmo-dev/contrail build -pnpm --filter rsvp-atmo dev +pnpm --filter @atmo-dev/contrail test +pnpm --filter contrail-cloudflare-workers-example dev ``` ## Releasing -Changesets drive versioning. `@atmo-dev/contrail` and `@atmo-dev/contrail-sync` are `linked` so their versions stay aligned (they share the realtime wire protocol). +Changesets version and publish `@atmo-dev/contrail`: ```bash -pnpm changeset # add a changeset -pnpm changeset version # bump versions -pnpm release # build + publish +pnpm changeset +pnpm changeset version +pnpm release ``` diff --git a/docs/01-indexing.md b/docs/01-indexing.md index 54659fd..05a5dc8 100644 --- a/docs/01-indexing.md +++ b/docs/01-indexing.md @@ -201,7 +201,4 @@ const db = createPostgresDatabase(pool); | `relays` | Bluesky | Relay URLs for discovery | | `notify` | off | `true` opens `notifyOfUpdate`; a string requires `Bearer` | | `feeds` | — | See [Feeds](./04-feeds.md) | -| `spaces` | — | See [Spaces](./06-spaces.md) | -| `community` | — | See [Communities](./07-communities.md) | -| `realtime` | — | See [Sync](./08-sync.md) | | `labels` | — | See [Labels](./09-labels.md) | diff --git a/docs/02-querying.md b/docs/02-querying.md index 72720b8..e8d288e 100644 --- a/docs/02-querying.md +++ b/docs/02-querying.md @@ -7,7 +7,7 @@ Once [indexing](./01-indexing.md) is set up, every collection you declared gets | `{namespace}.{short}.listRecords` | Paginated list with filters, sorts, hydration | | `{namespace}.{short}.getRecord?uri=…` | Single record by AT-URI | -Plus a few top-level ones: `{namespace}.getProfile`, `{namespace}.getCursor`, `{namespace}.getOverview`, `{namespace}.notifyOfUpdate`, `{namespace}.permissionSet`, `{namespace}.lexicons`. +Plus a few top-level ones: `{namespace}.getProfile`, `{namespace}.getCursor`, `{namespace}.getOverview`, `{namespace}.notifyOfUpdate`, and optionally `{namespace}.lexicons`. ## HTTP (what most callers use) diff --git a/docs/03-lexicons.md b/docs/03-lexicons.md deleted file mode 100644 index d06c4e8..0000000 --- a/docs/03-lexicons.md +++ /dev/null @@ -1,100 +0,0 @@ -# Lexicons - -Contrail emits lexicon JSON for every XRPC method it exposes. Your app needs those files for two things: **generating typed TypeScript clients** and **publishing to a PDS** so other apps can discover your schemas. The `contrail-lex` CLI handles both. - -```bash -pnpm add -D @atmo-dev/contrail-lexicons @atcute/lex-cli -``` - -`@atcute/lex-cli` is a peer dep — you pin the version. - -## CLI - -```bash -contrail-lex generate # emit lexicon JSON from your Contrail config -contrail-lex pull # wraps `lex-cli pull` (fetch external lexicons) -contrail-lex types # wraps `lex-cli generate` (JSON → TS types) -contrail-lex all # generate → pull → generate → pull → types -contrail-lex all --no-types # same, skip the type step -contrail-lex publish # publish lexicons to your PDS (add --dry-run to preview) -contrail-lex pull-service # consume a deployed contrail's /lexicons endpoint -``` - -Config is auto-detected at `contrail.config.ts`, `src/contrail.config.ts`, `src/lib/contrail.config.ts`, or `app/contrail.config.ts`. Override with `--config `. - -## What lands where - -Running `contrail-lex all` organises everything under one `lexicons/` directory: - -``` -lexicons/ - custom/ # hand-authored lexicons you write (optional) - generated/ # JSON emitted from your Contrail config - pulled/ # external NSIDs fetched by lex-cli pull -lex.config.js # regenerated each run (gitignore) -src/lexicon-types/ # TS types from lex-cli generate -``` - -`lexicons/custom/`, `lexicons/generated/`, and `lexicons/pulled/` should be **committed** — that way CI doesn't need network access. `lex.config.js` and `src/lexicon-types/` are regenerated on demand and safe to gitignore. - -`lexicons/generated/index.ts` is also emitted on every run — a barrel that imports every lexicon the deployment speaks (generated + pulled + custom). Pass it to `createWorker(config, { lexicons })` to expose them at `/xrpc/.lexicons` on your deployed service; consumer apps can then `pull-service` against it. - -## Consuming a deployed contrail - -If you're building a frontend that talks to someone's (or your own) deployed contrail, you don't need the backend's source code to get typed XRPC calls. Have the operator pass `{ lexicons }` to `createWorker`, then: - -```bash -contrail-lex pull-service https://my-contrail.dev/xrpc/com.example.lexicons -# or: contrail-lex pull-service https://my-contrail.dev --namespace com.example - -npx lex-cli generate -``` - -`pull-service` hits the manifest endpoint, writes each lexicon under `lexicons/pulled/.json`, and `lex-cli generate` emits TypeScript types for `@atcute/client`. Override the output dir with `--out `. - -The manifest includes the service's generated lexicons *plus* any external NSIDs the generator `$ref`s (e.g., `app.bsky.actor.profile`, `community.lexicon.calendar.event`) — so typegen resolves cleanly with no additional fetching from bsky / atproto registries. - -No PDS setup required, no DNS, no published lexicon records — just an HTTP endpoint and typegen. - -## Publishing - -Once your lexicons are generated and committed, publish them as `com.atproto.lexicon.schema` records on your PDS so other apps can resolve them: - -```bash -contrail-lex publish - -# or via env vars (nicer for CI): -LEXICON_ACCOUNT_IDENTIFIER=you.bsky.social \ -LEXICON_ACCOUNT_PASSWORD=xxxx-xxxx-xxxx-xxxx \ - contrail-lex publish -``` - -Writes each lexicon to `at:///com.atproto.lexicon.schema/`. You'll also need DNS TXT records for your NSID authorities — the command prints the exact records you need and won't proceed until you confirm (override with `--skip-confirm` in CI). - -Preview first with `--dry-run`: - -```bash -contrail-lex publish --dry-run -``` - -Walks your `lexicons/generated/` dir, prints the NSIDs it would publish and the exact TXT records you'd need. Doesn't log in or write anything. Credentials aren't required for dry-run, so it's safe to run without secrets configured — useful for sanity-checking what a release would push. - -**One-off flow:** generate once, commit, `contrail-lex publish --dry-run` to preview, `contrail-lex publish` once per version bump. You don't need to re-publish unless your lexicon JSON changes. - -## Programmatic API - -If you need to call the generator from code — e.g. to derive the XRPC method list for an OAuth permission set: - -```ts -import { generateLexicons, extractXrpcMethods } from "@atmo-dev/contrail-lexicons"; - -const generated = generateLexicons({ - config, - rootDir: process.cwd(), - outputDir: "lexicons/generated", -}); - -const methods = extractXrpcMethods(generated); // every query + procedure NSID -``` - -`publishLexicons` is also exported for custom flows, but the CLI covers the common case. diff --git a/docs/04-feeds.md b/docs/04-feeds.md index 3ecf741..0f5c5e1 100644 --- a/docs/04-feeds.md +++ b/docs/04-feeds.md @@ -118,7 +118,5 @@ That's it. Feeds are read-only over XRPC; writes to follow / target collections - No per-feed prune cap; the global pruner uses the largest `maxItems` across all feeds. - The 100-record backfill on a new follow is hardcoded — not tunable per feed. -- No dedicated test coverage for feeds yet (the production paths work, but treat the integration as load-bearing-but-untested). - No max-followers cap on target writes — a target record by a user with 1M followers means 1M `feed_items` inserts. For apps expecting that scale, partition feeds or rate-limit upstream. -- Feeds live in the **main DB** even when [spaces](./06-spaces.md) are split onto a separate DB; there is no `feeds_db` binding. -- Feeds do not currently union with [spaces](./06-spaces.md) records — `getFeed` reads only public target records. +- Feeds contain only public target records. diff --git a/docs/05-auth.md b/docs/05-auth.md deleted file mode 100644 index 6237fb8..0000000 --- a/docs/05-auth.md +++ /dev/null @@ -1,231 +0,0 @@ -# Auth - -Contrail has six auth mechanisms. Which one applies depends on who's calling, where they're calling, and what they're asking for. - -| Mechanism | Used by | For | -|---|---|---| -| Anonymous | anyone | public reads | -| Service-auth JWT | third-party apps acting on behalf of a user | authority-side ops + record-host fallback | -| **Space credential** (`X-Space-Credential`) | callers after exchange via `getCredential` | record-host reads/writes — primary path | -| In-process server client | your own server code | loaders / actions that skip HTTP entirely | -| Invite token | anonymous bearers | read-only access to a specific space | -| Watch ticket | browsers | realtime subscriptions (`watchRecords`) | - -The two "atproto-y" mechanisms (service-auth JWTs and space credentials) work in tandem on permissioned routes: a caller exchanges a JWT for a credential once via `space.getCredential`, then presents the credential on every subsequent record-host request until it expires. - -## Service-auth JWTs - -The standard atproto mechanism. When a third-party app wants to call your contrail service as a user, it: - -1. Asks the user's PDS to mint a service-auth JWT with `com.atproto.server.getServiceAuth` — the token's claims include `iss` (user's DID), `aud` (your service DID), `lxm` (the specific method NSID), and a short expiry. -2. Sends the request to your service with `Authorization: Bearer ` and `Atproto-Proxy: #` so the PDS knows where to route. - -Contrail verifies every request against the public key in the issuer's DID doc (`@atcute/xrpc-server` does the heavy lifting). It checks: - -- Signature valid -- `aud` matches the `serviceDid` you configured (under `spaces.authority.serviceDid`) -- `lxm` covers the method being called -- Token hasn't expired - -On pass, your handler sees a populated `serviceAuth = { issuer, audience, lxm, clientId }` context and can proceed. On fail, 401 or 403 with a structured reason. - -### Where service-auth JWTs apply - -- **Authority routes** (`.space.createSpace`, `addMember`, `getCredential`, etc.) — JWT-only. Credentials are scoped to record-host operations; you can't use one to manage spaces. -- **Record-host routes** (`putRecord`, `listRecords`, `uploadBlob`, etc.) — accept JWTs as a fallback path. The credential path (below) is preferred. - -### The `serviceDid` gotcha - -Use the **plain DID** (no `#fragment`) when configuring contrail: - -```ts -spaces: { authority: { serviceDid: "did:web:example.com" } } // right -spaces: { authority: { serviceDid: "did:web:example.com#com_example_x" } } // wrong -``` - -Many PDS implementations reject `aud` values containing `#fragment` in `com.atproto.server.getServiceAuth`, and contrail does strict string equality on `aud`. The fragment form belongs only in your DID doc's `service` entry, where PDSes use it to resolve the service endpoint URL for `Atproto-Proxy` routing — that's separate from JWT audience validation. - -## Space credentials - -Short-lived (default 2h) ES256 JWTs minted by the space authority. Once a caller has one, they present it via `X-Space-Credential: ` on every record-host request and skip the per-request JWT mint dance. This matches the rough atproto permissioned-data spec. - -### Lifecycle - -``` -1. Caller mints a service-auth JWT { aud, lxm: ".space.getCredential" }. -2. POST .space.getCredential { spaceUri } Authorization: Bearer - → { credential: "", expiresAt: } -3. Caller stores the credential. For ~2 hours, every record-host request: - X-Space-Credential: - succeeds without going back through the user's PDS. -4. Before expiry, refresh: - POST .space.refreshCredential { credential } - → { credential: , expiresAt: } -``` - -### Claims - -```json -{ - "iss": "", - "sub": "", - "space": "ats:////", - "scope": "rw", - "iat": 1746000000, - "exp": 1746014400 -} -``` - -- Signed with the authority's ES256 key (kid = `#atproto_space_authority`). -- Stateless — verifiable by anyone who can resolve the authority DID's verification key. -- `scope` is `"rw"` or `"read"`. Today only `rw` is issued via `getCredential`; the read-only path is a future read-grant invite replacement. - -### How verification works - -When the record host receives a credential, it: - -1. Decodes the JWT, reads `iss` and `space`. -2. Asks its **binding resolver** "who's authorized to sign for this space?" — primary source is the local enrollment table; fallbacks include PDS records and DID-doc service entries. -3. Confirms `iss` matches the authorized DID. -4. Resolves the issuer's verification key (local in-process, or via DID doc). -5. Verifies signature, expiry, scope, space match. - -In an in-process deployment, this is one DB lookup + one signature verification. No DID-doc fetches per request. See [Spaces](./06-spaces.md#discovery--binding-resolution) for the binding details. - -### When the credential gets rejected - -| Reason | Response | -|---|---| -| `malformed` | 401 — JWT structure invalid | -| `bad-alg` | 401 — header alg ≠ ES256 | -| `bad-signature` | 401 — signature didn't verify against the resolved key | -| `expired` | 401 — past `exp`. Refresh, or re-mint. | -| `wrong-space` | 403 — credential's `space` ≠ request's space | -| `wrong-scope` | 403 — read-only credential on a write | -| `unknown-issuer` | 401 — `iss` doesn't match the binding for the space (most often: not enrolled here) | - -## In-process server client - -When your own server code wants to call contrail, the service-auth dance is pointless — it's your code talking to your code. `createServerClient` skips it: - -```ts -import { createServerClient } from "@atmo-dev/contrail/server"; - -const client = createServerClient(async (req) => handle(req, env.DB), userDid); - -// Calls bypass fetch entirely; acts as `userDid` for ACL purposes. -const res = await client.get("com.example.event.listRecords", { params: {...} }); -``` - -Pass `did` to act as that user; omit it for anonymous calls against public endpoints. This is a trust boundary — anything that actually crosses a network needs a real service-auth JWT or space credential, not this shortcut. - -See [SvelteKit + Cloudflare](./frameworks/sveltekit-cloudflare.md) for the typical loader pattern. - -## Invite tokens - -First-class auth for spaces. When a space owner creates an invite: - -``` -.invite.create { spaceUri, kind, ttl?, maxUses? } - → { token: "...plaintext..." } // returned once, never again -``` - -The plaintext token is handed to the user out-of-band (link, QR, email). Contrail stores only a SHA-256 hash. Redemption is one atomic UPDATE: `used_count++ WHERE hash = ? AND !revoked AND !expired AND !exhausted`. - -Three invite kinds, depending on what the token does: - -- **`join`** — redeemed via `.invite.redeem` with a service-auth JWT. Adds the caller's DID to the member list. Members have full read + write inside the space; there's no per-member permission axis beyond "is a member." -- **`read`** — bearer-only. The token itself grants read access when passed as `?inviteToken=`, no DID, no redemption. Good for sharing a read-only link that doesn't add anyone to the member list. -- **`read-join`** — both. Works anonymously as a read token; can also be redeemed with a JWT to promote the caller to member. - -Tokens can be revoked (`invite.revoke`), expire automatically (`ttl`), and be exhausted (`maxUses`). - -## Watch tickets - -Realtime subscriptions (`watchRecords`) can't use regular service-auth JWTs for two reasons: the WebSocket upgrade can't carry arbitrary headers, and an open socket would outlive a 60s JWT TTL. So contrail uses separate short-lived tickets. - -Server-side minting comes in two flavours: - -- `<ns>.realtime.ticket` — POST `{ topic }` (e.g. `"space:ats://..."`) → `{ ticket, topics, expiresAt }`. Bare topic-list ticket, used with the generic `<ns>.realtime.subscribe` endpoint. -- `<collection>.watchRecords?mode=ws&spaceUri=…` (or `&actor=…`) handshake — returns `{ snapshot, ticket, wsUrl, sinceTs, ticketTtlMs, querySpec }`. The ticket is bound to `(did, topics, querySpec)` and is the one to use for the per-collection `watchRecords` stream — both for SSE (`?ticket=…`) and the subsequent WS upgrade. - -Both flavours are signed by `realtime.ticketSecret` (a 32-byte random, configured once). Clients hand the ticket off via `?ticket=...` on connect. - -In the `@atmo-dev/contrail-sync` client: - -```ts -createWatchStore({ - url: "/xrpc/com.example.message.watchRecords?spaceUri=ats://...", - mintTicket: async () => (await fetch("/api/ticket")).then((r) => r.text()), -}); -``` - -Each reconnect mints a fresh ticket, so expiry doesn't matter for long-lived subscriptions. See [Sync](./08-sync.md) for the full flow. - -## OAuth permission sets - -When a user grants a third-party app permission to act as them in your contrail service, the consent screen is driven by a **permission set** — a lexicon that bundles every XRPC method you expose. Contrail auto-generates `{namespace}.permissionSet` for you; `contrail-lex` publishes it alongside the other lexicons. - -A third-party app requests scope by referencing your permission set's NSID in its OAuth metadata: - -```jsonc -"scope": "include:com.example.permissionSet" -``` - -The user's PDS fetches that lexicon (via DNS-backed NSID resolution), shows the user what methods are being requested, and mints scoped service-auth JWTs on confirmation. - -### DNS requirements - -Permission sets live under *your* namespace (`com.example.permissionSet`), so PDSes resolve the NSID via DNS. Resolution does **not** walk up subdomains — every authority in your NSID tree needs its own `TXT` record at `_lexicon.<reversed-domain-path>`. - -`contrail-lex publish` prints the exact records you need (also works as a `--dry-run`). Without them, permission sets can't be fetched, and users get errors instead of a consent screen. - -## Anonymous / public - -No auth needed for: - -- `listRecords` / `getRecord` without `spaceUri` — returns public records -- `getProfile`, `getCursor`, `getOverview` -- `notifyOfUpdate` (unless you set `notify: "some-bearer-token"` in config; then it needs `Authorization: Bearer <that>`) - -Public requests skip all verification middleware — no JWT parsing, no DID-doc fetch. Fast path. - -## Trust assumptions - -Two trust assumptions in the binding layer cannot be closed at the Contrail layer alone. They are listed here so operators picking a binding strategy or onboarding flow can size them against their own threat model. - -### DID-doc binding path - -The record host can resolve a space's authority from three sources (see [Spaces § Discovery](./06-spaces.md#discovery--binding-resolution)): - -- **Local enrollment** — the host's own `record_host_enrollments` table, written via `recordHost.enroll`. Owner-signed; the host has full control over what's stored. -- **PDS record** — read from the owner's PDS at the space URI. -- **DID-doc service entry** — read from `service[id="#atproto_space_authority"]` on the owner's DID doc. - -The DID-doc path inherits PLC's authorization model: any rotation key on the owner's account can submit an update op rewriting the service entry. There is no per-entry signature or "this entry can only be edited by key X" constraint at the PLC layer. - -If the integrity of the space-authority binding needs to exceed what any one of the owner's rotation keys can already do, configure the host to resolve via local enrollment instead, or wait for an upstream signed-binding mechanism. Tracked upstream as [flo-bit/contrail#38](https://github.com/flo-bit/contrail/issues/38). - -### Contrail-held PDS app password - -Deployments running `community.provision` or `community.adopt` necessarily hold an ATProto app password for the user-owned PDS account so Contrail can write on the community's behalf. ATProto app passwords are unscoped at the PDS layer — they can write any record to the repo, including the `tools.atmo.space.declaration` record (or its embedded equivalent on a space-type record) that drives binding decisions in `createPdsBindingResolver`. - -This is an intentional consequence of the managed-community model, not a bug. Operators should treat stored app passwords with the same care as rotation keys, and reach for scoped app passwords (when ATProto adds them) or signed binding records to tighten the model further. Tracked upstream as [flo-bit/contrail#39](https://github.com/flo-bit/contrail/issues/39). - -## How the pieces fit - -A typical flow for a third-party app acting as a user in a space: - -1. App registers OAuth client pointing at your permission set NSID. -2. User grants consent — PDS fetches your permission set lexicon via DNS, shows the user the methods, records the scope. -3. App calls `com.atproto.server.getServiceAuth` on the user's PDS: `{ aud: "did:web:example.com", lxm: "com.example.space.getCredential", exp: <60s> }`. PDS signs, returns JWT. -4. App POSTs `<ns>.space.getCredential { spaceUri }` with `Authorization: Bearer <jwt>` and `Atproto-Proxy: did:web:example.com#com_example_space`. Authority verifies the JWT, checks membership, mints a 2h credential. -5. App caches the credential. Every subsequent `putRecord` / `listRecords` / `uploadBlob`: - ``` - POST <ns>.space.putRecord - X-Space-Credential: <credential> - ``` - The record host verifies the credential against its enrolled authority's key — no PDS roundtrip, no DID-doc fetch. -6. Before expiry, app calls `refreshCredential` to get a fresh one. - -For your own loaders/actions, the credential dance disappears — `createServerClient({did}).post(...)` bypasses it entirely. For a browser subscribing to a feed, steps 3–5 are replaced by a ticket mint from your server. Same auth model, different surface. diff --git a/docs/06-spaces.md b/docs/06-spaces.md deleted file mode 100644 index 338abc7..0000000 --- a/docs/06-spaces.md +++ /dev/null @@ -1,153 +0,0 @@ -# Spaces - -Auth-gated store for records that can't live on public PDSes — private events, invite-only groups, members-only chat. Opt-in; zero cost if you don't enable it. - -## Mental model - -> A **space** is a bag of records with one lock. The **member list** says who has the key. - -- One owner (DID), one type (NSID), one key. Identified by `ats://<owner>/<type>/<key>` — distinct scheme from atproto record URIs (`at://`) so the two can't be confused at any layer. -- Every member (including owner) has read + write inside the space. Delete is scoped to your own records — no one can remove records they didn't author, owner included. To wipe everything, delete the space. -- Optional **app policy** gates which OAuth clients can act in the space. - -Every permission boundary is its own space. No nested ACLs. Richer roles = more spaces or app-layer checks. - -### Two roles, one or two services - -A space has two operational roles: - -- **Space authority** — owns the member list, signs short-lived credentials. Identified by a service DID. -- **Record host** — stores records and blobs for spaces it has *enrolled*. - -In the default deployment both run in the same Contrail instance against the same DB; you don't notice the split. But the roles can also run separately — see [deployment shapes](./10-deployment-shapes.md) for ACL-on-arbiter / records-on-contrail patterns. - -## Enable - -```ts -import type { ContrailConfig } from "@atmo-dev/contrail"; -import { generateAuthoritySigningKey } from "@atmo-dev/contrail"; - -// One-time setup: generate a signing key and store it. The authority signs -// space credentials with this key; verifiers find the public key in the -// authority DID's DID document or via the binding-resolver chain. -const signing = await generateAuthoritySigningKey(); - -const config: ContrailConfig = { - namespace: "com.example", - collections: { /* ... */ }, - spaces: { - authority: { - type: "com.example.event.space", - serviceDid: "did:web:example.com", - signing, // omit to disable credential issuance - credentialTtlMs: 2 * 60 * 60 * 1000, // 2h, matches the rough spec - }, - recordHost: { - // blobs is optional; omit to disable blob endpoints - blobs: { adapter: blobsAdapter }, - }, - }, -}; -``` - -Each collection gets a parallel `spaces_records_<short>` table. Opt out per-collection: - -```ts -public_only: { collection: "com.example.public", allowInSpaces: false } -``` - -## Auth — three paths - -The record host accepts three forms of auth on read/write paths, in this precedence order: - -1. **`X-Space-Credential` header** — a short-lived JWT minted by the space authority. The primary path: callers exchange a service-auth JWT once via `space.getCredential`, then present the credential on every request until it expires. Skips per-request DID-doc fetches and member checks; the credential's signature is the proof. -2. **`?inviteToken=...` query** (read-only) — bearer access for shareable links. See [Auth § Invite tokens](./05-auth.md#invite-tokens). -3. **`Authorization: Bearer <service-auth-jwt>`** — the standard atproto path. Useful for one-off calls (the credential exchange itself, space-management endpoints) or as a fallback when the caller doesn't want to manage credentials. - -Authority-side endpoints (`createSpace`, `addMember`, `getCredential`, etc.) only accept service-auth JWTs — credentials are scoped to record-host operations. - -See [Auth](./05-auth.md) for the full picture. - -## Credential flow - -```text - ┌──────────────┐ - │ user PDS │ mints service-auth JWT (lxm: getCredential) - └──────┬───────┘ - ▼ - ┌──────────────┐ - │ authority │ validates JWT, checks membership, - │ (Contrail) │ signs ES256 credential (2h TTL) - └──────┬───────┘ - │ { credential, expiresAt } - ▼ - ┌──────────────┐ - │ record host │ verifies credential signature against - │ (Contrail or │ authority DID's published key, - │ elsewhere) │ checks scope/space/expiry, serves request - └──────────────┘ -``` - -`space.refreshCredential` re-issues a fresh credential from an unexpired one without going back through the JWT mint dance — useful for long-running clients. - -## Enrollment - -The record host maintains a local table of which spaces it accepts records for and which authority signs credentials for each. Two ways enrollment happens: - -- **Auto-enroll** (default for in-process deployments): the authority's `createSpace` automatically enrolls the new space on the colocated record host. New users see no enrollment surface; it just works. -- **Explicit `recordHost.enroll`**: for split deployments where the authority and record host run in different processes/operators, the owner (or the authority itself) calls `<ns>.recordHost.enroll { spaceUri, authority }` to consent. Idempotent — re-enrolling updates the binding. - -A non-enrolled space gets 404 "not-enrolled" on every record-host route. This is the host's consent layer — without it, anyone with a valid credential could create unbounded storage on your host. - -## Discovery — binding resolution - -When a record host receives a credential, it needs to know whether the credential's `iss` is authorized to sign for that space. Three sources, tried in order: - -1. **Local enrollment** — primary on the record host. `(spaceUri → authorityDid)` from the enrollment table. -2. **PDS record** at `at://<owner>/<type>/<key>` — for user-owned DIDs that declared a host via a normal PDS write. Lexicon: `tools.atmo.space.declaration` (or your namespaced variant). -3. **DID-doc service entry** — `#atproto_space_authority` on the owner's DID doc. For provisioned (no-PDS) DIDs. -4. **Owner self-issues** (fallback) — for the trivial case where the owner DID's own key signs credentials. - -For in-process deployments, step 1 is the only one that fires. The other resolvers are wired in by deployments that accept credentials from external authorities — see [deployment shapes](./10-deployment-shapes.md). - -## Unified `listRecords` - -| Call | Returns | -|---|---| -| no auth, no `spaceUri` | public only | -| `?spaceUri=…` + credential or JWT | one space (ACL-gated) | -| credential / JWT, no `spaceUri` | public **unioned** with every space the caller is a member of | - -Filters, sorts, hydration, and references work across all three. Records from a space carry a `space: <spaceUri>` field — same on `listRecords`/`getRecord` responses and `watchRecords` stream events. - -## Invites - -First-class primitive — see [Auth § Invite tokens](./05-auth.md#invite-tokens) for the mechanism. Space-specific: create via `<ns>.invite.create`, redeem via `.redeem` (membership grant) or `?inviteToken=...` query param (read-only bearer grant). - -## XRPCs - -### Authority routes (`<ns>.space.*` — spec-aligned) -- `createSpace` `getSpace` `listSpaces` `deleteSpace` -- `listMembers` `addMember` `removeMember` -- `getCredential` `refreshCredential` -- `leaveSpace` (contrail extra) - -### Record-host routes (`<ns>.space.*` for records, `<ns>.recordHost.*` for management) -- `putRecord` `deleteRecord` `getRecord` `listRecords` -- `uploadBlob` `getBlob` `listBlobs` (when `recordHost.blobs` is configured) -- `recordHost.enroll` - -### Contrail extras (`<ns>.spaceExt.*`) -- `whoami` — caller's relationship to a space (extensions plug in via the integration's whoami hook) - -### Invite (`<ns>.invite.*`) -- `create` `redeem` `revoke` `list` - -## What's not here - -- No E2EE (data is operator-readable). -- No FTS on `?spaceUri=…` yet. -- Records still live in the operator's DB rather than user PDSes — federation is greenfield. (See `refs/spaces-spec-mapping.md` for the migration notes.) -- No managing-app routing (join requests, approval queues — see `refs/spaces-later.md`). - -The design follows Daniel Holmgren's [permissioned data rough spec](https://dholms.leaflet.pub/3mhj6bcqats2o). When real atproto permissioned repos ship, migration is mostly data movement — the wire surface your app speaks doesn't change. diff --git a/docs/07-communities.md b/docs/07-communities.md deleted file mode 100644 index 53b3d7b..0000000 --- a/docs/07-communities.md +++ /dev/null @@ -1,116 +0,0 @@ -# Communities - -Group-controlled atproto DIDs. A community is a DID whose signing/rotation keys are held by the appview on behalf of multiple members, with tiered access levels. Built on top of [spaces](./06-spaces.md). - -Communities live in a separate package — `@atmo-dev/contrail-community` — that plugs into Contrail via an integration object. The contrail core has no knowledge of community-specific concepts; the package wires itself in via injectable hooks (whoami extension, invite handler, route registration, schema). - -## Install - -```bash -pnpm add @atmo-dev/contrail @atmo-dev/contrail-community -``` - -## Wire it up - -Construct the integration once, hand it to `Contrail` (or directly to `createApp`): - -```ts -import { Contrail, resolveConfig, type ContrailConfig } from "@atmo-dev/contrail"; -import { createCommunityIntegration } from "@atmo-dev/contrail-community"; - -const config: ContrailConfig = { - namespace: "com.example", - collections: { /* ... */ }, - spaces: { - authority: { type: "com.example.event.space", serviceDid: "did:web:example.com", signing }, - recordHost: {}, - }, - community: { - masterKey: env.COMMUNITY_MASTER_KEY, // 32-byte encryption key for stored credentials - serviceDid: "did:web:example.com", - levels: ["admin", "moderator"], // ranked, highest-first - }, -}; - -const resolved = resolveConfig(config); -const communityIntegration = createCommunityIntegration({ db, config: resolved }); - -const contrail = new Contrail({ ...config, db, communityIntegration }); -await contrail.init(); // applies community schema alongside contrail's own -``` - -Or with `createApp` directly: - -```ts -import { createApp, initSchema } from "@atmo-dev/contrail"; -import { createCommunityIntegration } from "@atmo-dev/contrail-community"; - -const community = createCommunityIntegration({ db, config }); -await initSchema(db, config, { extraSchemas: [community.applySchema] }); -const app = createApp(db, config, { community }); -``` - -Stored credentials (app passwords for adopted communities, signing keys for minted) are envelope-encrypted with `masterKey`. Never ship the placeholder. - -## When to use this - -When you want atproto records published under a *shared* identity — a team, a project, a channel — not a single user. Think: a group's published calendar events, a community's published posts. - -## Three modes - -- **Minted** — contrail creates a fresh `did:plc` for the community, holds the signing key plus one rotation key (a second rotation key is returned to the creator once for recovery), and publishes from it. -- **Adopted** — contrail takes over an existing account by holding an **app password** issued from its PDS. The owner's identity, signing key, and rotation keys are unchanged; contrail just gets PDS write access via the app password. -- **Provisioned** — contrail creates a fresh `did:plc` and a new PDS account. The caller supplies the rotation key; that key is set as the DID's rotation key in PLC. Contrail receives an app password from the new account and publishes through it. - -Whichever the mode, the result is the same shape: a DID that multiple members can act through, gated by access levels. - -## Choosing a mode - -Two questions typically determine the mode: - -1. **Does the community already have a DID?** Yes → **adopt**. No → continue. -2. **How should records be published?** Contrail signs them directly → **mint**. Contrail uses an app password against a PDS account → **provision**. - -The rotation key holder follows from the choice: contrail in mint mode, caller in adopt and provision modes. - -## Access levels - -Each member has a level (ranked). Levels map to write permissions. Owners can grant/revoke levels. Two reserved levels exist: `owner` and `member`. Your deployment defines the rest via `config.community.levels`. - -## How it composes with spaces - -A community *owns* spaces. Members of the community get access to community-owned spaces based on their access level. Grant access per-space per-level: - -``` -community.space.grant { spaceUri, subject: { did: "did:plc:..." }, accessLevel: "admin" } -``` - -The spaces layer stays ignorant of access levels — it just sees "this DID is a member." The community layer projects member × level → membership in specific spaces. Once a DID is a member of a space (through a community grant or otherwise), they have full read + write inside it. - -The integration plugs in to two contrail extension points: - -- **Whoami** — `<ns>.spaceExt.whoami` returns `accessLevel` for community-owned spaces (the community whoami extension overrides the default binary-membership response). -- **Invites** — the unified `<ns>.invite.*` family dispatches community-owned spaces through the community invite handler (which uses access levels) and user-owned spaces through the spaces module's binary-membership handler. - -## XRPCs - -- `<ns>.community.mint | adopt | provision | list | delete` -- `<ns>.community.invite.create | redeem | revoke | list` -- `<ns>.community.setAccessLevel | revoke | listMembers` -- `<ns>.community.space.create | grant | revoke | ...` — community-owned spaces -- `<ns>.community.putRecord | deleteRecord` — publish records as the community DID - -The `contrail-community reap` CLI tombstones provisioned DIDs in PLC when provisioning fails partway and orphan rows accumulate. It ships as a bin in the `@atmo-dev/contrail-community` package (`npx contrail-community reap`); under pnpm's isolated `node_modules` the core `contrail` CLI cannot resolve the community package, so `contrail reap` only works in hoisted installs where both packages sit together. - -- Default is dry-run; pass `--no-dry-run` to actually submit (irrevocable) tombstones. -- `--all-stuck` only reaps rows idle at least `--older-than <minutes>` (default 30), so a bulk run can't tombstone an in-flight provision that is mid-state-machine. `--attempt-id <uuid>` targets a single known row and ignores the age floor. -- **D1 and Postgres:** by default reap acquires the Cloudflare D1 binding via `wrangler getPlatformProxy()`. For the decoupled external (Postgres) index, pass `--db <connection-string>` or set `DATABASE_URL` and reap runs against Postgres instead (`--db` wins over the env var). The orchestrator and adapter SQL are dialect-agnostic, so both paths share the same reap logic. - -## What's not here - -- No per-record per-level ACLs. Model as spaces. -- No auto-rotation on key compromise yet. -- Adopted and provisioned modes: contrail's write access depends on an app password issued from the PDS, which the rotation-key holder can revoke at any time. -- Minted mode: contrail holds the signing key and one rotation key. The creator's recovery rotation key, returned once at mint time, is the only key not held by contrail. - -The design follows zicklag's [Arbiter design sketch](https://zicklag.leaflet.pub/3mjrvb5pul224) for group management on atproto. The post is an early design note; our implementation will track it as the spec firms up. diff --git a/docs/08-sync.md b/docs/08-sync.md deleted file mode 100644 index 2a7691b..0000000 --- a/docs/08-sync.md +++ /dev/null @@ -1,93 +0,0 @@ -# Sync - -Client-side reactive store over contrail's `watchRecords` endpoints. Subscribes once, reconciles forever, ships with optimistic updates and an optional IndexedDB cache. - -Lives in its own package: - -```bash -pnpm add @atmo-dev/contrail-sync -``` - -## Basic use - -```ts -import { createWatchStore } from "@atmo-dev/contrail-sync"; - -const store = createWatchStore({ - url: "/xrpc/com.example.message.watchRecords?roomUri=at://...", - transport: "sse", // or "ws" -}); - -store.subscribe(({ records, status }) => { /* re-render */ }); -store.start(); -``` - -Framework-agnostic — wrap it in Svelte `$state`, React `useSyncExternalStore`, Vue `ref`, whatever. - -## Transports - -- **SSE** (default) — one HTTP request, simplest. Works everywhere. -- **WS** — a two-step handshake: HTTP GET returns a snapshot + watch-scoped ticket, then you upgrade to WS. On Cloudflare, the WS terminates on a Durable Object that hibernates idle connections. Same event stream either way. - -## Authenticated watches - -Pass `mintTicket` for any non-public endpoint. One-shot string for SSR-minted tickets, function for fresh tickets per reconnect: - -```ts -mintTicket: async () => (await fetch("/api/ticket")).then((r) => r.text()), -``` - -Tickets are minted server-side via `com.example.realtime.ticket` (or any app-specific route). - -## Optimistic updates - -```ts -store.addOptimistic({ rkey, did, value: { text: "hi" } }); -// later, on mutation failure: -store.markFailed(rkey, err); -// or explicit rollback: -store.removeOptimistic(rkey); -``` - -When a real record with the same `rkey` arrives via the stream, the optimistic entry is dropped automatically. - -## IndexedDB cache - -Instant first paint from last session's records: - -```ts -import { createIndexedDBCache } from "@atmo-dev/contrail-sync/cache-idb"; - -createWatchStore({ - url, - cache: createIndexedDBCache(), - cacheMaxRecords: 200, -}); -``` - -Cached records show immediately; the live snapshot reconciles when the connection opens. - -## Server-side config - -Enable `watchRecords` emission in your Contrail config: - -```ts -realtime: { - ticketSecret: ENV.REALTIME_TICKET_SECRET, // 32 bytes - pubsub: new DurableObjectPubSub(env.PUBSUB), // or in-memory for dev -} -``` - -See [Indexing](./01-indexing.md) for the full config surface. - -## Lifecycle - -``` -idle → connecting → snapshot → live - ↓ (disconnect) - reconnecting → snapshot → live - ↓ (stop) - closed -``` - -Stale records stay visible across reconnects until the fresh snapshot arrives, at which point anything the server didn't re-send is evicted. Survives offline periods cleanly. diff --git a/docs/09-labels.md b/docs/09-labels.md index 39a873e..d34b702 100644 --- a/docs/09-labels.md +++ b/docs/09-labels.md @@ -134,9 +134,7 @@ The PK includes `cts`, so a `neg=true` retraction is a *new row* that replaces t ## What's not here - **Signature verification.** `sig` is stored if the labeler supplies it, but contrail does not verify it in v1. Document as TODO; most appviews skip it. -- **Live label updates on `watchRecords`.** The realtime stream snapshots labels with the initial query but does not push label deltas. Adding this means publishing `labels:<src>` topic events from the ingest worker and merging them in `runQueryStream` — future work. -- **Spaces / community labels.** Hydration already runs on the spaces read paths, so labels emitted by a labeler-DID member of a space (or under a community DID) will show up if you write them to the `labels` table. The auth surface for "make this DID a labeler in this space" is not yet exposed as XRPCs. -- **Outbound `subscribeLabels`.** Contrail does not republish labels. Communities-as-labelers (using the community DID as `src`) is a natural extension once you want to act as a labeler instead of just consume. +- **Outbound `subscribeLabels`.** Contrail consumes labels but does not act as a labeler or republish them. - **Label definitions / preferences UX.** Custom label names, blur behaviors, severity, and per-user preference state belong on the *client*, fetched directly from each labeler. Contrail intentionally stays out of this. ## Design diff --git a/docs/10-deployment-shapes.md b/docs/10-deployment-shapes.md deleted file mode 100644 index 7b8675b..0000000 --- a/docs/10-deployment-shapes.md +++ /dev/null @@ -1,208 +0,0 @@ -# Deployment shapes - -Spaces split into two roles, run together by default. Three deployment shapes, in increasing order of complexity: - -1. **All-in-one** — authority + record host + (optional) community in one process. The default; what you get from `createApp` with both `spaces.authority` and `spaces.recordHost` configured. Most apps want this. -2. **Authority-only** — a service that controls ACL and signs credentials, but doesn't store records. Useful when records live on someone else's host (e.g. a community arbiter that delegates storage to a heavier appview). -3. **Record-host-only** — a service that stores records, accepting credentials signed by an external authority. Useful when storage lives separately from governance — e.g. Contrail-as-host backing spaces that an "Arbiter" or HappyView manages. - -The split is real at the wire level (different XRPCs, different auth shapes) but the same Contrail codebase handles all three. This doc walks through each. - -## Shape 1: all-in-one (default) - -``` -┌────────────────────────────────────┐ -│ Contrail │ -│ ┌────────┐ ┌────────────┐ │ -│ │authority│ │ record host│ │ -│ │ + signing│ │ + enrollment│ │ -│ └────────┘ └────────────┘ │ -│ shared DB, single process │ -└────────────────────────────────────┘ -``` - -Config: - -```ts -spaces: { - authority: { - type: "com.example.event.space", - serviceDid: "did:web:example.com", - signing: await generateAuthoritySigningKey(), - }, - recordHost: { - blobs: { adapter: blobsAdapter }, // optional - }, -}, -``` - -What happens at startup: - -- `initSchema` creates both authority tables (`spaces`, `spaces_members`, `spaces_invites`) and record-host tables (`spaces_records_<short>`, `spaces_blobs`, `record_host_enrollments`). -- The umbrella router wires `registerAuthorityRoutes` + `registerRecordHostRoutes` against the same `HostedAdapter`. -- The credential verifier is built from `Local` binding + `Local` key — no DID-doc fetches; the host knows the authority's public key directly. - -What happens when a user creates a space: - -1. `createSpace` writes a row in `spaces` (authority) and immediately a row in `record_host_enrollments` (host). One round-trip, two DB writes. -2. From there, `getCredential` works, `putRecord` works, the world is in sync. - -This is the path most apps run. You don't notice the role split. - -## Shape 2: authority-only - -A lightweight service that holds ACL and signs credentials. Records live on someone else's host. - -``` - ┌────────────────┐ - │ this Contrail │ - │ authority │ - └────────────────┘ - ▲ - │ getCredential - │ - ┌─────────────┐ - │ client │ - └─────────────┘ - │ X-Space-Credential - ▼ - ┌─────────────┐ - │ external │ enrolled with this authority - │ record host │ (different operator, different DID) - └─────────────┘ -``` - -Config: - -```ts -spaces: { - authority: { - type: "com.example.event.space", - serviceDid: "did:web:authority.example.com", - signing: await generateAuthoritySigningKey(), - }, - // recordHost omitted — this deployment doesn't store records -}, -``` - -`createSpace` here does NOT auto-enroll anywhere. The space owner (or the authority itself) calls `recordHost.enroll` on whichever host they want to use; the host then accepts credentials signed by this authority for that space. - -The authority's DID document needs to publish the verification key under `#atproto_space_authority` so external hosts can resolve it. - -## Shape 3: record-host-only - -A storage tier that accepts credentials signed by external authorities. - -``` -┌────────────┐ -│ external │ signs credentials -│ authority │ -└────────────┘ - │ - ▼ -┌─────────────────────────────────────┐ -│ this Contrail │ -│ record host (no authority) │ -│ │ -│ verifies credentials via: │ -│ - enrollment table │ -│ - DID-doc key resolver │ -│ (for external authorities) │ -└─────────────────────────────────────┘ -``` - -Config: - -```ts -spaces: { - // authority is still needed for the JWT verifier infrastructure (so the - // record host can validate JWTs on the recordHost.enroll endpoint), but - // no signing key is configured — this deployment doesn't issue creds. - authority: { - type: "com.example.event.space", - serviceDid: "did:web:host.example.com", - }, - recordHost: { - blobs: { adapter: blobsAdapter }, - }, -}, -``` - -To accept credentials from an external authority, wire a custom verifier: - -```ts -import { - createApp, - createBindingCredentialVerifier, - createEnrollmentBindingResolver, - createDidDocKeyResolver, -} from "@atmo-dev/contrail"; -import { CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver } - from "@atcute/identity-resolver"; - -const didResolver = new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver(), - }, -}); - -const verifier = createBindingCredentialVerifier({ - // Local enrollment is the canonical binding source — only spaces this - // host has explicitly opted into are accepted. - bindings: createEnrollmentBindingResolver({ recordHost: hostAdapter }), - // For credential signature verification, walk DID docs of external - // authorities to find their published verification keys. - keys: createDidDocKeyResolver({ resolver: didResolver }), -}); - -const app = createApp(db, config, { - spaces: { credentialVerifier: verifier }, -}); -``` - -The flow when a request arrives: - -1. Caller presents `X-Space-Credential: <jwt>`. -2. Verifier reads `iss` from the JWT, looks up enrollment for `claims.space`. If the enrollment's `authorityDid` matches `iss` → continue. If not → 401 `unknown-issuer`. -3. Resolves the issuer DID, finds the verification method with id matching the JWT's `kid`, verifies the signature. -4. Checks expiry, scope, space match. -5. Serves the request. - -Enrollment is the host's consent layer: a credential can only be presented for spaces the host has agreed to store. Without enrollment, no records get written. - -## Mixing shapes - -You can run all three simultaneously in one Contrail instance. The umbrella router enables each set of routes based on what's configured: - -- `spaces.authority` → authority routes registered (`createSpace`, `getCredential`, etc.) -- `spaces.recordHost` → record-host routes registered (`putRecord`, `recordHost.enroll`, etc.) -- Both → today's default. - -A deployment can act as the authority for spaces it owns *and* a record host for spaces other authorities own. Auto-enroll fires only for spaces this deployment is the authority for; external authorities still enroll explicitly. - -## Choosing a shape - -| Need | Shape | -|---|---| -| One operator, one process, want it to work | All-in-one | -| You're running a "DAO governance / arbiter" service that decides ACL but not storage | Authority-only | -| You're running an appview / heavier storage tier and want to accept ACL decisions from external services | Record-host-only | -| You're an existing Contrail deployment that wants to also accept external authorities | All-in-one + custom verifier | - -When in doubt, all-in-one. Splitting is for when you have a real operational reason to separate the two — different teams running them, different latency profiles, different scaling targets, different governance. - -## Known trust assumptions - -Two assumptions in the binding layer cannot be closed at the Contrail layer alone, and which one applies depends on the shape you pick: - -- Deployments wiring `createDidDocBindingResolver` inherit PLC's rotation-key authorization model for the `#atproto_space_authority` service entry. Tracked as [flo-bit/contrail#38](https://github.com/flo-bit/contrail/issues/38). -- Deployments running `community.provision` or `community.adopt` hold an unscoped ATProto app password for each provisioned PDS account. Tracked as [flo-bit/contrail#39](https://github.com/flo-bit/contrail/issues/39). - -See [Auth § Trust assumptions](./05-auth.md#trust-assumptions) for the constraints in detail and what binding source to prefer when those assumptions don't fit your threat model. - -## What's not here - -- **Authority migration** — moving a space's authority from DID A to DID B. The architecture supports it (re-enroll on the host with the new authority binding) but no helper API yet. -- **Multi-authority per space** — could in principle allow several authorities to all sign for one space (replication scenarios). Not modeled today; spec is silent. -- **PDS-backed records** — when atproto's permissioned-repos protocol ships, records will federate from user PDSes. The host becomes an aggregator rather than a store. The role split here generalizes to that world without changes. diff --git a/docs/frameworks/sveltekit-cloudflare.md b/docs/frameworks/sveltekit-cloudflare.md index 6d01872..64270bd 100644 --- a/docs/frameworks/sveltekit-cloudflare.md +++ b/docs/frameworks/sveltekit-cloudflare.md @@ -7,8 +7,7 @@ Assumes you already have a SvelteKit app with `@sveltejs/adapter-cloudflare` and ## Install ```bash -pnpm add @atmo-dev/contrail -pnpm add -D @atmo-dev/contrail-lexicons @atcute/lex-cli +pnpm add @atmo-dev/contrail @atcute/client ``` ## Project layout @@ -61,13 +60,12 @@ export async function ensureInit(db: D1Database) { const handle = createHandler(contrail); -/** Typed in-process XRPC client for loaders / actions. Pass `did` to act as - * that user (no JWT / PDS roundtrip); omit for anonymous public reads. */ -export function getServerClient(db: D1Database, did?: string): Client { +/** In-process XRPC client for loaders and actions. */ +export function getServerClient(db: D1Database): Client { return createServerClient(async (req) => { await ensureInit(db); return handle(req, db) as Promise<Response>; - }, did); + }); } ``` @@ -104,8 +102,8 @@ Now every `com.example.*.listRecords` / `com.example.*.getRecord` / `com.example import { getServerClient } from "$lib/contrail"; import type { PageServerLoad } from "./$types"; -export const load: PageServerLoad = async ({ platform, locals }) => { - const rpc = getServerClient(platform!.env.DB, locals.did ?? undefined); +export const load: PageServerLoad = async ({ platform }) => { + const rpc = getServerClient(platform!.env.DB); const res = await rpc.get("com.example.event.listRecords", { params: { startsAtMin: "2026-01-01", limit: 20 }, }); @@ -113,7 +111,7 @@ export const load: PageServerLoad = async ({ platform, locals }) => { }; ``` -`createServerClient` bypasses fetch — the loader runs contrail's XRPC handler in-process, no extra network hop. `did` sets the caller identity without requiring a signed JWT (it's a server-to-server trust boundary; anything crossing an untrusted boundary still needs real service-auth). +`createServerClient` bypasses the network — the loader runs Contrail's public XRPC handler in-process. ## 5. Cron ingest — the workaround @@ -202,11 +200,10 @@ From now on: - [Indexing](../01-indexing.md) — config options, adapter choices - [Querying](../02-querying.md) — filters, sorts, hydration, search -- [Lexicons](../03-lexicons.md) — generate TS types for your XRPC surface - [Feeds](../04-feeds.md) — personalized timelines via follow + target collections -- [Auth](../05-auth.md) — service-auth JWTs, invite tokens, watch tickets, OAuth permission sets -- [Spaces](../06-spaces.md) / [Communities](../07-communities.md) — private records + group-controlled DIDs, which both slot into the same handler you just mounted -- [Sync](../08-sync.md) — reactive client-side subscriptions (`createWatchStore`) wrapped in Svelte `$state` +- [Labels](../09-labels.md) — moderation label hydration + +Use Atcute directly for Lexicon pulling, validation, and TypeScript generation. ## Common gotchas diff --git a/packages/contrail-appview/CHANGELOG.md b/packages/contrail-appview/CHANGELOG.md deleted file mode 100644 index 004b210..0000000 --- a/packages/contrail-appview/CHANGELOG.md +++ /dev/null @@ -1,247 +0,0 @@ -# @atmo-dev/contrail-appview - -## 0.12.2 - -### Patch Changes - -- 32ace91: Stop accumulating duplicate FTS rows when records are re-applied during backfill. - - The FTS-sync path only deleted an existing FTS row before inserting when the - record was already in `existingMap`. Backfill runs with `skipReplayDetection`, - which leaves `existingMap` empty, so every re-applied record looked brand-new - and appended another FTS row. The FTS virtual table has no uniqueness - constraint, so these accumulated, and the search JOIN fanned each event out into - one result row per duplicate. Make the delete-then-insert unconditional so FTS - sync is idempotent regardless of replay detection. - - The unconditional delete also evicts a stale FTS row when an update clears all - searchable fields: in that case there is no content to re-insert, but the prior - row must still be removed so old terms stop matching. - - - @atmo-dev/contrail-base@0.12.2 - - @atmo-dev/contrail-authority@0.12.2 - - @atmo-dev/contrail-record-host@0.12.2 - -## 0.12.1 - -### Patch Changes - -- 833a659: Stop running the `feed_items` prune sweep on every ingest tick. - - A feed only exceeds its cap right after a feed-mutating record, so the per-tick sweep was a no-op on the vast majority of ticks yet still issued a cutoff `DELETE` per actor (~98% of all D1 queries on one deployment). It now sweeps only when a feed-mutating collection was ingested, plus a recovery pass that becomes due ~6h after the previous one completed and then laps one slice per tick — including on idle persistent streams and the `notifyOfUpdate` path. New `getFeedMutatingNsids(config)` derives the gating set. See `docs/04-feeds.md` for sweep timing (and why the full-pass cadence is interval + lap time, not a hard 6h) and the fan-out promptness trade-off. - -- 74a2d3d: Make NSID-keyed collections work through normal ingestion, not just FTS. - - When a collection is keyed directly by its NSID (no short alias, `collection` - field omitted), the value defaulted to `undefined` everywhere it was read. The - records insert and FTS sync were patched via `resolveCollectionKey`, but the - real ingestion entry points still skipped these collections: `getCollectionNsids` - / `getDiscoverableNsids` / `getDependentNsids` produced `undefined` NSIDs (so - Jetstream never subscribed and backfill never ran), `shortNameForNsid` returned - undefined (so `notify` rejected the URI as "collection not tracked"), and - `validateConfig` rejected the config outright (missing `collection`, dotted key - failing short-name validation). - - `CollectionConfig.collection` is now optional. `resolveConfig` normalizes an - omitted `collection` to the map key, `validateConfig` accepts NSID-keyed entries, - and every collection-list / lookup helper resolves the NSID as `collection ?? key` - so the behavior is correct on both raw and resolved configs. - </content> - -- 9e01ada: Persist the jetstream ingest cursor before the identity-refresh tail in `runIngestCycle`. - - `saveCursor` previously ran after `refreshStaleIdentities`, whose per-DID network calls can run long. If the ingest isolate was aborted (e.g. a scheduled-invocation deadline) before the save, the cursor never advanced and the next cycle re-drained the same jetstream window indefinitely. Records are durably applied before this point, so the cursor is now saved first; identity refresh is idempotent and staleness-driven, so deferring it past the save is safe. - -- 9894787: Stop re-ingesting the last 10s on every cron cycle for single-instance jetstream configs. - - `@atcute/jetstream` rolls the cursor back 10s on the first connect when given an array `url`, to absorb clock skew across a pool of interchangeable instances. Contrail's cron ingestion rebuilds the subscription every cycle, so for a single-instance config that once-per-session rollback fired every cycle and redundantly re-delivered the last 10s of events. A new `jetstreamUrlOption` helper hands a one-element config to `@atcute` as a string (one fixed instance, no skew, no rollback) while leaving real multi-instance pools as an array so their cross-instance rollback is preserved. Applied at both subscription construction sites (cron `ingestEvents` and the persistent daemon). - - The per-cycle reconnect log is now accurate for single-instance configs: a reconnect to one fixed instance no longer claims to "pick a URL at random and roll the cursor back 10s" (it can't). The warning now fires only for multi-instance pools and reports the actual `rolled_back` value; single-instance reconnects log at info level confirming no rollback. - -- Updated dependencies [833a659] -- Updated dependencies [74a2d3d] -- Updated dependencies [9894787] - - @atmo-dev/contrail-base@0.12.1 - - @atmo-dev/contrail-record-host@0.12.1 - - @atmo-dev/contrail-authority@0.12.1 - -## 0.12.0 - -### Minor Changes - -- 6b34d87: Add a first-class `sinks` config option: write-only, post-commit observers of applied records. - - A `Sink` builds derived state (a search index, an audit log, a webhook fan-out) from every record contrail ingests. Each configured sink's `onRecords(events, { phase })` fires inside `applyEvents()` after the DB commit, on **both** the live and backfill paths, receiving one deduplicated `RecordEvent` per record. Failures are isolated — a throwing sink is logged via the configured logger and never blocks ingestion. - - Unlike `realtime.pubsub`, a sink is not a subscriber: it serves no reads, requires no ticket secret, and must see backfilled records (where realtime is intentionally silent). Public records only — space-scoped records publish via the publishing adapter and never reach the fan-out. - - Purely additive: `realtime` and all existing behavior are unchanged. Runs identically on D1 and Postgres (it is an in-process call after commit, not a database-log consumer). - -### Patch Changes - -- 91278c6: fix(ingest): stop the ingest cycle hanging on a quiet or all-filtered Jetstream - - Race `iterator.next()` against the safety timeout and check the exit conditions before each event, so a low-traffic stream no longer blocks until the caller's hard timeout kills the cycle with the batch and cursor unwritten. - -- Updated dependencies [6b34d87] - - @atmo-dev/contrail-base@0.12.0 - - @atmo-dev/contrail-authority@0.12.0 - - @atmo-dev/contrail-record-host@0.12.0 - -## 0.11.0 - -### Minor Changes - -- 1aeee9a: feat(getRecord): resolve a handle in the URI authority - - `<ns>.<collection>.getRecord` now accepts an AT-URI whose authority is a handle - (e.g. `at://alice.bsky.social/<coll>/<rkey>`), not just a DID. The authority is - resolved through the same `resolveActor` the actor-param endpoints - (`listRecords`/`getProfile`/`getFeed`) already use — local-first via the indexed - `identities` table, network only on a miss — so a handle-routed consumer can - hand the URI straight to `getRecord` instead of resolving handle→DID itself. - - Fully backward compatible: a DID authority resolves to itself unchanged, so - existing DID-URI callers are unaffected. Applies to both the public and - per-space (`?spaceUri=`) paths. `getRecord` stays a fast read — no blocking - backfill is added. Unresolvable authority → 400 (matching `listRecords`), - missing record → 404. Parsing now uses atcute's `parseResourceUri`, which - validates the actor / NSID / record-key shapes, so a syntactically invalid - `uri` returns 400 instead of silently 404ing. - - Internal: the hand-rolled `parseAtUri` (notify) is reimplemented over atcute's - `parseCanonicalResourceUri`; its signature is unchanged. - -### Patch Changes - -- @atmo-dev/contrail-base@0.11.0 -- @atmo-dev/contrail-authority@0.11.0 -- @atmo-dev/contrail-record-host@0.11.0 - -## 0.10.0 - -### Minor Changes - -- 89aee1b: perf: gate schema replay on a fingerprint; add opt-in planner-stat maintenance - - Two independent performance fixes found while profiling a D1 consumer. - - **Cold-start schema replay (always on).** `initSchema` ran ~40 base/collection/ - index/fts/feed/spaces DDL statements serially on every `init()` call, with no - gate. Consumers call `init()` once per isolate and Workers isolates recycle - constantly, so the first request to each cold isolate paid ~40 sequential - round-trips to the D1 storage object before any real work. `initSchema` now - records a fingerprint of the resolved schema (hash of the generated DDL + - `CONTRAIL_SCHEMA_VERSION`) in a new `_contrail_meta` table and, on a match, - skips all DDL after a single read. Steady-state cold start drops from ~40 - round-trips to one; the full apply only runs on first init or an actual schema - change. Concurrent-init safety on Postgres is unchanged (the gate just wraps the - existing idempotent apply). - - **Query-planner statistics (opt-in).** Without `ANALYZE`, SQLite's planner picks - the least-selective index for multi-predicate queries (measured ~50x more rows - read on a `subject.uri` + `status` filter). New opt-in config: - - ```ts - maintenance: { - optimize: true; - } // or { intervalMs, analysisLimit } - ``` - - When enabled, the ingest tick runs a CPU-bounded `PRAGMA analysis_limit=400; -PRAGMA optimize` on a persisted daily cadence (stored in `_contrail_meta`, so it - isn't defeated by recycled isolates — the same in-memory-state bug the feed - prune had). `analysis_limit` bounds the work so it can't exceed D1's per-query - CPU budget and reset the DO. Also exposed as `contrail.optimize(db)` for - consumers that prefer to schedule it themselves. No-op on Postgres - (autovacuum/autoanalyze handles planner stats). - -### Patch Changes - -- Updated dependencies [89aee1b] - - @atmo-dev/contrail-base@0.10.0 - - @atmo-dev/contrail-authority@0.10.0 - - @atmo-dev/contrail-record-host@0.10.0 - -## 0.9.1 - -### Patch Changes - -- Updated dependencies [4c8fedb] - - @atmo-dev/contrail-base@0.9.1 - - @atmo-dev/contrail-authority@0.9.1 - - @atmo-dev/contrail-record-host@0.9.1 - -## 0.9.0 - -### Minor Changes - -- 8f0b87e: fix(feeds): make feed_items pruning bounded so it can't reset the D1 DO - - The hourly feed prune ran a single global `ROW_NUMBER() OVER (PARTITION BY actor)` - window + `(actor, uri) NOT IN (...)` anti-join across the entire `feed_items` - table — O(n) CPU in one statement. Once the table grew large this exceeded D1's - per-query CPU limit and reset the shared Durable Object, taking down any - concurrent read on the same SQLite instance (unrelated user requests 500'd with - `was reset` / `Network connection lost`). Because the statement reset before - completing, caps were never enforced, the table kept growing, and the prune got - more expensive — a death spiral. - - Changes: - - - **Bounded per-actor prune.** Pruning is now an index-backed cutoff delete per - `(actor, collection)` using `idx_feed_actor_coll_time`, cost O(cap), never - O(table). New `pruneActorFeed` / `sweepFeedItems` exports; the ingest loops - run one bounded `sweepFeedItems` slice per tick (`FEED_PRUNE_SWEEP_ACTORS` - actors), which also serves as recovery for already-bloated tables. - - **Persisted prune cursor.** A new `feed_prune_cursor` row tracks the rolling - sweep position, so progress survives the cron isolate recycling that - previously made the in-memory hourly gate a no-op (it pruned on essentially - every tick). The time gate is removed from the cron path; the long-lived - persistent loop keeps a short in-memory throttle. - - **API:** `pruneFeedItems(db, caps)` now accepts only the per-collection - `Map<collection, cap>` (the legacy global-number form is removed) and is - reimplemented as a bounded full-table recovery loop — keep it off the hot - path. - - The follow fan-out's `subject` lookup is already covered by `idx_<follow>_subject`, - so no unbounded statement remains in the ingest path. - -### Patch Changes - -- @atmo-dev/contrail-base@0.9.0 -- @atmo-dev/contrail-authority@0.9.0 -- @atmo-dev/contrail-record-host@0.9.0 - -## 0.8.0 - -### Minor Changes - -- d7e0936: Private-network deployment support via a new optional `ContrailConfig.networkOverrides` block. - - `networkOverrides` carries three optional subfields, all defaulting to the current public-internet behavior (omit the block entirely and nothing changes): - - - **`resolver`** — a custom `DidDocumentResolver` used during DID-doc PDS fallback, labeler-endpoint resolution, and spaces service-auth JWT verification. Lets a deployment point at a private PLC mirror or inject a custom fetch (mTLS, retry, instrumentation). Trusted; not SSRF-checked. - - **`slingshotUrl`** — override the slingshot identity-resolver endpoint. Trusted; not SSRF-checked. - - **`additionalAllowedHosts`** — hostnames that bypass the default SSRF guard when validating a resolved PDS or labeler endpoint. Match is exact, case-insensitive, port-agnostic (e.g. `["pds.dev.svc.cluster.local"]`). This is the only knob that widens the validator; there is no "disable SSRF" flag. - - The overrides are threaded through PDS/identity resolution (`resolvePDS`, `getPDS`, `getClient`, `resolveIdentity*`, `refreshStaleIdentities`), labeler endpoint resolution and ingest (`resolveLabelerEndpoint`, `getLabelerState`, label subscribe cycles), and service-auth verification (`buildVerifier` in both the appview router and the community integration). The in-scope `config` is now also passed at every appview call site that resolves identities or PDS endpoints — the live-ingest refresh cycle (`runIngestCycle` → `refreshStaleIdentities`), the on-demand `refresh` path, and the router actor/identity/PDS resolution paths (`getProfile`, `getFeed`, collection queries, profile hydration, notify) — so private-network deploys honor the override on those paths instead of silently falling back to the public resolver and un-widened SSRF guard. - - The SSRF guard is now a single shared validator: `validateExternalUrl(url, additionalAllowedHosts?)` is exported from `contrail-base` and consumed by both the PDS client and labeler-endpoint resolution. `validateEndpointUrl` remains exported as a thin alias for backward compatibility. This removes the previous duplicate validator (`validatePdsUrl` + `validateEndpointUrl`) where an allowlist or SSRF-rule edit could be applied to only one copy. - - Also hardens schema initialization for concurrent/Postgres deployments: a dialect-aware `addColumnIfNotExists` (Postgres `ADD COLUMN IF NOT EXISTS`; SQLite pre-check), narrow absorption of the Postgres concurrent-`CREATE` race (42P07 / 23505 on pg_type/pg_class/pg_namespace indexes), and per-statement (rather than batched) DDL during `initSchema` / `initSpacesSchema` / spaces schema. Genuine DDL errors (syntax, type mismatch, missing column/table) still propagate. - -### Patch Changes - -- Updated dependencies [d7e0936] - - @atmo-dev/contrail-base@0.8.0 - - @atmo-dev/contrail-authority@0.8.0 - - @atmo-dev/contrail-record-host@0.8.0 - -## 0.7.0 - -### Patch Changes - -- @atmo-dev/contrail-base@0.7.0 -- @atmo-dev/contrail-authority@0.7.0 -- @atmo-dev/contrail-record-host@0.7.0 diff --git a/packages/contrail-appview/package.json b/packages/contrail-appview/package.json deleted file mode 100644 index 2c34590..0000000 --- a/packages/contrail-appview/package.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "name": "@atmo-dev/contrail-appview", - "version": "0.12.2", - "description": "Public-records appview for contrail — jetstream ingestion, backfill, query layer, feeds, labels, profiles, per-collection XRPC routes.", - "type": "module", - "sideEffects": false, - "files": [ - "dist" - ], - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "repository": { - "type": "git", - "url": "https://github.com/flo-bit/contrail.git", - "directory": "packages/contrail-appview" - }, - "keywords": [ - "atproto", - "contrail", - "appview", - "jetstream" - ], - "scripts": { - "build": "tsup", - "clean": "rm -rf dist", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@atcute/atproto": "^3.1.10", - "@atcute/cbor": "^2.3.2", - "@atcute/cid": "^2.4.1", - "@atcute/client": "^4.2.1", - "@atcute/identity": "^1.1.4", - "@atcute/identity-resolver": "^1.2.2", - "@atcute/jetstream": "^1.0.2", - "@atcute/lexicons": "^1.2.9", - "@atcute/xrpc-server": "^0.1.12", - "@atmo-dev/contrail-authority": "workspace:*", - "@atmo-dev/contrail-base": "workspace:*", - "@atmo-dev/contrail-record-host": "workspace:*", - "hono": "^4.12.8" - }, - "devDependencies": { - "@types/node": "^25.5.0", - "tsup": "^8.5.0", - "typescript": "^5.7.3" - }, - "license": "MIT" -} diff --git a/packages/contrail-appview/src/core/backfill.ts b/packages/contrail-appview/src/core/backfill.ts deleted file mode 100644 index f53a709..0000000 --- a/packages/contrail-appview/src/core/backfill.ts +++ /dev/null @@ -1,619 +0,0 @@ -import type {} from "@atcute/atproto"; -import { type Did } from "@atcute/lexicons"; -import { isDid, isNsid } from "@atcute/lexicons/syntax"; - -import type { Client } from "@atcute/client"; -import type { ContrailConfig, Database, IngestEvent } from "./types"; -import { - getDiscoverableNsids, - getDependentNsids, - DEFAULT_RELAYS, - shortNameForNsid, -} from "./types"; -import { applyEvents, getLastCursor, saveCursor } from "./db"; -import { getClient, getPDS } from "./client"; - -const DEFAULT_TIME_FIELD = "createdAt"; - -/** Parse the record's canonical time (e.g. createdAt) and return microseconds. - * Falls back to `nowUs` when missing/invalid. Clamps to nowUs to avoid - * user-controlled future timestamps pinning records at the top of feeds. */ -function recordTimeUs( - record: unknown, - collection: string, - config: ContrailConfig | undefined, - nowUs: number -): number { - if (!config) return nowUs; - const short = shortNameForNsid(config, collection); - const colCfg = short ? config.collections[short] : undefined; - const field = colCfg?.timeField ?? DEFAULT_TIME_FIELD; - if (field === false) return nowUs; - const raw = - record && typeof record === "object" - ? (record as Record<string, unknown>)[field] - : undefined; - if (typeof raw !== "string") return nowUs; - const ms = Date.parse(raw); - if (!Number.isFinite(ms) || ms <= 0) return nowUs; - const us = ms * 1000; - return us > nowUs ? nowUs : us; -} - -const PAGE_SIZE = 100; -const BATCH_SIZE = 100; -const MAX_RETRIES = 5; - -const REQUEST_TIMEOUT_MS = 10_000; - -async function withRetry<T>( - fn: () => Promise<T>, - label: string, - maxRetries = 3, - timeoutMs = REQUEST_TIMEOUT_MS -): Promise<T> { - let lastError: unknown; - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - return await Promise.race([ - fn(), - new Promise<never>((_, reject) => - setTimeout(() => reject(new Error(`Timeout: ${label}`)), timeoutMs) - ), - ]); - } catch (err) { - lastError = err; - if (attempt < maxRetries) { - const delay = Math.min(1000 * 2 ** attempt, 10000); - await new Promise((r) => setTimeout(r, delay)); - } - } - } - throw lastError; -} - -/** Drop events whose `subjectField` value is a DID we have no identity for. - * One bulk SELECT per call, suitable for use after each backfill page. */ -async function filterEventsBySubject( - db: Database, - events: IngestEvent[], - subjectField: string -): Promise<IngestEvent[]> { - const subjects = new Set<string>(); - const eventSubjects = new Map<string, string>(); - for (const e of events) { - if (!e.record) continue; - let subj: unknown; - try { - subj = JSON.parse(e.record)?.[subjectField]; - } catch { - continue; - } - if (typeof subj === "string" && isDid(subj)) { - subjects.add(subj); - eventSubjects.set(e.uri, subj); - } - } - if (subjects.size === 0) return []; - - const known = new Set<string>(); - const list = [...subjects]; - const CHUNK = 100; - for (let i = 0; i < list.length; i += CHUNK) { - const chunk = list.slice(i, i + CHUNK); - const placeholders = chunk.map(() => "?").join(","); - const rows = await db - .prepare(`SELECT did FROM identities WHERE did IN (${placeholders})`) - .bind(...chunk) - .all<{ did: string }>(); - for (const r of rows.results ?? []) known.add(r.did); - } - - return events.filter((e) => { - const subj = eventSubjects.get(e.uri); - return subj !== undefined && known.has(subj); - }); -} - -async function markFailed( - db: Database, - did: string, - collection: string, - error: string -): Promise<void> { - await db - .prepare( - "UPDATE backfills SET retries = retries + 1, last_error = ? WHERE did = ? AND collection = ?" - ) - .bind(error, did, collection) - .run(); -} - -export interface BackfillOptions { - /** Pre-resolved client — avoids redundant PDS lookups when batching by DID */ - client?: Client; - /** Skip replay detection in applyEvents (safe during initial backfill) */ - skipReplayDetection?: boolean; - /** Max retries per request (default: 3). Set to 0 for single-attempt mode. */ - maxRetries?: number; - /** Per-request timeout in ms (default: 10000). */ - requestTimeout?: number; -} - -export async function backfillUser( - db: Database, - did: string, - collection: string, - deadline: number, - config?: ContrailConfig, - options?: BackfillOptions -): Promise<number> { - if (Date.now() >= deadline) return 0; - - const status = await db - .prepare( - "SELECT completed, pds_cursor, retries FROM backfills WHERE did = ? AND collection = ?" - ) - .bind(did, collection) - .first<{ completed: number; pds_cursor: string | null; retries: number }>(); - - if (status?.completed) return 0; - - if (!status) { - await db - .prepare( - "INSERT INTO backfills (did, collection, completed) VALUES (?, ?, 0) ON CONFLICT DO NOTHING" - ) - .bind(did, collection) - .run(); - } - - let currentCursor: string | undefined = status?.pds_cursor ?? undefined; - const retries = options?.maxRetries ?? 3; - const timeout = options?.requestTimeout ?? REQUEST_TIMEOUT_MS; - - if (!isDid(did)) { - await markFailed(db, did, collection, `Invalid DID: ${did}`); - return 0; - } - - if (!isNsid(collection)) { - await markFailed(db, did, collection, `Invalid NSID: ${collection}`); - return 0; - } - - let client = options?.client; - if (!client) { - try { - client = await withRetry( - () => getClient(did as Did, db, config), - `getClient(${did})`, - Math.min(retries, 1), - timeout - ); - } catch (err) { - await markFailed(db, did, collection, String(err)); - return 0; - } - } - - let totalInserted = 0; - let done = false; - - // Lookup subject filter once: if this collection declares a subjectField, we - // drop records whose subject DID isn't already in our identities table. - const collectionShort = config - ? shortNameForNsid(config, collection) - : undefined; - const subjectField = collectionShort - ? config?.collections[collectionShort]?.subjectField - : undefined; - - try { - while (Date.now() < deadline) { - const response = await withRetry( - () => - client!.get("com.atproto.repo.listRecords", { - params: { - repo: did as Did, - collection, - limit: PAGE_SIZE, - cursor: currentCursor, - }, - }), - `listRecords(${did}/${collection})`, - retries, - timeout - ); - if (!response.ok) { - await markFailed( - db, - did, - collection, - `listRecords status ${response.status}` - ); - return totalInserted; - } - - if (response.data.records.length === 0) { - done = true; - break; - } - - const now = Date.now(); - const nowUs = now * 1000; - let events: IngestEvent[] = response.data.records.map((r) => ({ - uri: r.uri, - did, - collection, - rkey: r.uri.split("/").pop()!, - operation: "create" as const, - cid: r.cid, - record: JSON.stringify(r.value), - time_us: recordTimeUs(r.value, collection, config, nowUs), - indexed_at: nowUs, - })); - - if (subjectField) { - events = await filterEventsBySubject(db, events, subjectField); - } - - if (events.length > 0) { - await applyEvents(db, events, config, { - skipReplayDetection: options?.skipReplayDetection, - skipFeedFanout: true, - // Realtime pubsub stays off during backfill, but `config.sinks` fire - // so a rebuild repopulates derived indexes. Tagged so sinks can - // bulk-flush differently from live ingest. - phase: "backfill", - }); - } - totalInserted += events.length; - - currentCursor = response.data.cursor ?? undefined; - - await db - .prepare( - "UPDATE backfills SET pds_cursor = ? WHERE did = ? AND collection = ?" - ) - .bind(currentCursor ?? null, did, collection) - .run(); - - if (!currentCursor) { - done = true; - break; - } - } - } catch (err) { - await markFailed(db, did, collection, String(err)); - return totalInserted; - } - - if (done) { - await db - .prepare( - "UPDATE backfills SET completed = 1 WHERE did = ? AND collection = ?" - ) - .bind(did, collection) - .run(); - } - - return totalInserted; -} - -// --- Bulk backfill (groups by DID, resolves client once) --- - -export interface BackfillProgress { - records: number; - usersComplete: number; - usersTotal: number; - usersFailed: number; -} - -export interface BackfillAllOptions { - concurrency?: number; - onProgress?: (progress: BackfillProgress) => void; -} - -export async function backfillPending( - db: Database, - config: ContrailConfig, - options?: BackfillAllOptions -): Promise<number> { - const concurrency = options?.concurrency ?? 100; - let totalBackfilled = 0; - - // Anchor the jetstream cursor to now if it hasn't been set yet, so records - // emitted during backfill are replayed once jetstream starts. - if ((await getLastCursor(db)) === null) { - await saveCursor(db, Date.now() * 1000); - } - - // Reset retries so users that hit the cap in a prior run get another chance. - await db - .prepare("UPDATE backfills SET retries = 0 WHERE completed = 0") - .run(); - - while (true) { - const pending = await db - .prepare( - "SELECT did, collection FROM backfills WHERE completed = 0 AND retries < ? ORDER BY did" - ) - .bind(MAX_RETRIES) - .all<{ did: string; collection: string }>(); - - const rows = pending.results ?? []; - if (rows.length === 0) break; - - // Group by DID so we resolve PDS once per user - const byDid = new Map<string, string[]>(); - for (const row of rows) { - const cols = byDid.get(row.did) ?? []; - cols.push(row.collection); - byDid.set(row.did, cols); - } - - const dids = [...byDid.keys()]; - - // Resolve PDS endpoints up front (populates the in-memory cache) rather - // than concurrently with the backfill passes below. Overlapping the two put - // identity resolution and record backfill in contention for slingshot at - // once, and the partial responses that produced (a PDS without a handle) - // got persisted and stranded. Resolving first keeps that load separate. - for (let i = 0; i < dids.length; i += 200) { - await Promise.allSettled( - dids.slice(i, i + 200).map((did) => - getPDS(did as Did, db, config).catch(() => {}) - ) - ); - } - - let roundBackfilled = 0; - let usersComplete = 0; - let usersFailed = 0; - const failedDids: string[] = []; - - const FAST_TIMEOUT = 3_000; - - const emitProgress = () => - options?.onProgress?.({ - records: totalBackfilled + roundBackfilled, - usersComplete, - usersTotal: dids.length, - usersFailed, - }); - - // Fast pass: single attempt per user with short timeout - for (let i = 0; i < dids.length; i += concurrency) { - const batch = dids.slice(i, i + concurrency); - - const results = await Promise.allSettled( - batch.map(async (did) => { - let client: Client | undefined; - try { - client = await withRetry( - () => getClient(did as Did, db, config), - `getClient(${did})`, - 0, - FAST_TIMEOUT - ); - } catch { - failedDids.push(did); - return 0; - } - - const cols = byDid.get(did)!; - const counts = await Promise.all( - cols.map((col) => - backfillUser(db, did, col, Infinity, config, { - client, - skipReplayDetection: true, - maxRetries: 0, - requestTimeout: FAST_TIMEOUT, - }).catch(() => { - failedDids.push(did); - return 0; - }) - ) - ); - - usersComplete++; - return counts.reduce((a, b) => a + b, 0); - }) - ); - - for (const r of results) { - if (r.status === "fulfilled") roundBackfilled += r.value; - } - - emitProgress(); - } - - // Retry pass: failed DIDs get retries with backoff, still in concurrent batches - if (failedDids.length > 0) { - const uniqueFailed = [...new Set(failedDids)]; - usersComplete -= uniqueFailed.length; // don't count them yet - - for (let i = 0; i < uniqueFailed.length; i += concurrency) { - const batch = uniqueFailed.slice(i, i + concurrency); - - const results = await Promise.allSettled( - batch.map(async (did) => { - let client: Client | undefined; - try { - client = await withRetry( - () => getClient(did as Did, db, config), - `getClient(${did})`, - 2 - ); - } catch (err) { - for (const col of byDid.get(did)!) { - await markFailed(db, did, col, String(err)); - } - usersFailed++; - usersComplete++; - return 0; - } - - const cols = byDid.get(did)!; - const counts = await Promise.all( - cols.map((col) => - backfillUser(db, did, col, Infinity, config, { - client, - skipReplayDetection: true, - maxRetries: 2, - }) - ) - ); - usersComplete++; - return counts.reduce((a, b) => a + b, 0); - }) - ); - - for (const r of results) { - if (r.status === "fulfilled") roundBackfilled += r.value; - } - - emitProgress(); - } - } - - totalBackfilled += roundBackfilled; - - // If nothing was backfilled this round, we're stuck - if (roundBackfilled === 0) break; - } - - return totalBackfilled; -} - -// --- Discovery --- - -interface DiscoveryPage { - repos: { did: string }[]; - cursor?: string; -} - -async function fetchPage( - relay: string, - collection: string, - cursor?: string -): Promise<DiscoveryPage | null> { - const url = new URL( - `/xrpc/com.atproto.sync.listReposByCollection`, - relay - ); - url.searchParams.set("collection", collection); - url.searchParams.set("limit", "1000"); - if (cursor) { - url.searchParams.set("cursor", cursor); - } - - try { - return await withRetry( - async () => { - const response = await fetch(url.toString()); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - return (await response.json()) as DiscoveryPage; - }, - `fetchPage(${relay}, ${collection})` - ); - } catch (err) { - // Discovery page fetch failed after retries — skip this relay - return null; - } -} - -async function insertDiscoveredDIDs( - db: Database, - dids: string[], - collection: string -): Promise<void> { - if (dids.length === 0) return; - - // Use multi-row INSERT to reduce the number of statements - const CHUNK_SIZE = 50; - for (let i = 0; i < dids.length; i += CHUNK_SIZE) { - const chunk = dids.slice(i, i + CHUNK_SIZE); - const placeholders = chunk.map(() => "(?, ?, 0)").join(", "); - const bindings: string[] = []; - for (const did of chunk) { - bindings.push(did, collection); - } - await db - .prepare( - `INSERT INTO backfills (did, collection, completed) VALUES ${placeholders} ON CONFLICT DO NOTHING` - ) - .bind(...bindings) - .run(); - } -} - -async function saveDiscoveryState( - db: Database, - collection: string, - relay: string, - cursor: string | null, - completed: boolean -): Promise<void> { - await db - .prepare( - "INSERT INTO discovery (collection, relay, cursor, completed) VALUES (?, ?, ?, ?) ON CONFLICT(collection, relay) DO UPDATE SET cursor = excluded.cursor, completed = excluded.completed" - ) - .bind(collection, relay, cursor, completed ? 1 : 0) - .run(); -} - -export async function discoverDIDs( - db: Database, - config: ContrailConfig, - deadline: number -): Promise<string[]> { - const collections = getDiscoverableNsids(config); - const relays = config.relays ?? DEFAULT_RELAYS; - if (relays.length === 0 || collections.length === 0) return []; - - const discovered: string[] = []; - - for (const collection of collections) { - if (Date.now() >= deadline) break; - - let data: DiscoveryPage | null = null; - let relay: string | null = null; - - for (const r of relays) { - const row = await db - .prepare( - "SELECT cursor, completed FROM discovery WHERE collection = ? AND relay = ?" - ) - .bind(collection, r) - .first<{ cursor: string | null; completed: number }>(); - - if (row?.completed) continue; - - data = await fetchPage(r, collection, row?.cursor ?? undefined); - if (data) { - relay = r; - break; - } else { - await saveDiscoveryState(db, collection, r, null, true); - } - } - if (!data || !relay) continue; - - const dids = data.repos?.map((r) => r.did) ?? []; - await insertDiscoveredDIDs(db, dids, collection); - discovered.push(...dids); - - for (const depCollection of getDependentNsids(config)) { - await insertDiscoveredDIDs(db, dids, depCollection); - } - - const completed = !data.cursor; - await saveDiscoveryState(db, collection, relay, data.cursor ?? null, completed); - } - - return discovered; -} diff --git a/packages/contrail-appview/src/core/client.ts b/packages/contrail-appview/src/core/client.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/client.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/community-integration.ts b/packages/contrail-appview/src/core/community-integration.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/community-integration.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/constellation.ts b/packages/contrail-appview/src/core/constellation.ts deleted file mode 100644 index 5812685..0000000 --- a/packages/contrail-appview/src/core/constellation.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { isDid } from "@atcute/lexicons/syntax"; -import type { ContrailConfig, Database, Logger } from "./types"; -import { - DEFAULT_CONSTELLATION_URL, - DEFAULT_FOLLOW_NSID, - recordsTableName, - shortNameForNsid, -} from "./types"; - -const PAGE_LIMIT = 100; -const DID_FILTER_CHUNK = 50; - -interface BacklinksPage { - links?: Array<{ - did?: string; - rkey?: string; - /** Some Constellation versions return the full URI rather than did/rkey split. */ - uri?: string; - }>; - cursor?: string; -} - -function getLogger(config: ContrailConfig): Logger { - return config.logger ?? console; -} - -/** Resolve effective Constellation config; null when disabled. */ -function getConstellationSettings( - config: ContrailConfig -): { url: string; userAgent: string } | null { - const c = config.constellation; - if (c === false) return null; - if (c?.enabled === false) return null; - return { - url: c?.url ?? DEFAULT_CONSTELLATION_URL, - userAgent: c?.userAgent ?? `contrail/${config.namespace}`, - }; -} - -/** Find the configured short name for `app.bsky.graph.follow`, if any. */ -function getFollowShort(config: ContrailConfig): string | null { - const short = shortNameForNsid(config, DEFAULT_FOLLOW_NSID); - return short ?? null; -} - -interface BacklinkRow { - did: string; - rkey: string; - uri: string; -} - -function parseBacklink(entry: NonNullable<BacklinksPage["links"]>[number]): BacklinkRow | null { - if (entry.did && entry.rkey && isDid(entry.did)) { - return { - did: entry.did, - rkey: entry.rkey, - uri: entry.uri ?? `at://${entry.did}/${DEFAULT_FOLLOW_NSID}/${entry.rkey}`, - }; - } - if (entry.uri) { - const m = /^at:\/\/(did:[^/]+)\/[^/]+\/([^/]+)$/.exec(entry.uri); - if (m && isDid(m[1])) { - return { did: m[1], rkey: m[2], uri: entry.uri }; - } - } - return null; -} - -/** Filter a candidate-follower DID list to those already in our identities table. */ -async function filterKnownDids( - db: Database, - candidates: string[] -): Promise<Set<string>> { - const known = new Set<string>(); - for (let i = 0; i < candidates.length; i += DID_FILTER_CHUNK) { - const chunk = candidates.slice(i, i + DID_FILTER_CHUNK); - const placeholders = chunk.map(() => "?").join(","); - const rows = await db - .prepare(`SELECT did FROM identities WHERE did IN (${placeholders})`) - .bind(...chunk) - .all<{ did: string }>(); - for (const r of rows.results ?? []) known.add(r.did); - } - return known; -} - -/** Fetch one page of getBacklinks. Returns null on non-2xx (caller decides whether to bail). */ -async function fetchBacklinksPage( - url: string, - userAgent: string, - subject: string, - cursor?: string -): Promise<BacklinksPage | null> { - const u = new URL("/xrpc/blue.microcosm.links.getBacklinks", url); - u.searchParams.set("subject", subject); - u.searchParams.set("source", `${DEFAULT_FOLLOW_NSID}:.subject`); - u.searchParams.set("limit", String(PAGE_LIMIT)); - if (cursor) u.searchParams.set("cursor", cursor); - try { - const res = await fetch(u.toString(), { - headers: { "user-agent": userAgent, accept: "application/json" }, - }); - if (!res.ok) return null; - return (await res.json()) as BacklinksPage; - } catch { - return null; - } -} - -/** For a newly-known subject DID, find existing followers via Constellation - * and ingest synthesized follow records into the configured follow table. - * Best-effort: failures are logged but not retried (caller can re-trigger). */ -export async function backfillFollowersFromConstellation( - db: Database, - config: ContrailConfig, - subjectDid: string -): Promise<number> { - const settings = getConstellationSettings(config); - if (!settings) return 0; - if (!isDid(subjectDid)) return 0; - const followShort = getFollowShort(config); - if (!followShort) return 0; - const log = getLogger(config); - - const followTable = recordsTableName(followShort); - const recordJson = JSON.stringify({ - $type: DEFAULT_FOLLOW_NSID, - subject: subjectDid, - createdAt: new Date().toISOString(), - }); - const nowUs = Date.now() * 1000; - - let cursor: string | undefined; - let inserted = 0; - let pages = 0; - - while (true) { - const page = await fetchBacklinksPage( - settings.url, - settings.userAgent, - subjectDid, - cursor - ); - if (!page) break; - pages++; - - const rows: BacklinkRow[] = (page.links ?? []) - .map(parseBacklink) - .filter((r): r is BacklinkRow => r !== null && r.did !== subjectDid); - - if (rows.length > 0) { - const known = await filterKnownDids( - db, - rows.map((r) => r.did) - ); - const survivors = rows.filter((r) => known.has(r.did)); - - for (const r of survivors) { - const result = await db - .prepare( - `INSERT INTO ${followTable} (uri, did, rkey, cid, record, time_us, indexed_at) - VALUES (?, ?, ?, NULL, ?, ?, ?) - ON CONFLICT(uri) DO NOTHING` - ) - .bind(r.uri, r.did, r.rkey, recordJson, nowUs, nowUs) - .run(); - inserted += (result as { changes?: number })?.changes ?? 0; - } - } - - cursor = page.cursor ?? undefined; - if (!cursor) break; - } - - if (inserted > 0) { - log.log( - `[constellation] subject=${subjectDid} pages=${pages} inserted=${inserted}` - ); - } - return inserted; -} diff --git a/packages/contrail-appview/src/core/db/index.ts b/packages/contrail-appview/src/core/db/index.ts deleted file mode 100644 index 544ade3..0000000 --- a/packages/contrail-appview/src/core/db/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { initSchema, CONTRAIL_SCHEMA_VERSION } from "./schema"; -export { getMeta, setMeta, getMetaNumber } from "./meta"; -export { optimizeDatabase } from "./optimize"; -export { getLastCursor, saveCursor, applyEvents, lookupExistingRecords, queryRecords, queryAcrossSources, pruneFeedItems, pruneActorFeed, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor } from "./records"; -export type { QueryOptions, SortOption, ExistingRecordInfo, FeedSweepResult } from "./records"; -export type { RecordSource } from "../types"; diff --git a/packages/contrail-appview/src/core/db/records.ts b/packages/contrail-appview/src/core/db/records.ts deleted file mode 100644 index 933df81..0000000 --- a/packages/contrail-appview/src/core/db/records.ts +++ /dev/null @@ -1,1092 +0,0 @@ -import type { - ContrailConfig, - ResolvedContrailConfig, - RelationConfig, - Database, - Statement, - IngestEvent, - RecordRow, - RecordSource, - RecordEvent, -} from "../types"; -import { - getNestedValue, - getRelationField, - countColumnName, - groupedCountColumnName, - getFeedFollowShortNames, - recordsTableName, - spacesRecordsTableName, - shortNameForNsid, - resolveCollectionKey, - nsidForShortName, - normalizeFeedTarget, - feedTargetMaxItems, - DEFAULT_FOLLOW_SHORT, -} from "../types"; -import { getSearchableFields, ftsTableName, buildFtsContent } from "../search"; -import { ftsQueryClause, getDialect } from "../dialect"; - -// --- Counts --- - -interface InboundRelation { - /** Short name of the parent collection. */ - parentCollection: string; - relationName: string; - rel: RelationConfig; -} - -/** Find relations that target the given short-named child collection. */ -function getInboundRelations( - config: ContrailConfig, - childShortName: string -): InboundRelation[] { - const results: InboundRelation[] = []; - for (const [colName, colConfig] of Object.entries(config.collections)) { - for (const [relName, rel] of Object.entries(colConfig.relations ?? {})) { - if (rel.collection === childShortName) { - results.push({ parentCollection: colName, relationName: relName, rel }); - } - } - } - return results; -} - -/** - * Collect recount targets from a single event into a shared map. - * The map is keyed by `parentCollection:relationName:targetValue` to deduplicate - * across the entire batch — so 50 RSVPs to the same event produce one recount, not 50. - */ -function collectCountTargets( - event: IngestEvent, - config: ContrailConfig, - existingRecordJson: string | null, - targets: Map<string, { parentCollection: string; relationName: string; rel: RelationConfig; targetValue: string }> -): void { - const childShort = shortNameForNsid(config, event.collection); - if (!childShort) return; - const inbound = getInboundRelations(config, childShort); - if (inbound.length === 0) return; - - const record = event.record ? JSON.parse(event.record) : null; - const existingRecord = existingRecordJson ? JSON.parse(existingRecordJson) : null; - - for (const { parentCollection, relationName, rel } of inbound) { - if (rel.count === false) continue; - - const field = getRelationField(rel); - - const values: string[] = []; - if (record) { - const t = getNestedValue(record, field); - if (t) values.push(t); - } - if (existingRecord) { - const t = getNestedValue(existingRecord, field); - if (t && !values.includes(t)) values.push(t); - } - - for (const targetValue of values) { - const key = `${parentCollection}:${relationName}:${targetValue}`; - if (!targets.has(key)) { - targets.set(key, { parentCollection, relationName, rel, targetValue }); - } - } - } -} - -/** - * Build deduplicated count UPDATE statements from collected targets. - * One UPDATE per unique parent+relation+target, regardless of how many - * events in the batch affected that target. - */ -function buildBatchCountStatements( - db: Database, - config: ContrailConfig, - targets: Map<string, { parentCollection: string; relationName: string; rel: RelationConfig; targetValue: string }> -): Statement[] { - const statements: Statement[] = []; - - for (const { parentCollection, relationName, rel, targetValue } of targets.values()) { - const field = getRelationField(rel); - const matchColumn = rel.match === "did" ? "did" : "uri"; - const childTable = recordsTableName(rel.collection); - const parentTable = recordsTableName(parentCollection); - - const setClauses: string[] = []; - const setBindings: (string | number)[] = []; - - const countExpr = rel.countDistinct - ? `COUNT(DISTINCT ${rel.countDistinct})` - : "COUNT(*)"; - - // Total count - const totalCol = countColumnName(rel.collection); - setClauses.push( - `${totalCol} = (SELECT ${countExpr} FROM ${childTable} WHERE ${getDialect(db).jsonExtract('record', field)} = ?)` - ); - setBindings.push(targetValue); - - // Grouped counts — column names are `count_<child-short>_<group-key>`; match - // against the group's full token value in the record. - if (rel.groupBy) { - const mapping = (config as ResolvedContrailConfig)._resolved?.relations[parentCollection]?.[relationName]; - if (mapping?.groups) { - for (const [groupKey, fullToken] of Object.entries(mapping.groups)) { - const groupCol = groupedCountColumnName(rel.collection, groupKey); - setClauses.push( - `${groupCol} = (SELECT ${countExpr} FROM ${childTable} WHERE ${getDialect(db).jsonExtract('record', field)} = ? AND ${getDialect(db).jsonExtract('record', rel.groupBy)} = ?)` - ); - setBindings.push(targetValue, fullToken); - } - } - } - - if (setClauses.length > 0) { - statements.push( - db - .prepare( - `UPDATE ${parentTable} SET ${setClauses.join(", ")} WHERE ${matchColumn} = ?` - ) - .bind(...setBindings, targetValue) - ); - } - } - - return statements; -} - -// --- FTS --- - -function buildFtsStatements( - db: Database, - event: IngestEvent, - config: ContrailConfig -): Statement[] { - // PostgreSQL: tsvector generated column is auto-maintained, no manual FTS sync - if (getDialect(db).ftsStrategy === "generated-column") return []; - - const short = resolveCollectionKey(config, event.collection); - if (!short) return []; - const colConfig = config.collections[short]; - if (!colConfig) return []; - - const fields = getSearchableFields(short, colConfig); - if (!fields || fields.length === 0) return []; - - const table = ftsTableName(short); - const stmts: Statement[] = []; - - if (event.operation === "delete") { - stmts.push(db.prepare(`DELETE FROM ${table} WHERE uri = ?`).bind(event.uri)); - } else { - const record = event.record ? JSON.parse(event.record) : null; - if (!record) return []; - - // Always delete first so FTS sync is idempotent. The FTS virtual table has no - // uniqueness constraint, so a bare insert appends a duplicate row when one - // already exists. existingMap is unreliable here: backfill runs with - // skipReplayDetection, leaving it empty, so a re-applied record would look new - // and accumulate duplicate rows that fan out the search JOIN. The delete is - // unconditional so it also evicts a stale row when an update clears all - // searchable fields (content is null); only the re-insert is gated on content. - stmts.push(db.prepare(`DELETE FROM ${table} WHERE uri = ?`).bind(event.uri)); - - const content = buildFtsContent(record, fields); - if (content) { - stmts.push( - db.prepare(`INSERT INTO ${table} (uri, content) VALUES (?, ?)`).bind(event.uri, content) - ); - } - } - - return stmts; -} - -// --- Feeds --- - -function buildFeedStatements( - db: Database, - event: IngestEvent, - config: ContrailConfig, - existingRecords: Map<string, string | null> -): Statement[] { - if (!config.feeds) return []; - - const stmts: Statement[] = []; - - const eventShort = shortNameForNsid(config, event.collection); - if (!eventShort) return []; - - for (const [, feedConfig] of Object.entries(config.feeds)) { - const followShort = feedConfig.follow ?? DEFAULT_FOLLOW_SHORT; - const followTable = recordsTableName(followShort); - const targets = feedConfig.targets.map(normalizeFeedTarget); - const targetShorts = targets.map((t) => t.collection); - - // Target collection: fan out to followers - if (targetShorts.includes(eventShort)) { - if (event.operation === "create" || event.operation === "update") { - stmts.push( - db - .prepare( - getDialect(db).insertOrIgnore( - `INSERT INTO feed_items (actor, uri, collection, time_us) - SELECT r.did, ?, ?, ? - FROM ${followTable} r - WHERE ${getDialect(db).jsonExtract('r.record', 'subject')} = ?` - ) - ) - .bind(event.uri, event.collection, event.time_us, event.did) - ); - } else if (event.operation === "delete") { - stmts.push( - db.prepare("DELETE FROM feed_items WHERE uri = ?").bind(event.uri) - ); - } - } - - // Follow collection: handle follow/unfollow - if (eventShort === followShort) { - if (event.operation === "create") { - const record = event.record ? JSON.parse(event.record) : null; - const subject = record?.subject; - if (subject) { - for (const target of targets) { - const targetTable = recordsTableName(target.collection); - const targetNsid = nsidForShortName(config, target.collection) ?? target.collection; - const cap = feedTargetMaxItems(feedConfig, target); - stmts.push( - db - .prepare( - getDialect(db).insertOrIgnore( - `INSERT INTO feed_items (actor, uri, collection, time_us) - SELECT ?, r.uri, ?, r.time_us - FROM ${targetTable} r - WHERE r.did = ? - ORDER BY r.time_us DESC - LIMIT ${cap}` - ) - ) - .bind(event.did, targetNsid, subject) - ); - } - } - } else if (event.operation === "delete") { - const existingRecord = existingRecords.get(event.uri); - if (existingRecord) { - const parsed = JSON.parse(existingRecord); - const subject = parsed?.subject; - if (subject) { - for (const target of targets) { - const targetTable = recordsTableName(target.collection); - stmts.push( - db - .prepare( - `DELETE FROM feed_items WHERE actor = ? AND uri IN ( - SELECT uri FROM ${targetTable} WHERE did = ? - )` - ) - .bind(event.did, subject) - ); - } - } - } - } - } - } - - return stmts; -} - -// --- Feed pruning --- - -/** db.batch chunk size for the sweep — caps statements per transaction. */ -const SWEEP_BATCH_SIZE = 50; -/** Actor page size for the full-table {@link pruneFeedItems} recovery loop. */ -const FEED_PRUNE_RECOVERY_BATCH = 200; - -/** - * Build the bounded per-actor cutoff DELETE for one (actor, collection). - * - * Deletes everything older than the newest `cap` rows, driven directly by - * idx_feed_actor_coll_time(actor, collection, time_us DESC). Cost is - * O(cap + deleted) — never O(table). This is the ONLY prune shape contrail - * issues: an unbounded window/anti-join over the whole table can exhaust D1's - * per-query CPU budget and reset the shared Durable Object, which kills any - * concurrent read against the same SQLite instance. - * - * The cutoff is the cap-th newest row (`OFFSET cap - 1`); we delete strictly - * older rows. Actors with `cap` or fewer rows: the OFFSET subquery yields no - * row, the cutoff is NULL, and `time_us < NULL` matches nothing — a cheap - * index no-op. On a tie at the cutoff time_us we keep the extra rows rather - * than risk deleting a row we meant to keep (feed_items is a cache; a few over - * cap is harmless, dropping a wanted item is not). - */ -function actorCutoffDelete( - db: Database, - actor: string, - collection: string, - cap: number -): Statement { - // Plain `?` placeholders (bound repeatedly) rather than numbered params, so - // the Postgres adapter's positional `?`→`$n` rewrite stays correct. - return db - .prepare( - `DELETE FROM feed_items - WHERE actor = ? AND collection = ? - AND time_us < ( - SELECT time_us FROM feed_items - WHERE actor = ? AND collection = ? - ORDER BY time_us DESC LIMIT 1 OFFSET ? - )` - ) - .bind(actor, collection, actor, collection, Math.max(0, cap - 1)); -} - -/** Prune a single actor's feed for one collection to `cap`. Bounded O(cap). */ -export async function pruneActorFeed( - db: Database, - actor: string, - collection: string, - cap: number -): Promise<number> { - const result = await actorCutoffDelete(db, actor, collection, cap).run(); - return (result as any)?.changes ?? 0; -} - -export interface FeedSweepResult { - /** Rows deleted this slice. */ - pruned: number; - /** Actor to resume after; null once a full pass completed (wrap to start). */ - nextCursor: string | null; - /** True when this slice reached the end of the actor list. */ - done: boolean; -} - -/** - * One bounded slice of a rolling feed-items prune. - * - * Pages at most `actorBudget` distinct actors (resuming after `cursor`, via the - * feed_items (actor, uri) PK) and applies the per-(actor, collection) cutoff - * delete for every cap in `caps`. Every issued statement is index-backed and - * O(cap), so the slice's per-query CPU stays flat no matter how large - * feed_items grows — the property the old global window query lacked. - * - * Drive it across ticks with a persisted cursor (see getFeedPruneCursor): - * feed back `nextCursor` until `done`, at which point the cursor wraps to null - * and the next pass starts from the beginning. Because each pass visits every - * actor, this doubles as the recovery path for an already-bloated table. - */ -export async function sweepFeedItems( - db: Database, - caps: Map<string, number>, - cursor: string | null, - actorBudget: number -): Promise<FeedSweepResult> { - if (caps.size === 0 || actorBudget <= 0) { - return { pruned: 0, nextCursor: null, done: true }; - } - - const actorsRes = cursor - ? await db - .prepare( - "SELECT DISTINCT actor FROM feed_items WHERE actor > ? ORDER BY actor LIMIT ?" - ) - .bind(cursor, actorBudget) - .all<{ actor: string }>() - : await db - .prepare("SELECT DISTINCT actor FROM feed_items ORDER BY actor LIMIT ?") - .bind(actorBudget) - .all<{ actor: string }>(); - - const actors = (actorsRes.results ?? []).map((r) => r.actor); - if (actors.length === 0) { - // Ran off the end (cursor pointed past the last actor) — wrap next tick. - return { pruned: 0, nextCursor: null, done: true }; - } - - const stmts: Statement[] = []; - for (const actor of actors) { - for (const [collection, cap] of caps) { - stmts.push(actorCutoffDelete(db, actor, collection, cap)); - } - } - - let pruned = 0; - for (let i = 0; i < stmts.length; i += SWEEP_BATCH_SIZE) { - const results = await db.batch(stmts.slice(i, i + SWEEP_BATCH_SIZE)); - for (const r of results) pruned += (r as any)?.changes ?? 0; - } - - // A short page means we exhausted the actor list this slice. - const done = actors.length < actorBudget; - return { pruned, nextCursor: done ? null : actors[actors.length - 1], done }; -} - -/** - * Prune the ENTIRE feed_items table to the per-collection `caps` by looping the - * bounded {@link sweepFeedItems} until a full pass completes. - * - * Every statement is O(cap) and safe against D1's per-query CPU limit, but the - * statement count is O(distinct actors), so keep this OFF the hot ingest path — - * the cron/persistent loops issue a single bounded slice per tick instead. Use - * it for one-shot recovery or admin tooling. - */ -export async function pruneFeedItems( - db: Database, - caps: Map<string, number> -): Promise<number> { - let total = 0; - let cursor: string | null = null; - for (;;) { - const res = await sweepFeedItems(db, caps, cursor, FEED_PRUNE_RECOVERY_BATCH); - total += res.pruned; - if (res.done) break; - cursor = res.nextCursor; - } - return total; -} - -// --- Feed prune cursor --- - -/** Last actor swept by the rolling feed prune; null = start of a fresh pass. */ -export async function getFeedPruneCursor(db: Database): Promise<string | null> { - const row = await db - .prepare("SELECT actor FROM feed_prune_cursor WHERE id = 1") - .first<{ actor: string | null }>(); - return row?.actor ?? null; -} - -export async function saveFeedPruneCursor( - db: Database, - actor: string | null -): Promise<void> { - await db - .prepare( - "INSERT INTO feed_prune_cursor (id, actor) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET actor = excluded.actor" - ) - .bind(actor) - .run(); -} - -// --- Cursor --- - -export async function getLastCursor(db: Database): Promise<number | null> { - const row = await db - .prepare("SELECT time_us FROM cursor WHERE id = 1") - .first<{ time_us: number }>(); - return row ? row.time_us : null; -} - -export async function saveCursor( - db: Database, - timeUs: number -): Promise<void> { - await db - .prepare( - "INSERT INTO cursor (id, time_us) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET time_us = excluded.time_us" - ) - .bind(timeUs) - .run(); -} - -// --- Existing record lookup --- - -export interface ExistingRecordInfo { - cid: string | null; - record: string | null; - /** When the row was last written to our DB (microseconds). Populated - * whenever `lookupExistingRecords` runs, regardless of `includeRecord`. */ - indexed_at: number | null; -} - -/** - * Look up existing records for a set of events, grouped by collection. - * Returns a map of uri → { cid, record }. - * When includeRecord is false, record will always be null (saves reading large blobs). - */ -export async function lookupExistingRecords( - db: Database, - events: { uri: string; collection: string }[], - includeRecord: boolean = true, - config?: ContrailConfig -): Promise<Map<string, ExistingRecordInfo>> { - const result = new Map<string, ExistingRecordInfo>(); - if (events.length === 0) return result; - - // Group by short name (config lookup); skip events for collections not in our config. - const byShort = new Map<string, string[]>(); - for (const e of events) { - const short = config ? resolveCollectionKey(config, e.collection) : e.collection; - if (!short) continue; - const uris = byShort.get(short) ?? []; - uris.push(e.uri); - byShort.set(short, uris); - } - - const selectCols = includeRecord ? "uri, cid, record, indexed_at" : "uri, cid, indexed_at"; - for (const [short, uris] of byShort) { - const table = recordsTableName(short); - for (let i = 0; i < uris.length; i += 50) { - const chunk = uris.slice(i, i + 50); - const placeholders = chunk.map(() => "?").join(","); - const rows = await db - .prepare(`SELECT ${selectCols} FROM ${table} WHERE uri IN (${placeholders})`) - .bind(...chunk) - .all<{ - uri: string; - cid: string | null; - record?: string | null; - indexed_at: number | null; - }>(); - for (const row of rows.results ?? []) { - result.set(row.uri, { - cid: row.cid, - record: includeRecord ? (row.record ?? null) : null, - indexed_at: row.indexed_at ?? null, - }); - } - } - } - - return result; -} - -// --- Events --- - -export async function applyEvents( - db: Database, - events: IngestEvent[], - config?: ContrailConfig, - options?: { - skipReplayDetection?: boolean; - skipFeedFanout?: boolean; - /** Pre-fetched existing records — skips the internal lookup when provided */ - existing?: Map<string, ExistingRecordInfo>; - /** When provided, publish `collection:<nsid>` and `actor:<did>` realtime - * events for each applied event. Space-scoped publishing happens elsewhere - * (see `realtime/publishing-adapter.ts`); public topics carry public - * records only, which is exactly the scope of this function. */ - pubsub?: import("../realtime/types").PubSub; - /** Ingest phase forwarded to `config.sinks`. `"live"` for jetstream / - * persistent ingest (default), `"backfill"` for replay / rebuild. */ - phase?: "live" | "backfill"; - } -): Promise<void> { - if (events.length === 0) return; - - const followCollections = config ? getFeedFollowShortNames(config) : []; - const hasCountingRelations = config ? Object.values(config.collections).some(c => - Object.values(c.relations ?? {}).some(r => r.count !== false) - ) : false; - const needRecordContent = followCollections.length > 0 || hasCountingRelations; - - // Use pre-fetched data or look up existing records - let existingMap: Map<string, ExistingRecordInfo>; - if (options?.existing) { - existingMap = options.existing; - } else if (config && !options?.skipReplayDetection) { - existingMap = await lookupExistingRecords(db, events, needRecordContent, config); - } else { - existingMap = new Map(); - } - - const batch: Statement[] = []; - - // Build a record-content map for feed statements (needs string values) - const existingRecordStrings = new Map<string, string | null>(); - for (const [uri, info] of existingMap) { - existingRecordStrings.set(uri, info.record); - } - - // Collect all count recount targets across the batch, deduplicated - const countTargets = new Map<string, { parentCollection: string; relationName: string; rel: RelationConfig; targetValue: string }>(); - - for (const e of events) { - // Event's collection is an NSID. Resolve its storage key from config. - // If no config, treat collection string as-is (for tests that pre-populate tables). - const short = config ? resolveCollectionKey(config, e.collection) : e.collection; - if (!short) { - (config?.logger ?? console).warn( - `[ingest] drop (unknown collection in applyEvents): ${e.operation} ${e.uri} collection=${e.collection}` - ); - continue; - } - const table = recordsTableName(short); - - if (e.operation === "delete") { - batch.push(db.prepare(`DELETE FROM ${table} WHERE uri = ?`).bind(e.uri)); - } else { - batch.push( - db.prepare( - `INSERT INTO ${table} (uri, did, rkey, cid, record, time_us, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(uri) DO UPDATE SET cid = excluded.cid, record = excluded.record, time_us = excluded.time_us, indexed_at = excluded.indexed_at` - ).bind( - e.uri, - e.did, - e.rkey, - e.cid, - e.record, - e.time_us, - e.indexed_at - ) - ); - } - - if (config) { - // Collect count targets (deduplicated across the whole batch) - const existingRecordJson = existingMap.get(e.uri)?.record ?? null; - collectCountTargets(e, config, existingRecordJson, countTargets); - - // Feed fanout still needs replay detection - const existingInfo = existingMap.get(e.uri); - const isReplay = - e.operation === "delete" - ? existingInfo === undefined - : existingInfo?.cid === e.cid; - - if (!isReplay && !options?.skipFeedFanout) { - batch.push(...buildFeedStatements(db, e, config, existingRecordStrings)); - } - batch.push(...buildFtsStatements(db, e, config)); - } - } - - // Build deduplicated count statements — one UPDATE per unique target - if (config) { - batch.push(...buildBatchCountStatements(db, config, countTargets)); - } - - await db.batch(batch); - - // Publish realtime events for public records (collection: and actor:). - // Space records publish via the wrapping adapter; this path is public-only. - if (options?.pubsub) { - const pubsub = options.pubsub; - const ts = Date.now(); - for (const e of events) { - if (e.operation === "delete") { - const payload = { - uri: e.uri, - did: e.did, - collection: e.collection, - rkey: e.rkey, - }; - await pubsub.publish({ topic: `collection:${e.collection}`, kind: "record.deleted", payload, ts }); - await pubsub.publish({ topic: `actor:${e.did}`, kind: "record.deleted", payload, ts }); - } else { - const record = e.record ? safeParseJson(e.record) : {}; - const payload = { - uri: e.uri, - did: e.did, - collection: e.collection, - rkey: e.rkey, - cid: e.cid, - record, - time_us: e.time_us, - }; - await pubsub.publish({ topic: `collection:${e.collection}`, kind: "record.created", payload, ts }); - await pubsub.publish({ topic: `actor:${e.did}`, kind: "record.created", payload, ts }); - } - } - } - - // Fan out to write-only sinks (derived indexes, audit logs, webhooks). - // Unlike the realtime pubsub above this fires on BOTH the live and backfill - // paths (driven by `options.phase`), carries one deduplicated event per - // record, and isolates failures so a throwing sink never blocks ingestion. - const sinks = config?.sinks; - if (sinks && sinks.length > 0) { - const records: RecordEvent[] = events.map((e) => - e.operation === "delete" - ? { kind: "deleted", uri: e.uri, did: e.did, collection: e.collection, rkey: e.rkey } - : { - kind: "created", - uri: e.uri, - did: e.did, - collection: e.collection, - rkey: e.rkey, - cid: e.cid, - record: e.record ? safeParseJson(e.record) : {}, - time_us: e.time_us, - } - ); - const ctx = { phase: options?.phase ?? "live" } as const; - const logger = config?.logger ?? console; - for (const sink of sinks) { - try { - await sink.onRecords(records, ctx); - } catch (err) { - logger.error("[sink] onRecords failed", err); - } - } - } -} - -function safeParseJson(s: string): Record<string, unknown> { - try { - const v = JSON.parse(s); - return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {}; - } catch { - return {}; - } -} - -// --- Count columns --- - -/** Count column descriptor. `type` is the identifier returned in API responses and - * accepted in countFilters — we keep the full record token for grouped counts so - * callers pass e.g. "community.lexicon.calendar.rsvp#going" and filter/hydrate by it. */ -function getCountColumns( - config: ContrailConfig, - shortName: string -): { type: string; column: string }[] { - const colConfig = config.collections[shortName]; - if (!colConfig?.relations) return []; - const columns: { type: string; column: string }[] = []; - const relMap = (config as ResolvedContrailConfig)._resolved?.relations[shortName] ?? {}; - - for (const [relName, rel] of Object.entries(colConfig.relations)) { - if (rel.count === false) continue; - // Total: identifier is the child's short name; column is `count_<child-short>`. - columns.push({ type: rel.collection, column: countColumnName(rel.collection) }); - const mapping = relMap[relName]; - if (mapping) { - for (const [groupKey, fullToken] of Object.entries(mapping.groups)) { - // Grouped: identifier is the full record token (stable across deployments); - // column is `count_<child-short>_<group-key>`. - columns.push({ - type: fullToken, - column: groupedCountColumnName(rel.collection, groupKey), - }); - } - } - } - return columns; -} - -/** For a given "count type" (short name or full group token), return the DB column. */ -function countColumnForType( - config: ContrailConfig, - shortName: string, - type: string -): string | null { - for (const col of getCountColumns(config, shortName)) { - if (col.type === type) return col.column; - } - return null; -} - -// --- Query --- - -export interface SortOption { - recordField?: string; - countType?: string; - direction: "asc" | "desc"; -} - -/** Opaque keyset cursor. `t` is the tiebreaker (time_us of the last row), - * `v` is the sort-key value (string for record fields, number for counts), - * `k` identifies the sort so we can reject mismatched cursors. */ -interface CursorPayload { - t: number; - v?: string | number; - k: "time" | string; // "time" | `field:<name>` | `count:<type>` -} - -function sortKind(sort?: SortOption): "time" | string { - if (sort?.recordField) return `field:${sort.recordField}`; - if (sort?.countType) return `count:${sort.countType}`; - return "time"; -} - -function encodeCursor(payload: CursorPayload): string { - return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); -} - -function decodeCursor(cursor: string): CursorPayload | null { - try { - const json = Buffer.from(cursor, "base64url").toString("utf8"); - const p = JSON.parse(json); - if (typeof p?.t !== "number" || typeof p?.k !== "string") return null; - return p as CursorPayload; - } catch { - return null; - } -} - -export interface QueryOptions { - collection: string; - did?: string; - limit?: number; - cursor?: string; - filters?: Record<string, string>; - rangeFilters?: Record<string, { min?: string; max?: string }>; - countFilters?: Record<string, number>; - sort?: SortOption; - search?: string; - source?: RecordSource; - /** When set, query the per-space table (`spaces_records_<short>`) instead of the - * public `records_<short>` table, scoped to rows where `space_uri = ?`. */ - spaceUri?: string; -} - -export async function queryRecords( - db: Database, - config: ContrailConfig, - options: QueryOptions -): Promise<{ records: (RecordRow & { counts?: Record<string, number> })[]; cursor?: string }> { - const { - collection: collectionInput, - did, - limit: rawLimit, - cursor, - filters = {}, - rangeFilters = {}, - countFilters = {}, - sort, - search, - source, - spaceUri, - } = options; - - // Accept either the short name (canonical) or the full NSID for convenience. - const collection = - config.collections[collectionInput] - ? collectionInput - : shortNameForNsid(config, collectionInput) ?? collectionInput; - - const table = spaceUri ? spacesRecordsTableName(collection) : recordsTableName(collection); - const limit = Math.min(Math.max(1, rawLimit ?? 50), 200); - const conditions: string[] = []; - const bindings: (string | number)[] = []; - - if (spaceUri) { - conditions.push("r.space_uri = ?"); - bindings.push(spaceUri); - } - - if (source?.conditions) conditions.push(...source.conditions); - if (source?.params) bindings.push(...source.params); - - const countCols = getCountColumns(config, collection); - - if (did) { - conditions.push("r.did = ?"); - bindings.push(did); - } - - // Opaque keyset cursor encoding { t, v?, k }. Silently ignored if it doesn't - // match the current sort — callers shouldn't mix sort params with stale cursors. - const expectedKind = sortKind(sort); - if (cursor) { - const payload = decodeCursor(cursor); - if (payload && payload.k === expectedKind) { - if (sort?.recordField) { - const sortExpr = getDialect(db).jsonExtract('r.record', sort.recordField); - const cmp = sort.direction === "desc" ? "<" : ">"; - conditions.push(`(${sortExpr} ${cmp} ? OR (${sortExpr} = ? AND r.time_us < ?))`); - const v = payload.v ?? ""; - bindings.push(v as string | number, v as string | number, payload.t); - } else if (sort?.countType) { - const sortCol = countColumnForType(config, collection, sort.countType); - if (!sortCol) throw new Error(`Unknown countType: ${sort.countType}`); - const cmp = sort.direction === "desc" ? "<" : ">"; - conditions.push(`(r.${sortCol} ${cmp} ? OR (r.${sortCol} = ? AND r.time_us < ?))`); - const v = Number(payload.v ?? 0); - bindings.push(v, v, payload.t); - } else { - conditions.push("r.time_us < ?"); - bindings.push(payload.t); - } - } - } - - for (const [field, value] of Object.entries(filters)) { - conditions.push(`${getDialect(db).jsonExtract('r.record', field)} = ?`); - bindings.push(value); - } - - for (const [field, range] of Object.entries(rangeFilters)) { - if (range.min != null) { - conditions.push(`${getDialect(db).jsonExtract('r.record', field)} >= ?`); - bindings.push(range.min); - } - if (range.max != null) { - conditions.push(`${getDialect(db).jsonExtract('r.record', field)} <= ?`); - bindings.push(range.max); - } - } - - for (const [type, minCount] of Object.entries(countFilters)) { - const col = countColumnForType(config, collection, type); - if (!col) continue; // unknown count type — skip filter - conditions.push(`r.${col} >= ?`); - bindings.push(minCount); - } - - // FTS search. Not supported in space mode yet (would need composite keying - // because the same at-URI can appear in multiple spaces). - let ftsJoin = ""; - let ftsClause: ReturnType<typeof ftsQueryClause> | null = null; - if (search && !spaceUri) { - const colConfig2 = config.collections[collection]; - const fields = colConfig2 ? getSearchableFields(collection, colConfig2) : null; - if (fields && fields.length > 0) { - ftsClause = ftsQueryClause(getDialect(db), recordsTableName(collection)); - ftsJoin = ftsClause.join; - conditions.push(ftsClause.condition); - // SECURITY: `search` is user input bound as a parameter, not interpolated. - bindings.push(search); - } - } - - const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - - const countSelect = countCols.length > 0 - ? ", " + countCols.map(({ column }) => `r.${column}`).join(", ") - : ""; - const select = `r.uri, r.did, r.rkey, r.cid, r.record, r.time_us, r.indexed_at${countSelect}`; - - const join = [source?.joins, ftsJoin].filter(Boolean).join(" "); - - let orderBy: string; - if (sort?.recordField) { - const dir = sort.direction === "desc" ? "DESC" : "ASC"; - orderBy = `${getDialect(db).jsonExtract('r.record', sort.recordField)} ${dir}, r.time_us DESC`; - } else if (sort?.countType) { - const dir = sort.direction === "desc" ? "DESC" : "ASC"; - const sortCol = countColumnForType(config, collection, sort.countType); - if (!sortCol) throw new Error(`Unknown countType: ${sort.countType}`); - orderBy = `r.${sortCol} ${dir}, r.time_us DESC`; - } else if (ftsClause) { - orderBy = `${ftsClause.orderExpr}, r.time_us DESC`; - // PG ts_rank needs the search term bound again for ORDER BY - if (getDialect(db).ftsStrategy === "generated-column" && search) { - bindings.push(search); - } - } else { - orderBy = "r.time_us DESC"; - } - - bindings.push(limit); - - const query = `SELECT ${select} FROM ${table} r ${join} ${where} ORDER BY ${orderBy} LIMIT ?`; - - const result = await db - .prepare(query) - .bind(...bindings) - .all<any>(); - - const nsid = nsidForShortName(config, collection) ?? collection; - const records = (result.results ?? []).map((row: any) => { - const rec: RecordRow & { counts?: Record<string, number> } = { - uri: row.uri, - did: row.did, - collection: nsid, - rkey: row.rkey, - cid: row.cid, - record: row.record, - time_us: row.time_us, - indexed_at: row.indexed_at, - ...(spaceUri ? { space: spaceUri } : {}), - }; - if (countCols.length > 0) { - const counts: Record<string, number> = {}; - for (const { type, column } of countCols) { - const val = row[column]; - if (val != null && val !== 0) counts[type] = val; - } - if (Object.keys(counts).length > 0) rec.counts = counts; - } - return rec; - }); - - const nextCursor = - records.length === limit - ? buildCursor(records[records.length - 1], sort, expectedKind) - : undefined; - - return { records, cursor: nextCursor }; -} - -/** Build an opaque keyset cursor from the last row of a page. */ -function buildCursor( - row: RecordRow & { counts?: Record<string, number> }, - sort: SortOption | undefined, - kind: string -): string { - const t = Number(row.time_us); - if (sort?.recordField) { - const parsed = row.record ? JSON.parse(row.record) : null; - const v = parsed ? getNestedValue(parsed, sort.recordField) : undefined; - return encodeCursor({ t, v: v == null ? "" : String(v), k: kind }); - } - if (sort?.countType) { - const v = row.counts?.[sort.countType] ?? 0; - return encodeCursor({ t, v, k: kind }); - } - return encodeCursor({ t, k: kind }); -} - -/** Compare two rows according to the active sort order. Returns negative if - * `a` should come before `b`, positive otherwise. Matches the SQL ORDER BY. */ -function compareRows( - a: RecordRow & { counts?: Record<string, number> }, - b: RecordRow & { counts?: Record<string, number> }, - sort: SortOption | undefined -): number { - const timeCmp = Number(b.time_us) - Number(a.time_us); // time_us DESC - if (sort?.recordField) { - const ar = a.record ? JSON.parse(a.record) : null; - const br = b.record ? JSON.parse(b.record) : null; - const av = ar ? getNestedValue(ar, sort.recordField) : undefined; - const bv = br ? getNestedValue(br, sort.recordField) : undefined; - const dir = sort.direction === "desc" ? -1 : 1; - const cmp = (av === bv ? 0 : (av! < bv! ? -1 : 1)) * dir; - return cmp !== 0 ? cmp : timeCmp; - } - if (sort?.countType) { - const av = a.counts?.[sort.countType] ?? 0; - const bv = b.counts?.[sort.countType] ?? 0; - const dir = sort.direction === "desc" ? -1 : 1; - const cmp = (av === bv ? 0 : (av < bv ? -1 : 1)) * dir; - return cmp !== 0 ? cmp : timeCmp; - } - return timeCmp; -} - -/** Run a listRecords query across the public table and a set of per-space tables - * in parallel, then merge according to the active sort order. The cursor is a - * shared keyset cursor — every sub-query applies the same `WHERE` keyset, so - * pagination is consistent across sources. */ -export async function queryAcrossSources( - db: Database, - config: ContrailConfig, - options: QueryOptions, - spaceUris: string[] -): Promise<{ records: (RecordRow & { counts?: Record<string, number> })[]; cursor?: string }> { - if (spaceUris.length === 0) { - return queryRecords(db, config, options); - } - const limit = Math.min(Math.max(1, options.limit ?? 50), 200); - const perSourceLimit = limit; // each source fetches up to `limit`; we trim after merge - - const tasks: Promise<{ records: (RecordRow & { counts?: Record<string, number> })[] }>[] = [ - queryRecords(db, config, { ...options, limit: perSourceLimit }), - ]; - for (const spaceUri of spaceUris) { - tasks.push(queryRecords(db, config, { ...options, spaceUri, limit: perSourceLimit })); - } - const results = await Promise.all(tasks); - const merged = results.flatMap((r) => r.records); - merged.sort((a, b) => compareRows(a, b, options.sort)); - const trimmed = merged.slice(0, limit); - const kind = sortKind(options.sort); - const cursor = - trimmed.length === limit ? buildCursor(trimmed[trimmed.length - 1], options.sort, kind) : undefined; - return { records: trimmed, cursor }; -} - -// --- Users --- - diff --git a/packages/contrail-appview/src/core/db/schema.ts b/packages/contrail-appview/src/core/db/schema.ts deleted file mode 100644 index 1827269..0000000 --- a/packages/contrail-appview/src/core/db/schema.ts +++ /dev/null @@ -1,629 +0,0 @@ -import type { ContrailConfig, Database, ResolvedContrailConfig, ResolvedMaps } from "../types"; -import type { SqlDialect } from "../dialect"; -import { buildFtsSchema, getDialect, postgresDialect } from "../dialect"; -import { - getRelationField, - countColumnName, - groupedCountColumnName, - recordsTableName, - spacesRecordsTableName, - resolveConfig, -} from "../types"; -import { getSearchableFields } from "../search"; -import { buildSpacesBaseSchema } from "../spaces/schema"; -import { buildLabelsSchema } from "../labels/schema"; -import { getMeta, setMeta } from "./meta"; - -/** Bump when contrail changes schema in a way the generated-DDL hash below - * can't see on its own — chiefly the spaces / community / labels internal - * table shapes (their DDL isn't all enumerated into the fingerprint). Pure - * config-driven changes (collections, feeds, indexes, migrations) bust the - * fingerprint automatically and don't need a bump. */ -export const CONTRAIL_SCHEMA_VERSION = 1; - -const SCHEMA_FINGERPRINT_KEY = "schema_fingerprint"; - -function getResolved(config: ContrailConfig): ResolvedMaps { - return (config as ResolvedContrailConfig)._resolved ?? resolveConfig(config)._resolved; -} - -function buildBaseSchema(dialect: SqlDialect): string { - return ` -CREATE TABLE IF NOT EXISTS _contrail_meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); -CREATE TABLE IF NOT EXISTS backfills ( - did TEXT NOT NULL, - collection TEXT NOT NULL, - completed INTEGER NOT NULL DEFAULT 0, - pds_cursor TEXT, - retries INTEGER NOT NULL DEFAULT 0, - last_error TEXT, - PRIMARY KEY (did, collection) -); -CREATE TABLE IF NOT EXISTS discovery ( - collection TEXT NOT NULL, - relay TEXT NOT NULL, - cursor TEXT, - completed INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (collection, relay) -); -CREATE TABLE IF NOT EXISTS cursor ( - id INTEGER PRIMARY KEY CHECK (id = 1), - time_us ${dialect.bigintType} NOT NULL -); -CREATE TABLE IF NOT EXISTS identities ( - did TEXT PRIMARY KEY, - handle TEXT, - pds TEXT, - resolved_at ${dialect.bigintType} NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_identities_handle ON identities(handle); -`; -} - -function sanitizeName(name: string): string { - return name.replace(/[^a-zA-Z0-9]/g, "_"); -} - -interface BuilderOpts { - /** Emit tables for the spaces variant (spaces_records_<short> with space_uri column). */ - forSpaces?: boolean; -} - -function tableFor(shortName: string, opts: BuilderOpts): string { - return opts.forSpaces ? spacesRecordsTableName(shortName) : recordsTableName(shortName); -} - -function namePrefix(opts: BuilderOpts): string { - return opts.forSpaces ? "sp_" : ""; -} - -export function buildCollectionTables( - config: ContrailConfig, - dialect: SqlDialect, - opts: BuilderOpts = {} -): string[] { - const stmts: string[] = []; - for (const [shortName, colConfig] of Object.entries(config.collections)) { - if (opts.forSpaces && colConfig.allowInSpaces === false) continue; - const table = tableFor(shortName, opts); - const np = namePrefix(opts); - if (opts.forSpaces) { - stmts.push( - `CREATE TABLE IF NOT EXISTS ${table} ( - space_uri TEXT NOT NULL, - uri TEXT NOT NULL, - did TEXT NOT NULL, - rkey TEXT NOT NULL, - cid TEXT, - record ${dialect.recordColumnType}, - time_us ${dialect.bigintType} NOT NULL, - indexed_at ${dialect.bigintType} NOT NULL, - PRIMARY KEY (space_uri, did, rkey) - )` - ); - stmts.push( - `CREATE INDEX IF NOT EXISTS idx_${np}${sanitizeName(shortName)}_space_time ON ${table}(space_uri, time_us DESC)` - ); - stmts.push( - `CREATE INDEX IF NOT EXISTS idx_${np}${sanitizeName(shortName)}_space_did ON ${table}(space_uri, did)` - ); - } else { - stmts.push( - `CREATE TABLE IF NOT EXISTS ${table} ( - uri TEXT PRIMARY KEY, - did TEXT NOT NULL, - rkey TEXT NOT NULL, - cid TEXT, - record ${dialect.recordColumnType}, - time_us ${dialect.bigintType} NOT NULL, - indexed_at ${dialect.bigintType} NOT NULL - )` - ); - stmts.push(`CREATE INDEX IF NOT EXISTS idx_${sanitizeName(shortName)}_did ON ${table}(did)`); - stmts.push(`CREATE INDEX IF NOT EXISTS idx_${sanitizeName(shortName)}_time ON ${table}(time_us DESC)`); - } - } - return stmts; -} - -export function buildDynamicIndexes( - config: ContrailConfig, - dialect: SqlDialect, - opts: BuilderOpts = {} -): string[] { - const resolved = getResolved(config); - const indexes: string[] = []; - const np = namePrefix(opts); - for (const [collection, colConfig] of Object.entries(config.collections)) { - if (opts.forSpaces && colConfig.allowInSpaces === false) continue; - const table = tableFor(collection, opts); - const queryable = resolved.queryable[collection] ?? colConfig.queryable ?? {}; - for (const field of Object.keys(queryable)) { - const idxName = `idx_${np}${sanitizeName(collection)}_${sanitizeName(field)}`; - indexes.push( - `CREATE INDEX IF NOT EXISTS ${idxName} ON ${table}(${dialect.indexExpression(dialect.jsonExtract('record', field))})` - ); - } - - for (const [, rel] of Object.entries(colConfig.relations ?? {})) { - const childShort = rel.collection; - const childConfig = config.collections[childShort]; - if (opts.forSpaces && childConfig?.allowInSpaces === false) continue; - const on = getRelationField(rel); - const childTable = tableFor(childShort, opts); - const idxName = `idx_${np}${sanitizeName(childShort)}_${sanitizeName(on)}`; - indexes.push( - `CREATE INDEX IF NOT EXISTS ${idxName} ON ${childTable}(${dialect.indexExpression(dialect.jsonExtract('record', on))})` - ); - } - } - return indexes; -} - -export function buildCountColumns(config: ContrailConfig, opts: BuilderOpts = {}): string[] { - const resolved = getResolved(config); - const stmts: string[] = []; - const addedColumns = new Map<string, Set<string>>(); - const np = namePrefix(opts); - - for (const [collection, colConfig] of Object.entries(config.collections)) { - if (opts.forSpaces && colConfig.allowInSpaces === false) continue; - const table = tableFor(collection, opts); - const relMap = resolved.relations[collection] ?? {}; - - if (!addedColumns.has(table)) addedColumns.set(table, new Set()); - const tableColumns = addedColumns.get(table)!; - - for (const [relName, rel] of Object.entries(colConfig.relations ?? {})) { - if (rel.count === false) continue; - if (opts.forSpaces && config.collections[rel.collection]?.allowInSpaces === false) continue; - const totalCol = countColumnName(rel.collection); - if (!tableColumns.has(totalCol)) { - tableColumns.add(totalCol); - stmts.push( - `ALTER TABLE ${table} ADD COLUMN ${totalCol} INTEGER NOT NULL DEFAULT 0` - ); - } - stmts.push( - `CREATE INDEX IF NOT EXISTS idx_${np}${sanitizeName(collection)}_${totalCol} ON ${table}(${totalCol} DESC, time_us DESC)` - ); - - const mapping = relMap[relName]; - if (mapping) { - for (const groupKey of Object.keys(mapping.groups)) { - const groupCol = groupedCountColumnName(rel.collection, groupKey); - if (!tableColumns.has(groupCol)) { - tableColumns.add(groupCol); - stmts.push( - `ALTER TABLE ${table} ADD COLUMN ${groupCol} INTEGER NOT NULL DEFAULT 0` - ); - } - stmts.push( - `CREATE INDEX IF NOT EXISTS idx_${np}${sanitizeName(collection)}_${groupCol} ON ${table}(${groupCol} DESC, time_us DESC)` - ); - } - } - } - } - return stmts; -} - -/** - * Idempotently add a column to a table, surfacing real DDL errors. - * - * Postgres supports `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` natively, so we - * issue that and let any non-duplicate error propagate. SQLite (including - * `node:sqlite`) does NOT support `IF NOT EXISTS` on `ADD COLUMN`, so we - * pre-check `PRAGMA table_info` and short-circuit if the column is already - * there. Because the PRAGMA-check + ALTER pair is not atomic, a concurrent - * second `initSchema` call can still hit a "duplicate column name" race; we - * narrowly absorb exactly that error message and re-throw everything else. - * - * Net effect: only the duplicate-column case is absorbed. Missing tables, - * syntax errors, type mismatches, and any other DDL failure will throw. - * - * Exported for direct testing of the idempotency contract; callers in - * `initSchema` use this internally. - */ -export async function addColumnIfNotExists( - db: Database, - table: string, - column: string, - columnDef: string, -): Promise<void> { - const dialect = getDialect(db); - if (dialect === postgresDialect) { - await db.prepare( - `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${column} ${columnDef}`, - ).run(); - return; - } - // SQLite path: check existence first, then ALTER without IF NOT EXISTS. - // PRAGMA table_info() does not accept parameter binding, so we rely on the - // caller to pass a sanitized identifier (all current callers do — table - // names come from `recordsTableName`/`spacesRecordsTableName` which - // sanitize, and column names come from `countColumnName` / - // `groupedCountColumnName` which also sanitize). - const info = await db - .prepare(`PRAGMA table_info(${table})`) - .all<{ name: string }>(); - if (info.results.some((c) => c.name === column)) return; - try { - await db.prepare( - `ALTER TABLE ${table} ADD COLUMN ${column} ${columnDef}`, - ).run(); - } catch (err) { - // Narrow swallow: only the "duplicate column" race between the PRAGMA - // read and the ALTER is acceptable. Everything else surfaces. - if (!isDuplicateColumnError(err)) throw err; - } -} - -function isDuplicateColumnError(err: unknown): boolean { - if (!err || typeof err !== "object") return false; - const msg = (err as { message?: unknown }).message; - if (typeof msg !== "string") return false; - // node:sqlite / better-sqlite3: "duplicate column name: <col>" - return /duplicate column name/i.test(msg); -} - -/** - * Postgres `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` are - * NOT atomic against concurrent creators: two transactions can both pass the - * existence check before either has inserted into `pg_class` / `pg_type`. The - * loser raises 23505 on `pg_type_typname_nsp_index` (the unique index on - * `(typname, typnamespace)`) or `pg_class_relname_nsp_index`. Pre-existing - * tables also surface as 42P07 (`duplicate_table`). - * - * SQLite serializes DDL globally, so this race never manifests there. - * - * The caller is expected to issue idempotent DDL (IF NOT EXISTS); this helper - * only absorbs the narrow concurrent-create race. - */ -function isConcurrentCreateError(err: unknown): boolean { - if (!err || typeof err !== "object") return false; - const code = (err as { code?: unknown }).code; - if (code === "42P07" || code === "42P06") return true; - if (code === "23505") { - const constraint = (err as { constraint?: unknown }).constraint; - return ( - constraint === "pg_type_typname_nsp_index" || - constraint === "pg_class_relname_nsp_index" || - constraint === "pg_namespace_nspname_index" - ); - } - return false; -} - -/** - * Run a single DDL statement, absorbing only the concurrent-create race that - * `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` can hit on - * Postgres when multiple processes init the same schema in parallel. Genuine - * DDL errors (syntax, type mismatch, missing column) surface unchanged. - */ -async function runIdempotentDdl(db: Database, stmt: string): Promise<void> { - try { - await db.prepare(stmt).run(); - } catch (err) { - if (!isConcurrentCreateError(err)) throw err; - } -} - -/** - * Apply the ALTER+INDEX statements emitted by `buildCountColumns` - * idempotently and without swallowing non-duplicate errors. - * - * `buildCountColumns` mixes two statement shapes: `ALTER TABLE ... ADD COLUMN - * ...` (not idempotent on SQLite without a pre-check; supports IF NOT EXISTS - * on Postgres) and `CREATE INDEX IF NOT EXISTS ...` (idempotent on both - * dialects). We route ALTERs through `addColumnIfNotExists` and run indexes - * directly. - */ -export async function applyCountColumns( - db: Database, - config: ContrailConfig, - opts: BuilderOpts = {}, -): Promise<void> { - for (const stmt of buildCountColumns(config, opts)) { - const match = stmt.match( - /^ALTER TABLE\s+(\S+)\s+ADD COLUMN\s+(\S+)\s+(.+)$/i, - ); - if (match) { - const [, table, column, columnDef] = match; - await addColumnIfNotExists(db, table, column, columnDef); - } else { - await db.prepare(stmt).run(); - } - } -} - -function buildFeedTables(config: ContrailConfig, dialect: SqlDialect): string[] { - if (!config.feeds || Object.keys(config.feeds).length === 0) return []; - const stmts = [ - `CREATE TABLE IF NOT EXISTS feed_items ( - actor TEXT NOT NULL, - uri TEXT NOT NULL, - collection TEXT NOT NULL, - time_us ${dialect.bigintType} NOT NULL, - PRIMARY KEY (actor, uri) - )`, - `CREATE INDEX IF NOT EXISTS idx_feed_actor_coll_time ON feed_items(actor, collection, time_us DESC)`, - `CREATE INDEX IF NOT EXISTS idx_feed_actor_time ON feed_items(actor, time_us DESC)`, - // Single-row cursor for the rolling, bounded feed prune (see sweepFeedItems). - `CREATE TABLE IF NOT EXISTS feed_prune_cursor ( - id INTEGER PRIMARY KEY CHECK (id = 1), - actor TEXT - )`, - `CREATE TABLE IF NOT EXISTS feed_backfills ( - actor TEXT NOT NULL, - feed TEXT NOT NULL, - completed INTEGER NOT NULL DEFAULT 0, - retries INTEGER NOT NULL DEFAULT 0, - last_error TEXT, - started_at ${dialect.bigintType}, - PRIMARY KEY (actor, feed) - )`, - ]; - - const followCollections = new Set( - Object.values(config.feeds).map((f) => f.follow ?? "follow") - ); - for (const col of followCollections) { - const table = recordsTableName(col); - const safe = sanitizeName(col); - stmts.push( - `CREATE INDEX IF NOT EXISTS idx_${safe}_subject ON ${table}(${dialect.indexExpression(dialect.jsonExtract('record', 'subject'))})` - ); - } - - return stmts; -} - -export function buildFtsTables( - config: ContrailConfig, - dialect: SqlDialect, - opts: BuilderOpts = {} -): string[] { - const stmts: string[] = []; - for (const [collection, colConfig] of Object.entries(config.collections)) { - if (opts.forSpaces && colConfig.allowInSpaces === false) continue; - const fields = getSearchableFields(collection, colConfig); - if (!fields || fields.length === 0) continue; - const table = tableFor(collection, opts); - stmts.push(...buildFtsSchema(dialect, table, fields)); - } - return stmts; -} - -/** - * Schema migrations expressed as structured ADD-COLUMN ops. Each entry is - * applied via `addColumnIfNotExists` so the operation is idempotent on both - * dialects without swallowing genuine DDL errors. - * - * `target: "spaces"` is routed to the spaces DB (which may differ from the - * main DB in split-DB deployments) and only applied when spaces is enabled. - * `target: "feeds"` is only applied when feeds are configured (the - * `feed_backfills` table doesn't exist otherwise). All other migrations - * target the main DB unconditionally. - */ -interface MigrationOp { - table: string; - column: string; - columnDef: string; - target?: "spaces" | "feeds"; -} - -const MIGRATIONS: MigrationOp[] = [ - { table: "backfills", column: "retries", columnDef: "INTEGER NOT NULL DEFAULT 0" }, - { table: "backfills", column: "last_error", columnDef: "TEXT" }, - { - table: "spaces_invites", - column: "kind", - columnDef: "TEXT NOT NULL DEFAULT 'join'", - target: "spaces", - }, - { table: "feed_backfills", column: "retries", columnDef: "INTEGER NOT NULL DEFAULT 0", target: "feeds" }, - { table: "feed_backfills", column: "last_error", columnDef: "TEXT", target: "feeds" }, - { table: "feed_backfills", column: "started_at", columnDef: "BIGINT", target: "feeds" }, -]; - -async function runMigrations( - db: Database, - spacesDb: Database | undefined, - hasSpaces: boolean, - hasFeeds: boolean, -): Promise<void> { - for (const op of MIGRATIONS) { - if (op.target === "spaces") { - if (!hasSpaces) continue; - await addColumnIfNotExists(spacesDb ?? db, op.table, op.column, op.columnDef); - continue; - } - if (op.target === "feeds") { - if (!hasFeeds) continue; - await addColumnIfNotExists(db, op.table, op.column, op.columnDef); - continue; - } - await addColumnIfNotExists(db, op.table, op.column, op.columnDef); - } -} - -/** Pluggable schema applier — passed in by extension packages (community, - * third-party plugins) to install their own tables alongside contrail's. */ -export type SchemaModule = (db: Database) => Promise<void>; - -export interface InitSchemaOptions { - /** Separate DB for the spaces tables. Defaults to the main `db`. */ - spacesDb?: Database; - /** Extra schema modules to apply after contrail's own DDL. Used by the - * community package to install its tables — contrail core no longer - * imports community schema directly. */ - extraSchemas?: SchemaModule[]; -} - -async function applySpacesSchema( - target: Database, - config: ContrailConfig, - dialect: SqlDialect -): Promise<void> { - const base = buildSpacesBaseSchema(dialect); - const perCollection = buildCollectionTables(config, dialect, { forSpaces: true }); - const indexes = buildDynamicIndexes(config, dialect, { forSpaces: true }); - // Per-statement (not batched) so concurrent applySpacesSchema on Postgres - // races only on the individual CREATE statements; see initSchema for - // rationale. - for (const stmt of [...base, ...perCollection, ...indexes]) { - await runIdempotentDdl(target, stmt); - } - - const ftsStmts = buildFtsTables(config, dialect, { forSpaces: true }); - for (const stmt of ftsStmts) { - try { await target.prepare(stmt).run(); } catch { /* FTS5 unavailable */ } - } - // Idempotent count-column ALTERs + their indexes. Non-duplicate-column - // errors propagate. - await applyCountColumns(target, config, { forSpaces: true }); -} - -/** Stable, dependency-free 64-bit-ish hash (two seeded FNV-1a passes → hex). - * Sync and Workers-safe (no crypto). Collisions only matter if two *different* - * schemas hash identically AND a deploy transitions between them — negligible, - * and CONTRAIL_SCHEMA_VERSION is the explicit backstop. */ -function hashStrings(parts: string[]): string { - const joined = parts.join("�"); - let h1 = 0x811c9dc5; - let h2 = 0x01000193; - for (let i = 0; i < joined.length; i++) { - const c = joined.charCodeAt(i); - h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0; - h2 = Math.imul(h2 ^ c, 0x811c9dc5) >>> 0; - } - return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0"); -} - -/** Fingerprint of everything `initSchema` would apply, so an unchanged schema - * can skip the DDL entirely. Includes the generated core DDL (which already - * reflects collections/feeds/indexes), the count-column + migration set, the - * labels/spaces base DDL when enabled, feature flags, dialect, and the - * version constant. */ -function schemaFingerprint( - config: ContrailConfig, - dialect: SqlDialect, - ddl: { - base: string[]; - collections: string[]; - indexes: string[]; - feeds: string[]; - fts: string[]; - } -): string { - const hasSpaces = !!(config.spaces?.authority || config.spaces?.recordHost); - return hashStrings([ - `v${CONTRAIL_SCHEMA_VERSION}`, - dialect.bigintType, - config.community ? "community" : "", - config.spaces?.authority ? "spaces.authority" : "", - config.spaces?.recordHost ? "spaces.recordHost" : "", - config.labels ? "labels" : "", - ...ddl.base, - ...ddl.collections, - ...ddl.indexes, - ...ddl.feeds, - ...ddl.fts, - ...buildCountColumns(config), - ...(config.labels ? buildLabelsSchema(dialect) : []), - ...(hasSpaces ? buildSpacesBaseSchema(dialect) : []), - JSON.stringify(MIGRATIONS), - ]); -} - -export async function initSchema( - db: Database, - config: ContrailConfig, - options: InitSchemaOptions = {} -): Promise<void> { - const dialect = getDialect(db); - const baseStatements = buildBaseSchema(dialect).split(";") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - const collectionStatements = buildCollectionTables(config, dialect); - const indexStatements = buildDynamicIndexes(config, dialect); - const ftsStatements = buildFtsTables(config, dialect); - const feedStatements = buildFeedTables(config, dialect); - - // Steady-state fast path: if the schema already on disk matches what we'd - // apply, skip every DDL statement after one cheap read. Consumers call - // init() once per isolate, and Workers isolates recycle constantly, so - // otherwise the first request to each cold isolate pays ~40 sequential DDL - // round-trips to the D1 storage object before any real work. The read - // tolerates a missing `_contrail_meta` (true first init) and returns null. - const fingerprint = schemaFingerprint(config, dialect, { - base: baseStatements, - collections: collectionStatements, - indexes: indexStatements, - feeds: feedStatements, - fts: ftsStatements, - }); - if ((await getMeta(db, SCHEMA_FINGERPRINT_KEY)) === fingerprint) return; - - const spacesDb = options.spacesDb; - const spacesSharesMainDb = !spacesDb || spacesDb === db; - - const all = [...baseStatements, ...collectionStatements, ...indexStatements, ...feedStatements]; - - // Per-statement run (not a batched transaction) so concurrent initSchema - // callers on Postgres race only on individual CREATEs; the loser's - // duplicate-relation error is absorbed by runIdempotentDdl. Each statement - // is already idempotent (IF NOT EXISTS). - for (const stmt of all) { - await runIdempotentDdl(db, stmt); - } - - if (config.spaces?.authority || config.spaces?.recordHost) { - await applySpacesSchema(spacesSharesMainDb ? db : spacesDb!, config, dialect); - } - - // Extension schemas (e.g. community) — applied to the spacesDb when one's - // configured separately, since they typically reference space_uri. The - // caller is responsible for routing the schema to the right db; we just - // hand it the spaces-or-main DB as a sensible default. - const extensionTarget = spacesSharesMainDb ? db : spacesDb!; - for (const apply of options.extraSchemas ?? []) { - await apply(extensionTarget); - } - - if (config.labels) { - // Labels tables live on the main DB — they're keyed by at-URI / DID and - // are read alongside public records during hydration. - const labelsStmts = buildLabelsSchema(dialect); - for (const stmt of labelsStmts) { - await runIdempotentDdl(db, stmt); - } - } - - // FTS5 may not be available (e.g. node:sqlite) — skip gracefully - for (const stmt of ftsStatements) { - try { - await db.prepare(stmt).run(); - } catch { - // FTS5 not supported in this environment - } - } - const hasSpaces = !!(config.spaces?.authority || config.spaces?.recordHost); - const hasFeeds = !!(config.feeds && Object.keys(config.feeds).length > 0); - // Spaces-targeted migrations route to spacesDb when one is configured; - // otherwise they hit the main db (which is where the spaces tables live - // when no separate spacesDb is supplied). - await runMigrations(db, spacesSharesMainDb ? undefined : spacesDb, hasSpaces, hasFeeds); - - // Idempotent count-column ALTERs + their indexes. Routed through - // `applyCountColumns` so non-duplicate-column errors propagate. - await applyCountColumns(db, config); - - // Record the applied fingerprint so future cold starts skip all the DDL - // above after a single read. `_contrail_meta` was created by the base DDL. - await setMeta(db, SCHEMA_FINGERPRINT_KEY, fingerprint); -} diff --git a/packages/contrail-appview/src/core/dialect.ts b/packages/contrail-appview/src/core/dialect.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/dialect.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/identity.ts b/packages/contrail-appview/src/core/identity.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/identity.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/invite/community-handler.ts b/packages/contrail-appview/src/core/invite/community-handler.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/invite/community-handler.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/invite/index.ts b/packages/contrail-appview/src/core/invite/index.ts deleted file mode 100644 index 8dae0df..0000000 --- a/packages/contrail-appview/src/core/invite/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { generateInviteToken, hashInviteToken, mintInviteToken } from "./token"; -export { registerInviteRoutes } from "./router"; -export type { InviteRoutesOptions } from "./router"; diff --git a/packages/contrail-appview/src/core/invite/router.ts b/packages/contrail-appview/src/core/invite/router.ts deleted file mode 100644 index 920123f..0000000 --- a/packages/contrail-appview/src/core/invite/router.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-authority"; diff --git a/packages/contrail-appview/src/core/invite/token.ts b/packages/contrail-appview/src/core/invite/token.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/invite/token.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/jetstream.ts b/packages/contrail-appview/src/core/jetstream.ts deleted file mode 100644 index ef96120..0000000 --- a/packages/contrail-appview/src/core/jetstream.ts +++ /dev/null @@ -1,569 +0,0 @@ -import { JetstreamSubscription } from "@atcute/jetstream"; -import type { ContrailConfig, IngestEvent, Database, Logger } from "./types"; -import { - getCollectionNsids, - getDependentNsids, - jetstreamUrlOption, - shortNameForNsid, - buildFeedTargetCaps, - getFeedMutatingNsids, - optimizeEnabled, - optimizeIntervalMs, - optimizeAnalysisLimit, -} from "./types"; -import { initSchema, getLastCursor, saveCursor, applyEvents, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor, getMetaNumber, setMeta, optimizeDatabase } from "./db"; -import { refreshStaleIdentities, applyIdentityEvent } from "./identity"; -import { backfillFollowersFromConstellation } from "./constellation"; - -const BATCH_SIZE = 50; -/** Distinct actors pruned per ingest tick by the rolling feed sweep. Each - * actor costs a handful of index-backed O(cap) deletes, so this bounds the - * prune's per-tick CPU regardless of how large feed_items grows. */ -export const FEED_PRUNE_SWEEP_ACTORS = 500; - -/** How long after a completed full pass the recovery sweep becomes due again, - * even when no feed-relevant records are ingested, so over-cap rows that - * predate a config change (e.g. a lowered cap) or a bulk import still drain. - * Once due, the pass advances one slice per tick, so a full pass *completes* - * roughly every (this interval + lap time), where lap time is - * ceil(actors / FEED_PRUNE_SWEEP_ACTORS) ticks. Steady-state pruning is driven - * by ingest; this is only the safety net. */ -export const FEED_PRUNE_RECOVERY_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h - -/** `_contrail_meta` key for the wall-clock (ms) at which the rolling feed sweep - * last *completed a full pass* over every actor, so a recycled cron isolate can - * honor the recovery interval across ticks. Tracking pass completion (not the - * last slice) is what keeps the recovery interval measuring from a real drain - * rather than from one bounded slice — without it, a single slice resets the - * clock and a feed touched just after the cursor passed it could wait many - * intervals to be revisited. */ -const FEED_PRUNE_LAST_FULL_PASS_META = "feed_prune_last_full_pass_ms"; - -/** `_contrail_meta` key for the persisted optimize cadence (so recycled cron - * isolates don't re-run it every tick — the in-memory-state bug we hit with - * the feed prune). Shared by the persistent loop. */ -export const OPTIMIZE_LAST_MS_KEY = "optimize_last_ms"; - -/** Run the opt-in planner-stat maintenance if enabled and its persisted - * interval has elapsed. Bounded + no-op on Postgres (see optimizeDatabase). - * Wrapped by callers so a pragma-unsupported environment can't break ingest. */ -export async function maybeOptimize(db: Database, config: ContrailConfig, log: Logger): Promise<void> { - if (!optimizeEnabled(config)) return; - const last = await getMetaNumber(db, OPTIMIZE_LAST_MS_KEY); - if (Date.now() - (last ?? 0) <= optimizeIntervalMs(config)) return; - // Claim the interval up front so a failing/unsupported pragma can't re-run - // every tick — it retries only after the next interval elapses. - await setMeta(db, OPTIMIZE_LAST_MS_KEY, String(Date.now())); - try { - await optimizeDatabase(db, optimizeAnalysisLimit(config)); - log.log("[maintenance] refreshed planner stats (PRAGMA optimize)"); - } catch (err) { - log.warn(`[maintenance] optimize failed: ${err}`); - } -} - -/** One bounded feed-prune slice: advances the persisted rolling cursor by up to - * {@link FEED_PRUNE_SWEEP_ACTORS} actors and reports whether the slice reached - * the end of the actor list (i.e. a full pass just completed and the cursor - * wrapped). Callers decide WHEN to sweep (ingest-dirty vs recovery); this owns - * the slice + cursor mechanics so the cron loop, the persistent loop, and the - * notify path all prune identically. No-op (done) when no feed caps apply. */ -export async function runFeedPruneSlice( - db: Database, - config: ContrailConfig -): Promise<{ pruned: number; done: boolean }> { - const caps = buildFeedTargetCaps(config); - if (caps.size === 0) return { pruned: 0, done: true }; - const cursor = await getFeedPruneCursor(db); - const { pruned, nextCursor, done } = await sweepFeedItems( - db, - caps, - cursor, - FEED_PRUNE_SWEEP_ACTORS - ); - await saveFeedPruneCursor(db, nextCursor); - return { pruned, done }; -} - -/** Gate and run one feed-prune slice against the *persisted* recovery clock — - * shared by the recycling cron isolate and the stateless `notifyOfUpdate` path - * (the long-lived persistent loop uses its in-memory clocks instead). Slices - * when `feedTouched` (a feed-mutating record was just ingested) or when a full - * pass is overdue, and records pass completion so the recovery clock measures - * from a real drain (one slice per tick until the cursor wraps) rather than - * resetting on a single slice. - * - * The slice advances the shared rolling cursor, which is NOT necessarily the - * actor the mutation touched: a fan-out follower the cursor has already passed - * is pruned by the next pass, up to about one recovery interval later, not - * instantly. That is - * the deliberate trade for a per-tick cost bounded by `FEED_PRUNE_SWEEP_ACTORS` - * rather than by fan-out size (a popular author has unboundedly many followers). - * feed_items is a soft cache, so a follower sitting a few rows over cap until - * the next slice is harmless. No-op when feeds are unconfigured. */ -export async function runGatedFeedPrune( - db: Database, - config: ContrailConfig, - feedTouched: boolean -): Promise<void> { - if (!config.feeds) return; - if (buildFeedTargetCaps(config).size === 0) return; - const nowMs = Date.now(); - const lastFullPassMs = - (await getMetaNumber(db, FEED_PRUNE_LAST_FULL_PASS_META)) ?? 0; - const recoveryDue = nowMs - lastFullPassMs >= FEED_PRUNE_RECOVERY_INTERVAL_MS; - if (!feedTouched && !recoveryDue) return; - const { pruned, done } = await runFeedPruneSlice(db, config); - if (done) await setMeta(db, FEED_PRUNE_LAST_FULL_PASS_META, String(nowMs)); - if (pruned > 0) { - getLogger(config).log( - `Pruned ${pruned} feed items (sweep, reason=${feedTouched ? "ingest" : "recovery"})` - ); - } -} - -/** Mutable state that persists across ingest cycles within the same process. */ -export interface IngestState { - cachedKnownDids?: Set<string>; - schemaInitialized: boolean; - /** Wall-clock of the last feed sweep slice — used only by the long-lived - * persistent loop to throttle ingest-driven slices; the recycling cron - * isolate persists its clocks in `_contrail_meta` instead. */ - lastFeedSweepMs: number; - /** Wall-clock at which the persistent loop last *completed a full sweep pass* - * over every actor. Drives the recovery interval (a fresh pass becomes due - * {@link FEED_PRUNE_RECOVERY_INTERVAL_MS} after the last one completed, then - * laps one slice per tick), independent of the ingest-driven throttle above. - * The cron isolate persists the equivalent in - * `_contrail_meta`. */ - lastFullFeedPassMs: number; - /** Set by the persistent loop when a flushed batch ingested a feed-mutating - * record, so the next sweep window knows there may be prune work. Cleared - * when the sweep runs. The cron path makes the same decision per-tick from - * its `events` array and doesn't need the flag. */ - feedDirty: boolean; -} - -export function createIngestState(): IngestState { - return { - schemaInitialized: false, - lastFeedSweepMs: 0, - lastFullFeedPassMs: 0, - feedDirty: false, - }; -} - -function getLogger(config: ContrailConfig): Logger { - return config.logger ?? console; -} - -/** Sentinel returned by `nextWithDeadline` when the wait timed out. */ -const INGEST_TIMEOUT = Symbol("ingest-timeout"); - -/** Await the iterator's next value, but give up after `ms`. Without this a - * quiet Jetstream (the async iterator blocks forever waiting for an event that - * never arrives) holds the cycle past its safety timeout until the caller's - * hard timeout kills the isolate — so the batch and cursor are never written. */ -function nextWithDeadline<T>( - iterator: AsyncIterator<T>, - ms: number -): Promise<IteratorResult<T> | typeof INGEST_TIMEOUT> { - let timer: ReturnType<typeof setTimeout>; - const next = iterator.next(); - // If the timeout wins this race the next() promise stays pending; swallow a - // later rejection so it can't surface as an unhandled rejection. - next.catch(() => {}); - const timeout = new Promise<typeof INGEST_TIMEOUT>((resolve) => { - timer = setTimeout(() => resolve(INGEST_TIMEOUT), ms); - }); - return Promise.race([next, timeout]).finally(() => clearTimeout(timer)); -} - -export async function ingestEvents( - config: ContrailConfig, - cursor: number | null, - safetyTimeoutMs: number = 25_000, - knownDids?: Set<string> -): Promise<{ - events: IngestEvent[]; - lastCursor: number | null; - newlyKnownDids: string[]; - identityUpdates: Map<string, string>; -}> { - const log = getLogger(config); - const startTimeUs = Date.now() * 1000; - const deadline = Date.now() + safetyTimeoutMs; - const collected: IngestEvent[] = []; - - const collections = getCollectionNsids(config); - const dependentCollections = new Set(getDependentNsids(config)); - const urls = config.jetstreams ?? []; - - let totalCommits = 0; - let filteredUnknownDid = 0; - const filteredDidSamples = new Set<string>(); - let lastYieldedTimeUs: number | null = null; - let firstYieldedTimeUs: number | null = null; - let connectCount = 0; - const seenUris = new Map<string, number>(); // uri -> time_us of first occurrence - const duplicateUris: string[] = []; - const newlyKnownDids = new Set<string>(); - const identityUpdates = new Map<string, string>(); - - const subscription = new JetstreamSubscription({ - // A single-instance config is handed over as a string so @atcute skips its - // array-only first-connect cursor rollback (see jetstreamUrlOption). On the - // cron model that rollback would otherwise re-ingest 10s every cycle. - url: jetstreamUrlOption(urls), - wantedCollections: collections, - ...(cursor !== null ? { cursor } : {}), - onConnectionOpen() { - connectCount++; - log.log( - `[ingest] connected to Jetstream #${connectCount} (url=${urls.join("|")}, cursor=${cursor ?? "none"}, wanted=${collections.join(",")})` - ); - }, - onConnectionClose(event) { - log.log( - `[ingest] disconnected from Jetstream: ${event.code} ${event.reason}` - ); - }, - onConnectionError(event) { - log.error("[ingest] Jetstream error:", event.error); - }, - }); - - const iterator = subscription[Symbol.asyncIterator](); - type Ev = typeof subscription extends AsyncIterable<infer V> ? V : never; - - // Collect or skip a single event. Filtering uses early `return` rather than - // the loop's `continue` so the loop's exit checks still run after a filtered - // event (a stream of all-filtered events must not skip the deadline). - const handleEvent = (event: Ev): void => { - if (event.kind === "commit") { - const { commit } = event; - totalCommits++; - - const uri = `at://${event.did}/${commit.collection}/${commit.rkey}`; - - const short = shortNameForNsid(config, commit.collection); - const collectionCfg = short ? config.collections[short] : undefined; - - if (dependentCollections.has(commit.collection) && knownDids) { - if (!knownDids.has(event.did)) { - filteredUnknownDid++; - if (filteredDidSamples.size < 10) filteredDidSamples.add(event.did); - return; - } - // Subject filter: for collections with subjectField (e.g. follows - // pointing at a `subject` DID), drop records whose subject isn't a - // DID we care about. Trims network-wide social graph to the - // subjects our discoverable users overlap with. - const subjectField = collectionCfg?.subjectField; - if (subjectField && commit.operation !== "delete") { - const subj = (commit.record as Record<string, unknown> | undefined)?.[ - subjectField - ]; - if (typeof subj === "string" && !knownDids.has(subj)) { - return; - } - } - } - - if (collectionCfg?.recordFilter && commit.operation !== "delete") { - const rec = commit.record as Record<string, unknown> | undefined; - let keep = false; - try { - keep = !!(rec && collectionCfg.recordFilter(rec)); - } catch (err) { - log.warn(`[ingest] recordFilter threw for ${uri}: ${err}`); - } - if (!keep) return; - } - - const prev = seenUris.get(uri); - if (prev !== undefined) { - duplicateUris.push(uri); - log.warn( - `[ingest] DUPLICATE in cycle: ${uri} first time_us=${prev}, again=${event.time_us}, delta=${event.time_us - prev}us` - ); - } else { - seenUris.set(uri, event.time_us); - } - - const now = Date.now(); - - collected.push({ - uri, - did: event.did, - time_us: event.time_us, - collection: commit.collection, - operation: commit.operation as "create" | "update" | "delete", - rkey: commit.rkey, - cid: commit.operation === "delete" ? null : commit.cid, - record: - commit.operation === "delete" - ? null - : JSON.stringify(commit.record), - indexed_at: now * 1000, - }); - - log.log( - `[ingest] keep: ${commit.operation} ${uri} time_us=${event.time_us}` - ); - - if (knownDids && !dependentCollections.has(commit.collection)) { - if (!knownDids.has(event.did)) { - knownDids.add(event.did); - newlyKnownDids.add(event.did); - } - } - } else if (event.kind === "identity") { - identityUpdates.set(event.did, event.identity.handle); - } - }; - - for (;;) { - // Run the exit checks BEFORE awaiting the next event and regardless of - // whether the previous event was filtered — otherwise a quiet stream blocks - // forever and an all-filtered flood never reaches the deadline check. - if (Date.now() >= deadline) { - log.log( - `[ingest] safety timeout reached, stopping (deadline=${deadline}, collected=${collected.length})` - ); - break; - } - - const step = await nextWithDeadline(iterator, Math.max(0, deadline - Date.now())); - if (step === INGEST_TIMEOUT) { - log.log( - `[ingest] safety timeout reached, stopping (deadline=${deadline}, collected=${collected.length})` - ); - break; - } - if (step.done) break; - const event = step.value; - - if (firstYieldedTimeUs === null) firstYieldedTimeUs = event.time_us; - lastYieldedTimeUs = event.time_us; - - handleEvent(event); - - if (event.time_us >= startTimeUs) { - log.log( - `[ingest] caught up to present, stopping (last time_us=${event.time_us}, startTimeUs=${startTimeUs})` - ); - break; - } - } - - // Close the subscription's socket, but fire-and-forget: awaiting the - // iterator's return on a quiet stream could itself block (the hang we fix). - Promise.resolve(iterator.return?.()).catch(() => {}); - - if (filteredUnknownDid > 0) { - const sample = [...filteredDidSamples].join(", "); - log.log( - `[ingest] ${filteredUnknownDid} events filtered (unknown did). sample dids: ${sample}` - ); - } - const lastCursor = subscription.cursor || null; - - const cursorGap = - lastCursor !== null && lastYieldedTimeUs !== null - ? lastCursor - lastYieldedTimeUs - : null; - - // Detect the library's internal cursor rollback (picks a different URL → rolls - // back 10s → first event comes in BEFORE the cursor we asked it to start from). - const rolledBackUs = - cursor !== null && firstYieldedTimeUs !== null && firstYieldedTimeUs < cursor - ? cursor - firstYieldedTimeUs - : 0; - - log.log( - `[ingest] jetstream loop done. commits_seen=${totalCommits}, filtered=${filteredUnknownDid}, kept=${collected.length}, dupes=${duplicateUris.length}, connects=${connectCount}, first_yielded=${firstYieldedTimeUs ?? "none"}, last_yielded=${lastYieldedTimeUs ?? "none"}, subscription_cursor=${lastCursor ?? "none"}, cursor_gap=${cursorGap ?? "n/a"}us, rolled_back=${rolledBackUs}us` - ); - - if (cursorGap !== null && cursorGap > 1000) { - log.warn( - `[ingest] CURSOR GAP: subscription cursor is ${cursorGap}us (${Math.floor( - cursorGap / 1000 - )}ms) ahead of last yielded event — buffered events may be dropped` - ); - } - - if (connectCount > 1) { - if (urls.length > 1) { - // Multi-instance pool: each reconnect picks a URL at random, and @atcute - // rolls the cursor back up to 10s on a fresh instance to absorb clock skew. - log.warn( - `[ingest] RECONNECTED ${connectCount} times during cycle across a ${urls.length}-instance pool — each reconnect picks a URL at random and may roll the cursor back up to 10s (rolled_back=${rolledBackUs}us this cycle)` - ); - } else { - // Single fixed instance (see jetstreamUrlOption): reconnects resume on the - // same instance from the saved cursor, so there is no rollback. - log.log( - `[ingest] reconnected ${connectCount} times during cycle to the single fixed instance — no cursor rollback (rolled_back=${rolledBackUs}us)` - ); - } - } - - return { events: collected, lastCursor, newlyKnownDids: [...newlyKnownDids], identityUpdates }; -} - -// Run a full ingest cycle: init schema, load cursor, ingest, apply, save cursor -export async function runIngestCycle( - db: Database, - config: ContrailConfig, - timeoutMs: number = 25_000, - state?: IngestState, - pubsub?: import("./realtime/types").PubSub -): Promise<void> { - const log = getLogger(config); - const s = state ?? createIngestState(); - - if (!s.schemaInitialized) { - await initSchema(db, config); - s.schemaInitialized = true; - } - - const cursor = await getLastCursor(db); - const collections = getCollectionNsids(config); - const nowUs = Date.now() * 1000; - const lagMs = cursor !== null ? Math.floor((nowUs - cursor) / 1000) : null; - - log.log( - `[ingest] starting cycle. cursor=${cursor ?? "none"}${ - lagMs !== null ? ` (lag=${lagMs}ms)` : "" - }, timeout=${timeoutMs}ms, collections=${collections.join(", ")}` - ); - - // Load known DIDs for filtering dependent collections - const dependentCollections = getDependentNsids(config); - let knownDids: Set<string> | undefined; - - if (dependentCollections.length > 0) { - if (s.cachedKnownDids) { - knownDids = s.cachedKnownDids; - log.log(`Using cached known DIDs (${knownDids.size} users)`); - } else { - const result = await db - .prepare("SELECT did FROM identities") - .all<{ did: string }>(); - knownDids = new Set((result.results ?? []).map((r) => r.did)); - s.cachedKnownDids = knownDids; - log.log(`Loaded ${knownDids.size} known DIDs from database`); - } - } - - const { events, lastCursor, newlyKnownDids, identityUpdates } = await ingestEvents( - config, - cursor, - timeoutMs, - knownDids - ); - - if (events.length > 0) { - const breakdown: Record<string, number> = {}; - for (const e of events) { - const key = `${e.collection}:${e.operation}`; - breakdown[key] = (breakdown[key] ?? 0) + 1; - } - log.log( - `[ingest] received ${events.length} events. breakdown=${JSON.stringify(breakdown)}` - ); - } else { - log.log(`[ingest] received 0 events from Jetstream`); - } - - for (let i = 0; i < events.length; i += BATCH_SIZE) { - const batch = events.slice(i, i + BATCH_SIZE); - await applyEvents(db, batch, config, { pubsub }); - } - - // Apply handle changes from #identity events. UPDATE-only, so unknown - // DIDs are no-ops — we don't want to create partial rows lacking PDS. - if (identityUpdates.size > 0) { - for (const [did, handle] of identityUpdates) { - try { - await applyIdentityEvent(db, did, handle); - } catch (err) { - log.warn(`[ingest] identity update failed for ${did}: ${err}`); - } - } - log.log(`[ingest] applied ${identityUpdates.size} identity event(s)`); - } - - // Persist the cursor BEFORE the best-effort enrichment tail below. Records are - // already durably applied (applyEvents) and handle changes recorded, so the - // cursor's forward progress is real and must be committed now. The steps that - // follow — refreshStaleIdentities especially — make per-DID network calls and - // can run long; if the cron isolate is aborted (e.g. a scheduled-invocation - // deadline) while they run, an un-saved cursor makes the next cycle re-drain the - // identical window forever. Identity refresh is idempotent and staleness-driven, - // so deferring it past the save costs nothing. - if (lastCursor !== null) { - await saveCursor(db, lastCursor); - log.log( - `[ingest] saved cursor=${lastCursor} (advanced ${ - cursor !== null ? lastCursor - cursor : "n/a" - }us)` - ); - } else { - log.log(`[ingest] no cursor returned from subscription; not saving`); - } - - // Refresh stale/missing identities for DIDs in this batch (best-effort; runs - // after the cursor save so its network latency can't strand forward progress). - const uniqueDids = [...new Set(events.map((e) => e.did))]; - if (uniqueDids.length > 0) { - try { - await refreshStaleIdentities(db, uniqueDids, config); - } catch (err) { - log.warn(`Identity refresh failed: ${err}`); - } - } - - // Newly-discovered DIDs: ask Constellation for back-edges so they - // immediately appear in existing followers' feeds (best-effort, opt-out). - if (config.feeds && newlyKnownDids.length > 0) { - for (const subj of newlyKnownDids) { - try { - await backfillFollowersFromConstellation(db, config, subj); - } catch (err) { - log.warn(`[constellation] subject=${subj} failed: ${err}`); - } - } - } - - // Prune feed_items to per-collection caps with a bounded, cursored sweep. - // Every statement is index-backed and O(cap) (see sweepFeedItems), so it can - // never exhaust D1's per-query CPU budget and reset the shared DO — unlike - // the old global window+anti-join. The cron isolate recycles each tick, so the - // sweep cursor and the recovery clock both live in the DB. - // - // A feed only goes over cap right after a row is inserted, and rows are only - // inserted for feed-mutating collections (event fan-out, follow backfill). So - // we skip the sweep entirely on ticks that ingested nothing feed-relevant — - // the overwhelming majority — and otherwise advance one bounded slice. - // - // The recovery clock tracks when a *full pass* over every actor last - // completed, not the last slice: while a pass is overdue we keep slicing every - // tick (bounded cost) until the cursor wraps, then reset the clock. That bounds - // the worst-case time an over-cap feed waits to be revisited — a feed touched - // just after the cursor passed it, a lowered cap, or a bulk import all drain - // within one recovery interval plus the pass's lap time, instead of stalling - // for many intervals (one slice per interval) as a per-slice clock would. - if (config.feeds) { - const feedMutatingNsids = getFeedMutatingNsids(config); - const feedTouched = events.some((e) => feedMutatingNsids.has(e.collection)); - await runGatedFeedPrune(db, config, feedTouched); - } - - // Opt-in planner-stat maintenance (gated + persisted cadence; no-op unless - // config.maintenance.optimize is set). - await maybeOptimize(db, config, log); - - log.log(`[ingest] cycle complete. stored=${events.length}`); -} diff --git a/packages/contrail-appview/src/core/labels/apply.ts b/packages/contrail-appview/src/core/labels/apply.ts deleted file mode 100644 index cbf5558..0000000 --- a/packages/contrail-appview/src/core/labels/apply.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { Database, Statement } from "../types"; - -/** Wire shape of a single `com.atproto.label.defs#label` entry. Field names - * match the spec exactly. We accept the spec's ISO-8601 strings and - * convert to unix seconds at the storage boundary. */ -export interface IncomingLabel { - src: string; - uri: string; - val: string; - cid?: string; - neg?: boolean; - exp?: string; - cts: string; - sig?: Uint8Array; -} - -/** Upsert a batch of labels. Idempotent on `(src, uri, val, cts)`. Bad rows - * (missing required fields, unparseable timestamps) are dropped silently; - * we don't want one malformed label to abort an entire labeler frame. */ -export async function applyLabels( - db: Database, - labels: IncomingLabel[], -): Promise<number> { - if (labels.length === 0) return 0; - const stmts: Statement[] = []; - let kept = 0; - for (const l of labels) { - if (!l.src || !l.uri || !l.val || !l.cts) continue; - const cts = isoToUnixSec(l.cts); - if (cts == null) continue; - const exp = l.exp ? isoToUnixSec(l.exp) : null; - stmts.push( - db - .prepare( - `INSERT INTO labels (src, uri, val, cid, neg, exp, cts, sig) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(src, uri, val, cts) DO UPDATE SET - cid = excluded.cid, - neg = excluded.neg, - exp = excluded.exp, - sig = excluded.sig`, - ) - .bind( - l.src, - l.uri, - l.val, - l.cid ?? null, - l.neg ? 1 : 0, - exp, - cts, - l.sig ?? null, - ), - ); - kept++; - } - if (stmts.length > 0) await db.batch(stmts); - return kept; -} - -function isoToUnixSec(iso: string): number | null { - const ms = Date.parse(iso); - if (!Number.isFinite(ms)) return null; - return Math.floor(ms / 1000); -} diff --git a/packages/contrail-appview/src/core/labels/hydrate.ts b/packages/contrail-appview/src/core/labels/hydrate.ts deleted file mode 100644 index 02b947a44ef764f5c31f64fefd468727f76fd7d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3547 zcmd1IEyyn_Q7EY_NL8p-a7iplOiC<HRj5@+E6UGRP}0-W2T2txS#v>ERx2bWmLzAS zrg-KBmZla}A}cM*FD*$e($7fEDM&2>>EY7X(ozV|EK1ca&PXgsRY=P(Qb<(Ds7xtJ zEJ;mK$Vp5}%~5#NFhwC3Y-X`SLUMktUSdf>QGQ9j9#~p0B{i*B8O%)JQqWRRNXtyk zNzqNqFVY3G6pHf|lJoP5OLJ3;iWQO*^Arjai;ER9OB9MriV`!^GfEUnGK%s`(=!xG z@)e*)D+IfE7wai#>2sx4fc>ADSCU$kmYAHX-~n}<57=$hTnY-sMafnm!!q;It+^Bw zN{cd)xn+qt$lT=26nkVoC;<HPaw-)Hic*VH^GX!TGg9*uN{UKT!PYA%<fW$DTY&{Y zhJ%!Q2K(z;m>C%ADCFm6mVm+utS2vDA+@3)v#3%bvsj@xwFIg^wW0vSLYN5($wjG& zC7Jno3MHAjsl_FUxdl*N$tA@ISJrZIA^abdSPu4jP=2`rIlhM(t5BnmSDKRp@>E`G zx|Kp+X>L+#5r~^wQ2^s2DFV9)rl^(+oO0Y!OOi7nsiYX1c8XI=6!Oy)ic6ESQj<%H zbrjMvb4pT+KyjO#n3I!Qq+YC$n4Fwi017v7s!1&>)&VD+<oukR#Dd~fg#?Y_qGTOV zMCvG%CFW=*D3p{I<fIlWBvmTpB$lKWmnbA8mlP-HC?w{kD5MnS7r+#hXXF>Bf(?VJ z%Pdw%R7l86O}7O_X@WvgYDrOIGAPLP6oOJqN{jM93JOw-bfGR*$W1KJ<5JK98xjol z1vuFzr79$r=!OP)7AxczDI_Hpr7F01x)g)6M@dGiLUCelszOO(QckKuMt)98u|iUQ zNd`DNQz2<MvA8lXSs|@74{Sdymw_X*Sc6MJAtlKQp22lM=^AP~EXJZ^L87q0LlrGb zP0lY$an4L}sthg4w6{|5O)Rh}E-A{)OV@$s4QO22>2PUUDFhVd=VlhC+6001!BwJU zuIN}hJ8%-qOjFQ+22ZhGPHJ9yNrr;0t*wHALQM@kGEpQnK>=M_l&4T#Yt6-_prEg> z;F6kBT967)7^uPK;_0H0n^>ukSWu9fSfr4dr;w16nNpkpj^M;%1yJ}x0v}>nVrfZ6 zevvg;931WtNw68o`MIf((lIBqxI{r47SMWJ3gCjMM4>b<vk)G&whGaDdU|=O<qE;6 zC2((Q#)5JJsN8}mcZ4NbROPV904awXUyzfSoC-<;paRbpY#U6wUT$K6hK8nst(}6B zy^^M0R(@ulhLVnwCc?J7{PN(`WCdFV-^7v(y|kSC{2~pP#FA9Ky!>(vO$B`gLjwZ? zO<1TpW#*-1=A{=alw~HO7yOAusa9am6%?hWWmf1Y=;`StXO!k;$Lc8rl!A)gRE31p zih=}4m_jqER#IwOeo?9xIBXN*<30W2;}aB+`~)h{LGh6Uv05R&C?&NBRzX0F0!ar4 z`k?3nD?`LVQGR)`f~`Vgd17V>n!|07ia0w^#sFmvP|{aWNC<ZIadi#>75RwrK}R7u zGerkfQ0st7U>$|zl41q7Ab($I$;71q0pT95L9QUJ3Z8xn8Y<ODek!ijgsD<caP)Ig z&`7N)Q1A>^@C)_vQSc7}nX6!@V2`G%v?vp779^B36CjQRr6*7lgGIWI0whks*;fIa z9$={tqyZk60h!6!;82H_T}Aoj3I(Y}n8l%<LP%z6u|j?xq!?5vN-fUMDN9vIs)PoA zVs>U;I@l<X86_EsB?^f}MVV#bDjifBXXb%r3&9P9qSWHjoDxVumI6{$q??jjkeZj0 znpXlbFsZaep$JsqW#%aqfXmP1RBMIeg4AS%l>F4<JoOTVl+?7$yi|pf%v9Z^qExU0 zK#>h<oG5swRw~#+OCdx;hxpQ|5+V!AfS@wVJGD|r0VM%|^9HD4q5;W{ATQ;oDS#4? zCb)P~fM)A#ur&!P)kXQ`dc{S_wG0quX;CJOS(cbnn*eeGOiw{kW`2=^EwowzJ4P=( zwL~L3wGwQvf<k7Rf`%eQSxpVdRy|Piva?kHi-9<rD7uRwx^+NG!A8`A3P6a{^Gi#t z6bKZ#3bx=vQvqC`qIwxA#PrG%b4pW-H8f#C0CFbC-+8I&nxOC~$;>NFwFVWu&?+xA z1>_EJQS9vLqEL{TXQhw`$sr2h6q1*in+mE7LD8C-mYJ*osZ$g{suK$eav(`Ar&6IL zADmb8^&$GoQj3Z+^Yio+Jk!953R2C3TY0G|3W-o-bdxhvKy@p)L<f}*Mfv6G#V~_F zcBEt$C#DyrrWWf!s`ShfcpU+5C%}^$$T1*SfNg`?4l}4cRlO(`TKQz=rRym;CMV~Y z=9TE?q?V=TK)hS5paH6Hpf#GNLULkJQKdp&KFFsqa|$x^K(!Dev6rVR<b#@t$r-81 z+0aHJsM3ej(F)0+%D+6bBtsz)WDF?jLy`_CPC#B)Q-cNstd53794sY5LfbhrMZp$L zB{)HX%qcC(1g9uaaR!cWaODlsqG$`MXCQenMFH6^1&|a-b8==1Qo;cH6Pz+(_JhON zN&%$>07{EsmC!~u$Q)493@Qe0X@kT{i!wna8OUAW<~B&IEHMWr25vf*6o=${7Uye# z<dRE@H6fC<kjx17O@6r^*pXnDfS90=fC!?h0+r2*w&3O~DDNeu=IDWhY!#60NUbP< z29`#CX-PE5+R~!TSOt4~TZQOYO}&EB;tUP2RbXY{uqXnz2o>^6ORTwSxwzm>HJG`j wd6^Z#smZX`u_mky1}=u6nn48_sJ_;KDpt^f)Yy6@`JTc4!QdvDh9<~-0O=5WfdBvi diff --git a/packages/contrail-appview/src/core/labels/resolve.ts b/packages/contrail-appview/src/core/labels/resolve.ts deleted file mode 100644 index af673d9..0000000 --- a/packages/contrail-appview/src/core/labels/resolve.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { - CompositeDidDocumentResolver, - type DidDocumentResolver, - PlcDidDocumentResolver, - WebDidDocumentResolver, -} from "@atcute/identity-resolver"; -import type { Did } from "@atcute/lexicons"; -import type { Database } from "../types"; -import { validateExternalUrl } from "../client"; - -/** Optional network-override knobs accepted by labeler-endpoint resolution. - * Mirrors the `ContrailConfig.networkOverrides` shape — kept narrow here so - * callers can pass `config.networkOverrides` directly without re-shaping. - * Omitting the object preserves the previous public-internet behavior. */ -export interface LabelerResolveOverrides { - /** DID document resolver used when looking up the labeler service entry. - * When unset, falls back to a default composite (PLC + Web) pointing at the - * upstream PLC directory. Trusted; not SSRF-checked. - * Mirrors the resolver-injection pattern in `core/client.ts`. */ - resolver?: DidDocumentResolver; - /** Hostnames (DNS names or IP literals) to allow past the default SSRF - * guard when validating a resolved labeler endpoint. Match is exact, - * case-insensitive, port-agnostic. */ - additionalAllowedHosts?: string[]; -} - -/** Reject endpoint URLs that point to private/internal addresses or non-HTTPS. - * Thin alias for the single shared SSRF guard {@link validateExternalUrl} in - * `contrail-base` — labeler endpoints are validated by the exact same rules as - * PDS endpoints, so the allowlist logic must live in one place. Kept exported - * under this name for existing callers/tests. */ -export const validateEndpointUrl = validateExternalUrl; - -const DEFAULT_DID_RESOLVER: DidDocumentResolver = new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver(), - }, -}); - -/** Look up the labeler service endpoint from a DID. - * Reads the DID doc's `service[id="#atproto_labeler"].serviceEndpoint`. - * - * `networkOverrides` (optional): customize the DID resolver used during the - * lookup, and/or which hostnames bypass the default SSRF guard. Omitting it - * preserves the original public-internet behavior. */ -export async function resolveLabelerEndpoint( - did: string, - networkOverrides?: LabelerResolveOverrides, -): Promise<string | null> { - if (!did.startsWith("did:plc:") && !did.startsWith("did:web:")) return null; - const resolver = networkOverrides?.resolver ?? DEFAULT_DID_RESOLVER; - try { - const doc = await resolver.resolve(did as Did<"plc"> | Did<"web">); - const endpoint = doc.service - ?.find((s) => s.id === "#atproto_labeler") - ?.serviceEndpoint?.toString(); - if (!endpoint) return null; - if (!validateEndpointUrl(endpoint, networkOverrides?.additionalAllowedHosts ?? [])) { - return null; - } - return endpoint; - } catch { - return null; - } -} - -/** State row for a labeler — the per-DID equivalent of the singleton - * jetstream `cursor` table, with cached endpoint to avoid repeated DID-doc - * fetches. */ -export interface LabelerState { - did: string; - cursor: number; - endpoint: string | null; - resolved_at: number | null; -} - -const ENDPOINT_TTL_MS = 6 * 60 * 60 * 1000; // 6h, matches the recommended client cache for label-defs - -/** Get cached `(endpoint, cursor)` for a labeler. Resolves endpoint on - * cache miss or staleness; persists endpoint + resolved_at back to the DB - * so subsequent ingest cycles avoid the network round-trip. - * - * `networkOverrides` (optional): forwarded to `resolveLabelerEndpoint` for - * the cache-miss/stale path. Has no effect when `endpointOverride` is set - * or when a fresh cached endpoint is used. */ -export async function getLabelerState( - db: Database, - did: string, - endpointOverride: string | undefined, - networkOverrides?: LabelerResolveOverrides, -): Promise<LabelerState | null> { - const row = await db - .prepare( - "SELECT did, cursor, endpoint, resolved_at FROM labeler_cursors WHERE did = ?", - ) - .bind(did) - .first<LabelerState>(); - - let endpoint = endpointOverride ?? row?.endpoint ?? null; - const stale = - !row?.resolved_at || Date.now() - row.resolved_at > ENDPOINT_TTL_MS; - - if (!endpoint || (!endpointOverride && stale)) { - endpoint = await resolveLabelerEndpoint(did, networkOverrides); - if (!endpoint) return null; - const now = Date.now(); - await db - .prepare( - `INSERT INTO labeler_cursors (did, cursor, endpoint, resolved_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(did) DO UPDATE SET endpoint = excluded.endpoint, resolved_at = excluded.resolved_at`, - ) - .bind(did, row?.cursor ?? 0, endpoint, now) - .run(); - return { - did, - cursor: row?.cursor ?? 0, - endpoint, - resolved_at: now, - }; - } - - return row ?? { did, cursor: 0, endpoint, resolved_at: null }; -} - -/** Persist the highest seen seq number for a labeler. Idempotent; - * the next ingest cycle resumes from `cursor + 1` via the `?cursor=` param. */ -export async function saveLabelerCursor( - db: Database, - did: string, - cursor: number, -): Promise<void> { - await db - .prepare( - `INSERT INTO labeler_cursors (did, cursor) - VALUES (?, ?) - ON CONFLICT(did) DO UPDATE SET cursor = excluded.cursor`, - ) - .bind(did, cursor) - .run(); -} - -/** Reset cursor to 0 — used in response to `#info { name: "OutdatedCursor" }` - * frames, which signal that the labeler's seq history was rewound. */ -export async function resetLabelerCursor(db: Database, did: string): Promise<void> { - await db - .prepare( - `INSERT INTO labeler_cursors (did, cursor) - VALUES (?, 0) - ON CONFLICT(did) DO UPDATE SET cursor = 0`, - ) - .bind(did) - .run(); -} diff --git a/packages/contrail-appview/src/core/labels/schema.ts b/packages/contrail-appview/src/core/labels/schema.ts deleted file mode 100644 index 486ce27..0000000 --- a/packages/contrail-appview/src/core/labels/schema.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { SqlDialect } from "../dialect"; - -/** DDL for the labels module. Single `labels` table covers record-level - * (uri starts with `at://`) and account-level (uri is a bare DID) entries — - * the spec collapses both into the same row shape. `labeler_cursors` - * mirrors the role of the singleton `cursor` table for jetstream, but - * per-labeler. */ -export function buildLabelsSchema(dialect: SqlDialect): string[] { - return [ - `CREATE TABLE IF NOT EXISTS labels ( - src TEXT NOT NULL, - uri TEXT NOT NULL, - val TEXT NOT NULL, - cid TEXT, - neg INTEGER NOT NULL DEFAULT 0, - exp ${dialect.bigintType}, - cts ${dialect.bigintType} NOT NULL, - sig BLOB, - PRIMARY KEY (src, uri, val, cts) - )`, - `CREATE INDEX IF NOT EXISTS idx_labels_uri ON labels(uri)`, - `CREATE INDEX IF NOT EXISTS idx_labels_src_cts ON labels(src, cts DESC)`, - `CREATE TABLE IF NOT EXISTS labeler_cursors ( - did TEXT PRIMARY KEY, - cursor ${dialect.bigintType} NOT NULL DEFAULT 0, - endpoint TEXT, - resolved_at ${dialect.bigintType} - )`, - ]; -} diff --git a/packages/contrail-appview/src/core/labels/select.ts b/packages/contrail-appview/src/core/labels/select.ts deleted file mode 100644 index da89d9c..0000000 --- a/packages/contrail-appview/src/core/labels/select.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { LabelsConfig } from "./types"; -import { DEFAULT_LABELS_MAX_PER_REQUEST } from "./types"; - -/** Pick which labelers to honor for this request. - * - * Order of precedence: - * 1. `atproto-accept-labelers` header (atproto spec) - * 2. `?labelers=` query param (fallback for SSE/WS where headers are awkward) - * 3. `config.defaults` (operator policy) - * 4. every entry in `config.sources` - * - * Each candidate DID is checked against `config.sources`. Unknowns are - * dropped — we only have rows for labelers we've subscribed to. - * - * Header values can carry `;param` modifiers (e.g. `did:plc:...;redact`); - * v1 strips and ignores those — only the bare DID is honored. */ -export interface SelectedLabelers { - /** DIDs to use for hydration this request. */ - accepted: string[]; -} - -export function selectAcceptedLabelers( - headerValue: string | null | undefined, - paramValue: string | null | undefined, - cfg: LabelsConfig, -): SelectedLabelers { - const cap = cfg.maxPerRequest ?? DEFAULT_LABELS_MAX_PER_REQUEST; - const known = new Set(cfg.sources.map((s) => s.did)); - - const fromCaller = parseLabelerList(headerValue) ?? parseLabelerList(paramValue); - - let candidates: string[]; - if (fromCaller && fromCaller.length > 0) { - candidates = fromCaller; - } else { - candidates = (cfg.defaults ?? cfg.sources.map((s) => s.did)).slice(); - } - - const accepted: string[] = []; - const seen = new Set<string>(); - for (const did of candidates) { - if (seen.has(did)) continue; - seen.add(did); - if (known.has(did)) accepted.push(did); - if (accepted.length >= cap) break; - } - - return { accepted }; -} - -/** Parse a comma-separated DID list. Returns null when the input is empty - * or undefined so callers can distinguish "absent" from "empty list" (the - * latter — `atproto-accept-labelers: ` — is technically valid and means - * "no labelers"; we treat it the same as absent for ergonomics). */ -function parseLabelerList(value: string | null | undefined): string[] | null { - if (!value) return null; - const out: string[] = []; - for (const raw of value.split(",")) { - // Drop `;param` modifiers from the spec (e.g. `;redact`). v1 ignores them. - const head = raw.split(";")[0]!.trim(); - if (head.startsWith("did:")) out.push(head); - } - return out.length > 0 ? out : null; -} diff --git a/packages/contrail-appview/src/core/labels/subscribe.ts b/packages/contrail-appview/src/core/labels/subscribe.ts deleted file mode 100644 index 1351d01..0000000 --- a/packages/contrail-appview/src/core/labels/subscribe.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { decodeFirst } from "@atcute/cbor"; -import type { ContrailConfig, Database, Logger } from "../types"; -import type { LabelerSource } from "./types"; -import { applyLabels, type IncomingLabel } from "./apply"; -import { - getLabelerState, - resetLabelerCursor, - saveLabelerCursor, -} from "./resolve"; - -const DEFAULT_CYCLE_TIMEOUT_MS = 25_000; -const DEFAULT_BATCH_SIZE = 100; -const DEFAULT_FLUSH_INTERVAL_MS = 5_000; - -function getLogger(config: ContrailConfig): Logger { - return config.logger ?? console; -} - -/** One catch-up cycle for every configured labeler. Designed to fit inside a - * Cloudflare Workers cron tick — we drain frames until the labeler has no - * more buffered events for us, or `timeoutMs` is reached, then save cursor - * and disconnect. Mirrors the shape of `runIngestCycle` for jetstream. */ -export async function runLabelIngestCycle( - db: Database, - config: ContrailConfig, - timeoutMs = DEFAULT_CYCLE_TIMEOUT_MS, -): Promise<void> { - if (!config.labels) return; - const log = getLogger(config); - const deadline = Date.now() + timeoutMs; - - for (const source of config.labels.sources) { - if (Date.now() >= deadline) { - log.log(`[labels] cycle deadline hit before processing ${source.did}`); - break; - } - const remaining = Math.max(2_000, deadline - Date.now()); - try { - await pumpOneLabeler( - db, - source, - log, - remaining, - /* persistent */ false, - {}, - config.networkOverrides, - ); - } catch (err) { - log.warn(`[labels] cycle for ${source.did} failed: ${err}`); - } - } -} - -export interface PersistentLabelsOptions { - signal?: AbortSignal; - batchSize?: number; - flushIntervalMs?: number; - logger?: Logger; -} - -/** Long-lived equivalent — keeps one socket per labeler open forever, with - * exponential backoff reconnect. Mirrors `runPersistent` for jetstream. */ -export async function runPersistentLabels( - db: Database, - config: ContrailConfig, - options: PersistentLabelsOptions = {}, -): Promise<void> { - if (!config.labels) return; - const log = options.logger ?? config.logger ?? console; - const signal = options.signal; - - const tasks = config.labels.sources.map((source) => - runOneLabelerForever(db, source, log, signal, options, config.networkOverrides), - ); - await Promise.all(tasks); -} - -async function runOneLabelerForever( - db: Database, - source: LabelerSource, - log: Logger, - signal: AbortSignal | undefined, - options: PersistentLabelsOptions, - networkOverrides: ContrailConfig["networkOverrides"], -): Promise<void> { - let attempts = 0; - while (!signal?.aborted) { - try { - await pumpOneLabeler( - db, - source, - log, - /* timeoutMs */ Infinity, - true, - { - signal, - batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE, - flushIntervalMs: options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS, - }, - networkOverrides, - ); - attempts = 0; - } catch (err) { - if (signal?.aborted) break; - log.error(`[labels] ${source.did} stream error: ${err}`); - const delay = Math.min(1_000 * 2 ** attempts, 30_000); - attempts++; - log.log(`[labels] ${source.did} reconnecting in ${delay}ms (attempt ${attempts})`); - await new Promise((r) => setTimeout(r, delay)); - } - } -} - -interface PumpOptions { - signal?: AbortSignal; - batchSize?: number; - flushIntervalMs?: number; -} - -/** Open a `subscribeLabels` WebSocket, drain frames into a buffer, flush - * the buffer to `labels` in batches, and persist the seq cursor. Returns - * when: - * - the labeler closes the socket cleanly (caught up + no more events) - * - `timeoutMs` is reached (cron mode) - * - `signal` is aborted (persistent mode) - * - an error tears the socket down (caller may retry) */ -async function pumpOneLabeler( - db: Database, - source: LabelerSource, - log: Logger, - timeoutMs: number, - persistent: boolean, - pumpOpts: PumpOptions = {}, - networkOverrides?: ContrailConfig["networkOverrides"], -): Promise<void> { - const state = await getLabelerState(db, source.did, source.endpoint, networkOverrides); - if (!state) { - log.warn(`[labels] could not resolve labeler endpoint for ${source.did}; skipping`); - return; - } - - // First-time policy: cursor 0 = "from the beginning" if backfill is on - // (default), null = "from now" otherwise. After the first cycle we always - // resume from the saved cursor — `backfill` only flips the start point. - const isFirstRun = state.cursor === 0 && state.resolved_at === null; - const backfill = source.backfill !== false; - const startCursor = isFirstRun && !backfill ? null : state.cursor; - - const url = buildWsUrl(state.endpoint!, startCursor); - log.log(`[labels] connecting to ${source.did} (cursor=${startCursor ?? "now"})`); - - const ws = new WebSocket(url); - ws.binaryType = "arraybuffer"; - - const buffer: IncomingLabel[] = []; - let highestSeq = state.cursor; - let flushing = false; - let resolveDone!: () => void; - let rejectDone!: (err: unknown) => void; - const done = new Promise<void>((res, rej) => { - resolveDone = res; - rejectDone = rej; - }); - - const flush = async () => { - if (buffer.length === 0 || flushing) return; - flushing = true; - const batch = buffer.splice(0); - try { - const kept = await applyLabels(db, batch); - if (highestSeq > state.cursor) { - await saveLabelerCursor(db, source.did, highestSeq); - state.cursor = highestSeq; - } - log.log( - `[labels] ${source.did} flushed ${kept}/${batch.length} labels, cursor=${highestSeq}`, - ); - } catch (err) { - log.error(`[labels] ${source.did} flush failed: ${err}`); - } finally { - flushing = false; - } - }; - - const batchSize = pumpOpts.batchSize ?? DEFAULT_BATCH_SIZE; - const flushInterval = pumpOpts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS; - const flushTimer = setInterval(() => { - flush().catch(() => {}); - }, flushInterval); - - const cleanup = () => { - clearInterval(flushTimer); - try { - ws.close(); - } catch { - /* already closed */ - } - }; - - // External abort (persistent mode) — close socket gracefully. - const abortHandler = () => { - cleanup(); - flush().finally(() => resolveDone()); - }; - pumpOpts.signal?.addEventListener("abort", abortHandler, { once: true }); - - // Cron-mode time budget — close socket gracefully when reached. - let deadlineTimer: ReturnType<typeof setTimeout> | undefined; - if (Number.isFinite(timeoutMs)) { - deadlineTimer = setTimeout(() => { - log.log(`[labels] ${source.did} cycle deadline reached, closing`); - cleanup(); - flush().finally(() => resolveDone()); - }, timeoutMs); - } - - ws.addEventListener("error", (ev) => { - cleanup(); - if (deadlineTimer) clearTimeout(deadlineTimer); - pumpOpts.signal?.removeEventListener("abort", abortHandler); - rejectDone(new Error(`WebSocket error: ${(ev as ErrorEvent)?.message ?? "unknown"}`)); - }); - - ws.addEventListener("close", () => { - if (deadlineTimer) clearTimeout(deadlineTimer); - pumpOpts.signal?.removeEventListener("abort", abortHandler); - flush().finally(() => { - clearInterval(flushTimer); - resolveDone(); - }); - }); - - ws.addEventListener("message", async (ev) => { - let bytes: Uint8Array; - if (ev.data instanceof ArrayBuffer) { - bytes = new Uint8Array(ev.data); - } else if (ev.data instanceof Uint8Array) { - bytes = ev.data; - } else { - // Binary-only protocol — text frames shouldn't arrive. - return; - } - const frame = decodeFrame(bytes); - if (!frame) return; - - if (frame.t === "#labels") { - const seq = Number(frame.payload?.seq ?? 0); - const rawLabels = Array.isArray(frame.payload?.labels) ? frame.payload.labels : []; - for (const raw of rawLabels) { - const lab = normalizeLabel(raw, source.did); - if (lab) buffer.push(lab); - } - if (Number.isFinite(seq) && seq > highestSeq) highestSeq = seq; - if (buffer.length >= batchSize) { - flush().catch(() => {}); - } - } else if (frame.t === "#info") { - const name = String(frame.payload?.name ?? ""); - log.log(`[labels] ${source.did} info: ${name}`); - if (name === "OutdatedCursor") { - // Labeler rewound its log — discard our cursor and let the next - // run start from the beginning. We don't reconnect here; the - // caller (or the persistent loop) will pick up the reset on retry. - await resetLabelerCursor(db, source.did); - cleanup(); - } - } else if (frame.op === -1) { - log.warn(`[labels] ${source.did} error frame: ${JSON.stringify(frame.payload)}`); - cleanup(); - } - }); - - // Workers WebSocket doesn't always emit `open`; just await `done` directly. - await done; -} - -function buildWsUrl(httpEndpoint: string, cursor: number | null): string { - const u = new URL("/xrpc/com.atproto.label.subscribeLabels", httpEndpoint); - // wss:// for HTTPS endpoints — the protocol on the labeler service is - // expected to be HTTPS already (validated at resolution time). - u.protocol = u.protocol === "https:" ? "wss:" : "ws:"; - if (cursor !== null) u.searchParams.set("cursor", String(cursor)); - return u.toString(); -} - -interface DecodedFrame { - op: number; - t: string | undefined; - payload: Record<string, unknown>; -} - -/** Decode an atproto subscription frame: two consecutive CBOR objects. - * Header `{ op, t? }`, payload — shape depends on `t`. Returns null on - * decode failure or non-object frames. */ -function decodeFrame(bytes: Uint8Array): DecodedFrame | null { - try { - const [header, rest] = decodeFirst(bytes); - if (!header || typeof header !== "object") return null; - const op = typeof (header as { op?: number }).op === "number" ? (header as { op: number }).op : 1; - const t = typeof (header as { t?: string }).t === "string" ? (header as { t: string }).t : undefined; - const [payload] = decodeFirst(rest); - if (!payload || typeof payload !== "object") return null; - return { op, t, payload: payload as Record<string, unknown> }; - } catch { - return null; - } -} - -/** Coerce a wire `Label` object into our `IncomingLabel` shape. Returns - * null when required fields are missing — we'd rather skip a row than - * insert one with placeholder values. */ -function normalizeLabel(raw: unknown, expectedSrc: string): IncomingLabel | null { - if (!raw || typeof raw !== "object") return null; - const r = raw as Record<string, unknown>; - const src = typeof r.src === "string" ? r.src : null; - const uri = typeof r.uri === "string" ? r.uri : null; - const val = typeof r.val === "string" ? r.val : null; - const cts = typeof r.cts === "string" ? r.cts : null; - if (!src || !uri || !val || !cts) return null; - // A labeler shouldn't emit labels under a different `src` than its own - // DID — drop them rather than poison our table with cross-issuer rows. - if (src !== expectedSrc) return null; - return { - src, - uri, - val, - cts, - cid: typeof r.cid === "string" ? r.cid : undefined, - neg: r.neg === true, - exp: typeof r.exp === "string" ? r.exp : undefined, - sig: r.sig instanceof Uint8Array ? r.sig : undefined, - }; -} diff --git a/packages/contrail-appview/src/core/labels/types.ts b/packages/contrail-appview/src/core/labels/types.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/labels/types.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/persistent.ts b/packages/contrail-appview/src/core/persistent.ts deleted file mode 100644 index fa94104..0000000 --- a/packages/contrail-appview/src/core/persistent.ts +++ /dev/null @@ -1,349 +0,0 @@ -import type { JetstreamSubscription } from "@atcute/jetstream"; -import type { ContrailConfig, IngestEvent, Database, Logger, ResolvedContrailConfig } from "./types"; -import { - getCollectionNsids, - getDependentNsids, - buildFeedTargetCaps, - getFeedMutatingNsids, - jetstreamUrlOption, - resolveConfig, - shortNameForNsid, -} from "./types"; -import { initSchema, getLastCursor, saveCursor, applyEvents } from "./db"; -import { refreshStaleIdentities, applyIdentityEvent } from "./identity"; -import { backfillFollowersFromConstellation } from "./constellation"; -import { - createIngestState, - runFeedPruneSlice, - FEED_PRUNE_RECOVERY_INTERVAL_MS, - maybeOptimize, -} from "./jetstream"; -import type { IngestState } from "./jetstream"; - -/** How often the long-lived persistent loop runs a bounded feed sweep. The - * process stays resident, so this in-memory throttle is reliable here (unlike - * the recycling cron isolate). */ -const FEED_SWEEP_INTERVAL_MS = 10_000; - -export interface PersistentIngestOptions { - batchSize?: number; - flushIntervalMs?: number; - signal?: AbortSignal; - /** Override subscription creation for testing */ - createSubscription?: (cursor: number | null) => JetstreamSubscription; - logger?: Logger; - /** Publish `collection:<nsid>` / `actor:<did>` events for each applied - * public record. Usually supplied by the Contrail instance. */ - pubsub?: import("./realtime/types").PubSub; -} - -function getLogger(config: ContrailConfig, options?: PersistentIngestOptions): Logger { - return options?.logger ?? config.logger ?? console; -} - -export async function runPersistent( - db: Database, - config: ContrailConfig, - options?: PersistentIngestOptions, -): Promise<void> { - // Internals (applyEvents, count updates, query planning) read `_resolved` - // and silently skip features when it's missing. The Contrail class resolves - // in its constructor; callers using this raw export must also get a resolved - // config, so do it defensively here. resolveConfig is idempotent. - if (!(config as ResolvedContrailConfig)._resolved) { - config = resolveConfig(config); - } - const log = getLogger(config, options); - const batchSize = options?.batchSize ?? 50; - const flushIntervalMs = options?.flushIntervalMs ?? 5_000; - const signal = options?.signal; - const state = createIngestState(); - - // Init schema once - if (!state.schemaInitialized) { - await initSchema(db, config); - state.schemaInitialized = true; - } - - // Load known DIDs for dependent collection filtering - const dependentCollections: Set<string> = new Set(getDependentNsids(config)); - let knownDids: Set<string> | undefined; - if (dependentCollections.size > 0) { - const result = await db - .prepare("SELECT did FROM identities") - .all<{ did: string }>(); - knownDids = new Set((result.results ?? []).map((r) => r.did)); - state.cachedKnownDids = knownDids; - log.log(`Loaded ${knownDids.size} known DIDs from database`); - } - - const collections = getCollectionNsids(config); - let reconnectAttempts = 0; - - while (!signal?.aborted) { - const cursor = await getLastCursor(db); - log.log(`Starting persistent ingestion. Cursor: ${cursor ?? "none"}, Collections: ${collections.join(", ")}`); - - try { - await streamAndFlush(db, config, cursor, { - batchSize, - flushIntervalMs, - signal, - collections, - dependentCollections, - knownDids, - newlyKnownDids: new Set<string>(), - state, - log, - createSubscription: options?.createSubscription, - pubsub: options?.pubsub, - }); - reconnectAttempts = 0; - } catch (err) { - if (signal?.aborted) break; - log.error(`Jetstream connection error: ${err}`); - const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30_000); - reconnectAttempts++; - log.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})...`); - await new Promise((r) => setTimeout(r, delay)); - } - } - - log.log("Persistent ingestion stopped"); -} - -interface StreamOptions { - batchSize: number; - flushIntervalMs: number; - signal?: AbortSignal; - collections: string[]; - dependentCollections: Set<string>; - knownDids?: Set<string>; - /** DIDs that crossed from unknown→known during this stream's lifetime. - * Drained on each flush so Constellation reverse-lookups can run for them. */ - newlyKnownDids?: Set<string>; - state: IngestState; - log: Logger; - createSubscription?: (cursor: number | null) => any; - pubsub?: import("./realtime/types").PubSub; -} - -async function streamAndFlush( - db: Database, - config: ContrailConfig, - cursor: number | null, - opts: StreamOptions, -): Promise<void> { - const { batchSize, flushIntervalMs, signal, collections, dependentCollections, knownDids, state, log } = opts; - - const subscription = opts.createSubscription - ? opts.createSubscription(cursor) - : new (await import("@atcute/jetstream")).JetstreamSubscription({ - // Single-instance config → string, so @atcute skips its array-only - // first-connect cursor rollback (see jetstreamUrlOption). - url: jetstreamUrlOption(config.jetstreams ?? []), - wantedCollections: collections, - ...(cursor !== null ? { cursor } : {}), - onConnectionOpen() { log.log("Connected to Jetstream"); }, - onConnectionClose(event: any) { log.log(`Disconnected: ${event.code} ${event.reason}`); }, - onConnectionError(event: any) { log.error("Jetstream error:", event.error); }, - }); - - const buffer: IngestEvent[] = []; - // Guards against overlap between the periodic timer flush and a main-loop - // batchSize-driven flush. The main loop only ever awaits flush() sequentially, - // but the setInterval callback is a second entry point on another tick. - let flushing = false; - - const flush = async () => { - if (flushing) return; - flushing = true; - - try { - if (buffer.length > 0) { - const batch = buffer.splice(0); - await applyEvents(db, batch, config, { pubsub: opts.pubsub }); - - // A feed can only go over cap right after a feed-mutating record is - // applied, so remember whether this batch had one. The sweep below uses - // it to prune promptly (see the cron path in jetstream.ts). - if (config.feeds) { - const feedMutatingNsids = getFeedMutatingNsids(config); - if (batch.some((e) => feedMutatingNsids.has(e.collection))) { - state.feedDirty = true; - } - } - - const lastTimeUs = Math.max(...batch.map((e) => e.time_us)); - await saveCursor(db, lastTimeUs); - - const uniqueDids = [...new Set(batch.map((e) => e.did))]; - if (uniqueDids.length > 0) { - try { - await refreshStaleIdentities(db, uniqueDids, config); - } catch (err) { - log.warn(`Identity refresh failed: ${err}`); - } - } - - // Drain newly-known DIDs and ask Constellation for back-edges. - if (config.feeds && opts.newlyKnownDids && opts.newlyKnownDids.size > 0) { - const drained = [...opts.newlyKnownDids]; - opts.newlyKnownDids.clear(); - for (const subj of drained) { - try { - await backfillFollowersFromConstellation(db, config, subj); - } catch (err) { - log.warn(`[constellation] subject=${subj} failed: ${err}`); - } - } - } - - // Opt-in planner-stat maintenance (gated + persisted cadence). - await maybeOptimize(db, config, log); - - log.log(`Flushed ${batch.length} events. Cursor: ${lastTimeUs}`); - } - - // Bounded, cursored feed prune (see sweepFeedItems / runFeedPruneSlice). - // Runs whether or not this tick had events: ingest-dirty windows prune - // promptly (throttled by the sweep interval), and the recovery interval - // still fires on a fully idle stream — the timer drives this flush, and - // the old "return early when the buffer is empty" path starved recovery, - // so over-cap rows from a lowered cap or a bulk import never drained while - // the stream was quiet. The recovery clock tracks the last *completed* - // full pass (not the last slice), so an overdue pass keeps slicing each - // tick until the cursor wraps rather than advancing one slice per interval. - if (config.feeds) { - const caps = buildFeedTargetCaps(config); - if (caps.size > 0) { - const now = Date.now(); - const dirtyDue = - state.feedDirty && now - state.lastFeedSweepMs > FEED_SWEEP_INTERVAL_MS; - const recoveryDue = - now - state.lastFullFeedPassMs > FEED_PRUNE_RECOVERY_INTERVAL_MS; - if (dirtyDue || recoveryDue) { - const { pruned, done } = await runFeedPruneSlice(db, config); - if (done) state.lastFullFeedPassMs = now; - if (dirtyDue) { - state.feedDirty = false; - state.lastFeedSweepMs = now; - } - if (pruned > 0) { - log.log( - `Pruned ${pruned} feed items (sweep, reason=${dirtyDue ? "ingest" : "recovery"})` - ); - } - } - } - } - } finally { - flushing = false; - } - }; - - // Periodic flush decoupled from the main loop. Runs even when Jetstream is - // idle, which is the whole point — without it, buffered events strand until - // the next event or abort. Errors log and retry next interval rather than - // propagate, so transient DB hiccups don't force a reconnect. - const flushTimer = setInterval(() => { - flush().catch((err) => log.error(`Timer flush failed: ${err}`)); - }, flushIntervalMs); - - const onAbort = () => { - clearInterval(flushTimer); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - - const iterator = subscription[Symbol.asyncIterator](); - - try { - while (!signal?.aborted) { - // Per-iteration abort race so the handler can be removed synchronously - // after the race settles — otherwise addEventListener calls accumulate on - // the signal across the streamAndFlush lifetime. - let abortHandler!: () => void; - const abortPromise = new Promise<IteratorResult<any>>((resolve) => { - abortHandler = () => resolve({ value: undefined, done: true }); - signal?.addEventListener("abort", abortHandler, { once: true }); - }); - - let result: IteratorResult<any>; - try { - result = await Promise.race([iterator.next(), abortPromise]); - } finally { - signal?.removeEventListener("abort", abortHandler); - } - - if (result.done) break; - const event = result.value; - - if (event.kind === "commit") { - const { commit } = event; - - const short = shortNameForNsid(config, commit.collection); - const collectionCfg = short ? config.collections[short] : undefined; - - if (dependentCollections.has(commit.collection) && knownDids) { - if (!knownDids.has(event.did)) continue; - // Subject filter: skip records whose subject DID isn't known. - const subjectField = collectionCfg?.subjectField; - if (subjectField && commit.operation !== "delete") { - const subj = (commit.record as Record<string, unknown> | undefined)?.[ - subjectField - ]; - if (typeof subj === "string" && !knownDids.has(subj)) continue; - } - } - - if (collectionCfg?.recordFilter && commit.operation !== "delete") { - const rec = commit.record as Record<string, unknown> | undefined; - let keep = false; - try { - keep = !!(rec && collectionCfg.recordFilter(rec)); - } catch (err) { - log.warn(`recordFilter threw for ${commit.collection}/${commit.rkey}: ${err}`); - } - if (!keep) continue; - } - - const now = Date.now(); - const uri = `at://${event.did}/${commit.collection}/${commit.rkey}`; - - buffer.push({ - uri, - did: event.did, - time_us: event.time_us, - collection: commit.collection, - operation: commit.operation as "create" | "update" | "delete", - rkey: commit.rkey, - cid: commit.operation === "delete" ? null : commit.cid, - record: commit.operation === "delete" ? null : JSON.stringify(commit.record), - indexed_at: now * 1000, - }); - - if (knownDids && !dependentCollections.has(commit.collection)) { - if (!knownDids.has(event.did)) { - knownDids.add(event.did); - opts.newlyKnownDids?.add(event.did); - } - } - } else if (event.kind === "identity") { - try { - await applyIdentityEvent(db, event.did, event.identity.handle); - } catch (err) { - log.warn(`Identity update failed for ${event.did}: ${err}`); - } - } - - if (buffer.length >= batchSize) { - await flush(); - } - } - } finally { - clearInterval(flushTimer); - signal?.removeEventListener("abort", onAbort); - await iterator.return?.({ value: undefined, done: true }); - await flush(); - } -} diff --git a/packages/contrail-appview/src/core/realtime/durable-object.ts b/packages/contrail-appview/src/core/realtime/durable-object.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/realtime/durable-object.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/realtime/in-memory.ts b/packages/contrail-appview/src/core/realtime/in-memory.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/realtime/in-memory.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/realtime/index.ts b/packages/contrail-appview/src/core/realtime/index.ts deleted file mode 100644 index 05e39ec..0000000 --- a/packages/contrail-appview/src/core/realtime/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -export { registerRealtimeRoutes } from "./router"; -export type { RealtimeRoutesOptions } from "./router"; -export { InMemoryPubSub } from "./in-memory"; -export { DurableObjectPubSub, RealtimePubSubDO } from "./durable-object"; -export type { - DurableObjectId, - DurableObjectNamespace, - DurableObjectStub, - DurableObjectState, -} from "./durable-object"; -export { TicketSigner } from "./ticket"; -export type { TicketPayload } from "./ticket"; -export { wrapWithPublishing } from "./publishing-adapter"; -export { sseResponse } from "./sse"; -export { pumpWebSocket } from "./websocket"; -export type { WebSocketLike } from "./websocket"; -export { mergeAsyncIterables } from "./merge"; -export { resolveTopicForCaller } from "./resolve"; -export type { TopicResolution, TopicResolutionContext, TopicResolutionError } from "./resolve"; -export type { - PubSub, - RealtimeConfig, - RealtimeEvent, - RealtimeEventKind, -} from "./types"; -export { - actorTopic, - collectionTopic, - communityTopic, - parseCommunityTopic, - parseSpaceTopic, - spaceTopic, - isCommunityTopic, - DEFAULT_KEEPALIVE_MS, - DEFAULT_QUEUE_BOUND, - DEFAULT_TICKET_TTL_MS, -} from "./types"; diff --git a/packages/contrail-appview/src/core/realtime/merge.ts b/packages/contrail-appview/src/core/realtime/merge.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/realtime/merge.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/realtime/publishing-adapter.ts b/packages/contrail-appview/src/core/realtime/publishing-adapter.ts deleted file mode 100644 index c5dc61f..0000000 --- a/packages/contrail-appview/src/core/realtime/publishing-adapter.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** Decorator that wraps a spaces StorageAdapter and publishes realtime events - * after successful writes. Spaces and community modules stay unaware of - * realtime; the decorator is the only integration seam. */ - -import type { StorageAdapter, SpaceMemberRow } from "../spaces/types"; -import type { PubSub, RealtimeEvent } from "./types"; -import { communityTopic, spaceTopic } from "./types"; - -export interface PublishingAdapterOptions { - /** Optional lookup: given a space's ownerDid, return true if that DID is a - * community in the local `communities` table. When provided, writes also - * publish to `community:<ownerDid>` so subscribers who expanded that alias - * at ticket-mint time receive the event. - * - * The lookup is expected to be cheap (cached in the caller) — the decorator - * calls it on every write. */ - isCommunityDid?: (did: string) => Promise<boolean> | boolean; -} - -export function wrapWithPublishing( - inner: StorageAdapter, - pubsub: PubSub, - opts: PublishingAdapterOptions = {} -): StorageAdapter { - const publishSpaceAndCommunity = async ( - spaceUri: string, - ownerDid: string | null, - build: (topic: string) => RealtimeEvent - ): Promise<void> => { - await pubsub.publish(build(spaceTopic(spaceUri))); - if (ownerDid && opts.isCommunityDid && (await opts.isCommunityDid(ownerDid))) { - await pubsub.publish(build(communityTopic(ownerDid))); - } - }; - - const ownerOf = async (spaceUri: string): Promise<string | null> => { - const s = await inner.getSpace(spaceUri); - return s?.ownerDid ?? null; - }; - - const wrapped: StorageAdapter = { - ...inner, - createSpace: inner.createSpace.bind(inner), - getSpace: inner.getSpace.bind(inner), - listSpaces: inner.listSpaces.bind(inner), - deleteSpace: inner.deleteSpace.bind(inner), - updateSpaceAppPolicy: inner.updateSpaceAppPolicy.bind(inner), - getMember: inner.getMember.bind(inner), - listMembers: inner.listMembers.bind(inner), - createInvite: inner.createInvite.bind(inner), - listInvites: inner.listInvites.bind(inner), - revokeInvite: inner.revokeInvite.bind(inner), - getInvite: inner.getInvite.bind(inner), - redeemInvite: inner.redeemInvite.bind(inner), - enroll: inner.enroll.bind(inner), - getEnrollment: inner.getEnrollment.bind(inner), - listEnrollments: inner.listEnrollments.bind(inner), - removeEnrollment: inner.removeEnrollment.bind(inner), - getRecord: inner.getRecord.bind(inner), - listRecords: inner.listRecords.bind(inner), - listCollections: inner.listCollections.bind(inner), - putBlobMeta: inner.putBlobMeta.bind(inner), - getBlobMeta: inner.getBlobMeta.bind(inner), - listBlobMeta: inner.listBlobMeta.bind(inner), - deleteBlobMeta: inner.deleteBlobMeta.bind(inner), - findOrphanBlobs: inner.findOrphanBlobs.bind(inner), - - async addMember(spaceUri, did, addedBy) { - await inner.addMember(spaceUri, did, addedBy); - const owner = await ownerOf(spaceUri); - const now = Date.now(); - await publishSpaceAndCommunity(spaceUri, owner, (topic) => ({ - topic, - kind: "member.added", - payload: { space: spaceUri, did }, - ts: now, - })); - }, - - async removeMember(spaceUri, did) { - await inner.removeMember(spaceUri, did); - const owner = await ownerOf(spaceUri); - const now = Date.now(); - await publishSpaceAndCommunity(spaceUri, owner, (topic) => ({ - topic, - kind: "member.removed", - payload: { space: spaceUri, did }, - ts: now, - })); - }, - - async applyMembershipDiff(spaceUri, adds, removes, addedBy) { - await inner.applyMembershipDiff(spaceUri, adds, removes, addedBy); - if (adds.length === 0 && removes.length === 0) return; - const owner = await ownerOf(spaceUri); - const now = Date.now(); - for (const did of adds) { - await publishSpaceAndCommunity(spaceUri, owner, (topic) => ({ - topic, - kind: "member.added", - payload: { space: spaceUri, did }, - ts: now, - })); - } - for (const did of removes) { - await publishSpaceAndCommunity(spaceUri, owner, (topic) => ({ - topic, - kind: "member.removed", - payload: { space: spaceUri, did }, - ts: now, - })); - } - }, - - async putRecord(record) { - await inner.putRecord(record); - const owner = await ownerOf(record.spaceUri); - const now = Date.now(); - // Space records use ms timestamps; listRecords surface uses microseconds - // (time_us). Convert here so subscribers can render a row identically. - const time_us = record.createdAt * 1000; - const uri = `at://${record.authorDid}/${record.collection}/${record.rkey}`; - await publishSpaceAndCommunity(record.spaceUri, owner, (topic) => ({ - topic, - kind: "record.created", - payload: { - uri, - did: record.authorDid, - collection: record.collection, - rkey: record.rkey, - cid: record.cid, - record: record.record, - time_us, - space: record.spaceUri, - }, - ts: now, - })); - }, - - async deleteRecord(spaceUri, collection, authorDid, rkey) { - await inner.deleteRecord(spaceUri, collection, authorDid, rkey); - const owner = await ownerOf(spaceUri); - const now = Date.now(); - const uri = `at://${authorDid}/${collection}/${rkey}`; - await publishSpaceAndCommunity(spaceUri, owner, (topic) => ({ - topic, - kind: "record.deleted", - payload: { uri, did: authorDid, collection, rkey, space: spaceUri }, - ts: now, - })); - }, - }; - return wrapped; -} - -// Keep this import hint for types that downstream code might pull from here. -export type { SpaceMemberRow }; diff --git a/packages/contrail-appview/src/core/realtime/query-filter.ts b/packages/contrail-appview/src/core/realtime/query-filter.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/realtime/query-filter.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/realtime/resolve.ts b/packages/contrail-appview/src/core/realtime/resolve.ts deleted file mode 100644 index c16abe5..0000000 --- a/packages/contrail-appview/src/core/realtime/resolve.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** Resolve a raw topic request (as given by a caller) to the concrete set of - * delivery topics they are authorized to subscribe to. - * - * Rules (v1): - * - `space:<uri>` → allowed iff caller is owner or a member of the space. - * - `community:<did>` → expanded to `space:<uri>` for every space in the - * community reachable by the caller (direct grants - * or via delegation). `resolveReachableSpaces` - * already has exactly this semantics. - * - `actor:<did>` → self-only in v1. - * - `collection:<nsid>` → rejected unless the deployment opts in - * (not yet implemented). */ - -import type { StorageAdapter } from "../spaces/types"; -import type { CommunityProbe } from "../community-integration"; -import { spaceTopic, parseCommunityTopic, parseSpaceTopic } from "./types"; - -export interface TopicResolutionContext { - /** May be null when the deployment has no spaces module — in that case - * `space:` and `community:` topics are NotSupported. Public topics - * (`collection:`, `actor:`) still resolve. */ - spaces: StorageAdapter | null; - /** May be null if the community module is not enabled. */ - community: CommunityProbe | null; -} - -export interface TopicResolution { - ok: true; - topics: string[]; -} - -export interface TopicResolutionError { - ok: false; - error: "Forbidden" | "InvalidRequest" | "NotFound" | "NotSupported"; - reason: string; -} - -export async function resolveTopicForCaller( - rawTopic: string, - callerDid: string, - ctx: TopicResolutionContext -): Promise<TopicResolution | TopicResolutionError> { - // space:<uri> - const spaceUri = parseSpaceTopic(rawTopic); - if (spaceUri) { - if (!ctx.spaces) { - return { ok: false, error: "NotSupported", reason: "spaces-module-disabled" }; - } - const space = await ctx.spaces.getSpace(spaceUri); - if (!space) return { ok: false, error: "NotFound", reason: "space-not-found" }; - if (space.ownerDid === callerDid) return { ok: true, topics: [rawTopic] }; - const member = await ctx.spaces.getMember(spaceUri, callerDid); - if (!member) return { ok: false, error: "Forbidden", reason: "not-member" }; - return { ok: true, topics: [rawTopic] }; - } - - // community:<did> - const communityDid = parseCommunityTopic(rawTopic); - if (communityDid) { - if (!ctx.community || !ctx.spaces) { - return { ok: false, error: "NotSupported", reason: "community-module-disabled" }; - } - const row = await ctx.community.getCommunity(communityDid); - if (!row) return { ok: false, error: "NotFound", reason: "community-not-found" }; - const reachable = await ctx.community.resolveReachableSpaces(callerDid); - // Filter to spaces owned by THIS community — reachable may include spaces - // from other communities via cross-community delegation. - const ownedList = await ctx.spaces.listSpaces({ ownerDid: communityDid, limit: 1000 }); - const owned: Set<string> = new Set(ownedList.spaces.map((s) => s.uri)); - const topics: string[] = []; - for (const uri of reachable) { - if (owned.has(uri)) topics.push(spaceTopic(uri)); - } - if (topics.length === 0) { - return { ok: false, error: "Forbidden", reason: "no-reachable-spaces-in-community" }; - } - return { ok: true, topics }; - } - - // actor:<did> — public stream of records authored by this DID. - // Any caller can subscribe (parallels listRecords with an `actor` filter). - if (rawTopic.startsWith("actor:")) { - return { ok: true, topics: [rawTopic] }; - } - - // collection:<nsid> — public firehose for this collection. - if (rawTopic.startsWith("collection:")) { - return { ok: true, topics: [rawTopic] }; - } - - return { ok: false, error: "InvalidRequest", reason: "unknown-topic" }; -} diff --git a/packages/contrail-appview/src/core/realtime/router.ts b/packages/contrail-appview/src/core/realtime/router.ts deleted file mode 100644 index d10e45e..0000000 --- a/packages/contrail-appview/src/core/realtime/router.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** Realtime XRPC routes: ticket mint + subscribe (SSE | WS). */ - -import type { Context, Hono, MiddlewareHandler } from "hono"; -import type { ContrailConfig } from "../types"; -import type { ServiceAuth } from "../spaces/auth"; -import type { StorageAdapter } from "../spaces/types"; -import type { CommunityProbe } from "../community-integration"; -import { InMemoryPubSub } from "./in-memory"; -import { TicketSigner } from "./ticket"; -import { sseResponse } from "./sse"; -import { pumpWebSocket, type WebSocketLike } from "./websocket"; -import { mergeAsyncIterables } from "./merge"; -import { resolveTopicForCaller } from "./resolve"; -import type { PubSub, RealtimeEvent } from "./types"; -import { DEFAULT_TICKET_TTL_MS, DEFAULT_KEEPALIVE_MS } from "./types"; - -export interface RealtimeRoutesOptions { - /** Auth middleware for `<ns>.realtime.ticket` and for JWT-based bot - * subscriptions to private topics. Null when no JWT verifier is available - * (deployments without a spaces config) — in that case, private-topic - * subscribe paths return NotSupported and public topics still work without - * auth. */ - authMiddleware: MiddlewareHandler | null; - pubsub?: PubSub; -} - -/** Public topics: subscribable without any auth. Mirrors listRecords - * semantics — no JWT means "public records only". */ -function isPublicTopic(topic: string): boolean { - return topic.startsWith("collection:") || topic.startsWith("actor:"); -} - -/** WebSocketPair exists on Cloudflare Workers; on Node/Bun it's absent. - * When absent, a platform-provided WebSocket accept hook is used instead. */ -interface WebSocketPairCtor { - new (): { 0: WebSocketLike & { accept?: () => void }; 1: WebSocketLike & { accept?: () => void } }; -} - -export function registerRealtimeRoutes( - app: Hono, - config: ContrailConfig, - spaces: StorageAdapter | null, - community: CommunityProbe | null, - options: RealtimeRoutesOptions -): void { - const cfg = config.realtime; - if (!cfg) return; - - const pubsub: PubSub = options.pubsub ?? cfg.pubsub ?? new InMemoryPubSub({ queueBound: cfg.queueBound }); - const signer = new TicketSigner(cfg.ticketSecret); - const ticketTtl = cfg.ticketTtlMs ?? DEFAULT_TICKET_TTL_MS; - const keepaliveMs = cfg.keepaliveMs ?? DEFAULT_KEEPALIVE_MS; - - const NS = `${config.namespace}.realtime`; - - // POST /<ns>.realtime.ticket — { topic } → { ticket, topics, expiresAt } - // Ticket-minting exists so browsers (which can't set Authorization on - // EventSource) can subscribe to *private* topics. Public topics - // (collection:, actor:) don't need tickets — subscribe with `?topic=` directly. - if (options.authMiddleware) { - const authMw = options.authMiddleware; - app.post(`/xrpc/${NS}.ticket`, authMw, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as { topic?: string } | null; - if (!body?.topic) { - return c.json({ error: "InvalidRequest", message: "topic required" }, 400); - } - const resolved = await resolveTopicForCaller(body.topic, sa.issuer, { spaces, community }); - if (!resolved.ok) { - const status = resolved.error === "NotFound" ? 404 : resolved.error === "Forbidden" ? 403 : 400; - return c.json({ error: resolved.error, reason: resolved.reason }, status); - } - const ticket = await signer.sign({ - topics: resolved.topics, - did: sa.issuer, - ttlMs: ticketTtl, - }); - return c.json({ - ticket, - topics: resolved.topics, - expiresAt: Date.now() + ticketTtl, - }); - }); - } - - // GET /<ns>.realtime.subscribe — SSE or WS. - // - // Three access paths, all land on the same stream: - // - `?topic=collection:<nsid>` or `?topic=actor:<did>` — *public*, no auth. - // Mirrors listRecords semantics. - // - `?ticket=<jwt>` — presented by browsers, minted via `.ticket` after - // a JWT-authenticated call. Only used for private topics. - // - `Authorization: Bearer <jwt>` + `?topic=space:<uri>` — server-side - // bots can skip the ticket dance and go straight to subscribe. - app.get(`/xrpc/${NS}.subscribe`, async (c) => { - const url = new URL(c.req.url); - const ticketParam = url.searchParams.get("ticket"); - const collectionFilter = url.searchParams.get("collection"); - const topicParam = url.searchParams.get("topic"); - - let callerDid: string | null = null; - let topics: string[]; - - if (ticketParam) { - const payload = await signer.verify(ticketParam); - if (!payload) { - return c.json({ error: "AuthRequired", reason: "invalid-or-expired-ticket" }, 401); - } - callerDid = payload.did; - topics = payload.topics; - // Optional: narrow to topics the query explicitly requests. - if (topicParam) { - if (!payload.topics.includes(topicParam)) { - return c.json({ error: "Forbidden", reason: "topic-not-in-ticket" }, 403); - } - topics = [topicParam]; - } - } else if (topicParam && isPublicTopic(topicParam)) { - // Public subscribe — no auth required. Jetstream ingestion publishes - // record events to collection:/actor: topics directly. - topics = [topicParam]; - } else { - // JWT path for private-topic bots. If no auth middleware is available - // (deployment has no spaces config), private topics aren't offered. - if (!options.authMiddleware) { - return c.json( - { - error: "InvalidRequest", - reason: "private-topic-without-auth", - message: - "Subscribing to space:/community: topics requires a JWT verifier; only public topics (collection:, actor:) are available on this deployment.", - }, - 400 - ); - } - let authed = false; - await options.authMiddleware(c, async () => { - authed = true; - }); - if (!authed) return c.res; // middleware already responded with 401 - const sa = getAuth(c); - callerDid = sa.issuer; - if (!topicParam) { - return c.json({ error: "InvalidRequest", message: "topic required" }, 400); - } - const resolved = await resolveTopicForCaller(topicParam, callerDid, { spaces, community }); - if (!resolved.ok) { - const status = resolved.error === "NotFound" ? 404 : resolved.error === "Forbidden" ? 403 : 400; - return c.json({ error: resolved.error, reason: resolved.reason }, status); - } - topics = resolved.topics; - } - - if (topics.length === 0) { - return c.json({ error: "InvalidRequest", reason: "no-topics" }, 400); - } - - // Build the merged iterable, with an inline filter that closes the stream - // on a matching `member.removed` event (self-kick on revocation). - const ac = new AbortController(); - const signals: AbortSignal[] = [ac.signal]; - const reqSignal = c.req.raw.signal; - if (reqSignal) signals.push(reqSignal); - const combined = anySignal(signals); - - const sources = topics.map((t) => pubsub.subscribe(t, combined)); - const merged = withSelfKickAndFilter( - mergeAsyncIterables(sources, combined), - callerDid, - collectionFilter, - ac - ); - - // Content negotiation: Upgrade: websocket → WS, else SSE. - if (c.req.header("Upgrade")?.toLowerCase() === "websocket") { - const Pair = (globalThis as unknown as { WebSocketPair?: WebSocketPairCtor }) - .WebSocketPair; - if (!Pair) { - return c.json( - { error: "NotSupported", reason: "websockets-require-worker-or-ws-adapter" }, - 426 - ); - } - const pair = new Pair(); - const clientWs = pair[0]; - const serverWs = pair[1]; - serverWs.accept?.(); - // Pump in the background; don't await. - void pumpWebSocket(serverWs, merged, combined, { keepaliveMs }); - return new Response(null, { - status: 101, - // Hono/undici-compat: some runtimes honor `webSocket` on the init. - // @ts-expect-error - Workers-specific init field - webSocket: clientWs, - }); - } - - return sseResponse(merged, combined, { keepaliveMs }); - }); -} - -// ============================================================================ -// Helpers -// ============================================================================ - -function getAuth(c: Context): ServiceAuth { - const a = c.get("serviceAuth") as ServiceAuth | undefined; - if (!a) throw new Error("service auth not set"); - return a; -} - -/** Merge multiple AbortSignals into one. Aborts when any source aborts. */ -function anySignal(signals: AbortSignal[]): AbortSignal { - const ac = new AbortController(); - for (const s of signals) { - if (s.aborted) { - ac.abort(); - return ac.signal; - } - s.addEventListener("abort", () => ac.abort(), { once: true }); - } - return ac.signal; -} - -/** Wrap an iterable: drop events that don't pass the collection filter (if - * any), and close the outer controller as soon as we see a `member.removed` - * for the caller's own DID. `callerDid` may be null on public subscriptions - * (anonymous) — in that case self-kick is not applicable. */ -function withSelfKickAndFilter( - source: AsyncIterable<RealtimeEvent>, - callerDid: string | null, - collectionFilter: string | null, - ac: AbortController -): AsyncIterable<RealtimeEvent> { - return { - async *[Symbol.asyncIterator]() { - for await (const event of source) { - if (event.kind === "member.removed" && event.payload.did === callerDid) { - // Deliver the kick event so the client sees why, then close. - yield event; - ac.abort(); - return; - } - if ( - collectionFilter && - (event.kind === "record.created" || event.kind === "record.deleted") && - event.payload.collection !== collectionFilter - ) { - continue; - } - yield event; - } - }, - }; -} diff --git a/packages/contrail-appview/src/core/realtime/sse.ts b/packages/contrail-appview/src/core/realtime/sse.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/realtime/sse.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/realtime/ticket.ts b/packages/contrail-appview/src/core/realtime/ticket.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/realtime/ticket.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/realtime/types.ts b/packages/contrail-appview/src/core/realtime/types.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/realtime/types.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/realtime/websocket.ts b/packages/contrail-appview/src/core/realtime/websocket.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/realtime/websocket.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/refresh.ts b/packages/contrail-appview/src/core/refresh.ts deleted file mode 100644 index 2c8a343..0000000 --- a/packages/contrail-appview/src/core/refresh.ts +++ /dev/null @@ -1,267 +0,0 @@ -import type {} from "@atcute/atproto"; -/** - * Fresh refresh: re-walk every known DID's PDS for every configured collection - * and reconcile against what's in our DB. Unlike `backfillPending`, this - * ignores the `backfills` state machine — it's a "check what we might have - * missed" pass, not a resumable bulk load. - * - * Two categories of delta are counted: - * - missing — the PDS has a record we don't - * - staleUpdates — we have the same URI but a different CID, *and* our - * copy's `indexed_at` is older than `ignoreWindowMs` - * - * The ignore window exists because Jetstream can run ~seconds behind the - * PDS; without the window, "in-sync but racy" writes would show up as - * misses every run. Records inside the window are still applied (they - * might be legit updates), just not counted toward stats. - * - * Typical uses: - * - dev: "I ran backfillAll on Monday, haven't touched it for a week, - * how much did jetstream miss?" - * - prod: "we had jetstream outage yesterday, what did we drop?" - */ -import { type Did, type Nsid } from "@atcute/lexicons"; -import { isDid, isNsid } from "@atcute/lexicons/syntax"; - -import type { Client } from "@atcute/client"; -import type { ContrailConfig, Database, IngestEvent } from "./types.js"; -import { applyEvents, lookupExistingRecords } from "./db/records.js"; -import { getClient } from "./client.js"; - -const PAGE_SIZE = 100; -const REQUEST_TIMEOUT_MS = 10_000; - -async function withTimeout<T>(fn: () => Promise<T>, ms: number): Promise<T> { - return Promise.race([ - fn(), - new Promise<never>((_, rej) => - setTimeout(() => rej(new Error(`timeout after ${ms}ms`)), ms) - ), - ]); -} - -export interface CollectionStats { - /** Record exists on PDS but was absent from our DB. */ - missing: number; - /** Record exists in our DB with a different CID than the PDS, and our - * copy was written before the ignore window. */ - staleUpdates: number; - /** Record is present and matches (same CID, or within ignore window). */ - inSync: number; -} - -export interface RefreshProgress { - usersComplete: number; - usersTotal: number; - usersFailed: number; - recordsScanned: number; -} - -export interface RefreshResult { - /** Per-NSID stats. */ - byCollection: Record<string, CollectionStats>; - /** Sum across every NSID. */ - total: CollectionStats; - usersScanned: number; - usersFailed: number; - /** Effective ignore window used for classification, in ms. */ - ignoreWindowMs: number; - /** Wall-clock runtime, in ms. */ - elapsedMs: number; -} - -export interface RefreshOptions { - /** How many DIDs to fan out against in parallel. Default: 50. */ - concurrency?: number; - /** Records whose local `indexed_at` is within this window of `now` are - * still upserted but excluded from `staleUpdates` counts — guards - * against jetstream being briefly behind the PDS. Default: 60_000 ms. */ - ignoreWindowMs?: number; - /** Override which NSIDs to walk. Default: every `config.collections[*].collection`. */ - nsids?: string[]; - /** Optional progress callback (fires per completed DID). */ - onProgress?: (p: RefreshProgress) => void; - /** Max attempts per listRecords request. Default: 3. */ - maxRetries?: number; - /** Per-request timeout in ms. Default: 10000. */ - requestTimeout?: number; -} - -function emptyStats(): CollectionStats { - return { missing: 0, staleUpdates: 0, inSync: 0 }; -} - -export async function refresh( - db: Database, - config: ContrailConfig, - options?: RefreshOptions -): Promise<RefreshResult> { - const concurrency = options?.concurrency ?? 50; - const ignoreWindowMs = options?.ignoreWindowMs ?? 60_000; - const requestTimeout = options?.requestTimeout ?? REQUEST_TIMEOUT_MS; - const maxRetries = options?.maxRetries ?? 3; - const startedAt = Date.now(); - - // Default to every configured collection NSID. Profiles are already - // included because `resolveConfig` adds them to `config.collections`. - const nsids = - options?.nsids ?? - Object.entries(config.collections).map(([short, c]) => c.collection ?? short); - - const byCollection: Record<string, CollectionStats> = {}; - for (const nsid of nsids) byCollection[nsid] = emptyStats(); - const total: CollectionStats = emptyStats(); - - // Known DIDs = every author we've ever written for. `backfills` is a - // superset (it also includes failed/pending users that we never got - // records from), which is actually what we want — if we tried and - // failed before, we might succeed now. - const didRows = await db - .prepare("SELECT DISTINCT did FROM backfills") - .all<{ did: string }>(); - const dids = (didRows.results ?? []) - .map((r) => r.did) - .filter((d) => isDid(d)); - - const usersTotal = dids.length; - let usersComplete = 0; - let usersFailed = 0; - let recordsScanned = 0; - - const ignoreBeforeUs = (Date.now() - ignoreWindowMs) * 1000; - - const processDid = async (did: string): Promise<void> => { - let client: Client; - try { - client = await withTimeout( - () => getClient(did as Did, db, config), - requestTimeout - ); - } catch { - usersFailed++; - return; - } - - for (const nsid of nsids) { - if (!isNsid(nsid)) continue; - let cursor: string | undefined; - while (true) { - let pageRecords: Array<{ uri: string; cid: string; value: unknown }>; - let nextCursor: string | undefined; - try { - // Retry listRecords: transient PDS failures are expected during refresh - let attempt = 0; - // eslint-disable-next-line no-constant-condition - while (true) { - try { - const res = await withTimeout( - () => - client.get("com.atproto.repo.listRecords", { - params: { - repo: did as Did, - collection: nsid as Nsid, - limit: PAGE_SIZE, - cursor, - }, - }), - requestTimeout - ); - if (!res.ok) { - // 400s on a collection the user doesn't have are fine; stop - // paging this collection for this user. - pageRecords = []; - nextCursor = undefined; - break; - } - pageRecords = res.data.records; - nextCursor = res.data.cursor ?? undefined; - break; - } catch (err) { - if (attempt >= maxRetries) throw err; - attempt++; - await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); - } - } - } catch { - // Give up on this collection for this user; keep going. - break; - } - - if (pageRecords.length === 0) break; - - const now = Date.now(); - const events: IngestEvent[] = pageRecords.map((r) => ({ - uri: r.uri, - did, - collection: nsid, - rkey: r.uri.split("/").pop()!, - operation: "create" as const, - cid: r.cid, - record: JSON.stringify(r.value), - time_us: now * 1000, - indexed_at: now * 1000, - })); - - const existing = await lookupExistingRecords( - db, - events.map((e) => ({ uri: e.uri, collection: e.collection })), - false, - config - ); - - for (const ev of events) { - const ex = existing.get(ev.uri); - if (!ex) { - byCollection[nsid].missing++; - total.missing++; - } else if (ex.cid !== ev.cid) { - const inWindow = - ex.indexed_at !== null && ex.indexed_at >= ignoreBeforeUs; - if (inWindow) { - byCollection[nsid].inSync++; - total.inSync++; - } else { - byCollection[nsid].staleUpdates++; - total.staleUpdates++; - } - } else { - byCollection[nsid].inSync++; - total.inSync++; - } - } - - // Upsert everything — even records "inside the ignore window" - // might genuinely have a new CID; we just don't count them as a - // miss-signal. Skip feed fanout since this is a catch-up, not a - // user-visible write. - await applyEvents(db, events, config, { skipFeedFanout: true }); - recordsScanned += events.length; - - cursor = nextCursor; - if (!cursor) break; - } - } - - usersComplete++; - options?.onProgress?.({ - usersComplete, - usersTotal, - usersFailed, - recordsScanned, - }); - }; - - for (let i = 0; i < dids.length; i += concurrency) { - const batch = dids.slice(i, i + concurrency); - await Promise.allSettled(batch.map(processDid)); - } - - return { - byCollection, - total, - usersScanned: usersComplete, - usersFailed, - ignoreWindowMs, - elapsedMs: Date.now() - startedAt, - }; -} diff --git a/packages/contrail-appview/src/core/router/admin.ts b/packages/contrail-appview/src/core/router/admin.ts deleted file mode 100644 index a3b754b..0000000 --- a/packages/contrail-appview/src/core/router/admin.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Hono } from "hono"; -import type { ContrailConfig, Database } from "../types"; -import { getCollectionShortNames, recordsTableName, nsidForShortName } from "../types"; -import { getLastCursor } from "../db"; - -export function registerAdminRoutes( - app: Hono, - db: Database, - config: ContrailConfig -): void { - const ns = config.namespace; - - app.get(`/xrpc/${ns}.getCursor`, async (c) => { - const cursor = await getLastCursor(db); - if (cursor === null) return c.json({ cursor: null }); - - const dateMs = Math.floor(cursor / 1000); - return c.json({ - time_us: cursor, - date: new Date(dateMs).toISOString(), - seconds_ago: Math.floor((Date.now() - dateMs) / 1000), - }); - }); - - app.get(`/xrpc/${ns}.getOverview`, async (c) => { - const collections: { collection: string; records: number; unique_users: number }[] = []; - - for (const short of getCollectionShortNames(config)) { - const table = recordsTableName(short); - const nsid = nsidForShortName(config, short) ?? short; - const row = await db - .prepare(`SELECT COUNT(*) as records, COUNT(DISTINCT did) as unique_users FROM ${table}`) - .first<{ records: number; unique_users: number }>(); - if (row) { - collections.push({ collection: nsid, records: row.records, unique_users: row.unique_users }); - } - } - - return c.json({ - total_records: collections.reduce((sum, col) => sum + col.records, 0), - collections, - }); - }); -} diff --git a/packages/contrail-appview/src/core/router/collection.ts b/packages/contrail-appview/src/core/router/collection.ts deleted file mode 100644 index 2ec0462..0000000 --- a/packages/contrail-appview/src/core/router/collection.ts +++ /dev/null @@ -1,1263 +0,0 @@ -import type { Context, Hono } from "hono"; -import type { ContrailConfig, ResolvedContrailConfig, Database, RecordRow, QueryableField, RecordSource, RelationConfig } from "../types"; -import { - getCollectionShortNames, - countColumnName, - groupedCountColumnName, - recordsTableName, - nsidForShortName, - getCollectionMethods, -} from "../types"; -import { queryRecords, queryAcrossSources } from "../db"; -import type { SortOption } from "../db/records"; -import { backfillUser } from "../backfill"; -import { resolveHydrates, resolveReferences, parseHydrateParams } from "./hydrate"; -import { resolveProfiles, collectDids } from "./profiles"; -import { resolveActor } from "../identity"; -import type { FormattedRecord } from "./helpers"; -import { formatRecord, parseIntParam, fieldToParam } from "./helpers"; -import { selectAcceptedLabelers } from "../labels/select"; -import { hydrateLabels } from "../labels/hydrate"; -import { verifyServiceAuthRequest, extractInviteToken, checkInviteReadGrant } from "../spaces/auth"; -import { checkAccess } from "../spaces/acl"; -import { hashInviteToken } from "../invite/token"; -import type { SpacesContext } from "."; -import type { Nsid } from "@atcute/lexicons"; -import { parseResourceUri } from "@atcute/lexicons/syntax"; -import type { RealtimeEvent } from "../realtime/types"; -import { sseResponse } from "../realtime/sse"; -import { spaceTopic, communityTopic, parseSpaceTopic } from "../realtime/types"; -import type { SubscriberQuerySpec } from "../realtime/durable-object"; -import { DurableObjectPubSub } from "../realtime/durable-object"; -import { TicketSigner, type TicketQuerySpec } from "../realtime/ticket"; -import { resolveTopicForCaller } from "../realtime/resolve"; -import { mergeAsyncIterables } from "../realtime/merge"; -import type { CommunityProbe } from "../community-integration"; -import { getRelationField, getNestedValue } from "../types"; - -/** Scope of a watch stream. - * - `space`: single permissioned space — one `space:<uri>` topic. - * - `actor`: records authored by `actor` across multiple spaces — the - * resolver expanded these to a per-caller subset of space topics (plus - * `actor:<did>` for public records). Events outside `allowedSpaces` - * are filtered out. */ -type WatchScope = - | { kind: "space"; spaceUri: string } - | { - kind: "actor"; - actor: string; - /** Concrete pubsub topics to subscribe to (from resolveTopicForCaller). */ - topics: string[]; - /** Space URIs the caller can see. Events with `space` outside this - * set are dropped. Undefined `space` on an event (public record) - * is allowed only when `actor` topic is in `topics`. */ - allowedSpaces: Set<string>; - }; - -/** Shared implementation of the watchRecords snapshot+live loop. Called by - * both transport branches (SSE and Worker-terminated WS). The caller owns - * the actual socket/stream and provides a `send(kind, data)` closure. */ -async function runQueryStream(opts: { - send: (kind: string, data: unknown) => void; - abort: AbortController; - scope: WatchScope; - callerDid: string | undefined; - params: URLSearchParams; - db: Database; - config: ContrailConfig; - collection: string; - colNsid: string; - pubsub: import("../realtime/types").PubSub; - relations: Record<string, import("../types").RelationConfig>; - references: Record<string, import("../types").ReferenceConfig>; - childCollectionMap: Map< - string, - { relName: string; matchField: string; matchMode: "uri" | "did" } - >; -}): Promise<void> { - const { - send, - abort, - scope, - callerDid, - params, - db, - config, - collection, - colNsid, - pubsub, - relations, - references, - childCollectionMap - } = opts; - - // Predicate: does this event belong in the caller's scope? - const inScope = (space: string | undefined): boolean => { - if (scope.kind === "space") return space === scope.spaceUri; - if (space == null) return false; // actor mode: require space for now (app topic) - return scope.allowedSpaces.has(space); - }; - - const hydrateSpec = parseHydrateParams(params, relations, references); - const trackHydration = Object.keys(hydrateSpec.relations).length > 0; - const parentUris = new Set<string>(); - const parentDids = new Set<string>(); - const childToParent = new Map<string, { parentUri: string; relName: string }>(); - - const primaryUri = (payload: { uri: string }) => payload.uri; - - const handleChildEvent = (event: RealtimeEvent) => { - if (!trackHydration) return; - if (event.kind !== "record.created" && event.kind !== "record.deleted") return; - const meta = childCollectionMap.get(event.payload.collection); - if (!meta) return; - if (!(hydrateSpec.relations as Record<string, number>)[meta.relName]) return; - if (!inScope(event.payload.space)) return; - // Actor mode: additionally require the record's author match our actor - // (the caller might share spaces with other authors — we only surface - // records by the actor under watch). - if (scope.kind === "actor" && event.payload.did !== scope.actor) return; - - if (event.kind === "record.created") { - const matched = getNestedValue(event.payload.record, meta.matchField); - if (matched == null) return; - const parent = - meta.matchMode === "did" - ? parentDids.has(String(matched)) - ? `at://${String(matched)}/${colNsid}/_` - : null - : parentUris.has(String(matched)) - ? String(matched) - : null; - if (!parent) return; - childToParent.set(event.payload.rkey, { - parentUri: parent, - relName: meta.relName - }); - send("hydration.added", { - parentUri: parent, - relation: meta.relName, - child: { - uri: primaryUri(event.payload), - did: event.payload.did, - rkey: event.payload.rkey, - collection: event.payload.collection, - cid: event.payload.cid, - value: event.payload.record, - space: event.payload.space - } - }); - } else { - const info = childToParent.get(event.payload.rkey); - if (!info) return; - childToParent.delete(event.payload.rkey); - send("hydration.removed", { - parentUri: info.parentUri, - relation: info.relName, - childRkey: event.payload.rkey, - childDid: event.payload.did - }); - } - }; - - const handleLive = (event: RealtimeEvent) => { - if (abort.signal.aborted) return; - if (event.kind === "member.removed" && event.payload.did === callerDid) { - send("member.removed", event.payload); - abort.abort(); - return; - } - if (event.kind !== "record.created" && event.kind !== "record.deleted") return; - if (!inScope(event.payload.space)) return; - if (scope.kind === "actor" && event.payload.did !== scope.actor) return; - - if (event.payload.collection !== colNsid) { - handleChildEvent(event); - return; - } - - const nowUs = event.ts * 1000; - const uri = primaryUri(event.payload); - if (event.kind === "record.created") { - parentUris.add(uri); - parentDids.add(event.payload.did); - send("record.created", { - record: { - uri, - did: event.payload.did, - rkey: event.payload.rkey, - collection: event.payload.collection, - cid: event.payload.cid, - value: event.payload.record, - time_us: nowUs, - indexed_at: event.ts, - space: event.payload.space - } - }); - } else { - parentUris.delete(uri); - send("record.deleted", { - uri, - did: event.payload.did, - rkey: event.payload.rkey - }); - } - }; - - // Subscribe: one topic for space-scoped, merge across all topics for - // actor-scoped. `mergeAsyncIterables` exists for exactly this case. - let iter: AsyncIterable<RealtimeEvent>; - if (scope.kind === "space") { - iter = pubsub.subscribe(spaceTopic(scope.spaceUri), abort.signal); - } else { - const sources = scope.topics.map((t) => pubsub.subscribe(t, abort.signal)); - iter = mergeAsyncIterables(sources, abort.signal); - } - - const buffered: RealtimeEvent[] = []; - let snapshotDone = false; - - const pump = (async () => { - try { - for await (const event of iter) { - if (abort.signal.aborted) break; - if (!snapshotDone) buffered.push(event); - else handleLive(event); - } - } catch { - /* aborted or errored */ - } - })(); - - try { - send( - "snapshot.start", - scope.kind === "space" - ? { spaceUri: scope.spaceUri, collection: colNsid } - : { actor: scope.actor, collection: colNsid } - ); - const snapshotSpaces = - scope.kind === "space" ? [scope.spaceUri] : Array.from(scope.allowedSpaces); - const result = await runPipeline(db, config, collection, params, undefined, snapshotSpaces); - for (const record of result.records) { - if (abort.signal.aborted) break; - if (typeof record.uri === "string") parentUris.add(record.uri); - if (typeof record.did === "string") parentDids.add(record.did); - for (const [relName] of Object.entries(hydrateSpec.relations)) { - const hydratedGroups = (record as Record<string, unknown>)[relName]; - if (!hydratedGroups) continue; - const flat: Array<{ rkey?: string }> = Array.isArray(hydratedGroups) - ? (hydratedGroups as Array<{ rkey?: string }>) - : (Object.values(hydratedGroups as Record<string, unknown>).flat() as Array<{ - rkey?: string; - }>); - for (const child of flat) { - if (child?.rkey) { - childToParent.set(child.rkey, { - parentUri: record.uri as string, - relName - }); - } - } - } - send("snapshot.record", { record }); - } - send("snapshot.end", { cursor: result.cursor }); - snapshotDone = true; - for (const event of buffered) handleLive(event); - } catch (err) { - send("error", { - message: err instanceof Error ? err.message : String(err) - }); - abort.abort(); - } - - await pump.catch(() => {}); -} - -export async function runPipeline( - db: Database, - config: ContrailConfig, - collection: string, - params: URLSearchParams, - source?: RecordSource, - spaceUris?: string[], - /** Optional headers from the originating request — used for label - * hydration (`atproto-accept-labelers`). Other entry points pass nothing - * and labels are gated by `?labelers=` / config defaults. */ - headers?: Headers -): Promise<{ records: FormattedRecord[]; cursor?: string; profiles?: any[]; labelersApplied?: string[] }> { - const colConfig = config.collections[collection]; - if (!colConfig) throw new Error(`Unknown collection: ${collection}`); - - const relations = colConfig.relations ?? {}; - const references = colConfig.references ?? {}; - const queryableFields: Record<string, QueryableField> = - (config as ResolvedContrailConfig)._resolved?.queryable[collection] ?? colConfig.queryable ?? {}; - - const limit = parseIntParam(params.get("limit"), 50); - const cursor = params.get("cursor") || undefined; - const actor = params.get("actor") || params.get("did") || undefined; - const wantProfiles = params.get("profiles") === "true"; - - let did: string | undefined; - if (actor) { - const resolved = await resolveActor(db, actor, config); - if (!resolved) throw new Error("Could not resolve actor"); - did = resolved; - // backfillUser expects the record NSID (for PDS calls), not the short name. - const nsid = nsidForShortName(config, collection) ?? collection; - await backfillUser(db, did, nsid, Date.now() + 3_000, config, { - maxRetries: 0, - requestTimeout: 3_000, - }); - } - - const filters: Record<string, string> = {}; - const rangeFilters: Record<string, { min?: string; max?: string }> = {}; - for (const [field, fieldConfig] of Object.entries(queryableFields)) { - const param = fieldToParam(field); - if (fieldConfig.type === "range") { - const min = params.get(`${param}Min`); - const max = params.get(`${param}Max`); - if (min || max) { - rangeFilters[field] = {}; - if (min) rangeFilters[field].min = min; - if (max) rangeFilters[field].max = max; - } - } else { - const value = params.get(param); - if (value) filters[field] = value; - } - } - - const countFilters: Record<string, number> = {}; - const relMap = (config as ResolvedContrailConfig)._resolved?.relations[collection] ?? {}; - for (const [relName, rel] of Object.entries(relations)) { - const totalMin = parseIntParam(params.get(`${relName}CountMin`)); - if (totalMin != null) countFilters[rel.collection] = totalMin; - const mapping = relMap[relName]; - if (mapping) { - const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - for (const [shortName, fullToken] of Object.entries(mapping.groups)) { - const val = parseIntParam(params.get(`${relName}${capitalize(shortName)}CountMin`)); - if (val != null) countFilters[fullToken] = val; - } - } - } - - let sort: SortOption | undefined; - const sortParam = params.get("sort"); - if (sortParam) { - const orderParam = params.get("order"); - - const fieldEntry = Object.entries(queryableFields).find( - ([field]) => fieldToParam(field) === sortParam - ); - if (fieldEntry) { - const defaultDir = fieldEntry[1].type === "range" ? "desc" : "asc"; - const direction = orderParam === "asc" ? "asc" as const : orderParam === "desc" ? "desc" as const : defaultDir as "asc" | "desc"; - sort = { recordField: fieldEntry[0], direction }; - } else { - const direction = orderParam === "asc" ? "asc" as const : "desc" as const; - const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - for (const [relName, rel] of Object.entries(relations)) { - if (sortParam === `${relName}Count`) { - sort = { countType: rel.collection, direction }; - break; - } - const mapping = relMap[relName]; - if (mapping) { - for (const [shortName, fullToken] of Object.entries(mapping.groups)) { - if (sortParam === `${relName}${capitalize(shortName)}Count`) { - sort = { countType: fullToken, direction }; - break; - } - } - if (sort) break; - } - } - } - } - - const search = params.get("search") || undefined; - const spaceUri = params.get("spaceUri") || undefined; - - const queryOpts = { - collection, - did, - limit, - cursor, - filters, - rangeFilters, - countFilters, - sort, - search, - source, - spaceUri, - }; - const result = spaceUris && spaceUris.length > 0 && !spaceUri - ? await queryAcrossSources(db, config, queryOpts, spaceUris) - : await queryRecords(db, config, queryOpts); - - const rows = result.records; - const hydrateRequested = parseHydrateParams(params, relations, references); - const hydrates = await resolveHydrates( - db, - relations, - hydrateRequested.relations, - rows, - config - ); - const refs = await resolveReferences( - db, - references, - hydrateRequested.references, - rows, - config - ); - - const formattedRecords: FormattedRecord[] = rows.map((row) => { - const formatted = formatRecord(row); - flattenCounts(formatted, row.counts, relations); - const h = hydrates[row.uri]; - if (h) { - for (const [relName, groups] of Object.entries(h)) { - formatted[relName] = groups; - } - } - const r = refs[row.uri]; - if (r) { - for (const [refName, record] of Object.entries(r)) { - formatted[refName] = record; - } - } - return formatted; - }); - - const allDids = collectDids(rows, hydrates); - const profileMap = wantProfiles - ? await resolveProfiles(db, config, allDids) - : undefined; - - let labelersApplied: string[] | undefined; - if (config.labels) { - const sel = selectAcceptedLabelers( - headers?.get("atproto-accept-labelers") ?? null, - params.get("labelers"), - config.labels, - ); - if (sel.accepted.length > 0) { - const subjects: string[] = [ - ...formattedRecords.map((r) => r.uri), - ...allDids, - ]; - const cidByUri = new Map<string, string | null>(); - for (const r of formattedRecords) cidByUri.set(r.uri, r.cid); - const labelsByUri = await hydrateLabels(db, subjects, sel.accepted, cidByUri); - for (const fr of formattedRecords) { - const ls = labelsByUri[fr.uri]; - if (ls && ls.length > 0) fr.labels = ls; - } - if (profileMap) { - for (const entries of Object.values(profileMap)) { - for (const entry of entries) { - const ls = labelsByUri[entry.did]; - if (ls && ls.length > 0) entry.labels = ls; - } - } - } - labelersApplied = sel.accepted; - } - } - - return { - records: formattedRecords, - cursor: result.cursor, - ...(profileMap ? { profiles: Object.values(profileMap).flat() } : {}), - ...(labelersApplied ? { labelersApplied } : {}), - }; -} - -/** Serialize a runPipeline result as JSON, echoing - * `atproto-content-labelers` when labels were applied. The result's - * `labelersApplied` field never appears in the response body — it's a - * side channel for the route to read and turn into a header. */ -function jsonWithLabelers(c: Context, result: { labelersApplied?: string[] } & Record<string, unknown>) { - const { labelersApplied, ...body } = result; - if (labelersApplied && labelersApplied.length > 0) { - c.header("atproto-content-labelers", labelersApplied.join(",")); - } - return c.json(body); -} - -export function registerCollectionRoutes( - app: Hono, - db: Database, - config: ContrailConfig, - spacesCtx?: SpacesContext | null, - options: { - pubsub?: import("../realtime/types").PubSub | null; - community?: CommunityProbe | null; - } = {} -): void { - const ns = config.namespace; - const pubsub = options.pubsub ?? null; - const community = options.community ?? null; - - /** When a per-collection endpoint receives `?spaceUri=...`, verify the JWT, - * resolve membership, run the space ACL, and return the caller DID if allowed. - * Returns null if the spaces subsystem isn't available; the handler should - * then treat the spaceUri as invalid. - * Throws by returning a Response (caller checks via `instanceof Response`). */ - async function gateSpaceAccess( - c: Context, - spaceUri: string, - op: "read" - ): Promise<Response | { callerDid?: string; clientId?: string; viaInviteToken?: boolean }> { - if (!spacesCtx) { - return c.json( - { error: "InvalidRequest", message: "spaces not configured on this service" }, - 501 - ); - } - - // Read-token path: anonymous bearer access via `?inviteToken=...` (or - // `Authorization: Bearer atmo-invite:<token>`). Token must exist, be - // unexpired/unrevoked, scoped to this space, and have a kind that grants - // read (`read` or `read-join`). Token kind cannot grant write — caller must - // separately redeem to become a member for any non-read op. - if (op === "read") { - const rawToken = extractInviteToken(c.req.raw); - if (rawToken) { - const ok = await checkInviteReadGrant( - spacesCtx.adapter, - rawToken, - spaceUri, - hashInviteToken - ); - if (ok) { - const space = await spacesCtx.adapter.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - return { viaInviteToken: true }; - } - return c.json( - { error: "Forbidden", reason: "invalid-invite-token" }, - 403 - ); - } - } - - const nsid = new URL(c.req.url).pathname.match(/\/xrpc\/([^?]+)/)?.[1] as Nsid | null; - const auth = await verifyServiceAuthRequest(spacesCtx.verifier, c.req.raw, nsid); - if (!auth) { - return c.json( - { error: "AuthRequired", message: "spaceUri requires a valid service-auth JWT or read-grant invite token" }, - 401 - ); - } - const space = await spacesCtx.adapter.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - const member = await spacesCtx.adapter.getMember(spaceUri, auth.issuer); - const result = checkAccess({ - op, - space, - callerDid: auth.issuer, - member, - clientId: auth.clientId, - }); - if (!result.allow) { - return c.json({ error: "Forbidden", reason: result.reason }, 403); - } - return { callerDid: auth.issuer, clientId: auth.clientId }; - } - - for (const collection of getCollectionShortNames(config)) { - const colConfig = config.collections[collection]; - const methods = getCollectionMethods(colConfig); - - if (methods.includes("listRecords")) { - app.get(`/xrpc/${ns}.${collection}.listRecords`, async (c) => { - const params = new URL(c.req.url).searchParams; - const spaceUri = params.get("spaceUri") || undefined; - - if (spaceUri) { - const gated = await gateSpaceAccess(c, spaceUri, "read"); - if (gated instanceof Response) return gated; - // Route through runPipeline with a single-element space list so the - // full filter / sort / hydrate / reference surface works on per-space - // queries too, not just on the cross-space union path. - try { - const result = await runPipeline(db, config, collection, params, undefined, [spaceUri], c.req.raw.headers); - return jsonWithLabelers(c, result); - } catch (e: any) { - if (e.message === "Could not resolve actor") { - return c.json({ error: e.message }, 400); - } - throw e; - } - } - - // Union path: when the caller is authenticated, fold in records from - // spaces they're a member of. Anonymous callers just get public results. - // - // The caller authenticates with service-auth (`Authorization: Bearer - // <jwt>`). Their member-of space list comes from one of: - // 1. `X-Membership-Manifest` header — signed list issued by some - // authority asserting `sub` is in these spaces. The manifest's - // `sub` MUST match the JWT's issuer (DID) — the manifest is not - // a bearer token. Preferred for multi-authority deployments. - // 2. Local listSpaces — appview asks its own authority adapter - // `listSpaces({ memberDid: jwt.issuer })`. Works when the appview - // operator IS the authority. - let spaceUris: string[] | undefined; - const hasAuthHeader = !!c.req.header("Authorization"); - const manifestHeader = c.req.header("X-Membership-Manifest"); - if (spacesCtx) { - const nsid = new URL(c.req.url).pathname.match(/\/xrpc\/([^?]+)/)?.[1] as Nsid | null; - const auth = await verifyServiceAuthRequest(spacesCtx.verifier, c.req.raw, nsid); - if (auth) { - if (manifestHeader && spacesCtx.manifestVerifier) { - const verified = await spacesCtx.manifestVerifier.verify(manifestHeader); - if (!verified.ok) { - return c.json( - { error: "AuthRequired", reason: verified.reason, message: "invalid membership manifest" }, - 401 - ); - } - if (verified.claims.sub !== auth.issuer) { - return c.json( - { error: "Forbidden", reason: "manifest-sub-mismatch", message: "manifest sub does not match caller" }, - 403 - ); - } - spaceUris = verified.claims.spaces; - } else { - const { spaces } = await spacesCtx.adapter.listSpaces({ - memberDid: auth.issuer, - limit: 200, - }); - spaceUris = spaces.map((s) => s.uri); - } - } else if (hasAuthHeader) { - // Had an auth header but it was invalid — reject rather than - // silently downgrading to public results. - return c.json( - { error: "AuthRequired", message: "invalid service-auth JWT" }, - 401 - ); - } - } - - try { - const result = await runPipeline(db, config, collection, params, undefined, spaceUris, c.req.raw.headers); - return jsonWithLabelers(c, result); - } catch (e: any) { - if (e.message === "Could not resolve actor") { - return c.json({ error: e.message }, 400); - } - throw e; - } - }); - - // Streaming variant — same query shape, SSE'd forever. Opted in via the - // presence of the realtime module; no explicit method config needed. - if (pubsub && spacesCtx) { - const colNsid = colConfig.collection ?? collection; - const relations = colConfig.relations ?? {}; - const references = colConfig.references ?? {}; - // Map child-NSID → { relName, matchField } so we can route child - // events to hydration deltas without re-parsing config per event. - const childCollectionMap = new Map< - string, - { relName: string; matchField: string; matchMode: "uri" | "did" } - >(); - for (const [relName, rel] of Object.entries(relations)) { - const childNsid = nsidForShortName(config, rel.collection) ?? rel.collection; - childCollectionMap.set(childNsid, { - relName, - matchField: getRelationField(rel), - matchMode: rel.match ?? "uri" - }); - } - - // TicketSigner for watch-scoped tickets. Minted on `mode=ws` handshake - // so the subsequent WS upgrade can auth with just `?ticket=...` (no - // cookie or JWT needed — enables cross-origin + stateless clients). - const ticketSigner = config.realtime?.ticketSecret - ? new TicketSigner(config.realtime.ticketSecret) - : null; - const ticketTtl = config.realtime?.ticketTtlMs ?? 120_000; - - app.get(`/xrpc/${ns}.${collection}.watchRecords`, async (c) => { - const params = new URL(c.req.url).searchParams; - const spaceUri = params.get("spaceUri"); - const actorParam = params.get("actor"); - - if (!spaceUri && !actorParam) { - return c.json( - { error: "InvalidRequest", message: "spaceUri or actor required" }, - 400 - ); - } - - // Resolve the caller and their scope. Two parallel paths: - // - space-scoped: single `space:<uri>` topic, per-space ACL gate. - // - actor-scoped: caller's reachable spaces in the actor's - // community (v1 only supports community DIDs as the actor). - // Events are delivered via N `space:<uri>` topics and filtered - // to `did === actor`. - let callerDid: string | undefined; - let scope: WatchScope; - let scopeTopics: string[]; // for ticket signing - let ticketSpec: TicketQuerySpec | null = null; - - const providedTicket = params.get("ticket"); - if (providedTicket && ticketSigner) { - const payload = await ticketSigner.verify(providedTicket); - if (payload?.querySpec && payload.querySpec.collection === colNsid) { - const ts = payload.querySpec; - if (spaceUri && ts.spaceUri === spaceUri) { - if (payload.topics.includes(spaceTopic(spaceUri))) { - callerDid = payload.did; - ticketSpec = { - collection: ts.collection, - spaceUri: ts.spaceUri, - ...(ts.hydrate ? { hydrate: ts.hydrate } : {}) - }; - } - } else if (actorParam && ts.actor === actorParam) { - callerDid = payload.did; - ticketSpec = { - collection: ts.collection, - actor: ts.actor, - ...(ts.hydrate ? { hydrate: ts.hydrate } : {}) - }; - } - } - } - - const hydrateSpec = parseHydrateParams(params, relations, references); - const hydrateForSpec = Object.keys(hydrateSpec.relations).length > 0 - ? Object.fromEntries( - Object.entries(hydrateSpec.relations).map(([relName]) => { - const rel = relations[relName]!; - const childNsid = - nsidForShortName(config, rel.collection) ?? rel.collection; - return [ - relName, - { childCollection: childNsid, matchField: getRelationField(rel) } - ]; - }) - ) - : undefined; - - if (spaceUri) { - if (!ticketSpec) { - const gated = await gateSpaceAccess(c, spaceUri, "read"); - if (gated instanceof Response) return gated; - callerDid = "callerDid" in gated ? gated.callerDid : undefined; - } - scope = { kind: "space", spaceUri }; - scopeTopics = [spaceTopic(spaceUri)]; - } else { - // Actor-scoped path — v1 only supports community DIDs. - const actor = actorParam!; - if (!community || !spacesCtx) { - return c.json( - { error: "NotSupported", reason: "community-module-disabled" }, - 400 - ); - } - const isCommunity = !!(await community.getCommunity(actor)); - if (!isCommunity) { - return c.json( - { error: "InvalidRequest", reason: "actor-must-be-community-did", message: "cross-space watch currently only supports community DIDs as actor" }, - 400 - ); - } - - if (!ticketSpec) { - // Verify the caller via the same JWT/in-process path used for - // per-space queries, then resolve the community topic to the - // caller's accessible space topics. - const nsidLxm = new URL(c.req.url).pathname.match(/\/xrpc\/([^?]+)/)?.[1] as Nsid | null; - const auth = await verifyServiceAuthRequest(spacesCtx.verifier, c.req.raw, nsidLxm); - if (!auth) { - return c.json( - { error: "AuthRequired", message: "service-auth JWT or in-process principal required" }, - 401 - ); - } - callerDid = auth.issuer; - } - const resolved = await resolveTopicForCaller(communityTopic(actor), callerDid!, { - spaces: spacesCtx.adapter, - community - }); - if (!resolved.ok) { - const status = - resolved.error === "NotFound" ? 404 : - resolved.error === "Forbidden" ? 403 : 400; - return c.json({ error: resolved.error, reason: resolved.reason }, status); - } - const allowedSpaces = new Set<string>(); - for (const t of resolved.topics) { - const uri = parseSpaceTopic(t); - if (uri) allowedSpaces.add(uri); - } - scope = { kind: "actor", actor, topics: resolved.topics, allowedSpaces }; - scopeTopics = resolved.topics; - } - - const querySpec: TicketQuerySpec = ticketSpec ?? { - collection: colNsid, - ...(spaceUri ? { spaceUri } : { actor: actorParam! }), - ...(hydrateForSpec ? { hydrate: hydrateForSpec } : {}) - }; - - // Upgrade-to-WS path — forward directly to the DO with the spec, - // so the DO terminates the socket and hibernates when idle. - // Requires snapshot to be fetched separately (see `mode=ws` JSON - // handshake below) or accepted as lossy-on-connect for a plain WS - // upgrade. - const isUpgrade = c.req.header("Upgrade")?.toLowerCase() === "websocket"; - const isWsMode = params.get("mode") === "ws"; - - if (isWsMode && !isUpgrade) { - // Handshake: return snapshot + a ticket the client uses to - // upgrade. Ticket carries the (did, topics, querySpec) signed - // so the WS-upgrade route skips any other auth. - try { - const sinceTs = Date.now(); - const snapshotSpaces = - scope.kind === "space" ? [scope.spaceUri] : Array.from(scope.allowedSpaces); - const result = await runPipeline( - db, - config, - collection, - params, - undefined, - snapshotSpaces, - c.req.raw.headers - ); - let ticket: string | undefined; - if (ticketSigner && callerDid) { - ticket = await ticketSigner.sign({ - topics: scopeTopics, - did: callerDid, - ttlMs: ticketTtl, - querySpec - }); - } - const wsUrl = (() => { - const u = new URL(c.req.url); - u.searchParams.delete("mode"); - if (ticket) u.searchParams.set("ticket", ticket); - u.searchParams.set("sinceTs", String(sinceTs)); - return u.pathname + u.search; - })(); - return c.json({ - transport: "ws", - snapshot: { records: result.records, cursor: result.cursor }, - querySpec, - ticket, - ticketTtlMs: ticketTtl, - sinceTs, - wsUrl - }); - } catch (err) { - return c.json( - { error: "SnapshotFailed", message: err instanceof Error ? err.message : String(err) }, - 500 - ); - } - } - - if (isUpgrade && pubsub instanceof DurableObjectPubSub && scope.kind === "space") { - // Forward the WS upgrade to the DO. The DO owns the socket from - // here and hibernates when idle. Replays any events buffered - // since the handshake `sinceTs` so the client closes the gap. - // - // Actor-scoped queries fall through to the worker-terminated - // path below — the DO binding is single-topic today; extending - // it to fan out over N topics is future work. - const sinceTsParam = params.get("sinceTs"); - const sinceTs = sinceTsParam ? Number(sinceTsParam) : 0; - return pubsub.forwardSubscribe(spaceTopic(scope.spaceUri), c.req.raw, { - did: callerDid, - querySpec: { - collection: querySpec.collection, - spaceUri: scope.spaceUri, - ...(querySpec.hydrate ? { hydrate: querySpec.hydrate } : {}) - }, - sinceTs: Number.isFinite(sinceTs) ? sinceTs : 0 - }); - } - - const ac = new AbortController(); - const reqSignal = c.req.raw.signal; - if (reqSignal) { - if (reqSignal.aborted) ac.abort(); - else reqSignal.addEventListener("abort", () => ac.abort(), { once: true }); - } - - // Worker-terminated WebSocket — used when pubsub isn't DO-backed - // (dev InMemoryPubSub). Same query-filter loop as SSE; different - // transport. Runs in the same isolate so no cost benefit, but - // matches the prod protocol. - if (isUpgrade) { - const WsPair = (globalThis as unknown as { WebSocketPair?: any }).WebSocketPair; - if (!WsPair) { - return c.json( - { error: "NotSupported", reason: "websockets-require-workers-runtime" }, - 426 - ); - } - const pair = new WsPair(); - const clientWs = pair[0] as WebSocket; - const serverWs = pair[1] as WebSocket & { accept?: () => void }; - serverWs.accept?.(); - - const sendWs = (kind: string, data: unknown) => { - try { - serverWs.send(JSON.stringify({ kind, data })); - } catch { - ac.abort(); - } - }; - serverWs.addEventListener?.("close", () => ac.abort()); - serverWs.addEventListener?.("error", () => ac.abort()); - - void runQueryStream({ - send: sendWs, - abort: ac, - scope, - callerDid, - params, - db, - config, - collection, - colNsid, - pubsub, - relations, - references, - childCollectionMap - }).finally(() => { - try { - serverWs.close(); - } catch { - /* ignore */ - } - }); - - return new Response(null, { - status: 101, - webSocket: clientWs - } as ResponseInit & { webSocket: unknown }); - } - - // SSE fallback. - const encoder = new TextEncoder(); - const stream = new ReadableStream<Uint8Array>({ - start(controller) { - let closed = false; - const close = () => { - if (closed) return; - closed = true; - try { - controller.close(); - } catch { - /* already closed */ - } - }; - ac.signal.addEventListener("abort", close, { once: true }); - - const send = (kind: string, data: unknown) => { - if (closed) return; - try { - controller.enqueue( - encoder.encode(`event: ${kind}\ndata: ${JSON.stringify(data)}\n\n`) - ); - } catch { - close(); - } - }; - - const keepalive = setInterval(() => { - if (closed) return; - try { - controller.enqueue(encoder.encode(`: keepalive\n\n`)); - } catch { - close(); - } - }, 15_000); - ac.signal.addEventListener( - "abort", - () => clearInterval(keepalive), - { once: true } - ); - - void runQueryStream({ - send, - abort: ac, - scope, - callerDid, - params, - db, - config, - collection, - colNsid, - pubsub, - relations, - references, - childCollectionMap - }).finally(() => close()); - }, - cancel() { - ac.abort(); - }, - }); - - return new Response(stream, { - status: 200, - headers: { - "content-type": "text/event-stream", - "cache-control": "no-cache, no-transform", - connection: "keep-alive", - "x-accel-buffering": "no", - }, - }); - }); - } - } - - if (!methods.includes("getRecord")) { - // Skip getRecord + custom queries unless listRecords-only was explicitly requested. - for (const [queryName, handler] of Object.entries(colConfig.queries ?? {})) { - app.get(`/xrpc/${ns}.${collection}.${queryName}`, async (c) => { - const params = new URL(c.req.url).searchParams; - return handler(db, params, config); - }); - } - continue; - } - - app.get(`/xrpc/${ns}.${collection}.getRecord`, async (c) => { - const rawUri = c.req.query("uri"); - if (!rawUri) return c.json({ error: "uri parameter required" }, 400); - - // Parse the AT-URI (atcute validates the actor / NSID / rkey shapes) and - // resolve the authority to a DID up front — like the actor-param - // endpoints, the authority may be a handle. resolveActor is local-first - // (identities table, indexed) and returns a DID unchanged, so DID-URI - // callers are unaffected. The `rkey` guard rejects repo-only / - // collection-only URIs, narrowing to a full record URI. - const parsed = parseResourceUri(rawUri); - if (!parsed.ok || parsed.value.rkey === undefined) { - return c.json({ error: "InvalidRequest", message: "uri must be at://<actor>/<collection>/<rkey>" }, 400); - } - const did = await resolveActor(db, parsed.value.repo, config); - if (!did) return c.json({ error: "Could not resolve actor" }, 400); - const rkey = parsed.value.rkey; - const uri = `at://${did}/${parsed.value.collection}/${rkey}`; - - // Spaces path — `?spaceUri=` routes to the per-space store + ACL gate. - const spaceUri = c.req.query("spaceUri") || undefined; - if (spaceUri) { - const gated = await gateSpaceAccess(c, spaceUri, "read"); - if (gated instanceof Response) return gated; - - const nsid = colConfig.collection ?? collection; - const record = await spacesCtx!.adapter.getRecord(spaceUri, nsid, did, rkey); - if (!record) return c.json({ error: "NotFound" }, 404); - return c.json({ record }); - } - - const relations = colConfig.relations ?? {}; - const references = colConfig.references ?? {}; - const relMap = (config as ResolvedContrailConfig)._resolved?.relations[collection] ?? {}; - - const table = recordsTableName(collection); - const countCols = getRelationCountColumns(relations, relMap); - const selectCols = `uri, did, rkey, cid, record, time_us, indexed_at${countCols.length > 0 ? ", " + countCols.map(c => c.column).join(", ") : ""}`; - const row = await db - .prepare(`SELECT ${selectCols} FROM ${table} WHERE uri = ?`) - .bind(uri) - .first<any>(); - - if (!row) return c.json({ error: "Record not found" }, 404); - - const nsid = nsidForShortName(config, collection) ?? collection; - const formatted = formatRecord({ ...row, collection: nsid }); - const counts = extractCounts(row, relations); - if (counts) flattenCounts(formatted, counts, relations); - - const params = new URL(c.req.url).searchParams; - const wantProfilesSingle = params.get("profiles") === "true"; - - const hydrateRequested = parseHydrateParams(params, relations, references); - const hydrates = await resolveHydrates( - db, - relations, - hydrateRequested.relations, - [row], - config - ); - const refs = await resolveReferences( - db, - references, - hydrateRequested.references, - [row], - config - ); - const h = hydrates[row.uri]; - if (h) { - for (const [relName, groups] of Object.entries(h)) { - (formatted as any)[relName] = groups; - } - } - const r = refs[row.uri]; - if (r) { - for (const [refName, record] of Object.entries(r)) { - (formatted as any)[refName] = record; - } - } - - const allDids = collectDids([row], hydrates); - const profileMap = wantProfilesSingle - ? await resolveProfiles(db, config, allDids) - : undefined; - - let labelersApplied: string[] | undefined; - if (config.labels) { - const sel = selectAcceptedLabelers( - c.req.raw.headers.get("atproto-accept-labelers"), - params.get("labelers"), - config.labels, - ); - if (sel.accepted.length > 0) { - const subjects: string[] = [row.uri, ...allDids]; - const cidByUri = new Map<string, string | null>([[row.uri, row.cid]]); - const labelsByUri = await hydrateLabels(db, subjects, sel.accepted, cidByUri); - const ls = labelsByUri[row.uri]; - if (ls && ls.length > 0) (formatted as Record<string, unknown>).labels = ls; - if (profileMap) { - for (const entries of Object.values(profileMap)) { - for (const entry of entries) { - const els = labelsByUri[entry.did]; - if (els && els.length > 0) entry.labels = els; - } - } - } - labelersApplied = sel.accepted; - } - } - if (labelersApplied) { - c.header("atproto-content-labelers", labelersApplied.join(",")); - } - - return c.json({ - ...formatted, - ...(profileMap ? { profiles: Object.values(profileMap).flat() } : {}), - }); - }); - - for (const [queryName, handler] of Object.entries( - colConfig.queries ?? {} - )) { - app.get(`/xrpc/${ns}.${collection}.${queryName}`, async (c) => { - const params = new URL(c.req.url).searchParams; - return handler(db, params, config); - }); - } - - for (const [queryName, handler] of Object.entries( - colConfig.pipelineQueries ?? {} - )) { - app.get(`/xrpc/${ns}.${collection}.${queryName}`, async (c) => { - const params = new URL(c.req.url).searchParams; - try { - const source = await handler(db, params, config); - const result = await runPipeline(db, config, collection, params, source, undefined, c.req.raw.headers); - return jsonWithLabelers(c, result); - } catch (e: any) { - if (e.message === "Could not resolve actor") { - return c.json({ error: e.message }, 400); - } - throw e; - } - }); - } - } -} - -function getRelationCountColumns( - relations: Record<string, RelationConfig>, - relMap: Record<string, any> -): { column: string }[] { - const cols: { column: string }[] = []; - for (const [relName, rel] of Object.entries(relations)) { - if (rel.count === false) continue; - cols.push({ column: countColumnName(rel.collection) }); - const mapping = relMap[relName]; - if (mapping?.groups) { - for (const groupKey of Object.keys(mapping.groups as Record<string, string>)) { - cols.push({ column: groupedCountColumnName(rel.collection, groupKey) }); - } - } - } - return cols; -} - -function extractCounts( - row: any, - relations: Record<string, any> -): Record<string, number> | undefined { - const counts: Record<string, number> = {}; - - for (const [, rel] of Object.entries(relations)) { - if (rel.count === false) continue; - const totalCol = countColumnName(rel.collection); - const val = row[totalCol]; - if (val != null && val !== 0) counts[rel.collection] = val; - - if (rel.groups) { - for (const [groupKey, fullToken] of Object.entries(rel.groups as Record<string, string>)) { - const groupCol = groupedCountColumnName(rel.collection, groupKey); - const gval = row[groupCol]; - if (gval != null && gval !== 0) counts[fullToken] = gval; - } - } - } - - return Object.keys(counts).length > 0 ? counts : undefined; -} - -function flattenCounts( - formatted: FormattedRecord, - counts: Record<string, number> | undefined, - relations: Record<string, any> -): void { - if (!counts) return; - const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - - const collectionToRelName: Record<string, string> = {}; - const tokenToField: Record<string, string> = {}; - for (const [relName, rel] of Object.entries(relations)) { - collectionToRelName[rel.collection] = relName; - if (rel.groups) { - for (const [shortName, fullToken] of Object.entries(rel.groups as Record<string, string>)) { - tokenToField[fullToken] = `${relName}${capitalize(shortName)}Count`; - } - } - } - - for (const [type, count] of Object.entries(counts)) { - if (collectionToRelName[type]) { - formatted[`${collectionToRelName[type]}Count`] = count; - } else if (tokenToField[type]) { - formatted[tokenToField[type]] = count; - } - } -} diff --git a/packages/contrail-appview/src/core/router/feed.ts b/packages/contrail-appview/src/core/router/feed.ts deleted file mode 100644 index 8ef149e..0000000 --- a/packages/contrail-appview/src/core/router/feed.ts +++ /dev/null @@ -1,299 +0,0 @@ -import type { Context, Hono } from "hono"; -import type { - ContrailConfig, - Database, - FeedConfig, - FeedTargetConfig, -} from "../types"; -import { getDialect } from "../dialect"; -import { - DEFAULT_FOLLOW_SHORT, - feedTargetMaxItems, - normalizeFeedTarget, - recordsTableName, - shortNameForNsid, -} from "../types"; -import { resolveActor } from "../identity"; -import { backfillUser } from "../backfill"; -import { runPipeline } from "./collection"; - -const BACKFILL_TIMEOUT_MS = 30_000; -const BACKFILL_REQUEST_TIMEOUT_MS = 10_000; -const BACKFILL_MAX_RETRIES = 3; -/** Re-arm a stuck in-progress row after this long (covers process crashes mid-backfill). */ -const BACKFILL_STALE_MS = 5 * 60 * 1000; - -interface FeedBackfillStatus { - completed: number; - retries: number; - last_error: string | null; - started_at: number | null; -} - -/** Schedule async work, preferring waitUntil on Cloudflare Workers so the - * runtime keeps the request alive until the promise settles. Falls back to - * fire-and-forget with a logged catch. */ -function scheduleBackground( - c: Context, - config: ContrailConfig, - task: () => Promise<unknown> -): void { - const log = config.logger ?? console; - const promise = task().catch((err) => - log.error(`[feed] background task failed: ${err}`) - ); - try { - c.executionCtx.waitUntil(promise); - } catch { - // No executionCtx (Node/Bun); promise runs detached. - } -} - -/** Run the bootstrap copy + per-target prune. Returns rows inserted. */ -async function bootstrapFeedItems( - db: Database, - config: ContrailConfig, - actor: string, - feedConfig: FeedConfig -): Promise<number> { - const followShort = feedConfig.follow ?? DEFAULT_FOLLOW_SHORT; - const followTable = recordsTableName(followShort); - const targets = feedConfig.targets.map(normalizeFeedTarget); - let totalInserted = 0; - - for (const target of targets) { - const targetTable = recordsTableName(target.collection); - const targetCfg = config.collections[target.collection]; - if (!targetCfg) continue; - const cap = feedTargetMaxItems(feedConfig, target); - - const insert = await db - .prepare( - getDialect(db).insertOrIgnore( - `INSERT INTO feed_items (actor, uri, collection, time_us) - SELECT ?, r.uri, ?, r.time_us - FROM ${targetTable} r - WHERE r.did IN ( - SELECT ${getDialect(db).jsonExtract("f.record", "subject")} - FROM ${followTable} f - WHERE f.did = ? - ) - ORDER BY r.time_us DESC - LIMIT ${cap}` - ) - ) - .bind(actor, targetCfg.collection, actor) - .run(); - totalInserted += (insert as { changes?: number })?.changes ?? 0; - - // Per-target prune so high-volume targets don't squeeze out lower-volume ones. - await db - .prepare( - `DELETE FROM feed_items WHERE actor = ? AND collection = ? AND uri NOT IN ( - SELECT uri FROM feed_items WHERE actor = ? AND collection = ? - ORDER BY time_us DESC LIMIT ? - )` - ) - .bind(actor, targetCfg.collection, actor, targetCfg.collection, cap) - .run(); - } - - return totalInserted; -} - -/** Run a full backfill cycle: walk follow records → bootstrap feed_items → mark complete. - * Updates feed_backfills row with retries/last_error on failure. */ -async function runFeedBackfill( - db: Database, - config: ContrailConfig, - actor: string, - feedName: string, - feedConfig: FeedConfig -): Promise<void> { - const followShort = feedConfig.follow ?? DEFAULT_FOLLOW_SHORT; - const followCfg = config.collections[followShort]; - if (!followCfg) return; - - try { - const inserted = await backfillUser( - db, - actor, - followCfg.collection ?? followShort, - Date.now() + BACKFILL_TIMEOUT_MS, - config, - { - skipReplayDetection: true, - maxRetries: BACKFILL_MAX_RETRIES, - requestTimeout: BACKFILL_REQUEST_TIMEOUT_MS, - } - ); - - // Bootstrap from whatever follow records we now have (may be from this - // backfill, from earlier live ingest, or both). - await bootstrapFeedItems(db, config, actor, feedConfig); - - // Only mark complete if the underlying follow backfill actually finished - // (backfills.completed = 1). Avoids the old bug where timeouts/empty - // walks would lock the user out of any retry. - const followStatus = await db - .prepare( - "SELECT completed FROM backfills WHERE did = ? AND collection = ?" - ) - .bind(actor, followCfg.collection) - .first<{ completed: number }>(); - - if (followStatus?.completed) { - await db - .prepare( - "UPDATE feed_backfills SET completed = 1, last_error = NULL WHERE actor = ? AND feed = ?" - ) - .bind(actor, feedName) - .run(); - } else { - // Walk didn't complete (timeout/error), but record the partial progress - // so the next request retries. - await db - .prepare( - "UPDATE feed_backfills SET retries = retries + 1, started_at = NULL, last_error = ? WHERE actor = ? AND feed = ?" - ) - .bind( - `follow backfill incomplete (inserted=${inserted})`, - actor, - feedName - ) - .run(); - } - } catch (err) { - await db - .prepare( - "UPDATE feed_backfills SET retries = retries + 1, started_at = NULL, last_error = ? WHERE actor = ? AND feed = ?" - ) - .bind(String(err), actor, feedName) - .run(); - } -} - -/** Decide whether to (re)kick off a background backfill, and do so if needed. - * Always returns immediately so the request path stays cheap. */ -async function maybeBackfillFeed( - c: Context, - db: Database, - config: ContrailConfig, - actor: string, - feedName: string, - feedConfig: FeedConfig -): Promise<void> { - const status = await db - .prepare( - "SELECT completed, retries, last_error, started_at FROM feed_backfills WHERE actor = ? AND feed = ?" - ) - .bind(actor, feedName) - .first<FeedBackfillStatus>(); - - if (status?.completed) return; - - const now = Date.now(); - - // Skip if a backfill is already in flight (started_at recently set) — avoids - // duplicate work from concurrent requests for the same actor. - if (status?.started_at && now - status.started_at < BACKFILL_STALE_MS) return; - - // Either no row, or stale started_at. Claim it. - if (!status) { - await db - .prepare( - "INSERT INTO feed_backfills (actor, feed, completed, started_at) VALUES (?, ?, 0, ?) ON CONFLICT DO NOTHING" - ) - .bind(actor, feedName, now) - .run(); - } else { - await db - .prepare( - "UPDATE feed_backfills SET started_at = ? WHERE actor = ? AND feed = ?" - ) - .bind(now, actor, feedName) - .run(); - } - - scheduleBackground(c, config, () => - runFeedBackfill(db, config, actor, feedName, feedConfig) - ); -} - -export function registerFeedRoutes( - app: Hono, - db: Database, - config: ContrailConfig -): void { - if (!config.feeds) return; - - const ns = config.namespace; - - app.get(`/xrpc/${ns}.getFeed`, async (c) => { - const params = new URL(c.req.url).searchParams; - const feedName = params.get("feed"); - const actor = params.get("actor"); - - if (!feedName || !actor) { - return c.json({ error: "feed and actor parameters required" }, 400); - } - - const feedConfig = config.feeds![feedName]; - if (!feedConfig) { - return c.json({ error: "Unknown feed" }, 404); - } - - const did = await resolveActor(db, actor, config); - if (!did) return c.json({ error: "Could not resolve actor" }, 400); - - await maybeBackfillFeed(c, db, config, did, feedName, feedConfig); - - const targets = feedConfig.targets.map(normalizeFeedTarget); - if (targets.length === 0) { - return c.json({ error: "Feed has no targets configured" }, 500); - } - // Wire-level `collection` is an NSID (matches the generated lex enum and - // what's stored in feed_items.collection). Internally runPipeline expects - // the short name, so translate. - const requestedRaw = params.get("collection"); - let requestedShort: string; - if (!requestedRaw) { - requestedShort = targets[0].collection; - } else if (targets.some((t) => t.collection === requestedRaw)) { - // Tolerate callers passing the short name directly. - requestedShort = requestedRaw; - } else { - const asShort = shortNameForNsid(config, requestedRaw); - if (asShort && targets.some((t) => t.collection === asShort)) { - requestedShort = asShort; - } else { - return c.json({ error: "Collection not in feed targets" }, 400); - } - } - - // Strip feed-specific params so runPipeline doesn't misinterpret them - // (e.g. "actor" in feeds means "whose feed", not "filter by record creator") - const pipelineParams = new URLSearchParams(params); - pipelineParams.delete("feed"); - pipelineParams.delete("actor"); - pipelineParams.delete("collection"); - - const source = { - joins: "JOIN feed_items f ON r.uri = f.uri", - conditions: ["f.actor = ?"], - params: [did], - }; - - try { - const result = await runPipeline(db, config, requestedShort, pipelineParams, source); - return c.json(result); - } catch (e: any) { - if (e.message === "Could not resolve actor") { - return c.json({ error: e.message }, 400); - } - throw e; - } - }); -} - -export type { FeedTargetConfig }; diff --git a/packages/contrail-appview/src/core/router/helpers.ts b/packages/contrail-appview/src/core/router/helpers.ts deleted file mode 100644 index 7ff372e..0000000 --- a/packages/contrail-appview/src/core/router/helpers.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Database, RecordRow } from "../types"; - -export interface FormattedRecord { - uri: string; - cid: string | null; - value: unknown; - did: string; - collection: string; - rkey: string; - time_us: number; - [key: string]: unknown; -} - -export function formatRecord(row: RecordRow): FormattedRecord { - let value: unknown = null; - if (row.record) { - try { - value = JSON.parse(row.record); - } catch { - value = row.record; - } - } - return { - uri: row.uri, - cid: row.cid, - value, - did: row.did, - collection: row.collection, - rkey: row.rkey, - time_us: row.time_us, - ...(row.space ? { space: row.space } : {}), - }; -} - -export function parseIntParam( - value: string | null | undefined, - defaultValue?: number -): number | undefined { - if (!value) return defaultValue; - const parsed = parseInt(value, 10); - return isNaN(parsed) ? defaultValue : parsed; -} - -export function fieldToParam(field: string): string { - return field.replace(/\.(\w)/g, (_, c) => c.toUpperCase()); -} - -const BATCH_SIZE = 50; - -export async function batchedInQuery<T>( - db: Database, - sql: string, - prefixBindings: (string | number)[], - inValues: string[] -): Promise<T[]> { - const results: T[] = []; - for (let i = 0; i < inValues.length; i += BATCH_SIZE) { - const chunk = inValues.slice(i, i + BATCH_SIZE); - const query = sql.replace("__IN__", chunk.map(() => "?").join(",")); - const rows = await db - .prepare(query) - .bind(...prefixBindings, ...chunk) - .all<T>(); - results.push(...(rows.results ?? [])); - } - return results; -} diff --git a/packages/contrail-appview/src/core/router/hydrate.ts b/packages/contrail-appview/src/core/router/hydrate.ts deleted file mode 100644 index 8c1ae63..0000000 --- a/packages/contrail-appview/src/core/router/hydrate.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { RelationConfig, ReferenceConfig, RecordRow, Database, ContrailConfig } from "../types"; -import { getDialect } from "../dialect"; -import { - getNestedValue, - getRelationField, - recordsTableName, - spacesRecordsTableName, - nsidForShortName, -} from "../types"; -import { batchedInQuery, formatRecord } from "./helpers"; - -/** Group rows by their origin: public (undefined key) or a specific spaceUri. */ -function groupBySource<T extends { space?: string }>(rows: T[]): Map<string | undefined, T[]> { - const groups = new Map<string | undefined, T[]>(); - for (const r of rows) { - const key = r.space; - const g = groups.get(key); - if (g) g.push(r); - else groups.set(key, [r]); - } - return groups; -} - -// --- Hydration: embed related records --- - -export function parseHydrateParams( - params: URLSearchParams, - relations: Record<string, RelationConfig>, - references: Record<string, ReferenceConfig> -): { relations: Record<string, number>; references: Set<string> } { - const relHydrates: Record<string, number> = {}; - const refHydrates = new Set<string>(); - const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - - for (const relName of Object.keys(relations)) { - const val = params.get(`hydrate${capitalize(relName)}`); - if (val) { - const limit = parseInt(val, 10); - if (!isNaN(limit) && limit > 0) { - relHydrates[relName] = Math.min(limit, 50); - } - } - } - - for (const refName of Object.keys(references)) { - const val = params.get(`hydrate${capitalize(refName)}`); - if (val === "true" || val === "1") { - refHydrates.add(refName); - } - } - - return { relations: relHydrates, references: refHydrates }; -} - -// Per-relation hydrate result: array for ungrouped, Record<group, array> for grouped -export type HydrateResult = Record<string, Record<string, any[] | Record<string, any[]>>>; - -export async function resolveHydrates( - db: Database, - relations: Record<string, RelationConfig>, - requested: Record<string, number>, - records: RecordRow[], - config?: ContrailConfig -): Promise<HydrateResult> { - if (Object.keys(requested).length === 0 || records.length === 0) return {}; - - const grouped: Record<string, Record<string, Record<string, any[]>>> = {}; - - const sourceGroups = groupBySource(records); - - for (const [relName, hydrateLimit] of Object.entries(requested)) { - const rel = relations[relName]; - const field = getRelationField(rel); - const matchMode = rel.match ?? "uri"; - - for (const [sourceSpace, sourceRecords] of sourceGroups) { - const matchValues = matchMode === "did" - ? [...new Set(sourceRecords.map((r) => r.did))] - : sourceRecords.map((r) => r.uri); - - if (matchValues.length === 0) continue; - - const groupCount = rel.groupBy ? 10 : 1; - const maxRows = matchValues.length * hydrateLimit * groupCount; - - const table = sourceSpace - ? spacesRecordsTableName(rel.collection) - : recordsTableName(rel.collection); - const where = sourceSpace - ? `space_uri = ? AND ${getDialect(db).jsonExtract('record', field)} IN (__IN__)` - : `${getDialect(db).jsonExtract('record', field)} IN (__IN__)`; - const prefix = sourceSpace ? [sourceSpace] : []; - - const relatedRows = await batchedInQuery<Omit<RecordRow, "collection">>( - db, - `SELECT uri, did, rkey, record, time_us FROM ${table} - WHERE ${where} - ORDER BY time_us DESC - LIMIT ${maxRows}`, - prefix, - matchValues - ); - - for (const row of relatedRows) { - const record = row.record ? JSON.parse(row.record) : null; - const matchedValue = getNestedValue(record, field); - if (!matchedValue) continue; - - const parentUris = matchMode === "did" - ? sourceRecords.filter((r) => r.did === matchedValue).map((r) => r.uri) - : [matchedValue]; - - const groupValue = rel.groupBy - ? String(getNestedValue(record, rel.groupBy) ?? "other") - : "_flat"; - - for (const parentUri of parentUris) { - const targetUri = matchMode === "did" ? parentUri : matchedValue; - - if (!grouped[targetUri]) grouped[targetUri] = {}; - if (!grouped[targetUri][relName]) grouped[targetUri][relName] = {}; - if (!grouped[targetUri][relName][groupValue]) grouped[targetUri][relName][groupValue] = []; - - const group = grouped[targetUri][relName][groupValue]; - if (group.length < hydrateLimit) { - const childNsid = config - ? nsidForShortName(config, rel.collection) ?? rel.collection - : rel.collection; - group.push( - formatRecord({ - ...(row as any), - collection: childNsid, - ...(sourceSpace ? { space: sourceSpace } : {}), - } as RecordRow) - ); - } - } - } - } - } - - const result: HydrateResult = {}; - for (const [uri, rels] of Object.entries(grouped)) { - result[uri] = {}; - for (const [relName, groups] of Object.entries(rels)) { - if (relations[relName].groupBy) { - result[uri][relName] = groups; - } else { - result[uri][relName] = groups["_flat"] ?? []; - } - } - } - - return result; -} - -// --- References: embed records that our records point at --- - -export type ReferenceResult = Record<string, Record<string, any>>; - -export async function resolveReferences( - db: Database, - references: Record<string, ReferenceConfig>, - requested: Set<string>, - records: RecordRow[], - config?: ContrailConfig -): Promise<ReferenceResult> { - if (requested.size === 0 || records.length === 0) return {}; - - const result: ReferenceResult = {}; - - const sourceGroups = groupBySource(records); - - for (const refName of requested) { - const ref = references[refName]; - if (!ref) continue; - - const refNsid = config - ? nsidForShortName(config, ref.collection) ?? ref.collection - : ref.collection; - - for (const [sourceSpace, sourceRecords] of sourceGroups) { - const targetMap = new Map<string, string[]>(); - for (const r of sourceRecords) { - const parsed = r.record ? JSON.parse(r.record) : null; - const targetValue = parsed ? getNestedValue(parsed, ref.field) : null; - if (!targetValue) continue; - if (!targetMap.has(targetValue)) targetMap.set(targetValue, []); - targetMap.get(targetValue)!.push(r.uri); - } - - const targetUris = [...targetMap.keys()]; - if (targetUris.length === 0) continue; - - const table = sourceSpace - ? spacesRecordsTableName(ref.collection) - : recordsTableName(ref.collection); - const where = sourceSpace ? `space_uri = ? AND uri IN (__IN__)` : `uri IN (__IN__)`; - const prefix = sourceSpace ? [sourceSpace] : []; - - const rows = await batchedInQuery<Omit<RecordRow, "collection">>( - db, - `SELECT uri, did, rkey, record, time_us FROM ${table} WHERE ${where}`, - prefix, - targetUris - ); - - for (const row of rows) { - const parentUris = targetMap.get(row.uri) ?? []; - for (const parentUri of parentUris) { - if (!result[parentUri]) result[parentUri] = {}; - result[parentUri][refName] = formatRecord({ - ...(row as any), - collection: refNsid, - ...(sourceSpace ? { space: sourceSpace } : {}), - } as RecordRow); - } - } - } - } - - return result; -} diff --git a/packages/contrail-appview/src/core/router/index.ts b/packages/contrail-appview/src/core/router/index.ts deleted file mode 100644 index 10291f4..0000000 --- a/packages/contrail-appview/src/core/router/index.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { Hono } from "hono"; -import { cors } from "hono/cors"; -import type { Database, ContrailConfig } from "../types"; -import { normalizeProfileConfig } from "../types"; -import { registerAdminRoutes } from "./admin"; -import { registerCollectionRoutes } from "./collection"; -import { registerFeedRoutes } from "./feed"; -import { registerNotifyRoute } from "./notify"; -import { registerSpacesRoutes } from "../spaces/router"; -import type { SpacesRoutesOptions } from "../spaces/router"; -import { buildVerifier, createServiceAuthMiddleware } from "../spaces/auth"; -import { HostedAdapter } from "../spaces/adapter"; -import type { StorageAdapter } from "../spaces/types"; -import type { ServiceJwtVerifier } from "@atcute/xrpc-server/auth"; -import { createManifestVerifier } from "@atmo-dev/contrail-base"; -import type { ManifestVerifier } from "@atmo-dev/contrail-base"; -import type { CommunityIntegration } from "../community-integration"; -import { registerRealtimeRoutes } from "../realtime/router"; -import type { RealtimeRoutesOptions } from "../realtime/router"; -import { registerInviteRoutes } from "../invite/router"; -import { InMemoryPubSub } from "../realtime/in-memory"; -import { wrapWithPublishing } from "../realtime/publishing-adapter"; -import type { PubSub } from "../realtime/types"; -import { resolveActor } from "../identity"; -import { resolveProfiles } from "./profiles"; -import { backfillUser } from "../backfill"; -import { selectAcceptedLabelers } from "../labels/select"; -import { hydrateLabels } from "../labels/hydrate"; -import type { MiddlewareHandler } from "hono"; - -export interface SpacesContext { - adapter: StorageAdapter; - verifier: ServiceJwtVerifier; - /** Verifies inbound `X-Membership-Manifest` headers. Built automatically - * when an authority is configured locally with signing keys; deployments - * that aggregate manifests from multiple authorities should construct one - * via {@link createManifestVerifier} with a custom key resolver and pass - * it through `options.spacesCtx`. */ - manifestVerifier?: ManifestVerifier; -} - -export interface CreateAppOptions { - spaces?: SpacesRoutesOptions; - /** Pre-built community integration. Construct via the community package's - * `createCommunityIntegration({ ... })`. When set, contrail wires - * community whoami extension, invite handler, route registration, etc. - * When omitted, deployment runs without community features. */ - community?: CommunityIntegration | null; - /** Auth middleware override for community routes (rare — mostly for tests). */ - communityAuthMiddleware?: MiddlewareHandler; - realtime?: Partial<RealtimeRoutesOptions>; - /** Separate DB for the spaces tables. Defaults to `db`. */ - spacesDb?: Database; - /** Full spaces context override (escape hatch for tests). */ - spacesCtx?: SpacesContext | null; - /** Lexicon JSONs to serve at `/lexicons` so consumer apps can fetch + - * typegen against this deployment. Emit with `contrail-lex generate` — - * its `lexicons/generated/index.ts` exports the right shape. If omitted, - * the endpoint returns `404`. */ - lexicons?: object[]; -} - -export function createApp( - db: Database, - config: ContrailConfig, - options: CreateAppOptions = {} -): Hono { - const app = new Hono(); - app.use("*", cors()); - - app.get("/", (c) => c.json({ status: "ok" })); - app.get("/health", (c) => c.json({ status: "ok" })); - app.get("/xrpc/_health", (c) => c.json({ status: "ok" })); - - const ns = config.namespace; - - // Lexicon manifest — lets consumer apps fetch every lexicon this - // deployment speaks (generated + pulled + custom) over HTTP and - // typegen clients, without needing a PDS or DNS resolution. Only - // registered when the caller passed bundled lexicons at build time - // via `contrail-lex generate`. - if (options.lexicons && options.lexicons.length > 0) { - const lexicons = options.lexicons; - app.get(`/xrpc/${ns}.lexicons`, (c) => c.json({ lexicons })); - } - - app.get(`/xrpc/${ns}.getProfile`, async (c) => { - const actor = c.req.query("actor"); - if (!actor) return c.json({ error: "actor parameter required" }, 400); - - const did = await resolveActor(db, actor, config); - if (!did) return c.json({ error: "Could not resolve actor" }, 400); - - // Ensure profile records are backfilled - const profileConfigs = (config.profiles ?? []).map(normalizeProfileConfig); - for (const pc of profileConfigs) { - await backfillUser(db, did, pc.collection, Date.now() + 3_000, config, { - maxRetries: 0, - requestTimeout: 3_000, - }); - } - - const profileMap = await resolveProfiles(db, config, [did]); - const profiles = profileMap[did]; - if (!profiles || profiles.length === 0) return c.json({ error: "Profile not found" }, 404); - - if (config.labels) { - const params = new URL(c.req.url).searchParams; - const sel = selectAcceptedLabelers( - c.req.raw.headers.get("atproto-accept-labelers"), - params.get("labelers"), - config.labels, - ); - if (sel.accepted.length > 0) { - const labelsByUri = await hydrateLabels(db, [did], sel.accepted); - const ls = labelsByUri[did]; - if (ls && ls.length > 0) { - for (const entry of profiles) { - entry.labels = ls; - } - } - c.header("atproto-content-labelers", sel.accepted.join(",")); - } - } - - return c.json({ profiles }); - }); - - // Shared spaces context — verifier + adapter — reused by both the per-collection - // routes (for `?spaceUri=...` dispatch) and the `<ns>.space.*` routes. - // Built when an authority is configured (spaces are gated on the authority, - // not the record host — a record-host-only deployment still needs an - // authority somewhere, just possibly external). - const spacesDb = options.spacesDb ?? db; - let spacesCtx: SpacesContext | null = - options.spacesCtx !== undefined - ? options.spacesCtx - : config.spaces?.authority - ? { - adapter: options.spaces?.adapter ?? new HostedAdapter(spacesDb, config), - verifier: buildVerifier(config.spaces.authority, config.networkOverrides), - manifestVerifier: config.spaces.authority.signing - ? createManifestVerifier({ - resolveKey: async (iss) => - iss === config.spaces!.authority!.serviceDid - ? config.spaces!.authority!.signing!.publicKey - : null, - }) - : undefined, - } - : null; - - // Community is provided as a pre-built integration — contrail core never - // imports from the community package. The integration object is opaque; - // we just pass through its probe / whoamiExtension / inviteHandler / - // registerRoutes hooks at the right wiring points. - const community = options.community ?? null; - - // Realtime pubsub is built whenever realtime is configured — independent of - // spaces. With spaces, the spaces adapter is wrapped so private record/member - // events publish to space:/community: topics. Without spaces, only public - // topics (collection:/actor:) see traffic — those are published from - // applyEvents (jetstream ingestion), not from here. - let realtimePubsub: PubSub | null = null; - if (config.realtime) { - realtimePubsub = - options.realtime?.pubsub ?? config.realtime.pubsub ?? new InMemoryPubSub({ - queueBound: config.realtime.queueBound, - }); - if (spacesCtx) { - const isCommunityDid = community - ? cachedIsCommunityDid(community.probe) - : undefined; - spacesCtx = { - ...spacesCtx, - adapter: wrapWithPublishing(spacesCtx.adapter, realtimePubsub, { isCommunityDid }), - }; - } - } - - registerAdminRoutes(app, db, config); - - registerCollectionRoutes(app, db, config, spacesCtx, { - pubsub: realtimePubsub, - community: community?.probe ?? null, - }); - registerFeedRoutes(app, db, config); - registerNotifyRoute(app, db, config); - - // Spaces routes — get a whoami extension from the community integration - // when one's wired so community-owned spaces get an `accessLevel` field. - const spacesOptions = { - ...options.spaces, - whoamiExtension: - options.spaces?.whoamiExtension ?? community?.whoamiExtension, - }; - registerSpacesRoutes(app, spacesDb, config, spacesOptions, spacesCtx); - - if (community && spacesCtx) { - // Community routes reuse the spaces service-auth middleware (same JWT verifier). - const authMiddleware = - options.communityAuthMiddleware ?? - options.spaces?.authMiddleware ?? - createServiceAuthMiddleware(spacesCtx.verifier); - community.registerRoutes(app, { authMiddleware }); - } - - if (config.spaces?.authority && spacesCtx) { - // Unified invite surface: one `<ns>.invite.*` family that dispatches on - // space ownership (user-owned → addMember; community-owned → grant via - // the integration's invite handler). - const authMiddleware = - options.spaces?.authMiddleware ?? - createServiceAuthMiddleware(spacesCtx.verifier); - registerInviteRoutes( - app, - config, - spacesCtx.adapter, - community?.inviteHandler ?? null, - { authMiddleware } - ); - } - - if (config.realtime && realtimePubsub) { - // The ticket endpoint still needs a JWT verifier — but that verifier only - // exists when spaces is configured. Without spaces, private-topic ticket - // minting simply isn't offered; public subscriptions (collection:/actor:) - // require no auth and still work. - const authMiddleware = spacesCtx - ? options.realtime?.authMiddleware ?? - options.spaces?.authMiddleware ?? - createServiceAuthMiddleware(spacesCtx.verifier) - : null; - registerRealtimeRoutes( - app, - config, - spacesCtx?.adapter ?? null, - community?.probe ?? null, - { - authMiddleware, - pubsub: realtimePubsub, - } - ); - } - - return app; -} - -function cachedIsCommunityDid( - probe: import("../community-integration").CommunityProbe -): (did: string) => Promise<boolean> { - const TTL = 60_000; - const cache = new Map<string, { value: boolean; expires: number }>(); - return async (did: string) => { - const now = Date.now(); - const hit = cache.get(did); - if (hit && hit.expires > now) return hit.value; - const row = await probe.getCommunity(did); - const value = row != null; - cache.set(did, { value, expires: now + TTL }); - return value; - }; -} diff --git a/packages/contrail-appview/src/core/router/notify.ts b/packages/contrail-appview/src/core/router/notify.ts deleted file mode 100644 index f6c51c3..0000000 --- a/packages/contrail-appview/src/core/router/notify.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { Hono } from "hono"; -import type { Database, ContrailConfig, IngestEvent } from "../types"; -import { shortNameForNsid, getFeedMutatingNsids } from "../types"; -import { applyEvents, lookupExistingRecords } from "../db/records"; -import { runGatedFeedPrune } from "../jetstream"; -import { getPDS } from "../client"; -import type { Did } from "@atcute/lexicons"; -import { parseCanonicalResourceUri } from "@atcute/lexicons/syntax"; - -/** Parse a canonical (DID-authority) record AT-URI into its components, or null - * if it isn't a valid full record URI. Backed by atcute's validator, which - * also enforces the DID / NSID / record-key character classes. */ -export function parseAtUri(uri: string): { did: string; collection: string; rkey: string } | null { - const parsed = parseCanonicalResourceUri(uri); - if (!parsed.ok) return null; - const { repo, collection, rkey } = parsed.value; - return { did: repo, collection, rkey }; -} - -/** - * Fetch a single record from the user's PDS. - * Returns the record + cid on success, null if not found. - */ -async function fetchRecordFromPDS( - pds: string, - did: string, - collection: string, - rkey: string -): Promise<{ value: unknown; cid: string } | null> { - const url = new URL(`/xrpc/com.atproto.repo.getRecord`, pds); - url.searchParams.set("repo", did); - url.searchParams.set("collection", collection); - url.searchParams.set("rkey", rkey); - - const res = await fetch(url.toString()); - if (!res.ok) return null; - - const data = (await res.json()) as { value?: unknown; cid?: string }; - if (!data.value || !data.cid) return null; - return { value: data.value, cid: data.cid }; -} - -export interface NotifyResult { - indexed: number; - deleted: number; - errors?: string[]; -} - -/** - * Process notify URIs: fetch from PDS, detect changes, apply events. - * Shared by both the Hono route and the Contrail.notify() method. - */ -export async function processNotifyUris( - db: Database, - config: ContrailConfig, - uris: string[] -): Promise<NotifyResult> { - const events: IngestEvent[] = []; - const errors: string[] = []; - - // Validate and parse all URIs first - const validUris: { uri: string; parsed: { did: string; collection: string; rkey: string } }[] = []; - for (const uri of uris) { - const parsed = parseAtUri(uri); - if (!parsed) { - errors.push(`invalid AT URI: ${uri}`); - continue; - } - // `parsed.collection` is an NSID; look up the matching short name. - if (!shortNameForNsid(config, parsed.collection)) { - errors.push(`collection not tracked: ${parsed.collection}`); - continue; - } - validUris.push({ uri, parsed }); - } - - // Single batch lookup for all existing records (cid + record in one query) - const existing = await lookupExistingRecords( - db, - validUris.map(({ uri, parsed }) => ({ uri, collection: parsed.collection })), - true, - config - ); - - for (const { uri, parsed } of validUris) { - const pds = await getPDS(parsed.did as Did, db, config); - if (!pds) { - errors.push(`could not resolve PDS for ${parsed.did}`); - continue; - } - - const result = await fetchRecordFromPDS( - pds, - parsed.did, - parsed.collection, - parsed.rkey - ); - - const now = Date.now() * 1000; // microseconds - const existingInfo = existing.get(uri); - - if (result) { - if (existingInfo?.cid === result.cid) { - // Same CID — nothing changed - continue; - } - - events.push({ - uri, - did: parsed.did, - collection: parsed.collection, - rkey: parsed.rkey, - operation: existingInfo ? "update" : "create", - cid: result.cid, - record: JSON.stringify(result.value), - time_us: now, - indexed_at: now, - }); - } else if (existingInfo) { - // Record gone from PDS but exists locally — delete it. - events.push({ - uri, - did: parsed.did, - collection: parsed.collection, - rkey: parsed.rkey, - operation: "delete", - cid: null, - record: existingInfo.record, - time_us: now, - indexed_at: now, - }); - } - } - - if (events.length > 0) { - // Pass pre-fetched existing records so applyEvents skips re-querying - await applyEvents(db, events, config, { existing }); - } - - // applyEvents fans these records into feed_items exactly like the cron and - // persistent ingest paths, so prune here too — otherwise a notify-only - // deployment (no jetstream loop) would never sweep. Run the recovery-aware - // gate on every call, not only when records changed: a notify-only deployment - // that receives no-op notifications (a same-CID re-notify produces no events) - // must still be able to advance an overdue recovery pass. `feedTouched` is - // true only when this call actually applied a feed-mutating record. - if (config.feeds) { - const feedMutatingNsids = getFeedMutatingNsids(config); - const feedTouched = events.some((e) => feedMutatingNsids.has(e.collection)); - await runGatedFeedPrune(db, config, feedTouched); - } - - return { - indexed: events.filter((e) => e.operation === "create" || e.operation === "update").length, - deleted: events.filter((e) => e.operation === "delete").length, - errors: errors.length > 0 ? errors : undefined, - }; -} - -export function registerNotifyRoute( - app: Hono, - db: Database, - config: ContrailConfig -) { - // Endpoint is off by default. Set config.notify to true or a secret string to enable. - if (!config.notify) return; - - const ns = config.namespace; - const secret = typeof config.notify === "string" ? config.notify : null; - - app.post(`/xrpc/${ns}.notifyOfUpdate`, async (c) => { - if (secret) { - const auth = c.req.header("Authorization"); - if (auth !== `Bearer ${secret}`) { - return c.json({ error: "unauthorized" }, 401); - } - } - - const body = await c.req.json<{ uri?: string; uris?: string[] }>().catch(() => null); - const uris: string[] = []; - - if (body?.uris && Array.isArray(body.uris)) { - uris.push(...body.uris); - } else if (body?.uri) { - uris.push(body.uri); - } else { - return c.json({ error: "uri or uris required" }, 400); - } - - if (uris.length > 25) { - return c.json({ error: "max 25 URIs per request" }, 400); - } - - const result = await processNotifyUris(db, config, uris); - return c.json(result); - }); -} diff --git a/packages/contrail-appview/src/core/router/profiles.ts b/packages/contrail-appview/src/core/router/profiles.ts deleted file mode 100644 index 8a95c82..0000000 --- a/packages/contrail-appview/src/core/router/profiles.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { Database, ContrailConfig, RecordRow, ProfileConfig } from "../types"; -import { recordsTableName, normalizeProfileConfig } from "../types"; -import { resolveIdentities } from "../identity"; -import { getPDS } from "../client"; -import type { Did } from "@atcute/lexicons"; -import { batchedInQuery } from "./helpers"; - -export interface ProfileEntry { - did: string; - handle: string | null; - uri?: string; - cid?: string | null; - value?: unknown; - collection?: string; - rkey?: string; - /** Hydrated by the labels module when the caller has accepted-labelers - * active and there are matching labels on this DID. */ - labels?: unknown; -} - -export function collectDids( - records: RecordRow[], - hydrates: Record<string, Record<string, any[] | Record<string, any[]>>> -): string[] { - const dids = new Set(records.map((r) => r.did)); - for (const rels of Object.values(hydrates)) { - for (const value of Object.values(rels)) { - const items = Array.isArray(value) - ? value - : Object.values(value).flat(); - for (const item of items) { - if (item.did) dids.add(item.did); - } - } - } - return [...dids]; -} - -export async function resolveProfiles( - db: Database, - config: ContrailConfig, - dids: string[] -): Promise<Record<string, ProfileEntry[]>> { - if (dids.length === 0 || !config.profiles || config.profiles.length === 0) { - return {}; - } - - const profileConfigs = config.profiles.map(normalizeProfileConfig); - const result: Record<string, ProfileEntry[]> = {}; - - // Batch-lookup profile records for each configured profile collection - for (const pc of profileConfigs) { - const { collection, rkey: configRkey, shortName } = pc; - const rkey = configRkey ?? "self"; - const table = recordsTableName(shortName ?? collection); - const uris = dids.map((did) => `at://${did}/${collection}/${rkey}`); - - const rows = await batchedInQuery<Omit<RecordRow, "collection">>( - db, - `SELECT uri, did, rkey, cid, record FROM ${table} WHERE uri IN (__IN__)`, - [], - uris - ); - - for (const row of rows) { - let value: unknown = null; - if (row.record) { - try { - value = JSON.parse(row.record); - } catch { - value = row.record; - } - } - if (!result[row.did]) result[row.did] = []; - result[row.did].push({ - did: row.did, - handle: null, // filled below - uri: row.uri, - collection, - rkey: row.rkey, - cid: row.cid, - value, - }); - } - } - - // Resolve identities for all DIDs - const identities = await resolveIdentities(db, dids, config); - - // Fetch missing profile records from PDS on demand - const missingDids = dids.filter((d) => !result[d]); - if (missingDids.length > 0 && profileConfigs.length > 0) { - const fetched = await fetchMissingProfiles(db, config, missingDids); - for (const [did, entries] of Object.entries(fetched)) { - if (!result[did]) result[did] = []; - result[did].push(...entries); - } - } - - // Fill in handles and create entries for DIDs without profile records - for (const did of dids) { - const identity = identities.get(did); - const handle = identity?.handle ?? null; - - if (result[did]) { - for (const entry of result[did]) { - entry.handle = handle; - } - } else { - result[did] = [{ did, handle }]; - } - } - - return result; -} - -/** - * Fetch profile records from PDS for DIDs not yet in the index. - * Fetches in parallel across all configured profile collections, - * indexes the results into D1 for future requests. - */ -async function fetchMissingProfiles( - db: Database, - config: ContrailConfig, - dids: string[] -): Promise<Record<string, ProfileEntry[]>> { - const result: Record<string, ProfileEntry[]> = {}; - const profileConfigs = config.profiles!.map(normalizeProfileConfig); - - await Promise.all( - dids.flatMap((did) => - profileConfigs.map(async (pc) => { - const { collection, rkey: configRkey, shortName } = pc; - const rkey = configRkey ?? "self"; - const table = recordsTableName(shortName ?? collection); - try { - const pds = await getPDS(did as Did, db, config); - if (!pds) return; - - const url = new URL("/xrpc/com.atproto.repo.getRecord", pds); - url.searchParams.set("repo", did); - url.searchParams.set("collection", collection); - url.searchParams.set("rkey", rkey); - - const res = await fetch(url.toString()); - if (!res.ok) return; - - const data = (await res.json()) as { uri?: string; value?: unknown; cid?: string }; - if (!data.value || !data.cid) return; - - const uri = data.uri ?? `at://${did}/${collection}/${rkey}`; - const record = data.value; - const cid = data.cid; - - // Index into D1 for future requests - await db - .prepare( - `INSERT INTO ${table} (uri, did, rkey, cid, record, time_us, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(uri) DO UPDATE SET cid = excluded.cid, record = excluded.record, indexed_at = excluded.indexed_at` - ) - .bind(uri, did, rkey, cid, JSON.stringify(record), Date.now() * 1000, Date.now()) - .run(); - - if (!result[did]) result[did] = []; - result[did].push({ - did, - handle: null, - uri, - collection, - rkey, - cid, - value: record, - }); - } catch { - // Skip failures silently - } - }) - ) - ); - - return result; -} diff --git a/packages/contrail-appview/src/core/search.ts b/packages/contrail-appview/src/core/search.ts deleted file mode 100644 index a33dc5e..0000000 --- a/packages/contrail-appview/src/core/search.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { CollectionConfig } from "./types"; -import { getNestedValue } from "./types"; - -/** - * Resolve which fields are searchable for a collection. - * Returns null if search is disabled or no fields found. - */ -export function getSearchableFields( - collection: string, - colConfig: CollectionConfig -): string[] | null { - if (!Array.isArray(colConfig.searchable)) return null; - return colConfig.searchable.length > 0 ? colConfig.searchable : null; -} - -/** Sanitized FTS table name for a collection. */ -export function ftsTableName(collection: string): string { - return `fts_${collection.replace(/[^a-zA-Z0-9]/g, "_")}`; -} - -/** Extract searchable field values from a record and join them into a single string. */ -export function buildFtsContent(record: unknown, fields: string[]): string | null { - const parts: string[] = []; - for (const field of fields) { - const value = getNestedValue(record, field); - if (typeof value === "string" && value.length > 0) { - parts.push(value); - } - } - return parts.length > 0 ? parts.join(" ") : null; -} diff --git a/packages/contrail-appview/src/core/spaces/acl.ts b/packages/contrail-appview/src/core/spaces/acl.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/spaces/acl.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/spaces/adapter.ts b/packages/contrail-appview/src/core/spaces/adapter.ts deleted file mode 100644 index dab781e..0000000 --- a/packages/contrail-appview/src/core/spaces/adapter.ts +++ /dev/null @@ -1,505 +0,0 @@ -/** Contrail's all-in-one default adapter — extends the authority package's - * {@link HostedAuthorityAdapter} (which owns space lifecycle, member list, - * invites) and adds the record-host methods (records, blobs, enrollment). - * - * Phase 7a step 3 will lift the record-host methods into a separate - * HostedRecordHostAdapter, at which point this class becomes a thin - * composition / re-export. For now we keep both roles in one class so - * consumers can wire a single object that satisfies the full StorageAdapter. */ - -import type { ContrailConfig, RelationConfig, ResolvedContrailConfig } from "../types"; -import { - shortNameForNsid, - spacesRecordsTableName, - countColumnName, - groupedCountColumnName, - getRelationField, - getNestedValue, -} from "../types"; -import { getDialect } from "../dialect"; -import type { - BlobMetaRow, - CollectionCount, - EnrollmentRow, - ListBlobsOptions, - ListBlobsResult, - ListOptions, - ListResult, - StorageAdapter, - StoredRecord, -} from "./types"; -import type { Database } from "../types"; -import { buildRecordUri } from "./uri"; -import { HostedAuthorityAdapter, parseJson, toNum } from "@atmo-dev/contrail-authority"; - -function mapBlobMetaRow(row: any): BlobMetaRow { - return { - spaceUri: row.space_uri, - cid: row.cid, - mimeType: row.mime_type, - size: Number(row.size), - authorDid: row.author_did, - createdAt: toNum(row.created_at), - }; -} - -function mapEnrollmentRow(row: any): EnrollmentRow { - return { - spaceUri: row.space_uri, - authorityDid: row.authority_did, - enrolledAt: toNum(row.enrolled_at), - enrolledBy: row.enrolled_by, - }; -} - -/** Row mapper for per-collection spaces_records_<short> tables. - * `collection` is injected by the caller (known from the table name). */ -function mapRecordRow(row: any, collection: string): StoredRecord { - return { - spaceUri: row.space_uri, - collection, - authorDid: row.did, - rkey: row.rkey, - cid: row.cid ?? null, - record: parseJson<Record<string, unknown>>(row.record) ?? {}, - createdAt: toNum(row.time_us), - }; -} - -export class HostedAdapter extends HostedAuthorityAdapter implements StorageAdapter { - /** Resolve the per-collection spaces table name, or throw if the collection - * isn't configured (and therefore has no table). */ - private tableFor(collection: string): string { - if (!this.config) { - throw new Error( - `HostedAdapter: config not provided; cannot resolve table for collection ${collection}` - ); - } - const short = shortNameForNsid(this.config, collection); - if (!short) { - throw new Error( - `HostedAdapter: collection ${collection} is not configured in this deployment` - ); - } - return spacesRecordsTableName(short); - } - - // ---- Enrollment ---- - - async enroll(input: EnrollmentRow): Promise<void> { - await this.db - .prepare( - `INSERT INTO record_host_enrollments (space_uri, authority_did, enrolled_at, enrolled_by) - VALUES (?, ?, ?, ?) - ON CONFLICT (space_uri) DO UPDATE SET - authority_did = excluded.authority_did, - enrolled_at = excluded.enrolled_at, - enrolled_by = excluded.enrolled_by` - ) - .bind(input.spaceUri, input.authorityDid, input.enrolledAt, input.enrolledBy) - .run(); - } - - async getEnrollment(spaceUri: string): Promise<EnrollmentRow | null> { - const row = await this.db - .prepare(`SELECT * FROM record_host_enrollments WHERE space_uri = ?`) - .bind(spaceUri) - .first<any>(); - return row ? mapEnrollmentRow(row) : null; - } - - async listEnrollments( - options: { authorityDid?: string; limit?: number } = {} - ): Promise<EnrollmentRow[]> { - const limit = Math.min(options.limit ?? 200, 1000); - if (options.authorityDid) { - const { results } = await this.db - .prepare( - `SELECT * FROM record_host_enrollments WHERE authority_did = ? ORDER BY enrolled_at DESC LIMIT ?` - ) - .bind(options.authorityDid, limit) - .all<any>(); - return results.map(mapEnrollmentRow); - } - const { results } = await this.db - .prepare(`SELECT * FROM record_host_enrollments ORDER BY enrolled_at DESC LIMIT ?`) - .bind(limit) - .all<any>(); - return results.map(mapEnrollmentRow); - } - - async removeEnrollment(spaceUri: string): Promise<void> { - await this.db - .prepare(`DELETE FROM record_host_enrollments WHERE space_uri = ?`) - .bind(spaceUri) - .run(); - } - - // ---- Records ---- - - async putRecord(record: StoredRecord): Promise<void> { - const table = this.tableFor(record.collection); - const uri = buildRecordUri(record.authorDid, record.collection, record.rkey); - - const childShort = this.config ? shortNameForNsid(this.config, record.collection) : null; - const prev = childShort - ? await this.db - .prepare(`SELECT record FROM ${table} WHERE space_uri = ? AND did = ? AND rkey = ?`) - .bind(record.spaceUri, record.authorDid, record.rkey) - .first<{ record: unknown } | null>() - : null; - const beforeRecord = parseJson<Record<string, unknown>>(prev?.record ?? null); - - await this.db - .prepare( - `INSERT INTO ${table} (space_uri, uri, did, rkey, cid, record, time_us, indexed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (space_uri, did, rkey) DO UPDATE SET - uri = excluded.uri, - cid = excluded.cid, - record = excluded.record, - time_us = excluded.time_us, - indexed_at = excluded.indexed_at` - ) - .bind( - record.spaceUri, - uri, - record.authorDid, - record.rkey, - record.cid, - JSON.stringify(record.record), - record.createdAt, - Date.now() - ) - .run(); - - if (childShort && this.config) { - await this.recountParentsForSpace( - record.spaceUri, - childShort, - beforeRecord, - record.record, - record.authorDid - ); - } - } - - async getRecord( - spaceUri: string, - collection: string, - authorDid: string, - rkey: string - ): Promise<StoredRecord | null> { - const table = this.tableFor(collection); - const row = await this.db - .prepare( - `SELECT * FROM ${table} - WHERE space_uri = ? AND did = ? AND rkey = ?` - ) - .bind(spaceUri, authorDid, rkey) - .first<any>(); - return row ? mapRecordRow(row, collection) : null; - } - - async listRecords( - spaceUri: string, - collection: string, - options: ListOptions = {} - ): Promise<ListResult> { - const table = this.tableFor(collection); - const limit = Math.min(options.limit ?? 50, 200); - const clauses: string[] = ["space_uri = ?"]; - const params: any[] = [spaceUri]; - - if (options.byUser) { - clauses.push("did = ?"); - params.push(options.byUser); - } - if (options.cursor) { - clauses.push("time_us < ?"); - params.push(Number(options.cursor)); - } - - const sql = `SELECT * FROM ${table} - WHERE ${clauses.join(" AND ")} - ORDER BY time_us DESC - LIMIT ?`; - params.push(limit + 1); - - const { results } = await this.db.prepare(sql).bind(...params).all<any>(); - const records = results.map((r) => mapRecordRow(r, collection)); - let cursor: string | undefined; - if (records.length > limit) { - const next = records.pop()!; - cursor = String(next.createdAt); - } - return { records, cursor }; - } - - async deleteRecord( - spaceUri: string, - collection: string, - authorDid: string, - rkey: string - ): Promise<void> { - const table = this.tableFor(collection); - - const childShort = this.config ? shortNameForNsid(this.config, collection) : null; - const prev = childShort - ? await this.db - .prepare(`SELECT record FROM ${table} WHERE space_uri = ? AND did = ? AND rkey = ?`) - .bind(spaceUri, authorDid, rkey) - .first<{ record: unknown } | null>() - : null; - const beforeRecord = parseJson<Record<string, unknown>>(prev?.record ?? null); - - await this.db - .prepare( - `DELETE FROM ${table} - WHERE space_uri = ? AND did = ? AND rkey = ?` - ) - .bind(spaceUri, authorDid, rkey) - .run(); - - if (childShort && this.config) { - await this.recountParentsForSpace(spaceUri, childShort, beforeRecord, null, authorDid); - } - } - - /** Recompute count columns on parent records in the same space, scoped to the - * targets derived from before/after versions of the written/deleted child record. */ - private async recountParentsForSpace( - spaceUri: string, - childShort: string, - before: Record<string, unknown> | null, - after: Record<string, unknown> | null, - childDid: string - ): Promise<void> { - if (!this.config) return; - const config = this.config; - const resolved = (config as ResolvedContrailConfig)._resolved; - const childTable = spacesRecordsTableName(childShort); - - type Inbound = { parentShort: string; relationName: string; rel: RelationConfig }; - const inbound: Inbound[] = []; - for (const [parentShort, parentCfg] of Object.entries(config.collections)) { - if (parentCfg.allowInSpaces === false) continue; - for (const [relName, rel] of Object.entries(parentCfg.relations ?? {})) { - if (rel.count === false) continue; - if (rel.collection !== childShort) continue; - inbound.push({ parentShort, relationName: relName, rel }); - } - } - if (inbound.length === 0) return; - - // Deduplicate (parent, relation, target) across before/after. - const keyed = new Map<string, { parentShort: string; relationName: string; rel: RelationConfig; target: string }>(); - for (const { parentShort, relationName, rel } of inbound) { - const field = getRelationField(rel); - const collectTarget = (rec: Record<string, unknown> | null) => { - if (!rec) return; - if (rel.match === "did") { - keyed.set(`${parentShort}:${relationName}:${childDid}`, { - parentShort, relationName, rel, target: childDid, - }); - return; - } - const v = getNestedValue(rec, field); - if (typeof v === "string" && v.length > 0) { - keyed.set(`${parentShort}:${relationName}:${v}`, { - parentShort, relationName, rel, target: v, - }); - } - }; - collectTarget(before); - collectTarget(after); - } - if (keyed.size === 0) return; - - const dialect = getDialect(this.db); - const stmts: ReturnType<Database["prepare"]>[] = []; - - for (const { parentShort, relationName, rel, target } of keyed.values()) { - const parentTable = spacesRecordsTableName(parentShort); - const matchColumn = rel.match === "did" ? "did" : "uri"; - const field = getRelationField(rel); - const countExpr = rel.countDistinct - ? `COUNT(DISTINCT ${rel.countDistinct})` - : "COUNT(*)"; - - const setClauses: string[] = []; - const binds: (string | number)[] = []; - - const totalCol = countColumnName(rel.collection); - setClauses.push( - `${totalCol} = (SELECT ${countExpr} FROM ${childTable} WHERE space_uri = ? AND ${dialect.jsonExtract("record", field)} = ?)` - ); - binds.push(spaceUri, target); - - if (rel.groupBy) { - const mapping = resolved?.relations[parentShort]?.[relationName]; - if (mapping?.groups) { - for (const [groupKey, fullToken] of Object.entries(mapping.groups)) { - const groupCol = groupedCountColumnName(rel.collection, groupKey); - setClauses.push( - `${groupCol} = (SELECT ${countExpr} FROM ${childTable} WHERE space_uri = ? AND ${dialect.jsonExtract("record", field)} = ? AND ${dialect.jsonExtract("record", rel.groupBy)} = ?)` - ); - binds.push(spaceUri, target, fullToken); - } - } - } - - binds.push(spaceUri, target); - stmts.push( - this.db - .prepare( - `UPDATE ${parentTable} SET ${setClauses.join(", ")} WHERE space_uri = ? AND ${matchColumn} = ?` - ) - .bind(...binds) - ); - } - - if (stmts.length > 0) await this.db.batch(stmts); - } - - async listCollections( - spaceUri: string, - options: { byUser?: string } = {} - ): Promise<CollectionCount[]> { - if (!this.config) return []; - const results: CollectionCount[] = []; - for (const [short, colConfig] of Object.entries(this.config.collections)) { - if (colConfig.allowInSpaces === false) continue; - const table = spacesRecordsTableName(short); - const clauses: string[] = ["space_uri = ?"]; - const params: any[] = [spaceUri]; - if (options.byUser) { - clauses.push("did = ?"); - params.push(options.byUser); - } - try { - const row = await this.db - .prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE ${clauses.join(" AND ")}`) - .bind(...params) - .first<{ count: number }>(); - const count = Number(row?.count ?? 0); - if (count > 0) results.push({ collection: colConfig.collection ?? short, count }); - } catch { - // table doesn't exist (collection added after init, or allowInSpaces toggled) — skip - } - } - return results; - } - - // ---- Blobs ---- - - async putBlobMeta(row: BlobMetaRow): Promise<void> { - const sql = `INSERT INTO spaces_blobs (space_uri, cid, mime_type, size, author_did, created_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT (space_uri, cid) DO NOTHING`; - await this.db - .prepare(sql) - .bind(row.spaceUri, row.cid, row.mimeType, row.size, row.authorDid, row.createdAt) - .run(); - } - - async getBlobMeta(spaceUri: string, cid: string): Promise<BlobMetaRow | null> { - const r = await this.db - .prepare(`SELECT * FROM spaces_blobs WHERE space_uri = ? AND cid = ?`) - .bind(spaceUri, cid) - .first<any>(); - return r ? mapBlobMetaRow(r) : null; - } - - async listBlobMeta( - spaceUri: string, - options: ListBlobsOptions = {} - ): Promise<ListBlobsResult> { - const limit = Math.min(options.limit ?? 50, 200); - const clauses: string[] = ["space_uri = ?"]; - const params: any[] = [spaceUri]; - if (options.byUser) { - clauses.push("author_did = ?"); - params.push(options.byUser); - } - if (options.cursor) { - clauses.push("created_at < ?"); - params.push(Number(options.cursor)); - } - const sql = `SELECT * FROM spaces_blobs - WHERE ${clauses.join(" AND ")} - ORDER BY created_at DESC - LIMIT ?`; - params.push(limit + 1); - const { results } = await this.db.prepare(sql).bind(...params).all<any>(); - const blobs = results.map(mapBlobMetaRow); - let cursor: string | undefined; - if (blobs.length > limit) { - const next = blobs.pop()!; - cursor = String(next.createdAt); - } - return { blobs, cursor }; - } - - async deleteBlobMeta(spaceUri: string, cid: string): Promise<void> { - await this.db - .prepare(`DELETE FROM spaces_blobs WHERE space_uri = ? AND cid = ?`) - .bind(spaceUri, cid) - .run(); - } - - async findOrphanBlobs( - spaceUri: string, - cutoff: number, - limit: number - ): Promise<BlobMetaRow[]> { - if (!this.config) return []; - // Gather candidate blobs older than cutoff, then filter out any whose CID - // appears in any record JSON in this space. We use a cheap substring probe - // (LIKE) per collection — false positives are OK because an orphan that - // survives GC just gets collected next cycle; false negatives (deleting - // a referenced blob) would be a bug, and substring search over the full - // CID is safe enough for that. - const { results } = await this.db - .prepare( - `SELECT * FROM spaces_blobs - WHERE space_uri = ? AND created_at < ? - ORDER BY created_at ASC - LIMIT ?` - ) - .bind(spaceUri, cutoff, limit) - .all<any>(); - const candidates = results.map(mapBlobMetaRow); - if (candidates.length === 0) return []; - - const tables: string[] = []; - for (const [short, colConfig] of Object.entries(this.config.collections)) { - if (colConfig.allowInSpaces === false) continue; - tables.push(spacesRecordsTableName(short)); - } - - const orphans: BlobMetaRow[] = []; - for (const blob of candidates) { - let referenced = false; - const pattern = `%${blob.cid}%`; - for (const table of tables) { - try { - const row = await this.db - .prepare( - `SELECT 1 FROM ${table} WHERE space_uri = ? AND record LIKE ? LIMIT 1` - ) - .bind(spaceUri, pattern) - .first<any>(); - if (row) { - referenced = true; - break; - } - } catch { - // table missing — ignore - } - } - if (!referenced) orphans.push(blob); - } - return orphans; - } -} diff --git a/packages/contrail-appview/src/core/spaces/auth.ts b/packages/contrail-appview/src/core/spaces/auth.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/spaces/auth.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/spaces/binding.ts b/packages/contrail-appview/src/core/spaces/binding.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/spaces/binding.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/spaces/blob-adapter.ts b/packages/contrail-appview/src/core/spaces/blob-adapter.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/spaces/blob-adapter.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/spaces/blob-gc.ts b/packages/contrail-appview/src/core/spaces/blob-gc.ts deleted file mode 100644 index d523fcd..0000000 --- a/packages/contrail-appview/src/core/spaces/blob-gc.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { BlobAdapter } from "./blob-adapter"; -import { blobKey } from "./blob-adapter"; -import type { StorageAdapter } from "./types"; - -export interface BlobGcOptions { - /** Orphan rows created before this timestamp are eligible for deletion. */ - olderThan: number; - /** Maximum number of blobs to delete in this pass. Defaults to 500. */ - batchSize?: number; -} - -export interface BlobGcResult { - deleted: number; - cids: string[]; -} - -/** Delete blob bytes + metadata for any blob older than `olderThan` that - * is not referenced by any record in the space. Safe to run periodically. */ -export async function gcOrphanBlobs( - storage: StorageAdapter, - blobs: BlobAdapter, - spaceUri: string, - options: BlobGcOptions -): Promise<BlobGcResult> { - const batchSize = options.batchSize ?? 500; - const orphans = await storage.findOrphanBlobs(spaceUri, options.olderThan, batchSize); - if (orphans.length === 0) return { deleted: 0, cids: [] }; - - const keys: string[] = []; - for (const row of orphans) { - keys.push(await blobKey(row.spaceUri, row.cid)); - } - await blobs.delete(keys); - for (const row of orphans) { - await storage.deleteBlobMeta(row.spaceUri, row.cid); - } - return { deleted: orphans.length, cids: orphans.map((o) => o.cid) }; -} diff --git a/packages/contrail-appview/src/core/spaces/blob-refs.ts b/packages/contrail-appview/src/core/spaces/blob-refs.ts deleted file mode 100644 index 62cf693..0000000 --- a/packages/contrail-appview/src/core/spaces/blob-refs.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Walk a record JSON and collect every atproto blob ref. - * - * Blob refs look like: - * { "$type": "blob", "ref": { "$link": "<cid>" }, "mimeType": "...", "size": N } - * - * We return the CID strings. - */ -export function collectBlobCids(value: unknown, out: Set<string> = new Set()): Set<string> { - if (value == null) return out; - if (Array.isArray(value)) { - for (const v of value) collectBlobCids(v, out); - return out; - } - if (typeof value !== "object") return out; - - const obj = value as Record<string, unknown>; - if (obj["$type"] === "blob") { - const ref = obj["ref"] as { $link?: unknown } | undefined; - if (ref && typeof ref["$link"] === "string") out.add(ref["$link"]); - // Don't descend — a blob ref's own shape has no nested blobs. - return out; - } - - for (const v of Object.values(obj)) collectBlobCids(v, out); - return out; -} diff --git a/packages/contrail-appview/src/core/spaces/credentials.ts b/packages/contrail-appview/src/core/spaces/credentials.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/spaces/credentials.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/spaces/in-process.ts b/packages/contrail-appview/src/core/spaces/in-process.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/spaces/in-process.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/spaces/router.ts b/packages/contrail-appview/src/core/spaces/router.ts deleted file mode 100644 index 9d4ce90..0000000 --- a/packages/contrail-appview/src/core/spaces/router.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** Umbrella that wires the authority + record-host route registrations from - * their respective packages onto a single Hono app. The actual route - * handlers live in `@atmo-dev/contrail-authority` and - * `@atmo-dev/contrail-record-host`. */ - -import type { Hono, MiddlewareHandler } from "hono"; -import type { ContrailConfig, Database } from "../types"; -import { HostedAdapter } from "./adapter"; -import { - buildVerifier, - createBindingCredentialVerifier, - createEnrollmentBindingResolver, - createLocalKeyResolver, - createServiceAuthMiddleware, -} from "@atmo-dev/contrail-base"; -import type { - CredentialVerifier, - StorageAdapter, - WhoamiExtension, -} from "@atmo-dev/contrail-base"; -import { registerAuthorityRoutes } from "@atmo-dev/contrail-authority"; -import { registerRecordHostRoutes } from "@atmo-dev/contrail-record-host"; - -// Re-export the route-registration functions and WhoamiExtension type so -// existing consumers of `@atmo-dev/contrail` keep their imports working -// without switching to the new packages. -export { registerAuthorityRoutes, registerRecordHostRoutes }; -export type { WhoamiExtension }; - -export interface SpacesRoutesOptions { - /** Provide a custom middleware (e.g. for tests). If omitted and authority is set, a real one is built. */ - authMiddleware?: MiddlewareHandler; - /** Storage adapter override. Defaults to HostedAdapter(db). */ - adapter?: StorageAdapter; - /** Optional whoami extension; see {@link WhoamiExtension}. */ - whoamiExtension?: WhoamiExtension; - /** Optional credential verifier for the record host. */ - credentialVerifier?: CredentialVerifier; -} - -/** Umbrella registration: wires both the authority and the record-host - * routes against the same adapter. Today's deployments enable both via - * `config.spaces.authority` and `config.spaces.recordHost`. */ -export function registerSpacesRoutes( - app: Hono, - db: Database, - config: ContrailConfig, - options: SpacesRoutesOptions = {}, - ctx?: { adapter: StorageAdapter; verifier: import("@atcute/xrpc-server/auth").ServiceJwtVerifier } | null -): void { - const spacesConfig = config.spaces; - if (!spacesConfig) return; - const authorityConfig = spacesConfig.authority; - if (!authorityConfig) return; - - const adapter = options.adapter ?? ctx?.adapter ?? new HostedAdapter(db, config); - const verifier = ctx?.verifier ?? buildVerifier(authorityConfig, config.networkOverrides); - const auth = options.authMiddleware ?? createServiceAuthMiddleware(verifier); - - const localRecordHost = spacesConfig.recordHost ? adapter : null; - registerAuthorityRoutes( - app, - adapter, - authorityConfig, - config, - auth, - options.whoamiExtension, - localRecordHost - ); - - if (spacesConfig.recordHost) { - const credentialVerifier = - options.credentialVerifier ?? - (authorityConfig.signing - ? createBindingCredentialVerifier({ - bindings: createEnrollmentBindingResolver({ recordHost: adapter }), - keys: createLocalKeyResolver({ - authorityDid: authorityConfig.serviceDid, - publicKey: authorityConfig.signing.publicKey, - }), - }) - : undefined); - registerRecordHostRoutes(app, adapter, adapter, spacesConfig.recordHost, config, auth, credentialVerifier); - } -} diff --git a/packages/contrail-appview/src/core/spaces/schema.ts b/packages/contrail-appview/src/core/spaces/schema.ts deleted file mode 100644 index fcdf0cd..0000000 --- a/packages/contrail-appview/src/core/spaces/schema.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { ContrailConfig, Database } from "../types"; -import type { SqlDialect } from "../dialect"; -import { getDialect } from "../dialect"; -import { - buildCollectionTables, - buildDynamicIndexes, - buildFtsTables, - applyCountColumns, -} from "../db/schema"; - -/** Spaces metadata tables — spaces, members, invites. No per-collection tables. */ -export function buildSpacesBaseSchema(dialect: SqlDialect): string[] { - return [ - `CREATE TABLE IF NOT EXISTS spaces ( - uri TEXT PRIMARY KEY, - owner_did TEXT NOT NULL, - type TEXT NOT NULL, - key TEXT NOT NULL, - service_did TEXT NOT NULL, - app_policy_ref TEXT, - app_policy ${dialect.recordColumnType}, - created_at ${dialect.bigintType} NOT NULL, - deleted_at ${dialect.bigintType} - )`, - `CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_did)`, - `CREATE INDEX IF NOT EXISTS idx_spaces_type ON spaces(type)`, - - `CREATE TABLE IF NOT EXISTS spaces_members ( - space_uri TEXT NOT NULL, - did TEXT NOT NULL, - added_at ${dialect.bigintType} NOT NULL, - added_by TEXT, - PRIMARY KEY (space_uri, did) - )`, - `CREATE INDEX IF NOT EXISTS idx_spaces_members_did ON spaces_members(did)`, - - `CREATE TABLE IF NOT EXISTS spaces_blobs ( - space_uri TEXT NOT NULL, - cid TEXT NOT NULL, - mime_type TEXT NOT NULL, - size INTEGER NOT NULL, - author_did TEXT NOT NULL, - created_at ${dialect.bigintType} NOT NULL, - PRIMARY KEY (space_uri, cid) - )`, - `CREATE INDEX IF NOT EXISTS idx_spaces_blobs_author ON spaces_blobs(space_uri, author_did)`, - `CREATE INDEX IF NOT EXISTS idx_spaces_blobs_created ON spaces_blobs(space_uri, created_at)`, - - `CREATE TABLE IF NOT EXISTS spaces_invites ( - token_hash TEXT PRIMARY KEY, - space_uri TEXT NOT NULL, - kind TEXT NOT NULL DEFAULT 'join', - expires_at ${dialect.bigintType}, - max_uses INTEGER, - used_count INTEGER NOT NULL DEFAULT 0, - created_by TEXT NOT NULL, - created_at ${dialect.bigintType} NOT NULL, - revoked_at ${dialect.bigintType}, - note TEXT - )`, - `CREATE INDEX IF NOT EXISTS idx_spaces_invites_space ON spaces_invites(space_uri, created_at DESC)`, - - // Record-host enrollment table — the host's local cache of "spaces I - // accept records for, and which authority signs credentials for each." - // Filled by the recordHost.enroll endpoint, or auto-populated by the - // authority's createSpace when both roles run in the same process. - `CREATE TABLE IF NOT EXISTS record_host_enrollments ( - space_uri TEXT PRIMARY KEY, - authority_did TEXT NOT NULL, - enrolled_at ${dialect.bigintType} NOT NULL, - enrolled_by TEXT NOT NULL - )`, - `CREATE INDEX IF NOT EXISTS idx_enrollments_authority ON record_host_enrollments(authority_did)`, - ]; -} - -/** Full spaces schema (base + per-collection tables + indexes). For callers - * that need a single array of statements. Note: this does NOT include FTS - * virtual tables or ALTER TABLE count columns — FTS is best-effort (engine - * may not be present) and count columns require dialect-aware idempotent - * ALTER. Both are handled by `initSchema` / `initSpacesSchema`. */ -export function buildSpacesSchema(db: Database, config?: ContrailConfig): string[] { - const dialect = getDialect(db); - const base = buildSpacesBaseSchema(dialect); - if (!config) return base; - return [ - ...base, - ...buildCollectionTables(config, dialect, { forSpaces: true }), - ...buildDynamicIndexes(config, dialect, { forSpaces: true }), - ]; -} - -export async function initSpacesSchema(db: Database, config?: ContrailConfig): Promise<void> { - const dialect = getDialect(db); - const stmts = buildSpacesSchema(db, config); - await db.batch(stmts.map((s) => db.prepare(s))); - if (!config) return; - // FTS virtual tables: best-effort; the runtime may not have FTS5 compiled - // in (e.g. node:sqlite). Other DDL failures (count columns) propagate. - for (const stmt of buildFtsTables(config, dialect, { forSpaces: true })) { - try { await db.prepare(stmt).run(); } catch { /* FTS5 unavailable */ } - } - await applyCountColumns(db, config, { forSpaces: true }); -} diff --git a/packages/contrail-appview/src/core/spaces/tid.ts b/packages/contrail-appview/src/core/spaces/tid.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/spaces/tid.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/spaces/types.ts b/packages/contrail-appview/src/core/spaces/types.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/spaces/types.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/spaces/uri.ts b/packages/contrail-appview/src/core/spaces/uri.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/spaces/uri.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/core/types.ts b/packages/contrail-appview/src/core/types.ts deleted file mode 100644 index 1129419..0000000 --- a/packages/contrail-appview/src/core/types.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail-appview/src/index.ts b/packages/contrail-appview/src/index.ts deleted file mode 100644 index a89b7a3..0000000 --- a/packages/contrail-appview/src/index.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** @atmo-dev/contrail-appview — public-records appview for contrail. - * - * Owns: jetstream ingestion, backfill, refresh, query layer, per-collection - * XRPC routes, feeds, profiles, labels, the umbrella `createApp` that wires - * authority + record-host integrations, plus the `HostedAdapter` - * composition for in-process deployments. - * - * Re-exported wholesale from each module so consumers don't need to know - * internal path layout. */ - -// Forward base + authority + record-host so a consumer that imports -// `@atmo-dev/contrail-appview` (or a shim that re-exports it) sees the full -// shared surface in one place. Contrail bundle re-exports from here to keep -// its public API surface unchanged. -export * from "@atmo-dev/contrail-base"; -export * from "@atmo-dev/contrail-authority"; -export * from "@atmo-dev/contrail-record-host"; - -// Record-sync ingestion (consumer side of recordHost.sync) -export { - runRecordHostSync, - buildRecordSyncSchema, - applyRecordSyncSchema, -} from "./sync"; -export type { - RecordHostSyncSource, - RecordHostSyncOptions, -} from "./sync"; - -// Indexing pipeline (jetstream, persistent, backfill, refresh, ingest helpers) -export * from "./core/jetstream"; -export * from "./core/persistent"; -export * from "./core/backfill"; -export * from "./core/refresh"; -export * from "./core/search"; -export * from "./core/constellation"; - -// DB -export * from "./core/db/schema"; -export * from "./core/db/records"; -export * from "./core/db/meta"; -export * from "./core/db/optimize"; -// note: ./core/db/index is implicitly covered by the wildcard if we export it -// — but we don't, since both schema and records may export overlapping names. -// Tests can import the specifics they need. - -// Router (createApp, registerCollectionRoutes, admin, feed, notify, profiles, hydrate) -export * from "./core/router"; -export * from "./core/router/notify"; -export * from "./core/router/profiles"; -export * from "./core/router/feed"; -export * from "./core/router/admin"; -export * from "./core/router/collection"; -export * from "./core/router/hydrate"; -export * from "./core/router/helpers"; - -// Spaces — re-exports + the bundle's HostedAdapter composition -export { HostedAdapter } from "./core/spaces/adapter"; -export { - registerSpacesRoutes, - registerAuthorityRoutes, - registerRecordHostRoutes, -} from "./core/spaces/router"; -export type { - SpacesRoutesOptions, - WhoamiExtension, -} from "./core/spaces/router"; - -// Realtime -export * from "./core/realtime"; - -// Labels -export * from "./core/labels/types"; -export * from "./core/labels/hydrate"; -export * from "./core/labels/select"; -export * from "./core/labels/apply"; -export * from "./core/labels/subscribe"; -export * from "./core/labels/resolve"; -export * from "./core/labels/schema"; diff --git a/packages/contrail-appview/src/sync.ts b/packages/contrail-appview/src/sync.ts deleted file mode 100644 index e2526fc..0000000 --- a/packages/contrail-appview/src/sync.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** Appview-side ingestion loop for the recordHost.sync streaming endpoint. - * - * Opens an SSE connection to a remote host's `<ns>.recordHost.sync` endpoint - * for a (host, space) pair, parses the event stream, writes each - * `record.created` / `record.deleted` event into the local record host's - * tables, and persists the cursor after every checkpoint. - * - * Designed to be called per-subscription. Reconnect logic is the caller's — - * this function returns when the stream ends or an error throws. Wrap it in - * a retry-with-backoff loop in your worker / persistent-process. */ - -import type { - ContrailConfig, - Database, - RecordHost, - SqlDialect, -} from "@atmo-dev/contrail-base"; -import { getDialect } from "@atmo-dev/contrail-base"; - -export interface RecordHostSyncSource { - /** Remote host's base URL, e.g. "https://contrail-a.example.com". */ - hostUrl: string; - /** Space we want to sync. */ - spaceUri: string; - /** Authority DID — used for auto-enrolling locally on first connect. */ - authorityDid: string; - /** Credential the appview presents to read this space's stream. */ - credential: string; - /** Sync endpoint NSID; defaults to "<config.namespace>.recordHost.sync". */ - endpointNsid?: string; -} - -export interface RecordHostSyncOptions { - /** Local DB the records go into (same DB the appview's RecordHost adapter uses). */ - db: Database; - /** Resolved config. Used to derive the remote endpoint NSID and to find - * collection short names for table writes. */ - config: ContrailConfig; - /** Local record host — the destination for ingested events. The function - * calls `putRecord` / `deleteRecord` / `enroll` on this. */ - recordHost: RecordHost; - /** fetch implementation — pass the remote host app's fetch directly for - * in-process tests; defaults to globalThis.fetch. */ - fetch?: typeof fetch; - /** Aborts the stream when triggered. */ - signal?: AbortSignal; - /** Called on each cursor checkpoint, after persistence. */ - onCursor?: (cursor: string) => void; -} - -/** Run sync for a single (host, space) source until the stream ends or the - * signal aborts. Reads the prior cursor from `record_sync_subscriptions` if - * present; persists the new cursor as it advances. Auto-enrolls the space - * locally on first connect using the source's `authorityDid`. */ -export async function runRecordHostSync( - source: RecordHostSyncSource, - options: RecordHostSyncOptions -): Promise<void> { - const fetchImpl = options.fetch ?? globalThis.fetch; - await ensureSyncSchema(options.db); - - // Auto-enroll the space so the local record host accepts subsequent - // queries against it. - const existing = await options.recordHost.getEnrollment(source.spaceUri); - if (!existing) { - await options.recordHost.enroll({ - spaceUri: source.spaceUri, - authorityDid: source.authorityDid, - enrolledAt: Date.now(), - enrolledBy: source.authorityDid, - }); - } - - // Resume from the last persisted cursor for this subscription. - const since = await readCursor(options.db, source.hostUrl, source.spaceUri); - - const endpoint = - source.endpointNsid ?? `${options.config.namespace}.recordHost.sync`; - const url = new URL(`${source.hostUrl}/xrpc/${endpoint}`); - url.searchParams.set("spaceUri", source.spaceUri); - if (since) url.searchParams.set("since", since); - - const res = await fetchImpl(url.toString(), { - headers: { - "X-Space-Credential": source.credential, - accept: "text/event-stream", - }, - signal: options.signal, - }); - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error(`recordHost.sync ${res.status}: ${body}`); - } - if (!res.body) { - throw new Error("recordHost.sync response has no body"); - } - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buf = ""; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - while (true) { - const idx = buf.indexOf("\n\n"); - if (idx < 0) break; - const block = buf.slice(0, idx); - buf = buf.slice(idx + 2); - const dataLine = block.split("\n").find((l) => l.startsWith("data:")); - if (!dataLine) continue; - const json = dataLine.slice(5).trim(); - if (!json) continue; - let event: any; - try { - event = JSON.parse(json); - } catch { - continue; - } - await applyEvent(event, options); - if (event.kind === "cursor" && typeof event.value === "string") { - await persistCursor( - options.db, - source.hostUrl, - source.spaceUri, - event.value - ); - options.onCursor?.(event.value); - } - } - } - } finally { - await reader.cancel().catch(() => {}); - } -} - -async function applyEvent( - event: any, - options: RecordHostSyncOptions -): Promise<void> { - if (event.kind === "record.created") { - const p = event.payload; - await options.recordHost.putRecord({ - spaceUri: p.space, - collection: p.collection, - authorDid: p.did, - rkey: p.rkey, - cid: p.cid ?? null, - record: p.record ?? {}, - // The host's time_us is microseconds; createdAt on putRecord is ms-ish - // historically. Keep the host's ordering by passing it through; the - // host adapter writes time_us = createdAt * 1000 internally so this - // round-trips. We store the source's time_us directly to preserve - // ordering across hosts. - createdAt: p.time_us != null ? Math.floor(p.time_us / 1000) : Date.now(), - }); - } else if (event.kind === "record.deleted") { - const p = event.payload; - await options.recordHost.deleteRecord(p.space, p.collection, p.did, p.rkey); - } - // cursor events are handled by the caller for persistence -} - -// ---- Schema + cursor persistence ---- - -const SYNC_SCHEMA_APPLIED = new WeakSet<object>(); - -/** Idempotent: applies the `record_sync_subscriptions` table on first call - * for a given DB. Tracks per-DB (by reference) so repeated calls in tests - * don't re-issue DDL each time. */ -async function ensureSyncSchema(db: Database): Promise<void> { - if (SYNC_SCHEMA_APPLIED.has(db as unknown as object)) return; - const dialect = getDialect(db); - const stmts = buildRecordSyncSchema(dialect); - await db.batch(stmts.map((s) => db.prepare(s))); - SYNC_SCHEMA_APPLIED.add(db as unknown as object); -} - -export function buildRecordSyncSchema(dialect: SqlDialect): string[] { - return [ - `CREATE TABLE IF NOT EXISTS record_sync_subscriptions ( - host_url TEXT NOT NULL, - space_uri TEXT NOT NULL, - cursor TEXT, - last_synced_at ${dialect.bigintType}, - PRIMARY KEY (host_url, space_uri) - )`, - ]; -} - -/** SchemaModule-shaped helper for `initSchema({ extraSchemas: [...] })`. */ -export async function applyRecordSyncSchema(db: Database): Promise<void> { - const dialect = getDialect(db); - const stmts = buildRecordSyncSchema(dialect); - await db.batch(stmts.map((s) => db.prepare(s))); -} - -async function readCursor( - db: Database, - hostUrl: string, - spaceUri: string -): Promise<string | null> { - const row = await db - .prepare( - `SELECT cursor FROM record_sync_subscriptions WHERE host_url = ? AND space_uri = ?` - ) - .bind(hostUrl, spaceUri) - .first<{ cursor: string | null } | null>(); - return row?.cursor ?? null; -} - -async function persistCursor( - db: Database, - hostUrl: string, - spaceUri: string, - cursor: string -): Promise<void> { - await db - .prepare( - `INSERT INTO record_sync_subscriptions (host_url, space_uri, cursor, last_synced_at) - VALUES (?, ?, ?, ?) - ON CONFLICT (host_url, space_uri) DO UPDATE SET - cursor = excluded.cursor, - last_synced_at = excluded.last_synced_at` - ) - .bind(hostUrl, spaceUri, cursor, Date.now()) - .run(); -} diff --git a/packages/contrail-appview/tsconfig.build.json b/packages/contrail-appview/tsconfig.build.json deleted file mode 100644 index 6092abf..0000000 --- a/packages/contrail-appview/tsconfig.build.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/packages/contrail-appview/tsconfig.json b/packages/contrail-appview/tsconfig.json deleted file mode 100644 index 6092abf..0000000 --- a/packages/contrail-appview/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/packages/contrail-appview/tsup.config.ts b/packages/contrail-appview/tsup.config.ts deleted file mode 100644 index eb81354..0000000 --- a/packages/contrail-appview/tsup.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/index.ts"], - format: ["esm"], - dts: true, - sourcemap: true, - clean: true, - tsconfig: "tsconfig.build.json", - external: [ - "@atmo-dev/contrail-base", - "@atmo-dev/contrail-authority", - "@atmo-dev/contrail-record-host", - ], -}); diff --git a/packages/contrail-authority/CHANGELOG.md b/packages/contrail-authority/CHANGELOG.md deleted file mode 100644 index 7df5b30..0000000 --- a/packages/contrail-authority/CHANGELOG.md +++ /dev/null @@ -1,62 +0,0 @@ -# @atmo-dev/contrail-authority - -## 0.12.2 - -### Patch Changes - -- @atmo-dev/contrail-base@0.12.2 - -## 0.12.1 - -### Patch Changes - -- Updated dependencies [833a659] -- Updated dependencies [74a2d3d] -- Updated dependencies [9894787] - - @atmo-dev/contrail-base@0.12.1 - -## 0.12.0 - -### Patch Changes - -- Updated dependencies [6b34d87] - - @atmo-dev/contrail-base@0.12.0 - -## 0.11.0 - -### Patch Changes - -- @atmo-dev/contrail-base@0.11.0 - -## 0.10.0 - -### Patch Changes - -- Updated dependencies [89aee1b] - - @atmo-dev/contrail-base@0.10.0 - -## 0.9.1 - -### Patch Changes - -- Updated dependencies [4c8fedb] - - @atmo-dev/contrail-base@0.9.1 - -## 0.9.0 - -### Patch Changes - -- @atmo-dev/contrail-base@0.9.0 - -## 0.8.0 - -### Patch Changes - -- Updated dependencies [d7e0936] - - @atmo-dev/contrail-base@0.8.0 - -## 0.7.0 - -### Patch Changes - -- @atmo-dev/contrail-base@0.7.0 diff --git a/packages/contrail-authority/package.json b/packages/contrail-authority/package.json deleted file mode 100644 index c2fe2d8..0000000 --- a/packages/contrail-authority/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "@atmo-dev/contrail-authority", - "version": "0.12.2", - "description": "Default space-authority implementation for contrail — member list, invites, app policy, credential issuance. Contrail's binary-membership ACL flavor; for ladder-style access levels see @atmo-dev/contrail-community.", - "type": "module", - "sideEffects": false, - "files": [ - "dist" - ], - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "repository": { - "type": "git", - "url": "https://github.com/flo-bit/contrail.git", - "directory": "packages/contrail-authority" - }, - "keywords": [ - "atproto", - "contrail" - ], - "scripts": { - "build": "tsup", - "clean": "rm -rf dist", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@atcute/cid": "^2.4.1", - "@atcute/lexicons": "^1.2.9", - "@atmo-dev/contrail-base": "workspace:*", - "hono": "^4.12.8" - }, - "devDependencies": { - "tsup": "^8.5.0", - "typescript": "^5.7.3" - }, - "license": "MIT" -} diff --git a/packages/contrail-authority/src/adapter.ts b/packages/contrail-authority/src/adapter.ts deleted file mode 100644 index 66ffdfb..0000000 --- a/packages/contrail-authority/src/adapter.ts +++ /dev/null @@ -1,317 +0,0 @@ -/** Default {@link SpaceAuthority} implementation backed by a Database. - * - * Owns three tables (`spaces`, `spaces_members`, `spaces_invites`) and - * exposes the authority surface: space lifecycle, member list, invite - * storage, app-policy management. - * - * Designed for inheritance — fields are `protected` so a record-host - * adapter (or, transitionally, contrail's all-in-one HostedAdapter) can - * extend this class to add its own methods without re-implementing the - * authority side. */ - -import type { - ContrailConfig, - Database, - SpaceAuthority, - AppPolicy, - CreateInviteInput, - InviteKind, - InviteRow, - ListSpacesOptions, - SpaceMemberRow, - SpaceRow, -} from "@atmo-dev/contrail-base"; - -export function parseJson<T>(value: unknown): T | null { - if (value == null) return null; - if (typeof value === "string") { - try { - return JSON.parse(value) as T; - } catch { - return null; - } - } - return value as T; -} - -export function toNum(v: unknown): number { - return typeof v === "string" ? Number(v) : (v as number); -} - -export function mapSpaceRow(row: any): SpaceRow { - return { - uri: row.uri, - ownerDid: row.owner_did, - type: row.type, - key: row.key, - serviceDid: row.service_did, - appPolicyRef: row.app_policy_ref ?? null, - appPolicy: parseJson<AppPolicy>(row.app_policy), - createdAt: toNum(row.created_at), - deletedAt: row.deleted_at == null ? null : toNum(row.deleted_at), - }; -} - -export function mapMemberRow(row: any): SpaceMemberRow { - return { - spaceUri: row.space_uri, - did: row.did, - addedAt: toNum(row.added_at), - addedBy: row.added_by ?? null, - }; -} - -export function mapInviteRow(row: any): InviteRow { - return { - tokenHash: row.token_hash, - spaceUri: row.space_uri, - kind: (row.kind ?? "join") as InviteKind, - expiresAt: row.expires_at == null ? null : toNum(row.expires_at), - maxUses: row.max_uses == null ? null : Number(row.max_uses), - usedCount: Number(row.used_count), - createdBy: row.created_by, - createdAt: toNum(row.created_at), - revokedAt: row.revoked_at == null ? null : toNum(row.revoked_at), - note: row.note ?? null, - }; -} - -export class HostedAuthorityAdapter implements SpaceAuthority { - constructor( - protected readonly db: Database, - protected readonly config?: ContrailConfig - ) {} - - async createSpace(space: Omit<SpaceRow, "createdAt" | "deletedAt">): Promise<SpaceRow> { - const now = Date.now(); - await this.db - .prepare( - `INSERT INTO spaces (uri, owner_did, type, key, service_did, app_policy_ref, app_policy, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ) - .bind( - space.uri, - space.ownerDid, - space.type, - space.key, - space.serviceDid, - space.appPolicyRef, - space.appPolicy ? JSON.stringify(space.appPolicy) : null, - now - ) - .run(); - return { ...space, createdAt: now, deletedAt: null }; - } - - async getSpace(spaceUri: string): Promise<SpaceRow | null> { - const row = await this.db - .prepare(`SELECT * FROM spaces WHERE uri = ? AND deleted_at IS NULL`) - .bind(spaceUri) - .first<any>(); - return row ? mapSpaceRow(row) : null; - } - - async listSpaces(options: ListSpacesOptions): Promise<{ spaces: SpaceRow[]; cursor?: string }> { - const limit = Math.min(options.limit ?? 50, 200); - const clauses: string[] = ["s.deleted_at IS NULL"]; - const params: any[] = []; - let join = ""; - - if (options.type) { - clauses.push("s.type = ?"); - params.push(options.type); - } - if (options.ownerDid) { - clauses.push("s.owner_did = ?"); - params.push(options.ownerDid); - } - if (options.memberDid) { - join = "JOIN spaces_members m ON m.space_uri = s.uri"; - clauses.push("m.did = ?"); - params.push(options.memberDid); - } - if (options.cursor) { - clauses.push("s.created_at < ?"); - params.push(Number(options.cursor)); - } - - const sql = `SELECT s.* FROM spaces s ${join} - WHERE ${clauses.join(" AND ")} - ORDER BY s.created_at DESC - LIMIT ?`; - params.push(limit + 1); - - const { results } = await this.db.prepare(sql).bind(...params).all<any>(); - const spaces = results.map(mapSpaceRow); - let cursor: string | undefined; - if (spaces.length > limit) { - const next = spaces.pop()!; - cursor = String(next.createdAt); - } - return { spaces, cursor }; - } - - async deleteSpace(spaceUri: string): Promise<void> { - await this.db - .prepare(`UPDATE spaces SET deleted_at = ? WHERE uri = ?`) - .bind(Date.now(), spaceUri) - .run(); - } - - async updateSpaceAppPolicy(spaceUri: string, appPolicy: AppPolicy): Promise<void> { - await this.db - .prepare(`UPDATE spaces SET app_policy = ? WHERE uri = ?`) - .bind(JSON.stringify(appPolicy), spaceUri) - .run(); - } - - async addMember(spaceUri: string, did: string, addedBy: string | null): Promise<void> { - await this.db - .prepare( - `INSERT INTO spaces_members (space_uri, did, added_at, added_by) - VALUES (?, ?, ?, ?) - ON CONFLICT (space_uri, did) DO NOTHING` - ) - .bind(spaceUri, did, Date.now(), addedBy) - .run(); - } - - async removeMember(spaceUri: string, did: string): Promise<void> { - await this.db - .prepare(`DELETE FROM spaces_members WHERE space_uri = ? AND did = ?`) - .bind(spaceUri, did) - .run(); - } - - async getMember(spaceUri: string, did: string): Promise<SpaceMemberRow | null> { - const row = await this.db - .prepare(`SELECT * FROM spaces_members WHERE space_uri = ? AND did = ?`) - .bind(spaceUri, did) - .first<any>(); - return row ? mapMemberRow(row) : null; - } - - async listMembers(spaceUri: string): Promise<SpaceMemberRow[]> { - const { results } = await this.db - .prepare(`SELECT * FROM spaces_members WHERE space_uri = ? ORDER BY added_at ASC`) - .bind(spaceUri) - .all<any>(); - return results.map(mapMemberRow); - } - - async applyMembershipDiff( - spaceUri: string, - adds: string[], - removes: string[], - addedBy: string | null - ): Promise<void> { - const now = Date.now(); - const stmts: any[] = []; - for (const did of adds) { - stmts.push( - this.db - .prepare( - `INSERT INTO spaces_members (space_uri, did, added_at, added_by) - VALUES (?, ?, ?, ?) - ON CONFLICT (space_uri, did) DO NOTHING` - ) - .bind(spaceUri, did, now, addedBy) - ); - } - for (const did of removes) { - stmts.push( - this.db - .prepare(`DELETE FROM spaces_members WHERE space_uri = ? AND did = ?`) - .bind(spaceUri, did) - ); - } - if (stmts.length > 0) { - await this.db.batch(stmts); - } - } - - async createInvite(input: CreateInviteInput): Promise<InviteRow> { - const now = Date.now(); - await this.db - .prepare( - `INSERT INTO spaces_invites (token_hash, space_uri, kind, expires_at, max_uses, used_count, created_by, created_at, note) - VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)` - ) - .bind( - input.tokenHash, - input.spaceUri, - input.kind, - input.expiresAt, - input.maxUses, - input.createdBy, - now, - input.note - ) - .run(); - return { - tokenHash: input.tokenHash, - spaceUri: input.spaceUri, - kind: input.kind, - expiresAt: input.expiresAt, - maxUses: input.maxUses, - usedCount: 0, - createdBy: input.createdBy, - createdAt: now, - revokedAt: null, - note: input.note, - }; - } - - async getInvite(tokenHash: string): Promise<InviteRow | null> { - const row = await this.db - .prepare(`SELECT * FROM spaces_invites WHERE token_hash = ?`) - .bind(tokenHash) - .first<any>(); - return row ? mapInviteRow(row) : null; - } - - async listInvites( - spaceUri: string, - options: { includeRevoked?: boolean } = {} - ): Promise<InviteRow[]> { - const sql = options.includeRevoked - ? `SELECT * FROM spaces_invites WHERE space_uri = ? ORDER BY created_at DESC` - : `SELECT * FROM spaces_invites WHERE space_uri = ? AND revoked_at IS NULL ORDER BY created_at DESC`; - const { results } = await this.db.prepare(sql).bind(spaceUri).all<any>(); - return results.map(mapInviteRow); - } - - async revokeInvite(tokenHash: string): Promise<boolean> { - const res = await this.db - .prepare(`UPDATE spaces_invites SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`) - .bind(Date.now(), tokenHash) - .run(); - const changes = (res as any)?.changes ?? (res as any)?.meta?.changes ?? 0; - return Number(changes) > 0; - } - - async redeemInvite(tokenHash: string, now: number): Promise<InviteRow | null> { - // Atomic: increment used_count only if the invite is usable right now AND - // its kind allows redemption (read-only tokens cannot be consumed for membership). - const res = await this.db - .prepare( - `UPDATE spaces_invites - SET used_count = used_count + 1 - WHERE token_hash = ? - AND kind IN ('join', 'read-join') - AND revoked_at IS NULL - AND (expires_at IS NULL OR expires_at > ?) - AND (max_uses IS NULL OR used_count < max_uses)` - ) - .bind(tokenHash, now) - .run(); - const changes = (res as any)?.changes ?? (res as any)?.meta?.changes ?? 0; - if (Number(changes) === 0) return null; - - const row = await this.db - .prepare(`SELECT * FROM spaces_invites WHERE token_hash = ?`) - .bind(tokenHash) - .first<any>(); - return row ? mapInviteRow(row) : null; - } -} diff --git a/packages/contrail-authority/src/index.ts b/packages/contrail-authority/src/index.ts deleted file mode 100644 index 8bd56ef..0000000 --- a/packages/contrail-authority/src/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @atmo-dev/contrail-authority — default space-authority implementation. - * - * Owns the authority-side adapter (member list, invites, app policy, space - * lifecycle, credential issuance), DDL, and route registration. */ - -export { - HostedAuthorityAdapter, - parseJson, - toNum, - mapSpaceRow, - mapMemberRow, - mapInviteRow, -} from "./adapter"; - -export { - buildAuthoritySchema, - applyAuthoritySchema, -} from "./schema"; - -export { registerAuthorityRoutes } from "./routes"; - -export { registerInviteRoutes } from "./invite-routes"; -export type { InviteRoutesOptions } from "./invite-routes"; diff --git a/packages/contrail-authority/src/invite-routes.ts b/packages/contrail-authority/src/invite-routes.ts deleted file mode 100644 index dd0fc04..0000000 --- a/packages/contrail-authority/src/invite-routes.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** Unified invite surface: a single `<ns>.invite.*` family serving both - * user-owned spaces (handled here directly via the authority adapter) and - * community-owned spaces (delegated to a {@link CommunityInviteHandler}). - * - * Storage stays separate (`spaces_invites` vs `community_invites` tables) — - * schemas differ enough that unifying them would be net-negative. The token - * primitive and HTTP dance are shared. */ - -import type { Context, Hono, MiddlewareHandler } from "hono"; -import type { - CommunityInviteHandler, - ContrailConfig, - HandlerResponse, - InviteKind, - InviteRow, - ServiceAuth, - SpaceAuthority, -} from "@atmo-dev/contrail-base"; -import { hashInviteToken, mintInviteToken } from "@atmo-dev/contrail-base"; - -export interface InviteRoutesOptions { - authMiddleware: MiddlewareHandler; -} - -interface PublicInviteView { - tokenHash: string; - spaceUri: string; - kind?: InviteKind; - createdBy: string; - createdAt: number; - expiresAt: number | null; - maxUses: number | null; - usedCount: number; - revokedAt: number | null; - note: string | null; -} - -function toSpacesView(row: InviteRow): PublicInviteView { - return { - tokenHash: row.tokenHash, - spaceUri: row.spaceUri, - kind: row.kind, - createdBy: row.createdBy, - createdAt: row.createdAt, - expiresAt: row.expiresAt, - maxUses: row.maxUses, - usedCount: row.usedCount, - revokedAt: row.revokedAt, - note: row.note, - }; -} - -export function registerInviteRoutes( - app: Hono, - config: ContrailConfig, - authority: SpaceAuthority, - community: CommunityInviteHandler | null, - options: InviteRoutesOptions -): void { - if (!config.spaces?.authority) return; - - const NS = `${config.namespace}.invite`; - const auth = options.authMiddleware; - - const classifySpace = async (spaceUri: string) => { - const space = await authority.getSpace(spaceUri); - if (!space) return null; - const isCommunity = community ? await community.isCommunityOwned(spaceUri) : false; - return { space, isCommunity }; - }; - - app.post(`/xrpc/${NS}.create`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { - spaceUri?: string; - kind?: string; - accessLevel?: string; - expiresAt?: number; - maxUses?: number; - note?: string; - } - | null; - if (!body?.spaceUri) { - return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); - } - if (body.kind && body.accessLevel) { - return c.json( - { error: "InvalidRequest", reason: "kind-or-accessLevel", message: "pass kind OR accessLevel, not both" }, - 400 - ); - } - - const classified = await classifySpace(body.spaceUri); - if (!classified) return c.json({ error: "NotFound" }, 404); - const { space, isCommunity } = classified; - - if (isCommunity) { - if (!community) return c.json({ error: "InvalidState" }, 500); - return relay(c, await community.create({ - spaceUri: body.spaceUri, - callerDid: sa.issuer, - accessLevel: body.accessLevel, - kind: body.kind, - expiresAt: body.expiresAt ?? null, - maxUses: body.maxUses ?? null, - note: body.note ?? null, - })); - } - - if (body.accessLevel) { - return c.json( - { error: "InvalidRequest", reason: "accessLevel-on-user-space", message: "user-owned spaces take kind, not accessLevel" }, - 400 - ); - } - if (space.ownerDid !== sa.issuer) { - return c.json({ error: "Forbidden", reason: "not-owner" }, 403); - } - const kind = (body.kind ?? "join") as InviteKind; - if (kind !== "join" && kind !== "read" && kind !== "read-join") { - return c.json({ error: "InvalidRequest", message: "kind must be 'join', 'read', or 'read-join'" }, 400); - } - const { token, tokenHash } = await mintInviteToken(); - const invite = await authority.createInvite({ - spaceUri: body.spaceUri, - tokenHash, - kind, - expiresAt: body.expiresAt ?? null, - maxUses: body.maxUses ?? null, - createdBy: sa.issuer, - note: body.note ?? null, - }); - return c.json({ token, invite: toSpacesView(invite) }); - }); - - app.get(`/xrpc/${NS}.list`, auth, async (c) => { - const sa = getAuth(c); - const spaceUri = c.req.query("spaceUri"); - if (!spaceUri) return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); - const includeRevoked = c.req.query("includeRevoked") === "true"; - - const classified = await classifySpace(spaceUri); - if (!classified) return c.json({ error: "NotFound" }, 404); - const { space, isCommunity } = classified; - - if (isCommunity) { - return relay(c, await community!.list({ - spaceUri, - callerDid: sa.issuer, - includeRevoked, - })); - } - - if (space.ownerDid !== sa.issuer) { - return c.json({ error: "Forbidden", reason: "not-owner" }, 403); - } - const rows = await authority.listInvites(spaceUri, { includeRevoked }); - return c.json({ invites: rows.map(toSpacesView) }); - }); - - app.post(`/xrpc/${NS}.revoke`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { spaceUri?: string; tokenHash?: string } - | null; - if (!body?.tokenHash) { - return c.json({ error: "InvalidRequest", message: "tokenHash required" }, 400); - } - - if (body.spaceUri) { - const classified = await classifySpace(body.spaceUri); - if (!classified) return c.json({ error: "NotFound" }, 404); - if (classified.isCommunity) { - return relay(c, await community!.revoke({ - spaceUri: body.spaceUri, - tokenHash: body.tokenHash, - callerDid: sa.issuer, - })); - } - if (classified.space.ownerDid !== sa.issuer) { - return c.json({ error: "Forbidden", reason: "not-owner" }, 403); - } - const ok = await authority.revokeInvite(body.tokenHash); - return c.json({ ok }); - } - - if (community) { - const r = await community.tryRevokeByToken({ - tokenHash: body.tokenHash, - callerDid: sa.issuer, - }); - if (r) return relay(c, r); - } - const srow = await authority.getInvite(body.tokenHash); - if (!srow) return c.json({ error: "NotFound" }, 404); - const space = await authority.getSpace(srow.spaceUri); - if (space && space.ownerDid !== sa.issuer) { - return c.json({ error: "Forbidden", reason: "not-owner" }, 403); - } - const ok = await authority.revokeInvite(body.tokenHash); - return c.json({ ok }); - }); - - app.post(`/xrpc/${NS}.redeem`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as { token?: string } | null; - if (!body?.token) { - return c.json({ error: "InvalidRequest", message: "token required" }, 400); - } - const tokenHash = await hashInviteToken(body.token); - const now = Date.now(); - - if (community) { - const r = await community.tryRedeem({ - tokenHash, - callerDid: sa.issuer, - now, - }); - if (r) return relay(c, r); - } - - const sinvite = await authority.redeemInvite(tokenHash, now); - if (!sinvite) { - return c.json({ error: "InvalidInvite", reason: "expired-revoked-or-exhausted" }, 400); - } - await authority.addMember(sinvite.spaceUri, sa.issuer, sinvite.createdBy); - return c.json({ spaceUri: sinvite.spaceUri, kind: sinvite.kind }); - }); -} - -function relay(c: Context, r: HandlerResponse) { - return c.json(r.body, r.status as Parameters<typeof c.json>[1]); -} - -function getAuth(c: Context): ServiceAuth { - const a = c.get("serviceAuth") as ServiceAuth | undefined; - if (!a) throw new Error("service auth not set"); - return a; -} diff --git a/packages/contrail-authority/src/routes.ts b/packages/contrail-authority/src/routes.ts deleted file mode 100644 index 369ac31..0000000 --- a/packages/contrail-authority/src/routes.ts +++ /dev/null @@ -1,465 +0,0 @@ -/** Authority XRPC routes — space lifecycle, members, app policy, whoami, - * credential issuance. Does NOT touch records or blobs. - * - * Deployments wire this via `registerAuthorityRoutes(app, authority, ...)`. - * The umbrella `registerSpacesRoutes` in @atmo-dev/contrail composes this - * with the record-host routes; split deployments call this directly. */ - -import type { Context, Hono, MiddlewareHandler } from "hono"; -import type { - AuthorityConfig, - ContrailConfig, - CredentialClaims, - RecordHost, - ServiceAuth, - SpaceAuthority, - SpaceRow, - WhoamiExtension, -} from "@atmo-dev/contrail-base"; -import { - buildSpaceUri, - checkInviteReadGrant, - decodeUnverifiedClaims, - DEFAULT_CREDENTIAL_TTL_MS, - DEFAULT_MANIFEST_TTL_MS, - extractInviteToken, - hashInviteToken, - issueCredential, - issueMembershipManifest, - nextTid, - verifyCredential, -} from "@atmo-dev/contrail-base"; - -/** When `localRecordHost` is non-null, the authority's `createSpace` handler - * also enrolls the new space on that host — convenient for in-process - * deployments where the same operator runs both roles. Split deployments - * pass `null` and arrange enrollment explicitly via `recordHost.enroll`. */ -export function registerAuthorityRoutes( - app: Hono, - authority: SpaceAuthority, - authorityConfig: AuthorityConfig, - config: ContrailConfig, - auth: MiddlewareHandler, - whoamiExtension?: WhoamiExtension, - localRecordHost?: RecordHost | null -): void { - /** Space endpoints are emitted per-deployment under the configured namespace; - * the deployment owns and publishes its own lexicons. */ - const SPACE = `${config.namespace}.space`; - const SPACE_EXT = `${config.namespace}.spaceExt`; - - // ---- Read endpoints ---- - - app.get(`/xrpc/${SPACE}.listSpaces`, auth, async (c) => { - const sa = getAuth(c); - const scope = c.req.query("scope") ?? "member"; - 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<typeof authority.listSpaces>[0] = { type, cursor, limit }; - if (scope === "owner") opts.ownerDid = sa.issuer; - else { - opts.memberDid = sa.issuer; - if (owner) opts.ownerDid = owner; - } - - const result = await authority.listSpaces(opts); - return c.json({ - spaces: result.spaces.map((s) => publicSpaceView(s, s.ownerDid === sa.issuer)), - cursor: result.cursor, - }); - }); - - app.get(`/xrpc/${SPACE}.listMembers`, auth, async (c) => { - const sa = getAuth(c); - const spaceUri = c.req.query("spaceUri"); - if (!spaceUri) return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); - const space = await authority.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - - const isOwner = space.ownerDid === sa.issuer; - const member = isOwner ? null : await authority.getMember(spaceUri, sa.issuer); - if (!isOwner && !member) { - return c.json({ error: "Forbidden", reason: "not-member" }, 403); - } - const members = await authority.listMembers(spaceUri); - return c.json({ members }); - }); - - /** Read-route auth: skip the JWT middleware when an `?inviteToken=` is - * present so anonymous bearer reads don't 401 before the route handler can - * validate the token. */ - const readAuth: MiddlewareHandler = async (c, next) => { - if (extractInviteToken(c.req.raw)) { - await next(); - return; - } - return auth(c, next); - }; - - app.get(`/xrpc/${SPACE}.getSpace`, readAuth, async (c) => { - const uri = c.req.query("uri"); - if (!uri) return c.json({ error: "InvalidRequest", message: "uri required" }, 400); - const space = await authority.getSpace(uri); - if (!space) return c.json({ error: "NotFound" }, 404); - - const authz = await authorizeRead(c, authority, uri); - if (authz instanceof Response) return authz; - - if (authz.via === "token") { - return c.json({ space: publicSpaceView(space, false) }); - } - - if (authz.via === "credential") { - const isOwner = authz.claims.sub === space.ownerDid; - return c.json({ space: publicSpaceView(space, isOwner) }); - } - - const sa = authz.sa; - const isOwner = sa.issuer === space.ownerDid; - const member = isOwner ? null : await authority.getMember(uri, sa.issuer); - if (!isOwner && !member) { - return c.json({ error: "Forbidden", reason: "not-member" }, 403); - } - return c.json({ space: publicSpaceView(space, isOwner) }); - }); - - // ---- Space management (owner-gated) ---- - - app.post(`/xrpc/${SPACE}.createSpace`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => ({}))) as { - type?: string; - key?: string; - appPolicy?: SpaceRow["appPolicy"]; - appPolicyRef?: string; - }; - - const type = body.type ?? authorityConfig.type; - const key = body.key ?? nextTid(); - const uri = buildSpaceUri({ ownerDid: sa.issuer, type, key }); - - const existing = await authority.getSpace(uri); - if (existing) return c.json({ error: "AlreadyExists", uri }, 409); - - const space = await authority.createSpace({ - uri, - ownerDid: sa.issuer, - type, - key, - serviceDid: authorityConfig.serviceDid, - appPolicyRef: body.appPolicyRef ?? null, - appPolicy: body.appPolicy ?? authorityConfig.defaultAppPolicy ?? null, - }); - await authority.addMember(uri, sa.issuer, sa.issuer); - - if (localRecordHost) { - await localRecordHost.enroll({ - spaceUri: uri, - authorityDid: authorityConfig.serviceDid, - enrolledAt: Date.now(), - enrolledBy: sa.issuer, - }); - } - - return c.json({ space: publicSpaceView(space, true) }); - }); - - app.post(`/xrpc/${SPACE}.addMember`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { spaceUri?: string; did?: string } - | null; - if (!body?.spaceUri || !body.did) { - return c.json({ error: "InvalidRequest", message: "spaceUri and did required" }, 400); - } - const space = await authority.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - if (space.ownerDid !== sa.issuer) { - return c.json({ error: "Forbidden", reason: "not-owner" }, 403); - } - await authority.addMember(body.spaceUri, body.did, sa.issuer); - return c.json({ ok: true }); - }); - - app.post(`/xrpc/${SPACE}.removeMember`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { spaceUri?: string; did?: string } - | null; - if (!body?.spaceUri || !body.did) { - return c.json({ error: "InvalidRequest", message: "spaceUri and did required" }, 400); - } - const space = await authority.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - if (space.ownerDid !== sa.issuer) { - return c.json({ error: "Forbidden", reason: "not-owner" }, 403); - } - if (body.did === space.ownerDid) { - return c.json({ error: "InvalidRequest", reason: "cannot-remove-owner" }, 400); - } - await authority.removeMember(body.spaceUri, body.did); - return c.json({ ok: true }); - }); - - app.post(`/xrpc/${SPACE}.leaveSpace`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as { spaceUri?: string } | null; - if (!body?.spaceUri) { - return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); - } - const space = await authority.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - if (space.ownerDid === sa.issuer) { - return c.json( - { error: "InvalidRequest", reason: "owner-cannot-leave", message: "Owner cannot leave; delete the space instead" }, - 400 - ); - } - await authority.removeMember(body.spaceUri, sa.issuer); - return c.json({ ok: true }); - }); - - // Unified whoami — extension can override with richer data (e.g. community - // accessLevel); without one, returns binary owner/member. - app.get(`/xrpc/${SPACE_EXT}.whoami`, auth, async (c) => { - const sa = getAuth(c); - const spaceUri = c.req.query("spaceUri"); - if (!spaceUri) return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); - const space = await authority.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - - const isOwner = space.ownerDid === sa.issuer; - - if (whoamiExtension) { - const ext = await whoamiExtension({ - spaceUri, - callerDid: sa.issuer, - isOwner, - ownerDid: space.ownerDid, - }); - if (ext) return c.json(ext); - } - - if (isOwner) return c.json({ isOwner: true, isMember: true }); - const member = await authority.getMember(spaceUri, sa.issuer); - return c.json({ isOwner: false, isMember: !!member }); - }); - - // ---- Credential endpoints ---- - - app.post(`/xrpc/${SPACE}.getCredential`, auth, async (c) => { - if (!authorityConfig.signing) { - return c.json( - { error: "NotImplemented", message: "authority is not configured to sign credentials" }, - 501 - ); - } - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as { spaceUri?: string } | null; - if (!body?.spaceUri) { - return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); - } - const space = await authority.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - - const isOwner = space.ownerDid === sa.issuer; - const member = isOwner ? null : await authority.getMember(body.spaceUri, sa.issuer); - if (!isOwner && !member) { - return c.json({ error: "Forbidden", reason: "not-member" }, 403); - } - - if (space.appPolicy) { - const allowed = checkClientId(space.appPolicy, sa.clientId); - if (!allowed) return c.json({ error: "Forbidden", reason: "app-not-allowed" }, 403); - } - - const ttl = authorityConfig.credentialTtlMs ?? DEFAULT_CREDENTIAL_TTL_MS; - const { credential, expiresAt } = await issueCredential( - { - iss: authorityConfig.serviceDid, - sub: sa.issuer, - space: body.spaceUri, - scope: "rw", - ttlMs: ttl, - }, - authorityConfig.signing - ); - return c.json({ credential, expiresAt }); - }); - - app.post(`/xrpc/${SPACE}.refreshCredential`, async (c) => { - if (!authorityConfig.signing) { - return c.json( - { error: "NotImplemented", message: "authority is not configured to sign credentials" }, - 501 - ); - } - const body = (await c.req.json().catch(() => null)) as { credential?: string } | null; - if (!body?.credential) { - return c.json({ error: "InvalidRequest", message: "credential required" }, 400); - } - const signing = authorityConfig.signing; - const claims = await verifyAndAuthorizeRefresh(body.credential, authorityConfig); - if ("error" in claims) return c.json(claims, claims.status); - - const space = await authority.getSpace(claims.space); - if (!space) return c.json({ error: "NotFound" }, 404); - const isOwner = space.ownerDid === claims.sub; - const member = isOwner ? null : await authority.getMember(claims.space, claims.sub); - if (!isOwner && !member) { - return c.json({ error: "Forbidden", reason: "not-member" }, 403); - } - - const ttl = authorityConfig.credentialTtlMs ?? DEFAULT_CREDENTIAL_TTL_MS; - const { credential, expiresAt } = await issueCredential( - { - iss: authorityConfig.serviceDid, - sub: claims.sub, - space: claims.space, - scope: claims.scope, - ttlMs: ttl, - }, - signing - ); - return c.json({ credential, expiresAt }); - }); - - // ---- Membership manifest ---- - // - // Issues a signed list of every space the caller is a member of (or owns) - // according to this authority. Appviews carry it on inbound requests so - // unioned listRecords queries can be filtered without syncing the full - // member list. Same key material as credentials, different payload. - - app.post(`/xrpc/${SPACE}.getMembershipManifest`, auth, async (c) => { - if (!authorityConfig.signing) { - return c.json( - { error: "NotImplemented", message: "authority is not configured to sign manifests" }, - 501 - ); - } - const sa = getAuth(c); - const cap = authorityConfig.manifestMaxSpaces ?? 500; - - // Page through listSpaces — owner-or-member union — up to the cap. - const seen = new Set<string>(); - const drain = async (scope: "owner" | "member"): Promise<void> => { - let cursor: string | undefined; - while (seen.size < cap) { - const result = await authority.listSpaces({ - ...(scope === "owner" ? { ownerDid: sa.issuer } : { memberDid: sa.issuer }), - cursor, - limit: Math.min(200, cap - seen.size), - }); - for (const s of result.spaces) { - if (s.deletedAt == null) seen.add(s.uri); - if (seen.size >= cap) break; - } - if (!result.cursor || seen.size >= cap) break; - cursor = result.cursor; - } - }; - await drain("owner"); - if (seen.size < cap) await drain("member"); - - const ttl = authorityConfig.manifestTtlMs ?? DEFAULT_MANIFEST_TTL_MS; - const { manifest, expiresAt } = await issueMembershipManifest( - { - iss: authorityConfig.serviceDid, - sub: sa.issuer, - spaces: [...seen], - ttlMs: ttl, - }, - authorityConfig.signing - ); - return c.json({ manifest, expiresAt, truncated: seen.size >= cap }); - }); -} - -/** Verify a credential presented at refreshCredential. */ -async function verifyAndAuthorizeRefresh( - credential: string, - authorityConfig: AuthorityConfig -): Promise<CredentialClaims | { error: string; reason?: string; message?: string; status: 400 | 401 }> { - const peek = decodeUnverifiedClaims(credential); - if (!peek) return { error: "InvalidRequest", reason: "malformed", status: 400 }; - if (peek.iss !== authorityConfig.serviceDid) { - return { error: "Forbidden", reason: "wrong-issuer", status: 401 }; - } - if (!authorityConfig.signing) { - return { error: "InvalidState", status: 401 }; - } - const signing = authorityConfig.signing; - const result = await verifyCredential(credential, { - expectedSpace: peek.space, - resolveKey: async (iss) => (iss === authorityConfig.serviceDid ? signing.publicKey : null), - }); - if (!result.ok) { - return { error: "InvalidCredential", reason: result.reason, status: 401 }; - } - return result.claims; -} - -function checkClientId( - appPolicy: NonNullable<SpaceRow["appPolicy"]>, - clientId: string | undefined -): boolean { - const listed = clientId ? appPolicy.apps.includes(clientId) : false; - if (appPolicy.mode === "allow") return !listed; - return listed; -} - -/** Authorize a read request on the authority side — three valid paths: - * credential (set by upstream middleware), invite token, or service-auth JWT. */ -async function authorizeRead( - c: Context, - authority: SpaceAuthority, - spaceUri: string -): Promise< - | { via: "credential"; claims: CredentialClaims } - | { via: "token" } - | { via: "jwt"; sa: ServiceAuth } - | Response -> { - const cred = c.get("spaceCredential") as CredentialClaims | undefined; - if (cred) { - if (cred.space !== spaceUri) { - return c.json({ error: "Forbidden", reason: "credential-wrong-space" }, 403); - } - return { via: "credential", claims: cred }; - } - const rawToken = extractInviteToken(c.req.raw); - if (rawToken) { - const ok = await checkInviteReadGrant(authority, rawToken, spaceUri, hashInviteToken); - if (!ok) return c.json({ error: "Forbidden", reason: "invalid-invite-token" }, 403); - return { via: "token" }; - } - const sa = c.get("serviceAuth") as ServiceAuth | undefined; - if (sa) return { via: "jwt", sa }; - return c.json( - { error: "AuthRequired", message: "JWT, credential, or read-grant invite token required" }, - 401 - ); -} - -function getAuth(c: Context): ServiceAuth { - const auth = c.get("serviceAuth") as ServiceAuth | undefined; - if (!auth) throw new Error("service auth not set"); - return auth; -} - -function publicSpaceView(space: SpaceRow, forOwner: boolean) { - return { - uri: space.uri, - ownerDid: space.ownerDid, - type: space.type, - key: space.key, - serviceDid: space.serviceDid, - appPolicyRef: space.appPolicyRef, - createdAt: space.createdAt, - ...(forOwner ? { appPolicy: space.appPolicy } : {}), - }; -} diff --git a/packages/contrail-authority/src/schema.ts b/packages/contrail-authority/src/schema.ts deleted file mode 100644 index e9f19e9..0000000 --- a/packages/contrail-authority/src/schema.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** Authority-side DDL: `spaces`, `spaces_members`, `spaces_invites`. */ - -import type { Database, SqlDialect } from "@atmo-dev/contrail-base"; -import { getDialect } from "@atmo-dev/contrail-base"; - -export function buildAuthoritySchema(dialect: SqlDialect): string[] { - return [ - `CREATE TABLE IF NOT EXISTS spaces ( - uri TEXT PRIMARY KEY, - owner_did TEXT NOT NULL, - type TEXT NOT NULL, - key TEXT NOT NULL, - service_did TEXT NOT NULL, - app_policy_ref TEXT, - app_policy ${dialect.recordColumnType}, - created_at ${dialect.bigintType} NOT NULL, - deleted_at ${dialect.bigintType} - )`, - `CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_did)`, - `CREATE INDEX IF NOT EXISTS idx_spaces_type ON spaces(type)`, - - `CREATE TABLE IF NOT EXISTS spaces_members ( - space_uri TEXT NOT NULL, - did TEXT NOT NULL, - added_at ${dialect.bigintType} NOT NULL, - added_by TEXT, - PRIMARY KEY (space_uri, did) - )`, - `CREATE INDEX IF NOT EXISTS idx_spaces_members_did ON spaces_members(did)`, - - `CREATE TABLE IF NOT EXISTS spaces_invites ( - token_hash TEXT PRIMARY KEY, - space_uri TEXT NOT NULL, - kind TEXT NOT NULL DEFAULT 'join', - expires_at ${dialect.bigintType}, - max_uses INTEGER, - used_count INTEGER NOT NULL DEFAULT 0, - created_by TEXT NOT NULL, - created_at ${dialect.bigintType} NOT NULL, - revoked_at ${dialect.bigintType}, - note TEXT - )`, - `CREATE INDEX IF NOT EXISTS idx_spaces_invites_space ON spaces_invites(space_uri, created_at DESC)`, - ]; -} - -/** SchemaModule-shaped function suitable for `initSchema({ extraSchemas: [...] })`. */ -export async function applyAuthoritySchema(db: Database): Promise<void> { - const dialect = getDialect(db); - const stmts = buildAuthoritySchema(dialect); - await db.batch(stmts.map((s) => db.prepare(s))); -} diff --git a/packages/contrail-authority/tsconfig.build.json b/packages/contrail-authority/tsconfig.build.json deleted file mode 100644 index 6092abf..0000000 --- a/packages/contrail-authority/tsconfig.build.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/packages/contrail-authority/tsconfig.json b/packages/contrail-authority/tsconfig.json deleted file mode 100644 index 6092abf..0000000 --- a/packages/contrail-authority/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/packages/contrail-authority/tsup.config.ts b/packages/contrail-authority/tsup.config.ts deleted file mode 100644 index 7d6b7eb..0000000 --- a/packages/contrail-authority/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/index.ts"], - format: ["esm"], - dts: true, - sourcemap: true, - clean: true, - tsconfig: "tsconfig.build.json", - external: ["@atmo-dev/contrail-base"], -}); diff --git a/packages/contrail-base/CHANGELOG.md b/packages/contrail-base/CHANGELOG.md deleted file mode 100644 index 76e4b60..0000000 --- a/packages/contrail-base/CHANGELOG.md +++ /dev/null @@ -1,135 +0,0 @@ -# @atmo-dev/contrail-base - -## 0.12.2 - -## 0.12.1 - -### Patch Changes - -- 833a659: Stop running the `feed_items` prune sweep on every ingest tick. - - A feed only exceeds its cap right after a feed-mutating record, so the per-tick sweep was a no-op on the vast majority of ticks yet still issued a cutoff `DELETE` per actor (~98% of all D1 queries on one deployment). It now sweeps only when a feed-mutating collection was ingested, plus a recovery pass that becomes due ~6h after the previous one completed and then laps one slice per tick — including on idle persistent streams and the `notifyOfUpdate` path. New `getFeedMutatingNsids(config)` derives the gating set. See `docs/04-feeds.md` for sweep timing (and why the full-pass cadence is interval + lap time, not a hard 6h) and the fan-out promptness trade-off. - -- 74a2d3d: Make NSID-keyed collections work through normal ingestion, not just FTS. - - When a collection is keyed directly by its NSID (no short alias, `collection` - field omitted), the value defaulted to `undefined` everywhere it was read. The - records insert and FTS sync were patched via `resolveCollectionKey`, but the - real ingestion entry points still skipped these collections: `getCollectionNsids` - / `getDiscoverableNsids` / `getDependentNsids` produced `undefined` NSIDs (so - Jetstream never subscribed and backfill never ran), `shortNameForNsid` returned - undefined (so `notify` rejected the URI as "collection not tracked"), and - `validateConfig` rejected the config outright (missing `collection`, dotted key - failing short-name validation). - - `CollectionConfig.collection` is now optional. `resolveConfig` normalizes an - omitted `collection` to the map key, `validateConfig` accepts NSID-keyed entries, - and every collection-list / lookup helper resolves the NSID as `collection ?? key` - so the behavior is correct on both raw and resolved configs. - </content> - -- 9894787: Stop re-ingesting the last 10s on every cron cycle for single-instance jetstream configs. - - `@atcute/jetstream` rolls the cursor back 10s on the first connect when given an array `url`, to absorb clock skew across a pool of interchangeable instances. Contrail's cron ingestion rebuilds the subscription every cycle, so for a single-instance config that once-per-session rollback fired every cycle and redundantly re-delivered the last 10s of events. A new `jetstreamUrlOption` helper hands a one-element config to `@atcute` as a string (one fixed instance, no skew, no rollback) while leaving real multi-instance pools as an array so their cross-instance rollback is preserved. Applied at both subscription construction sites (cron `ingestEvents` and the persistent daemon). - - The per-cycle reconnect log is now accurate for single-instance configs: a reconnect to one fixed instance no longer claims to "pick a URL at random and roll the cursor back 10s" (it can't). The warning now fires only for multi-instance pools and reports the actual `rolled_back` value; single-instance reconnects log at info level confirming no rollback. - -## 0.12.0 - -### Minor Changes - -- 6b34d87: Add a first-class `sinks` config option: write-only, post-commit observers of applied records. - - A `Sink` builds derived state (a search index, an audit log, a webhook fan-out) from every record contrail ingests. Each configured sink's `onRecords(events, { phase })` fires inside `applyEvents()` after the DB commit, on **both** the live and backfill paths, receiving one deduplicated `RecordEvent` per record. Failures are isolated — a throwing sink is logged via the configured logger and never blocks ingestion. - - Unlike `realtime.pubsub`, a sink is not a subscriber: it serves no reads, requires no ticket secret, and must see backfilled records (where realtime is intentionally silent). Public records only — space-scoped records publish via the publishing adapter and never reach the fan-out. - - Purely additive: `realtime` and all existing behavior are unchanged. Runs identically on D1 and Postgres (it is an in-process call after commit, not a database-log consumer). - -## 0.11.0 - -## 0.10.0 - -### Minor Changes - -- 89aee1b: perf: gate schema replay on a fingerprint; add opt-in planner-stat maintenance - - Two independent performance fixes found while profiling a D1 consumer. - - **Cold-start schema replay (always on).** `initSchema` ran ~40 base/collection/ - index/fts/feed/spaces DDL statements serially on every `init()` call, with no - gate. Consumers call `init()` once per isolate and Workers isolates recycle - constantly, so the first request to each cold isolate paid ~40 sequential - round-trips to the D1 storage object before any real work. `initSchema` now - records a fingerprint of the resolved schema (hash of the generated DDL + - `CONTRAIL_SCHEMA_VERSION`) in a new `_contrail_meta` table and, on a match, - skips all DDL after a single read. Steady-state cold start drops from ~40 - round-trips to one; the full apply only runs on first init or an actual schema - change. Concurrent-init safety on Postgres is unchanged (the gate just wraps the - existing idempotent apply). - - **Query-planner statistics (opt-in).** Without `ANALYZE`, SQLite's planner picks - the least-selective index for multi-predicate queries (measured ~50x more rows - read on a `subject.uri` + `status` filter). New opt-in config: - - ```ts - maintenance: { - optimize: true; - } // or { intervalMs, analysisLimit } - ``` - - When enabled, the ingest tick runs a CPU-bounded `PRAGMA analysis_limit=400; -PRAGMA optimize` on a persisted daily cadence (stored in `_contrail_meta`, so it - isn't defeated by recycled isolates — the same in-memory-state bug the feed - prune had). `analysis_limit` bounds the work so it can't exceed D1's per-query - CPU budget and reset the DO. Also exposed as `contrail.optimize(db)` for - consumers that prefer to schedule it themselves. No-op on Postgres - (autovacuum/autoanalyze handles planner stats). - -## 0.9.1 - -### Patch Changes - -- 4c8fedb: fix(identity): stop stranding/clobbering handles during resolution (#42) - - Backfill left a meaningful fraction of identities with a PDS but no handle. - Two root causes: - - - `resolvePDSCached` short-circuited on any row with a non-null PDS and returned - without ever resolving the handle. A partial resolution (slingshot can return - a PDS without a handle under load) was therefore persisted and never healed. - It now treats a row as a complete cache hit only when both PDS _and_ handle are - present; a PDS-only row falls through to re-resolve and fill the handle, while - still serving the known PDS (including if the re-resolution fails). - - `saveIdentity` overwrote `handle`/`pds` unconditionally, so - `refreshStaleIdentities` (which passes a null handle through when slingshot - omits one) could clobber a previously-resolved handle with null. The upsert now - COALESCEs both columns: a fresh non-null value still applies (handle changes - work), but a null never nulls a good value. - - Backfill also resolves PDS endpoints up front instead of in a detached - background promise, so identity resolution no longer competes with record - backfill for slingshot — reducing the partial responses that triggered the - above in the first place. - -## 0.9.0 - -## 0.8.0 - -### Minor Changes - -- d7e0936: Private-network deployment support via a new optional `ContrailConfig.networkOverrides` block. - - `networkOverrides` carries three optional subfields, all defaulting to the current public-internet behavior (omit the block entirely and nothing changes): - - - **`resolver`** — a custom `DidDocumentResolver` used during DID-doc PDS fallback, labeler-endpoint resolution, and spaces service-auth JWT verification. Lets a deployment point at a private PLC mirror or inject a custom fetch (mTLS, retry, instrumentation). Trusted; not SSRF-checked. - - **`slingshotUrl`** — override the slingshot identity-resolver endpoint. Trusted; not SSRF-checked. - - **`additionalAllowedHosts`** — hostnames that bypass the default SSRF guard when validating a resolved PDS or labeler endpoint. Match is exact, case-insensitive, port-agnostic (e.g. `["pds.dev.svc.cluster.local"]`). This is the only knob that widens the validator; there is no "disable SSRF" flag. - - The overrides are threaded through PDS/identity resolution (`resolvePDS`, `getPDS`, `getClient`, `resolveIdentity*`, `refreshStaleIdentities`), labeler endpoint resolution and ingest (`resolveLabelerEndpoint`, `getLabelerState`, label subscribe cycles), and service-auth verification (`buildVerifier` in both the appview router and the community integration). The in-scope `config` is now also passed at every appview call site that resolves identities or PDS endpoints — the live-ingest refresh cycle (`runIngestCycle` → `refreshStaleIdentities`), the on-demand `refresh` path, and the router actor/identity/PDS resolution paths (`getProfile`, `getFeed`, collection queries, profile hydration, notify) — so private-network deploys honor the override on those paths instead of silently falling back to the public resolver and un-widened SSRF guard. - - The SSRF guard is now a single shared validator: `validateExternalUrl(url, additionalAllowedHosts?)` is exported from `contrail-base` and consumed by both the PDS client and labeler-endpoint resolution. `validateEndpointUrl` remains exported as a thin alias for backward compatibility. This removes the previous duplicate validator (`validatePdsUrl` + `validateEndpointUrl`) where an allowlist or SSRF-rule edit could be applied to only one copy. - - Also hardens schema initialization for concurrent/Postgres deployments: a dialect-aware `addColumnIfNotExists` (Postgres `ADD COLUMN IF NOT EXISTS`; SQLite pre-check), narrow absorption of the Postgres concurrent-`CREATE` race (42P07 / 23505 on pg_type/pg_class/pg_namespace indexes), and per-statement (rather than batched) DDL during `initSchema` / `initSpacesSchema` / spaces schema. Genuine DDL errors (syntax, type mismatch, missing column/table) still propagate. - -## 0.7.0 diff --git a/packages/contrail-base/package.json b/packages/contrail-base/package.json deleted file mode 100644 index 74d7a51..0000000 --- a/packages/contrail-base/package.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "name": "@atmo-dev/contrail-base", - "version": "0.12.2", - "description": "Shared infrastructure for the contrail family of packages — interfaces (SpaceAuthority, RecordHost, CommunityIntegration), credential primitives, binding resolvers, realtime infra, schema scaffolding. No routes, no tables of its own.", - "type": "module", - "sideEffects": false, - "files": [ - "dist" - ], - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./sqlite": { - "types": "./dist/adapters/sqlite.d.ts", - "import": "./dist/adapters/sqlite.js" - }, - "./postgres": { - "types": "./dist/adapters/postgres.d.ts", - "import": "./dist/adapters/postgres.js" - } - }, - "repository": { - "type": "git", - "url": "https://github.com/flo-bit/contrail.git", - "directory": "packages/contrail-base" - }, - "keywords": [ - "atproto", - "contrail" - ], - "scripts": { - "build": "tsup", - "clean": "rm -rf dist", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@atcute/atproto": "^3.1.10", - "@atcute/cid": "^2.4.1", - "@atcute/client": "^4.2.1", - "@atcute/identity": "^1.1.4", - "@atcute/identity-resolver": "^1.2.2", - "@atcute/lexicons": "^1.2.9", - "@atcute/xrpc-server": "^0.1.12", - "hono": "^4.12.8" - }, - "devDependencies": { - "@types/node": "^25.5.0", - "@types/pg": "^8.20.0", - "pg": "^8.20.0", - "tsup": "^8.5.0", - "typescript": "^5.7.3" - }, - "peerDependencies": { - "pg": "^8.0.0" - }, - "peerDependenciesMeta": { - "pg": { - "optional": true - } - }, - "license": "MIT" -} diff --git a/packages/contrail-base/src/adapters/postgres.ts b/packages/contrail-base/src/adapters/postgres.ts deleted file mode 100644 index e8c2b14..0000000 --- a/packages/contrail-base/src/adapters/postgres.ts +++ /dev/null @@ -1,93 +0,0 @@ -import pg from "pg"; -import type { Database, Statement } from "../types"; -import { postgresDialect } from "../dialect"; - -/** Internal interface for statements that can run on a specific client */ -interface PgStatement extends Statement { - /** Execute on a specific client (used by batch for transaction isolation) */ - _runOn(client: pg.PoolClient): Promise<any>; -} - -/** Column names known to be BIGINT — PostgreSQL returns these as strings */ -const BIGINT_COLUMNS = new Set(["time_us", "indexed_at", "resolved_at"]); - -function normalizeRow(row: any): any { - if (!row) return row; - if (typeof row.record === "object" && row.record !== null) { - row.record = JSON.stringify(row.record); - } - for (const col of BIGINT_COLUMNS) { - if (typeof row[col] === "string") row[col] = Number(row[col]); - } - return row; -} - -export function createPostgresDatabase(pool: pg.Pool): Database { - function rewritePlaceholders(sql: string): string { - let idx = 0; - let inString = false; - let result = ""; - for (let i = 0; i < sql.length; i++) { - const ch = sql[i]; - if (ch === "'" && sql[i - 1] !== "\\") { - inString = !inString; - result += ch; - } else if (ch === "?" && !inString) { - result += `$${++idx}`; - } else { - result += ch; - } - } - return result; - } - - function wrapStatement(sql: string, boundValues: any[] = []): PgStatement { - const pgSql = rewritePlaceholders(sql); - - return { - bind(...values: any[]): PgStatement { - return wrapStatement(sql, values); - }, - async run() { - const result = await pool.query(pgSql, boundValues); - return { changes: result.rowCount }; - }, - async _runOn(client: pg.PoolClient) { - const result = await client.query(pgSql, boundValues); - return { changes: result.rowCount }; - }, - async all<T>() { - const result = await pool.query(pgSql, boundValues); - return { results: result.rows.map(normalizeRow) as T[] }; - }, - async first<T>() { - const result = await pool.query(pgSql, boundValues); - return result.rows[0] ? (normalizeRow(result.rows[0]) as T) : null; - }, - }; - } - - return { - prepare(sql: string): Statement { - return wrapStatement(sql); - }, - async batch(stmts: Statement[]): Promise<any[]> { - const client = await pool.connect(); - try { - await client.query("BEGIN"); - const results: any[] = []; - for (const stmt of stmts) { - results.push(await (stmt as PgStatement)._runOn(client)); - } - await client.query("COMMIT"); - return results; - } catch (e) { - await client.query("ROLLBACK"); - throw e; - } finally { - client.release(); - } - }, - dialect: postgresDialect, - }; -} diff --git a/packages/contrail-base/src/adapters/sqlite.ts b/packages/contrail-base/src/adapters/sqlite.ts deleted file mode 100644 index 305dc39..0000000 --- a/packages/contrail-base/src/adapters/sqlite.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { DatabaseSync } from "node:sqlite"; -import type { Database, Statement } from "../types"; -import { sqliteDialect } from "../dialect"; - -export function createSqliteDatabase(path: string): Database { - const raw = new DatabaseSync(path); - raw.exec("PRAGMA journal_mode = WAL"); - - function wrapStatement(sql: string, boundValues: any[] = []): Statement { - return { - bind(...values: any[]): Statement { - return wrapStatement(sql, values); - }, - async run() { - return raw.prepare(sql).run(...boundValues); - }, - async all<T>() { - return { results: raw.prepare(sql).all(...boundValues) as T[] }; - }, - async first<T>() { - return (raw.prepare(sql).get(...boundValues) as T) ?? null; - }, - }; - } - - return { - prepare(sql: string): Statement { - return wrapStatement(sql); - }, - async batch(stmts: Statement[]): Promise<any[]> { - const results: any[] = []; - for (const stmt of stmts) { - results.push(await stmt.run()); - } - return results; - }, - dialect: sqliteDialect, - }; -} diff --git a/packages/contrail-base/src/client.ts b/packages/contrail-base/src/client.ts deleted file mode 100644 index 10c89fa..0000000 --- a/packages/contrail-base/src/client.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { - CompositeDidDocumentResolver, - PlcDidDocumentResolver, - WebDidDocumentResolver, - type DidDocumentResolver, -} from "@atcute/identity-resolver"; -import { type Did } from "@atcute/lexicons"; -import { Client, simpleFetchHandler } from "@atcute/client"; -import type {} from "@atcute/atproto"; -import type { ContrailConfig, Database } from "./types"; - -// Slingshot-first PDS resolution with fallback to DID document resolution -const SLINGSHOT_URL = - "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc"; - -export interface ResolvedIdentity { - did: string; - handle: string | null; - pds: string | null; -} - -/** Reject external URLs (PDS, labeler, …) that point to private/internal - * addresses or non-HTTPS. The single SSRF guard shared across packages — - * callers MUST route every externally-resolved endpoint through this so the - * allowlist rules live in exactly one place. - * - * Hostnames in `additionalAllowedHosts` skip both checks. Match is exact, - * case-insensitive (allowlist entries are lowercased on compare; `URL.hostname` - * is already lowercased), and port-agnostic. - * - * Scope: best-effort guard against the obvious internal-address classes - * (private/link-local IPv4 literals, localhost, non-HTTPS). It does NOT - * resolve DNS, so a public hostname that resolves to a private address is not - * caught here, and IPv6 / non-canonical IP encodings are only partially - * covered. Defense-in-depth (egress network policy) is expected when resolver - * inputs are fully untrusted. */ -export function validateExternalUrl(url: string, additionalAllowedHosts?: string[]): boolean { - let parsed: URL; - try { - parsed = new URL(url); - } catch { - return false; - } - if (additionalAllowedHosts?.some((h) => h.toLowerCase() === parsed.hostname)) { - return true; - } - if (parsed.protocol !== "https:") return false; - const host = parsed.hostname; - // Block private/internal IP ranges - if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return false; - if (host.startsWith("10.")) return false; - if (host.startsWith("192.168.")) return false; - if (host.startsWith("169.254.")) return false; - if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return false; - return true; -} - -async function resolveViaSlingshot( - identifier: string, - slingshotUrl: string, -): Promise<ResolvedIdentity | undefined> { - const url = new URL(slingshotUrl); - url.searchParams.set("identifier", identifier); - - try { - const response = await fetch(url.toString()); - if (!response.ok) return undefined; - const data = (await response.json()) as { - did?: string; - handle?: string; - pds?: string; - }; - if (!data.did && !data.pds) return undefined; - return { - did: data.did ?? identifier, - handle: data.handle ?? null, - pds: data.pds ?? null, - }; - } catch { - return undefined; - } -} - -const DEFAULT_DID_RESOLVER: DidDocumentResolver = new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver(), - }, -}); - -async function getPDSViaDidDoc( - did: Did, - config?: ContrailConfig, -): Promise<string | undefined> { - const resolver = config?.networkOverrides?.resolver ?? DEFAULT_DID_RESOLVER; - const doc = await resolver.resolve(did as Did<"plc"> | Did<"web">); - return doc.service - ?.find((s) => s.id === "#atproto_pds") - ?.serviceEndpoint.toString(); -} - -/** - * Resolve identity info (did, handle, pds) for a DID or handle. - * Uses slingshot first, falls back to DID doc for PDS. - * - * `config?.networkOverrides` (optional): customize the slingshot endpoint, - * the PLC URL used during DID-doc fallback, and/or which hostnames bypass - * the default SSRF guard. Omitting `config` preserves all defaults. - */ -export async function resolvePDS( - identifier: string, - config?: ContrailConfig, -): Promise<ResolvedIdentity | undefined> { - const slingshotUrl = config?.networkOverrides?.slingshotUrl ?? SLINGSHOT_URL; - const allowed = config?.networkOverrides?.additionalAllowedHosts; - const result = await resolveViaSlingshot(identifier, slingshotUrl); - if (result?.pds) { - if (!validateExternalUrl(result.pds, allowed)) return { ...result, pds: null }; - return result; - } - - // Fall back to DID doc resolution (only works for DIDs, not handles) - if (identifier.startsWith("did:")) { - try { - const pds = await getPDSViaDidDoc(identifier as Did, config); - if (pds && validateExternalUrl(pds, allowed)) { - return { - did: identifier, - handle: result?.handle ?? null, - pds, - }; - } - } catch { - // ignore - } - } - - return result; -} - -// In-memory PDS cache with TTL + size limit, plus in-flight deduplication. -// -// Keyed by DID only — this assumes a single, process-wide `networkOverrides` -// config (the deployment model: one resolver + one SSRF allowlist per process). -// Every caller in this monorepo now threads the same in-scope `config`, so a -// config-less and an override-aware resolution can never race for the same DID. -// If a future deployment ever resolves the same DID under differing overrides -// in one process, key these caches by an override fingerprint instead. -const PDS_CACHE_TTL = 60 * 60 * 1000; // 1 hour -const PDS_CACHE_MAX = 10_000; -const pdsCache = new Map<string, { pds: string; at: number }>(); -const pdsInflight = new Map<string, Promise<string | undefined>>(); - -function pdsCacheGet(did: string): string | undefined { - const entry = pdsCache.get(did); - if (!entry) return undefined; - if (Date.now() - entry.at > PDS_CACHE_TTL) { - pdsCache.delete(did); - return undefined; - } - return entry.pds; -} - -function pdsCacheSet(did: string, pds: string): void { - // Evict oldest entries if over limit - if (pdsCache.size >= PDS_CACHE_MAX) { - const first = pdsCache.keys().next().value; - if (first) pdsCache.delete(first); - } - pdsCache.set(did, { pds, at: Date.now() }); -} - -export async function getPDS( - did: Did, - db?: Database, - config?: ContrailConfig, -): Promise<string | undefined> { - const mem = pdsCacheGet(did); - if (mem) return mem; - - // Deduplicate concurrent calls for the same DID - const inflight = pdsInflight.get(did); - if (inflight) return inflight; - - const promise = resolvePDSCached(did, db, config); - pdsInflight.set(did, promise); - try { - return await promise; - } finally { - pdsInflight.delete(did); - } -} - -async function resolvePDSCached( - did: Did, - db?: Database, - config?: ContrailConfig, -): Promise<string | undefined> { - let knownPds: string | undefined; - if (db) { - const cached = await db - .prepare("SELECT pds, handle FROM identities WHERE did = ? AND pds IS NOT NULL") - .bind(did) - .first<{ pds: string; handle: string | null }>(); - if (cached?.pds) { - pdsCacheSet(did, cached.pds); - // A row with both a PDS and a handle is a complete cache hit. A row with - // a PDS but no handle is a *partial* resolution — slingshot can return a - // PDS without a handle under load — so fall through to re-resolve and - // fill the handle instead of stranding the row forever (this DB - // short-circuit previously meant the handle was never backfilled). We - // keep serving the known PDS meanwhile, including if the re-resolve fails. - if (cached.handle) return cached.pds; - knownPds = cached.pds; - } - } - - const resolved = await resolvePDS(did, config); - if (!resolved?.pds) return knownPds; - - pdsCacheSet(did, resolved.pds); - - // Persist to DB for future runs. COALESCE keeps an existing handle when this - // resolution didn't return one, and never nulls a good handle. - if (db) { - await db - .prepare( - "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?) ON CONFLICT(did) DO UPDATE SET pds = excluded.pds, handle = COALESCE(excluded.handle, identities.handle), resolved_at = excluded.resolved_at" - ) - .bind(did, resolved.handle, resolved.pds, Date.now()) - .run(); - } - - return resolved.pds; -} - -export async function getClient( - did: Did, - db?: Database, - config?: ContrailConfig, -): Promise<Client> { - const pds = await getPDS(did, db, config); - if (!pds) throw new Error(`PDS not found for ${did}`); - return new Client({ - handler: simpleFetchHandler({ service: pds }), - }); -} - -/** Test-only: clear module-level PDS caches. Production code MUST NOT call this. - * Exported with a `__` prefix to signal it is not part of the public API. */ -export function __resetPdsCachesForTests(): void { - pdsCache.clear(); - pdsInflight.clear(); -} diff --git a/packages/contrail-base/src/community-integration.ts b/packages/contrail-base/src/community-integration.ts deleted file mode 100644 index bc2f936..0000000 --- a/packages/contrail-base/src/community-integration.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** Pluggable integration surface for the community module. - * - * Phase 6 extracted community to its own package (`@atmo-dev/contrail-community`). - * The contrail core package never imports from it — couplings only flow - * through these interfaces. The community package's - * `createCommunityIntegration({ ... })` returns a {@link CommunityIntegration}, - * which the consumer hands to `createApp` via `options.community`. - * - * Two layers: - * - {@link CommunityProbe}: minimal "is this a community DID" / "what - * spaces does this caller reach" surface used by realtime + collection - * routes for community-aware dispatch. - * - {@link CommunityIntegration}: the umbrella bundle — probe, whoami - * extension, invite handler, plus route + schema wiring that the - * umbrella router calls during setup. */ - -import type { Hono, MiddlewareHandler } from "hono"; -import type { Database } from "./types"; -import type { CommunityInviteHandler } from "./invite/community-handler"; - -/** Optional hook to extend `<ns>.spaceExt.whoami` with extra fields when a - * module above spaces (e.g. community) wants to override the default - * binary-membership response. If the hook returns a non-null object, that - * object is the entire response body. If null, falls through to the - * default behavior (just `isOwner`/`isMember`). - * - * Spaces stays community-agnostic: any consumer can plug in here. */ -export type WhoamiExtension = (input: { - spaceUri: string; - callerDid: string; - isOwner: boolean; - ownerDid: string; -}) => Promise<Record<string, unknown> | null>; - -/** Narrow interface for the deep callers (realtime/resolve, router/collection) - * that just need to ask "is this a community DID?" or "what spaces does this - * caller reach via community membership?" */ -export interface CommunityProbe { - /** Look up a community row by DID. Returns null for non-community DIDs. - * Callers usually only check truthiness — community-specific fields stay - * inside the community package. */ - getCommunity(did: string): Promise<{ did: string } | null>; - - /** Resolve the set of space URIs reachable by `callerDid` through community - * membership (direct grants + delegations). Used by realtime to expand - * community: topics into the caller's concrete space: topics. */ - resolveReachableSpaces(callerDid: string): Promise<Set<string>>; -} - -/** Umbrella integration the consumer constructs once and hands to createApp. - * contrail core treats this as an opaque bundle — it doesn't introspect - * community state, just calls these methods at the right wiring points. */ -export interface CommunityIntegration { - /** Probe used by realtime + collection cross-cutting concerns. */ - probe: CommunityProbe; - /** Whoami extension that returns `accessLevel` for community-owned spaces. */ - whoamiExtension: WhoamiExtension; - /** Handler for the community-grant path of the unified invite surface. */ - inviteHandler: CommunityInviteHandler; - /** Register `<ns>.community.*` routes onto the Hono app. */ - registerRoutes( - app: Hono, - options?: { authMiddleware?: MiddlewareHandler } - ): void; - /** Apply community schema (DDL) to the database. Called by initSchema. */ - applySchema(db: Database): Promise<void>; -} diff --git a/packages/contrail-base/src/dialect.ts b/packages/contrail-base/src/dialect.ts deleted file mode 100644 index e3bb790..0000000 --- a/packages/contrail-base/src/dialect.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** Get the dialect from a Database, defaulting to SQLite (for D1 compatibility) */ -export function getDialect(db: { dialect?: SqlDialect }): SqlDialect { - return db.dialect ?? sqliteDialect; -} - -const SAFE_FIELD = /^[a-zA-Z0-9_.]+$/; - -function assertSafeField(field: string): void { - if (!SAFE_FIELD.test(field)) { - throw new Error(`Invalid field name: ${field}`); - } -} - -export interface SqlDialect { - /** json_extract(col, '$.field') or col->>'field' */ - jsonExtract(column: string, field: string): string; - - /** Convert INSERT INTO to ignore-duplicates form. - * SQLite: INSERT INTO → INSERT OR IGNORE INTO - * PG: appends ON CONFLICT DO NOTHING - * Accepts full SQL starting with "INSERT INTO" (works with both VALUES and SELECT). */ - insertOrIgnore(sql: string): string; - - /** Column type for the record column: TEXT (SQLite) or JSONB (PostgreSQL) */ - readonly recordColumnType: string; - - /** FTS strategy: 'virtual-table' (SQLite FTS5) or 'generated-column' (PG tsvector) */ - readonly ftsStrategy: "virtual-table" | "generated-column"; - - /** INTEGER type name — same on both, but PostgreSQL may want BIGINT for time_us */ - readonly integerType: string; - - /** BIGINT type name for timestamps */ - readonly bigintType: string; - - /** Wrap an expression for use in CREATE INDEX — PostgreSQL requires parens around expressions */ - indexExpression(expr: string): string; -} - -export const sqliteDialect: SqlDialect = { - jsonExtract(column: string, field: string): string { - assertSafeField(field); - return `json_extract(${column}, '$.${field}')`; - }, - - insertOrIgnore(sql: string): string { - return sql.replace(/^INSERT INTO/, "INSERT OR IGNORE INTO"); - }, - - recordColumnType: "TEXT", - ftsStrategy: "virtual-table", - integerType: "INTEGER", - bigintType: "INTEGER", - - indexExpression(expr: string): string { - return expr; - }, -}; - -export const postgresDialect: SqlDialect = { - jsonExtract(column: string, field: string): string { - assertSafeField(field); - const parts = field.split("."); - if (parts.length === 1) { - return `${column}->>'${parts[0]}'`; - } - // a.b.c → col->'a'->'b'->>'c' - const intermediate = parts.slice(0, -1).map((p) => `->'${p}'`).join(""); - return `${column}${intermediate}->>'${parts[parts.length - 1]}'`; - }, - - insertOrIgnore(sql: string): string { - return `${sql} ON CONFLICT DO NOTHING`; - }, - - recordColumnType: "JSONB", - ftsStrategy: "generated-column", - integerType: "INTEGER", - bigintType: "BIGINT", - - indexExpression(expr: string): string { - return `(${expr})`; - }, -}; - -/** Generate FTS schema statements based on dialect */ -export function buildFtsSchema( - dialect: SqlDialect, - recordsTable: string, - fields: string[] -): string[] { - if (dialect.ftsStrategy === "virtual-table") { - const ftsTable = recordsTable.replace("records_", "fts_"); - return [ - `CREATE VIRTUAL TABLE IF NOT EXISTS ${ftsTable} USING fts5(uri UNINDEXED, content)` - ]; - } else { - const concatExpr = fields - .map((f) => `COALESCE(${dialect.jsonExtract("record", f)}, '')`) - .join(" || ' ' || "); - return [ - `ALTER TABLE ${recordsTable} ADD COLUMN IF NOT EXISTS search_vector TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', ${concatExpr})) STORED`, - `CREATE INDEX IF NOT EXISTS idx_${recordsTable}_search ON ${recordsTable} USING GIN (search_vector)`, - ]; - } -} - -/** Generate FTS query clause based on dialect */ -export function ftsQueryClause( - dialect: SqlDialect, - recordsTable: string -): { join: string; condition: string; orderExpr: string } { - if (dialect.ftsStrategy === "virtual-table") { - const ftsTable = recordsTable.replace("records_", "fts_"); - return { - join: `JOIN ${ftsTable} fts ON fts.uri = r.uri`, - condition: "fts.content MATCH ?", - orderExpr: "fts.rank", - }; - } else { - return { - join: "", - condition: "r.search_vector @@ plainto_tsquery('english', ?)", - orderExpr: "ts_rank(r.search_vector, plainto_tsquery('english', ?))", - }; - } -} diff --git a/packages/contrail-base/src/identity.ts b/packages/contrail-base/src/identity.ts deleted file mode 100644 index a18120d..0000000 --- a/packages/contrail-base/src/identity.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { Did } from "@atcute/lexicons"; -import type { ContrailConfig, Database, Logger } from "./types"; -import { isDid, isHandle } from "@atcute/lexicons/syntax"; -import { resolvePDS } from "./client"; - -const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours - -export interface Identity { - did: string; - handle: string | null; - pds: string | null; - resolved_at: number; -} - -async function saveIdentity(db: Database, identity: Identity): Promise<void> { - await db - .prepare( - // COALESCE so a null handle/pds from a partial resolution never clobbers - // a previously-resolved value (e.g. refreshStaleIdentities passes through - // a null handle when slingshot omits it). A fresh non-null value still - // overwrites — handle changes apply normally. - "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?) ON CONFLICT(did) DO UPDATE SET handle = COALESCE(excluded.handle, identities.handle), pds = COALESCE(excluded.pds, identities.pds), resolved_at = excluded.resolved_at" - ) - .bind(identity.did, identity.handle, identity.pds, identity.resolved_at) - .run(); -} - -function isStale(resolvedAt: number): boolean { - return Date.now() - resolvedAt >= STALE_MS; -} - -async function fetchAndSave( - db: Database, - identifier: string, - cached?: Identity | null, - config?: ContrailConfig, -): Promise<Identity> { - const resolved = await resolvePDS(identifier, config); - const identity: Identity = { - did: resolved?.did ?? identifier, - handle: resolved?.handle ?? cached?.handle ?? null, - pds: resolved?.pds ?? cached?.pds ?? null, - resolved_at: Date.now(), - }; - await saveIdentity(db, identity); - return identity; -} - -export async function resolveIdentity( - db: Database, - did: Did, - config?: ContrailConfig, -): Promise<Identity> { - const cached = await db - .prepare("SELECT did, handle, pds, resolved_at FROM identities WHERE did = ?") - .bind(did) - .first<Identity>(); - - if (cached && !isStale(cached.resolved_at)) return cached; - - return fetchAndSave(db, did, cached, config); -} - -export async function resolveIdentities( - db: Database, - dids: string[], - config?: ContrailConfig, -): Promise<Map<string, Identity>> { - const map = new Map<string, Identity>(); - if (dids.length === 0) return map; - - // Batch lookup from DB - const BATCH = 50; - for (let i = 0; i < dids.length; i += BATCH) { - const chunk = dids.slice(i, i + BATCH); - const placeholders = chunk.map(() => "?").join(","); - const rows = await db - .prepare(`SELECT did, handle, pds, resolved_at FROM identities WHERE did IN (${placeholders})`) - .bind(...chunk) - .all<Identity>(); - for (const row of rows.results ?? []) { - map.set(row.did, row); - } - } - - // Resolve missing via slingshot directly (no redundant DB lookup) - for (const did of dids) { - if (map.has(did) || !isDid(did)) continue; - try { - const identity = await fetchAndSave(db, did, undefined, config); - map.set(did, identity); - } catch { - // Silently skip unresolvable identities - } - } - - return map; -} - -export async function resolveActor( - db: Database, - actor: string, - config?: ContrailConfig, -): Promise<string | null> { - if (isDid(actor)) return actor; - if (!isHandle(actor)) return null; - - // Look up handle in identities table - const cached = await db - .prepare("SELECT did, resolved_at FROM identities WHERE handle = ?") - .bind(actor) - .first<{ did: string; resolved_at: number }>(); - - if (cached && !isStale(cached.resolved_at)) return cached.did; - - // Resolve via slingshot - const resolved = await resolvePDS(actor, config); - if (!resolved?.did || !isDid(resolved.did)) return null; - - await saveIdentity(db, { - did: resolved.did, - handle: resolved.handle ?? actor, - pds: resolved.pds ?? null, - resolved_at: Date.now(), - }); - - return resolved.did; -} - -/** - * Apply a handle change from a Jetstream `#identity` event. - * - * UPDATE-only — does not create a row for unknown DIDs (we'd lack PDS, and - * partial rows confuse the rest of the pipeline). PDS column is left - * untouched; it gets refreshed lazily via `getPDS` / next slingshot resolve. - */ -export async function applyIdentityEvent( - db: Database, - did: string, - handle: string -): Promise<void> { - await db - .prepare("UPDATE identities SET handle = ?, resolved_at = ? WHERE did = ?") - .bind(handle, Date.now(), did) - .run(); -} - -export async function refreshStaleIdentities( - db: Database, - dids: string[], - config?: ContrailConfig, -): Promise<void> { - if (dids.length === 0) return; - - const unique = [...new Set(dids)].filter(isDid); - if (unique.length === 0) return; - - const staleThreshold = Date.now() - STALE_MS; - const toRefresh: string[] = []; - - const BATCH = 50; - for (let i = 0; i < unique.length; i += BATCH) { - const chunk = unique.slice(i, i + BATCH); - const placeholders = chunk.map(() => "?").join(","); - const rows = await db - .prepare(`SELECT did, resolved_at FROM identities WHERE did IN (${placeholders})`) - .bind(...chunk) - .all<{ did: string; resolved_at: number }>(); - - const found = new Map((rows.results ?? []).map((r) => [r.did, r.resolved_at])); - for (const did of chunk) { - const resolvedAt = found.get(did); - if (resolvedAt === undefined || resolvedAt < staleThreshold) { - toRefresh.push(did); - } - } - } - - for (const did of toRefresh) { - try { - await fetchAndSave(db, did, undefined, config); - } catch { - // Silently skip unresolvable identities - } - } -} diff --git a/packages/contrail-base/src/index.ts b/packages/contrail-base/src/index.ts deleted file mode 100644 index 9d46c95..0000000 --- a/packages/contrail-base/src/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** @atmo-dev/contrail-base — shared infrastructure for the contrail family. - * - * No routes. No tables of its own. Pure types, interfaces, primitives, and - * shared utilities used across contrail / contrail-appview / contrail-authority / - * contrail-record-host / contrail-community. - * - * Re-exported wholesale from each source module — anything internal that - * needed to be hidden would have an explicit subpath export instead. */ - -// Core types + config + helpers (Database, ContrailConfig, dialect helpers, etc.) -export * from "./types"; - -// Dialect (SqlDialect, getDialect, sqliteDialect, postgresDialect, buildFtsSchema) -export * from "./dialect"; - -// Identity (resolveActor, resolveIdentities, refreshStaleIdentities, applyIdentityEvent) -export * from "./identity"; - -// PDS client helpers (getPDS, getClient) -export * from "./client"; - -// Spaces interfaces + shared types -export * from "./spaces/types"; - -// Spaces URI helpers -export * from "./spaces/uri"; - -// TID generator -export * from "./spaces/tid"; - -// In-process auth marker -export * from "./spaces/in-process"; - -// Service-auth verification -export * from "./spaces/auth"; - -// ACL pure functions -export * from "./spaces/acl"; - -// Credentials -export * from "./spaces/credentials"; - -// Membership manifests -export * from "./spaces/manifest"; - -// Binding + key resolution -export * from "./spaces/binding"; - -// Blob adapter interface + built-in impls -export * from "./spaces/blob-adapter"; - -// Invite token primitives + community-handler interface -export * from "./invite/token"; -export * from "./invite/community-handler"; - -// Community integration interface + WhoamiExtension -export * from "./community-integration"; - -// Labels types -export * from "./labels/types"; - -// Sinks (write-only, post-commit observers of applied records) -export * from "./sinks/types"; - -// Realtime infrastructure -export * from "./realtime/types"; -export * from "./realtime/in-memory"; -export * from "./realtime/ticket"; -export * from "./realtime/durable-object"; -export * from "./realtime/sse"; -export * from "./realtime/websocket"; -export * from "./realtime/merge"; -export * from "./realtime/query-filter"; diff --git a/packages/contrail-base/src/invite/community-handler.ts b/packages/contrail-base/src/invite/community-handler.ts deleted file mode 100644 index 1043525..0000000 --- a/packages/contrail-base/src/invite/community-handler.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** Pluggable handler for community-grant invites within the unified invite - * surface. The invite router calls into this when the target space is - * community-owned, or "tries" it on the redeem / revoke-without-spaceUri - * paths. Community module provides the impl; invite/router doesn't import - * from community at all. - * - * Each method returns a `HandlerResponse`: a `{status, body}` envelope that - * the router relays as JSON, or `null` (only on the "try" methods) meaning - * "not applicable, fall through to the user-owned path." */ - -export type HandlerResponse = { - status: number; - body: Record<string, unknown>; -}; - -export interface CommunityInviteHandler { - /** True iff this space is owned by a community (vs. a regular user DID). - * Used by the invite router to choose the dispatch path on - * create / list / revoke-with-spaceUri. */ - isCommunityOwned(spaceUri: string): Promise<boolean>; - - /** Create a community-grant invite. Caller is validated upstream for - * having a JWT; this method handles the access-level checks. */ - create(input: { - spaceUri: string; - callerDid: string; - /** Raw caller-supplied access level — implementation validates. */ - accessLevel?: string; - /** Caller-supplied `kind` field — community spaces don't accept this; the - * handler returns an InvalidRequest if set. */ - kind?: string; - expiresAt: number | null; - maxUses: number | null; - note: string | null; - }): Promise<HandlerResponse>; - - /** List invites for a community-owned space. */ - list(input: { - spaceUri: string; - callerDid: string; - includeRevoked: boolean; - }): Promise<HandlerResponse>; - - /** Revoke a known community-owned invite (caller already passed spaceUri - * and the router classified it as community-owned). */ - revoke(input: { - spaceUri: string; - tokenHash: string; - callerDid: string; - }): Promise<HandlerResponse>; - - /** Revoke without a spaceUri — try to find the invite in the community - * table; return null if not a community invite (router falls through). */ - tryRevokeByToken(input: { - tokenHash: string; - callerDid: string; - }): Promise<HandlerResponse | null>; - - /** Try to redeem a token as a community invite. Returns null if the token - * is not a community invite, in which case the router falls through to - * the user-owned redeem path. */ - tryRedeem(input: { - tokenHash: string; - callerDid: string; - now: number; - }): Promise<HandlerResponse | null>; -} diff --git a/packages/contrail-base/src/invite/token.ts b/packages/contrail-base/src/invite/token.ts deleted file mode 100644 index 76f4880..0000000 --- a/packages/contrail-base/src/invite/token.ts +++ /dev/null @@ -1,43 +0,0 @@ -const B64U_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - -function bytesToB64Url(bytes: Uint8Array): string { - let out = ""; - for (let i = 0; i < bytes.length; i += 3) { - const b0 = bytes[i]; - const b1 = bytes[i + 1] ?? 0; - const b2 = bytes[i + 2] ?? 0; - out += B64U_ALPHABET[b0 >> 2]; - out += B64U_ALPHABET[((b0 & 3) << 4) | (b1 >> 4)]; - if (i + 1 < bytes.length) out += B64U_ALPHABET[((b1 & 15) << 2) | (b2 >> 6)]; - if (i + 2 < bytes.length) out += B64U_ALPHABET[b2 & 63]; - } - return out; -} - -function bytesToHex(bytes: Uint8Array): string { - let out = ""; - for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0"); - return out; -} - -/** Generate a fresh invite token (cryptographically random, 32 bytes base64url-encoded). */ -export function generateInviteToken(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return bytesToB64Url(bytes); -} - -/** SHA-256 hash of a token, hex-encoded. Used as the PK in storage so raw tokens are never persisted. */ -export async function hashInviteToken(token: string): Promise<string> { - const encoded = new TextEncoder().encode(token); - const digest = await crypto.subtle.digest("SHA-256", encoded); - return bytesToHex(new Uint8Array(digest)); -} - -/** Convenience: generate a token and return both the raw form (returned to - * the creator once) and its hash (persisted as the stable ID). */ -export async function mintInviteToken(): Promise<{ token: string; tokenHash: string }> { - const token = generateInviteToken(); - const tokenHash = await hashInviteToken(token); - return { token, tokenHash }; -} diff --git a/packages/contrail-base/src/labels/types.ts b/packages/contrail-base/src/labels/types.ts deleted file mode 100644 index f2e36c2..0000000 --- a/packages/contrail-base/src/labels/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Database } from "../types"; - -/** A labeler the operator wants contrail to track. */ -export interface LabelerSource { - /** Labeler DID — `did:plc:...` or `did:web:...`. */ - did: string; - /** Override the service endpoint resolution. Otherwise resolved from the - * DID doc's `service[id="#atproto_labeler"].serviceEndpoint`. */ - endpoint?: string; - /** Backfill from `cursor=0` on first sight. Defaults to true. Set false - * for "start from now" — useful for very chatty labelers. */ - backfill?: boolean; -} - -export interface LabelsConfig { - /** Labelers to subscribe to and index. */ - sources: LabelerSource[]; - /** DIDs honored when the caller sends no `atproto-accept-labelers` / - * `?labelers=`. Defaults to every entry in `sources`. Set `[]` for - * opt-in-only — clients see no labels unless they ask. */ - defaults?: string[]; - /** Per-request cap. Default: 20 (matches Bluesky). */ - maxPerRequest?: number; -} - -export const DEFAULT_LABELS_MAX_PER_REQUEST = 20; - -/** A single label as stored. Matches `com.atproto.label.defs#label`. */ -export interface LabelRow { - /** Issuing labeler DID. */ - src: string; - /** Subject — at-URI for record labels, plain DID for account labels. */ - uri: string; - /** Label value — kebab-case, ≤128 bytes per spec. */ - val: string; - /** Optional CID pin to a specific record version. */ - cid: string | null; - /** When true, retracts a previously-emitted label for the same (src, uri, val). */ - neg: boolean; - /** Expiry, unix seconds. Past this, hydration drops the row. */ - exp: number | null; - /** Creation timestamp, unix seconds — what we collapse on. */ - cts: number; - /** Raw signature bytes. Stored when present so we can re-emit later; - * not verified in v1. */ - sig: Uint8Array | null; -} - -/** Per-labeler state row — endpoint cache and last-seen seq cursor. */ -export interface LabelerCursorRow { - did: string; - cursor: number; - endpoint: string | null; - resolved_at: number | null; -} - diff --git a/packages/contrail-base/src/realtime/durable-object.ts b/packages/contrail-base/src/realtime/durable-object.ts deleted file mode 100644 index 966ab53..0000000 --- a/packages/contrail-base/src/realtime/durable-object.ts +++ /dev/null @@ -1,567 +0,0 @@ -/** Durable Object backend for realtime PubSub. - * - * Two pieces live here: - * 1. `RealtimePubSubDO` — the DO class. Ship it from your Worker via - * `export { RealtimePubSubDO } from "@atmo-dev/contrail";` and bind it in - * your `wrangler.toml`. One DO = one topic; addressed by name. - * 2. `DurableObjectPubSub` — client-side adapter implementing the PubSub - * interface against a DO namespace binding. - * - * Wire format between Worker and DO (internal, not a stable public contract): - * POST /publish — body = RealtimeEvent JSON - * GET /subscribe — server-sent events stream, optionally with - * `Upgrade: websocket` for WS connections. - * Auth/ACL is already checked at the Worker edge; - * the DO trusts anything that reaches it. */ - -import type { PubSub, RealtimeEvent } from "./types"; -import { translateForQuery, type TranslatedEnvelope } from "./query-filter"; -type TranslatedEvent = TranslatedEnvelope; - -/** Query spec attached to a WS subscriber, used to filter events before - * delivery. Shape matches what the Worker's `watchRecords` handler builds; - * forwarded to the DO via trusted internal headers on the WS upgrade. */ -export interface SubscriberQuerySpec { - /** NSID of the primary collection the client is watching. */ - collection: string; - /** Space URI this subscription is scoped to. Events outside are dropped. */ - spaceUri: string; - /** Hydrated relations. Keyed by relName — value is the child collection - * NSID and the field on the child record that references the parent. */ - hydrate?: Record<string, { childCollection: string; matchField: string }>; -} - -// ---- Minimal structural typings so we don't depend on @cloudflare/workers-types -// at the library level. Callers on Workers will have proper types. -// ---------------------------------------------------------------------------- - -export interface DurableObjectId { - toString(): string; -} - -export interface DurableObjectStub { - fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>; -} - -export interface DurableObjectNamespace { - idFromName(name: string): DurableObjectId; - get(id: DurableObjectId): DurableObjectStub; -} - -export interface DurableObjectState { - acceptWebSocket(ws: any, tags?: string[]): void; - getWebSockets(tag?: string): any[]; -} - -// ---------------------------------------------------------------------------- -// Client adapter -// ---------------------------------------------------------------------------- - -export class DurableObjectPubSub implements PubSub { - constructor(private readonly namespace: DurableObjectNamespace) {} - - private stub(topic: string): DurableObjectStub { - return this.namespace.get(this.namespace.idFromName(topic)); - } - - async publish(event: RealtimeEvent): Promise<void> { - const res = await this.stub(event.topic).fetch("https://do/publish", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(event), - }); - if (!res.ok) { - // Consume the body so the edge doesn't hold a dangling response. - await res.text().catch(() => ""); - throw new Error(`DO publish failed (${res.status})`); - } - } - - /** In-Worker server-side subscribe. Browsers should hit the SSE endpoint - * directly; this is the path for an in-process consumer that wants an - * AsyncIterable (tests, bots embedded in the Worker). */ - subscribe(topic: string, signal?: AbortSignal): AsyncIterable<RealtimeEvent> { - const stub = this.stub(topic); - return { - [Symbol.asyncIterator]() { - return pullIterator(stub, signal); - }, - }; - } - - /** Forward an incoming browser WS upgrade (or SSE GET) through to the DO - * that owns this topic, attaching a query-filter spec that the DO will use - * to decide what to deliver. The Worker must verify auth + spec validity - * before calling this — the DO trusts the headers. */ - async forwardSubscribe( - topic: string, - request: Request, - opts: { - did?: string; - querySpec?: SubscriberQuerySpec; - /** Unix ms. DO replays any buffered event with ts > sinceTs before - * going live — closes the snapshot→WS race window on the client. */ - sinceTs?: number; - } = {} - ): Promise<Response> { - const headers = new Headers(request.headers); - if (opts.querySpec) { - headers.set("X-Contrail-Query-Spec", JSON.stringify(opts.querySpec)); - } - const url = new URL("https://do/subscribe"); - if (opts.did) url.searchParams.set("did", opts.did); - if (opts.sinceTs && opts.sinceTs > 0) { - url.searchParams.set("sinceTs", String(opts.sinceTs)); - } - return this.stub(topic).fetch(url.toString(), { - method: "GET", - headers - }); - } -} - -function pullIterator( - stub: DurableObjectStub, - signal?: AbortSignal -): AsyncIterator<RealtimeEvent> { - let reader: ReadableStreamDefaultReader<Uint8Array> | null = null; - let buf = ""; - const decoder = new TextDecoder(); - const ac = new AbortController(); - if (signal) { - if (signal.aborted) ac.abort(); - else signal.addEventListener("abort", () => ac.abort(), { once: true }); - } - - const open = async () => { - const res = await stub.fetch("https://do/subscribe", { - method: "GET", - headers: { accept: "text/event-stream" }, - signal: ac.signal, - }); - if (!res.ok || !res.body) throw new Error(`DO subscribe failed (${res.status})`); - reader = res.body.getReader(); - }; - - return { - async next(): Promise<IteratorResult<RealtimeEvent>> { - if (!reader) await open(); - while (true) { - // Drain buffered frames. - while (true) { - const sep = buf.indexOf("\n\n"); - if (sep < 0) break; - const frame = buf.slice(0, sep); - buf = buf.slice(sep + 2); - let data: string | null = null; - for (const line of frame.split("\n")) { - if (line.startsWith(":")) continue; - if (line.startsWith("data:")) data = line.slice(5).trim(); - } - if (data) { - try { - return { value: JSON.parse(data) as RealtimeEvent, done: false }; - } catch { - /* skip malformed */ - } - } - } - if (ac.signal.aborted) return { value: undefined, done: true }; - const r = await reader!.read(); - if (r.done) return { value: undefined, done: true }; - buf += decoder.decode(r.value, { stream: true }); - } - }, - async return(): Promise<IteratorResult<RealtimeEvent>> { - ac.abort(); - try { - await reader?.cancel(); - } catch { - /* ignore */ - } - return { value: undefined, done: true }; - }, - }; -} - -// ---------------------------------------------------------------------------- -// Durable Object class -// ---------------------------------------------------------------------------- - -/** The Durable Object implementation. Each DO instance owns the fan-out for - * exactly one topic. WebSocket connections are stored via the Hibernation - * API (`state.acceptWebSocket`) so idle rooms cost near-zero. - * - * This class intentionally avoids the `DurableObject` base class so we don't - * have to depend on @cloudflare/workers-types at the library level — users - * wire it up directly in their Worker entry. */ -/** Rolling buffer of recent events, used to close the snapshot→WS race: - * when a new subscriber connects with `?sinceTs=X`, replay any buffered - * event with `event.ts > X` before going live. Bounded by count + age so - * memory stays small. */ -const RECENT_BUFFER_MS = 15_000; -const RECENT_BUFFER_MAX = 500; - -export class RealtimePubSubDO { - private readonly recentEvents: RealtimeEvent[] = []; - - constructor( - protected readonly state: DurableObjectState, - _env?: unknown - ) {} - - private pushRecent(event: RealtimeEvent): void { - this.recentEvents.push(event); - const cutoff = Date.now() - RECENT_BUFFER_MS; - while ( - this.recentEvents.length > RECENT_BUFFER_MAX || - (this.recentEvents.length > 0 && this.recentEvents[0]!.ts < cutoff) - ) { - this.recentEvents.shift(); - } - } - - /** Worker entry delegates `fetch` to this method. */ - async fetch(request: Request): Promise<Response> { - const url = new URL(request.url); - if (request.method === "POST" && url.pathname === "/publish") { - let event: RealtimeEvent; - try { - event = (await request.json()) as RealtimeEvent; - } catch { - return new Response(JSON.stringify({ error: "InvalidRequest" }), { status: 400 }); - } - this.publishEvent(event); - return new Response("{}", { status: 200 }); - } - if (request.method === "GET" && url.pathname === "/subscribe") { - const did = url.searchParams.get("did") ?? undefined; - const sinceTsRaw = url.searchParams.get("sinceTs"); - const sinceTs = sinceTsRaw ? Number(sinceTsRaw) : 0; - // Optional query-filter spec, forwarded by the Worker after it has - // verified the caller's auth + access. Parsed once here; the parsed - // object is serialized into the WS attachment so the DO can filter - // events on publish without re-parsing. - let querySpec: SubscriberQuerySpec | undefined; - const rawSpec = request.headers.get("X-Contrail-Query-Spec"); - if (rawSpec) { - try { - querySpec = JSON.parse(rawSpec) as SubscriberQuerySpec; - } catch { - return new Response( - JSON.stringify({ error: "InvalidRequest", message: "bad X-Contrail-Query-Spec" }), - { status: 400 } - ); - } - } - - if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") { - const Pair = (globalThis as unknown as { WebSocketPair?: any }).WebSocketPair; - if (!Pair) return new Response("websockets require Workers", { status: 426 }); - const pair = new Pair(); - this.acceptWebSocketSubscriber(pair[1], did, querySpec); - if (sinceTs > 0) this.replayRecentTo(pair[1], sinceTs); - return new Response(null, { - status: 101, - // Workers-specific init field - webSocket: pair[0], - } as ResponseInit & { webSocket: unknown }); - } - return this.openSseResponse(did, querySpec, sinceTs); - } - return new Response("not found", { status: 404 }); - } - - /** Fan-out an event to every connected subscriber (WS + SSE). - * Public so tests + advanced callers can skip the HTTP layer. - * - * If a subscriber has attached a `querySpec`, we translate the raw event - * into 0–1 watchRecords-shaped events (record.created, record.deleted, - * hydration.added, hydration.removed) and deliver only those. Otherwise - * the raw event is delivered as-is (topic-firehose behaviour for the - * `realtime.subscribe` endpoint). */ - publishEvent(event: RealtimeEvent): void { - // Buffer first so a subscriber connecting mid-publish (race-window - // replay) can pick up this event too once they provide their sinceTs. - this.pushRecent(event); - - const rawPayload = JSON.stringify(event); - const rawFrame = `event: ${event.kind}\ndata: ${rawPayload}\n\n`; - - for (const ws of this.state.getWebSockets()) { - const attachment = getAttachment(ws); - - if (attachment?.querySpec) { - const translated = translateForQuery(event, attachment); - if (translated) this.writeSubscriberState(ws, attachment, translated); - for (const msg of translated ?? []) { - try { - ws.send(JSON.stringify(msg)); - } catch { - /* ignore */ - } - } - } else { - try { - ws.send(rawPayload); - } catch { - /* ignore */ - } - } - - if ( - event.kind === "member.removed" && - attachment?.did && - event.payload.did === attachment.did - ) { - try { - ws.close(4003, "membership-revoked"); - } catch { - /* ignore */ - } - } - } - - for (const entry of this.sseControllers) { - if (entry.querySpec) { - const translated = translateForQuery(event, entry); - if (translated) this.writeSubscriberStateForSse(entry, translated); - for (const msg of translated ?? []) { - try { - entry.controller.enqueue( - this.encoder.encode(`event: ${msg.kind}\ndata: ${JSON.stringify(msg.data)}\n\n`) - ); - } catch { - /* drop */ - } - } - } else { - try { - entry.controller.enqueue(this.encoder.encode(rawFrame)); - } catch { - /* drop; cleanup happens on the subscribe-side */ - } - } - - if ( - event.kind === "member.removed" && - entry.did && - event.payload.did === entry.did - ) { - try { - entry.controller.close(); - } catch { - /* ignore */ - } - } - } - } - - /** Register a server-side WebSocket as a subscriber. Wires the DID + - * optional query spec into the hibernation attachment so this DO can - * filter and route events after going to sleep. */ - acceptWebSocketSubscriber( - serverWs: any, - did?: string, - querySpec?: SubscriberQuerySpec - ): void { - this.state.acceptWebSocket(serverWs, did ? [did] : undefined); - if (did || querySpec) { - setAttachment(serverWs, { - did, - querySpec, - parentUris: [], - childToParent: {} - }); - } - } - - /** Open an SSE subscriber; returns the streaming Response. */ - openSseResponse( - did?: string, - querySpec?: SubscriberQuerySpec, - sinceTs = 0 - ): Response { - let entry: SseEntry; - const stream = new ReadableStream<Uint8Array>({ - start: (controller) => { - entry = { - controller, - did, - querySpec, - parentUris: new Set(), - childToParent: new Map() - }; - this.sseControllers.add(entry); - controller.enqueue(this.encoder.encode(`: open\n\n`)); - if (sinceTs > 0) this.replayRecentToSse(entry, sinceTs); - }, - cancel: () => { - this.sseControllers.delete(entry); - }, - }); - return new Response(stream, { - status: 200, - headers: { - "content-type": "text/event-stream", - "cache-control": "no-cache, no-transform", - connection: "keep-alive", - }, - }); - } - - /** Replay buffered events with ts > sinceTs through this subscriber's - * query-spec filter. Called once, synchronously, on WS connect. */ - private replayRecentTo(ws: any, sinceTs: number): void { - const attachment = getAttachment(ws); - for (const event of this.recentEvents) { - if (event.ts <= sinceTs) continue; - if (attachment?.querySpec) { - const translated = translateForQuery(event, attachment); - if (translated) this.writeSubscriberState(ws, attachment, translated); - for (const msg of translated ?? []) { - try { - ws.send(JSON.stringify(msg)); - } catch { - /* ignore */ - } - } - } else { - try { - ws.send(JSON.stringify(event)); - } catch { - /* ignore */ - } - } - } - } - - private replayRecentToSse(entry: SseEntry, sinceTs: number): void { - for (const event of this.recentEvents) { - if (event.ts <= sinceTs) continue; - if (entry.querySpec) { - const translated = translateForQuery(event, entry); - if (translated) this.writeSubscriberStateForSse(entry, translated); - for (const msg of translated ?? []) { - try { - entry.controller.enqueue( - this.encoder.encode(`event: ${msg.kind}\ndata: ${JSON.stringify(msg.data)}\n\n`) - ); - } catch { - /* drop */ - } - } - } else { - try { - entry.controller.enqueue( - this.encoder.encode(`event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`) - ); - } catch { - /* drop */ - } - } - } - } - - /** Update the persisted WS attachment state after we've decided which - * events to forward. Keeps the parent/child tracking tables warm across - * hibernation. */ - private writeSubscriberState( - ws: any, - attachment: WsAttachment, - translated: TranslatedEvent[] - ): void { - let dirty = false; - for (const msg of translated) { - if (msg.kind === "record.created" && msg.data.record?.uri) { - attachment.parentUris = Array.from( - new Set([...(attachment.parentUris ?? []), msg.data.record.uri]) - ); - dirty = true; - } else if (msg.kind === "record.deleted" && msg.data.uri) { - const before = attachment.parentUris ?? []; - attachment.parentUris = before.filter((u) => u !== msg.data.uri); - if (attachment.parentUris.length !== before.length) dirty = true; - } else if (msg.kind === "hydration.added" && msg.data.child?.rkey) { - attachment.childToParent = { - ...(attachment.childToParent ?? {}), - [msg.data.child.rkey]: { - parentUri: msg.data.parentUri, - relName: msg.data.relation - } - }; - dirty = true; - } else if (msg.kind === "hydration.removed" && msg.data.childRkey) { - const next = { ...(attachment.childToParent ?? {}) }; - if (next[msg.data.childRkey]) { - delete next[msg.data.childRkey]; - attachment.childToParent = next; - dirty = true; - } - } - } - if (dirty) setAttachment(ws, attachment); - } - - private writeSubscriberStateForSse( - entry: SseEntry, - translated: TranslatedEvent[] - ): void { - for (const msg of translated) { - if (msg.kind === "record.created" && msg.data.record?.uri) { - entry.parentUris?.add(msg.data.record.uri); - } else if (msg.kind === "record.deleted" && msg.data.uri) { - entry.parentUris?.delete(msg.data.uri); - } else if (msg.kind === "hydration.added" && msg.data.child?.rkey) { - entry.childToParent?.set(msg.data.child.rkey, { - parentUri: msg.data.parentUri, - relName: msg.data.relation - }); - } else if (msg.kind === "hydration.removed" && msg.data.childRkey) { - entry.childToParent?.delete(msg.data.childRkey); - } - } - } - - private readonly sseControllers = new Set<SseEntry>(); - private readonly encoder = new TextEncoder(); -} - -interface SseEntry { - controller: ReadableStreamDefaultController<Uint8Array>; - did: string | undefined; - querySpec?: SubscriberQuerySpec; - parentUris?: Set<string>; - childToParent?: Map<string, { parentUri: string; relName: string }>; -} - -interface WsAttachment { - did?: string; - querySpec?: SubscriberQuerySpec; - /** URIs of primary records currently in this subscriber's result set. */ - parentUris?: string[]; - /** childRkey → parent info, for routing child delete events. */ - childToParent?: Record<string, { parentUri: string; relName: string }>; -} - -function setAttachment(ws: any, attachment: WsAttachment): void { - try { - ws.serializeAttachment?.(attachment); - } catch { - /* non-hibernating socket — fall back to a direct property */ - ws.__attachment = attachment; - } -} - -function getAttachment(ws: any): WsAttachment | null { - try { - const a = ws.deserializeAttachment?.(); - if (a) return a as WsAttachment; - } catch { - /* ignore */ - } - return (ws.__attachment as WsAttachment | undefined) ?? null; -} - -// Query-spec filtering lives in ./query-filter so the Worker can reuse it for -// non-DO (InMemoryPubSub) watchRecords paths without bundling the whole DO. diff --git a/packages/contrail-base/src/realtime/in-memory.ts b/packages/contrail-base/src/realtime/in-memory.ts deleted file mode 100644 index 311c0a5..0000000 --- a/packages/contrail-base/src/realtime/in-memory.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { PubSub, RealtimeEvent } from "./types"; -import { DEFAULT_QUEUE_BOUND } from "./types"; - -/** Single-process PubSub backed by in-memory subscriber sets. - * - * Each subscriber owns a bounded queue; when full, oldest events are dropped. - * `publish` returns once every subscriber has been offered the event — it - * never awaits a subscriber's consumption, so a slow consumer can't block - * producers. The cost of that guarantee is the drop-oldest policy. */ -export class InMemoryPubSub implements PubSub { - private readonly subscribers = new Map<string, Set<Subscriber>>(); - private readonly queueBound: number; - - constructor(opts: { queueBound?: number } = {}) { - this.queueBound = opts.queueBound ?? DEFAULT_QUEUE_BOUND; - } - - async publish(event: RealtimeEvent): Promise<void> { - const set = this.subscribers.get(event.topic); - if (!set) return; - for (const sub of set) sub.push(event); - } - - subscribe(topic: string, signal?: AbortSignal): AsyncIterable<RealtimeEvent> { - const sub = new Subscriber(this.queueBound); - let set = this.subscribers.get(topic); - if (!set) { - set = new Set(); - this.subscribers.set(topic, set); - } - set.add(sub); - - const cleanup = () => { - sub.close(); - const s = this.subscribers.get(topic); - if (s) { - s.delete(sub); - if (s.size === 0) this.subscribers.delete(topic); - } - }; - - if (signal) { - if (signal.aborted) cleanup(); - else signal.addEventListener("abort", cleanup, { once: true }); - } - - return sub.iterate(cleanup); - } - - /** Test-only: current subscriber count for a topic. */ - subscriberCount(topic: string): number { - return this.subscribers.get(topic)?.size ?? 0; - } -} - -class Subscriber { - private readonly queue: RealtimeEvent[] = []; - private pending: ((v: RealtimeEvent | null) => void) | null = null; - private closed = false; - /** Number of events dropped because the queue was full. The consumer can - * observe a gap by comparing monotonic event timestamps; exposing the - * count on a side channel is future work. */ - public droppedCount = 0; - - constructor(private readonly bound: number) {} - - push(event: RealtimeEvent): void { - if (this.closed) return; - if (this.pending) { - const p = this.pending; - this.pending = null; - p(event); - return; - } - if (this.queue.length >= this.bound) { - this.queue.shift(); - this.droppedCount += 1; - } - this.queue.push(event); - } - - close(): void { - if (this.closed) return; - this.closed = true; - if (this.pending) { - const p = this.pending; - this.pending = null; - p(null); - } - } - - iterate(cleanup: () => void): AsyncIterable<RealtimeEvent> { - const self = this; - return { - [Symbol.asyncIterator]() { - return { - async next(): Promise<IteratorResult<RealtimeEvent>> { - if (self.queue.length > 0) { - return { value: self.queue.shift()!, done: false }; - } - if (self.closed) return { value: undefined, done: true }; - const event = await new Promise<RealtimeEvent | null>((resolve) => { - self.pending = resolve; - }); - if (event === null) return { value: undefined, done: true }; - return { value: event, done: false }; - }, - async return(): Promise<IteratorResult<RealtimeEvent>> { - cleanup(); - return { value: undefined, done: true }; - }, - }; - }, - }; - } -} diff --git a/packages/contrail-base/src/realtime/merge.ts b/packages/contrail-base/src/realtime/merge.ts deleted file mode 100644 index e0fa7df..0000000 --- a/packages/contrail-base/src/realtime/merge.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** Merge N AsyncIterables into one, interleaving events as they arrive. - * Terminates when every source terminates, or when `signal` aborts. */ - -export function mergeAsyncIterables<T>( - sources: AsyncIterable<T>[], - signal?: AbortSignal -): AsyncIterable<T> { - if (sources.length === 0) { - return { - async *[Symbol.asyncIterator]() { - /* nothing to yield */ - }, - }; - } - - return { - [Symbol.asyncIterator]() { - const iterators = sources.map((s) => s[Symbol.asyncIterator]()); - // One in-flight next() per source, racing each other. - type Slot = { - idx: number; - promise: Promise<{ idx: number; result: IteratorResult<T> }>; - }; - const pending = new Map<number, Slot>(); - let doneCount = 0; - - const schedule = (idx: number) => { - const slot: Slot = { - idx, - promise: iterators[idx]! - .next() - .then((result) => ({ idx, result })), - }; - pending.set(idx, slot); - }; - - for (let i = 0; i < iterators.length; i++) schedule(i); - - const cleanup = () => { - for (const it of iterators) { - try { - it.return?.(); - } catch { - /* ignore */ - } - } - }; - - if (signal) { - if (signal.aborted) cleanup(); - else signal.addEventListener("abort", cleanup, { once: true }); - } - - return { - async next(): Promise<IteratorResult<T>> { - while (pending.size > 0) { - const slots = [...pending.values()]; - const { idx, result } = await Promise.race(slots.map((s) => s.promise)); - pending.delete(idx); - if (result.done) { - doneCount += 1; - if (doneCount === iterators.length) return { value: undefined, done: true }; - continue; - } - schedule(idx); - return { value: result.value, done: false }; - } - return { value: undefined, done: true }; - }, - async return(): Promise<IteratorResult<T>> { - cleanup(); - return { value: undefined, done: true }; - }, - }; - }, - }; -} diff --git a/packages/contrail-base/src/realtime/query-filter.ts b/packages/contrail-base/src/realtime/query-filter.ts deleted file mode 100644 index 1b79eb6..0000000 --- a/packages/contrail-base/src/realtime/query-filter.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** Shared query-spec → event-translation logic. - * - * Used by: - * - the Durable Object's WS publish path (per-subscriber filter after hibernation) - * - the Worker's SSE / Worker-terminated WS path (in-process filter) - * - * Given a raw RealtimeEvent and a SubscriberQuerySpec, returns the list of - * `{kind, data}` envelopes to send to the subscriber, or `null` if the - * subscriber has no spec (i.e. raw-firehose mode). */ - -import type { RealtimeEvent } from "./types"; -import type { SubscriberQuerySpec } from "./durable-object"; - -export type TranslatedEnvelope = - | { - kind: "record.created"; - data: { - record: { - uri: string; - did: string; - rkey: string; - collection: string; - cid: string | null | undefined; - record: Record<string, unknown>; - time_us: number; - indexed_at: number; - space: string; - }; - }; - } - | { kind: "record.deleted"; data: { uri: string; did: string; rkey: string } } - | { - kind: "hydration.added"; - data: { - parentUri: string; - relation: string; - child: { - uri: string; - did: string; - rkey: string; - collection: string; - cid: string | null | undefined; - record: Record<string, unknown>; - space: string; - }; - }; - } - | { - kind: "hydration.removed"; - data: { - parentUri: string; - relation: string; - childRkey: string; - childDid?: string; - }; - }; - -export interface SubscriberView { - querySpec?: SubscriberQuerySpec; - parentUris?: Set<string> | string[]; - childToParent?: - | Map<string, { parentUri: string; relName: string }> - | Record<string, { parentUri: string; relName: string }>; -} - -export function translateForQuery( - event: RealtimeEvent, - sub: SubscriberView -): TranslatedEnvelope[] | null { - const spec = sub.querySpec; - if (!spec) return null; - if (event.kind !== "record.created" && event.kind !== "record.deleted") return []; - if (event.payload.space !== spec.spaceUri) return []; - - const primaryUri = `at://${event.payload.did}/${event.payload.collection}/${event.payload.rkey}`; - - if (event.payload.collection === spec.collection) { - if (event.kind === "record.created") { - return [ - { - kind: "record.created", - data: { - record: { - uri: primaryUri, - did: event.payload.did, - rkey: event.payload.rkey, - collection: event.payload.collection, - cid: event.payload.cid, - record: event.payload.record, - time_us: event.ts * 1000, - indexed_at: event.ts, - space: spec.spaceUri - } - } - } - ]; - } - return [ - { - kind: "record.deleted", - data: { - uri: primaryUri, - did: event.payload.did, - rkey: event.payload.rkey - } - } - ]; - } - - if (!spec.hydrate) return []; - for (const [relName, rel] of Object.entries(spec.hydrate)) { - if (rel.childCollection !== event.payload.collection) continue; - if (event.kind === "record.created") { - const parentUri = getNestedValue( - event.payload.record as Record<string, unknown>, - rel.matchField - ); - if (typeof parentUri !== "string") continue; - if (!hasParent(sub.parentUris, parentUri)) continue; - return [ - { - kind: "hydration.added", - data: { - parentUri, - relation: relName, - child: { - uri: primaryUri, - did: event.payload.did, - rkey: event.payload.rkey, - collection: event.payload.collection, - cid: event.payload.cid, - record: event.payload.record, - space: spec.spaceUri - } - } - } - ]; - } - const info = getChildInfo(sub.childToParent, event.payload.rkey); - if (!info || info.relName !== relName) continue; - return [ - { - kind: "hydration.removed", - data: { - parentUri: info.parentUri, - relation: relName, - childRkey: event.payload.rkey, - childDid: event.payload.did - } - } - ]; - } - return []; -} - -export function applyEnvelopesToSubscriber( - subscriber: SubscriberView, - envs: TranslatedEnvelope[] -): void { - for (const msg of envs) { - if (msg.kind === "record.created") { - ensureParentSet(subscriber).add(msg.data.record.uri); - } else if (msg.kind === "record.deleted") { - const set = subscriber.parentUris; - if (set instanceof Set) set.delete(msg.data.uri); - else if (Array.isArray(set)) { - const idx = set.indexOf(msg.data.uri); - if (idx >= 0) set.splice(idx, 1); - } - } else if (msg.kind === "hydration.added") { - ensureChildMap(subscriber).set(msg.data.child.rkey, { - parentUri: msg.data.parentUri, - relName: msg.data.relation - }); - } else if (msg.kind === "hydration.removed") { - const map = subscriber.childToParent; - if (map instanceof Map) map.delete(msg.data.childRkey); - else if (map && typeof map === "object") { - delete (map as Record<string, unknown>)[msg.data.childRkey]; - } - } - } -} - -function ensureParentSet(sub: SubscriberView): Set<string> { - if (sub.parentUris instanceof Set) return sub.parentUris; - const set = new Set<string>(sub.parentUris ?? []); - sub.parentUris = set; - return set; -} - -function ensureChildMap( - sub: SubscriberView -): Map<string, { parentUri: string; relName: string }> { - if (sub.childToParent instanceof Map) return sub.childToParent; - const map = new Map<string, { parentUri: string; relName: string }>(); - if (sub.childToParent && typeof sub.childToParent === "object") { - for (const [k, v] of Object.entries(sub.childToParent)) map.set(k, v); - } - sub.childToParent = map; - return map; -} - -function hasParent( - parents: Set<string> | string[] | undefined, - uri: string -): boolean { - if (!parents) return false; - if (parents instanceof Set) return parents.has(uri); - return parents.includes(uri); -} - -function getChildInfo( - map: - | Map<string, { parentUri: string; relName: string }> - | Record<string, { parentUri: string; relName: string }> - | undefined, - rkey: string -): { parentUri: string; relName: string } | undefined { - if (!map) return undefined; - if (map instanceof Map) return map.get(rkey); - return map[rkey]; -} - -function getNestedValue( - obj: Record<string, unknown>, - path: string -): unknown { - let cur: unknown = obj; - for (const key of path.split(".")) { - if (cur == null || typeof cur !== "object") return undefined; - cur = (cur as Record<string, unknown>)[key]; - } - return cur; -} diff --git a/packages/contrail-base/src/realtime/sse.ts b/packages/contrail-base/src/realtime/sse.ts deleted file mode 100644 index 65d89c1..0000000 --- a/packages/contrail-base/src/realtime/sse.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** Server-Sent Events transport. - * - * Wraps an AsyncIterable<RealtimeEvent> as a streaming Response. The caller - * (the router) has already done auth and has an AbortSignal it can use to - * tear the stream down (e.g. on `member.removed` for the subscriber's DID). */ - -import type { RealtimeEvent } from "./types"; -import { DEFAULT_KEEPALIVE_MS } from "./types"; - -export interface SseOptions { - keepaliveMs?: number; - /** Called before the stream closes. Useful for cleanup that the caller - * can't do via the signal (e.g. removing a subscriber from a set). */ - onClose?: () => void; -} - -export function sseResponse( - iter: AsyncIterable<RealtimeEvent>, - signal: AbortSignal, - opts: SseOptions = {} -): Response { - const keepaliveMs = opts.keepaliveMs ?? DEFAULT_KEEPALIVE_MS; - const encoder = new TextEncoder(); - - const stream = new ReadableStream<Uint8Array>({ - start(controller) { - let closed = false; - let keepalive: ReturnType<typeof setInterval> | null = null; - - const close = () => { - if (closed) return; - closed = true; - if (keepalive) clearInterval(keepalive); - try { - controller.close(); - } catch { - /* already closed */ - } - opts.onClose?.(); - }; - - signal.addEventListener("abort", close, { once: true }); - - keepalive = setInterval(() => { - if (closed) return; - try { - controller.enqueue(encoder.encode(`: keepalive\n\n`)); - } catch { - close(); - } - }, keepaliveMs); - - (async () => { - // Opening comment — helps some clients / proxies initialize promptly. - controller.enqueue(encoder.encode(`: open\n\n`)); - try { - for await (const event of iter) { - if (closed) break; - controller.enqueue(encoder.encode(frameEvent(event))); - } - } catch (err) { - if (!closed) { - try { - controller.enqueue( - encoder.encode( - `event: error\ndata: ${JSON.stringify({ - message: err instanceof Error ? err.message : String(err), - })}\n\n` - ) - ); - } catch { - /* stream already torn down */ - } - } - } finally { - close(); - } - })(); - }, - cancel() { - opts.onClose?.(); - }, - }); - - return new Response(stream, { - status: 200, - headers: { - "content-type": "text/event-stream", - "cache-control": "no-cache, no-transform", - connection: "keep-alive", - "x-accel-buffering": "no", - }, - }); -} - -function frameEvent(event: RealtimeEvent): string { - return `event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`; -} diff --git a/packages/contrail-base/src/realtime/ticket.ts b/packages/contrail-base/src/realtime/ticket.ts deleted file mode 100644 index 1c6e3de..0000000 --- a/packages/contrail-base/src/realtime/ticket.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** Subscription tickets — HMAC-signed short-lived `{topics, did, exp}` blobs. - * - * Wire format: `<payload>.<sig>` where - * payload = base64url(JSON({ topics, did, exp, iat })) - * sig = base64url(HMAC-SHA256(key, payload)) - * - * Tickets are integrity-only (not encrypted). Browsers use them because - * EventSource / WebSocket can't send Authorization headers; server-side - * consumers skip the ticket dance and send their JWT directly. */ - -export interface TicketPayload { - /** Concrete delivery topics this ticket authorizes. `community:<did>` is - * expanded to the caller's visible spaces before signing — a ticket never - * carries a community alias. */ - topics: string[]; - did: string; - /** Unix ms. */ - exp: number; - /** Unix ms — useful for debugging; ignored on verify. */ - iat: number; - /** Optional: query-scoped watchRecords spec this ticket authorizes. Present - * when the ticket was minted from a watchRecords handshake. The server - * trusts the signed spec on upgrade and forwards it to the DO. */ - querySpec?: TicketQuerySpec; -} - -export interface TicketQuerySpec { - collection: string; - /** Exactly one of `spaceUri` or `actor` is set. `spaceUri` = per-space - * watch; `actor` = cross-space watch for records authored by this DID - * (the ticket's `topics` list carries the expanded delivery topics). */ - spaceUri?: string; - actor?: string; - hydrate?: Record<string, { childCollection: string; matchField: string }>; -} - -function normalizeSecret(secret: Uint8Array | string): Uint8Array { - if (typeof secret !== "string") { - if (secret.length !== 32) { - throw new Error(`realtime ticketSecret must be 32 bytes, got ${secret.length}`); - } - return secret; - } - // 64 hex chars would also round-trip as base64 (to 48 bytes). Prefer hex - // when the input matches the hex alphabet exactly; fall back to base64. - const hex = tryHex(secret); - if (hex && hex.length === 32) return hex; - const b64 = tryBase64(secret); - if (b64 && b64.length === 32) return b64; - if (hex || b64) { - const got = (hex ?? b64)!.length; - throw new Error(`realtime ticketSecret must decode to 32 bytes, got ${got}`); - } - throw new Error("realtime ticketSecret must be a 32-byte Uint8Array or base64/hex string"); -} - -function tryBase64(s: string): Uint8Array | null { - try { - const normal = s.replace(/-/g, "+").replace(/_/g, "/"); - const padded = normal + "=".repeat((4 - (normal.length % 4)) % 4); - if (!/^[A-Za-z0-9+/]*=*$/.test(padded)) return null; - const bin = atob(padded); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; - } catch { - return null; - } -} - -function tryHex(s: string): Uint8Array | null { - if (!/^[0-9a-fA-F]+$/.test(s) || s.length % 2 !== 0) return null; - const out = new Uint8Array(s.length / 2); - for (let i = 0; i < out.length; i++) { - out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); - } - return out; -} - -function b64urlFromBytes(bytes: Uint8Array): string { - let bin = ""; - for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!); - return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -function b64urlToBytes(s: string): Uint8Array { - const normal = s.replace(/-/g, "+").replace(/_/g, "/"); - const padded = normal + "=".repeat((4 - (normal.length % 4)) % 4); - const bin = atob(padded); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -function b64urlFromString(s: string): string { - return b64urlFromBytes(new TextEncoder().encode(s)); -} - -function stringFromB64url(s: string): string { - return new TextDecoder().decode(b64urlToBytes(s)); -} - -function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!; - return diff === 0; -} - -export class TicketSigner { - private readonly keyPromise: Promise<CryptoKey>; - - constructor(secret: Uint8Array | string) { - const raw = normalizeSecret(secret); - this.keyPromise = crypto.subtle.importKey( - "raw", - raw as BufferSource, - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign", "verify"] - ); - } - - async sign(input: { - topics: string[]; - did: string; - ttlMs: number; - querySpec?: TicketQuerySpec; - }): Promise<string> { - const now = Date.now(); - const payload: TicketPayload = { - topics: input.topics, - did: input.did, - exp: now + input.ttlMs, - iat: now, - ...(input.querySpec ? { querySpec: input.querySpec } : {}), - }; - const payloadPart = b64urlFromString(JSON.stringify(payload)); - const sig = await crypto.subtle.sign( - "HMAC", - await this.keyPromise, - new TextEncoder().encode(payloadPart) as BufferSource - ); - const sigPart = b64urlFromBytes(new Uint8Array(sig)); - return `${payloadPart}.${sigPart}`; - } - - /** Returns the decoded payload if the ticket is valid + unexpired, else null. */ - async verify(ticket: string): Promise<TicketPayload | null> { - const dot = ticket.indexOf("."); - if (dot < 0) return null; - const payloadPart = ticket.slice(0, dot); - const sigPart = ticket.slice(dot + 1); - let expectedSig: Uint8Array; - try { - expectedSig = b64urlToBytes(sigPart); - } catch { - return null; - } - const computedRaw = await crypto.subtle.sign( - "HMAC", - await this.keyPromise, - new TextEncoder().encode(payloadPart) as BufferSource - ); - const computed = new Uint8Array(computedRaw); - if (!constantTimeEq(expectedSig, computed)) return null; - let parsed: TicketPayload; - try { - parsed = JSON.parse(stringFromB64url(payloadPart)); - } catch { - return null; - } - if (!parsed || !Array.isArray(parsed.topics) || typeof parsed.did !== "string") { - return null; - } - if (typeof parsed.exp !== "number" || parsed.exp <= Date.now()) return null; - return parsed; - } -} diff --git a/packages/contrail-base/src/realtime/types.ts b/packages/contrail-base/src/realtime/types.ts deleted file mode 100644 index 05a246e..0000000 --- a/packages/contrail-base/src/realtime/types.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** Realtime module — canonical types + interfaces. See docs/realtime.md. */ - -/** Discriminated union of every event kind that flows through the PubSub. - * - * `record.created` carries the full record body so a subscriber can apply an - * insert or upsert without a follow-up `getRecord` call. Writing a new record - * to the same `(did, collection, rkey)` publishes another `record.created` — - * treat it as upsert. - * - * **Payload shape mirrors `listRecords` output** (`uri`, `did`, `space?`, - * `time_us`), so a subscriber can render a live row the same way it renders - * a fetched row. - * - * **Publisher/topic matrix (intentional trust split):** - * - `collection:<nsid>` and `actor:<did>` carry *public* record events only - * (from jetstream ingestion) — no `space`. - * - `space:<uri>` and `community:<did>` carry *space* events — `space` is - * always set. Never cross-published to public topics (privacy). */ -export type RealtimeEvent = - | { - topic: string; - kind: "record.created"; - payload: { - uri: string; - did: string; - collection: string; - rkey: string; - cid: string | null; - record: Record<string, unknown>; - time_us: number; - /** Present only for space records; absent for public records. */ - space?: string; - }; - ts: number; - } - | { - topic: string; - kind: "record.deleted"; - payload: { - uri: string; - did: string; - collection: string; - rkey: string; - /** Present only for space records; absent for public records. */ - space?: string; - }; - ts: number; - } - | { - topic: string; - kind: "member.added"; - payload: { space: string; did: string }; - ts: number; - } - | { - topic: string; - kind: "member.removed"; - payload: { space: string; did: string }; - ts: number; - }; - -export type RealtimeEventKind = RealtimeEvent["kind"]; - -/** Core pubsub abstraction. Implementations: InMemoryPubSub, DurableObjectPubSub. */ -export interface PubSub { - publish(event: RealtimeEvent): Promise<void>; - /** Stream events on the topic until the signal aborts (or the iterator is - * returned/broken out of). Implementations use a bounded per-subscriber - * queue with drop-oldest semantics — a slow subscriber can't stall publishers. */ - subscribe(topic: string, signal?: AbortSignal): AsyncIterable<RealtimeEvent>; -} - -// ---- Canonical topic strings ----------------------------------------------- -// `community:<did>` is an alias resolved at ticket-mint time to the concrete -// set of `space:<uri>` topics the caller can see; it is never a real delivery -// topic. The other three are real. - -export function spaceTopic(uri: string): string { - return `space:${uri}`; -} - -export function communityTopic(did: string): string { - return `community:${did}`; -} - -export function collectionTopic(nsid: string): string { - return `collection:${nsid}`; -} - -export function actorTopic(did: string): string { - return `actor:${did}`; -} - -export function isCommunityTopic(topic: string): boolean { - return topic.startsWith("community:"); -} - -export function parseCommunityTopic(topic: string): string | null { - return isCommunityTopic(topic) ? topic.slice("community:".length) : null; -} - -export function parseSpaceTopic(topic: string): string | null { - return topic.startsWith("space:") ? topic.slice("space:".length) : null; -} - -// ---- Config ----------------------------------------------------------------- - -export interface RealtimeConfig { - /** Backing pubsub. Default: new InMemoryPubSub() (single-process only). On - * Workers, pass `new DurableObjectPubSub(env.REALTIME)`. */ - pubsub?: PubSub; - /** HMAC secret used to sign subscription tickets. 32 bytes. Accepts raw - * Uint8Array or base64 / hex string. Envelope-encrypts nothing — tickets - * are integrity-only, not confidential. */ - ticketSecret: Uint8Array | string; - /** Ticket lifetime in ms. Default 120_000 (2 minutes). */ - ticketTtlMs?: number; - /** SSE/WS keepalive interval in ms. Default 15_000. */ - keepaliveMs?: number; - /** Per-subscriber queue bound. Default 1024. Events beyond this are dropped - * oldest-first and the subscriber receives a `lag` signal (out of band). */ - queueBound?: number; -} - -export const DEFAULT_TICKET_TTL_MS = 120_000; -export const DEFAULT_KEEPALIVE_MS = 15_000; -export const DEFAULT_QUEUE_BOUND = 1024; diff --git a/packages/contrail-base/src/realtime/websocket.ts b/packages/contrail-base/src/realtime/websocket.ts deleted file mode 100644 index 833483d..0000000 --- a/packages/contrail-base/src/realtime/websocket.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** WebSocket transport. - * - * Accepts a new WebSocket connection (either via `WebSocketPair` on Workers - * or a platform-provided server-side socket) and pumps events to it from an - * AsyncIterable<RealtimeEvent>. Messages are UTF-8 JSON, one event per frame. - * - * Close codes (subset, RFC 6455 + app-custom): - * - 4001: server error pumping - * - 4003: membership revoked - * - 4008: ticket/auth invalid (used by the router, not here) - */ - -import type { RealtimeEvent } from "./types"; -import { DEFAULT_KEEPALIVE_MS } from "./types"; - -export interface WebSocketLike { - send(data: string): void; - close(code?: number, reason?: string): void; - addEventListener(type: "message" | "close" | "error", listener: (ev: any) => void): void; -} - -export interface WebSocketPumpOptions { - keepaliveMs?: number; - onClose?: () => void; -} - -/** Pump events from `iter` to `ws` until the signal aborts or the iter ends. - * Caller is responsible for having already accept()ed the socket. */ -export async function pumpWebSocket( - ws: WebSocketLike, - iter: AsyncIterable<RealtimeEvent>, - signal: AbortSignal, - opts: WebSocketPumpOptions = {} -): Promise<void> { - const keepaliveMs = opts.keepaliveMs ?? DEFAULT_KEEPALIVE_MS; - let closed = false; - - const close = (code?: number, reason?: string) => { - if (closed) return; - closed = true; - try { - ws.close(code, reason); - } catch { - /* already closed */ - } - opts.onClose?.(); - }; - - ws.addEventListener("close", () => { - closed = true; - opts.onClose?.(); - }); - ws.addEventListener("error", () => { - closed = true; - opts.onClose?.(); - }); - signal.addEventListener("abort", () => close(1000, "aborted"), { once: true }); - - const keepalive = setInterval(() => { - if (closed) return; - try { - ws.send(JSON.stringify({ kind: "$keepalive" })); - } catch { - close(); - } - }, keepaliveMs); - - try { - for await (const event of iter) { - if (closed) break; - try { - ws.send(JSON.stringify(event)); - } catch { - close(4001, "send-failed"); - break; - } - } - } catch { - close(4001, "pump-error"); - } finally { - clearInterval(keepalive); - close(); - } -} diff --git a/packages/contrail-base/src/sinks/types.ts b/packages/contrail-base/src/sinks/types.ts deleted file mode 100644 index a12ccb3..0000000 --- a/packages/contrail-base/src/sinks/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** Sinks — write-only, post-commit observers of applied records. - * - * A sink builds *derived state* from the records contrail ingests: a search - * index, an audit log, a webhook fan-out. It is NOT a realtime subscriber — - * it never serves reads, and unlike `realtime.pubsub` (a lossy, drop-oldest - * delivery channel for live UI feeds) it must see every applied record, - * including during backfill. Contrail invokes each configured sink after every - * `applyEvents()` commit, on BOTH the live and backfill paths, and isolates - * failures: a throwing sink is logged and never blocks ingestion. - * - * Scope — public records only. The fan-out lives in `applyEvents`, so sinks - * see exactly what that path carries: public records. Space-scoped (private) - * records publish through the separate publishing-adapter and never reach - * `applyEvents`, so a sink cannot accidentally observe them. */ - -export interface SinkContext { - /** `"live"` for jetstream / persistent ingest, `"backfill"` for replay or a - * rebuild-after-wipe. A sink can buffer and bulk-flush differently during a - * large backfill (sinks are expected to batch). */ - phase: "live" | "backfill"; -} - -/** One event per applied record — deduplicated. Unlike the realtime - * `RealtimeEvent`, a record is NOT split across `collection:` and `actor:` - * topics (that split is a delivery concern), and there are no `member.*` - * kinds. `created` covers both create and update — i.e. an upsert, matching - * how the realtime path already collapses them. `deleted` carries identity - * only. */ -export type RecordEvent = - | { - kind: "created"; - uri: string; - did: string; - collection: string; - rkey: string; - cid: string | null; - record: Record<string, unknown>; - time_us: number; - } - | { - kind: "deleted"; - uri: string; - did: string; - collection: string; - rkey: string; - }; - -/** A write-only, post-commit observer of applied records. */ -export interface Sink { - /** Called once per `applyEvents()` batch, after the DB commit. Receives the - * deduplicated records from that batch and the ingest `phase`. May be async; - * contrail awaits it. A thrown error is caught, logged via the configured - * logger, and never propagated — ingestion continues. */ - onRecords(events: RecordEvent[], ctx: SinkContext): Promise<void> | void; -} diff --git a/packages/contrail-base/src/spaces/acl.ts b/packages/contrail-base/src/spaces/acl.ts deleted file mode 100644 index 6e0da0a..0000000 --- a/packages/contrail-base/src/spaces/acl.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { AppPolicy, SpaceMemberRow, SpaceRow } from "./types"; - -export type AclOp = "read" | "write" | "delete"; - -export interface AclInput { - op: AclOp; - space: SpaceRow; - callerDid: string; - /** Membership row for the caller (or null). Owner does not require a row. */ - member: SpaceMemberRow | null; - /** OAuth client_id of the app calling on caller's behalf, for app policy checks. */ - clientId?: string; - /** For per-record ops (get/delete), the record's author DID. */ - targetAuthorDid?: string; -} - -export type AclResult = - | { allow: true } - | { allow: false; reason: AclDenyReason }; - -export type AclDenyReason = - | "not-member" - | "not-own-record" - | "app-not-allowed" - | "unknown-op"; - -/** Check whether the caller's app is permitted to act in this space. */ -export function checkAppPolicy( - appPolicy: AppPolicy | null, - clientId: string | undefined -): boolean { - if (!appPolicy) return true; // no policy = allow-all - const listed = clientId ? appPolicy.apps.includes(clientId) : false; - if (appPolicy.mode === "allow") return !listed; // apps[] is a denylist - return listed; // mode === "deny": apps[] is an allowlist -} - -const isOwner = (space: SpaceRow, did: string) => space.ownerDid === did; -const hasMember = (space: SpaceRow, member: SpaceMemberRow | null, did: string) => - isOwner(space, did) || member != null; - -/** Space-level access check. - * Membership = access. Any member (including owner) can read and write. - * Delete is scoped to the caller's own records — owners don't get a bypass. - * A random member can't nuke other people's records, and neither can the - * owner. To remove a non-author record, delete the whole space. */ -export function checkAccess(input: AclInput): AclResult { - if (!checkAppPolicy(input.space.appPolicy, input.clientId)) { - return { allow: false, reason: "app-not-allowed" }; - } - - if (input.op === "read" || input.op === "write") { - return hasMember(input.space, input.member, input.callerDid) - ? { allow: true } - : { allow: false, reason: "not-member" }; - } - - if (input.op === "delete") { - if (!hasMember(input.space, input.member, input.callerDid)) { - return { allow: false, reason: "not-member" }; - } - if (input.targetAuthorDid && input.targetAuthorDid !== input.callerDid) { - return { allow: false, reason: "not-own-record" }; - } - return { allow: true }; - } - - return { allow: false, reason: "unknown-op" }; -} diff --git a/packages/contrail-base/src/spaces/auth.ts b/packages/contrail-base/src/spaces/auth.ts deleted file mode 100644 index 4c255f6..0000000 --- a/packages/contrail-base/src/spaces/auth.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { Context, MiddlewareHandler } from "hono"; -import { ServiceJwtVerifier } from "@atcute/xrpc-server/auth"; -import { - CompositeDidDocumentResolver, - PlcDidDocumentResolver, - WebDidDocumentResolver, - type DidDocumentResolver, -} from "@atcute/identity-resolver"; -import type { Did, Nsid } from "@atcute/lexicons"; -import type { AuthorityConfig } from "./types"; -import { readInProcess } from "./in-process"; - -export { ServiceJwtVerifier }; - -/** Build a ServiceJwtVerifier from an AuthorityConfig, using (in precedence - * order) the authority-specific resolver, then the deployment-wide - * `networkOverrides.resolver`, then a default PLC+Web composite. The verifier - * checks that incoming JWTs target this authority's serviceDid (aud claim). - * - * `networkOverrides` is optional and is the same shape carried on - * `ContrailConfig.networkOverrides` — callers with a ContrailConfig in scope - * should pass `config.networkOverrides` so private-network deployments share - * one resolver across both identity resolution and service-auth verification. */ -export function buildVerifier( - authority: AuthorityConfig, - networkOverrides?: { resolver?: DidDocumentResolver }, -): ServiceJwtVerifier { - const resolver = - authority.resolver ?? - networkOverrides?.resolver ?? - new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver(), - }, - }); - return new ServiceJwtVerifier({ - serviceDid: authority.serviceDid as Did, - resolver, - }); -} - -export interface ServiceAuth { - issuer: string; - audience: string; - lxm: string | undefined; - /** OAuth client_id of the caller, if the JWT carries one. */ - clientId?: string; -} - -export interface ServiceAuthOptions { - serviceDid: Did; - resolver: DidDocumentResolver; -} - -/** Hono middleware that authenticates XRPC requests. Order of precedence: - * 1. In-process marker (same-module calls; see `core/spaces/in-process.ts`) - * 2. Authorization: Bearer <JWT> as an atproto service-auth token - * - * On success, attaches the claims to `c.var.serviceAuth`. Expected Nsid is - * taken from the route pattern (last segment after `/xrpc/`). */ -export function createServiceAuthMiddleware( - verifier: ServiceJwtVerifier -): MiddlewareHandler { - return async (c, next) => { - const lxm = extractLxmFromPath(c); - - const inProcess = readInProcess(c.req.raw); - if (inProcess) { - c.set("serviceAuth", { - issuer: inProcess.did, - audience: "", - lxm: lxm ?? undefined, - } satisfies ServiceAuth); - await next(); - return; - } - - const header = c.req.header("Authorization"); - if (!header || !header.startsWith("Bearer ")) { - return c.json({ error: "AuthRequired", message: "Missing bearer token" }, 401); - } - const token = header.slice(7).trim(); - - const result = await verifier.verify(token, { lxm }); - if (!result.ok) { - const err = result.error as { error?: string; description?: string } | undefined; - return c.json( - { - error: "AuthRequired", - message: err?.description ?? err?.error ?? String(result.error), - }, - 401, - ); - } - - c.set("serviceAuth", { - issuer: result.value.issuer, - audience: result.value.audience, - lxm: result.value.lxm, - } satisfies ServiceAuth); - - await next(); - }; -} - -function extractLxmFromPath(c: Context): Nsid | null { - const path = new URL(c.req.url).pathname; - const match = path.match(/\/xrpc\/([a-zA-Z0-9.-]+)/); - return (match?.[1] as Nsid) ?? null; -} - -/** Read the service auth claims set by the middleware. Throws if unset. */ -export function requireServiceAuth(c: Context): ServiceAuth { - const auth = c.get("serviceAuth") as ServiceAuth | undefined; - if (!auth) throw new Error("service auth missing; middleware not attached"); - return auth; -} - -/** Out-of-band auth check for handlers that don't always require auth. - * Returns claims on success, or null if no valid credentials are present. - * Order of precedence: in-process marker → service-auth JWT. */ -export async function verifyServiceAuthRequest( - verifier: ServiceJwtVerifier, - request: Request, - lxm?: Nsid | null -): Promise<ServiceAuth | null> { - const inProcess = readInProcess(request); - if (inProcess) { - return { - issuer: inProcess.did, - audience: "", - lxm: lxm ?? undefined, - }; - } - - const header = request.headers.get("Authorization"); - if (!header || !header.startsWith("Bearer ")) return null; - const token = header.slice(7).trim(); - const result = await verifier.verify(token, { lxm: lxm ?? null }); - if (!result.ok) return null; - return { - issuer: result.value.issuer, - audience: result.value.audience, - lxm: result.value.lxm, - }; -} - -/** Pull a space credential off the request — `X-Space-Credential: <jwt>` - * header. Returns the raw token or null. */ -export function extractSpaceCredential(request: Request): string | null { - const header = request.headers.get("X-Space-Credential"); - return header ? header.trim() : null; -} - -/** Pull a read-grant invite token off the request — query string `?inviteToken=` - * or `Authorization: Bearer atmo-invite:<token>`. Returns the raw token (not - * hashed) or null. Routes hash + look up via the adapter. */ -export function extractInviteToken(request: Request): string | null { - const url = new URL(request.url); - const q = url.searchParams.get("inviteToken"); - if (q) return q.trim(); - const header = request.headers.get("Authorization"); - if (header?.startsWith("Bearer atmo-invite:")) { - return header.slice("Bearer atmo-invite:".length).trim(); - } - return null; -} - -/** Validate a read-grant invite token against a target spaceUri. Returns true - * if the token exists, scopes to this space, has a kind that grants read - * (`read` or `read-join`), and is not expired/revoked. */ -export async function checkInviteReadGrant( - adapter: { getInvite(tokenHash: string): Promise<{ spaceUri: string; kind: string; revokedAt: number | null; expiresAt: number | null } | null> }, - rawToken: string, - spaceUri: string, - hashFn: (token: string) => Promise<string> -): Promise<boolean> { - const tokenHash = await hashFn(rawToken); - const invite = await adapter.getInvite(tokenHash); - if (!invite) return false; - if (invite.spaceUri !== spaceUri) return false; - if (invite.kind !== "read" && invite.kind !== "read-join") return false; - if (invite.revokedAt != null) return false; - if (invite.expiresAt != null && invite.expiresAt <= Date.now()) return false; - return true; -} diff --git a/packages/contrail-base/src/spaces/binding.ts b/packages/contrail-base/src/spaces/binding.ts deleted file mode 100644 index 2457f4d..0000000 --- a/packages/contrail-base/src/spaces/binding.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** Binding resolution: given a space URI, which DID is authorized to sign - * credentials for it, and where do we find that DID's verification key? - * - * Two layers of pluggable resolvers compose into a credential verifier: - * - * BindingResolver — `ats://<owner>/<type>/<key>` → authority DID - * KeyResolver — (DID, kid) → JsonWebKey - * - * The BindingResolver is what makes user-owned-DID-with-PDS-record work: - * given a space URI, we resolve the owner's PDS, fetch the declaration - * record, and read its `authority` field. For provisioned (no-PDS) DIDs we - * fall back to the owner DID's `#atproto_space_authority` service entry. - * And finally for the trivial case (HappyView-style "owner self-issues"), - * we return the owner DID itself. - * - * See conversation history (phase 4 design) for the rationale on why these - * three sources, in this order. */ - -import type { DidDocumentResolver } from "@atcute/identity-resolver"; -import type { Did } from "@atcute/lexicons"; -import { parseSpaceUri } from "./uri"; -import type { RecordHost } from "./types"; - -export interface BindingResolver { - /** Resolve the DID authorized to sign credentials for this space. Returns - * null if no binding could be found via this resolver — the composite - * walks down its list looking for a non-null. */ - resolveAuthority(spaceUri: string): Promise<string | null>; -} - -export interface KeyResolver { - /** Resolve `did`'s verification key for credential signing. `kid` is the - * full header `kid` value (e.g. "did:web:x.com#atproto_space_authority"), - * used to disambiguate when a DID doc lists multiple methods. */ - resolveKey(did: string, kid: string | undefined): Promise<JsonWebKey | null>; -} - -// --------------------------------------------------------------------------- -// Binding resolvers -// --------------------------------------------------------------------------- - -/** Always returns the configured authority DID. Used in-process when the - * authority and record host run in one deployment — no need to walk DID - * docs or PDSes; we know what we are. */ -export function createLocalBindingResolver(args: { - authorityDid: string; -}): BindingResolver { - const { authorityDid } = args; - return { - async resolveAuthority() { - return authorityDid; - }, - }; -} - -/** Reads the record host's local enrollment table. This is the *canonical* - * binding source on a record host: the host owner explicitly consented to a - * given authority for a given space (via the `recordHost.enroll` endpoint - * or auto-enrollment from the authority's createSpace). PDS-record / - * DID-doc resolvers are out-of-band discovery aids; the enrollment is what - * actually gates whether records get stored here. */ -export function createEnrollmentBindingResolver(args: { - recordHost: RecordHost; -}): BindingResolver { - return { - async resolveAuthority(spaceUri) { - const e = await args.recordHost.getEnrollment(spaceUri); - return e?.authorityDid ?? null; - }, - }; -} - -/** Returns the space owner DID as the authority. This is the implicit - * fallback ("HappyView path") — when no PDS record and no DID-doc service - * entry declare an issuer, the owner is taken to be its own. Whether the - * resulting credential actually verifies depends on whether the owner's DID - * doc publishes a usable signing key. */ -export function createOwnerSelfBindingResolver(): BindingResolver { - return { - async resolveAuthority(spaceUri) { - const parts = parseSpaceUri(spaceUri); - return parts ? parts.ownerDid : null; - }, - }; -} - -/** Walks the resolver list in order, returns the first non-null. Use this - * to compose [pdsRecord, didDocService, ownerSelf] etc. */ -export function createCompositeBindingResolver( - resolvers: BindingResolver[] -): BindingResolver { - return { - async resolveAuthority(spaceUri) { - for (const r of resolvers) { - const did = await r.resolveAuthority(spaceUri); - if (did) return did; - } - return null; - }, - }; -} - -/** Reads a space-declaration record from the owner's PDS at - * `at://<owner>/<type>/<key>` and returns its `authority` field if present. - * - * This is the user-owned-DID path: the user writes a record to their PDS - * authorizing some service as the space's authority, no DID-doc edits - * required. */ -export function createPdsBindingResolver(args: { - /** DID resolver, used to look up the owner's PDS endpoint. */ - resolver: DidDocumentResolver; - /** Fetch impl. Defaults to `globalThis.fetch`. */ - fetch?: typeof fetch; - /** Per-request timeout in ms. Defaults to 5000. */ - timeoutMs?: number; -}): BindingResolver { - const fetchImpl = args.fetch ?? globalThis.fetch; - const timeoutMs = args.timeoutMs ?? 5000; - - return { - async resolveAuthority(spaceUri) { - const parts = parseSpaceUri(spaceUri); - if (!parts) return null; - const pds = await pdsEndpointFor(args.resolver, parts.ownerDid); - if (!pds) return null; - - const url = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`); - url.searchParams.set("repo", parts.ownerDid); - url.searchParams.set("collection", parts.type); - url.searchParams.set("rkey", parts.key); - - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), timeoutMs); - let res: Response; - try { - res = await fetchImpl(url.toString(), { signal: ctrl.signal }); - } catch { - return null; - } finally { - clearTimeout(timer); - } - if (!res.ok) return null; - const body = (await res.json().catch(() => null)) as - | { value?: { $type?: unknown; authority?: unknown; createdAt?: unknown } } - | null; - const value = body?.value; - if (!value) return null; - if (value.$type !== parts.type) return null; - if (typeof value.createdAt !== "string") return null; - const authority = value.authority; - if (typeof authority !== "string") return null; - if (!/^did:(plc|web):[a-zA-Z0-9._:%-]+(#[a-zA-Z0-9._-]+)?$/.test(authority)) return null; - return authority; - }, - }; -} - -/** Reads `service[id="#atproto_space_authority"].serviceEndpoint` from the - * owner's DID doc. This is the no-PDS path — useful for provisioned space - * DIDs that exist as DID docs only. - * - * Note the service endpoint here is a *DID*, not a URL. The DID names the - * authority; the key resolver's job is to then fetch its verification key. - * For DID docs that declare a URL endpoint, we treat the URL as a - * did:web hint — caller can normalize. */ -export function createDidDocBindingResolver(args: { - resolver: DidDocumentResolver; - /** Service id to look up. Defaults to "#atproto_space_authority". */ - serviceId?: string; -}): BindingResolver { - const serviceId = args.serviceId ?? "#atproto_space_authority"; - return { - async resolveAuthority(spaceUri) { - const parts = parseSpaceUri(spaceUri); - if (!parts) return null; - let doc; - try { - doc = await args.resolver.resolve(parts.ownerDid as Did); - } catch { - return null; - } - const entry = doc.service?.find((s: { id?: string }) => s.id === serviceId); - if (!entry) return null; - const endpoint = (entry as { serviceEndpoint?: unknown }).serviceEndpoint; - if (typeof endpoint !== "string") return null; - // Endpoint may be a DID (preferred) or a URL hint. Only DIDs are - // verifiable downstream; URLs require the caller to map URL → DID. - return endpoint.startsWith("did:") ? endpoint : null; - }, - }; -} - -// --------------------------------------------------------------------------- -// Key resolvers -// --------------------------------------------------------------------------- - -/** Knows the local authority's public key directly. Returns null for any - * other DID — composite with a DID-doc resolver if you also accept - * external authorities. */ -export function createLocalKeyResolver(args: { - authorityDid: string; - publicKey: JsonWebKey; -}): KeyResolver { - return { - async resolveKey(did) { - return did === args.authorityDid ? args.publicKey : null; - }, - }; -} - -/** Resolves a DID, finds the verification method matching `kid`, returns - * its `publicKeyJwk`. */ -export function createDidDocKeyResolver(args: { - resolver: DidDocumentResolver; -}): KeyResolver { - return { - async resolveKey(did, kid) { - let doc; - try { - doc = await args.resolver.resolve(did as Did); - } catch { - return null; - } - const methods = (doc as { verificationMethod?: VerificationMethod[] }).verificationMethod; - if (!methods) return null; - // kid is "<did>#<methodId>" — we match against the method.id which DID - // docs spell as "<did>#<methodId>" too. - const method = kid - ? methods.find((m) => m.id === kid) - : methods[0]; - if (!method?.publicKeyJwk) return null; - return method.publicKeyJwk as JsonWebKey; - }, - }; -} - -/** Walks resolvers in order; returns the first non-null. */ -export function createCompositeKeyResolver( - resolvers: KeyResolver[] -): KeyResolver { - return { - async resolveKey(did, kid) { - for (const r of resolvers) { - const k = await r.resolveKey(did, kid); - if (k) return k; - } - return null; - }, - }; -} - -interface VerificationMethod { - id: string; - type?: string; - controller?: string; - publicKeyJwk?: unknown; - publicKeyMultibase?: string; -} - -// --------------------------------------------------------------------------- -// Internal: PDS endpoint lookup -// --------------------------------------------------------------------------- - -async function pdsEndpointFor( - resolver: DidDocumentResolver, - did: string -): Promise<string | null> { - let doc; - try { - doc = await resolver.resolve(did as Did); - } catch { - return null; - } - const entry = doc.service?.find( - (s: { id?: string }) => s.id === "#atproto_pds" - ); - if (!entry) return null; - const endpoint = (entry as { serviceEndpoint?: unknown }).serviceEndpoint; - return typeof endpoint === "string" ? endpoint : null; -} diff --git a/packages/contrail-base/src/spaces/blob-adapter.ts b/packages/contrail-base/src/spaces/blob-adapter.ts deleted file mode 100644 index 632b566..0000000 --- a/packages/contrail-base/src/spaces/blob-adapter.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Bytes-only storage adapter for space blobs. Metadata (CID, mime, size, - * author, space) lives in the `spaces_blobs` table on the main StorageAdapter; - * this interface only moves bytes in and out of a backend (R2, S3, fs, …). - * - * Keys are opaque strings formed by the router as `blobKey(spaceUri, cid)`. - */ - -export interface BlobUploadMeta { - mimeType: string; - size: number; -} - -export interface BlobAdapter { - put(key: string, bytes: Uint8Array, meta: BlobUploadMeta): Promise<void>; - get(key: string): Promise<Uint8Array | null>; - /** Bulk delete. Adapters that don't support batch can implement serially. */ - delete(keys: string[]): Promise<void>; -} - -/** In-memory adapter. Useful for tests and local development. */ -export class MemoryBlobAdapter implements BlobAdapter { - private readonly store = new Map<string, Uint8Array>(); - - async put(key: string, bytes: Uint8Array): Promise<void> { - this.store.set(key, bytes.slice()); - } - - async get(key: string): Promise<Uint8Array | null> { - const v = this.store.get(key); - return v ? v.slice() : null; - } - - async delete(keys: string[]): Promise<void> { - for (const k of keys) this.store.delete(k); - } - - /** Test helper. */ - size(): number { - return this.store.size; - } -} - -/** Minimal Cloudflare R2 bucket shape — matches @cloudflare/workers-types' R2Bucket - * without forcing a types dependency here. */ -export interface R2BucketLike { - put( - key: string, - value: ArrayBuffer | ArrayBufferView | ReadableStream | Blob, - options?: { httpMetadata?: { contentType?: string }; customMetadata?: Record<string, string> } - ): Promise<unknown>; - get(key: string): Promise<{ arrayBuffer(): Promise<ArrayBuffer> } | null>; - delete(keys: string | string[]): Promise<void>; -} - -/** Cloudflare R2 adapter. Pass the `env.BLOBS` binding from your Worker. */ -export class R2BlobAdapter implements BlobAdapter { - constructor(private readonly bucket: R2BucketLike) {} - - async put(key: string, bytes: Uint8Array, meta: BlobUploadMeta): Promise<void> { - await this.bucket.put(key, bytes, { - httpMetadata: { contentType: meta.mimeType }, - }); - } - - async get(key: string): Promise<Uint8Array | null> { - const obj = await this.bucket.get(key); - if (!obj) return null; - const buf = await obj.arrayBuffer(); - return new Uint8Array(buf); - } - - async delete(keys: string[]): Promise<void> { - if (keys.length === 0) return; - await this.bucket.delete(keys); - } -} - -/** Hash a space URI to a short, filesystem/R2-safe key segment. - * Used as the first segment of a blob key so all blobs for one space - * share a common prefix (enables bulk delete on space deletion). */ -export async function spaceKeyPrefix(spaceUri: string): Promise<string> { - const bytes = new TextEncoder().encode(spaceUri); - const digest = await crypto.subtle.digest("SHA-256", bytes); - const hex = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); - return hex.slice(0, 16); -} - -/** Compose an adapter key from a space URI and CID. - * Shape: `<16-hex-chars-of-sha256(spaceUri)>/<cid>`. */ -export async function blobKey(spaceUri: string, cid: string): Promise<string> { - const prefix = await spaceKeyPrefix(spaceUri); - return `${prefix}/${cid}`; -} diff --git a/packages/contrail-base/src/spaces/credentials.ts b/packages/contrail-base/src/spaces/credentials.ts deleted file mode 100644 index 741b82d..0000000 --- a/packages/contrail-base/src/spaces/credentials.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** Space-credential primitives: ES256 (P-256) JWTs minted by the authority, - * verified by the record host (or any third party that can resolve the - * authority's DID document). - * - * Format is a compact JWS: - * header = { alg: "ES256", typ: "JWT", kid: "<authorityDid>#<keyId>" } - * payload = { iss, sub, space, scope, iat, exp } - * - * - `iss` is the authority DID (the signer; for phase 3 this is the local - * authority's serviceDid; phase 4 adds a binding-resolution layer that - * lets the issuer be a *different* DID from the space owner). - * - `sub` is the caller DID — the credential bearer. - * - `space` is the full `ats://<owner>/<type>/<key>` URI. - * - `scope` is "rw" or "read". - * - * We don't use a JWT library — Web Crypto's subtle covers everything (P-256 - * generate, sign, verify, JWK import/export) and saves a runtime dep. */ - -const ALG = "ES256"; -const TYP = "JWT"; -const DEFAULT_KEY_ID = "atproto_space_authority"; - -export type CredentialScope = "rw" | "read"; - -export interface CredentialClaims { - iss: string; - sub: string; - space: string; - scope: CredentialScope; - iat: number; // seconds since epoch - exp: number; // seconds since epoch -} - -export interface CredentialKeyMaterial { - /** Private key in JWK form. P-256 / ES256. */ - privateKey: JsonWebKey; - /** Public key in JWK form. Must match privateKey. */ - publicKey: JsonWebKey; - /** DID-doc verification method id. The full JWT `kid` becomes - * `<authorityDid>#<keyId>`. Defaults to "atproto_space_authority". */ - keyId?: string; -} - -/** Generate a fresh P-256 keypair as JWKs. Useful for local dev / tests; in - * production the operator generates once and stores out-of-band. */ -export async function generateAuthoritySigningKey(): Promise<CredentialKeyMaterial> { - const pair = (await crypto.subtle.generateKey( - { name: "ECDSA", namedCurve: "P-256" }, - true, - ["sign", "verify"] - )) as CryptoKeyPair; - const privateKey = (await crypto.subtle.exportKey("jwk", pair.privateKey)) as JsonWebKey; - const publicKey = (await crypto.subtle.exportKey("jwk", pair.publicKey)) as JsonWebKey; - return { privateKey, publicKey }; -} - -const enc = new TextEncoder(); -const dec = new TextDecoder(); - -function base64urlEncode(bytes: Uint8Array): string { - let s = btoa(String.fromCharCode(...bytes)); - return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -function base64urlDecode(s: string): Uint8Array { - const padded = s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="); - const bin = atob(padded); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -function jsonEncode(value: unknown): string { - return base64urlEncode(enc.encode(JSON.stringify(value))); -} - -function jsonDecode<T>(seg: string): T { - return JSON.parse(dec.decode(base64urlDecode(seg))) as T; -} - -async function importPrivate(jwk: JsonWebKey): Promise<CryptoKey> { - return crypto.subtle.importKey( - "jwk", - jwk, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["sign"] - ); -} - -async function importPublic(jwk: JsonWebKey): Promise<CryptoKey> { - return crypto.subtle.importKey( - "jwk", - jwk, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["verify"] - ); -} - -/** Sign a credential payload with the authority's private key. - * `iat` and `exp` are filled in by the caller (so tests can mint expired - * tokens deterministically). */ -export async function signCredential( - payload: CredentialClaims, - key: CredentialKeyMaterial -): Promise<string> { - const kid = `${payload.iss}#${key.keyId ?? DEFAULT_KEY_ID}`; - const header = { alg: ALG, typ: TYP, kid }; - const head = jsonEncode(header); - const body = jsonEncode(payload); - const signingInput = `${head}.${body}`; - const privateKey = await importPrivate(key.privateKey); - const sig = await crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - privateKey, - enc.encode(signingInput) - ); - return `${signingInput}.${base64urlEncode(new Uint8Array(sig))}`; -} - -/** Issue a credential using the current wall-clock for iat/exp. */ -export async function issueCredential( - args: Omit<CredentialClaims, "iat" | "exp"> & { ttlMs: number }, - key: CredentialKeyMaterial -): Promise<{ credential: string; expiresAt: number }> { - const now = Math.floor(Date.now() / 1000); - const expSec = now + Math.floor(args.ttlMs / 1000); - const claims: CredentialClaims = { - iss: args.iss, - sub: args.sub, - space: args.space, - scope: args.scope, - iat: now, - exp: expSec, - }; - const credential = await signCredential(claims, key); - return { credential, expiresAt: expSec * 1000 }; -} - -export type VerifyOk = { ok: true; claims: CredentialClaims }; -export type VerifyErr = { - ok: false; - reason: - | "malformed" - | "bad-alg" - | "bad-signature" - | "expired" - | "not-yet-valid" - | "wrong-space" - | "wrong-scope" - | "unknown-issuer"; -}; - -export interface VerifyOptions { - /** Optional: when set, rejects credentials whose `space` claim differs. - * Omit when verifying in middleware where the target space isn't known - * yet — handlers can do the match themselves against the verified - * claims. */ - expectedSpace?: string; - /** Optional: required scope (e.g. "rw" rejects read-only credentials on writes). */ - requiredScope?: CredentialScope; - /** Resolve a verification key for `iss`. If null, verification fails with - * unknown-issuer. */ - resolveKey: (iss: string, kid: string | undefined) => Promise<JsonWebKey | null>; - /** Time provider for tests. Returns ms since epoch. */ - now?: () => number; -} - -export async function verifyCredential( - jwt: string, - opts: VerifyOptions -): Promise<VerifyOk | VerifyErr> { - const parts = jwt.split("."); - if (parts.length !== 3) return { ok: false, reason: "malformed" }; - const [headSeg, bodySeg, sigSeg] = parts as [string, string, string]; - - let header: { alg?: string; typ?: string; kid?: string }; - let claims: CredentialClaims; - try { - header = jsonDecode(headSeg); - claims = jsonDecode(bodySeg); - } catch { - return { ok: false, reason: "malformed" }; - } - if (header.alg !== ALG) return { ok: false, reason: "bad-alg" }; - if (opts.expectedSpace !== undefined && claims.space !== opts.expectedSpace) { - return { ok: false, reason: "wrong-space" }; - } - if (opts.requiredScope === "rw" && claims.scope !== "rw") { - return { ok: false, reason: "wrong-scope" }; - } - - const nowMs = (opts.now ?? Date.now)(); - const nowSec = Math.floor(nowMs / 1000); - if (claims.exp <= nowSec) return { ok: false, reason: "expired" }; - if (claims.iat > nowSec + 60) return { ok: false, reason: "not-yet-valid" }; - - const jwk = await opts.resolveKey(claims.iss, header.kid); - if (!jwk) return { ok: false, reason: "unknown-issuer" }; - - const publicKey = await importPublic(jwk); - const sigBytes = base64urlDecode(sigSeg); - const signingInput = `${headSeg}.${bodySeg}`; - const valid = await crypto.subtle.verify( - { name: "ECDSA", hash: "SHA-256" }, - publicKey, - sigBytes as BufferSource, - enc.encode(signingInput) - ); - if (!valid) return { ok: false, reason: "bad-signature" }; - return { ok: true, claims }; -} - -/** Header reader for handlers that want to peek at `iss` before resolving the - * key (e.g. to short-circuit DID-doc fetches for the local authority). */ -export function decodeUnverifiedClaims(jwt: string): CredentialClaims | null { - const parts = jwt.split("."); - if (parts.length !== 3) return null; - try { - return jsonDecode<CredentialClaims>(parts[1]!); - } catch { - return null; - } -} - -/** Verifier interface consumed by the record host. The record host doesn't - * care HOW credentials get verified — it only cares whether a given JWT is - * valid. Phase 3 ships an in-process verifier that knows the local - * authority's public key; phase 4 adds a binding-resolving verifier that - * consults PDS records / DID docs. */ -export interface CredentialVerifier { - /** Verify a credential's signature, expiry, and `not-before` window. Does - * NOT enforce a space match — handlers do that against the request URI. */ - verify(jwt: string): Promise<VerifyOk | VerifyErr>; -} - -/** In-process verifier for the simple deployment: the authority and record - * host run in one process and the record host has direct access to the - * authority's public key. Rejects any credential whose `iss` isn't the - * configured authority. Phase 4 has a more general - * {@link createBindingCredentialVerifier} that does proper binding lookup. */ -export function createInProcessVerifier(args: { - authorityDid: string; - publicKey: JsonWebKey; -}): CredentialVerifier { - return { - verify(jwt) { - return verifyCredential(jwt, { - resolveKey: async (iss) => (iss === args.authorityDid ? args.publicKey : null), - }); - }, - }; -} - -/** Verifier composed of a {@link BindingResolver} (which DID is authorized - * to issue for this space?) and a {@link KeyResolver} (what's that DID's - * public key?). This is the production-shape verifier — phase 4's main - * contribution. - * - * Verification flow: - * 1. Decode the JWT's claims (no signature check yet). - * 2. Ask the binding resolver: who's authorized for `claims.space`? - * 3. Confirm `claims.iss === authorizedDid`. - * 4. Ask the key resolver for that DID's verification key. - * 5. Verify signature + expiry + scope match. - */ -export function createBindingCredentialVerifier(args: { - bindings: import("./binding").BindingResolver; - keys: import("./binding").KeyResolver; -}): CredentialVerifier { - return { - async verify(jwt) { - const peek = decodeUnverifiedClaims(jwt); - if (!peek) return { ok: false, reason: "malformed" }; - const authorizedDid = await args.bindings.resolveAuthority(peek.space); - if (!authorizedDid) return { ok: false, reason: "unknown-issuer" }; - if (peek.iss !== authorizedDid) return { ok: false, reason: "unknown-issuer" }; - return verifyCredential(jwt, { - resolveKey: (iss, kid) => args.keys.resolveKey(iss, kid), - }); - }, - }; -} diff --git a/packages/contrail-base/src/spaces/in-process.ts b/packages/contrail-base/src/spaces/in-process.ts deleted file mode 100644 index ec1d7a1..0000000 --- a/packages/contrail-base/src/spaces/in-process.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** In-process auth marker. - * - * For same-module callers (e.g. a SvelteKit worker that imports contrail and - * dispatches requests directly to the handler), service-auth JWTs are pure - * overhead: no network boundary is crossed, so there's nothing for the JWT to - * protect against. Instead, the caller tags the `Request` with a principal via - * a module-private WeakMap, and the auth middleware reads it back. - * - * Security note: this is unforgeable from outside the module because - * - WeakMap keys are `Request` object identities, not serialized data; - * - no HTTP request crossing a network boundary can reach into this map; - * - exploiting it requires code execution inside the same isolate, at - * which point auth is already game over. - * - * This is the strongest auth adapter contrail offers — it has no secret to - * leak. */ - -export interface InProcessPrincipal { - did: string; -} - -const PRINCIPALS = new WeakMap<Request, InProcessPrincipal>(); - -/** Tag a Request with an in-process principal. The returned Request is the - * same reference; the return value is for ergonomics. */ -export function markInProcess(req: Request, did: string): Request { - PRINCIPALS.set(req, { did }); - return req; -} - -/** Read the in-process principal for a Request, or null if unmarked. */ -export function readInProcess(req: Request): InProcessPrincipal | null { - return PRINCIPALS.get(req) ?? null; -} diff --git a/packages/contrail-base/src/spaces/manifest.ts b/packages/contrail-base/src/spaces/manifest.ts deleted file mode 100644 index fbc8d31..0000000 --- a/packages/contrail-base/src/spaces/manifest.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** Membership manifest: a short-lived signed list of spaces a caller is a - * member of, issued by an authority. Lets appviews filter unioned queries - * without syncing the full member list — each user carries their own - * bounded slice as they hit the appview. - * - * Same signing infrastructure as space credentials (ES256 JWT), different - * payload + endpoint. */ - -import { - signCredential, - verifyCredential, - decodeUnverifiedClaims, - type CredentialKeyMaterial, -} from "./credentials"; - -const ALG = "ES256"; -const TYP = "JWT"; -const DEFAULT_KEY_ID = "atproto_space_authority"; - -/** Default manifest TTL — same 2h as credentials. */ -export const DEFAULT_MANIFEST_TTL_MS = 2 * 60 * 60 * 1000; - -export interface MembershipManifestClaims { - /** Authority DID that issued (and signed) the manifest. */ - iss: string; - /** User DID this manifest is for. */ - sub: string; - /** Space URIs the user is a member of (according to this authority). */ - spaces: string[]; - /** Seconds since epoch. */ - iat: number; - exp: number; -} - -/** Sign a manifest using the authority's signing key. */ -export async function signMembershipManifest( - payload: MembershipManifestClaims, - key: CredentialKeyMaterial -): Promise<string> { - // signCredential internally builds the JWT given a CredentialClaims-like - // shape. The manifest payload has different fields (`spaces` instead of - // `space`/`scope`) so we hand-roll the JWT here using the same utilities. - const enc = new TextEncoder(); - const kid = `${payload.iss}#${key.keyId ?? DEFAULT_KEY_ID}`; - const header = { alg: ALG, typ: TYP, kid }; - const head = base64urlEncode(enc.encode(JSON.stringify(header))); - const body = base64urlEncode(enc.encode(JSON.stringify(payload))); - const signingInput = `${head}.${body}`; - const privateKey = await crypto.subtle.importKey( - "jwk", - key.privateKey, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["sign"] - ); - const sig = await crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - privateKey, - enc.encode(signingInput) - ); - return `${signingInput}.${base64urlEncode(new Uint8Array(sig))}`; -} - -/** Issue a manifest with current iat/exp. */ -export async function issueMembershipManifest( - args: Omit<MembershipManifestClaims, "iat" | "exp"> & { ttlMs: number }, - key: CredentialKeyMaterial -): Promise<{ manifest: string; expiresAt: number }> { - const now = Math.floor(Date.now() / 1000); - const expSec = now + Math.floor(args.ttlMs / 1000); - const payload: MembershipManifestClaims = { - iss: args.iss, - sub: args.sub, - spaces: args.spaces, - iat: now, - exp: expSec, - }; - const manifest = await signMembershipManifest(payload, key); - return { manifest, expiresAt: expSec * 1000 }; -} - -export type ManifestVerifyOk = { ok: true; claims: MembershipManifestClaims }; -export type ManifestVerifyErr = { - ok: false; - reason: "malformed" | "bad-alg" | "bad-signature" | "expired" | "not-yet-valid" | "unknown-issuer"; -}; - -export interface VerifyManifestOptions { - /** Resolve the issuer's verification key. */ - resolveKey: (iss: string, kid: string | undefined) => Promise<JsonWebKey | null>; - /** Time provider for tests. */ - now?: () => number; -} - -export async function verifyMembershipManifest( - jwt: string, - opts: VerifyManifestOptions -): Promise<ManifestVerifyOk | ManifestVerifyErr> { - const parts = jwt.split("."); - if (parts.length !== 3) return { ok: false, reason: "malformed" }; - const [headSeg, bodySeg, sigSeg] = parts as [string, string, string]; - - let header: { alg?: string; kid?: string }; - let claims: MembershipManifestClaims; - try { - header = JSON.parse(new TextDecoder().decode(base64urlDecode(headSeg))); - claims = JSON.parse(new TextDecoder().decode(base64urlDecode(bodySeg))); - } catch { - return { ok: false, reason: "malformed" }; - } - if (header.alg !== ALG) return { ok: false, reason: "bad-alg" }; - if (!Array.isArray(claims.spaces)) return { ok: false, reason: "malformed" }; - - const nowMs = (opts.now ?? Date.now)(); - const nowSec = Math.floor(nowMs / 1000); - if (claims.exp <= nowSec) return { ok: false, reason: "expired" }; - if (claims.iat > nowSec + 60) return { ok: false, reason: "not-yet-valid" }; - - const jwk = await opts.resolveKey(claims.iss, header.kid); - if (!jwk) return { ok: false, reason: "unknown-issuer" }; - - const publicKey = await crypto.subtle.importKey( - "jwk", - jwk, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["verify"] - ); - const sigBytes = base64urlDecode(sigSeg); - const enc = new TextEncoder(); - const valid = await crypto.subtle.verify( - { name: "ECDSA", hash: "SHA-256" }, - publicKey, - sigBytes as BufferSource, - enc.encode(`${headSeg}.${bodySeg}`) - ); - if (!valid) return { ok: false, reason: "bad-signature" }; - return { ok: true, claims }; -} - -/** Stateful manifest verifier with a TTL'd in-memory cache keyed by JWT. - * Returns the same shape as {@link verifyMembershipManifest}; cache hits skip - * the crypto round-trip but still respect `exp`. Reuse one verifier per - * process — never construct per-request. */ -export interface ManifestVerifier { - verify(jwt: string): Promise<ManifestVerifyOk | ManifestVerifyErr>; -} - -/** Build a manifest verifier with caching. The cache is unbounded for now — - * manifests are short-lived (default 2h) and clients typically refresh them, - * so cardinality is bounded by active-user count. */ -export function createManifestVerifier(opts: VerifyManifestOptions): ManifestVerifier { - const cache = new Map<string, ManifestVerifyOk | ManifestVerifyErr>(); - const now = opts.now ?? Date.now; - return { - async verify(jwt) { - const cached = cache.get(jwt); - if (cached) { - if (cached.ok) { - if (cached.claims.exp * 1000 > now()) return cached; - cache.delete(jwt); - } else { - // Negative cache only for permanent failures (signature, alg); - // expired / not-yet-valid will roll over with the clock. - if (cached.reason === "bad-signature" || cached.reason === "bad-alg" || cached.reason === "malformed") { - return cached; - } - cache.delete(jwt); - } - } - const result = await verifyMembershipManifest(jwt, opts); - cache.set(jwt, result); - return result; - }, - }; -} - -/** Peek at claims without verifying — useful for routing decisions. */ -export function decodeUnverifiedManifest(jwt: string): MembershipManifestClaims | null { - const parts = jwt.split("."); - if (parts.length !== 3) return null; - try { - return JSON.parse(new TextDecoder().decode(base64urlDecode(parts[1]!))) as MembershipManifestClaims; - } catch { - return null; - } -} - -// Local base64url helpers — mirror the credentials module so we don't expose -// these as public utilities. -function base64urlEncode(bytes: Uint8Array): string { - const s = btoa(String.fromCharCode(...bytes)); - return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} -function base64urlDecode(s: string): Uint8Array { - const padded = s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="); - const bin = atob(padded); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} diff --git a/packages/contrail-base/src/spaces/tid.ts b/packages/contrail-base/src/spaces/tid.ts deleted file mode 100644 index c419090..0000000 --- a/packages/contrail-base/src/spaces/tid.ts +++ /dev/null @@ -1,20 +0,0 @@ -const B32_CHARSET = "234567abcdefghijklmnopqrstuvwxyz"; - -let lastTimestamp = 0; -let clockId = Math.floor(Math.random() * 1024); - -/** Generate an atproto TID: 13-char base32-sortable (timestamp-ordered). */ -export function nextTid(): string { - let now = Date.now() * 1000; - if (now <= lastTimestamp) now = lastTimestamp + 1; - lastTimestamp = now; - - const n = BigInt(now) * 1024n + BigInt(clockId); - let s = ""; - let v = n; - for (let i = 0; i < 13; i++) { - s = B32_CHARSET[Number(v & 31n)] + s; - v >>= 5n; - } - return s; -} diff --git a/packages/contrail-base/src/spaces/types.ts b/packages/contrail-base/src/spaces/types.ts deleted file mode 100644 index 0e62f6c..0000000 --- a/packages/contrail-base/src/spaces/types.ts +++ /dev/null @@ -1,283 +0,0 @@ -import type { Database } from "../types"; -import type { DidDocumentResolver } from "@atcute/identity-resolver"; -import type { BlobAdapter } from "./blob-adapter"; -import type { CredentialKeyMaterial } from "./credentials"; - -export type AppPolicyMode = "allow" | "deny"; - -export interface AppPolicy { - mode: AppPolicyMode; - apps: string[]; -} - -export interface SpacesBlobsConfig { - /** Bytes backend (R2, S3, in-memory, …). */ - adapter: BlobAdapter; - /** Max blob size in bytes. Defaults to 2 MiB. */ - maxSize?: number; - /** MIME allowlist. If set, only these content types are accepted. */ - accept?: string[]; - /** Orphan blobs (those with no referencing record) are kept this long before - * GC can delete them, to allow upload-then-putRecord flows. - * Defaults to 24 hours. */ - gcOrphanAfterMs?: number; -} - -export const DEFAULT_BLOB_MAX_SIZE = 2 * 1024 * 1024; -export const DEFAULT_BLOB_GC_ORPHAN_AFTER_MS = 24 * 60 * 60 * 1000; - -/** Default credential lifetime. The rough spec calls for 2–4h; we pick the - * lower bound so revocation (kicked-from-space) is observable within 2h. */ -export const DEFAULT_CREDENTIAL_TTL_MS = 2 * 60 * 60 * 1000; - -/** Configuration for the **space authority** role: holds the member list, - * signs credentials, and gates space-management operations. In a fully-split - * deployment, the authority can run in a different process (or even a - * different operator) than the record host. */ -export interface AuthorityConfig { - /** NSID that identifies the kind of space this authority hosts, - * e.g. "tools.atmo.event.space". */ - type: string; - /** Service DID that service-auth tokens must target (aud claim) AND that - * signs credentials it issues (`iss` claim on emitted JWTs). */ - serviceDid: string; - /** Default app policy applied to new spaces. */ - defaultAppPolicy?: AppPolicy; - /** DID document resolver for service-auth JWT verification. - * Defaults to a composite PLC + did:web resolver if omitted. */ - resolver?: DidDocumentResolver; - /** Signing key material for issuing space credentials. When omitted, - * `<ns>.space.getCredential` returns 501 NotImplemented and the record - * host's credential-verifying middleware can't be wired up. */ - signing?: CredentialKeyMaterial; - /** Credential lifetime in ms. Defaults to {@link DEFAULT_CREDENTIAL_TTL_MS}. */ - credentialTtlMs?: number; - /** Membership-manifest lifetime in ms. Manifests carry a user's full - * member-of list and let appviews filter unioned queries without syncing - * the authority's full member tables. Same key material as credentials. - * Defaults to {@link DEFAULT_MANIFEST_TTL_MS}. */ - manifestTtlMs?: number; - /** Maximum number of spaces returned in a manifest. The endpoint paginates - * through `listSpaces` up to this cap; users with more spaces get a - * truncated manifest (the remainder won't be unioned in queries). Defaults - * to 500. */ - manifestMaxSpaces?: number; -} - -/** Configuration for the **record host** role: stores per-space records and - * blobs and serves reads. Verifies space credentials (later phases) on - * incoming traffic. */ -export interface RecordHostConfig { - /** Blob-upload backend. When omitted, blob XRPCs are not exposed. */ - blobs?: SpacesBlobsConfig; -} - -/** Spaces config — host an authority, a record host, or both. - * Today both run in one process and most deployments will set both; the - * shape is split now so phase 5 can run them independently without churning - * every consumer's config. */ -export interface SpacesConfig { - /** Space-authority config — member list, credentials (later), space - * management. Required for any space to exist. */ - authority?: AuthorityConfig; - /** Record-host config — record + blob storage. Required for records to be - * written/read on this deployment. */ - recordHost?: RecordHostConfig; -} - -export interface SpaceRow { - uri: string; - ownerDid: string; - type: string; - key: string; - serviceDid: string; - appPolicyRef: string | null; - appPolicy: AppPolicy | null; - createdAt: number; - deletedAt: number | null; -} - -export interface SpaceMemberRow { - spaceUri: string; - did: string; - addedAt: number; - addedBy: string | null; -} - -export interface StoredRecord { - spaceUri: string; - collection: string; - authorDid: string; - rkey: string; - cid: string | null; - record: Record<string, unknown>; - createdAt: number; -} - -export interface ListOptions { - byUser?: string; - cursor?: string; - limit?: number; -} - -export interface ListResult { - records: StoredRecord[]; - cursor?: string; -} - -export interface ListSpacesOptions { - type?: string; - ownerDid?: string; - memberDid?: string; - limit?: number; - cursor?: string; -} - -export interface CollectionCount { - collection: string; - count: number; -} - -/** What a token holder can do with this invite. - * - `'join'`: must be redeemed while signed in; redeemer becomes a member. - * - `'read'`: bearer-only — token itself grants read access to the space; cannot be redeemed. - * - `'read-join'`: both — anonymous holders read; signed-in holders may also redeem to join. */ -export type InviteKind = "join" | "read" | "read-join"; - -export interface InviteRow { - tokenHash: string; - spaceUri: string; - kind: InviteKind; - expiresAt: number | null; - maxUses: number | null; - usedCount: number; - createdBy: string; - createdAt: number; - revokedAt: number | null; - note: string | null; -} - -export interface CreateInviteInput { - spaceUri: string; - tokenHash: string; - kind: InviteKind; - expiresAt: number | null; - maxUses: number | null; - createdBy: string; - note: string | null; -} - -export interface RedeemInviteResult { - spaceUri: string; -} - -export interface BlobMetaRow { - spaceUri: string; - cid: string; - mimeType: string; - size: number; - authorDid: string; - createdAt: number; -} - -/** Local cache on the record host: this space is accepted here, and `authority_did` - * is the DID authorized to sign credentials for it. Populated via the - * `recordHost.enroll` endpoint or auto-populated by the authority's - * createSpace when both roles run in one process. */ -export interface EnrollmentRow { - spaceUri: string; - authorityDid: string; - enrolledAt: number; - enrolledBy: string; -} - -export interface ListBlobsOptions { - byUser?: string; - cursor?: string; - limit?: number; -} - -export interface ListBlobsResult { - blobs: BlobMetaRow[]; - cursor?: string; -} - -/** **Space authority** interface — owner of the space's ACL state and - * (eventually) credential issuer. Holds the member list, manages invites, - * governs space lifecycle and app policy. Does NOT touch records or blobs. - * - * In a fully-split deployment this is a separate service; today the - * HostedAdapter implements both this and {@link RecordHost} against one DB. */ -export interface SpaceAuthority { - // Space lifecycle - createSpace(space: Omit<SpaceRow, "createdAt" | "deletedAt">): Promise<SpaceRow>; - getSpace(spaceUri: string): Promise<SpaceRow | null>; - listSpaces(options: ListSpacesOptions): Promise<{ spaces: SpaceRow[]; cursor?: string }>; - deleteSpace(spaceUri: string): Promise<void>; - updateSpaceAppPolicy(spaceUri: string, appPolicy: AppPolicy): Promise<void>; - - // Members - addMember(spaceUri: string, did: string, addedBy: string | null): Promise<void>; - removeMember(spaceUri: string, did: string): Promise<void>; - getMember(spaceUri: string, did: string): Promise<SpaceMemberRow | null>; - listMembers(spaceUri: string): Promise<SpaceMemberRow[]>; - /** Bulk-apply a membership diff. Used only by the community module's reconciler; - * not exposed as an XRPC endpoint. */ - applyMembershipDiff( - spaceUri: string, - adds: string[], - removes: string[], - addedBy: string | null - ): Promise<void>; - - // Invites (token primitive — issued by the authority, scoped to a space) - createInvite(input: CreateInviteInput): Promise<InviteRow>; - listInvites(spaceUri: string, options?: { includeRevoked?: boolean }): Promise<InviteRow[]>; - revokeInvite(tokenHash: string): Promise<boolean>; - /** Look up an invite without consuming it. Used to validate read-token bearer access. */ - getInvite(tokenHash: string): Promise<InviteRow | null>; - /** Atomically mark a join-capable invite as used. Returns the row if usable - * (kind allows join, not expired/revoked/exhausted), null otherwise. */ - redeemInvite(tokenHash: string, now: number): Promise<InviteRow | null>; -} - -/** **Record host** interface — stores records and blobs for a space, plus - * the local enrollment table that decides which spaces this host accepts. - * - * Trust model: the host trusts whatever credential the authority signs, so - * long as the authority is the one named in the local enrollment for this - * space. Enrollment is the consent step — the host owner agrees to spend - * storage on a given space, scoped to a specific authority. */ -export interface RecordHost { - // Enrollment - enroll(input: EnrollmentRow): Promise<void>; - getEnrollment(spaceUri: string): Promise<EnrollmentRow | null>; - listEnrollments(options?: { authorityDid?: string; limit?: number }): Promise<EnrollmentRow[]>; - removeEnrollment(spaceUri: string): Promise<void>; - - // Records - putRecord(record: StoredRecord): Promise<void>; - getRecord(spaceUri: string, collection: string, authorDid: string, rkey: string): Promise<StoredRecord | null>; - listRecords(spaceUri: string, collection: string, options?: ListOptions): Promise<ListResult>; - deleteRecord(spaceUri: string, collection: string, authorDid: string, rkey: string): Promise<void>; - listCollections(spaceUri: string, options?: { byUser?: string }): Promise<CollectionCount[]>; - - // Blobs (metadata only; bytes live on BlobAdapter) - putBlobMeta(row: BlobMetaRow): Promise<void>; - getBlobMeta(spaceUri: string, cid: string): Promise<BlobMetaRow | null>; - listBlobMeta(spaceUri: string, options?: ListBlobsOptions): Promise<ListBlobsResult>; - deleteBlobMeta(spaceUri: string, cid: string): Promise<void>; - /** Find blob rows older than `cutoff` whose CIDs are not referenced in any - * record JSON in this space. Capped at `limit` to bound a single GC pass. */ - findOrphanBlobs(spaceUri: string, cutoff: number, limit: number): Promise<BlobMetaRow[]>; -} - -/** Combined adapter. Used internally where a single object satisfies both - * roles (today's HostedAdapter, the community reconciler, the realtime - * publishing wrapper). Phases 5+ replace consumers of this with two - * injected interfaces. */ -export type StorageAdapter = SpaceAuthority & RecordHost; - -export interface AdapterContext { - db: Database; -} diff --git a/packages/contrail-base/src/spaces/uri.ts b/packages/contrail-base/src/spaces/uri.ts deleted file mode 100644 index e73ec21..0000000 --- a/packages/contrail-base/src/spaces/uri.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** Centralized space URI construction / parsing. - * - * Permissioned spaces are addressed by (ownerDid, type, key) and use the - * `ats://` scheme — distinct from atproto record URIs (`at://`) so the two - * can't be confused at any layer (logs, params, dispatch). Tracks the rough - * spec at https://dholms.leaflet.pub/3mhj6bcqats2o. - * - * Record URIs inside a space are minted by authorDid for index purposes - * (`at://<authorDid>/<collection>/<rkey>`); the spec is explicitly undecided - * about authority (user vs space owner), so we don't expose those as a - * canonical record address — they're storage-internal. */ - -export interface SpaceUriParts { - ownerDid: string; - type: string; - key: string; -} - -/** Build a space URI from its three addressing components. */ -export function buildSpaceUri(parts: SpaceUriParts): string { - return `ats://${parts.ownerDid}/${parts.type}/${parts.key}`; -} - -/** Parse a space URI into its components, or null if malformed. */ -export function parseSpaceUri(uri: string): SpaceUriParts | null { - if (!uri.startsWith("ats://")) return null; - const rest = uri.slice("ats://".length); - const [ownerDid, type, key, ...extra] = rest.split("/"); - if (!ownerDid || !type || !key || extra.length > 0) return null; - return { ownerDid, type, key }; -} - -/** Build a record URI under a given author. Used only as a secondary index key - * inside storage — not a canonical address for permissioned records. */ -export function buildRecordUri(authorDid: string, collection: string, rkey: string): string { - return `at://${authorDid}/${collection}/${rkey}`; -} diff --git a/packages/contrail-base/src/types.ts b/packages/contrail-base/src/types.ts deleted file mode 100644 index af37b17..0000000 --- a/packages/contrail-base/src/types.ts +++ /dev/null @@ -1,785 +0,0 @@ -import type { SqlDialect } from "./dialect"; - -// Database interface — D1 implements this natively -export interface Database { - prepare(sql: string): Statement; - batch(stmts: Statement[]): Promise<any[]>; - dialect?: SqlDialect; -} - -export interface Statement { - bind(...values: any[]): Statement; - run(): Promise<any>; - all<T = any>(): Promise<{ results: T[] }>; - first<T = any>(): Promise<T | null>; -} - -// Config types - -export interface QueryableField { - type?: "range"; -} - -export interface RelationConfig { - /** Short name of the child collection (a key in `collections`). */ - collection: string; - field?: string; - match?: "uri" | "did"; - groupBy?: string; - /** Enable materialized count columns on the parent. Defaults to true. */ - count?: boolean; - /** Count distinct values of a field (e.g. "did" for unique users) instead of total records. */ - countDistinct?: string; - /** Pre-resolved group mappings: shortName → full token (e.g. { going: "community.lexicon.calendar.rsvp#going" }). Auto-computed from groupBy if omitted. */ - groups?: Record<string, string>; -} - -/** A forward reference: this collection's records point at another collection. */ -export interface ReferenceConfig { - /** Short name of the target collection. */ - collection: string; - /** Field on this collection's records containing the target URI. */ - field: string; -} - -export type CustomQueryHandler = ( - db: Database, - params: URLSearchParams, - config: ContrailConfig -) => Promise<Response>; - -export interface RecordSource { - joins?: string; - conditions?: string[]; - params?: (string | number)[]; -} - -export type PipelineQueryHandler = ( - db: Database, - params: URLSearchParams, - config: ContrailConfig -) => Promise<RecordSource>; - -export interface FeedTargetConfig { - /** Short name of the target collection. */ - collection: string; - /** Per-target item cap. Falls back to FeedConfig.maxItems if unset. */ - maxItems?: number; -} - -export interface FeedConfig { - /** Short name of the follow collection. Defaults to "follow" - * (auto-added with NSID `app.bsky.graph.follow`, `discover: false`). */ - follow?: string; - /** Target collections to fan out to. Each entry is either a short name - * or `{ collection, maxItems? }` for per-target caps. */ - targets: (string | FeedTargetConfig)[]; - /** Default per-target item cap when a target doesn't specify its own - * (default: 200). Oldest items per (actor, collection) are pruned. */ - maxItems?: number; -} - -export const DEFAULT_FEED_MAX_ITEMS = 200; -export const DEFAULT_FOLLOW_NSID = "app.bsky.graph.follow"; -export const DEFAULT_FOLLOW_SHORT = "follow"; - -/** Normalize a feed target entry to FeedTargetConfig. */ -export function normalizeFeedTarget( - t: string | FeedTargetConfig -): FeedTargetConfig { - return typeof t === "string" ? { collection: t } : t; -} - -/** Resolve a feed's per-target item cap, falling back to FeedConfig.maxItems then global default. */ -export function feedTargetMaxItems( - feed: FeedConfig, - target: FeedTargetConfig -): number { - return target.maxItems ?? feed.maxItems ?? DEFAULT_FEED_MAX_ITEMS; -} - -/** Build a Map<target-NSID, maxItems> across all configured feeds, taking the - * largest cap if the same target collection appears in multiple feeds. */ -export function buildFeedTargetCaps( - config: ContrailConfig -): Map<string, number> { - const caps = new Map<string, number>(); - if (!config.feeds) return caps; - for (const feed of Object.values(config.feeds)) { - for (const t of feed.targets) { - const target = normalizeFeedTarget(t); - const colCfg = config.collections[target.collection]; - if (!colCfg) continue; - const cap = feedTargetMaxItems(feed, target); - const nsid = colCfg.collection ?? target.collection; - const existing = caps.get(nsid) ?? 0; - if (cap > existing) caps.set(nsid, cap); - } - } - return caps; -} - -export type CollectionMethod = "listRecords" | "getRecord"; -export const DEFAULT_COLLECTION_METHODS: CollectionMethod[] = [ - "listRecords", - "getRecord", -]; - -export interface CollectionConfig { - /** Full NSID of the record type this collection indexes. May be omitted when - * the collection's map key is itself the full NSID (an "NSID-keyed" config); - * `resolveConfig` normalizes the omitted value to that key. */ - collection?: string; - /** Include this collection in Jetstream ingest / discovery (default true). - * Set false for dependent collections (auto-fetched on demand). */ - discover?: boolean; - queryable?: Record<string, QueryableField>; - relations?: Record<string, RelationConfig>; - /** Forward references: fields on this collection's records that point at another collection. */ - references?: Record<string, ReferenceConfig>; - queries?: Record<string, CustomQueryHandler>; - pipelineQueries?: Record<string, PipelineQueryHandler>; - /** FTS5 search fields. Provide an array of field names to enable full-text search. Omit or set to false to disable. */ - searchable?: string[] | false; - /** XRPC methods to emit. Defaults to ['listRecords', 'getRecord']. */ - methods?: CollectionMethod[]; - /** When spaces are enabled globally, emit a parallel spaces_records_<short> table - * so this collection can also live inside spaces. Defaults to true. */ - allowInSpaces?: boolean; - /** JSON field on the record used as the canonical event time, parsed and - * written into `time_us` during backfill (and clamped to now). Default - * `"createdAt"`. Set to `false` to disable parsing and keep ingest time. */ - timeField?: string | false; - /** JSON field on the record holding a DID that this record points at - * (e.g. `"subject"` for follows). When set on a `discover: false` - * collection, ingest also drops records whose subject DID is not in - * knownDids — useful for trimming network-wide social graphs to the - * subjects we care about. */ - subjectField?: string; - /** Per-record predicate run during ingest. Returning false drops the - * record before it hits the buffer / DB. Runs only for create/update; - * deletes always pass through (the delete may target a record that *did* - * pass an earlier version of the filter). Thrown errors are caught, - * logged, and treated as "drop". Note: Jetstream filters only by - * `wantedCollections`, so non-matching records still travel over the wire - * — this trims what gets persisted, not bandwidth. */ - recordFilter?: (record: Record<string, unknown>) => boolean; -} - -export interface ProfileConfig { - /** Full NSID of the profile record type. */ - collection: string; - /** Short name used for table/endpoint naming. Defaults to the NSID's last segment. */ - shortName?: string; - rkey?: string; // defaults to "self" -} - -export const DEFAULT_PROFILES: ProfileConfig[] = [ - { collection: "app.bsky.actor.profile", shortName: "profile" }, -]; - -/** Normalize a profiles config entry (string or object) into ProfileConfig. */ -export function normalizeProfileConfig( - p: string | ProfileConfig -): ProfileConfig { - if (typeof p === "string") { - return { collection: p, shortName: deriveShortName(p) }; - } - return { ...p, shortName: p.shortName ?? deriveShortName(p.collection) }; -} - -/** Last NSID segment, used as fallback short name. */ -export function deriveShortName(nsid: string): string { - const parts = nsid.split("."); - return parts[parts.length - 1] ?? nsid; -} - -export const DEFAULT_JETSTREAMS = [ - "wss://jetstream1.us-east.bsky.network", -]; - -/** - * Shape a configured jetstream list for `@atcute/jetstream`'s `url` option. - * - * @atcute distinguishes a string url (one fixed instance) from an array url (a - * pool it picks from at random each connect). For an array it seeds - * `#lastUsedUrl=''` and rolls the cursor back 10s on the first connect, to absorb - * clock skew between whichever pooled instances a resumed cursor may have - * crossed. A string takes no rollback: a single instance emits a monotonic - * cursor, so resuming at the saved value on that same instance can't skip its own - * events — there is no second instance to be skewed against. - * - * Contrail's cron ingestion rebuilds the subscription every cycle, so for a - * single-instance config that "first-connect" rollback fires *every* cycle and - * redundantly re-ingests the last 10s. Collapsing a one-element pool to a string - * matches @atcute's own single-instance semantics and drops that dead margin; a - * real pool (2+) stays an array so the cross-instance rollback is preserved. - */ -export function jetstreamUrlOption(jetstreams: string[]): string | string[] { - return jetstreams.length === 1 ? jetstreams[0] : jetstreams; -} - -export const DEFAULT_RELAYS = [ - "https://relay1.us-east.bsky.network" -]; - -export interface Logger { - log(...args: any[]): void; - warn(...args: any[]): void; - error(...args: any[]): void; -} - -export interface ContrailConfig { - namespace: string; - /** Collections to index, keyed by short name. Short names become endpoint URL segments - * (`<namespace>.<short>.listRecords`) and table suffixes (`records_<short>`). */ - collections: Record<string, CollectionConfig>; - profiles?: (string | ProfileConfig)[]; - relays?: string[]; - /** Jetstream endpoints to ingest from (defaults to {@link DEFAULT_JETSTREAMS}). - * Prefer a single endpoint: one instance has no clock skew, so `@atcute` takes - * no cursor rollback (see {@link jetstreamUrlOption}) — important for the cron - * model, which rebuilds the subscription every cycle. Use 2+ only for failover - * across interchangeable endpoints, and ideally only with a persistent - * connection (`runPersistent`), where the per-switch 10s skew rollback fires - * about once rather than every cycle. */ - jetstreams?: string[]; - feeds?: Record<string, FeedConfig>; - logger?: Logger; - /** Expose the notifyOfUpdate HTTP endpoint. Off by default. - * Set to `true` for open access, or a string to require `Authorization: Bearer <secret>`. */ - notify?: boolean | string; - /** Permissioned spaces configuration. When set, the service exposes space XRPCs. */ - spaces?: import("./spaces/types").SpacesConfig; - /** Community module configuration. Typed by the community package via - * declaration merging — contrail core only knows it's "something the - * community package consumes." Set when wiring community via - * `createCommunityIntegration({ ... })`. Requires `spaces.authority`. */ - community?: unknown; - /** Realtime module configuration. When set, the service exposes ticket + SSE/WS - * subscribe XRPCs, and wraps the spaces adapter to publish events after writes. */ - realtime?: import("./realtime/types").RealtimeConfig; - /** Write-only, post-commit observers of applied records — derived indexes, - * audit logs, webhook fan-outs. Each fires after every `applyEvents()` commit - * on BOTH the live and backfill paths, with failures isolated so a throwing - * sink never blocks ingestion. Distinct from `realtime`, which serves live - * subscribers over a lossy delivery channel and is intentionally silent during - * backfill. Public records only. */ - sinks?: import("./sinks/types").Sink[]; - /** Labels module configuration. When set, contrail subscribes to the - * configured labelers, indexes their labels into a single `labels` table, - * and hydrates `record.labels` onto `listRecords` / `getRecord` / profile - * responses gated by the caller's `atproto-accept-labelers` header. */ - labels?: import("./labels/types").LabelsConfig; - /** Customize the auto-generated `<namespace>.authFull` lexicon. */ - permissionSet?: PermissionSetConfig; - /** Constellation-backed reverse-follower lookup (default: enabled). - * When a DID is first seen producing a discoverable record, contrail - * queries Constellation for follow records pointing at that DID and - * ingests synthesized rows for any follower already in our identities - * table. Lets newcomers immediately appear in existing users' feeds. */ - constellation?: ConstellationConfig | false; - /** Network overrides for private-network or test deployments. - * All subfields default to current public-internet behavior; - * omitting `networkOverrides` entirely preserves current behavior. - * - * SECURITY: `resolver` and `slingshotUrl` are taken at face value and are - * NOT validated against the SSRF guard — the consumer is trusted to - * configure them. Only the PDS URL returned downstream is validated, - * and only `additionalAllowedHosts` widens that PDS validator. There is - * no "disable SSRF" flag. */ - networkOverrides?: { - /** DID document resolver used during the DID-doc PDS fallback. When - * unset, contrail constructs a default `CompositeDidDocumentResolver` - * with PLC + Web methods pointing at the upstream PLC directory. - * Pass a custom resolver to point at a private PLC mirror, inject a - * custom fetch (mTLS, retry, instrumentation), or swap in an - * alternative DID method composition. - * Mirrors the `AuthorityConfig.resolver` pattern in `spaces/types.ts`. */ - resolver?: import("@atcute/identity-resolver").DidDocumentResolver; - /** Slingshot identity resolver URL override. Trusted; not SSRF-checked. - * Default: https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc */ - slingshotUrl?: string; - /** Hostnames (DNS names or IP literals) to allow past the default SSRF - * guard when validating a resolved PDS URL. - * For listed hostnames, the non-HTTPS + private-CIDR checks are skipped. - * For all other hostnames, the default validator runs unchanged. - * Match semantics: exact hostname, case-insensitive (entries are - * lowercased on comparison; `URL.hostname` is already lowercased), - * port-agnostic. - * Example: ["pds.dev.svc.cluster.local"]. */ - additionalAllowedHosts?: string[]; - }; - /** Optional background database maintenance. All off by default. */ - maintenance?: MaintenanceConfig; -} - -export interface MaintenanceConfig { - /** Periodically refresh the SQLite query planner's statistics so - * multi-predicate queries pick the selective index instead of the planner's - * default heuristic (measured ~50x fewer rows read on a 2-predicate query). - * Off by default — it's a DB write + CPU and shouldn't change behavior for - * existing consumers unless enabled. `true` uses defaults; pass an object to - * tune. No-op on Postgres, where autovacuum/autoanalyze handles this. */ - optimize?: boolean | MaintenanceOptimizeConfig; -} - -export interface MaintenanceOptimizeConfig { - /** Minimum gap between optimize runs (default: 24h). Planner stats change - * slowly, so daily is plenty. */ - intervalMs?: number; - /** `PRAGMA analysis_limit` — bounds the work per run so it can't exceed - * D1's per-query CPU budget and reset the shared DO (default: 400). */ - analysisLimit?: number; -} - -export const DEFAULT_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60 * 1000; -export const DEFAULT_ANALYSIS_LIMIT = 400; - -/** Whether the opt-in planner-stat maintenance is enabled. */ -export function optimizeEnabled(config: ContrailConfig): boolean { - return !!config.maintenance?.optimize; -} - -/** Resolved optimize interval (ms), falling back to the 24h default. */ -export function optimizeIntervalMs(config: ContrailConfig): number { - const o = config.maintenance?.optimize; - if (o && typeof o === "object" && o.intervalMs != null) return o.intervalMs; - return DEFAULT_OPTIMIZE_INTERVAL_MS; -} - -/** Resolved `analysis_limit` for optimize, falling back to the default. */ -export function optimizeAnalysisLimit(config: ContrailConfig): number { - const o = config.maintenance?.optimize; - if (o && typeof o === "object" && o.analysisLimit != null) return o.analysisLimit; - return DEFAULT_ANALYSIS_LIMIT; -} - -export interface ConstellationConfig { - /** Override the default Constellation instance URL. */ - url?: string; - /** Sent as the User-Agent header per Constellation's request that - * callers identify themselves. Defaults to `contrail/<namespace>`. */ - userAgent?: string; - /** Set false to disable lookups while keeping the table around. */ - enabled?: boolean; -} - -export const DEFAULT_CONSTELLATION_URL = "https://constellation.microcosm.blue"; - -/** Single entry in an atproto permission-set's `permissions` array. - * See https://atproto.com/guides/permission-sets for the full schema. */ -export type PermissionEntry = - | { type: "permission"; resource: "rpc"; lxm?: string[]; aud?: string; inheritAud?: boolean } - | { type: "permission"; resource: "repo"; collection?: string[] } - | { type: "permission"; resource: "blob"; accept?: string[]; maxSize?: number } - | { type: "permission"; resource: "account"; attr?: string[] } - | { type: "permission"; resource: string; [key: string]: unknown }; - -export interface PermissionSetConfig { - /** Shown on the OAuth consent screen. Defaults to the namespace. */ - title?: string; - /** Shown on the OAuth consent screen. Defaults to a generated description. */ - description?: string; - /** Extra permission entries appended after the auto-generated rpc entry — - * e.g. repo writes for collections your app needs the user to create, or - * blob permissions for uploads. */ - additional?: PermissionEntry[]; -} - -export interface ResolvedRelation { - /** Short name of the child collection. */ - collection: string; - groupBy: string; - groups: Record<string, string>; // shortName → full token value -} - -export interface ResolvedMaps { - queryable: Record<string, Record<string, QueryableField>>; - relations: Record<string, Record<string, ResolvedRelation>>; - /** Reverse map: full record NSID → short name. */ - nsidToShort: Record<string, string>; -} - -/** Config after resolveConfig() — has computed queryable/relation maps attached. */ -export interface ResolvedContrailConfig extends ContrailConfig { - _resolved: ResolvedMaps; -} - -/** - * Resolve config: apply defaults, auto-add profile collections, compute queryable maps. - */ -export function resolveConfig(config: ContrailConfig): ResolvedContrailConfig { - const profiles = (config.profiles ?? DEFAULT_PROFILES).map( - normalizeProfileConfig - ); - const collections: Record<string, CollectionConfig> = {}; - - // Normalize an omitted `collection` (NSID-keyed config) to the map key, then - // default `discover: false` for any collection whose NSID lives under the - // `app.bsky.*` namespace, since these are external/network-wide records that - // would otherwise blow up storage if left discoverable. - for (const [short, rawC] of Object.entries(config.collections)) { - const c = - rawC.collection === undefined ? { ...rawC, collection: short } : rawC; - collections[short] = - c.discover === undefined && c.collection!.startsWith("app.bsky.") - ? { ...c, discover: false } - : c; - } - - for (const p of profiles) { - const short = p.shortName!; - if (!collections[short]) { - collections[short] = { collection: p.collection, discover: false }; - } - } - - // Auto-add a follow collection for any feed that doesn't declare one. - // Default short name `follow` → `app.bsky.graph.follow`, with a `subject` - // filter so we only persist follows pointing at known DIDs. - const feeds = config.feeds; - if (feeds) { - const usedFollowShorts = new Set<string>(); - for (const [, feed] of Object.entries(feeds)) { - const shortName = feed.follow ?? DEFAULT_FOLLOW_SHORT; - usedFollowShorts.add(shortName); - } - for (const short of usedFollowShorts) { - if (!collections[short]) { - collections[short] = { - collection: DEFAULT_FOLLOW_NSID, - discover: false, - subjectField: "subject", - }; - } - } - } - - const base = { - ...config, - collections, - profiles, - jetstreams: config.jetstreams ?? DEFAULT_JETSTREAMS, - relays: config.relays ?? DEFAULT_RELAYS, - logger: config.logger ?? console, - }; - - return { - ...base, - _resolved: _resolveQueryableMaps(base), - }; -} - -function _resolveQueryableMaps(config: ContrailConfig): ResolvedMaps { - const queryable: Record<string, Record<string, QueryableField>> = {}; - const relations: Record<string, Record<string, ResolvedRelation>> = {}; - const nsidToShort: Record<string, string> = {}; - - for (const [short, colConfig] of Object.entries(config.collections)) { - nsidToShort[colConfig.collection ?? short] = short; - - if (colConfig.queryable) { - queryable[short] = colConfig.queryable; - } - - if (colConfig.relations) { - for (const [relName, rel] of Object.entries(colConfig.relations)) { - if (!rel.groupBy) continue; - const groups: Record<string, string> = rel.groups ? { ...rel.groups } : {}; - if (Object.keys(groups).length > 0) { - if (!relations[short]) relations[short] = {}; - relations[short][relName] = { - collection: rel.collection, - groupBy: rel.groupBy, - groups, - }; - } - } - } - } - - return { queryable, relations, nsidToShort }; -} - -export function getFeedFollowShortNames(config: ContrailConfig): string[] { - if (!config.feeds) return []; - return [ - ...new Set( - Object.values(config.feeds).map((f) => f.follow ?? DEFAULT_FOLLOW_SHORT) - ), - ]; -} - -/** Alias for getFeedFollowShortNames. */ -export const getFeedFollowCollections = getFeedFollowShortNames; - -/** - * NSIDs whose ingest can mutate `feed_items`: feed *target* collections (a - * create/update fans out to followers, a delete tears the item down) and feed - * *follow* collections (a follow backfills the follower's feed, an unfollow - * removes it). These are the only records that can push a feed over its cap, so - * a tick that ingested none of them cannot have created prune work — callers use - * this to skip the feed sweep on idle ticks. Returns an empty set when no feeds - * are configured. */ -export function getFeedMutatingNsids(config: ContrailConfig): Set<string> { - const nsids = new Set<string>(); - if (!config.feeds) return nsids; - for (const targetNsid of buildFeedTargetCaps(config).keys()) { - nsids.add(targetNsid); - } - for (const short of getFeedFollowShortNames(config)) { - const nsid = nsidForShortName(config, short); - if (nsid) nsids.add(nsid); - } - return nsids; -} - -// Record types - -export interface RecordRow { - uri: string; - did: string; - collection: string; // full NSID - rkey: string; - cid: string | null; - record: string | null; - time_us: number; - indexed_at: number; - /** Set when the row originates from a per-space table. Used by the - * pipeline/hydration/response layers to route child queries to the same - * space and tag the output. */ - space?: string; -} - -export interface IngestEvent { - uri: string; - did: string; - collection: string; // full NSID - rkey: string; - operation: "create" | "update" | "delete"; - cid: string | null; - record: string | null; - time_us: number; - indexed_at: number; -} - -// Validation - -const SAFE_FIELD_NAME = /^[a-zA-Z0-9_.]+$/; -const SAFE_SHORT_NAME = /^[a-zA-Z][a-zA-Z0-9]*$/; - -export function validateFieldName(field: string): string { - if (!SAFE_FIELD_NAME.test(field)) { - throw new Error(`Invalid field name: ${field}`); - } - return field; -} - -function validateShortName(short: string): void { - if (!SAFE_SHORT_NAME.test(short)) { - throw new Error( - `Invalid collection short name: "${short}". Must be alphanumeric, starting with a letter.` - ); - } -} - -export function validateConfig(config: ContrailConfig): void { - const shortNames = new Set<string>(); - for (const [short, colConfig] of Object.entries(config.collections)) { - // NSID-keyed collections use the map key as the NSID (no short alias), so - // the key legitimately contains dots and must skip short-name validation. - const nsidKeyed = - colConfig.collection === undefined || colConfig.collection === short; - if (!nsidKeyed) validateShortName(short); - if (shortNames.has(short)) { - throw new Error(`Duplicate collection short name: ${short}`); - } - shortNames.add(short); - - for (const field of Object.keys(colConfig.queryable ?? {})) { - validateFieldName(field); - } - for (const [, rel] of Object.entries(colConfig.relations ?? {})) { - if (rel.field) validateFieldName(rel.field); - if (rel.groupBy) validateFieldName(rel.groupBy); - if (rel.countDistinct) validateFieldName(rel.countDistinct); - if (!config.collections[rel.collection]) { - throw new Error( - `Relation in "${short}" references unknown collection short name "${rel.collection}"` - ); - } - } - for (const [, ref] of Object.entries(colConfig.references ?? {})) { - validateFieldName(ref.field); - if (!config.collections[ref.collection]) { - throw new Error( - `Reference in "${short}" references unknown collection short name "${ref.collection}"` - ); - } - } - if (Array.isArray(colConfig.searchable)) { - for (const field of colConfig.searchable) { - validateFieldName(field); - } - } - } - - if (config.feeds) { - for (const [feedName, feed] of Object.entries(config.feeds)) { - const followShort = feed.follow ?? DEFAULT_FOLLOW_SHORT; - if (!config.collections[followShort]) { - throw new Error( - `Feed "${feedName}" references unknown follow collection "${followShort}"` - ); - } - for (const t of feed.targets) { - const targetShort = normalizeFeedTarget(t).collection; - if (!config.collections[targetShort]) { - throw new Error( - `Feed "${feedName}" references unknown target collection "${targetShort}"` - ); - } - } - } - } - - if (config.community && !config.spaces?.authority) { - throw new Error( - "Invalid config: `community` requires `spaces.authority`. Community-owned spaces reuse the spaces storage adapter." - ); - } -} - -// Helpers - -export function getNestedValue(obj: any, path: string): any { - let current = obj; - for (const key of path.split(".")) { - if (current == null) return undefined; - current = current[key]; - } - return current; -} - -const DEFAULT_RELATION_FIELD = "subject.uri"; - -export function getRelationField(rel: RelationConfig): string { - return rel.field ?? DEFAULT_RELATION_FIELD; -} - -/** Sanitize a short name for use in SQL identifiers (already-validated; kept for paranoia). */ -function sanitizeIdentifier(name: string): string { - return name.replace(/[^a-zA-Z0-9]/g, "_"); -} - -/** Total-count column name for a relation targeting the given short name. */ -export function countColumnName(childShortName: string): string { - return "count_" + sanitizeIdentifier(childShortName); -} - -/** Grouped-count column name: `count_<child-short>_<groupKey>`. */ -export function groupedCountColumnName( - childShortName: string, - groupKey: string -): string { - return `count_${sanitizeIdentifier(childShortName)}_${sanitizeIdentifier(groupKey)}`; -} - -/** Table name for a collection's records. */ -export function recordsTableName(shortName: string): string { - return "records_" + sanitizeIdentifier(shortName); -} - -/** Table name for a collection's records inside spaces. */ -export function spacesRecordsTableName(shortName: string): string { - return "spaces_records_" + sanitizeIdentifier(shortName); -} - -/** All collection short names. */ -export function getCollectionShortNames(config: ContrailConfig): string[] { - return Object.keys(config.collections); -} - -/** Alias: collection short names (same as getCollectionShortNames). */ -export const getCollectionNames = getCollectionShortNames; - -/** All indexed record NSIDs (what Jetstream filters on). For NSID-keyed - * collections (omitted `collection`), the map key is the NSID. */ -export function getCollectionNsids(config: ContrailConfig): string[] { - return Object.entries(config.collections).map(([short, c]) => c.collection ?? short); -} - -export function getDependentShortNames(config: ContrailConfig): string[] { - return Object.entries(config.collections) - .filter(([, c]) => c.discover === false) - .map(([name]) => name); -} - -export function getDiscoverableShortNames(config: ContrailConfig): string[] { - return Object.entries(config.collections) - .filter(([, c]) => c.discover !== false) - .map(([name]) => name); -} - -/** Aliases for readability elsewhere. These return short names (new semantic). */ -export const getDependentCollections = getDependentShortNames; -export const getDiscoverableCollections = getDiscoverableShortNames; - -/** Short names of collections the user declared with `discover !== false`, mapped to NSIDs. */ -export function getDiscoverableNsids(config: ContrailConfig): string[] { - return Object.entries(config.collections) - .filter(([, c]) => c.discover !== false) - .map(([short, c]) => c.collection ?? short); -} - -export function getDependentNsids(config: ContrailConfig): string[] { - return Object.entries(config.collections) - .filter(([, c]) => c.discover === false) - .map(([short, c]) => c.collection ?? short); -} - -/** Short name for a record NSID, if known. */ -export function shortNameForNsid( - config: ContrailConfig, - nsid: string -): string | undefined { - const resolved = (config as ResolvedContrailConfig)._resolved; - if (resolved?.nsidToShort) return resolved.nsidToShort[nsid]; - for (const [short, c] of Object.entries(config.collections)) { - if ((c.collection ?? short) === nsid) return short; - } - return undefined; -} - -/** The config key a collection's rows are stored under: its short alias when - * one exists, otherwise the NSID itself when the config is keyed directly by - * NSID. Returns null when the collection is unknown. Use this wherever you need - * the storage key (records insert, FTS, existing-record lookup). Unlike - * {@link shortNameForNsid}, which only reports an alias and so returns - * undefined for NSID-keyed configs. */ -export function resolveCollectionKey( - config: ContrailConfig, - nsid: string -): string | null { - return ( - shortNameForNsid(config, nsid) ?? - (config.collections[nsid] ? nsid : null) - ); -} - -/** Full NSID for a collection short name. For NSID-keyed collections (omitted - * `collection`), the short name is itself the NSID. */ -export function nsidForShortName( - config: ContrailConfig, - short: string -): string | undefined { - const c = config.collections[short]; - if (!c) return undefined; - return c.collection ?? short; -} - -/** The methods a collection should expose via XRPC. */ -export function getCollectionMethods(cfg: CollectionConfig): CollectionMethod[] { - return cfg.methods ?? DEFAULT_COLLECTION_METHODS; -} diff --git a/packages/contrail-base/tsconfig.build.json b/packages/contrail-base/tsconfig.build.json deleted file mode 100644 index 6092abf..0000000 --- a/packages/contrail-base/tsconfig.build.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/packages/contrail-base/tsconfig.json b/packages/contrail-base/tsconfig.json deleted file mode 100644 index 6092abf..0000000 --- a/packages/contrail-base/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/packages/contrail-base/tsup.config.ts b/packages/contrail-base/tsup.config.ts deleted file mode 100644 index 3ad3082..0000000 --- a/packages/contrail-base/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/index.ts", "src/adapters/sqlite.ts", "src/adapters/postgres.ts"], - format: ["esm"], - dts: true, - sourcemap: true, - clean: true, - tsconfig: "tsconfig.build.json", - external: ["pg", "node:sqlite"], -}); diff --git a/packages/contrail-community/CHANGELOG.md b/packages/contrail-community/CHANGELOG.md deleted file mode 100644 index 3ce8ae5..0000000 --- a/packages/contrail-community/CHANGELOG.md +++ /dev/null @@ -1,208 +0,0 @@ -# @atmo-dev/contrail-community - -## 0.12.2 - -### Patch Changes - -- @atmo-dev/contrail@0.12.2 -- @atmo-dev/contrail-base@0.12.2 - -## 0.12.1 - -### Patch Changes - -- Updated dependencies [833a659] -- Updated dependencies [74a2d3d] -- Updated dependencies [9894787] - - @atmo-dev/contrail-base@0.12.1 - - @atmo-dev/contrail@0.12.1 - -## 0.12.0 - -### Patch Changes - -- Updated dependencies [6b34d87] - - @atmo-dev/contrail-base@0.12.0 - - @atmo-dev/contrail@0.12.0 - -## 0.11.0 - -### Patch Changes - -- @atmo-dev/contrail@0.11.0 -- @atmo-dev/contrail-base@0.11.0 - -## 0.10.0 - -### Patch Changes - -- Updated dependencies [89aee1b] - - @atmo-dev/contrail-base@0.10.0 - - @atmo-dev/contrail@0.10.0 - -## 0.9.1 - -### Patch Changes - -- Updated dependencies [4c8fedb] - - @atmo-dev/contrail-base@0.9.1 - - @atmo-dev/contrail@0.9.1 - -## 0.9.0 - -### Patch Changes - -- @atmo-dev/contrail@0.9.0 -- @atmo-dev/contrail-base@0.9.0 - -## 0.8.0 - -### Minor Changes - -- bea0dd2: A third community-creation mode: **provision**. alongside the existing `adopt` (caller already has a `did:plc`) and `mint` (caller wants a DID but brings their own PDS) modes, contrail can now provision a community on a stock `@atproto/pds` end-to-end — minting the `did:plc`, creating and activating the PDS account, generating an app password, and persisting credentials so the existing `community.putRecord` / `.deleteRecord` publish path keeps working. contrail never holds PDS admin credentials. - - **`xrpc/{ns}.community.provision`** runs the five-step PLC + PDS dance (key generation → PLC genesis → `createAccount` → `getRecommendedDidCredentials` + signed PLC update op → `activateAccount`), persists each step in a new `provision_attempts` table so a partially-failed attempt can be resumed, mints an app password, and seeds the session cache. - - **`contrail-community reap [--all-stuck] [--older-than <minutes>] [--db <url>] [--dry-run]`** new CLI (a bin shipped by `@atmo-dev/contrail-community`) that cleans up provision attempts which didn't reach `status='activated'` by tombstoning their PLC entries. `--dry-run` is the default; per-row confirmation is required for live reaping unless `--all-stuck` is given. `--all-stuck` only acts on rows idle at least `--older-than` minutes (default 30) so a bulk run can't tombstone an in-flight provision. Runs against the Cloudflare D1 binding by default, or against the decoupled Postgres index when `--db`/`DATABASE_URL` is set. It ships as a contrail-community bin because the PR #30 package split removed contrail's edge into community code: under pnpm's isolated `node_modules` the core `contrail` CLI can't resolve `@atmo-dev/contrail-community`, so `contrail reap` only registers in hoisted installs where both packages sit together. - - custody model: the caller supplies a `rotationKey` and that key sits at `rotationKeys[0]` — the highest-priority rotation slot on the resulting DID. contrail generates a subordinate keypair and persists it (AES-GCM-encrypted under `masterKey`) at `rotationKeys[1]`, so it can submit later PLC ops on the community's behalf — most importantly the post-activation PLC update during provision, and the tombstone op that `reap` issues to clean up stuck DIDs. - - the caller's key dominates: PLC's 72-hour nullification window means any op contrail signs with its subordinate key can be overridden within 72h by an op signed with the caller's key. with this caveat: a tombstone is irrevocable. a malicious or compromised contrail instance could tombstone any DID it provisioned. there is no managed code path, no shared rotation, and `rootCredentials` are returned to the caller in the response so they can also be persisted out-of-band. - - what you need to configure / know: - - - new `community` config block: `masterKey` (32-byte AES-GCM envelope key for the encrypted credential columns), `allowedProvisionPdsEndpoints` (URL-origin matching, collapses scheme case / default ports / trailing slash / IDN), optional `plcDirectory` override. - - - **provisioning fails closed.** When `allowProvisioning` is true, `allowedProvisionPdsEndpoints` MUST be non-empty — a missing/empty allowlist no longer means "accept any PDS" (that was a fail-open hole: any caller could have a PLC genesis op signed by Contrail's rotation key against an attacker-chosen PDS). To deliberately accept any endpoint, set the separate, loud `allowAnyProvisionPdsEndpoint: true`. The field was renamed from `allowedPdsEndpoints` to make clear it gates _provisioning_ only, not which PDSes Contrail reads/indexes. - - - new tables `provision_attempts` and `community_credentials`. credentials are stored AES-GCM-encrypted under that key; lose the key, lose the ability to mint sessions for previously-provisioned communities. - -- d7e0936: Private-network deployment support via a new optional `ContrailConfig.networkOverrides` block. - - `networkOverrides` carries three optional subfields, all defaulting to the current public-internet behavior (omit the block entirely and nothing changes): - - - **`resolver`** — a custom `DidDocumentResolver` used during DID-doc PDS fallback, labeler-endpoint resolution, and spaces service-auth JWT verification. Lets a deployment point at a private PLC mirror or inject a custom fetch (mTLS, retry, instrumentation). Trusted; not SSRF-checked. - - **`slingshotUrl`** — override the slingshot identity-resolver endpoint. Trusted; not SSRF-checked. - - **`additionalAllowedHosts`** — hostnames that bypass the default SSRF guard when validating a resolved PDS or labeler endpoint. Match is exact, case-insensitive, port-agnostic (e.g. `["pds.dev.svc.cluster.local"]`). This is the only knob that widens the validator; there is no "disable SSRF" flag. - - The overrides are threaded through PDS/identity resolution (`resolvePDS`, `getPDS`, `getClient`, `resolveIdentity*`, `refreshStaleIdentities`), labeler endpoint resolution and ingest (`resolveLabelerEndpoint`, `getLabelerState`, label subscribe cycles), and service-auth verification (`buildVerifier` in both the appview router and the community integration). The in-scope `config` is now also passed at every appview call site that resolves identities or PDS endpoints — the live-ingest refresh cycle (`runIngestCycle` → `refreshStaleIdentities`), the on-demand `refresh` path, and the router actor/identity/PDS resolution paths (`getProfile`, `getFeed`, collection queries, profile hydration, notify) — so private-network deploys honor the override on those paths instead of silently falling back to the public resolver and un-widened SSRF guard. - - The SSRF guard is now a single shared validator: `validateExternalUrl(url, additionalAllowedHosts?)` is exported from `contrail-base` and consumed by both the PDS client and labeler-endpoint resolution. `validateEndpointUrl` remains exported as a thin alias for backward compatibility. This removes the previous duplicate validator (`validatePdsUrl` + `validateEndpointUrl`) where an allowlist or SSRF-rule edit could be applied to only one copy. - - Also hardens schema initialization for concurrent/Postgres deployments: a dialect-aware `addColumnIfNotExists` (Postgres `ADD COLUMN IF NOT EXISTS`; SQLite pre-check), narrow absorption of the Postgres concurrent-`CREATE` race (42P07 / 23505 on pg_type/pg_class/pg_namespace indexes), and per-statement (rather than batched) DDL during `initSchema` / `initSpacesSchema` / spaces schema. Genuine DDL errors (syntax, type mismatch, missing column/table) still propagate. - -### Patch Changes - -- Updated dependencies [bea0dd2] -- Updated dependencies [d7e0936] - - @atmo-dev/contrail@0.8.0 - - @atmo-dev/contrail-base@0.8.0 - -## 0.7.0 - -### Minor Changes - -- 7e3145b: Spaces refactor: split authority + record host into independently runnable - roles, add space credentials, extract community into its own package. - - **Breaking — config shape** - - `spaces` is no longer flat — split into `authority` and `recordHost`: - - ```ts - // before - spaces: { - type: "com.example.event.space", - serviceDid: "did:web:example.com", - blobs: { adapter, maxSize }, - } - - // after - spaces: { - authority: { - type: "com.example.event.space", - serviceDid: "did:web:example.com", - signing: await generateAuthoritySigningKey(), - }, - recordHost: { - blobs: { adapter, maxSize }, - }, - } - ``` - - **Breaking — community moved to its own package** - - Community has been extracted to `@atmo-dev/contrail-community`. Wire it via - `createCommunityIntegration`: - - ```ts - import { Contrail, resolveConfig } from "@atmo-dev/contrail"; - import { createCommunityIntegration } from "@atmo-dev/contrail-community"; - - const resolved = resolveConfig(config); - const communityIntegration = createCommunityIntegration({ - db, - config: resolved, - }); - const contrail = new Contrail({ ...config, communityIntegration }); - ``` - - The community config (`config.community`) stays the same; only the wiring - moves. Imports of `CommunityAdapter`, `registerCommunityRoutes`, - `reconcile`, etc. now come from `@atmo-dev/contrail-community` instead of - `@atmo-dev/contrail`. - - **New — space credentials (`X-Space-Credential`)** - - The space authority issues short-lived ES256 JWTs (default 2h TTL) via - `<ns>.space.getCredential` and `refreshCredential`. The record host accepts - them on read/write paths in lieu of per-request service-auth JWTs. Skips - DID-doc fetches and member checks; the credential's signature is the proof. - - Generate a signing key once at deploy time: - - ```ts - import { generateAuthoritySigningKey } from "@atmo-dev/contrail"; - const signing = await generateAuthoritySigningKey(); - // Store the JWK; pass to spaces.authority.signing. - ``` - - **New — binding resolution** - - Verifiers can resolve "which authority signs for this space?" from three - sources, in order: local enrollment table, PDS records at - `at://<owner>/<type>/<key>`, DID-doc `#atproto_space_authority` service - entry, owner-self fallback. Lets user-owned DIDs authorize a third-party - authority via a normal PDS write — no DID-doc surgery. - - **New — independent deployments + enrollment** - - The authority and record host can run as separate processes/operators. - A new `<ns>.recordHost.enroll` endpoint lets owners (or authorities) - register a space onto a host. In-process deployments auto-enroll on - `createSpace`; nothing changes for single-instance setups. - - See `docs/10-deployment-shapes.md` for all-in-one / authority-only / - host-only configurations and when to choose each. - - **Migration** - - For most deployments running spaces today, the migration is: - - 1. Update the config: split `spaces.{type, serviceDid, blobs}` into - `spaces.authority.{type, serviceDid}` and `spaces.recordHost.{blobs}`. - 2. Generate and store an authority signing key - (`generateAuthoritySigningKey()`); add to `spaces.authority.signing`. - 3. If using community: install `@atmo-dev/contrail-community`, build - `createCommunityIntegration({ db, config })`, pass via - `new Contrail({ communityIntegration })` (or `createApp({ community })`). - - Existing service-auth JWT clients keep working as a fallback path. - Migrate to space credentials when convenient — exchange a JWT for a - credential once via `getCredential`, then reuse it. - -### Patch Changes - -- Updated dependencies [7e3145b] - - @atmo-dev/contrail@0.7.0 - - @atmo-dev/contrail-base@0.7.0 diff --git a/packages/contrail-community/package.json b/packages/contrail-community/package.json deleted file mode 100644 index cfac893..0000000 --- a/packages/contrail-community/package.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "name": "@atmo-dev/contrail-community", - "version": "0.12.2", - "description": "Community module for contrail — community-owned spaces with tiered access levels (member → moderator → admin), invite tokens, DID provisioning, and the access-level reconciler that keeps spaces_members in sync.", - "type": "module", - "sideEffects": false, - "files": [ - "dist" - ], - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "bin": { - "contrail-community": "./dist/cli/index.js" - }, - "repository": { - "type": "git", - "url": "https://github.com/flo-bit/contrail.git", - "directory": "packages/contrail-community" - }, - "keywords": [ - "atproto", - "contrail", - "community" - ], - "scripts": { - "build": "tsup", - "clean": "rm -rf dist", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "@atcute/atproto": "^3.1.10", - "@atcute/cbor": "^2.3.2", - "@atcute/identity": "^1.1.4", - "@atcute/identity-resolver": "^1.2.2", - "@atcute/lexicons": "^1.2.9", - "@atcute/xrpc-server": "^0.1.12", - "@atmo-dev/contrail": "workspace:*", - "@atmo-dev/contrail-base": "workspace:*", - "cac": "^7.0.0", - "hono": "^4.12.8" - }, - "peerDependencies": { - "pg": "^8.0.0", - "wrangler": "^4.0.0" - }, - "peerDependenciesMeta": { - "pg": { - "optional": true - }, - "wrangler": { - "optional": true - } - }, - "devDependencies": { - "@types/node": "^25.5.0", - "@types/pg": "^8.20.0", - "pg": "^8.20.0", - "tsup": "^8.5.0", - "typescript": "^5.7.3", - "vitest": "^4.1.0" - }, - "license": "MIT" -} diff --git a/packages/contrail-community/src/acl.ts b/packages/contrail-community/src/acl.ts deleted file mode 100644 index 5ef23a2..0000000 --- a/packages/contrail-community/src/acl.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { AccessLevel } from "./types"; -import { ACCESS_LEVELS, rankOf } from "./types"; -import type { CommunityAdapter } from "./adapter"; - -export interface EffectiveLevelOptions { - /** Hard cap on recursion depth when walking group-of-groups. */ - maxDepth?: number; -} - -const DEFAULT_MAX_DEPTH = 8; - -/** Resolve the effective access level a DID has in `spaceUri`. - * - * - Direct grants: rows where subject = did. - * - Indirect grants: rows where subject is another space the DID is - * (transitively) a member of. The path-level is the MIN of every level - * traversed, since delegation caps access at each hop. - * - * Returns `null` if the caller has no resolvable access. */ -export async function resolveEffectiveLevel( - adapter: CommunityAdapter, - spaceUri: string, - callerDid: string, - opts: EffectiveLevelOptions = {} -): Promise<AccessLevel | null> { - const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH; - // Walk: start with spaceUri. At each step we see who is a direct member; - // if the caller is present, record their level (capped by the path minimum). - let best: number = -1; - const visited = new Set<string>(); - - async function walk(targetSpace: string, pathMin: number, depth: number): Promise<void> { - if (depth > maxDepth) return; - if (visited.has(targetSpace)) return; - visited.add(targetSpace); - const rows = await adapter.listAccessRows(targetSpace); - for (const row of rows) { - const levelRank = rankOf(row.accessLevel); - const capped = pathMin < 0 ? levelRank : Math.min(pathMin, levelRank); - if (row.subjectDid === callerDid) { - if (capped > best) best = capped; - } else if (row.subjectSpaceUri) { - // Walk into the delegated space with the capped path level. - await walk(row.subjectSpaceUri, capped, depth + 1); - } - } - } - - await walk(spaceUri, -1, 0); - return best >= 0 ? ACCESS_LEVELS[best]! : null; -} - -/** Flatten the effective membership (DIDs with level ≥ `member`) for a space. - * Used by the reconciler to write `spaces_members`. */ -export async function flattenEffectiveMembers( - adapter: CommunityAdapter, - spaceUri: string, - opts: EffectiveLevelOptions = {} -): Promise<Set<string>> { - const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH; - const dids = new Set<string>(); - const visited = new Set<string>(); - - async function walk(targetSpace: string, depth: number): Promise<void> { - if (depth > maxDepth) return; - if (visited.has(targetSpace)) return; - visited.add(targetSpace); - const rows = await adapter.listAccessRows(targetSpace); - for (const row of rows) { - if (row.subjectDid) { - dids.add(row.subjectDid); - } else if (row.subjectSpaceUri) { - await walk(row.subjectSpaceUri, depth + 1); - } - } - } - - await walk(spaceUri, 0); - 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<Set<string>> { - const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH; - const reachable = new Set<string>(); - 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, - spaceUri: string, - subjectSpaceUri: string -): Promise<boolean> { - // A cycle exists if spaceUri is reachable from subjectSpaceUri via the - // subject-space graph. - const visited = new Set<string>(); - const stack: string[] = [subjectSpaceUri]; - while (stack.length) { - const s = stack.pop()!; - if (s === spaceUri) return true; - if (visited.has(s)) continue; - visited.add(s); - const rows = await adapter.listAccessRows(s); - for (const row of rows) { - if (row.subjectSpaceUri && !visited.has(row.subjectSpaceUri)) { - stack.push(row.subjectSpaceUri); - } - } - } - return false; -} - -export { ACCESS_LEVELS, rankOf }; diff --git a/packages/contrail-community/src/adapter.ts b/packages/contrail-community/src/adapter.ts deleted file mode 100644 index 39641f0..0000000 --- a/packages/contrail-community/src/adapter.ts +++ /dev/null @@ -1,656 +0,0 @@ -import type { Database } from "@atmo-dev/contrail"; -import type { - AccessLevel, - AccessLevelRow, - CommunityInviteRow, - CommunityMode, - CommunityRow, - CreateCommunityInviteInput, - CreateProvisionAttemptInput, - ProvisionAttemptRow, - ProvisionStatus, -} from "./types"; - -function toNum(v: unknown): number { - return typeof v === "string" ? Number(v) : (v as number); -} - -function mapCommunityRow(row: any): CommunityRow { - return { - did: row.did, - mode: row.mode as CommunityMode, - pdsEndpoint: row.pds_endpoint ?? null, - identifier: row.identifier ?? null, - createdBy: row.created_by, - createdAt: toNum(row.created_at), - deletedAt: row.deleted_at == null ? null : toNum(row.deleted_at), - }; -} - -function mapAccessRow(row: any): AccessLevelRow { - const isSpace = row.subject_kind === "space"; - return { - spaceUri: row.space_uri, - subjectDid: isSpace ? null : row.subject, - subjectSpaceUri: isSpace ? row.subject : null, - accessLevel: row.access_level as AccessLevel, - grantedBy: row.granted_by, - grantedAt: toNum(row.granted_at), - }; -} - -export interface CreateAdoptedCommunityInput { - did: string; - pdsEndpoint: string; - appPasswordEncrypted: string; // base64 envelope - identifier: string; - createdBy: string; -} - -export interface CreateMintedCommunityInput { - did: string; - signingKeyEncrypted: string; // base64 envelope - rotationKeyEncrypted: string; // base64 envelope - createdBy: string; -} - -export interface CreateProvisionedCommunityInput { - did: string; - pdsEndpoint: string; - handle: string; - /** Encrypted PDS app password — already-encrypted base64 envelope. The - * orchestrator persisted this on the provision_attempts row after a - * post-activation `createAppPassword` call; the route handler hands it - * through so we keep one source of truth for the credential and avoid - * round-tripping the plaintext password through the adapter. */ - appPasswordEncrypted: string; - createdBy: string; -} - -export interface GrantInput { - spaceUri: string; - subjectDid?: string; - subjectSpaceUri?: string; - accessLevel: AccessLevel; - grantedBy: string; -} - -export class CommunityAdapter { - constructor(private readonly db: Database) {} - - // ---- Communities ------------------------------------------------------- - - async createAdoptedCommunity(input: CreateAdoptedCommunityInput): Promise<CommunityRow> { - const now = Date.now(); - await this.db - .prepare( - `INSERT INTO communities (did, mode, pds_endpoint, app_password_encrypted, identifier, created_by, created_at) - VALUES (?, 'adopt', ?, ?, ?, ?, ?)` - ) - .bind( - input.did, - input.pdsEndpoint, - input.appPasswordEncrypted, - input.identifier, - input.createdBy, - now - ) - .run(); - return { - did: input.did, - mode: "adopt", - pdsEndpoint: input.pdsEndpoint, - identifier: input.identifier, - createdBy: input.createdBy, - createdAt: now, - deletedAt: null, - }; - } - - async createFromProvisioned( - input: CreateProvisionedCommunityInput - ): Promise<CommunityRow> { - const now = Date.now(); - await this.db - .prepare( - `INSERT INTO communities (did, mode, pds_endpoint, app_password_encrypted, identifier, created_by, created_at) - VALUES (?, 'provision', ?, ?, ?, ?, ?)` - ) - .bind( - input.did, - input.pdsEndpoint, - input.appPasswordEncrypted, - input.handle, - input.createdBy, - now - ) - .run(); - return { - did: input.did, - mode: "provision", - pdsEndpoint: input.pdsEndpoint, - identifier: input.handle, - createdBy: input.createdBy, - createdAt: now, - deletedAt: null, - }; - } - - async createMintedCommunity(input: CreateMintedCommunityInput): Promise<CommunityRow> { - const now = Date.now(); - await this.db - .prepare( - `INSERT INTO communities (did, mode, signing_key_encrypted, rotation_key_encrypted, created_by, created_at) - VALUES (?, 'mint', ?, ?, ?, ?)` - ) - .bind( - input.did, - input.signingKeyEncrypted, - input.rotationKeyEncrypted, - input.createdBy, - now - ) - .run(); - return { - did: input.did, - mode: "mint", - pdsEndpoint: null, - identifier: null, - createdBy: input.createdBy, - createdAt: now, - deletedAt: null, - }; - } - - async getCommunity(did: string): Promise<CommunityRow | null> { - const row = await this.db - .prepare(`SELECT * FROM communities WHERE did = ? AND deleted_at IS NULL`) - .bind(did) - .first<any>(); - return row ? mapCommunityRow(row) : null; - } - - /** Look up the raw encrypted credential strings for the community. */ - async getRawCredentials(did: string): Promise<{ - pdsEndpoint: string | null; - appPasswordEncrypted: string | null; - signingKeyEncrypted: string | null; - rotationKeyEncrypted: string | null; - identifier: string | null; - mode: CommunityMode; - } | null> { - const row = await this.db - .prepare( - `SELECT mode, pds_endpoint, app_password_encrypted, signing_key_encrypted, - rotation_key_encrypted, identifier - FROM communities WHERE did = ? AND deleted_at IS NULL` - ) - .bind(did) - .first<any>(); - if (!row) return null; - return { - mode: row.mode as CommunityMode, - pdsEndpoint: row.pds_endpoint ?? null, - appPasswordEncrypted: row.app_password_encrypted ?? null, - signingKeyEncrypted: row.signing_key_encrypted ?? null, - rotationKeyEncrypted: row.rotation_key_encrypted ?? null, - identifier: row.identifier ?? null, - }; - } - - async updateAdoptedCredentials(input: { - did: string; - pdsEndpoint: string; - appPasswordEncrypted: string; - identifier: string; - }): Promise<void> { - await this.db - .prepare( - `UPDATE communities - SET pds_endpoint = ?, app_password_encrypted = ?, identifier = ? - WHERE did = ? AND mode = 'adopt'` - ) - .bind(input.pdsEndpoint, input.appPasswordEncrypted, input.identifier, input.did) - .run(); - } - - /** Direct access-level rows held by the given DID subject. Used as the seed - * set for reverse-graph traversal. */ - async listAccessRowsForSubject(subjectDid: string): Promise<AccessLevelRow[]> { - const { results } = await this.db - .prepare( - `SELECT * FROM community_access_levels - WHERE subject_kind = 'did' AND subject = ?` - ) - .bind(subjectDid) - .all<any>(); - return results.map(mapAccessRow); - } - - /** Communities owning any of the given space URIs. */ - async listCommunitiesOwningSpaces(spaceUris: string[]): Promise<CommunityRow[]> { - 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 - WHERE c.deleted_at IS NULL AND s.uri IN (${placeholders}) - ORDER BY c.created_at DESC` - ) - .bind(...spaceUris) - .all<any>(); - return results.map(mapCommunityRow); - } - - async softDeleteCommunity(did: string): Promise<void> { - const now = Date.now(); - await this.db - .prepare(`UPDATE communities SET deleted_at = ? WHERE did = ?`) - .bind(now, did) - .run(); - // Cascade soft-delete all spaces owned by the community. - await this.db - .prepare( - `UPDATE spaces SET deleted_at = ? WHERE owner_did = ? AND deleted_at IS NULL` - ) - .bind(now, did) - .run(); - } - - // ---- Access levels ----------------------------------------------------- - - async grant(input: GrantInput): Promise<void> { - if ((input.subjectDid ? 1 : 0) + (input.subjectSpaceUri ? 1 : 0) !== 1) { - throw new Error("grant requires exactly one of subjectDid or subjectSpaceUri"); - } - const kind = input.subjectDid ? "did" : "space"; - const subject = (input.subjectDid ?? input.subjectSpaceUri)!; - const now = Date.now(); - await this.db - .prepare( - `INSERT INTO community_access_levels (space_uri, subject, subject_kind, access_level, granted_by, granted_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT (space_uri, subject) DO UPDATE SET - access_level = excluded.access_level, - granted_by = excluded.granted_by, - granted_at = excluded.granted_at` - ) - .bind(input.spaceUri, subject, kind, input.accessLevel, input.grantedBy, now) - .run(); - } - - async revoke(input: { - spaceUri: string; - subjectDid?: string; - subjectSpaceUri?: string; - }): Promise<void> { - if ((input.subjectDid ? 1 : 0) + (input.subjectSpaceUri ? 1 : 0) !== 1) { - throw new Error("revoke requires exactly one of subjectDid or subjectSpaceUri"); - } - const subject = (input.subjectDid ?? input.subjectSpaceUri)!; - await this.db - .prepare( - `DELETE FROM community_access_levels WHERE space_uri = ? AND subject = ?` - ) - .bind(input.spaceUri, subject) - .run(); - } - - async getAccessRow( - spaceUri: string, - subject: string - ): Promise<AccessLevelRow | null> { - const row = await this.db - .prepare( - `SELECT * FROM community_access_levels WHERE space_uri = ? AND subject = ?` - ) - .bind(spaceUri, subject) - .first<any>(); - return row ? mapAccessRow(row) : null; - } - - async listAccessRows(spaceUri: string): Promise<AccessLevelRow[]> { - const { results } = await this.db - .prepare( - `SELECT * FROM community_access_levels WHERE space_uri = ? ORDER BY granted_at ASC` - ) - .bind(spaceUri) - .all<any>(); - return results.map(mapAccessRow); - } - - /** Spaces that reference `subjectSpaceUri` as a member — used for reverse-graph reconciliation. */ - async listSpacesDelegatingTo(subjectSpaceUri: string): Promise<string[]> { - const { results } = await this.db - .prepare( - `SELECT DISTINCT space_uri FROM community_access_levels - WHERE subject_kind = 'space' AND subject = ?` - ) - .bind(subjectSpaceUri) - .all<any>(); - return results.map((r: any) => r.space_uri); - } - - async deleteAllAccessForSpace(spaceUri: string): Promise<void> { - await this.db - .prepare(`DELETE FROM community_access_levels WHERE space_uri = ?`) - .bind(spaceUri) - .run(); - } - - // ---- Invites ----------------------------------------------------------- - - async createInvite(input: CreateCommunityInviteInput): Promise<CommunityInviteRow> { - const now = Date.now(); - await this.db - .prepare( - `INSERT INTO community_invites - (token_hash, space_uri, access_level, created_by, created_at, expires_at, max_uses, used_count, note) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)` - ) - .bind( - input.tokenHash, - input.spaceUri, - input.accessLevel, - input.createdBy, - now, - input.expiresAt, - input.maxUses, - input.note - ) - .run(); - return { - tokenHash: input.tokenHash, - spaceUri: input.spaceUri, - accessLevel: input.accessLevel, - createdBy: input.createdBy, - createdAt: now, - expiresAt: input.expiresAt, - maxUses: input.maxUses, - usedCount: 0, - revokedAt: null, - note: input.note, - }; - } - - async getInvite(tokenHash: string): Promise<CommunityInviteRow | null> { - const row = await this.db - .prepare(`SELECT * FROM community_invites WHERE token_hash = ?`) - .bind(tokenHash) - .first<any>(); - return row ? mapCommunityInviteRow(row) : null; - } - - async listInvites( - spaceUri: string, - options: { includeRevoked?: boolean } = {} - ): Promise<CommunityInviteRow[]> { - const sql = options.includeRevoked - ? `SELECT * FROM community_invites WHERE space_uri = ? ORDER BY created_at DESC` - : `SELECT * FROM community_invites WHERE space_uri = ? AND revoked_at IS NULL ORDER BY created_at DESC`; - const { results } = await this.db.prepare(sql).bind(spaceUri).all<any>(); - return results.map(mapCommunityInviteRow); - } - - async revokeInvite(tokenHash: string): Promise<boolean> { - const res = await this.db - .prepare( - `UPDATE community_invites SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL` - ) - .bind(Date.now(), tokenHash) - .run(); - const changes = (res as any)?.changes ?? (res as any)?.meta?.changes ?? 0; - return Number(changes) > 0; - } - - /** Atomically consume one "use" from an invite. Returns the updated row if - * the invite is currently usable, null otherwise (expired, revoked, - * exhausted, or missing). The router calls this, then issues a grant if - * the return is non-null. */ - async redeemInvite(tokenHash: string, now: number): Promise<CommunityInviteRow | null> { - const res = await this.db - .prepare( - `UPDATE community_invites - SET used_count = used_count + 1 - WHERE token_hash = ? - AND revoked_at IS NULL - AND (expires_at IS NULL OR expires_at > ?) - AND (max_uses IS NULL OR used_count < max_uses)` - ) - .bind(tokenHash, now) - .run(); - const changes = (res as any)?.changes ?? (res as any)?.meta?.changes ?? 0; - if (Number(changes) === 0) return null; - - const row = await this.db - .prepare(`SELECT * FROM community_invites WHERE token_hash = ?`) - .bind(tokenHash) - .first<any>(); - return row ? mapCommunityInviteRow(row) : null; - } - - // ---- Provision attempts ------------------------------------------------ - - async createProvisionAttempt(input: CreateProvisionAttemptInput): Promise<void> { - const now = Date.now(); - await this.db - .prepare( - `INSERT INTO provision_attempts ( - attempt_id, did, status, pds_endpoint, handle, email, invite_code, - encrypted_signing_key, encrypted_rotation_key, - created_at, updated_at - ) VALUES (?, ?, 'keys_generated', ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .bind( - input.attemptId, - input.did, - input.pdsEndpoint, - input.handle, - input.email, - input.inviteCode ?? null, - input.encryptedSigningKey, - input.encryptedRotationKey, - now, - now - ) - .run(); - } - - async getProvisionAttempt(attemptId: string): Promise<ProvisionAttemptRow | null> { - const row = await this.db - .prepare(`SELECT * FROM provision_attempts WHERE attempt_id = ?`) - .bind(attemptId) - .first<Record<string, any>>(); - return row ? rowToProvisionAttempt(row) : null; - } - - async updateProvisionStatus( - attemptId: string, - status: ProvisionStatus, - opts: { lastError?: string; encryptedPassword?: string } = {} - ): Promise<void> { - const now = Date.now(); - const stampCol = ({ - genesis_submitted: "genesis_submitted_at", - account_created: "account_created_at", - did_doc_updated: "did_doc_updated_at", - activated: "activated_at", - } as Record<string, string | undefined>)[status]; - - const sets: string[] = [`status = ?`, `updated_at = ?`]; - const args: any[] = [status, now]; - if (stampCol) { - sets.push(`${stampCol} = ?`); - args.push(now); - } - if (opts.lastError !== undefined) { - sets.push(`last_error = ?`); - args.push(opts.lastError); - } - if (opts.encryptedPassword !== undefined) { - sets.push(`encrypted_password = ?`); - args.push(opts.encryptedPassword); - } - args.push(attemptId); - - await this.db - .prepare(`UPDATE provision_attempts SET ${sets.join(", ")} WHERE attempt_id = ?`) - .bind(...args) - .run(); - } - - /** List provision attempts that did NOT reach `activated` AND have been - * idle for at least `olderThanMs` (no update within that window). These are - * the rows reap can act on: a non-terminal status means the flow stopped - * partway, leaving (typically) a dangling DID in PLC that needs - * tombstoning. The age floor is mandatory and exists so a bulk reap can - * never select an in-flight attempt that is mid-state-machine (seconds old) - * and tombstone a DID that was about to activate. Pass `0` to disable the - * floor (e.g. a test, or an operator who has confirmed nothing is running). */ - async listStuckAttempts(olderThanMs: number): Promise<ProvisionAttemptRow[]> { - const cutoff = Date.now() - olderThanMs; - const rows = await this.db - .prepare( - `SELECT * FROM provision_attempts - WHERE status != 'activated' AND updated_at <= ? - ORDER BY updated_at ASC` - ) - .bind(cutoff) - .all<Record<string, any>>(); - return rows.results.map(rowToProvisionAttempt); - } - - /** Move a stuck provision_attempts row into the archive table after reap - * has tombstoned its DID in PLC. Insert + delete run as a single - * `db.batch()`, which is atomic on Postgres (BEGIN/COMMIT) and D1; on the - * plain sqlite adapter it is not, so the archive INSERT is also idempotent - * (`ON CONFLICT (attempt_id) DO NOTHING`). Together that makes a - * retry-after-partial-failure safe: if a prior run's INSERT landed but its - * DELETE did not, the row is stranded in both tables; re-running archives - * cleanly (the INSERT is a no-op, the DELETE removes the live row) instead - * of dying on a PRIMARY KEY conflict. */ - async archiveStuckAttempt( - attemptId: string, - opts: { tombstoneOpCid?: string | null; notes?: string | null } = {} - ): Promise<void> { - const row = await this.db - .prepare(`SELECT * FROM provision_attempts WHERE attempt_id = ?`) - .bind(attemptId) - .first<Record<string, any>>(); - if (!row) { - throw new Error(`provision_attempt not found: ${attemptId}`); - } - const now = Date.now(); - const insert = this.db - .prepare( - `INSERT INTO provision_attempts_archive ( - attempt_id, did, pds_endpoint, handle, email, invite_code, - last_status, last_error, - archived_at, tombstone_op_cid, notes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (attempt_id) DO NOTHING` - ) - .bind( - row.attempt_id, - row.did, - row.pds_endpoint, - row.handle, - row.email, - row.invite_code ?? null, - row.status, - row.last_error ?? null, - now, - opts.tombstoneOpCid ?? null, - opts.notes ?? null - ); - const del = this.db - .prepare(`DELETE FROM provision_attempts WHERE attempt_id = ?`) - .bind(attemptId); - await this.db.batch([insert, del]); - } - - // ---- Community sessions cache ----------------------------------------- - - async getSession(communityDid: string): Promise<{ - accessJwt: string; - refreshJwt: string; - accessExp: number; - } | null> { - const r = await this.db - .prepare( - `SELECT access_jwt, refresh_jwt, access_exp FROM community_sessions WHERE community_did = ?` - ) - .bind(communityDid) - .first<{ access_jwt: string; refresh_jwt: string; access_exp: number }>(); - if (!r) return null; - return { - accessJwt: r.access_jwt, - refreshJwt: r.refresh_jwt, - accessExp: Number(r.access_exp), - }; - } - - async upsertSession( - communityDid: string, - s: { accessJwt: string; refreshJwt: string; accessExp: number } - ): Promise<void> { - const now = Date.now(); - await this.db - .prepare( - `INSERT INTO community_sessions (community_did, access_jwt, refresh_jwt, access_exp, updated_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT (community_did) DO UPDATE SET - access_jwt = excluded.access_jwt, - refresh_jwt = excluded.refresh_jwt, - access_exp = excluded.access_exp, - updated_at = excluded.updated_at` - ) - .bind(communityDid, s.accessJwt, s.refreshJwt, s.accessExp, now) - .run(); - } - - async clearSession(communityDid: string): Promise<void> { - await this.db - .prepare(`DELETE FROM community_sessions WHERE community_did = ?`) - .bind(communityDid) - .run(); - } -} - -function rowToProvisionAttempt(r: Record<string, any>): ProvisionAttemptRow { - return { - attemptId: r.attempt_id, - did: r.did, - status: r.status as ProvisionStatus, - pdsEndpoint: r.pds_endpoint, - handle: r.handle, - email: r.email, - inviteCode: r.invite_code ?? null, - encryptedSigningKey: r.encrypted_signing_key ?? null, - encryptedRotationKey: r.encrypted_rotation_key ?? null, - encryptedPassword: r.encrypted_password ?? null, - genesisSubmittedAt: r.genesis_submitted_at == null ? null : Number(r.genesis_submitted_at), - accountCreatedAt: r.account_created_at == null ? null : Number(r.account_created_at), - didDocUpdatedAt: r.did_doc_updated_at == null ? null : Number(r.did_doc_updated_at), - activatedAt: r.activated_at == null ? null : Number(r.activated_at), - lastError: r.last_error ?? null, - createdAt: Number(r.created_at), - updatedAt: Number(r.updated_at), - }; -} - -function mapCommunityInviteRow(row: any): CommunityInviteRow { - return { - tokenHash: row.token_hash, - spaceUri: row.space_uri, - accessLevel: row.access_level as AccessLevel, - createdBy: row.created_by, - createdAt: toNum(row.created_at), - expiresAt: row.expires_at == null ? null : toNum(row.expires_at), - maxUses: row.max_uses == null ? null : Number(row.max_uses), - usedCount: Number(row.used_count ?? 0), - revokedAt: row.revoked_at == null ? null : toNum(row.revoked_at), - note: row.note ?? null, - }; -} diff --git a/packages/contrail-community/src/cli/index.ts b/packages/contrail-community/src/cli/index.ts deleted file mode 100644 index 15fe32b..0000000 --- a/packages/contrail-community/src/cli/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node -/** - * contrail-community — CLI entrypoint. - * - * Exposes `reap` (tombstone stuck provision_attempts in PLC). This lives here, - * as contrail-community's own bin, rather than only as a dynamically-imported - * subcommand of `contrail`: the PR #30 package split removed contrail's - * dependency edge into community code, so under pnpm's isolated node_modules - * `contrail` cannot resolve `@atmo-dev/contrail-community` and the dynamic - * import there silently no-ops. Shipping the bin here guarantees `reap` is - * always reachable wherever contrail-community is installed. - * - * Config loading is reconstructed from contrail's public `./cli-config` - * helpers so contrail-community needs no copy of contrail's CLI plumbing and - * no new dependency edge (it already depends on `@atmo-dev/contrail`). - */ -import { cac } from "cac"; -import { - findConfigFile, - loadConfig, - CONFIG_CANDIDATES_MESSAGE, -} from "@atmo-dev/contrail/cli-config"; -import { registerReap } from "./reap.js"; - -const cli = cac("contrail-community"); - -registerReap(cli, { - resolveAndLoadConfig: async (opts) => { - const root = opts.root ?? process.cwd(); - const path = findConfigFile(root, opts.config); - if (!path) { - console.error( - "Could not find a Contrail config. Pass --config <path> or place one at\n" + - ` ${CONFIG_CANDIDATES_MESSAGE}` - ); - process.exit(1); - } - return loadConfig(path); - }, -}); - -cli.help(); - -try { - cli.parse(); -} catch (err) { - console.error(err); - process.exit(1); -} diff --git a/packages/contrail-community/src/cli/reap.ts b/packages/contrail-community/src/cli/reap.ts deleted file mode 100644 index b053f99..0000000 --- a/packages/contrail-community/src/cli/reap.ts +++ /dev/null @@ -1,397 +0,0 @@ -import type { CAC } from "cac"; -import { createInterface } from "node:readline/promises"; -import { stdin as input, stdout as output } from "node:process"; -import type { Database } from "@atmo-dev/contrail-base"; -import { CommunityAdapter } from "../adapter.js"; -import { CredentialCipher } from "../credentials.js"; -import { - buildTombstoneOp, - cidForOp, - getLastOpCid, - signTombstoneOp, - submitTombstoneOp, -} from "../plc.js"; - -interface ReapOpts { - config?: string; - root?: string; - remote?: boolean; - binding: string; - attemptId?: string; - allStuck?: boolean; - dryRun?: boolean; - yes?: boolean; - olderThan?: number; - db?: string; -} - -/** Minimal logger interface so `runReap` can be invoked from tests with - * silent stubs and from the CLI shim with `console`. */ -export interface ReapLogger { - log(...args: unknown[]): void; - error(...args: unknown[]): void; -} - -/** Default idle window for `--all-stuck`: a row must have gone untouched this - * long before bulk reap will consider tombstoning it. Provisioning is a - * fast (seconds) state machine, so 30 minutes is a wide safety margin that - * still reaps genuinely-abandoned rows. Operators can override via - * `--older-than <minutes>`. */ -export const DEFAULT_REAP_AGE_FLOOR_MS = 30 * 60 * 1000; - -/** Where reap should read its provision_attempts rows from. Postgres (the - * decoupled external index) is selected by an explicit `--db <url>` or, as a - * convenience matching the apps/postgres deployment, a `DATABASE_URL` in the - * environment. Otherwise reap uses the Cloudflare D1 binding via wrangler. - * An explicit `--db` always wins over the env var. */ -export type ReapDbSource = - | { kind: "postgres"; url: string } - | { kind: "d1" }; - -export function chooseReapDbSource(opts: { - db?: string; - databaseUrl?: string; -}): ReapDbSource { - const url = opts.db ?? opts.databaseUrl; - return url ? { kind: "postgres", url } : { kind: "d1" }; -} - -export interface RunReapOptions { - adapter: CommunityAdapter; - cipher: CredentialCipher; - plcDirectory: string; - fetch?: typeof fetch; - logger: ReapLogger; - /** Skip confirmation prompts. The CLI passes this when the user supplied - * --yes; tests always pass true since they don't have a TTY. */ - yes: boolean; - attemptId?: string; - allStuck?: boolean; - dryRun?: boolean; - /** Idle-age floor for `--all-stuck` (milliseconds). Rows updated more - * recently than this are left alone so a bulk reap can't tombstone an - * in-flight provision. Ignored for the single `--attempt-id` path, which - * is an explicit operator action on a known row. Defaults to - * DEFAULT_REAP_AGE_FLOOR_MS. */ - olderThanMs?: number; -} - -export interface RunReapResult { - ok: boolean; - /** Validation/precondition error, present when ok=false. */ - error?: string; - /** Number of rows successfully reaped (PLC tombstone submitted + archived). */ - reaped: number; - /** Number of rows skipped because of --dry-run. */ - dryRunSkipped: number; - /** Number of rows that errored out during reap (activated row, PLC error, etc.). */ - errors: number; -} - -/** Yes/no prompt that respects --yes (auto-accept) and falls back to the - * provided default in non-TTY environments — the user isn't there to answer. */ -async function promptYesNo( - question: string, - defaultYes: boolean, - autoYes: boolean -): Promise<boolean> { - if (autoYes) return true; - if (!input.isTTY) return false; - const rl = createInterface({ input, output }); - try { - const hint = defaultYes ? "[Y/n]" : "[y/N]"; - const ans = (await rl.question(`${question} ${hint} `)).trim().toLowerCase(); - if (ans === "") return defaultYes; - return ans === "y" || ans === "yes"; - } finally { - rl.close(); - } -} - -/** Core reap logic, decoupled from the CAC plumbing so tests can drive it - * without going through `getPlatformProxy()`. */ -export async function runReap(opts: RunReapOptions): Promise<RunReapResult> { - const result: RunReapResult = { - ok: true, - reaped: 0, - dryRunSkipped: 0, - errors: 0, - }; - - // Safety default: an operator who omits the flag must NOT trigger - // irrevocable PLC tombstones. Real action requires an explicit - // `dryRun: false` (CLI: `--no-dry-run`). - const dryRun = opts.dryRun ?? true; - - const hasAttemptId = !!opts.attemptId; - const hasAllStuck = !!opts.allStuck; - if (!hasAttemptId && !hasAllStuck) { - return { - ...result, - ok: false, - error: "Specify exactly one of --attempt-id <uuid> or --all-stuck.", - }; - } - if (hasAttemptId && hasAllStuck) { - return { - ...result, - ok: false, - error: - "--attempt-id and --all-stuck are mutually exclusive; pass exactly one.", - }; - } - - const olderThanMs = opts.olderThanMs ?? DEFAULT_REAP_AGE_FLOOR_MS; - const rows = hasAttemptId - ? await loadSingle(opts.adapter, opts.attemptId!) - : await opts.adapter.listStuckAttempts(olderThanMs); - - if (rows.length === 0) { - opts.logger.log("No stuck provision_attempts to reap."); - return result; - } - - for (const row of rows) { - if (row.status === "activated") { - opts.logger.error( - `Refusing to reap ${row.attemptId}: status is "activated"; reap will not tombstone live communities.` - ); - result.errors += 1; - continue; - } - - opts.logger.log( - `Reaping ${row.attemptId} (did=${row.did}, status=${row.status})` - ); - - if (!row.encryptedRotationKey) { - opts.logger.error( - ` no encrypted_rotation_key on ${row.attemptId}; cannot sign tombstone` - ); - result.errors += 1; - continue; - } - - let rotationJwk: JsonWebKey; - try { - const decoded = await opts.cipher.decryptString(row.encryptedRotationKey); - rotationJwk = JSON.parse(decoded) as JsonWebKey; - } catch (err) { - opts.logger.error( - ` failed to decrypt rotation key for ${row.attemptId}: ${err instanceof Error ? err.message : err}` - ); - result.errors += 1; - continue; - } - - let prev: string; - try { - prev = await getLastOpCid(opts.plcDirectory, row.did, { - fetch: opts.fetch, - }); - } catch (err) { - opts.logger.error( - ` failed to fetch last PLC op cid for ${row.did}: ${err instanceof Error ? err.message : err}` - ); - result.errors += 1; - continue; - } - - const unsigned = buildTombstoneOp(prev); - const signed = await signTombstoneOp(unsigned, rotationJwk); - const opCid = await cidForOp(signed); - - if (dryRun) { - opts.logger.log( - ` [dry-run] would submit tombstone (op cid=${opCid}, prev=${prev})` - ); - result.dryRunSkipped += 1; - continue; - } - - if (!opts.yes) { - const confirmed = await promptYesNo( - `submit PLC tombstone for ${row.did}?`, - false, - false - ); - if (!confirmed) { - opts.logger.log(` skipped ${row.attemptId} (not confirmed)`); - continue; - } - } - - try { - await submitTombstoneOp(opts.plcDirectory, row.did, signed, { - fetch: opts.fetch, - }); - } catch (err) { - opts.logger.error( - ` PLC tombstone submit failed for ${row.did}: ${err instanceof Error ? err.message : err}` - ); - result.errors += 1; - continue; - } - - try { - await opts.adapter.archiveStuckAttempt(row.attemptId, { - tombstoneOpCid: opCid, - }); - } catch (err) { - opts.logger.error( - ` archive failed for ${row.attemptId} after tombstone submit: ${err instanceof Error ? err.message : err}` - ); - result.errors += 1; - continue; - } - - result.reaped += 1; - } - - opts.logger.log( - `Reaped ${result.reaped} attempts (${result.dryRunSkipped} dry-run skipped, ${result.errors} errors).` - ); - return result; -} - -async function loadSingle( - adapter: CommunityAdapter, - attemptId: string -): Promise<Awaited<ReturnType<CommunityAdapter["listStuckAttempts"]>>> { - const row = await adapter.getProvisionAttempt(attemptId); - return row ? [row] : []; -} - -/** Dependency-injection shape: host CLI (contrail) loads its own config and - * passes the result, so contrail-community doesn't depend on contrail's - * cli-config infrastructure. */ -export interface ReapHostDeps { - /** Returns a contrail config; host is expected to exit(1) on its own if - * no config is found. The shape is opaque to reap. */ - resolveAndLoadConfig: (opts: { config?: string; root?: string }) => Promise<{ - community?: { - plcDirectory?: string; - masterKey: Uint8Array; - }; - }>; -} - -export function registerReap(cli: CAC, host: ReapHostDeps): void { - cli - .command( - "reap", - "Tombstone stuck provision_attempts rows in PLC and archive them" - ) - .option("--config <path>", "Path to Contrail config file") - .option("--root <path>", "Project root for auto-detection (default: CWD)") - .option("--remote", "Use production D1 bindings") - .option("--binding <name>", "D1 binding name in wrangler.jsonc", { - default: "DB", - }) - .option( - "--db <url>", - "Postgres connection string for the decoupled external index. When set (or DATABASE_URL is in the env), reap runs against Postgres instead of the D1 binding." - ) - .option("--attempt-id <uuid>", "Reap a single attempt by ID") - .option( - "--all-stuck", - "Reap every provision_attempts row that did not reach status=activated and has been idle past --older-than" - ) - .option( - "--older-than <minutes>", - "With --all-stuck, only reap rows idle at least this many minutes (guards in-flight provisions)", - { default: DEFAULT_REAP_AGE_FLOOR_MS / 60_000 } - ) - .option( - "--dry-run", - "Print what would be tombstoned without submitting to PLC (DEFAULT)" - ) - .option( - "--no-dry-run", - "Actually submit tombstones to PLC. Irrevocable. Required for real runs." - ) - .option("--yes", "Auto-confirm prompts") - .action(async (options: ReapOpts) => { - // Flag validation (exactly one of --attempt-id / --all-stuck) lives in - // runReap, the testable core; the `!result.ok` branch below surfaces its - // error and exits non-zero, so there's no second copy of it here. - const config = await host.resolveAndLoadConfig(options); - const community = config.community; - if (!community) { - console.error( - "config.community is not set; reap requires a configured community module." - ); - process.exit(1); - } - if (!community.plcDirectory) { - console.error( - "config.community.plcDirectory is required for `reap`." - ); - process.exit(1); - } - - // Run reap against the acquired db, then exit non-zero on any failure. - const reapAndExit = async (db: Database): Promise<void> => { - const result = await runReap({ - adapter: new CommunityAdapter(db), - cipher: new CredentialCipher(community.masterKey), - plcDirectory: community.plcDirectory!, - logger: console, - yes: !!options.yes, - attemptId: options.attemptId, - allStuck: options.allStuck, - dryRun: options.dryRun, - olderThanMs: - options.olderThan !== undefined - ? Number(options.olderThan) * 60_000 - : undefined, - }); - if (!result.ok) { - console.error(result.error); - process.exit(1); - } - if (result.errors > 0) { - process.exit(1); - } - }; - - const source = chooseReapDbSource({ - db: options.db, - databaseUrl: process.env.DATABASE_URL, - }); - - if (source.kind === "postgres") { - // Decoupled external index. Build a pool, run, then close it. - const pg = (await import("pg")).default; - const { createPostgresDatabase } = await import( - "@atmo-dev/contrail/postgres" as string - ); - console.log("reap: using Postgres index"); - const pool = new pg.Pool({ connectionString: source.url }); - try { - await reapAndExit(createPostgresDatabase(pool)); - } finally { - await pool.end(); - } - return; - } - - // D1 via wrangler. - const { getPlatformProxy } = await import("wrangler"); - const { env, dispose } = await getPlatformProxy(); - try { - const db = (env as Record<string, unknown>)[options.binding] as - | Database - | undefined; - if (!db) { - console.error( - `D1 binding "${options.binding}" not found in wrangler env.` - ); - process.exit(1); - } - await reapAndExit(db); - } finally { - await dispose(); - } - }); -} diff --git a/packages/contrail-community/src/credentials.ts b/packages/contrail-community/src/credentials.ts deleted file mode 100644 index 3acb5c2..0000000 --- a/packages/contrail-community/src/credentials.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** Envelope-encryption helpers for community credentials. - * AES-GCM with a 32-byte master key provided via config. */ - -const IV_LEN = 12; - -function normalizeKey(key: Uint8Array | string): Uint8Array { - if (typeof key !== "string") { - if (key.length !== 32) { - throw new Error(`community master key must be 32 bytes, got ${key.length}`); - } - return key; - } - // Try base64 first, fall back to hex. - const bytes = tryBase64(key) ?? tryHex(key); - if (!bytes) { - throw new Error( - "community master key must be a 32-byte Uint8Array or base64/hex string" - ); - } - if (bytes.length !== 32) { - throw new Error(`community master key must decode to 32 bytes, got ${bytes.length}`); - } - return bytes; -} - -function tryBase64(s: string): Uint8Array | null { - try { - const normal = s.replace(/-/g, "+").replace(/_/g, "/"); - const padded = normal + "=".repeat((4 - (normal.length % 4)) % 4); - if (!/^[A-Za-z0-9+/]*=*$/.test(padded)) return null; - const bin = atob(padded); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; - } catch { - return null; - } -} - -function tryHex(s: string): Uint8Array | null { - if (!/^[0-9a-fA-F]+$/.test(s) || s.length % 2 !== 0) return null; - const out = new Uint8Array(s.length / 2); - for (let i = 0; i < out.length; i++) { - out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); - } - return out; -} - -function bytesToBase64(bytes: Uint8Array): string { - let bin = ""; - for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); - return btoa(bin); -} - -function base64ToBytes(s: string): Uint8Array { - const bin = atob(s); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -async function importKey(raw: Uint8Array): Promise<CryptoKey> { - return crypto.subtle.importKey( - "raw", - raw as BufferSource, - { name: "AES-GCM" }, - false, - ["encrypt", "decrypt"] - ); -} - -export class CredentialCipher { - private readonly keyPromise: Promise<CryptoKey>; - - constructor(masterKey: Uint8Array | string) { - this.keyPromise = importKey(normalizeKey(masterKey)); - } - - /** Encrypts `plaintext` and returns a base64 string containing iv || ciphertext. */ - async encrypt(plaintext: string | Uint8Array): Promise<string> { - const key = await this.keyPromise; - const iv = crypto.getRandomValues(new Uint8Array(IV_LEN)); - const bytes = - typeof plaintext === "string" - ? new TextEncoder().encode(plaintext) - : plaintext; - const ct = await crypto.subtle.encrypt( - { name: "AES-GCM", iv: iv as BufferSource }, - key, - bytes as BufferSource - ); - const combined = new Uint8Array(iv.length + ct.byteLength); - combined.set(iv, 0); - combined.set(new Uint8Array(ct), iv.length); - return bytesToBase64(combined); - } - - async decrypt(encoded: string): Promise<Uint8Array> { - const key = await this.keyPromise; - const combined = base64ToBytes(encoded); - if (combined.length <= IV_LEN) { - throw new Error("ciphertext too short"); - } - const iv = combined.subarray(0, IV_LEN); - const ct = combined.subarray(IV_LEN); - const pt = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: iv as BufferSource }, - key, - ct as BufferSource - ); - return new Uint8Array(pt); - } - - async decryptString(encoded: string): Promise<string> { - return new TextDecoder().decode(await this.decrypt(encoded)); - } -} diff --git a/packages/contrail-community/src/index.ts b/packages/contrail-community/src/index.ts deleted file mode 100644 index 80373aa..0000000 --- a/packages/contrail-community/src/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -export { registerCommunityRoutes } from "./router"; -export type { CommunityRoutesOptions } from "./router"; -export { CommunityAdapter } from "./adapter"; -export type { - AccessLevel, - AccessLevelRow, - CommunityConfig, - CommunityInviteRow, - CommunityMode, - CommunityRow, - CreateCommunityInviteInput, - ReservedKey, -} from "./types"; -export { - ACCESS_LEVELS, - RESERVED_KEYS, - isAccessLevel, - isReservedKey, - rankOf, -} from "./types"; -export { CredentialCipher } from "./credentials"; -export { resolveEffectiveLevel, flattenEffectiveMembers, wouldCycle } from "./acl"; -export { reconcile } from "./reconcile"; -export { createCommunityInviteHandler } from "./invite-handler"; -export { createCommunityWhoamiExtension } from "./whoami"; -export { initCommunitySchema, buildCommunitySchema } from "./schema"; -export { - resolveIdentity, - createPdsSession, - pdsCreateAccount, - pdsGetRecommendedDidCredentials, - pdsActivateAccount, - pdsCreateAppPassword, -} from "./pds"; -export type { - PdsCreateAccountBody, - PdsCreateAccountResult, - RecommendedDidCredentials, -} from "./pds"; -export { - generateKeyPair, - buildGenesisOp, - signGenesisOp, - computeDidPlc, - submitGenesisOp, - encodeDagCbor, - jwkToDidKey, - buildUpdateOp, - signUpdateOp, - cidForOp, - getLastOpCid, - buildTombstoneOp, - signTombstoneOp, - submitTombstoneOp, -} from "./plc"; -export type { - KeyPair, - GenesisOpInput, - UnsignedGenesisOp, - SignedGenesisOp, - UpdateOpInput, - UnsignedUpdateOp, - SignedUpdateOp, - UnsignedTombstoneOp, - SignedTombstoneOp, -} from "./plc"; - -// The headline export — wire community into a contrail app via: -// const community = createCommunityIntegration({ db, config }); -// const app = createApp(db, config, { community }); -export { createCommunityIntegration } from "./integration"; -export type { CommunityIntegrationOptions } from "./integration"; - -export { ProvisionOrchestrator } from "./provision"; -export type { - PdsClient, - PlcClient, - ProvisionInput, - ProvisionResult, - ProvisionOrchestratorDeps, -} from "./provision"; - -export { registerReap, runReap } from "./cli/reap"; -export type { - ReapLogger, - ReapHostDeps, - RunReapOptions, - RunReapResult, -} from "./cli/reap"; diff --git a/packages/contrail-community/src/integration.ts b/packages/contrail-community/src/integration.ts deleted file mode 100644 index 38b9435..0000000 --- a/packages/contrail-community/src/integration.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** Factory that builds a {@link CommunityIntegration} for contrail's - * `createApp({ community })` option. The integration is opaque to contrail - * core — it just exposes the hooks the umbrella router needs. */ - -import type { - CommunityIntegration, - CommunityProbe, - ContrailConfig, - Database, - StorageAdapter, -} from "@atmo-dev/contrail"; -import { HostedAdapter, buildVerifier, createServiceAuthMiddleware } from "@atmo-dev/contrail"; -import { CommunityAdapter } from "./adapter"; -import { createCommunityWhoamiExtension } from "./whoami"; -import { createCommunityInviteHandler } from "./invite-handler"; -import { resolveReachableSpaces } from "./acl"; -import { registerCommunityRoutes } from "./router"; -import { buildCommunitySchema } from "./schema"; -import { getDialect } from "@atmo-dev/contrail"; - -export interface CommunityIntegrationOptions { - /** Database the community tables live in. Should be the same DB the - * spaces module uses (community rows reference space_uri). */ - db: Database; - /** Resolved contrail config. */ - config: ContrailConfig; - /** Optional override of the community adapter (for tests). */ - communityAdapter?: CommunityAdapter; - /** Optional override of the spaces adapter (for tests). Otherwise built - * from the same db using HostedAdapter. */ - spacesAdapter?: StorageAdapter; -} - -export function createCommunityIntegration( - options: CommunityIntegrationOptions -): CommunityIntegration { - const { db, config } = options; - const community = options.communityAdapter ?? new CommunityAdapter(db); - const spaces = options.spacesAdapter ?? new HostedAdapter(db, config); - - const probe: CommunityProbe = { - async getCommunity(did) { - return community.getCommunity(did); - }, - async resolveReachableSpaces(callerDid) { - return resolveReachableSpaces(community, callerDid); - }, - }; - - const whoamiExtension = createCommunityWhoamiExtension({ community }); - const inviteHandler = createCommunityInviteHandler({ - community, - authority: spaces, - }); - - return { - probe, - whoamiExtension, - inviteHandler, - registerRoutes(app, opts) { - // Reuse the spaces JWT verifier — the auth model is identical. - if (!config.spaces?.authority) return; - const verifier = buildVerifier(config.spaces.authority, config.networkOverrides); - const authMiddleware = - opts?.authMiddleware ?? createServiceAuthMiddleware(verifier); - registerCommunityRoutes( - app, - db, - config, - { authMiddleware, communityAdapter: community, spacesAdapter: spaces }, - { spacesAdapter: spaces, verifier } - ); - }, - async applySchema(target) { - const dialect = getDialect(target); - const stmts = buildCommunitySchema(dialect); - await target.batch(stmts.map((s) => target.prepare(s))); - }, - }; -} diff --git a/packages/contrail-community/src/invite-handler.ts b/packages/contrail-community/src/invite-handler.ts deleted file mode 100644 index 9f27f1d..0000000 --- a/packages/contrail-community/src/invite-handler.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** Implementation of {@link CommunityInviteHandler} for community-grant - * invites. Lives here (not in invite/) so the dependency edge points - * community → invite (downward), not the other way around. */ - -import type { - CommunityInviteHandler, - HandlerResponse, - SpaceAuthority, -} from "@atmo-dev/contrail"; -import { mintInviteToken } from "@atmo-dev/contrail"; -import type { CommunityAdapter } from "./adapter"; -import { resolveEffectiveLevel } from "./acl"; -import { reconcile } from "./reconcile"; -import type { AccessLevel, CommunityInviteRow } from "./types"; -import { isAccessLevel, rankOf } from "./types"; - -interface PublicInviteView { - tokenHash: string; - spaceUri: string; - accessLevel: AccessLevel; - createdBy: string; - createdAt: number; - expiresAt: number | null; - maxUses: number | null; - usedCount: number; - revokedAt: number | null; - note: string | null; -} - -function toView(row: CommunityInviteRow): PublicInviteView { - return { - tokenHash: row.tokenHash, - spaceUri: row.spaceUri, - accessLevel: row.accessLevel, - createdBy: row.createdBy, - createdAt: row.createdAt, - expiresAt: row.expiresAt, - maxUses: row.maxUses, - usedCount: row.usedCount, - revokedAt: row.revokedAt, - note: row.note, - }; -} - -const ok = (body: Record<string, unknown>): HandlerResponse => ({ status: 200, body }); -const err = (status: number, body: Record<string, unknown>): HandlerResponse => ({ status, body }); - -export function createCommunityInviteHandler(args: { - community: CommunityAdapter; - /** Space authority — used to look up space metadata after redemption (e.g. - * to return the community DID). */ - authority: SpaceAuthority; -}): CommunityInviteHandler { - const { community, authority } = args; - - return { - async isCommunityOwned(spaceUri) { - const space = await authority.getSpace(spaceUri); - if (!space) return false; - return !!(await community.getCommunity(space.ownerDid)); - }, - - async create(input) { - if (input.kind) { - return err(400, { - error: "InvalidRequest", - reason: "kind-on-community-space", - message: "community spaces take accessLevel, not kind", - }); - } - if (!input.accessLevel || !isAccessLevel(input.accessLevel)) { - return err(400, { error: "InvalidRequest", reason: "accessLevel-required" }); - } - const callerLevel = await resolveEffectiveLevel(community, input.spaceUri, input.callerDid); - if (!callerLevel || rankOf(callerLevel) < rankOf("manager")) { - return err(403, { error: "Forbidden", reason: "manager-required" }); - } - if (rankOf(input.accessLevel) > rankOf(callerLevel)) { - return err(403, { error: "Forbidden", reason: "cannot-grant-higher-than-self" }); - } - const { token, tokenHash } = await mintInviteToken(); - const row = await community.createInvite({ - spaceUri: input.spaceUri, - tokenHash, - accessLevel: input.accessLevel, - createdBy: input.callerDid, - expiresAt: input.expiresAt, - maxUses: input.maxUses, - note: input.note, - }); - return ok({ token, invite: toView(row) }); - }, - - async list(input) { - const callerLevel = await resolveEffectiveLevel(community, input.spaceUri, input.callerDid); - if (!callerLevel || rankOf(callerLevel) < rankOf("manager")) { - return err(403, { error: "Forbidden", reason: "manager-required" }); - } - const rows = await community.listInvites(input.spaceUri, { - includeRevoked: input.includeRevoked, - }); - return ok({ invites: rows.map(toView) }); - }, - - async revoke(input) { - const level = await resolveEffectiveLevel(community, input.spaceUri, input.callerDid); - const managerOrHigher = !!level && rankOf(level) >= rankOf("manager"); - if (!managerOrHigher) { - const crow = await community.getInvite(input.tokenHash); - if (!crow || crow.createdBy !== input.callerDid) { - return err(403, { error: "Forbidden", reason: "creator-or-manager-required" }); - } - } - const revoked = await community.revokeInvite(input.tokenHash); - return ok({ ok: revoked }); - }, - - async tryRevokeByToken(input) { - const crow = await community.getInvite(input.tokenHash); - if (!crow) return null; - let allowed = crow.createdBy === input.callerDid; - if (!allowed) { - const level = await resolveEffectiveLevel(community, crow.spaceUri, input.callerDid); - allowed = !!level && rankOf(level) >= rankOf("manager"); - } - if (!allowed) { - return err(403, { error: "Forbidden", reason: "creator-or-manager-required" }); - } - const revoked = await community.revokeInvite(input.tokenHash); - return ok({ ok: revoked }); - }, - - async tryRedeem(input) { - const cinvite = await community.redeemInvite(input.tokenHash, input.now); - if (!cinvite) return null; - const space = await authority.getSpace(cinvite.spaceUri); - if (!space) return err(404, { error: "NotFound", reason: "space-not-found" }); - // The token itself is the authorization: creator (manager+) pre-signed - // "anyone with this token gets level X". Grant directly, attributing - // to the creator so audit trails make sense. - await community.grant({ - spaceUri: cinvite.spaceUri, - subjectDid: input.callerDid, - accessLevel: cinvite.accessLevel, - grantedBy: cinvite.createdBy, - }); - await reconcile(community, authority, cinvite.spaceUri, cinvite.createdBy); - return ok({ - spaceUri: cinvite.spaceUri, - accessLevel: cinvite.accessLevel, - communityDid: space.ownerDid, - }); - }, - }; -} diff --git a/packages/contrail-community/src/pds.ts b/packages/contrail-community/src/pds.ts deleted file mode 100644 index b8c63cc..0000000 --- a/packages/contrail-community/src/pds.ts +++ /dev/null @@ -1,344 +0,0 @@ -/** Helpers for interacting with a community's PDS account — resolving identity - * and creating sessions from stored app passwords. */ - -import { - CompositeDidDocumentResolver, - PlcDidDocumentResolver, - WebDidDocumentResolver, - type DidDocumentResolver, -} from "@atcute/identity-resolver"; - -/** Canonicalize a PDS endpoint URL so allowlist comparisons aren't bypassed by - * trailing slash, default port, scheme case, or IDN encoding differences. - * Returns the URL's `origin` — scheme + host + (non-default) port — which - * collapses every variant of a single PDS to one string. Throws if the input - * is not a parseable URL; callers in request paths should catch and respond - * with a 400. */ -export function normalizePdsEndpoint(url: string): string { - return new URL(url).origin; -} - -export interface ResolvedIdentity { - did: string; - handle: string | null; - pdsEndpoint: string; -} - -export interface PdsSession { - accessJwt: string; - refreshJwt: string; - did: string; -} - -function defaultResolver(): DidDocumentResolver { - return new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver(), - }, - }) as unknown as DidDocumentResolver; -} - -/** Given a handle or DID, resolve to { did, pdsEndpoint } using the DID doc's - * `atproto_pds` service entry. */ -export async function resolveIdentity( - identifier: string, - opts: { resolver?: DidDocumentResolver; fetch?: typeof fetch } = {} -): Promise<ResolvedIdentity> { - const f = opts.fetch ?? fetch; - let did = identifier; - let handle: string | null = null; - - if (!identifier.startsWith("did:")) { - // Handle → DID via /.well-known or _atproto DNS. - did = await resolveHandleToDid(identifier, f); - handle = identifier; - } - - const resolver = opts.resolver ?? defaultResolver(); - const doc = await (resolver as any).resolve(did); - if (!doc) throw new Error(`could not resolve DID document for ${did}`); - - const services: Array<{ id?: string; type?: string; serviceEndpoint?: string }> = - (doc as any).service ?? []; - const pds = services.find( - (s) => - s.type === "AtprotoPersonalDataServer" || - s.id === "#atproto_pds" || - s.id?.endsWith("#atproto_pds") - ); - if (!pds?.serviceEndpoint) { - throw new Error(`DID document for ${did} has no atproto_pds service`); - } - - return { did, handle, pdsEndpoint: pds.serviceEndpoint }; -} - -async function resolveHandleToDid(handle: string, f: typeof fetch): Promise<string> { - // Try /.well-known/atproto-did first (cheaper, no DNS). - try { - const res = await f(`https://${handle}/.well-known/atproto-did`, { - redirect: "follow", - }); - if (res.ok) { - const did = (await res.text()).trim(); - if (did.startsWith("did:")) return did; - } - } catch { - /* fall through */ - } - // Fallback: DNS TXT _atproto.<handle>. Not available in Workers without - // a DNS-over-HTTPS provider; use Cloudflare's 1.1.1.1 as a default. - try { - const res = await f( - `https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`, - { headers: { accept: "application/dns-json" } } - ); - if (res.ok) { - const body = (await res.json()) as { - Answer?: Array<{ data?: string; type?: number }>; - }; - for (const ans of body.Answer ?? []) { - if (ans.type === 16 && ans.data) { - const trimmed = ans.data.replace(/^"|"$/g, ""); - const m = /^did=(did:[^"\s]+)$/.exec(trimmed); - if (m) return m[1]!; - } - } - } - } catch { - /* fall through */ - } - throw new Error(`could not resolve handle ${handle}`); -} - -/** Decode the `exp` claim from a JWT's payload (in seconds since epoch). Used - * by the session cache to decide if a cached access token is still usable. - * Returns 0 if the claim is missing or the token is malformed — callers should - * treat 0 as "expired, refresh now". Avoids `Buffer` so it works in Workers. */ -export function decodeJwtExp(jwt: string): number { - const parts = jwt.split("."); - if (parts.length < 2) return 0; - const payload = parts[1]!; - const padded = payload.replace(/-/g, "+").replace(/_/g, "/"); - const padding = "=".repeat((4 - (padded.length % 4)) % 4); - try { - const json = atob(padded + padding); - const claims = JSON.parse(json) as { exp?: number }; - return Number(claims.exp ?? 0); - } catch { - return 0; - } -} - -/** POST com.atproto.server.refreshSession with the refresh JWT in Authorization. - * Returns null on any non-200 — callers fall back to `createPdsSession`. */ -export async function tryRefreshSession(input: { - pdsUrl: string; - refreshJwt: string; - fetch?: typeof fetch; -}): Promise<{ accessJwt: string; refreshJwt: string; accessExp: number } | null> { - const f = input.fetch ?? fetch; - const url = `${input.pdsUrl.replace(/\/$/, "")}/xrpc/com.atproto.server.refreshSession`; - const res = await f(url, { - method: "POST", - headers: { authorization: `Bearer ${input.refreshJwt}` }, - }); - if (res.status !== 200) return null; - const body = (await res.json().catch(() => null)) as - | { accessJwt?: string; refreshJwt?: string } - | null; - if (!body?.accessJwt || !body.refreshJwt) return null; - return { - accessJwt: body.accessJwt, - refreshJwt: body.refreshJwt, - accessExp: decodeJwtExp(body.accessJwt), - }; -} - -/** Create an atproto session on the given PDS using identifier + app password. - * Returns the access/refresh JWTs and the session's DID. */ -export async function createPdsSession( - pdsEndpoint: string, - identifier: string, - appPassword: string, - opts: { fetch?: typeof fetch } = {} -): Promise<PdsSession> { - const f = opts.fetch ?? fetch; - const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.server.createSession`; - const res = await f(url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ identifier, password: appPassword }), - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`createSession failed (${res.status}): ${text}`); - } - const body = (await res.json()) as { - accessJwt?: string; - refreshJwt?: string; - did?: string; - }; - if (!body.accessJwt || !body.refreshJwt || !body.did) { - throw new Error("createSession response missing expected fields"); - } - return { - accessJwt: body.accessJwt, - refreshJwt: body.refreshJwt, - did: body.did, - }; -} - -export interface PdsDescribeServerResult { - did: string; - /** Other fields (availableUserDomains, contact, links, inviteCodeRequired) - * are returned by the PDS but unused by Contrail's provisioning flow. */ - [key: string]: unknown; -} - -/** Calls `com.atproto.server.describeServer` on the target PDS to discover - * the DID it publishes for itself. Used as `aud` in the service-auth JWT for - * `createAccount`; the PDS verifies the audience matches its own DID and - * rejects with `BadJwtAudience` otherwise. Resolving dynamically (instead of - * hardcoding to a config value) is what allows a single Contrail instance - * to mint communities on multiple PDSes. */ -export async function pdsDescribeServer( - pdsEndpoint: string, - opts: { fetch?: typeof fetch } = {} -): Promise<PdsDescribeServerResult> { - const f = opts.fetch ?? fetch; - const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.server.describeServer`; - const res = await f(url); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`describeServer failed (${res.status}): ${text}`); - } - const body = (await res.json()) as PdsDescribeServerResult; - if (!body?.did || typeof body.did !== "string") { - throw new Error("describeServer response missing required `did` field"); - } - return body; -} - -export interface PdsCreateAccountBody { - handle: string; - did: string; - email: string; - password: string; - inviteCode?: string; -} - -export interface PdsCreateAccountResult { - did: string; - handle: string; - accessJwt: string; - refreshJwt: string; -} - -/** Calls `com.atproto.server.createAccount` on the target PDS using a - * service-auth JWT (signed by the iss DID's verificationMethod). The PDS - * verifies `requester === did` against the published DID-doc, validates the - * invite, and creates the account in deactivated state. */ -export async function pdsCreateAccount( - pdsEndpoint: string, - serviceAuthJwt: string, - body: PdsCreateAccountBody, - opts: { fetch?: typeof fetch } = {} -): Promise<PdsCreateAccountResult> { - const f = opts.fetch ?? fetch; - const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.server.createAccount`; - const res = await f(url, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${serviceAuthJwt}`, - }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`createAccount failed (${res.status}): ${text}`); - } - return (await res.json()) as PdsCreateAccountResult; -} - -export interface RecommendedDidCredentials { - rotationKeys: string[]; - verificationMethods: { atproto: string }; - alsoKnownAs: string[]; - services: Record<string, { type: string; endpoint: string }>; -} - -/** Calls `com.atproto.identity.getRecommendedDidCredentials` on the target PDS - * using the session's accessJwt (returned by `pdsCreateAccount`, NOT a - * service-auth JWT). Returns the DID-doc fields the PDS would self-publish. */ -export async function pdsGetRecommendedDidCredentials( - pdsEndpoint: string, - accessJwt: string, - opts: { fetch?: typeof fetch } = {} -): Promise<RecommendedDidCredentials> { - const f = opts.fetch ?? fetch; - const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.identity.getRecommendedDidCredentials`; - const res = await f(url, { - headers: { authorization: `Bearer ${accessJwt}` }, - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`getRecommendedDidCredentials failed (${res.status}): ${text}`); - } - return (await res.json()) as RecommendedDidCredentials; -} - -/** Calls `com.atproto.server.activateAccount` on the target PDS using the - * session's accessJwt (returned by `pdsCreateAccount`, NOT a service-auth - * JWT). Resolves on success; throws otherwise. */ -export async function pdsActivateAccount( - pdsEndpoint: string, - accessJwt: string, - opts: { fetch?: typeof fetch } = {} -): Promise<void> { - const f = opts.fetch ?? fetch; - const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.server.activateAccount`; - const res = await f(url, { - method: "POST", - headers: { authorization: `Bearer ${accessJwt}` }, - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`activateAccount failed (${res.status}): ${text}`); - } -} - -export interface PdsCreateAppPasswordResult { - name: string; - password: string; - createdAt: string; -} - -/** Calls `com.atproto.server.createAppPassword` on the target PDS using the - * session's accessJwt. Returns the freshly minted app password. We always - * send `privileged: false` so the credential can be revoked without - * affecting the root account. */ -export async function pdsCreateAppPassword( - pdsEndpoint: string, - accessJwt: string, - name: string, - opts: { fetch?: typeof fetch } = {} -): Promise<PdsCreateAppPasswordResult> { - const f = opts.fetch ?? fetch; - const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.server.createAppPassword`; - const res = await f(url, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${accessJwt}`, - }, - body: JSON.stringify({ name, privileged: false }), - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`createAppPassword failed (${res.status}): ${text}`); - } - return (await res.json()) as PdsCreateAppPasswordResult; -} diff --git a/packages/contrail-community/src/plc.ts b/packages/contrail-community/src/plc.ts deleted file mode 100644 index a66a883..0000000 --- a/packages/contrail-community/src/plc.ts +++ /dev/null @@ -1,498 +0,0 @@ -/** did:plc minting: key generation, genesis op construction, signing, - * submission. Zero external deps — uses Web Crypto for P-256 and a - * minimal hand-rolled DAG-CBOR encoder for the specific op shape. */ - -// ============================================================================ -// Key handling (P-256 / ES256) -// ============================================================================ - -export interface KeyPair { - /** JWK of the private key; stored encrypted. */ - privateJwk: JsonWebKey; - /** did:key multibase encoding of the public key. */ - publicDidKey: string; -} - -export async function generateKeyPair(): Promise<KeyPair> { - const pair = (await crypto.subtle.generateKey( - { name: "ECDSA", namedCurve: "P-256" }, - true, - ["sign", "verify"] - )) as CryptoKeyPair; - const privateJwk = (await crypto.subtle.exportKey("jwk", pair.privateKey)) as JsonWebKey; - const publicJwk = (await crypto.subtle.exportKey("jwk", pair.publicKey)) as JsonWebKey; - return { privateJwk, publicDidKey: jwkToDidKey(publicJwk) }; -} - -/** Convert a P-256 JWK public key to did:key format. - * multicodec for P-256 pub: 0x1200 (varint: [0x80, 0x24]). - * did:key:zBase58btc(multicodec || compressed-pub-key). */ -export function jwkToDidKey(jwk: JsonWebKey): string { - if (jwk.kty !== "EC" || jwk.crv !== "P-256" || !jwk.x || !jwk.y) { - throw new Error("expected EC P-256 JWK"); - } - const x = b64urlToBytes(jwk.x); - const y = b64urlToBytes(jwk.y); - if (x.length !== 32 || y.length !== 32) { - throw new Error("malformed P-256 JWK"); - } - // Compressed form: 0x02 if y is even, 0x03 if odd, then x. - const prefix = (y[31]! & 1) === 0 ? 0x02 : 0x03; - const compressed = new Uint8Array(33); - compressed[0] = prefix; - compressed.set(x, 1); - const multicodec = new Uint8Array([0x80, 0x24]); - const combined = new Uint8Array(multicodec.length + compressed.length); - combined.set(multicodec, 0); - combined.set(compressed, multicodec.length); - return "did:key:z" + base58btcEncode(combined); -} - -/** Order of the P-256 curve (n). */ -const P256_N = BigInt( - "0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551" -); -const P256_N_HALF = P256_N >> 1n; - -export async function signBytes(privateJwk: JsonWebKey, bytes: Uint8Array): Promise<Uint8Array> { - const key = await crypto.subtle.importKey( - "jwk", - privateJwk, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["sign"] - ); - const sig = new Uint8Array( - await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, bytes as BufferSource) - ); - // Web Crypto returns IEEE P1363 form: r || s (32 bytes each for P-256). - // atproto/PLC (and most modern ECDSA consumers) require low-S — i.e. - // s must be in the lower half of the curve order. Flip high-S signatures - // by replacing s with n - s. This yields an equivalent valid signature. - return normalizeLowS(sig); -} - -function normalizeLowS(sig: Uint8Array): Uint8Array { - if (sig.length !== 64) return sig; // not a P-256 P1363 sig — don't touch - const r = sig.slice(0, 32); - const sBytes = sig.slice(32); - const s = bytesToBigInt(sBytes); - if (s <= P256_N_HALF) return sig; - const sLow = P256_N - s; - const sLowBytes = bigIntToBytes(sLow, 32); - const out = new Uint8Array(64); - out.set(r, 0); - out.set(sLowBytes, 32); - return out; -} - -function bytesToBigInt(b: Uint8Array): bigint { - let v = 0n; - for (const byte of b) v = (v << 8n) | BigInt(byte); - return v; -} - -function bigIntToBytes(v: bigint, length: number): Uint8Array { - const out = new Uint8Array(length); - let x = v; - for (let i = length - 1; i >= 0; i--) { - out[i] = Number(x & 0xffn); - x >>= 8n; - } - return out; -} - -// ============================================================================ -// Genesis op construction -// ============================================================================ - -export interface GenesisOpInput { - rotationKeys: string[]; // did:key strings - verificationMethodAtproto: string; // did:key - services?: Record<string, { type: string; endpoint: string }>; - alsoKnownAs?: string[]; -} - -export interface UnsignedGenesisOp { - type: "plc_operation"; - rotationKeys: string[]; - verificationMethods: Record<string, string>; - alsoKnownAs: string[]; - services: Record<string, { type: string; endpoint: string }>; - prev: null; -} - -export interface SignedGenesisOp extends UnsignedGenesisOp { - sig: string; // base64url, unpadded -} - -export function buildGenesisOp(input: GenesisOpInput): UnsignedGenesisOp { - return { - type: "plc_operation", - rotationKeys: input.rotationKeys, - verificationMethods: { atproto: input.verificationMethodAtproto }, - alsoKnownAs: input.alsoKnownAs ?? [], - services: input.services ?? {}, - prev: null, - }; -} - -/** Sign a genesis op with a rotation key's private JWK. */ -export async function signGenesisOp( - op: UnsignedGenesisOp, - signerPrivateJwk: JsonWebKey -): Promise<SignedGenesisOp> { - const encoded = encodeDagCbor(op); - const sigBytes = await signBytes(signerPrivateJwk, encoded); - return { ...op, sig: bytesToB64url(sigBytes) }; -} - -/** Compute the did:plc from a signed genesis op. - * The DID is `did:plc:` + base32-lower-unpadded(sha256(cbor(signedOp)))[:24]. */ -export async function computeDidPlc(signedOp: SignedGenesisOp): Promise<string> { - const encoded = encodeDagCbor(signedOp); - const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", encoded as BufferSource)); - return "did:plc:" + base32Lower(hash).slice(0, 24); -} - -// ============================================================================ -// Update op construction (subsequent ops chain via `prev`) -// ============================================================================ - -export interface UpdateOpInput { - prev: string; // CID string of the previous op in the chain - rotationKeys: string[]; - verificationMethodAtproto: string; - alsoKnownAs: string[]; - services: Record<string, { type: string; endpoint: string }>; -} - -export interface UnsignedUpdateOp { - type: "plc_operation"; - prev: string; - rotationKeys: string[]; - verificationMethods: { atproto: string }; - alsoKnownAs: string[]; - services: Record<string, { type: string; endpoint: string }>; -} - -export interface SignedUpdateOp extends UnsignedUpdateOp { - sig: string; // base64url, unpadded -} - -export function buildUpdateOp(input: UpdateOpInput): UnsignedUpdateOp { - return { - type: "plc_operation", - prev: input.prev, - rotationKeys: input.rotationKeys, - verificationMethods: { atproto: input.verificationMethodAtproto }, - alsoKnownAs: input.alsoKnownAs, - services: input.services, - }; -} - -/** Sign an update op with a rotation key's private JWK. */ -export async function signUpdateOp( - unsigned: UnsignedUpdateOp, - signerPrivateJwk: JsonWebKey -): Promise<SignedUpdateOp> { - const encoded = encodeDagCbor(unsigned); - const sigBytes = await signBytes(signerPrivateJwk, encoded); - return { ...unsigned, sig: bytesToB64url(sigBytes) }; -} - -/** Compute the CIDv1 for a signed op (genesis, update, or tombstone). - * CIDv1 (0x01) + dag-cbor codec (0x71) + sha2-256 (0x12 0x20) + hash, - * base32-lower with multibase "b" prefix. - * - * The tombstone shape ({type, prev, sig}) is a strict subset of update — - * the DAG-CBOR encoder accepts all three uniformly, and PLC computes its - * stored CID from the same canonical encoding. */ -export async function cidForOp( - signedOp: SignedGenesisOp | SignedUpdateOp | SignedTombstoneOp -): Promise<string> { - const encoded = encodeDagCbor(signedOp); - const hash = new Uint8Array( - await crypto.subtle.digest("SHA-256", encoded as BufferSource) - ); - const cidBytes = new Uint8Array(4 + hash.length); - cidBytes[0] = 0x01; - cidBytes[1] = 0x71; - cidBytes[2] = 0x12; - cidBytes[3] = 0x20; - cidBytes.set(hash, 4); - return "b" + base32Lower(cidBytes); -} - -/** Fetch the CID of the most recent op in a DID's PLC log. Used during - * provision recovery to obtain the genesis op's CID at resume time (we can't - * recompute it locally because ECDSA signatures are randomized) and by the - * reap CLI to chain a tombstone onto the latest op. - * - * PLC's `/log/last` endpoint returns the bare signed op object — no envelope, - * no `cid` field. We compute the CID locally with the same DAG-CBOR encoder - * cidForOp uses; PLC computes its stored CID identically, so the result - * matches the entry's CID in `/log/audit`. */ -export async function getLastOpCid( - plcDirectory: string, - did: string, - opts: { fetch?: typeof fetch } = {} -): Promise<string> { - const f = opts.fetch ?? fetch; - const url = `${plcDirectory.replace(/\/$/, "")}/${did}/log/last`; - const res = await f(url); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`PLC log/last failed (${res.status}): ${text}`); - } - const op = (await res.json()) as - | SignedGenesisOp - | SignedUpdateOp - | SignedTombstoneOp; - return cidForOp(op); -} - -// ============================================================================ -// Tombstone op construction -// A tombstone op marks a DID's PLC log as terminated — no further ops will be -// accepted. Used by `contrail reap` to clean up DIDs whose PDS account is -// permanently unrecoverable. -// ============================================================================ - -export interface UnsignedTombstoneOp { - type: "plc_tombstone"; - prev: string; -} - -export interface SignedTombstoneOp extends UnsignedTombstoneOp { - sig: string; // base64url, unpadded -} - -export function buildTombstoneOp(prev: string): UnsignedTombstoneOp { - return { type: "plc_tombstone", prev }; -} - -/** Sign a tombstone op with a rotation key's private JWK. */ -export async function signTombstoneOp( - op: UnsignedTombstoneOp, - signerPrivateJwk: JsonWebKey -): Promise<SignedTombstoneOp> { - const encoded = encodeDagCbor(op); - const sigBytes = await signBytes(signerPrivateJwk, encoded); - return { ...op, sig: bytesToB64url(sigBytes) }; -} - -/** Submit a signed tombstone op to the PLC directory. PLC accepts genesis, - * update, and tombstone ops at the same `${plcDirectory}/${did}` endpoint. */ -export async function submitTombstoneOp( - plcDirectory: string, - did: string, - signedOp: SignedTombstoneOp, - opts: { fetch?: typeof fetch } = {} -): Promise<void> { - const f = opts.fetch ?? fetch; - const url = `${plcDirectory.replace(/\/$/, "")}/${did}`; - const res = await f(url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(signedOp), - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`PLC tombstone submit failed (${res.status}): ${text}`); - } -} - -/** Submit a signed genesis op to the PLC directory. */ -export async function submitGenesisOp( - plcDirectory: string, - did: string, - signedOp: SignedGenesisOp, - opts: { fetch?: typeof fetch } = {} -): Promise<void> { - const f = opts.fetch ?? fetch; - const url = `${plcDirectory.replace(/\/$/, "")}/${did}`; - const res = await f(url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(signedOp), - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`PLC submit failed (${res.status}): ${text}`); - } -} - -// ============================================================================ -// Minimal DAG-CBOR encoder -// Only supports the types needed for PLC genesis ops: -// null, text strings, arrays, maps with string keys. -// Maps are canonicalized: keys sorted by their CBOR byte encoding, -// ascending lexicographically. Integers use the smallest encoding. -// See RFC 8949 + https://ipld.io/specs/codecs/dag-cbor/spec/ -// ============================================================================ - -export function encodeDagCbor(value: unknown): Uint8Array { - const chunks: Uint8Array[] = []; - encode(value, chunks); - return concat(chunks); -} - -function encode(value: unknown, out: Uint8Array[]): void { - if (value === null) { - out.push(new Uint8Array([0xf6])); - return; - } - if (value === false) { - out.push(new Uint8Array([0xf4])); - return; - } - if (value === true) { - out.push(new Uint8Array([0xf5])); - return; - } - if (typeof value === "string") { - const bytes = new TextEncoder().encode(value); - encodeHead(3, bytes.length, out); - out.push(bytes); - return; - } - if (typeof value === "number") { - if (!Number.isInteger(value)) { - throw new Error("DAG-CBOR: floats not supported in PLC op encoder"); - } - if (value >= 0) { - encodeHead(0, value, out); - } else { - encodeHead(1, -value - 1, out); - } - return; - } - if (Array.isArray(value)) { - encodeHead(4, value.length, out); - for (const v of value) encode(v, out); - return; - } - if (typeof value === "object") { - // Map with string keys, canonicalized. - const entries = Object.entries(value as Record<string, unknown>).filter( - ([, v]) => v !== undefined - ); - // Sort keys by their CBOR-encoded byte form. - // For DAG-CBOR with text-string keys, this equals: - // (a) shorter UTF-8 byte length first, (b) lexicographic byte order within same length. - entries.sort(([a], [b]) => { - const ab = new TextEncoder().encode(a); - const bb = new TextEncoder().encode(b); - if (ab.length !== bb.length) return ab.length - bb.length; - for (let i = 0; i < ab.length; i++) { - if (ab[i] !== bb[i]) return ab[i]! - bb[i]!; - } - return 0; - }); - encodeHead(5, entries.length, out); - for (const [k, v] of entries) { - encode(k, out); - encode(v, out); - } - return; - } - throw new Error(`DAG-CBOR: unsupported value type ${typeof value}`); -} - -function encodeHead(majorType: number, n: number, out: Uint8Array[]): void { - const mt = majorType << 5; - if (n < 24) { - out.push(new Uint8Array([mt | n])); - } else if (n < 0x100) { - out.push(new Uint8Array([mt | 24, n])); - } else if (n < 0x10000) { - out.push(new Uint8Array([mt | 25, (n >> 8) & 0xff, n & 0xff])); - } else if (n < 0x100000000) { - const b = new Uint8Array(5); - b[0] = mt | 26; - b[1] = (n >>> 24) & 0xff; - b[2] = (n >>> 16) & 0xff; - b[3] = (n >>> 8) & 0xff; - b[4] = n & 0xff; - out.push(b); - } else { - throw new Error("DAG-CBOR: integer too large"); - } -} - -function concat(parts: Uint8Array[]): Uint8Array { - let total = 0; - for (const p of parts) total += p.length; - const out = new Uint8Array(total); - let off = 0; - for (const p of parts) { - out.set(p, off); - off += p.length; - } - return out; -} - -// ============================================================================ -// Base encoding helpers (base58btc, base32-lower-unpadded, base64url) -// ============================================================================ - -const B58_ALPHA = - "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; - -function base58btcEncode(bytes: Uint8Array): string { - if (bytes.length === 0) return ""; - // Count leading zeros. - let zeros = 0; - while (zeros < bytes.length && bytes[zeros] === 0) zeros++; - // Repeated divmod by 58 using big-endian byte buffer. - const input = Array.from(bytes); - let encoded = ""; - let start = zeros; - while (start < input.length) { - let carry = 0; - for (let i = start; i < input.length; i++) { - const v = (carry << 8) + input[i]!; - input[i] = Math.floor(v / 58); - carry = v % 58; - } - encoded = B58_ALPHA[carry]! + encoded; - while (start < input.length && input[start] === 0) start++; - } - return "1".repeat(zeros) + encoded; -} - -const B32_ALPHA = "abcdefghijklmnopqrstuvwxyz234567"; - -function base32Lower(bytes: Uint8Array): string { - let bits = 0; - let value = 0; - let out = ""; - for (let i = 0; i < bytes.length; i++) { - value = (value << 8) | bytes[i]!; - bits += 8; - while (bits >= 5) { - bits -= 5; - out += B32_ALPHA[(value >>> bits) & 31]; - } - } - if (bits > 0) { - out += B32_ALPHA[(value << (5 - bits)) & 31]; - } - return out; -} - -export function bytesToB64url(bytes: Uint8Array): string { - let bin = ""; - for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!); - return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -function b64urlToBytes(s: string): Uint8Array { - const normal = s.replace(/-/g, "+").replace(/_/g, "/"); - const padded = normal + "=".repeat((4 - (normal.length % 4)) % 4); - const bin = atob(padded); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} diff --git a/packages/contrail-community/src/provision.ts b/packages/contrail-community/src/provision.ts deleted file mode 100644 index b5ee50e..0000000 --- a/packages/contrail-community/src/provision.ts +++ /dev/null @@ -1,403 +0,0 @@ -/** Provision orchestrator: runs the 5-RPC flow (genesis → createAccount → - * recommendedCreds → PLC update → activate), persisting status after each - * step so a stuck attempt is recognizable to the reap CLI. - * - * Steps and persisted statuses: - * Step 0 generate keys + persist row → keys_generated - * Step 1 PLC genesis op → genesis_submitted - * Step 2 PDS createAccount (service-auth JWT) → account_created - * Step 3 fetch recommended DID credentials (no status change) - * Step 4 PLC update op merging recommended credentials → did_doc_updated - * Step 5 PDS activateAccount → activated - */ - -import { - generateKeyPair, - buildGenesisOp, - signGenesisOp, - computeDidPlc, - buildUpdateOp, - signUpdateOp, - cidForOp, -} from "./plc"; -import type { RecommendedDidCredentials } from "./pds"; -import { decodeJwtExp } from "./pds"; -import { mintServiceAuthJwt } from "./service-auth"; -import type { CommunityAdapter } from "./adapter"; -import type { CredentialCipher } from "./credentials"; - -export interface PlcClient { - submit(did: string, op: any): Promise<unknown>; -} - -export interface PdsClient { - createAccount(input: { - pdsUrl: string; - serviceAuthJwt: string; - body: { - handle: string; - did: string; - email: string; - password: string; - inviteCode?: string; - }; - }): Promise<{ - did: string; - handle: string; - accessJwt: string; - refreshJwt: string; - }>; - getRecommendedDidCredentials(input: { - pdsUrl: string; - accessJwt: string; - }): Promise<RecommendedDidCredentials>; - activateAccount(input: { pdsUrl: string; accessJwt: string }): Promise<void>; - /** Mints a revocable app password on the freshly-activated account. Used by - * the self-sovereign custody mode so Contrail keeps publishing authority - * without holding the account's root password. */ - createAppPassword(input: { - pdsUrl: string; - accessJwt: string; - name: string; - }): Promise<{ password: string }>; - /** Used only by the C3 retry path: when a provision call failed at - * createAppPassword, retrying with the same attemptId needs a fresh - * accessJwt. The session-cache JWT from the failed attempt may have - * expired by the time the caller retries. Optional — non-retry callers - * never invoke this. */ - createSession?(input: { - pdsUrl: string; - identifier: string; - password: string; - }): Promise<{ accessJwt: string; refreshJwt: string; did: string }>; -} - -export interface ProvisionOrchestratorDeps { - adapter: CommunityAdapter; - cipher: CredentialCipher; - plc: PlcClient; - pds: PdsClient; - /** DID of the target PDS; used as `aud` in the service-auth JWT. */ - pdsDid: string; -} - -export interface ProvisionInput { - attemptId: string; - pdsEndpoint: string; - handle: string; - email: string; - password: string; - inviteCode?: string; - /** Caller-supplied rotation public key (did:key:z…). Sits at rotationKeys[0] - * in the genesis op; Contrail's generated key is the subordinate at [1]. - * After activation Contrail mints a revocable app password (via - * createAppPassword) for ongoing publishing — the user's account password - * is never persisted. */ - rotationKey: string; -} - -export interface ProvisionResult { - attemptId: string; - did: string; - status: "activated"; - /** The caller is expected to store these — Contrail does NOT retain the - * user's root password once the app password has been minted. */ - rootCredentials: { - handle: string; - password: string; - recoveryHint: string; - }; -} - -export class ProvisionOrchestrator { - constructor(private deps: ProvisionOrchestratorDeps) {} - - async provision(input: ProvisionInput): Promise<ProvisionResult> { - const { adapter, cipher, plc, pds, pdsDid } = this.deps; - - if (!isDidKeyZ(input.rotationKey)) { - throw new Error( - `rotationKey must be a did:key:z… string (got: ${input.rotationKey.slice(0, 24)}…)` - ); - } - - // Idempotent retry: a caller that gets a 5xx with an attemptId can - // re-invoke provision with the SAME attemptId and the orchestrator picks - // up where it left off. Two recoverable shapes: - // 1. status='activated', encryptedPassword present — the orchestrator - // itself completed successfully but a downstream graduation step - // (e.g. the route's createFromProvisioned, bootstrap of reserved - // spaces) failed. We return success without re-running any PLC/PDS - // work so the caller — or the route — can resume the post-orch path. - // 2. status='activated', no encryptedPassword — activation succeeded - // but the post-activation createAppPassword failed. retryAppPasswordOnly - // re-runs only that final step. - // Other partial states are not resumable through this entry point. - const existing = await adapter.getProvisionAttempt(input.attemptId); - if (existing) { - if (existing.status === "activated" && existing.encryptedPassword) { - return { - attemptId: input.attemptId, - did: existing.did, - status: "activated", - rootCredentials: { - handle: input.handle, - password: input.password, - recoveryHint: "store this — Contrail does not retain it", - }, - }; - } - if (existing.status === "activated" && !existing.encryptedPassword) { - return this.retryAppPasswordOnly(input, existing); - } - throw new Error( - `provision attempt ${input.attemptId} already exists at status="${existing.status}"; ` + - `retry is only supported for attempts that failed at createAppPassword` - ); - } - - // Step 0: keys + persist. Contrail generates a SUBORDINATE rotation key - // (rotationKeys[1]) so it retains a path to submit subsequent PLC ops; - // the caller's key sits at rotationKeys[0]. - const signingKey = await generateKeyPair(); - const contrailRotation = await generateKeyPair(); - const encryptedSigning = await cipher.encrypt( - JSON.stringify(signingKey.privateJwk) - ); - const encryptedRotation = await cipher.encrypt( - JSON.stringify(contrailRotation.privateJwk) - ); - - const unsigned = buildGenesisOp({ - rotationKeys: [input.rotationKey, contrailRotation.publicDidKey], - verificationMethodAtproto: signingKey.publicDidKey, - alsoKnownAs: [`at://${input.handle}`], - services: { - atproto_pds: { - type: "AtprotoPersonalDataServer", - endpoint: input.pdsEndpoint, - }, - }, - }); - // Genesis is signed with Contrail's subordinate rotation key — it's listed - // at rotationKeys[1] so PLC accepts the signature. - const signedGenesis = await signGenesisOp(unsigned, contrailRotation.privateJwk); - const did = await computeDidPlc(signedGenesis); - - await adapter.createProvisionAttempt({ - attemptId: input.attemptId, - did, - pdsEndpoint: input.pdsEndpoint, - handle: input.handle, - email: input.email, - inviteCode: input.inviteCode ?? null, - encryptedSigningKey: encryptedSigning, - encryptedRotationKey: encryptedRotation, - }); - - // Step 1: PLC genesis - try { - await plc.submit(did, signedGenesis); - await adapter.updateProvisionStatus(input.attemptId, "genesis_submitted"); - } catch (err: any) { - await adapter.updateProvisionStatus(input.attemptId, "keys_generated", { - lastError: `plc-genesis: ${err.message}`, - }); - throw err; - } - - // Step 2: createAccount - let session: { - did: string; - handle: string; - accessJwt: string; - refreshJwt: string; - }; - try { - const serviceAuthJwt = await mintServiceAuthJwt({ - privateJwk: signingKey.privateJwk, - iss: did, - aud: pdsDid, - lxm: "com.atproto.server.createAccount", - ttlSec: 60, - }); - session = await pds.createAccount({ - pdsUrl: input.pdsEndpoint, - serviceAuthJwt, - body: { - handle: input.handle, - did, - email: input.email, - password: input.password, - inviteCode: input.inviteCode, - }, - }); - await adapter.updateProvisionStatus(input.attemptId, "account_created"); - } catch (err: any) { - await adapter.updateProvisionStatus(input.attemptId, "genesis_submitted", { - lastError: `createAccount: ${err.message}`, - }); - throw err; - } - - // Step 3 + 4: getRecommendedDidCredentials + PLC update op - try { - const recommended = await pds.getRecommendedDidCredentials({ - pdsUrl: input.pdsEndpoint, - accessJwt: session.accessJwt, - }); - const baseRotationKeys = [input.rotationKey, contrailRotation.publicDidKey]; - const updatedRotationKeys = [ - ...baseRotationKeys, - ...recommended.rotationKeys.filter((k) => !baseRotationKeys.includes(k)), - ]; - const unsignedUpdate = buildUpdateOp({ - prev: await cidForOp(signedGenesis), - rotationKeys: updatedRotationKeys, - verificationMethodAtproto: recommended.verificationMethods.atproto, - alsoKnownAs: recommended.alsoKnownAs, - services: recommended.services, - }); - const signedUpdate = await signUpdateOp( - unsignedUpdate, - contrailRotation.privateJwk - ); - await plc.submit(did, signedUpdate); - await adapter.updateProvisionStatus(input.attemptId, "did_doc_updated"); - } catch (err: any) { - await adapter.updateProvisionStatus(input.attemptId, "account_created", { - lastError: `did-doc-update: ${err.message}`, - }); - throw err; - } - - // Step 5: activateAccount - try { - await pds.activateAccount({ - pdsUrl: input.pdsEndpoint, - accessJwt: session.accessJwt, - }); - await adapter.updateProvisionStatus(input.attemptId, "activated"); - } catch (err: any) { - await adapter.updateProvisionStatus(input.attemptId, "did_doc_updated", { - lastError: `activateAccount: ${err.message}`, - }); - throw err; - } - - // Seed the session cache with the JWTs createAccount returned, so the - // first publish doesn't waste a createSession round-trip. ensureSession - // refreshes or falls back to the stored password as the JWTs age out. - await adapter.upsertSession(did, { - accessJwt: session.accessJwt, - refreshJwt: session.refreshJwt, - accessExp: decodeJwtExp(session.accessJwt), - }); - - // Mint a revocable app password so we can publish without holding the - // user's root password. Failure here leaves the row at status=activated - // (the account IS activated upstream) with a last_error breadcrumb; the - // caller can retry with the same attemptId and it will pick up at - // createAppPassword only (see retryAppPasswordOnly). - try { - const minted = await pds.createAppPassword({ - pdsUrl: input.pdsEndpoint, - accessJwt: session.accessJwt, - name: `contrail-${input.attemptId}`, - }); - const encryptedPassword = await cipher.encrypt(minted.password); - await adapter.updateProvisionStatus(input.attemptId, "activated", { - encryptedPassword, - }); - } catch (err: any) { - await adapter.updateProvisionStatus(input.attemptId, "activated", { - lastError: `createAppPassword: ${err.message}`, - }); - // Wrap the error so the route handler / log readers see immediately - // that this is the recoverable retry case, not a generic PDS failure. - throw new Error(`createAppPassword: ${err.message}`); - } - - return { - attemptId: input.attemptId, - did, - status: "activated", - rootCredentials: { - handle: input.handle, - password: input.password, - recoveryHint: "store this — Contrail does not retain it", - }, - }; - } - - /** C3 retry path. Triggered when provision() is called with an attemptId - * whose row is at status='activated' + no encrypted_password. The DID is - * already on PLC; the PDS account is already created and activated; only - * the post-activation app-password mint failed. We need a fresh accessJwt - * (the cached one may have expired by the time the caller retries), then - * re-run createAppPassword. */ - private async retryAppPasswordOnly( - input: ProvisionInput, - existing: NonNullable<Awaited<ReturnType<CommunityAdapter["getProvisionAttempt"]>>> - ): Promise<ProvisionResult> { - const { adapter, cipher, pds } = this.deps; - if (!pds.createSession) { - throw new Error( - "retry path requires PdsClient.createSession to be wired (production buildOrchestrator does this; tests must stub it)" - ); - } - - const session = await pds.createSession({ - pdsUrl: existing.pdsEndpoint, - identifier: input.handle, - password: input.password, - }); - - let mintedPassword: string; - try { - const minted = await pds.createAppPassword({ - pdsUrl: existing.pdsEndpoint, - accessJwt: session.accessJwt, - name: `contrail-${input.attemptId}`, - }); - mintedPassword = minted.password; - } catch (err: any) { - await adapter.updateProvisionStatus(input.attemptId, "activated", { - lastError: `createAppPassword (retry): ${err.message}`, - }); - throw new Error(`createAppPassword (retry): ${err.message}`); - } - - const encryptedPassword = await cipher.encrypt(mintedPassword); - await adapter.updateProvisionStatus(input.attemptId, "activated", { - encryptedPassword, - }); - // Refresh the session cache with the JWTs we just obtained, so the - // first publish for this community doesn't need another createSession. - await adapter.upsertSession(existing.did, { - accessJwt: session.accessJwt, - refreshJwt: session.refreshJwt, - accessExp: decodeJwtExp(session.accessJwt), - }); - - return { - attemptId: input.attemptId, - did: existing.did, - status: "activated", - rootCredentials: { - handle: input.handle, - password: input.password, - recoveryHint: "store this — Contrail does not retain it", - }, - }; - } - -} - -/** Cheap structural check for did:key:z multibase identifiers. The orchestrator - * trusts the caller's submitted rotation key beyond this; PLC will reject any - * malformed key when the genesis op is submitted. */ -function isDidKeyZ(s: string): boolean { - return typeof s === "string" && s.startsWith("did:key:z") && s.length > 12; -} - diff --git a/packages/contrail-community/src/reconcile.ts b/packages/contrail-community/src/reconcile.ts deleted file mode 100644 index 1b0bbb0..0000000 --- a/packages/contrail-community/src/reconcile.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { SpaceAuthority } from "@atmo-dev/contrail"; -import type { CommunityAdapter } from "./adapter"; -import { flattenEffectiveMembers } from "./acl"; - -/** Reconcile `spaces_members` for `spaceUri` to match the flattened effective - * member set derived from `community_access_levels`. Also re-reconciles any - * spaces that delegate to this one (reverse-graph). - * - * Takes a {@link SpaceAuthority} (not a full `StorageAdapter`) — the - * reconciler only needs member-level operations, so depending on the - * narrower interface keeps the dependency direction clean and proves we - * could swap in a non-Contrail authority. */ -export async function reconcile( - community: CommunityAdapter, - spaces: SpaceAuthority, - spaceUri: string, - byDid: string, - opts: { depth?: number; maxReverseDepth?: number } = {} -): Promise<void> { - const maxReverse = opts.maxReverseDepth ?? 8; - const startDepth = opts.depth ?? 0; - if (startDepth > maxReverse) return; - - const effective = await flattenEffectiveMembers(community, spaceUri); - const current = new Set( - (await spaces.listMembers(spaceUri)).map((m) => m.did) - ); - - const adds: string[] = []; - const removes: string[] = []; - for (const did of effective) if (!current.has(did)) adds.push(did); - for (const did of current) if (!effective.has(did)) removes.push(did); - - if (adds.length || removes.length) { - await spaces.applyMembershipDiff(spaceUri, adds, removes, byDid); - } - - // Reverse-graph: if this space is a subject of other spaces' access rows, - // their effective membership may have changed too. - const parents = await community.listSpacesDelegatingTo(spaceUri); - for (const parent of parents) { - await reconcile(community, spaces, parent, byDid, { - depth: startDepth + 1, - maxReverseDepth: maxReverse, - }); - } -} diff --git a/packages/contrail-community/src/router.ts b/packages/contrail-community/src/router.ts deleted file mode 100644 index eb2e4ba..0000000 --- a/packages/contrail-community/src/router.ts +++ /dev/null @@ -1,1372 +0,0 @@ -import type { Context, Hono, MiddlewareHandler } from "hono"; -import type { - ContrailConfig, - Database, - ServiceAuth, - StorageAdapter as SpacesAdapter, -} from "@atmo-dev/contrail"; -import { buildSpaceUri, HostedAdapter } from "@atmo-dev/contrail"; -import { CommunityAdapter } from "./adapter"; -import { CredentialCipher } from "./credentials"; -import { - resolveIdentity, - createPdsSession, - decodeJwtExp, - tryRefreshSession, - normalizePdsEndpoint, -} from "./pds"; -import { - generateKeyPair, - buildGenesisOp, - signGenesisOp, - computeDidPlc, - submitGenesisOp, -} from "./plc"; -import { - pdsCreateAccount, - pdsGetRecommendedDidCredentials, - pdsActivateAccount, - pdsCreateAppPassword, - pdsDescribeServer, -} from "./pds"; -import { - ProvisionOrchestrator, - type PdsClient, - type PlcClient, -} from "./provision"; -import { resolveEffectiveLevel, resolveReachableSpaces, wouldCycle } from "./acl"; -import { reconcile } from "./reconcile"; -import type { AccessLevel } from "./types"; -import { - ACCESS_LEVELS, - rankOf, - isAccessLevel, - isReservedKey, - RESERVED_KEYS, -} from "./types"; -import type { ServiceJwtVerifier } from "@atcute/xrpc-server/auth"; -export interface CommunityRoutesOptions { - /** Override auth middleware for tests. */ - authMiddleware?: MiddlewareHandler; - /** Storage adapter overrides (defaults: HostedAdapter on the given db). */ - communityAdapter?: CommunityAdapter; - spacesAdapter?: SpacesAdapter; -} - -export function registerCommunityRoutes( - app: Hono, - db: Database, - config: ContrailConfig, - options: CommunityRoutesOptions = {}, - ctx?: { - spacesAdapter: SpacesAdapter; - verifier: ServiceJwtVerifier; - } | null -): void { - const cfg = config.community as import("./types").CommunityConfig | undefined; - if (!cfg) return; - if (!config.spaces?.authority) { - throw new Error("community module requires spaces.authority to be enabled in config"); - } - - const community = - options.communityAdapter ?? new CommunityAdapter(db); - const spaces = - options.spacesAdapter ?? ctx?.spacesAdapter ?? new HostedAdapter(db, config); - const cipher = new CredentialCipher(cfg.masterKey); - - const auth = - options.authMiddleware ?? - (() => { - throw new Error( - "community routes require an authMiddleware. Pass options.authMiddleware or ensure spaces.authMiddleware is configured." - ); - })(); - - const NS = `${config.namespace}.community`; - const spaceType = config.spaces.authority.type; - const spaceServiceDid = cfg.serviceDid ?? config.spaces.authority.serviceDid; - - // ========================================================================== - // Community lifecycle - // ========================================================================== - - app.post(`/xrpc/${NS}.adopt`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { identifier?: string; appPassword?: string } - | null; - if (!body?.identifier || !body.appPassword) { - return c.json( - { error: "InvalidRequest", message: "identifier and appPassword required" }, - 400 - ); - } - - // 1. Resolve identifier → DID + PDS endpoint. - let resolved; - try { - resolved = await resolveIdentity(body.identifier, { - resolver: cfg.resolver, - fetch: cfg.fetch, - }); - } catch (err: any) { - return c.json( - { error: "InvalidRequest", message: `could not resolve: ${err.message}` }, - 400 - ); - } - - // 2. Verify the credentials by creating a session. - try { - await createPdsSession( - resolved.pdsEndpoint, - body.identifier, - body.appPassword, - { fetch: cfg.fetch } - ); - } catch (err: any) { - return c.json( - { error: "Unauthorized", message: `credential check failed: ${err.message}` }, - 401 - ); - } - - // 3. Check not already adopted. - const existing = await community.getCommunity(resolved.did); - if (existing) { - return c.json({ error: "AlreadyExists", did: resolved.did }, 409); - } - - // 4. Encrypt + store. - const encrypted = await cipher.encrypt(body.appPassword); - await community.createAdoptedCommunity({ - did: resolved.did, - pdsEndpoint: resolved.pdsEndpoint, - appPasswordEncrypted: encrypted, - identifier: body.identifier, - createdBy: sa.issuer, - }); - - // 5. Bootstrap reserved spaces with caller as owner. - await bootstrapReservedSpaces({ - communityDid: resolved.did, - creatorDid: sa.issuer, - spaces, - community, - type: spaceType, - serviceDid: spaceServiceDid, - }); - - return c.json({ communityDid: resolved.did }); - }); - - app.post(`/xrpc/${NS}.mint`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { handle?: string; pdsEndpoint?: string } - | null; - - // Generate three keypairs: signing (atproto verificationMethod), - // contrail-held rotation, creator-held rotation (recovery). - const signingKey = await generateKeyPair(); - const contrailRotation = await generateKeyPair(); - const creatorRotation = await generateKeyPair(); - - // Build + sign genesis op using contrail's rotation key. - const unsigned = buildGenesisOp({ - rotationKeys: [contrailRotation.publicDidKey, creatorRotation.publicDidKey], - verificationMethodAtproto: signingKey.publicDidKey, - alsoKnownAs: body?.handle ? [`at://${body.handle}`] : [], - services: body?.pdsEndpoint - ? { - atproto_pds: { - type: "AtprotoPersonalDataServer", - endpoint: body.pdsEndpoint, - }, - } - : {}, - }); - const signed = await signGenesisOp(unsigned, contrailRotation.privateJwk); - const did = await computeDidPlc(signed); - - // Submit to PLC directory. - const plcDir = cfg.plcDirectory ?? "https://plc.directory"; - try { - await submitGenesisOp(plcDir, did, signed, { fetch: cfg.fetch }); - } catch (err: any) { - return c.json( - { error: "UpstreamFailure", message: err.message }, - 502 - ); - } - - // Encrypt stored keys (signing + contrail rotation). The creator's - // rotation key is returned once and never stored. - const signingEncrypted = await cipher.encrypt(JSON.stringify(signingKey.privateJwk)); - const contrailRotEncrypted = await cipher.encrypt( - JSON.stringify(contrailRotation.privateJwk) - ); - - await community.createMintedCommunity({ - did, - signingKeyEncrypted: signingEncrypted, - rotationKeyEncrypted: contrailRotEncrypted, - createdBy: sa.issuer, - }); - - await bootstrapReservedSpaces({ - communityDid: did, - creatorDid: sa.issuer, - spaces, - community, - type: spaceType, - serviceDid: spaceServiceDid, - }); - - return c.json({ - communityDid: did, - recoveryKey: creatorRotation.privateJwk, - }); - }); - - app.post(`/xrpc/${NS}.provision`, auth, async (c) => { - // Default-deny gate. Every successful call burns an invite code on the - // target PDS and adds a permanent entry to PLC, so the route refuses - // unless the operator has explicitly opted in via cfg.allowProvisioning. - // Checked BEFORE auth-issued state inspection so an unauthorized op - // doesn't even surface that the route exists in a usable form. - if (!cfg.allowProvisioning) { - return c.json( - { - error: "ProvisioningDisabled", - message: "community.provision is disabled on this Contrail deployment", - }, - 403 - ); - } - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { - attemptId?: string; - handle?: string; - email?: string; - password?: string; - inviteCode?: string; - pdsEndpoint?: string; - rotationKey?: string; - } - | null; - if ( - !body?.handle || - !body.email || - !body.password || - !body.pdsEndpoint || - !body.rotationKey - ) { - return c.json( - { - error: "InvalidRequest", - message: "handle, email, password, pdsEndpoint, rotationKey required", - }, - 400 - ); - } - if ( - !(typeof body.rotationKey === "string" && body.rotationKey.startsWith("did:key:z")) - ) { - return c.json( - { - error: "InvalidRequest", - message: "rotationKey must be a did:key:z…", - }, - 400 - ); - } - - let normalizedPdsEndpoint: string; - try { - normalizedPdsEndpoint = normalizePdsEndpoint(body.pdsEndpoint); - } catch { - return c.json( - { - error: "InvalidRequest", - message: "pdsEndpoint must be a parseable URL", - }, - 400 - ); - } - - const allowed = cfg.allowedProvisionPdsEndpoints; - if (allowed && allowed.length > 0) { - const allowedNormalized = allowed.map((e) => { - try { - return normalizePdsEndpoint(e); - } catch { - // An unparseable allowlist entry can never match; treat as the - // original string so an obvious config typo at least produces a - // reject for the caller rather than a server crash. - return e; - } - }); - if (!allowedNormalized.includes(normalizedPdsEndpoint)) { - return c.json( - { - error: "InvalidRequest", - message: `pdsEndpoint not in allowlist`, - }, - 400 - ); - } - } else if (!cfg.allowAnyProvisionPdsEndpoint) { - // Fail closed: provisioning is enabled but no allowlist is configured. - // An empty/undefined allowlist must NOT mean "sign a genesis op for any - // caller-supplied PDS" — that's the exact attack the allowlist prevents. - // Refuse unless the operator has explicitly opted into the dangerous - // accept-any mode via allowAnyProvisionPdsEndpoint. - // - // SSRF note: allowAnyProvisionPdsEndpoint also bypasses any endpoint - // guard — the caller-supplied pdsEndpoint flows straight to - // describeServer + the createAccount fetches below with no private-IP / - // link-local / cloud-metadata protection. Only enable it behind trusted - // auth, and pair it with an egress network policy (block 169.254.0.0/16) - // + IMDSv2 on the host. The allowlist path above contains this by - // construction. - return c.json( - { - error: "ProvisioningMisconfigured", - message: - "community.provision requires a non-empty allowedProvisionPdsEndpoints allowlist; " + - "set the allowlist, or set allowAnyProvisionPdsEndpoint:true to deliberately accept any PDS", - }, - 403 - ); - } - body.pdsEndpoint = normalizedPdsEndpoint; - - // Resolve the target PDS's DID dynamically. The service-auth JWT's `aud` - // must match what the PDS publishes for itself via describeServer; the - // PDS rejects with BadJwtAudience otherwise. This is what allows a - // single Contrail to mint communities on multiple PDSes — using a - // cfg-pinned value would force a 1:1 Contrail-to-PDS deployment. - let pdsDid: string; - try { - const described = await pdsDescribeServer(body.pdsEndpoint, { - fetch: cfg.fetch, - }); - pdsDid = described.did; - } catch (err: any) { - return c.json( - { - error: "PdsUnreachable", - message: `describeServer failed for ${body.pdsEndpoint}: ${err.message}`, - }, - 502 - ); - } - const orchestrator = buildOrchestrator(cfg, community, cipher, pdsDid); - - const attemptId = body.attemptId ?? crypto.randomUUID(); - let result; - try { - result = await orchestrator.provision({ - attemptId, - pdsEndpoint: body.pdsEndpoint, - handle: body.handle, - email: body.email, - password: body.password, - inviteCode: body.inviteCode, - rotationKey: body.rotationKey, - }); - } catch (err: any) { - // attemptId must always come back to the caller so they can retry - // idempotently (see the C3 retry path in ProvisionOrchestrator). - return c.json( - { error: "ProvisioningFailed", message: err.message, attemptId }, - 502 - ); - } - - // Idempotent graduation: if the community row already exists, the first - // call already wrote both the row and the reserved spaces. A retry with - // the same attemptId should return success without double-creating. - const alreadyGraduated = (await community.getCommunity(result.did)) != null; - if (!alreadyGraduated) { - // Hand the already-encrypted password from the provision_attempts row - // to the communities row, keeping a single source of truth for the - // credential. - const attempt = await community.getProvisionAttempt(attemptId); - if (!attempt?.encryptedPassword) { - return c.json( - { - error: "ProvisioningFailed", - message: "provision attempt missing encryptedPassword after activation", - }, - 502 - ); - } - - await community.createFromProvisioned({ - did: result.did, - pdsEndpoint: body.pdsEndpoint, - handle: body.handle, - appPasswordEncrypted: attempt.encryptedPassword, - createdBy: sa.issuer, - }); - - await bootstrapReservedSpaces({ - communityDid: result.did, - creatorDid: sa.issuer, - spaces, - community, - type: spaceType, - serviceDid: spaceServiceDid, - }); - } - - const responseBody: { - communityDid: string; - status: string; - rootCredentials?: { handle: string; password: string; recoveryHint: string }; - } = { communityDid: result.did, status: result.status }; - if (result.rootCredentials) { - responseBody.rootCredentials = result.rootCredentials; - } - return c.json(responseBody); - }); - - app.post(`/xrpc/${NS}.delete`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { communityDid?: string } - | null; - if (!body?.communityDid) { - return c.json({ error: "InvalidRequest", message: "communityDid required" }, 400); - } - const row = await community.getCommunity(body.communityDid); - if (!row) return c.json({ error: "NotFound" }, 404); - - const adminUri = buildSpaceUri({ - ownerDid: body.communityDid, - type: spaceType, - key: "$admin", - }); - const level = await resolveEffectiveLevel(community, adminUri, sa.issuer); - if (level !== "owner") { - return c.json({ error: "Forbidden", reason: "owner-required" }, 403); - } - - await community.softDeleteCommunity(body.communityDid); - return c.json({ ok: true }); - }); - - app.get(`/xrpc/${NS}.list`, auth, async (c) => { - const sa = getAuth(c); - const actor = c.req.query("actor") ?? sa.issuer; - // 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, - mode: r.mode, - identifier: r.identifier, - createdAt: r.createdAt, - })), - }); - }); - - // `whoami` lives under `<ns>.spaceExt.whoami` — unified across user-owned - // and community-owned spaces, returns `{ isOwner, isMember, accessLevel? }`. - - // ========================================================================== - // Space lifecycle - // ========================================================================== - - app.post(`/xrpc/${NS}.space.create`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { communityDid?: string; key?: string } - | null; - if (!body?.communityDid) { - return c.json({ error: "InvalidRequest", message: "communityDid required" }, 400); - } - - const communityRow = await community.getCommunity(body.communityDid); - if (!communityRow) return c.json({ error: "NotFound", reason: "community-not-found" }, 404); - - if (body.key && isReservedKey(body.key)) { - return c.json( - { error: "InvalidRequest", reason: "reserved-key", message: `reserved keys cannot be created manually` }, - 400 - ); - } - - // Caller must have admin+ in $admin. - const adminUri = buildSpaceUri({ - ownerDid: body.communityDid, - type: spaceType, - key: "$admin", - }); - const level = await resolveEffectiveLevel(community, adminUri, sa.issuer); - if (!level || rankOf(level) < rankOf("admin")) { - return c.json({ error: "Forbidden", reason: "admin-required-in-admin" }, 403); - } - - const key = body.key ?? generateKey(); - const uri = buildSpaceUri({ ownerDid: body.communityDid, type: spaceType, key }); - - const existing = await spaces.getSpace(uri); - if (existing) return c.json({ error: "AlreadyExists", uri }, 409); - - await spaces.createSpace({ - uri, - ownerDid: body.communityDid, - type: spaceType, - key, - serviceDid: spaceServiceDid, - appPolicyRef: null, - appPolicy: null, - }); - - // Creator becomes owner of the new space. - await community.grant({ - spaceUri: uri, - subjectDid: sa.issuer, - accessLevel: "owner", - grantedBy: sa.issuer, - }); - await reconcile(community, spaces, uri, sa.issuer); - - return c.json({ - space: { - uri, - ownerDid: body.communityDid, - type: spaceType, - key, - serviceDid: spaceServiceDid, - createdAt: Date.now(), - }, - }); - }); - - app.post(`/xrpc/${NS}.space.delete`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as { spaceUri?: string } | null; - if (!body?.spaceUri) { - return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); - } - const space = await spaces.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - - const communityRow = await community.getCommunity(space.ownerDid); - if (!communityRow) { - return c.json({ error: "InvalidRequest", reason: "not-community-owned" }, 400); - } - - // Block deletion of reserved spaces. - if (isReservedKey(space.key)) { - return c.json({ error: "Forbidden", reason: "reserved-space-cannot-be-deleted" }, 403); - } - - // Caller must be owner of this space OR admin+ in $admin. - const onSpace = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); - const onAdmin = await resolveEffectiveLevel( - community, - buildSpaceUri({ ownerDid: space.ownerDid, type: spaceType, key: "$admin" }), - sa.issuer - ); - const allowed = - onSpace === "owner" || (onAdmin != null && rankOf(onAdmin) >= rankOf("admin")); - if (!allowed) { - 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. - const members = await spaces.listMembers(body.spaceUri); - if (members.length) { - await spaces.applyMembershipDiff( - body.spaceUri, - [], - members.map((m) => m.did), - sa.issuer - ); - } - for (const parent of delegatingParents) { - await reconcile(community, spaces, parent, sa.issuer); - } - return c.json({ ok: true }); - }); - - // ========================================================================== - // Membership - // ========================================================================== - - app.post(`/xrpc/${NS}.space.grant`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { - spaceUri?: string; - subject?: { did?: string; spaceUri?: string }; - accessLevel?: string; - } - | null; - if (!body?.spaceUri || !body.subject || !body.accessLevel) { - return c.json( - { error: "InvalidRequest", message: "spaceUri, subject, accessLevel required" }, - 400 - ); - } - if (!isAccessLevel(body.accessLevel)) { - return c.json( - { error: "InvalidRequest", message: `invalid accessLevel; one of ${ACCESS_LEVELS.join(", ")}` }, - 400 - ); - } - const subjectDid = body.subject.did; - const subjectSpaceUri = body.subject.spaceUri; - if ((subjectDid ? 1 : 0) + (subjectSpaceUri ? 1 : 0) !== 1) { - return c.json( - { error: "InvalidRequest", message: "subject must have exactly one of did or spaceUri" }, - 400 - ); - } - - const space = await spaces.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - - const communityRow = await community.getCommunity(space.ownerDid); - if (!communityRow) { - return c.json({ error: "InvalidRequest", reason: "not-community-owned" }, 400); - } - - // Caller must have at least manager. - const callerLevel = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); - if (!callerLevel || rankOf(callerLevel) < rankOf("manager")) { - return c.json({ error: "Forbidden", reason: "manager-required" }, 403); - } - // Cannot grant higher than own level. - 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) { - if (subjectSpaceUri === body.spaceUri) { - return c.json({ error: "InvalidRequest", reason: "self-reference" }, 400); - } - if (await wouldCycle(community, body.spaceUri, subjectSpaceUri)) { - return c.json({ error: "InvalidRequest", reason: "cycle-detected" }, 400); - } - } - - await community.grant({ - spaceUri: body.spaceUri, - subjectDid, - subjectSpaceUri, - accessLevel: body.accessLevel, - grantedBy: sa.issuer, - }); - await reconcile(community, spaces, body.spaceUri, sa.issuer); - - return c.json({ ok: true }); - }); - - app.post(`/xrpc/${NS}.space.revoke`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { - spaceUri?: string; - subject?: { did?: string; spaceUri?: string }; - } - | null; - if (!body?.spaceUri || !body.subject) { - return c.json( - { error: "InvalidRequest", message: "spaceUri and subject required" }, - 400 - ); - } - const subjectDid = body.subject.did; - const subjectSpaceUri = body.subject.spaceUri; - if ((subjectDid ? 1 : 0) + (subjectSpaceUri ? 1 : 0) !== 1) { - return c.json( - { error: "InvalidRequest", message: "subject must have exactly one of did or spaceUri" }, - 400 - ); - } - - const space = await spaces.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - - // Caller must outrank the target's current level. - const subject = subjectDid ?? subjectSpaceUri!; - const existing = await community.getAccessRow(body.spaceUri, subject); - if (!existing) { - return c.json({ error: "NotFound", reason: "no-such-grant" }, 404); - } - const callerLevel = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); - if (!callerLevel || rankOf(callerLevel) < rankOf("manager")) { - return c.json({ error: "Forbidden", reason: "manager-required" }, 403); - } - if (rankOf(existing.accessLevel) > rankOf(callerLevel)) { - return c.json({ error: "Forbidden", reason: "cannot-revoke-higher-than-self" }, 403); - } - - // Refuse to remove the last owner — would leave the space (and, on $admin, - // the whole community) unmanageable. Caller can hand off ownership first - // by promoting a successor to `owner`, then revoking themselves. - if (existing.accessLevel === "owner") { - const rows = await community.listAccessRows(body.spaceUri); - const ownerCount = rows.filter((r) => r.accessLevel === "owner").length; - if (ownerCount <= 1) { - return c.json({ error: "LastOwner", reason: "last-owner" }, 409); - } - } - - await community.revoke({ spaceUri: body.spaceUri, subjectDid, subjectSpaceUri }); - await reconcile(community, spaces, body.spaceUri, sa.issuer); - - return c.json({ ok: true }); - }); - - app.get(`/xrpc/${NS}.space.listMembers`, auth, async (c) => { - const sa = getAuth(c); - const spaceUri = c.req.query("spaceUri"); - if (!spaceUri) { - return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); - } - const flatten = c.req.query("flatten") === "true"; - const space = await spaces.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - - // Caller must have at least member. - const callerLevel = await resolveEffectiveLevel(community, spaceUri, sa.issuer); - if (!callerLevel) { - return c.json({ error: "Forbidden", reason: "not-member" }, 403); - } - - if (flatten) { - const members = await spaces.listMembers(spaceUri); - return c.json({ members: members.map((m) => ({ did: m.did, addedAt: m.addedAt })) }); - } - - const rows = await community.listAccessRows(spaceUri); - return c.json({ - rows: rows.map((r) => ({ - subject: r.subjectDid - ? { did: r.subjectDid } - : { spaceUri: r.subjectSpaceUri }, - accessLevel: r.accessLevel, - grantedBy: r.grantedBy, - grantedAt: r.grantedAt, - })), - }); - }); - - app.post(`/xrpc/${NS}.space.setAccessLevel`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { - spaceUri?: string; - subject?: { did?: string; spaceUri?: string }; - accessLevel?: string; - } - | null; - if (!body?.spaceUri || !body.subject || !body.accessLevel) { - return c.json( - { error: "InvalidRequest", message: "spaceUri, subject, accessLevel required" }, - 400 - ); - } - if (!isAccessLevel(body.accessLevel)) { - return c.json({ error: "InvalidRequest", message: "invalid accessLevel" }, 400); - } - const subjectDid = body.subject.did; - const subjectSpaceUri = body.subject.spaceUri; - if ((subjectDid ? 1 : 0) + (subjectSpaceUri ? 1 : 0) !== 1) { - return c.json( - { error: "InvalidRequest", message: "subject must have exactly one of did or spaceUri" }, - 400 - ); - } - - const space = await spaces.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - - const subject = subjectDid ?? subjectSpaceUri!; - const existing = await community.getAccessRow(body.spaceUri, subject); - if (!existing) return c.json({ error: "NotFound", reason: "no-such-grant" }, 404); - - const callerLevel = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); - if (!callerLevel || rankOf(callerLevel) < rankOf("manager")) { - return c.json({ error: "Forbidden", reason: "manager-required" }, 403); - } - // Caller must outrank both the old level AND the new one. - if (rankOf(existing.accessLevel) > rankOf(callerLevel)) { - return c.json({ error: "Forbidden", reason: "cannot-modify-higher-than-self" }, 403); - } - if (rankOf(body.accessLevel) > rankOf(callerLevel)) { - return c.json({ error: "Forbidden", reason: "cannot-grant-higher-than-self" }, 403); - } - // Refuse to demote the last owner — same reasoning as the revoke path. - if (existing.accessLevel === "owner" && body.accessLevel !== "owner") { - const rows = await community.listAccessRows(body.spaceUri); - const ownerCount = rows.filter((r) => r.accessLevel === "owner").length; - if (ownerCount <= 1) { - return c.json({ error: "LastOwner", reason: "last-owner" }, 409); - } - } - - await community.grant({ - spaceUri: body.spaceUri, - subjectDid, - subjectSpaceUri, - accessLevel: body.accessLevel, - grantedBy: sa.issuer, - }); - await reconcile(community, spaces, body.spaceUri, sa.issuer); - return c.json({ ok: true }); - }); - - app.post(`/xrpc/${NS}.space.resync`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as { spaceUri?: string } | null; - if (!body?.spaceUri) { - return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); - } - const space = await spaces.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - - const level = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); - if (!level || rankOf(level) < rankOf("admin")) { - return c.json({ error: "Forbidden", reason: "admin-required" }, 403); - } - - await reconcile(community, spaces, body.spaceUri, sa.issuer); - return c.json({ ok: true }); - }); - - // ========================================================================== - // Publishing — public records (via community PDS) and in-space records - // (authored by the community DID) - // ========================================================================== - - app.post(`/xrpc/${NS}.putRecord`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { - communityDid?: string; - collection?: string; - rkey?: string; - record?: Record<string, unknown>; - validate?: boolean; - } - | null; - if (!body?.communityDid || !body.collection || !body.record) { - return c.json( - { error: "InvalidRequest", message: "communityDid, collection, record required" }, - 400 - ); - } - - const row = await community.getCommunity(body.communityDid); - if (!row) return c.json({ error: "NotFound" }, 404); - - if (row.mode === "mint") { - return c.json( - { error: "NotSupported", reason: "publishing-not-supported-for-minted-communities" }, - 400 - ); - } - // adopt + provision modes both share the credential-proxy publishing path: - // both store {pds_endpoint, identifier, app_password_encrypted}. Falls through. - - // Caller must be member+ in $publishers. - const publishersUri = buildSpaceUri({ - ownerDid: body.communityDid, - type: spaceType, - key: "$publishers", - }); - const level = await resolveEffectiveLevel(community, publishersUri, sa.issuer); - if (!level) { - return c.json({ error: "Forbidden", reason: "not-in-publishers" }, 403); - } - - // Decrypt the stored app password and create a session. - const raw = await community.getRawCredentials(body.communityDid); - if (!raw?.appPasswordEncrypted || !raw.pdsEndpoint || !raw.identifier) { - return c.json( - { error: "InvalidState", reason: "missing-credentials" }, - 500 - ); - } - let session; - try { - const appPassword = await cipher.decryptString(raw.appPasswordEncrypted); - session = await ensureSession({ - community, - did: body.communityDid, - pdsEndpoint: raw.pdsEndpoint, - identifier: raw.identifier, - password: appPassword, - fetch: cfg.fetch, - }); - } catch (err: any) { - return c.json( - { error: "UpstreamFailure", reason: "session-creation-failed", message: err.message }, - 502 - ); - } - - // Proxy createRecord. - const f = cfg.fetch ?? fetch; - const res = await f( - `${raw.pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.repo.createRecord`, - { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${session.accessJwt}`, - }, - body: JSON.stringify({ - repo: body.communityDid, - collection: body.collection, - rkey: body.rkey, - record: body.record, - validate: body.validate, - }), - } - ); - if (res.status === 401) { - // Stale or revoked session: drop the cache so the next request goes - // cold through ensureSession. - await community.clearSession(body.communityDid); - } - if (!res.ok) { - const text = await res.text().catch(() => ""); - return c.json( - { error: "UpstreamFailure", message: `createRecord failed (${res.status}): ${text}` }, - 502 - ); - } - const out = (await res.json()) as { uri?: string; cid?: string }; - return c.json({ uri: out.uri, cid: out.cid }); - }); - - app.post(`/xrpc/${NS}.deleteRecord`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { communityDid?: string; collection?: string; rkey?: string } - | null; - if (!body?.communityDid || !body.collection || !body.rkey) { - return c.json( - { error: "InvalidRequest", message: "communityDid, collection, rkey required" }, - 400 - ); - } - const row = await community.getCommunity(body.communityDid); - if (!row) return c.json({ error: "NotFound" }, 404); - if (row.mode === "mint") { - return c.json({ error: "NotSupported" }, 400); - } - // adopt + provision: same credential-proxy path; falls through. - - const publishersUri = buildSpaceUri({ - ownerDid: body.communityDid, - type: spaceType, - key: "$publishers", - }); - const level = await resolveEffectiveLevel(community, publishersUri, sa.issuer); - if (!level) { - return c.json({ error: "Forbidden", reason: "not-in-publishers" }, 403); - } - - const raw = await community.getRawCredentials(body.communityDid); - if (!raw?.appPasswordEncrypted || !raw.pdsEndpoint || !raw.identifier) { - return c.json({ error: "InvalidState" }, 500); - } - let session; - try { - const appPassword = await cipher.decryptString(raw.appPasswordEncrypted); - session = await ensureSession({ - community, - did: body.communityDid, - pdsEndpoint: raw.pdsEndpoint, - identifier: raw.identifier, - password: appPassword, - fetch: cfg.fetch, - }); - } catch (err: any) { - return c.json( - { error: "UpstreamFailure", message: err.message }, - 502 - ); - } - - const f = cfg.fetch ?? fetch; - const res = await f( - `${raw.pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.repo.deleteRecord`, - { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${session.accessJwt}`, - }, - body: JSON.stringify({ - repo: body.communityDid, - collection: body.collection, - rkey: body.rkey, - }), - } - ); - if (res.status === 401) { - await community.clearSession(body.communityDid); - } - if (!res.ok) { - const text = await res.text().catch(() => ""); - return c.json( - { error: "UpstreamFailure", message: `deleteRecord failed (${res.status}): ${text}` }, - 502 - ); - } - return c.json({ ok: true }); - }); - - app.post(`/xrpc/${NS}.space.putRecord`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { - spaceUri?: string; - collection?: string; - rkey?: string; - record?: Record<string, unknown>; - } - | null; - if (!body?.spaceUri || !body.collection || !body.record) { - return c.json( - { error: "InvalidRequest", message: "spaceUri, collection, record required" }, - 400 - ); - } - - const space = await spaces.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - const row = await community.getCommunity(space.ownerDid); - if (!row) return c.json({ error: "InvalidRequest", reason: "not-community-owned" }, 400); - - const level = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); - if (!level || rankOf(level) < rankOf("admin")) { - return c.json({ error: "Forbidden", reason: "admin-required" }, 403); - } - - const rkey = body.rkey ?? generateKey(); - const now = Date.now(); - await spaces.putRecord({ - spaceUri: body.spaceUri, - collection: body.collection, - authorDid: space.ownerDid, // community DID - rkey, - cid: null, - record: body.record, - createdAt: now, - }); - return c.json({ rkey, authorDid: space.ownerDid, createdAt: now }); - }); - - app.post(`/xrpc/${NS}.space.deleteRecord`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { spaceUri?: string; collection?: string; rkey?: string } - | null; - if (!body?.spaceUri || !body.collection || !body.rkey) { - return c.json( - { error: "InvalidRequest", message: "spaceUri, collection, rkey required" }, - 400 - ); - } - const space = await spaces.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - const row = await community.getCommunity(space.ownerDid); - if (!row) return c.json({ error: "InvalidRequest", reason: "not-community-owned" }, 400); - - const level = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); - if (!level || rankOf(level) < rankOf("admin")) { - return c.json({ error: "Forbidden", reason: "admin-required" }, 403); - } - - await spaces.deleteRecord(body.spaceUri, body.collection, space.ownerDid, body.rkey); - return c.json({ ok: true }); - }); - - // ========================================================================== - // Credential health + reauth - // ========================================================================== - - app.get(`/xrpc/${NS}.getHealth`, auth, async (c) => { - const sa = getAuth(c); - const communityDid = c.req.query("communityDid"); - if (!communityDid) { - return c.json({ error: "InvalidRequest", message: "communityDid required" }, 400); - } - const row = await community.getCommunity(communityDid); - if (!row) return c.json({ error: "NotFound" }, 404); - - // Any member of $admin can ask. - const adminUri = buildSpaceUri({ - ownerDid: communityDid, - type: spaceType, - key: "$admin", - }); - const level = await resolveEffectiveLevel(community, adminUri, sa.issuer); - if (!level) { - return c.json({ error: "Forbidden", reason: "not-member-of-admin" }, 403); - } - - if (row.mode === "mint") { - // Minted communities: we hold the signing key directly; if decryption works, healthy. - try { - const raw = await community.getRawCredentials(communityDid); - if (raw?.signingKeyEncrypted) await cipher.decrypt(raw.signingKeyEncrypted); - return c.json({ status: "healthy" }); - } catch { - return c.json({ status: "expired" }); - } - } - - // Adopted + provisioned: both store an app password against an external PDS. - // Health = we can still create a session with the stored credentials. - const raw = await community.getRawCredentials(communityDid); - if (!raw?.appPasswordEncrypted || !raw.pdsEndpoint || !raw.identifier) { - return c.json({ status: "expired" }); - } - try { - const appPassword = await cipher.decryptString(raw.appPasswordEncrypted); - await ensureSession({ - community, - did: communityDid, - pdsEndpoint: raw.pdsEndpoint, - identifier: raw.identifier, - password: appPassword, - fetch: cfg.fetch, - }); - return c.json({ status: "healthy" }); - } catch { - return c.json({ status: "expired" }); - } - }); - - app.post(`/xrpc/${NS}.reauth`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { communityDid?: string; appPassword?: string } - | null; - if (!body?.communityDid || !body.appPassword) { - return c.json( - { error: "InvalidRequest", message: "communityDid and appPassword required" }, - 400 - ); - } - const row = await community.getCommunity(body.communityDid); - if (!row) return c.json({ error: "NotFound" }, 404); - if (row.mode !== "adopt") { - return c.json({ error: "NotSupported", reason: "reauth-only-for-adopted" }, 400); - } - - // Caller must have owner in $admin. - const adminUri = buildSpaceUri({ - ownerDid: body.communityDid, - type: spaceType, - key: "$admin", - }); - const level = await resolveEffectiveLevel(community, adminUri, sa.issuer); - if (level !== "owner") { - return c.json({ error: "Forbidden", reason: "owner-required" }, 403); - } - - // Re-resolve in case the PDS moved, and verify the new password. - const identifier = row.identifier ?? body.communityDid; - let resolved; - try { - resolved = await resolveIdentity(identifier, { - resolver: cfg.resolver, - fetch: cfg.fetch, - }); - } catch (err: any) { - return c.json( - { error: "InvalidRequest", message: `could not resolve: ${err.message}` }, - 400 - ); - } - try { - await createPdsSession(resolved.pdsEndpoint, identifier, body.appPassword, { - fetch: cfg.fetch, - }); - } catch (err: any) { - return c.json( - { error: "Unauthorized", message: `credential check failed: ${err.message}` }, - 401 - ); - } - - const encrypted = await cipher.encrypt(body.appPassword); - await community.updateAdoptedCredentials({ - did: body.communityDid, - pdsEndpoint: resolved.pdsEndpoint, - appPasswordEncrypted: encrypted, - identifier, - }); - return c.json({ ok: true }); - }); - - // Invites live under `<ns>.invite.*` — see src/core/invite/router.ts. The - // unified surface dispatches on space ownership to choose between the - // ladder-granting path here and the binary-membership path in spaces. -} - -// ============================================================================ -// Helpers -// ============================================================================ - -function getAuth(c: Context): ServiceAuth { - const a = c.get("serviceAuth") as ServiceAuth | undefined; - if (!a) throw new Error("service auth not set"); - return a; -} - -/** TID-ish ASCII lowercase base32-like identifier; good enough for space keys - * alongside the reserved `$`-prefixed ones. Mirrors the approach in spaces/tid.ts. */ -function generateKey(): string { - const chars = "234567abcdefghijklmnopqrstuvwxyz"; - const bytes = crypto.getRandomValues(new Uint8Array(13)); - let out = ""; - for (let i = 0; i < 13; i++) out += chars[bytes[i]! % 32]; - return out; -} - -/** Build a ProvisionOrchestrator wired with real PDS/PLC clients backed by - * `cfg.fetch` (so tests can stub the network the same way they do for the - * mint/adopt routes). Mirrors the ad-hoc wrapper used in the live e2e test - * at apps/contrail-e2e/tests/provision.test.ts. */ -function buildOrchestrator( - cfg: import("./types").CommunityConfig, - adapter: CommunityAdapter, - cipher: CredentialCipher, - pdsDid: string -): ProvisionOrchestrator { - const plcDirectory = cfg.plcDirectory ?? "https://plc.directory"; - const fetchOpts = { fetch: cfg.fetch }; - - const plc: PlcClient = { - submit: (did, op) => submitGenesisOp(plcDirectory, did, op as any, fetchOpts), - }; - - const pds: PdsClient = { - createAccount: ({ pdsUrl, serviceAuthJwt, body }) => - pdsCreateAccount(pdsUrl, serviceAuthJwt, body, fetchOpts), - getRecommendedDidCredentials: ({ pdsUrl, accessJwt }) => - pdsGetRecommendedDidCredentials(pdsUrl, accessJwt, fetchOpts), - activateAccount: ({ pdsUrl, accessJwt }) => - pdsActivateAccount(pdsUrl, accessJwt, fetchOpts), - createAppPassword: async ({ pdsUrl, accessJwt, name }) => { - const r = await pdsCreateAppPassword(pdsUrl, accessJwt, name, fetchOpts); - return { password: r.password }; - }, - createSession: ({ pdsUrl, identifier, password }) => - createPdsSession(pdsUrl, identifier, password, fetchOpts), - }; - - return new ProvisionOrchestrator({ adapter, cipher, plc, pds, pdsDid }); -} - -async function bootstrapReservedSpaces(args: { - communityDid: string; - creatorDid: string; - spaces: SpacesAdapter; - community: CommunityAdapter; - type: string; - serviceDid: string; -}): Promise<void> { - for (const key of RESERVED_KEYS) { - const uri = buildSpaceUri({ - ownerDid: args.communityDid, - type: args.type, - key, - }); - await args.spaces.createSpace({ - uri, - ownerDid: args.communityDid, - type: args.type, - key, - serviceDid: args.serviceDid, - appPolicyRef: null, - appPolicy: null, - }); - await args.community.grant({ - spaceUri: uri, - subjectDid: args.creatorDid, - accessLevel: "owner", - grantedBy: args.creatorDid, - }); - // Materialize membership: creator is in the space. - await args.spaces.applyMembershipDiff(uri, [args.creatorDid], [], args.creatorDid); - } -} - -/** Ensure a usable PDS session for the given community DID. Tries the cached - * session first (with a 30s skew); if expired, tries refresh; if refresh fails - * (or there's no cache), falls back to creating a fresh session with the - * stored app password. The result is always written back to the cache. */ -async function ensureSession(args: { - community: CommunityAdapter; - did: string; - pdsEndpoint: string; - identifier: string; - password: string; - fetch?: typeof fetch; -}): Promise<{ accessJwt: string; refreshJwt: string }> { - const now = Math.floor(Date.now() / 1000); - const cached = await args.community.getSession(args.did); - if (cached && cached.accessExp > now + 30) { - return { accessJwt: cached.accessJwt, refreshJwt: cached.refreshJwt }; - } - if (cached) { - const refreshed = await tryRefreshSession({ - pdsUrl: args.pdsEndpoint, - refreshJwt: cached.refreshJwt, - fetch: args.fetch, - }); - if (refreshed) { - await args.community.upsertSession(args.did, refreshed); - return { accessJwt: refreshed.accessJwt, refreshJwt: refreshed.refreshJwt }; - } - } - const session = await createPdsSession( - args.pdsEndpoint, - args.identifier, - args.password, - { fetch: args.fetch } - ); - await args.community.upsertSession(args.did, { - accessJwt: session.accessJwt, - refreshJwt: session.refreshJwt, - accessExp: decodeJwtExp(session.accessJwt), - }); - return { accessJwt: session.accessJwt, refreshJwt: session.refreshJwt }; -} diff --git a/packages/contrail-community/src/schema.ts b/packages/contrail-community/src/schema.ts deleted file mode 100644 index 20d34f2..0000000 --- a/packages/contrail-community/src/schema.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { Database, SqlDialect } from "@atmo-dev/contrail"; -import { getDialect } from "@atmo-dev/contrail"; - -export function buildCommunitySchema(dialect: SqlDialect): string[] { - return [ - `CREATE TABLE IF NOT EXISTS communities ( - did TEXT PRIMARY KEY, - mode TEXT NOT NULL, - pds_endpoint TEXT, - app_password_encrypted TEXT, - identifier TEXT, - signing_key_encrypted TEXT, - rotation_key_encrypted TEXT, - created_by TEXT NOT NULL, - created_at ${dialect.bigintType} NOT NULL, - deleted_at ${dialect.bigintType} - )`, - `CREATE INDEX IF NOT EXISTS idx_communities_created_at ON communities(created_at DESC)`, - - `CREATE TABLE IF NOT EXISTS community_access_levels ( - space_uri TEXT NOT NULL, - subject TEXT NOT NULL, - subject_kind TEXT NOT NULL CHECK (subject_kind IN ('did', 'space')), - access_level TEXT NOT NULL, - granted_by TEXT NOT NULL, - granted_at ${dialect.bigintType} NOT NULL, - PRIMARY KEY (space_uri, subject) - )`, - `CREATE INDEX IF NOT EXISTS idx_cal_subject ON community_access_levels(subject)`, - `CREATE INDEX IF NOT EXISTS idx_cal_subject_space ON community_access_levels(subject) - WHERE subject_kind = 'space'`, - - `CREATE TABLE IF NOT EXISTS community_invites ( - token_hash TEXT PRIMARY KEY NOT NULL, - space_uri TEXT NOT NULL, - access_level TEXT NOT NULL, - created_by TEXT NOT NULL, - created_at ${dialect.bigintType} NOT NULL, - expires_at ${dialect.bigintType}, - max_uses INTEGER, - used_count INTEGER NOT NULL DEFAULT 0, - revoked_at ${dialect.bigintType}, - note TEXT - )`, - `CREATE INDEX IF NOT EXISTS idx_community_invites_space ON community_invites(space_uri, created_at DESC)`, - - `CREATE TABLE IF NOT EXISTS provision_attempts ( - attempt_id TEXT PRIMARY KEY NOT NULL, - did TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ( - 'keys_generated', - 'genesis_submitted', - 'account_created', - 'did_doc_updated', - 'activated' - )), - pds_endpoint TEXT NOT NULL, - handle TEXT NOT NULL, - email TEXT NOT NULL, - invite_code TEXT, - encrypted_signing_key TEXT, - encrypted_rotation_key TEXT, - encrypted_password TEXT, - genesis_submitted_at ${dialect.bigintType}, - account_created_at ${dialect.bigintType}, - did_doc_updated_at ${dialect.bigintType}, - activated_at ${dialect.bigintType}, - last_error TEXT, - created_at ${dialect.bigintType} NOT NULL, - updated_at ${dialect.bigintType} NOT NULL - )`, - `CREATE INDEX IF NOT EXISTS idx_provision_attempts_status ON provision_attempts(status, updated_at DESC)`, - `CREATE UNIQUE INDEX IF NOT EXISTS idx_provision_attempts_did ON provision_attempts(did)`, - - `CREATE TABLE IF NOT EXISTS provision_attempts_archive ( - attempt_id TEXT PRIMARY KEY NOT NULL, - did TEXT NOT NULL, - pds_endpoint TEXT NOT NULL, - handle TEXT NOT NULL, - email TEXT NOT NULL, - invite_code TEXT, - last_status TEXT, - last_error TEXT, - archived_at ${dialect.bigintType} NOT NULL, - tombstone_op_cid TEXT, - notes TEXT - )`, - `CREATE INDEX IF NOT EXISTS idx_provision_attempts_archive_archived_at ON provision_attempts_archive(archived_at DESC)`, - - `CREATE TABLE IF NOT EXISTS community_sessions ( - community_did TEXT PRIMARY KEY NOT NULL, - access_jwt TEXT NOT NULL, - refresh_jwt TEXT NOT NULL, - access_exp ${dialect.bigintType} NOT NULL, - updated_at ${dialect.bigintType} NOT NULL - )`, - ]; -} - -export async function initCommunitySchema(db: Database): Promise<void> { - const dialect = getDialect(db); - const stmts = buildCommunitySchema(dialect); - await db.batch(stmts.map((s) => db.prepare(s))); -} diff --git a/packages/contrail-community/src/service-auth.ts b/packages/contrail-community/src/service-auth.ts deleted file mode 100644 index 0c02073..0000000 --- a/packages/contrail-community/src/service-auth.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** Mint an ES256 service-auth JWT for PDS XRPC calls. - * See com.atproto.server.createAccount handler: PDS verifies - * iss === did, aud === pds-did, lxm === lexicon-method, exp not in past. - * Signature is verified against the iss DID's atproto verificationMethod. */ - -import { signBytes, bytesToB64url } from "./plc"; - -function b64url(input: Uint8Array | string): string { - const b = typeof input === "string" ? new TextEncoder().encode(input) : input; - return bytesToB64url(b); -} - -function b64urlJson(obj: unknown): string { - return b64url(JSON.stringify(obj)); -} - -export interface MintServiceAuthInput { - /** Private JWK for the signing key (atproto verificationMethod). */ - privateJwk: JsonWebKey; - /** Issuer DID (the account's did:plc). */ - iss: string; - /** Audience DID (the target PDS, e.g. did:web:pds.example). */ - aud: string; - /** Lexicon method being authorized, e.g. com.atproto.server.createAccount. */ - lxm: string; - /** Token TTL in seconds. Defaults to 60. */ - ttlSec?: number; - /** Override "now" for deterministic tests; epoch milliseconds. */ - now?: number; -} - -export async function mintServiceAuthJwt(input: MintServiceAuthInput): Promise<string> { - const iat = Math.floor((input.now ?? Date.now()) / 1000); - const ttl = input.ttlSec ?? 60; - // Header: alg+typ only — atproto's service-auth verification doesn't use kid; - // the signing key is resolved from the iss DID's verificationMethod. - const header = { alg: "ES256", typ: "JWT" }; - const payload = { - iat, - iss: input.iss, - aud: input.aud, - exp: iat + ttl, - lxm: input.lxm, - jti: crypto.randomUUID(), - }; - const signingInput = `${b64urlJson(header)}.${b64urlJson(payload)}`; - // signBytes returns IEEE P1363 r||s (64 bytes), low-S normalized. - // JWT ES256 mandates raw r||s, NOT DER. atproto enforces low-S as well. - const sig = await signBytes(input.privateJwk, new TextEncoder().encode(signingInput)); - return `${signingInput}.${b64url(sig)}`; -} diff --git a/packages/contrail-community/src/types.ts b/packages/contrail-community/src/types.ts deleted file mode 100644 index 4eba700..0000000 --- a/packages/contrail-community/src/types.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { Database } from "@atmo-dev/contrail"; -import type { DidDocumentResolver } from "@atcute/identity-resolver"; - -/** Access level a subject (did or group-space) has on a given space. - * Levels are totally ordered. Higher levels include lower levels' powers. - * See docs/community.md for the exact semantics. */ -export type AccessLevel = "member" | "manager" | "admin" | "owner"; - -export const ACCESS_LEVELS: readonly AccessLevel[] = [ - "member", - "manager", - "admin", - "owner", -] as const; - -export function rankOf(level: AccessLevel): number { - return ACCESS_LEVELS.indexOf(level); -} - -export function isAccessLevel(v: unknown): v is AccessLevel { - return typeof v === "string" && ACCESS_LEVELS.includes(v as AccessLevel); -} - -export type CommunityMode = "adopt" | "mint" | "provision"; - -export const PROVISION_STATUSES = [ - "keys_generated", - "genesis_submitted", - "account_created", - "did_doc_updated", - "activated", -] as const; -export type ProvisionStatus = (typeof PROVISION_STATUSES)[number]; - -export interface ProvisionAttemptRow { - attemptId: string; - did: string; - status: ProvisionStatus; - pdsEndpoint: string; - handle: string; - email: string; - inviteCode: string | null; - encryptedSigningKey: string | null; - encryptedRotationKey: string | null; - encryptedPassword: string | null; - genesisSubmittedAt: number | null; - accountCreatedAt: number | null; - didDocUpdatedAt: number | null; - activatedAt: number | null; - lastError: string | null; - createdAt: number; - updatedAt: number; -} - -export interface CreateProvisionAttemptInput { - attemptId: string; - did: string; - pdsEndpoint: string; - handle: string; - email: string; - inviteCode?: string | null; - encryptedSigningKey: string; - encryptedRotationKey: string; -} - -export interface CommunityConfig { - /** Service DID for JWT verification. Falls back to spaces.serviceDid when both modules are enabled. */ - serviceDid?: string; - /** PLC directory host for minted communities. */ - plcDirectory?: string; - /** Master key for envelope-encrypting stored credentials (app passwords, signing keys, rotation keys). - * Accepts a raw Uint8Array (preferred) or a base64/hex string that decodes to 32 bytes. */ - masterKey: Uint8Array | string; - /** Optional override for DID resolution (used during adopt to resolve identifier → DID → PDS). */ - resolver?: DidDocumentResolver; - /** Optional override for the fetch implementation (useful for tests). */ - fetch?: typeof fetch; - /** Allowlist of PDS endpoints `community.provision` may create a community - * account on. Callers must supply a `pdsEndpoint` that matches one of these - * entries (after normalization); other values are rejected before any PLC - * op is signed. This gates ONLY provisioning — Contrail still reads/indexes - * records from every PDS on the network; this is not a global PDS filter. - * - * Fail-closed: when `allowProvisioning` is true this list MUST be non-empty, - * otherwise `community.provision` is refused. An empty/undefined allowlist - * no longer means "any PDS" — to genuinely accept any caller-supplied - * endpoint, set `allowAnyProvisionPdsEndpoint: true` (a separate, loud - * opt-in). Operators running a public/multi-tenant Contrail MUST keep a - * real allowlist here so callers can't mint PLC entries pointing at - * attacker-controlled PDSes signed by Contrail's rotation key. */ - allowedProvisionPdsEndpoints?: string[]; - /** Explicit, loud opt-in to accept ANY caller-supplied `pdsEndpoint` when - * provisioning is enabled. Only honored when `allowProvisioning` is true. - * This is the dangerous mode the allowlist exists to prevent: every - * successful call signs a permanent PLC genesis op pointing at a - * caller-controlled endpoint with Contrail's rotation key. Leave unset - * (or false) on any public/multi-tenant deployment and use - * `allowedProvisionPdsEndpoints` instead. */ - allowAnyProvisionPdsEndpoint?: boolean; - /** Top-level switch for the `community.provision` route. Default-deny: a - * call with the route present but this flag unset (or false) returns 403 - * ProvisioningDisabled BEFORE any PLC/PDS work runs. Set to `true` only - * when the operator has confirmed the upstream auth middleware restricts - * this route to authorized callers — every successful call burns a real - * invite code on the target PDS and adds a permanent entry to PLC. - * `mint` and `adopt` are not gated by this flag; they don't burn external - * resources. */ - allowProvisioning?: boolean; -} - -/** 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; - pdsEndpoint: string | null; - identifier: string | null; - createdBy: string; - createdAt: number; - deletedAt: number | null; -} - -export interface AccessLevelRow { - spaceUri: string; - subjectDid: string | null; - subjectSpaceUri: string | null; - accessLevel: AccessLevel; - grantedBy: string; - grantedAt: number; -} - -/** A pre-signed grant for a community-owned space. An admin/manager creates - * one; anyone with the raw token can redeem it once to get the specified - * access level. Storage keeps only the SHA-256 of the token. */ -export interface CommunityInviteRow { - tokenHash: string; - spaceUri: string; - accessLevel: AccessLevel; - createdBy: string; - createdAt: number; - expiresAt: number | null; - maxUses: number | null; - usedCount: number; - revokedAt: number | null; - note: string | null; -} - -export interface CreateCommunityInviteInput { - spaceUri: string; - tokenHash: string; - accessLevel: AccessLevel; - createdBy: string; - expiresAt: number | null; - maxUses: number | null; - note: string | null; -} - -/** Key prefix for reserved community-owned spaces. */ -export const RESERVED_KEYS = ["$admin", "$publishers"] as const; -export type ReservedKey = (typeof RESERVED_KEYS)[number]; - -export function isReservedKey(key: string): key is ReservedKey { - return (RESERVED_KEYS as readonly string[]).includes(key); -} - -export interface AdapterContext { - db: Database; -} diff --git a/packages/contrail-community/src/whoami.ts b/packages/contrail-community/src/whoami.ts deleted file mode 100644 index 5f06123..0000000 --- a/packages/contrail-community/src/whoami.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** Whoami extension that adds `accessLevel` (and the corrected `isMember`) - * for community-owned spaces. Returns null for non-community spaces so the - * spaces module's default binary-membership logic runs. */ - -import type { WhoamiExtension } from "@atmo-dev/contrail"; -import type { CommunityAdapter } from "./adapter"; -import { resolveEffectiveLevel } from "./acl"; - -export function createCommunityWhoamiExtension(args: { - community: CommunityAdapter; -}): WhoamiExtension { - const { community } = args; - return async ({ spaceUri, callerDid, isOwner, ownerDid }) => { - const isCommunity = !!(await community.getCommunity(ownerDid)); - if (!isCommunity) return null; - - // The reconciler keeps spaces_members in sync with the access-level - // ladder, so isMember derives from the effective level directly. - const level = await resolveEffectiveLevel(community, spaceUri, callerDid); - return { - isOwner, - isMember: isOwner || !!level, - accessLevel: level, - }; - }; -} diff --git a/packages/contrail-community/tests/cli-reap.test.ts b/packages/contrail-community/tests/cli-reap.test.ts deleted file mode 100644 index 0407475..0000000 --- a/packages/contrail-community/tests/cli-reap.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import type { Database } from "@atmo-dev/contrail-base"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; -import { initCommunitySchema } from "../src/schema"; -import { CommunityAdapter } from "../src/adapter"; -import { CredentialCipher } from "../src/credentials"; -import { - generateKeyPair, - buildTombstoneOp, - signTombstoneOp, - submitTombstoneOp, - cidForOp, - type SignedGenesisOp, -} from "../src/plc"; -import { runReap, chooseReapDbSource } from "../src/cli/reap"; - -type SeedStatus = - | "keys_generated" - | "genesis_submitted" - | "account_created" - | "did_doc_updated" - | "activated"; - -interface SeedAttemptOpts { - attemptId: string; - did: string; - status: SeedStatus; -} - -async function seedAttempt( - adapter: CommunityAdapter, - cipher: CredentialCipher, - opts: SeedAttemptOpts -): Promise<{ rotationJwk: JsonWebKey }> { - const kp = await generateKeyPair(); - const encryptedRotation = await cipher.encrypt(JSON.stringify(kp.privateJwk)); - await adapter.createProvisionAttempt({ - attemptId: opts.attemptId, - did: opts.did, - pdsEndpoint: "https://pds.test", - handle: `${opts.attemptId}.pds.test`, - email: `${opts.attemptId}@x.test`, - encryptedSigningKey: await cipher.encrypt("{}"), - encryptedRotationKey: encryptedRotation, - }); - // Walk the row forward to its target status. The row starts at - // keys_generated after createProvisionAttempt. - const path: SeedStatus[] = [ - "genesis_submitted", - "account_created", - "did_doc_updated", - "activated", - ]; - for (const next of path) { - if (opts.status === "keys_generated") break; - await adapter.updateProvisionStatus(opts.attemptId, next); - if (next === opts.status) break; - } - return { rotationJwk: kp.privateJwk }; -} - -interface PlcCall { - url: string; - method: string; - body: any; -} - -/** Stand-in for what PLC's `/log/last` actually returns: the bare signed op - * object, no envelope. `getLastOpCid` computes the CID locally via cidForOp. */ -const FAKE_LAST_OP: SignedGenesisOp = { - type: "plc_operation", - prev: null, - rotationKeys: ["did:key:zQ3shfakerotation00000000000000000000000000000000000"], - verificationMethods: { atproto: "did:key:zQ3shfakeverif00000000000000000000000000000000000000" }, - alsoKnownAs: ["at://fixture.pds.test"], - services: { - atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://pds.test" }, - }, - sig: "fakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfak", -}; - -function makeFakeFetch(calls: PlcCall[]): typeof fetch { - return (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url.endsWith("/log/last")) { - return new Response(JSON.stringify(FAKE_LAST_OP), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - calls.push({ - url, - method: init?.method ?? "GET", - body: init?.body ? JSON.parse(String(init.body)) : null, - }); - return new Response("", { status: 200 }); - }) as typeof fetch; -} - -describe("runReap (cli reap)", () => { - let db: Database; - let adapter: CommunityAdapter; - let cipher: CredentialCipher; - - beforeEach(async () => { - db = createSqliteDatabase(":memory:"); - await initCommunitySchema(db); - cipher = new CredentialCipher(new Uint8Array(32).fill(7)); - adapter = new CommunityAdapter(db); - }); - - it("rejects when neither --attempt-id nor --all-stuck is set", async () => { - const result = await runReap({ - adapter, - cipher, - plcDirectory: "https://plc.test", - fetch: makeFakeFetch([]), - logger: { log: () => {}, error: () => {} }, - yes: true, - }); - expect(result.ok).toBe(false); - expect(result.error).toMatch(/--attempt-id|--all-stuck/i); - }); - - it("rejects when both --attempt-id and --all-stuck are set", async () => { - const result = await runReap({ - adapter, - cipher, - plcDirectory: "https://plc.test", - fetch: makeFakeFetch([]), - logger: { log: () => {}, error: () => {} }, - yes: true, - attemptId: "a1", - allStuck: true, - }); - expect(result.ok).toBe(false); - expect(result.error).toMatch(/mutually exclusive|both|exactly one/i); - }); - - it("real run with --attempt-id submits a tombstone and archives the row", async () => { - await seedAttempt(adapter, cipher, { - attemptId: "a-stuck", - did: "did:plc:stuck", - status: "genesis_submitted", - }); - - const calls: PlcCall[] = []; - const result = await runReap({ - adapter, - cipher, - plcDirectory: "https://plc.test", - fetch: makeFakeFetch(calls), - logger: { log: () => {}, error: () => {} }, - yes: true, - attemptId: "a-stuck", - dryRun: false, - }); - - expect(result.ok).toBe(true); - expect(result.reaped).toBe(1); - expect(result.errors).toBe(0); - expect(calls.length).toBe(1); - expect(calls[0]!.url).toBe("https://plc.test/did:plc:stuck"); - expect(calls[0]!.body.type).toBe("plc_tombstone"); - expect(calls[0]!.body.prev).toBe(await cidForOp(FAKE_LAST_OP)); - - // Original row removed from provision_attempts. - expect(await adapter.getProvisionAttempt("a-stuck")).toBeNull(); - // Archive row populated with the row's last live status. - const archive = await db - .prepare( - "SELECT * FROM provision_attempts_archive WHERE attempt_id = ?" - ) - .bind("a-stuck") - .first<Record<string, any>>(); - expect(archive).not.toBeNull(); - expect(archive!.did).toBe("did:plc:stuck"); - expect(archive!.last_status).toBe("genesis_submitted"); - expect(archive!.tombstone_op_cid).toBeTruthy(); - }); - - it("defaults to dry-run when dryRun is unspecified (safety default)", async () => { - await seedAttempt(adapter, cipher, { - attemptId: "a-stuck", - did: "did:plc:stuck", - status: "did_doc_updated", - }); - - const calls: PlcCall[] = []; - const result = await runReap({ - adapter, - cipher, - plcDirectory: "https://plc.test", - fetch: makeFakeFetch(calls), - logger: { log: () => {}, error: () => {} }, - yes: true, - attemptId: "a-stuck", - // dryRun INTENTIONALLY OMITTED — must default to dry-run. - }); - - expect(result.ok).toBe(true); - expect(result.reaped).toBe(0); - expect(result.dryRunSkipped).toBe(1); - expect(calls.length).toBe(0); - const row = await adapter.getProvisionAttempt("a-stuck"); - expect(row?.status).toBe("did_doc_updated"); - }); - - it("with --all-stuck reaps every non-activated row, regardless of status", async () => { - await seedAttempt(adapter, cipher, { - attemptId: "s1", - did: "did:plc:s1", - status: "keys_generated", - }); - await seedAttempt(adapter, cipher, { - attemptId: "s2", - did: "did:plc:s2", - status: "genesis_submitted", - }); - await seedAttempt(adapter, cipher, { - attemptId: "s3", - did: "did:plc:s3", - status: "did_doc_updated", - }); - // An activated row must NOT be reaped. - await seedAttempt(adapter, cipher, { - attemptId: "live", - did: "did:plc:live", - status: "activated", - }); - - const calls: PlcCall[] = []; - const result = await runReap({ - adapter, - cipher, - plcDirectory: "https://plc.test", - fetch: makeFakeFetch(calls), - logger: { log: () => {}, error: () => {} }, - yes: true, - allStuck: true, - dryRun: false, - olderThanMs: 0, // these rows are freshly seeded; disable the age floor - }); - - expect(result.ok).toBe(true); - expect(result.reaped).toBe(3); - expect(calls.map((c) => c.url).sort()).toEqual([ - "https://plc.test/did:plc:s1", - "https://plc.test/did:plc:s2", - "https://plc.test/did:plc:s3", - ]); - // The activated row is untouched. - const live = await adapter.getProvisionAttempt("live"); - expect(live?.status).toBe("activated"); - }); - - it("--all-stuck skips freshly-updated in-flight rows under the default age floor", async () => { - // A row mid-state-machine (updated_at ~ now) must survive a default - // --all-stuck run so reap can't tombstone a DID about to activate. - await seedAttempt(adapter, cipher, { - attemptId: "in-flight", - did: "did:plc:inflight", - status: "genesis_submitted", - }); - - const calls: PlcCall[] = []; - const result = await runReap({ - adapter, - cipher, - plcDirectory: "https://plc.test", - fetch: makeFakeFetch(calls), - logger: { log: () => {}, error: () => {} }, - yes: true, - allStuck: true, - dryRun: false, - // olderThanMs OMITTED — must apply the default 30-min floor. - }); - - expect(result.ok).toBe(true); - expect(result.reaped).toBe(0); - expect(calls.length).toBe(0); - // The in-flight row is untouched. - expect((await adapter.getProvisionAttempt("in-flight"))?.status).toBe( - "genesis_submitted" - ); - }); - - it("refuses to reap an activated row passed via --attempt-id", async () => { - await seedAttempt(adapter, cipher, { - attemptId: "live", - did: "did:plc:live", - status: "activated", - }); - - const calls: PlcCall[] = []; - const result = await runReap({ - adapter, - cipher, - plcDirectory: "https://plc.test", - fetch: makeFakeFetch(calls), - logger: { log: () => {}, error: () => {} }, - yes: true, - attemptId: "live", - dryRun: false, - }); - - expect(calls.length).toBe(0); - expect(result.errors).toBeGreaterThanOrEqual(1); - const live = await adapter.getProvisionAttempt("live"); - expect(live?.status).toBe("activated"); - }); -}); - -describe("chooseReapDbSource", () => { - it("selects Postgres when --db is given", () => { - expect( - chooseReapDbSource({ db: "postgres://x", databaseUrl: undefined }) - ).toEqual({ kind: "postgres", url: "postgres://x" }); - }); - - it("selects Postgres from DATABASE_URL when --db is absent", () => { - expect( - chooseReapDbSource({ db: undefined, databaseUrl: "postgres://env" }) - ).toEqual({ kind: "postgres", url: "postgres://env" }); - }); - - it("prefers an explicit --db over DATABASE_URL", () => { - expect( - chooseReapDbSource({ db: "postgres://flag", databaseUrl: "postgres://env" }) - ).toEqual({ kind: "postgres", url: "postgres://flag" }); - }); - - it("falls back to the D1 binding when neither is set", () => { - expect(chooseReapDbSource({ db: undefined, databaseUrl: undefined })).toEqual({ - kind: "d1", - }); - }); -}); - -describe("plc tombstone helpers", () => { - it("signTombstoneOp adds a base64url sig", async () => { - const kp = await generateKeyPair(); - const op = buildTombstoneOp("bafyreigenesis"); - const signed = await signTombstoneOp(op, kp.privateJwk); - expect(signed.type).toBe("plc_tombstone"); - expect(signed.prev).toBe("bafyreigenesis"); - expect(signed.sig).toMatch(/^[A-Za-z0-9_-]+$/); - }); - - it("submitTombstoneOp POSTs to the PLC directory at the DID URL", async () => { - const kp = await generateKeyPair(); - const signed = await signTombstoneOp( - buildTombstoneOp("bafyreigenesis"), - kp.privateJwk - ); - - let calledUrl = ""; - let calledBody: any = null; - const fakeFetch: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - calledUrl = String(input); - calledBody = init?.body ? JSON.parse(String(init.body)) : null; - return new Response("", { status: 200 }); - }) as typeof fetch; - - await submitTombstoneOp("https://plc.test", "did:plc:abc", signed, { - fetch: fakeFetch, - }); - expect(calledUrl).toBe("https://plc.test/did:plc:abc"); - expect(calledBody.type).toBe("plc_tombstone"); - expect(calledBody.prev).toBe("bafyreigenesis"); - expect(calledBody.sig).toBe(signed.sig); - }); - - it("submitTombstoneOp throws on non-2xx", async () => { - const kp = await generateKeyPair(); - const signed = await signTombstoneOp( - buildTombstoneOp("bafyreigenesis"), - kp.privateJwk - ); - const fakeFetch: typeof fetch = (async () => - new Response("denied", { status: 400 })) as typeof fetch; - await expect( - submitTombstoneOp("https://plc.test", "did:plc:abc", signed, { - fetch: fakeFetch, - }) - ).rejects.toThrow(/400.*denied/); - }); -}); diff --git a/packages/contrail-community/tests/community-delegation.test.ts b/packages/contrail-community/tests/community-delegation.test.ts deleted file mode 100644 index 5449e7c..0000000 --- a/packages/contrail-community/tests/community-delegation.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { describe, it, expect, beforeAll, beforeEach } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; -import { initSchema } from "@atmo-dev/contrail"; -import { createApp } from "@atmo-dev/contrail"; -import { resolveConfig } from "@atmo-dev/contrail"; -import type { ContrailConfig } from "@atmo-dev/contrail"; -import { createCommunityIntegration } from "../src/integration"; - -const ALICE = "did:plc:alice"; -const BOB = "did:plc:bob"; -const CHARLIE = "did:plc:charlie"; -const DIANA = "did:plc:diana"; -const COMMUNITY_DID = "did:plc:acme"; -const PDS_ENDPOINT = "https://pds.example"; - -const MASTER_KEY = new Uint8Array(32).fill(11); - -const CONFIG: ContrailConfig = { - namespace: "test.comm", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: "did:web:test.example#svc", - }, - recordHost: {}, - }, - community: { - masterKey: MASTER_KEY, - plcDirectory: "https://plc.test", - fetch: mockFetch, - resolver: mockResolver(), - }, -}; - -function mockResolver(): any { - return { - resolve: async (did: string) => { - if (did !== COMMUNITY_DID) throw new Error("unknown did"); - return { - id: did, - service: [ - { - id: "#atproto_pds", - type: "AtprotoPersonalDataServer", - serviceEndpoint: PDS_ENDPOINT, - }, - ], - }; - }, - }; -} - -async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/xrpc/com.atproto.server.createSession") && init?.method === "POST") { - return new Response( - JSON.stringify({ accessJwt: "a.b.c", refreshJwt: "r.r.r", did: COMMUNITY_DID }), - { 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 }); -} - -function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { issuer: did, audience: CONFIG.spaces!.authority!.serviceDid, lxm: undefined }); - await next(); - }; -} - -async function makeApp(): Promise<Hono> { - const db = createSqliteDatabase(":memory:"); - const resolved = resolveConfig(CONFIG); - const community = createCommunityIntegration({ db, config: resolved }); - await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); - return createApp(db, resolved, { - spaces: { authMiddleware: fakeAuth() }, - community, - }); -} - -function call( - app: Hono, - method: string, - path: string, - did: string, - body?: any -): Promise<Response> { - const headers: Record<string, string> = { "X-Test-Did": did }; - if (body !== undefined) headers["Content-Type"] = "application/json"; - return app.fetch( - new Request(`http://localhost${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) - ); -} - -async function adopt(app: Hono, caller: string) { - const res = await call(app, "POST", "/xrpc/test.comm.community.adopt", caller, { - identifier: COMMUNITY_DID, - appPassword: "ok", - }); - expect(res.status).toBe(200); -} - -async function createSpace(app: Hono, caller: string, key: string): Promise<string> { - const res = await call(app, "POST", "/xrpc/test.comm.community.space.create", caller, { - communityDid: COMMUNITY_DID, - key, - }); - expect(res.status).toBe(200); - return ((await res.json()) as any).space.uri; -} - -async function grant(app: Hono, caller: string, spaceUri: string, subject: any, accessLevel: string) { - const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", caller, { - spaceUri, - subject, - accessLevel, - }); - return res; -} - -async function whoamiLevel(app: Hono, caller: string, spaceUri: string): Promise<string | null> { - const res = await call(app, "GET", `/xrpc/test.comm.spaceExt.whoami?spaceUri=${encodeURIComponent(spaceUri)}`, caller); - expect(res.status).toBe(200); - return ((await res.json()) as any).accessLevel; -} - -async function flatMembers(app: Hono, caller: string, spaceUri: string): Promise<string[]> { - const res = await call( - app, - "GET", - `/xrpc/test.comm.community.space.listMembers?spaceUri=${encodeURIComponent(spaceUri)}&flatten=true`, - caller - ); - expect(res.status).toBe(200); - return ((await res.json()) as any).members.map((m: any) => m.did); -} - -describe("community delegation — stage 2", () => { - let app: Hono; - - beforeAll(async () => { - app = await makeApp(); - await adopt(app, ALICE); - }); - - it("subject_space_uri delegates membership from one space to another", async () => { - const mods = await createSpace(app, ALICE, "mods"); - const chat = await createSpace(app, ALICE, "mod-chat"); - - // Bob is a member of mods. - expect((await grant(app, ALICE, mods, { did: BOB }, "member")).status).toBe(200); - // mods is a member of mod-chat. - expect((await grant(app, ALICE, chat, { spaceUri: mods }, "member")).status).toBe(200); - - // Bob is transitively a member of mod-chat. - expect(await whoamiLevel(app, BOB, chat)).toBe("member"); - const flat = await flatMembers(app, ALICE, chat); - expect(flat).toContain(BOB); - expect(flat).toContain(ALICE); // owner - }); - - it("access is capped at the path minimum (delegation can only reduce)", async () => { - const mods = await createSpace(app, ALICE, "mods2"); - const target = await createSpace(app, ALICE, "target2"); - - // Bob is an owner of mods2. - expect((await grant(app, ALICE, mods, { did: BOB }, "owner")).status).toBe(200); - // mods2 is a `member` of target2 (capped path). - expect((await grant(app, ALICE, target, { spaceUri: mods }, "member")).status).toBe(200); - - // Bob's effective level in target2 is `member`, not `owner`. - expect(await whoamiLevel(app, BOB, target)).toBe("member"); - }); - - it("cycle detection rejects A → B → A", async () => { - const a = await createSpace(app, ALICE, "cycle-a"); - const b = await createSpace(app, ALICE, "cycle-b"); - - expect((await grant(app, ALICE, a, { spaceUri: b }, "member")).status).toBe(200); - // Now creating b → a would close the cycle. - const res = await grant(app, ALICE, b, { spaceUri: a }, "member"); - expect(res.status).toBe(400); - expect(((await res.json()) as any).reason).toBe("cycle-detected"); - }); - - it("rejects self-reference", async () => { - const s = await createSpace(app, ALICE, "self-ref"); - const res = await grant(app, ALICE, s, { spaceUri: s }, "member"); - expect(res.status).toBe(400); - expect(((await res.json()) as any).reason).toBe("self-reference"); - }); - - it("reverse-graph reconcile: adding to source propagates to delegated spaces", async () => { - const mods = await createSpace(app, ALICE, "mods3"); - const chat1 = await createSpace(app, ALICE, "chat-a"); - const chat2 = await createSpace(app, ALICE, "chat-b"); - - // Both chats delegate to mods3. - expect((await grant(app, ALICE, chat1, { spaceUri: mods }, "member")).status).toBe(200); - expect((await grant(app, ALICE, chat2, { spaceUri: mods }, "member")).status).toBe(200); - - // Add Charlie to mods3 — should propagate to both chats. - expect((await grant(app, ALICE, mods, { did: CHARLIE }, "member")).status).toBe(200); - - expect(await flatMembers(app, ALICE, chat1)).toContain(CHARLIE); - expect(await flatMembers(app, ALICE, chat2)).toContain(CHARLIE); - }); - - it("reverse-graph reconcile: removing from source propagates to delegated spaces", async () => { - const mods = await createSpace(app, ALICE, "mods4"); - const chat = await createSpace(app, ALICE, "chat-c"); - - expect((await grant(app, ALICE, chat, { spaceUri: mods }, "member")).status).toBe(200); - expect((await grant(app, ALICE, mods, { did: DIANA }, "member")).status).toBe(200); - expect(await flatMembers(app, ALICE, chat)).toContain(DIANA); - - // Revoke from mods4 — should disappear from chat-c. - const res = await call(app, "POST", "/xrpc/test.comm.community.space.revoke", ALICE, { - spaceUri: mods, - subject: { did: DIANA }, - }); - expect(res.status).toBe(200); - - 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 - expect((await grant(app, ALICE, s, { did: BOB }, "member")).status).toBe(200); - - const res = await call(app, "POST", "/xrpc/test.comm.community.space.resync", BOB, { - spaceUri: s, - }); - 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 = `ats://${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, { - spaceUri: s, - }); - expect(res.status).toBe(200); - }); -}); diff --git a/packages/contrail-community/tests/community-e2e.test.ts b/packages/contrail-community/tests/community-e2e.test.ts deleted file mode 100644 index 95d6048..0000000 --- a/packages/contrail-community/tests/community-e2e.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; -import { initSchema } from "@atmo-dev/contrail"; -import { createApp } from "@atmo-dev/contrail"; -import { resolveConfig } from "@atmo-dev/contrail"; -import type { ContrailConfig } from "@atmo-dev/contrail"; -import { createCommunityIntegration } from "../src/integration"; - -const ALICE = "did:plc:alice"; -const BOB = "did:plc:bob"; -const CHARLIE = "did:plc:charlie"; -const COMMUNITY_DID = "did:plc:acmecommunity"; -const PDS_ENDPOINT = "https://pds.example"; - -// 32-byte test master key (deterministic; not a real secret). -const MASTER_KEY = new Uint8Array(32).fill(7); - -const CONFIG: ContrailConfig = { - namespace: "test.comm", - collections: { - message: { collection: "app.event.message" }, - }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: "did:web:test.example#svc", - }, - recordHost: {}, - }, - community: { - masterKey: MASTER_KEY, - // Fake network: hand-rolled below. - fetch: mockFetch, - resolver: mockResolver(), - }, -}; - -function mockResolver(): any { - return { - resolve: async (did: string) => { - if (did !== COMMUNITY_DID) throw new Error("unknown did"); - return { - id: did, - service: [ - { - id: "#atproto_pds", - type: "AtprotoPersonalDataServer", - serviceEndpoint: PDS_ENDPOINT, - }, - ], - }; - }, - }; -} - -async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - // Handle resolution paths are handled by the mock resolver, not fetch. - if (url.endsWith("/xrpc/com.atproto.server.createSession") && init?.method === "POST") { - const body = JSON.parse((init.body as string) ?? "{}"); - if (body.password === "app-password-ok") { - return new Response( - JSON.stringify({ - accessJwt: "a.b.c", - refreshJwt: "r.r.r", - did: COMMUNITY_DID, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - return new Response(JSON.stringify({ error: "AuthFactorTokenRequired" }), { status: 401 }); - } - return new Response("not found", { status: 404 }); -} - -function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { - issuer: did, - audience: CONFIG.spaces!.authority!.serviceDid, - lxm: undefined, - }); - await next(); - }; -} - -async function makeApp(): Promise<Hono> { - const db = createSqliteDatabase(":memory:"); - const resolved = resolveConfig(CONFIG); - const community = createCommunityIntegration({ db, config: resolved }); - await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); - return createApp(db, resolved, { - spaces: { authMiddleware: fakeAuth() }, - community, - }); -} - -function call( - app: Hono, - method: string, - path: string, - did: string | null, - body?: any -): Promise<Response> { - const headers: Record<string, string> = {}; - if (did) headers["X-Test-Did"] = did; - if (body !== undefined) headers["Content-Type"] = "application/json"; - return app.fetch( - new Request(`http://localhost${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) - ); -} - -async function adopt(app: Hono, caller: string) { - const res = await call(app, "POST", "/xrpc/test.comm.community.adopt", caller, { - identifier: COMMUNITY_DID, - appPassword: "app-password-ok", - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { communityDid: string }; - expect(body.communityDid).toBe(COMMUNITY_DID); - return body.communityDid; -} - -describe("community e2e — stage 1", () => { - let app: Hono; - - beforeAll(async () => { - app = await makeApp(); - }); - - it("adopts a community and creates reserved spaces with creator as owner", async () => { - const did = await adopt(app, ALICE); - - const adminUri = `ats://${did}/tools.atmo.event.space/$admin`; - const publishersUri = `ats://${did}/tools.atmo.event.space/$publishers`; - - // whoami in both reserved spaces → owner - for (const uri of [adminUri, publishersUri]) { - const r = await call(app, "GET", `/xrpc/test.comm.spaceExt.whoami?spaceUri=${encodeURIComponent(uri)}`, ALICE); - expect(r.status).toBe(200); - expect(((await r.json()) as any).accessLevel).toBe("owner"); - } - - // Bob isn't in either - const r = await call(app, "GET", `/xrpc/test.comm.spaceExt.whoami?spaceUri=${encodeURIComponent(adminUri)}`, BOB); - expect(r.status).toBe(200); - expect(((await r.json()) as any).accessLevel).toBe(null); - }); - - it("rejects bad credentials", async () => { - const app2 = await makeApp(); - const res = await call(app2, "POST", "/xrpc/test.comm.community.adopt", ALICE, { - identifier: COMMUNITY_DID, - appPassword: "wrong", - }); - expect(res.status).toBe(401); - }); - - it("rejects duplicate adoption", async () => { - const res = await call(app, "POST", "/xrpc/test.comm.community.adopt", BOB, { - identifier: COMMUNITY_DID, - appPassword: "app-password-ok", - }); - expect(res.status).toBe(409); - }); - - it("creates a non-reserved space via community.space.create", async () => { - const res = await call(app, "POST", "/xrpc/test.comm.community.space.create", ALICE, { - communityDid: COMMUNITY_DID, - key: "general", - }); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - expect(body.space.uri).toBe(`ats://${COMMUNITY_DID}/tools.atmo.event.space/general`); - expect(body.space.ownerDid).toBe(COMMUNITY_DID); - }); - - it("rejects reserved keys in space.create", async () => { - const res = await call(app, "POST", "/xrpc/test.comm.community.space.create", ALICE, { - communityDid: COMMUNITY_DID, - key: "$admin", - }); - expect(res.status).toBe(400); - expect(((await res.json()) as any).reason).toBe("reserved-key"); - }); - - it("non-admin cannot create a space", async () => { - const res = await call(app, "POST", "/xrpc/test.comm.community.space.create", BOB, { - communityDid: COMMUNITY_DID, - key: "random", - }); - expect(res.status).toBe(403); - }); - - it("owner grants Bob member access to #general; reconciler populates spaces_members", async () => { - const spaceUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`; - const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", ALICE, { - spaceUri, - subject: { did: BOB }, - accessLevel: "member", - }); - expect(res.status).toBe(200); - - // Bob can now see the members list - const list = await call( - app, - "GET", - `/xrpc/test.comm.community.space.listMembers?spaceUri=${encodeURIComponent(spaceUri)}`, - BOB - ); - expect(list.status).toBe(200); - const body = (await list.json()) as any; - expect(body.rows.map((r: any) => r.subject.did)).toContain(BOB); - }); - - it("manager cannot grant higher than own level", async () => { - const spaceUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`; - // Promote Bob to manager - await call(app, "POST", "/xrpc/test.comm.community.space.grant", ALICE, { - spaceUri, - subject: { did: BOB }, - accessLevel: "manager", - }); - // Bob tries to grant Charlie owner — should fail - const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", BOB, { - spaceUri, - subject: { did: CHARLIE }, - accessLevel: "owner", - }); - expect(res.status).toBe(403); - 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 = `ats://${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 = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`; - const res = await call(app, "POST", "/xrpc/test.comm.community.space.revoke", ALICE, { - spaceUri, - subject: { did: BOB }, - }); - expect(res.status).toBe(200); - - const level = await call( - app, - "GET", - `/xrpc/test.comm.spaceExt.whoami?spaceUri=${encodeURIComponent(spaceUri)}`, - BOB - ); - expect(((await level.json()) as any).accessLevel).toBe(null); - }); - - it("cannot delete a reserved space", async () => { - const adminUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; - const res = await call(app, "POST", "/xrpc/test.comm.community.space.delete", ALICE, { - spaceUri: adminUri, - }); - expect(res.status).toBe(403); - expect(((await res.json()) as any).reason).toBe("reserved-space-cannot-be-deleted"); - }); - - it("lists communities for an actor", async () => { - const res = await call(app, "GET", `/xrpc/test.comm.community.list`, ALICE); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - expect(body.communities.map((c: any) => c.did)).toContain(COMMUNITY_DID); - }); -}); diff --git a/packages/contrail-community/tests/community-mint.test.ts b/packages/contrail-community/tests/community-mint.test.ts deleted file mode 100644 index 02ea73b..0000000 --- a/packages/contrail-community/tests/community-mint.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; -import { initSchema } from "@atmo-dev/contrail"; -import { createApp } from "@atmo-dev/contrail"; -import { resolveConfig } from "@atmo-dev/contrail"; -import type { ContrailConfig } from "@atmo-dev/contrail"; -import { createCommunityIntegration } from "../src/integration"; -import { - buildGenesisOp, - computeDidPlc, - encodeDagCbor, - generateKeyPair, - jwkToDidKey, - signGenesisOp, -} from "../src/plc"; - -const ALICE = "did:plc:alice"; -const BOB = "did:plc:bob"; - -const MASTER_KEY = new Uint8Array(32).fill(99); - -/** Captures requests that would have gone to plc.directory. */ -const plcCalls: Array<{ url: string; method: string; body: any }> = []; - -const CONFIG: ContrailConfig = { - namespace: "test.comm", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: "did:web:test.example#svc", - }, - recordHost: {}, - }, - community: { - masterKey: MASTER_KEY, - plcDirectory: "https://plc.test", - fetch: mockFetch, - }, -}; - -async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const method = init?.method ?? "GET"; - const body = init?.body ? JSON.parse(init.body as string) : {}; - if (url.startsWith("https://plc.test/")) { - plcCalls.push({ url, method, body }); - return new Response("{}", { status: 200 }); - } - return new Response("not found", { status: 404 }); -} - -function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { issuer: did, audience: CONFIG.spaces!.authority!.serviceDid, lxm: undefined }); - await next(); - }; -} - -async function makeApp(): Promise<Hono> { - const db = createSqliteDatabase(":memory:"); - const resolved = resolveConfig(CONFIG); - const community = createCommunityIntegration({ db, config: resolved }); - await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); - return createApp(db, resolved, { - spaces: { authMiddleware: fakeAuth() }, - community, - }); -} - -function call( - app: Hono, - method: string, - path: string, - did: string, - body?: any -): Promise<Response> { - const headers: Record<string, string> = { "X-Test-Did": did }; - if (body !== undefined) headers["Content-Type"] = "application/json"; - return app.fetch( - new Request(`http://localhost${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) - ); -} - -describe("plc op encoding (unit)", () => { - it("encodes canonical CBOR with sorted map keys", () => { - // Maps must be sorted by key length first, then lexicographically. - const out = encodeDagCbor({ b: 1, a: 2, ab: 3 }); - // Expected order: a, b, ab (short keys first). - // Header byte: major 5 (map), count 3 → 0xa3. - expect(out[0]).toBe(0xa3); - // First key should be "a" (0x61 text-string head len 1, then 0x61). - expect(out[1]).toBe(0x61); - expect(out[2]).toBe(0x61); - }); - - it("generates a P-256 keypair and a did:key", async () => { - const pair = await generateKeyPair(); - expect(pair.publicDidKey).toMatch(/^did:key:z/); - expect(pair.privateJwk.kty).toBe("EC"); - expect(pair.privateJwk.crv).toBe("P-256"); - }); - - it("computeDidPlc returns a stable did:plc", async () => { - const signing = await generateKeyPair(); - const rotation = await generateKeyPair(); - const unsigned = buildGenesisOp({ - rotationKeys: [rotation.publicDidKey], - verificationMethodAtproto: signing.publicDidKey, - }); - const signed = await signGenesisOp(unsigned, rotation.privateJwk); - const did = await computeDidPlc(signed); - expect(did).toMatch(/^did:plc:[a-z2-7]{24}$/); - }); - - it("jwkToDidKey: public key roundtrip shape", async () => { - const pair = await generateKeyPair(); - const k = jwkToDidKey(pair.privateJwk); // private JWK carries the pub coords - expect(k).toBe(pair.publicDidKey); - }); -}); - -describe("community.mint — stage 4", () => { - let app: Hono; - - beforeAll(async () => { - app = await makeApp(); - }); - - it("mints a community, returns recovery key, submits to PLC, bootstraps reserved spaces", async () => { - const before = plcCalls.length; - const res = await call(app, "POST", "/xrpc/test.comm.community.mint", ALICE, {}); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - - expect(body.communityDid).toMatch(/^did:plc:[a-z2-7]{24}$/); - // Recovery key is a private JWK returned once. - expect(body.recoveryKey.kty).toBe("EC"); - expect(body.recoveryKey.crv).toBe("P-256"); - expect(body.recoveryKey.d).toBeTruthy(); // private scalar - - // PLC submission happened. - const ourCalls = plcCalls.slice(before); - expect(ourCalls).toHaveLength(1); - expect(ourCalls[0]!.method).toBe("POST"); - expect(ourCalls[0]!.url).toBe(`https://plc.test/${body.communityDid}`); - expect(ourCalls[0]!.body.type).toBe("plc_operation"); - expect(ourCalls[0]!.body.sig).toBeTruthy(); - expect(ourCalls[0]!.body.rotationKeys).toHaveLength(2); - - // Reserved spaces exist with the caller as owner. - const adminUri = `ats://${body.communityDid}/tools.atmo.event.space/$admin`; - const whoami = await call(app, "GET", `/xrpc/test.comm.spaceExt.whoami?spaceUri=${encodeURIComponent(adminUri)}`, ALICE); - expect(((await whoami.json()) as any).accessLevel).toBe("owner"); - }); - - it("minted community rejects publishing (no PDS)", async () => { - const res = await call(app, "POST", "/xrpc/test.comm.community.mint", ALICE, {}); - const { communityDid } = (await res.json()) as any; - - const pub = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { - communityDid, - collection: "app.event.message", - record: { text: "nope" }, - }); - expect(pub.status).toBe(400); - expect(((await pub.json()) as any).reason).toBe("publishing-not-supported-for-minted-communities"); - }); - - it("multiple mints produce distinct DIDs", async () => { - const r1 = await call(app, "POST", "/xrpc/test.comm.community.mint", BOB, {}); - const r2 = await call(app, "POST", "/xrpc/test.comm.community.mint", BOB, {}); - const d1 = ((await r1.json()) as any).communityDid; - const d2 = ((await r2.json()) as any).communityDid; - expect(d1).not.toBe(d2); - }); -}); diff --git a/packages/contrail-community/tests/community-provision-attempts.test.ts b/packages/contrail-community/tests/community-provision-attempts.test.ts deleted file mode 100644 index 486888b..0000000 --- a/packages/contrail-community/tests/community-provision-attempts.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import type { Database } from "@atmo-dev/contrail-base"; -import { initCommunitySchema } from "../src/schema"; -import { CommunityAdapter } from "../src/adapter"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; - -describe("provision_attempts adapter", () => { - let db: Database; - let adapter: CommunityAdapter; - - beforeEach(async () => { - db = createSqliteDatabase(":memory:"); - await initCommunitySchema(db); - adapter = new CommunityAdapter(db); - }); - - it("creates and reads a provision attempt", async () => { - const before = Date.now(); - await adapter.createProvisionAttempt({ - attemptId: "a1", - did: "did:plc:abc", - pdsEndpoint: "https://pds.test", - handle: "abc.pds.test", - email: "abc@x.test", - inviteCode: "code-1", - encryptedSigningKey: "sk-enc", - encryptedRotationKey: "rk-enc", - }); - - const row = await adapter.getProvisionAttempt("a1"); - expect(row).not.toBeNull(); - expect(row?.attemptId).toBe("a1"); - expect(row?.did).toBe("did:plc:abc"); - expect(row?.status).toBe("keys_generated"); - expect(row?.pdsEndpoint).toBe("https://pds.test"); - expect(row?.handle).toBe("abc.pds.test"); - expect(row?.email).toBe("abc@x.test"); - expect(row?.inviteCode).toBe("code-1"); - expect(row?.encryptedSigningKey).toBe("sk-enc"); - expect(row?.encryptedRotationKey).toBe("rk-enc"); - expect(row?.encryptedPassword).toBeNull(); - expect(row?.lastError).toBeNull(); - expect(row?.genesisSubmittedAt).toBeNull(); - expect(row?.accountCreatedAt).toBeNull(); - expect(row?.didDocUpdatedAt).toBeNull(); - expect(row?.activatedAt).toBeNull(); - expect(row?.createdAt).toBeGreaterThanOrEqual(before); - expect(row?.updatedAt).toBeGreaterThanOrEqual(before); - }); - - it("getProvisionAttempt returns null for unknown attempt", async () => { - const row = await adapter.getProvisionAttempt("no-such-attempt"); - expect(row).toBeNull(); - }); - - it("treats missing inviteCode as null", async () => { - await adapter.createProvisionAttempt({ - attemptId: "a-no-invite", - did: "did:plc:noinv", - pdsEndpoint: "https://pds.test", - handle: "noinv.pds.test", - email: "noinv@x.test", - encryptedSigningKey: "sk", - encryptedRotationKey: "rk", - }); - const row = await adapter.getProvisionAttempt("a-no-invite"); - expect(row?.inviteCode).toBeNull(); - }); - - it("advances status, stamps the matching timestamp, and persists last_error", async () => { - await adapter.createProvisionAttempt({ - attemptId: "a1", - did: "did:plc:abc", - pdsEndpoint: "https://pds.test", - handle: "abc.pds.test", - email: "abc@x.test", - encryptedSigningKey: "sk-enc", - encryptedRotationKey: "rk-enc", - }); - - const initial = await adapter.getProvisionAttempt("a1"); - const initialUpdated = initial!.updatedAt; - - // Small wait so updated_at can advance on millisecond clocks. - await new Promise((r) => setTimeout(r, 2)); - - await adapter.updateProvisionStatus("a1", "genesis_submitted"); - let row = await adapter.getProvisionAttempt("a1"); - expect(row?.status).toBe("genesis_submitted"); - expect(row?.genesisSubmittedAt).toBeTruthy(); - expect(row?.accountCreatedAt).toBeNull(); - expect(row?.didDocUpdatedAt).toBeNull(); - expect(row?.activatedAt).toBeNull(); - expect(row?.updatedAt).toBeGreaterThanOrEqual(initialUpdated); - - await adapter.updateProvisionStatus("a1", "account_created"); - row = await adapter.getProvisionAttempt("a1"); - expect(row?.status).toBe("account_created"); - expect(row?.accountCreatedAt).toBeTruthy(); - // Earlier stamp must be preserved on subsequent updates. - expect(row?.genesisSubmittedAt).toBeTruthy(); - - await adapter.updateProvisionStatus("a1", "did_doc_updated", { lastError: "transient PLC error" }); - row = await adapter.getProvisionAttempt("a1"); - expect(row?.status).toBe("did_doc_updated"); - expect(row?.lastError).toBe("transient PLC error"); - // Earlier stamps still preserved. - expect(row?.genesisSubmittedAt).toBeTruthy(); - expect(row?.accountCreatedAt).toBeTruthy(); - }); - - it("persists encryptedPassword via updateProvisionStatus", async () => { - await adapter.createProvisionAttempt({ - attemptId: "a-pwd", - did: "did:plc:pwd", - pdsEndpoint: "https://pds.test", - handle: "pwd.pds.test", - email: "pwd@x.test", - encryptedSigningKey: "sk", - encryptedRotationKey: "rk", - }); - expect((await adapter.getProvisionAttempt("a-pwd"))?.encryptedPassword).toBeNull(); - - await adapter.updateProvisionStatus("a-pwd", "account_created", { - encryptedPassword: "pwd-enc", - }); - const row = await adapter.getProvisionAttempt("a-pwd"); - expect(row?.encryptedPassword).toBe("pwd-enc"); - expect(row?.accountCreatedAt).toBeTruthy(); - }); - - describe("listStuckAttempts age threshold", () => { - async function seedStuck(attemptId: string, did: string): Promise<void> { - await adapter.createProvisionAttempt({ - attemptId, - did, - pdsEndpoint: "https://pds.test", - handle: `${attemptId}.pds.test`, - email: `${attemptId}@x.test`, - encryptedSigningKey: "sk", - encryptedRotationKey: "rk", - }); - } - /** Backdate a row's updated_at so it looks old to the age filter. */ - async function ageRow(attemptId: string, ageMs: number): Promise<void> { - await db - .prepare(`UPDATE provision_attempts SET updated_at = ? WHERE attempt_id = ?`) - .bind(Date.now() - ageMs, attemptId) - .run(); - } - - it("excludes a freshly-updated (in-flight) non-activated row", async () => { - await seedStuck("fresh", "did:plc:fresh"); - // updated_at is ~now; a 30-minute floor must not select it. - const rows = await adapter.listStuckAttempts(30 * 60 * 1000); - expect(rows.map((r) => r.attemptId)).not.toContain("fresh"); - }); - - it("includes a row older than the threshold", async () => { - await seedStuck("old", "did:plc:old"); - await ageRow("old", 2 * 60 * 60 * 1000); // 2 hours ago - const rows = await adapter.listStuckAttempts(30 * 60 * 1000); - expect(rows.map((r) => r.attemptId)).toContain("old"); - }); - - it("with a zero threshold returns every non-activated row", async () => { - await seedStuck("a", "did:plc:a"); - await seedStuck("b", "did:plc:b"); - const rows = await adapter.listStuckAttempts(0); - expect(rows.map((r) => r.attemptId).sort()).toEqual(["a", "b"]); - }); - }); - - describe("archiveStuckAttempt idempotency", () => { - it("retry after a partial failure (archive row already present, live row stranded) does not throw and finishes the move", async () => { - await adapter.createProvisionAttempt({ - attemptId: "partial", - did: "did:plc:partial", - pdsEndpoint: "https://pds.test", - handle: "partial.pds.test", - email: "partial@x.test", - encryptedSigningKey: "sk", - encryptedRotationKey: "rk", - }); - // Simulate the first reap: its archive INSERT landed, but the live-row - // DELETE failed, leaving the row in BOTH tables. - await db - .prepare( - `INSERT INTO provision_attempts_archive - (attempt_id, did, pds_endpoint, handle, email, invite_code, - last_status, last_error, archived_at, tombstone_op_cid, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .bind( - "partial", - "did:plc:partial", - "https://pds.test", - "partial.pds.test", - "partial@x.test", - null, - "genesis_submitted", - null, - Date.now(), - "cid-first", - null - ) - .run(); - - // The retry must not hit a PRIMARY KEY conflict on the archive INSERT. - await expect( - adapter.archiveStuckAttempt("partial", { tombstoneOpCid: "cid-retry" }) - ).resolves.toBeUndefined(); - - // Live row is now gone; the archive row remains (the original landed copy). - expect(await adapter.getProvisionAttempt("partial")).toBeNull(); - const archive = await db - .prepare("SELECT * FROM provision_attempts_archive WHERE attempt_id = ?") - .bind("partial") - .first<Record<string, any>>(); - expect(archive).not.toBeNull(); - expect(archive!.tombstone_op_cid).toBe("cid-first"); - }); - }); - - it("enforces did uniqueness across attempts", async () => { - await adapter.createProvisionAttempt({ - attemptId: "first", - did: "did:plc:dupe", - pdsEndpoint: "https://pds.test", - handle: "first.pds.test", - email: "first@x.test", - encryptedSigningKey: "sk", - encryptedRotationKey: "rk", - }); - await expect( - adapter.createProvisionAttempt({ - attemptId: "second", - did: "did:plc:dupe", - pdsEndpoint: "https://pds.test", - handle: "second.pds.test", - email: "second@x.test", - encryptedSigningKey: "sk", - encryptedRotationKey: "rk", - }) - ).rejects.toThrow(); - }); -}); diff --git a/packages/contrail-community/tests/community-provision-pds-allowlist.test.ts b/packages/contrail-community/tests/community-provision-pds-allowlist.test.ts deleted file mode 100644 index 8cf2abf..0000000 --- a/packages/contrail-community/tests/community-provision-pds-allowlist.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; -import { initSchema } from "@atmo-dev/contrail"; -import { createApp } from "@atmo-dev/contrail"; -import { resolveConfig } from "@atmo-dev/contrail"; -import type { ContrailConfig } from "@atmo-dev/contrail"; -import { createCommunityIntegration } from "../src/integration"; -import { normalizePdsEndpoint } from "../src/pds"; - -const ALICE = "did:plc:alice"; -const MASTER_KEY = new Uint8Array(32).fill(99); -const ALLOWED_PDS = "https://allowed.pds.test"; -const ATTACKER_PDS = "https://attacker.pds.test"; -const PLC_DIRECTORY = "https://plc.test"; - -const FAKE_ACCESS_JWT = "head.body.sig"; - -async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const method = init?.method ?? "GET"; - const body = init?.body ? JSON.parse(init.body as string) : {}; - - if (url === `${ALLOWED_PDS}/xrpc/com.atproto.server.describeServer`) { - return new Response(JSON.stringify({ did: "did:web:allowed.pds.test" }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - if (url.startsWith(`${PLC_DIRECTORY}/`) && !url.endsWith("/log/last") && method === "POST") { - return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); - } - if (url.endsWith("/log/last") && method === "GET") { - return new Response(JSON.stringify({ cid: "bafyreitestcid" }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - if (url === `${ALLOWED_PDS}/xrpc/com.atproto.server.createAccount` && method === "POST") { - return new Response( - JSON.stringify({ - did: body.did, - handle: body.handle, - accessJwt: FAKE_ACCESS_JWT, - refreshJwt: "RT", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - if (url === `${ALLOWED_PDS}/xrpc/com.atproto.identity.getRecommendedDidCredentials`) { - return new Response( - JSON.stringify({ - rotationKeys: [], - verificationMethods: { atproto: "did:key:zPdsSig" }, - alsoKnownAs: ["at://newcomm.allowed.pds.test"], - services: { - atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: ALLOWED_PDS }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - if (url === `${ALLOWED_PDS}/xrpc/com.atproto.server.activateAccount` && method === "POST") { - return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); - } - if (url === `${ALLOWED_PDS}/xrpc/com.atproto.server.createAppPassword` && method === "POST") { - return new Response( - JSON.stringify({ name: body.name, password: "minted-app-pw" }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - return new Response(`unmocked: ${method} ${url}`, { status: 404 }); -} - -function buildConfig( - allowedProvisionPdsEndpoints: string[] | undefined, - extra: { allowAnyProvisionPdsEndpoint?: boolean } = {} -): ContrailConfig { - return { - namespace: "test.comm", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: "did:web:test.example#svc", - }, - recordHost: {}, - }, - community: { - masterKey: MASTER_KEY, - plcDirectory: PLC_DIRECTORY, - fetch: mockFetch, - allowedProvisionPdsEndpoints, - allowProvisioning: true, - allowAnyProvisionPdsEndpoint: extra.allowAnyProvisionPdsEndpoint, - }, - }; -} - -function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { issuer: did, audience: "did:web:test.example#svc", lxm: undefined }); - await next(); - }; -} - -async function makeApp( - allowedProvisionPdsEndpoints: string[] | undefined, - extra: { allowAnyProvisionPdsEndpoint?: boolean } = {} -): Promise<Hono> { - const db = createSqliteDatabase(":memory:"); - const cfg = buildConfig(allowedProvisionPdsEndpoints, extra); - const resolved = resolveConfig(cfg); - const community = createCommunityIntegration({ db, config: resolved }); - await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); - return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() }, community }); -} - -async function call(app: Hono, body: any): Promise<Response> { - return await app.fetch( - new Request(`http://localhost/xrpc/test.comm.community.provision`, { - method: "POST", - headers: { "X-Test-Did": ALICE, "Content-Type": "application/json" }, - body: JSON.stringify(body), - }) - ); -} - -describe("provision pdsEndpoint allowlist (M3)", () => { - it("rejects pdsEndpoint not in allowedProvisionPdsEndpoints", async () => { - const app = await makeApp([ALLOWED_PDS]); - const res = await call(app, { - handle: "newcomm.attacker.pds.test", - email: "x@x.test", - password: "secret", - pdsEndpoint: ATTACKER_PDS, - rotationKey: "did:key:zStubCallerRotationKey", - }); - expect(res.status).toBe(400); - const j = (await res.json()) as { error: string; message: string }; - expect(j.error).toBe("InvalidRequest"); - expect(j.message).toMatch(/pdsEndpoint/i); - }); - - it("accepts pdsEndpoint that is in allowedProvisionPdsEndpoints", async () => { - const app = await makeApp([ALLOWED_PDS]); - const res = await call(app, { - handle: "newcomm.allowed.pds.test", - email: "x@x.test", - password: "secret", - inviteCode: "code-x", - pdsEndpoint: ALLOWED_PDS, - rotationKey: "did:key:zStubCallerRotationKey", - }); - expect(res.status).toBe(200); - }); - - it("fails closed: provisioning enabled + undefined allowlist → rejected", async () => { - const app = await makeApp(undefined); - const res = await call(app, { - handle: "newcomm.allowed.pds.test", - email: "x@x.test", - password: "secret", - inviteCode: "code-x", - pdsEndpoint: ALLOWED_PDS, - rotationKey: "did:key:zStubCallerRotationKey", - }); - expect(res.status).toBe(403); - const j = (await res.json()) as { error: string; message: string }; - expect(j.error).toBe("ProvisioningMisconfigured"); - expect(j.message).toMatch( - /allowlist|allowedProvisionPdsEndpoints|allowAnyProvisionPdsEndpoint/i - ); - }); - - it("fails closed: provisioning enabled + empty allowlist → rejected", async () => { - const app = await makeApp([]); - const res = await call(app, { - handle: "newcomm.allowed.pds.test", - email: "x@x.test", - password: "secret", - inviteCode: "code-x", - pdsEndpoint: ALLOWED_PDS, - rotationKey: "did:key:zStubCallerRotationKey", - }); - expect(res.status).toBe(403); - const j = (await res.json()) as { error: string; message: string }; - expect(j.error).toBe("ProvisioningMisconfigured"); - }); - - it("allowAnyProvisionPdsEndpoint=true is a loud opt-in: empty allowlist accepts any pdsEndpoint", async () => { - const app = await makeApp([], { allowAnyProvisionPdsEndpoint: true }); - const res = await call(app, { - handle: "newcomm.allowed.pds.test", - email: "x@x.test", - password: "secret", - inviteCode: "code-x", - pdsEndpoint: ALLOWED_PDS, - rotationKey: "did:key:zStubCallerRotationKey", - }); - expect(res.status).toBe(200); - }); - - it("matches when caller adds a trailing slash to a slash-less allowlist entry", async () => { - const app = await makeApp([ALLOWED_PDS]); - const res = await call(app, { - handle: "newcomm.allowed.pds.test", - email: "x@x.test", - password: "secret", - inviteCode: "code-x", - pdsEndpoint: `${ALLOWED_PDS}/`, - rotationKey: "did:key:zStubCallerRotationKey", - }); - expect(res.status).toBe(200); - }); - - it("matches when caller uppercases the scheme on an allowlisted endpoint", async () => { - const app = await makeApp([ALLOWED_PDS]); - const res = await call(app, { - handle: "newcomm.allowed.pds.test", - email: "x@x.test", - password: "secret", - inviteCode: "code-x", - pdsEndpoint: ALLOWED_PDS.replace(/^https/, "HTTPS"), - rotationKey: "did:key:zStubCallerRotationKey", - }); - expect(res.status).toBe(200); - }); - - it("matches when caller appends the default :443 port", async () => { - const app = await makeApp([ALLOWED_PDS]); - const res = await call(app, { - handle: "newcomm.allowed.pds.test", - email: "x@x.test", - password: "secret", - inviteCode: "code-x", - pdsEndpoint: ALLOWED_PDS.replace(/^https:\/\/([^/]+)/, "https://$1:443"), - rotationKey: "did:key:zStubCallerRotationKey", - }); - expect(res.status).toBe(200); - }); - - it("rejects pdsEndpoint that is not a parseable URL", async () => { - const app = await makeApp([ALLOWED_PDS]); - const res = await call(app, { - handle: "newcomm.allowed.pds.test", - email: "x@x.test", - password: "secret", - pdsEndpoint: "not a url", - rotationKey: "did:key:zStubCallerRotationKey", - }); - expect(res.status).toBe(400); - const j = (await res.json()) as { error: string; message: string }; - expect(j.error).toBe("InvalidRequest"); - expect(j.message).toMatch(/parseable|url/i); - }); -}); - -describe("normalizePdsEndpoint", () => { - it("collapses scheme case", () => { - expect(normalizePdsEndpoint("HTTPS://pds.example.com")).toBe( - "https://pds.example.com" - ); - }); - it("collapses host case", () => { - expect(normalizePdsEndpoint("https://PDS.Example.com")).toBe( - "https://pds.example.com" - ); - }); - it("strips trailing slash", () => { - expect(normalizePdsEndpoint("https://pds.example.com/")).toBe( - "https://pds.example.com" - ); - }); - it("strips default :443 for https", () => { - expect(normalizePdsEndpoint("https://pds.example.com:443")).toBe( - "https://pds.example.com" - ); - }); - it("strips default :80 for http", () => { - expect(normalizePdsEndpoint("http://pds.example.com:80")).toBe( - "http://pds.example.com" - ); - }); - it("preserves a non-default port", () => { - expect(normalizePdsEndpoint("https://pds.example.com:8443")).toBe( - "https://pds.example.com:8443" - ); - }); - it("converts an IDN hostname to its punycode form", () => { - expect(normalizePdsEndpoint("https://exämple.com")).toBe( - "https://xn--exmple-cua.com" - ); - }); - it("throws on an unparseable URL", () => { - expect(() => normalizePdsEndpoint("not a url")).toThrow(); - }); -}); diff --git a/packages/contrail-community/tests/community-provision-router.test.ts b/packages/contrail-community/tests/community-provision-router.test.ts deleted file mode 100644 index 535c70e..0000000 --- a/packages/contrail-community/tests/community-provision-router.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; -import { initSchema } from "@atmo-dev/contrail"; -import { createApp } from "@atmo-dev/contrail"; -import { createCommunityIntegration } from "../src/integration"; -import { resolveConfig } from "@atmo-dev/contrail"; -import type { ContrailConfig } from "@atmo-dev/contrail"; - -const ALICE = "did:plc:alice"; -const MASTER_KEY = new Uint8Array(32).fill(99); -const PDS_ENDPOINT = "https://pds.test"; -const PLC_DIRECTORY = "https://plc.test"; -/** The DID describeServer claims for this PDS. INTENTIONALLY DIFFERENT from - * CONFIG.spaces.serviceDid so tests can detect a regression where the route - * falls back to the spaces DID instead of resolving the PDS DID dynamically. */ -const PDS_DESCRIBE_DID = "did:web:pds.test"; - -/** Captures upstream calls so we can assert the right RPCs ran. */ -const upstreamCalls: Array<{ url: string; method: string; body: any; authorization?: string }> = []; - -// Placeholder JWT — the orchestrator passes accessJwt through to PDS calls -// untouched; nothing in the contrail flow parses its claims. -const FAKE_ACCESS_JWT = "head.body.sig"; - -async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const method = init?.method ?? "GET"; - const body = init?.body ? JSON.parse(init.body as string) : {}; - const headers = new Headers(init?.headers ?? {}); - upstreamCalls.push({ - url, - method, - body, - authorization: headers.get("authorization") ?? undefined, - }); - - // PDS describeServer — used by the route to resolve the target PDS's DID - // for service-auth JWT `aud`. - if (url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.describeServer`) { - return new Response(JSON.stringify({ did: PDS_DESCRIBE_DID }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - // PLC submit: POST {plcDirectory}/{did} (genesis + update share the URL). - if (url.startsWith(`${PLC_DIRECTORY}/`) && url.endsWith("/log/last") === false && method === "POST") { - return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); - } - // PLC log/last: not used in the happy path but be defensive. - if (url.endsWith("/log/last") && method === "GET") { - return new Response(JSON.stringify({ cid: "bafyreitestcid" }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - // PDS createAccount. - if (url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.createAccount` && method === "POST") { - return new Response( - JSON.stringify({ - did: body.did, - handle: body.handle, - accessJwt: FAKE_ACCESS_JWT, - refreshJwt: "RT", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - // PDS getRecommendedDidCredentials. - if ( - url === `${PDS_ENDPOINT}/xrpc/com.atproto.identity.getRecommendedDidCredentials` - ) { - return new Response( - JSON.stringify({ - rotationKeys: [], - verificationMethods: { atproto: "did:key:zPdsSig" }, - alsoKnownAs: ["at://newcomm.pds.test"], - services: { - atproto_pds: { - type: "AtprotoPersonalDataServer", - endpoint: PDS_ENDPOINT, - }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - // PDS activateAccount. - if (url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.activateAccount` && method === "POST") { - return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); - } - // PDS createAppPassword (post-activation, mints publishing credential). - if (url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.createAppPassword` && method === "POST") { - return new Response( - JSON.stringify({ name: body.name, password: "minted-app-pw" }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - return new Response(`unmocked: ${method} ${url}`, { status: 404 }); -} - -const CONFIG: ContrailConfig = { - namespace: "test.comm", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: "did:web:test.example#svc", - }, - recordHost: {}, - }, - community: { - masterKey: MASTER_KEY, - plcDirectory: PLC_DIRECTORY, - fetch: mockFetch, - allowProvisioning: true, - // Fail-closed requires a non-empty allowlist when provisioning is enabled. - allowedProvisionPdsEndpoints: [PDS_ENDPOINT], - }, -}; - -function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { issuer: did, audience: CONFIG.spaces!.authority!.serviceDid, lxm: undefined }); - await next(); - }; -} - -async function makeApp(): Promise<Hono> { - const db = createSqliteDatabase(":memory:"); - const resolved = resolveConfig(CONFIG); - const community = createCommunityIntegration({ db, config: resolved }); - await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); - return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() }, community }); -} - -async function call( - app: Hono, - method: string, - path: string, - did: string | null, - body?: any -): Promise<Response> { - const headers: Record<string, string> = {}; - if (did !== null) headers["X-Test-Did"] = did; - if (body !== undefined) headers["Content-Type"] = "application/json"; - return await app.fetch( - new Request(`http://localhost${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) - ); -} - -describe("POST /xrpc/{ns}.community.provision (allowProvisioning gate)", () => { - // Builds an app whose community config OMITS allowProvisioning. The route - // is expected to refuse with 403 ProvisioningDisabled — operators must - // explicitly opt in. The default-deny posture protects deployments where - // the auth middleware allows broader audiences than "operator only" from - // having any authenticated caller mint communities + burn invite codes. - async function makeAppWithoutAllowProvisioning(): Promise<Hono> { - const db = createSqliteDatabase(":memory:"); - const configWithoutFlag: ContrailConfig = { - ...CONFIG, - community: { ...CONFIG.community!, allowProvisioning: undefined } as any, - }; - const resolved = resolveConfig(configWithoutFlag); - const community = createCommunityIntegration({ db, config: resolved }); - await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); - return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() }, community }); - } - - it("returns 403 ProvisioningDisabled when allowProvisioning is not set", async () => { - const app = await makeAppWithoutAllowProvisioning(); - const res = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, { - handle: "newcomm.pds.test", - email: "newcomm@x.test", - password: "secret", - pdsEndpoint: PDS_ENDPOINT, - rotationKey: "did:key:zStubCallerRotationKey", - }); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("ProvisioningDisabled"); - }); -}); - -describe("POST /xrpc/{ns}.community.provision", () => { - let app: Hono; - - beforeAll(async () => { - app = await makeApp(); - }); - - it("requires auth", async () => { - const res = await call(app, "POST", "/xrpc/test.comm.community.provision", null, { - handle: "x.pds.test", - email: "x@x.test", - password: "p", - pdsEndpoint: PDS_ENDPOINT, - rotationKey: "did:key:zStubCallerRotationKey", - }); - expect(res.status).toBe(401); - }); - - it("rejects missing required fields", async () => { - const res = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, {}); - expect(res.status).toBe(400); - const j = (await res.json()) as { error: string }; - expect(j.error).toBe("InvalidRequest"); - }); - - it("provisions a community and returns did + status=activated", async () => { - const before = upstreamCalls.length; - const res = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, { - handle: "newcomm.pds.test", - email: "newcomm@x.test", - password: "secret", - inviteCode: "code-x", - pdsEndpoint: PDS_ENDPOINT, - rotationKey: "did:key:zStubCallerRotationKey", - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { communityDid: string; status: string }; - - expect(body.communityDid).toMatch(/^did:plc:[a-z2-7]{24}$/); - expect(body.status).toBe("activated"); - - // Verify the row was inserted into communities with mode='provision'. - // We round-trip via the GET list endpoint so we don't have to reach into - // the adapter — the route bootstrapped reserved spaces with the caller as - // owner, which makes the community reachable. - const listRes = await call(app, "GET", "/xrpc/test.comm.community.list", ALICE); - expect(listRes.status).toBe(200); - const list = (await listRes.json()) as { - communities: Array<{ did: string; mode: string }>; - }; - const row = list.communities.find((r) => r.did === body.communityDid); - expect(row).toBeDefined(); - expect(row!.mode).toBe("provision"); - - // Confirm we touched all five upstream RPCs: 2 PLC posts (genesis + update), - // createAccount, getRecommendedDidCredentials, activateAccount. - const ourCalls = upstreamCalls.slice(before); - const plcPosts = ourCalls.filter( - (c) => c.url.startsWith(`${PLC_DIRECTORY}/`) && c.method === "POST" - ); - expect(plcPosts.length).toBe(2); - expect( - ourCalls.some((c) => - c.url.endsWith("/xrpc/com.atproto.server.createAccount") - ) - ).toBe(true); - expect( - ourCalls.some((c) => - c.url.endsWith("/xrpc/com.atproto.identity.getRecommendedDidCredentials") - ) - ).toBe(true); - expect( - ourCalls.some((c) => - c.url.endsWith("/xrpc/com.atproto.server.activateAccount") - ) - ).toBe(true); - }); - - it("is idempotent on retry with the same attemptId after a fully-completed first call", async () => { - // The route already returns attemptId on every error response so a - // caller can retry. This guards the case where the first call - // succeeded end-to-end (orchestrator + graduation + reserved spaces) - // but the caller didn't receive the 200 (e.g. lost connection): a - // resent request with the same attemptId must still 200, return the - // same DID, and not double-create rows. - const attemptId = "retry-idem-1"; - const body = { - attemptId, - handle: "retryidem.pds.test", - email: "retryidem@x.test", - password: "secret", - pdsEndpoint: PDS_ENDPOINT, - rotationKey: "did:key:zStubCallerRotationKey", - }; - - const first = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, body); - expect(first.status).toBe(200); - const firstJson = (await first.json()) as { communityDid: string }; - - const second = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, body); - expect(second.status).toBe(200); - const secondJson = (await second.json()) as { communityDid: string }; - - expect(secondJson.communityDid).toBe(firstJson.communityDid); - }); - - it("uses the describeServer-returned DID as the service-auth JWT audience (not cfg.serviceDid)", async () => { - const before = upstreamCalls.length; - const res = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, { - handle: "audtest.pds.test", - email: "audtest@x.test", - password: "secret", - pdsEndpoint: PDS_ENDPOINT, - rotationKey: "did:key:zStubCallerRotationKey", - }); - expect(res.status).toBe(200); - - const ourCalls = upstreamCalls.slice(before); - - // 1. The route must call describeServer on the target PDS. - const describeCall = ourCalls.find( - (c) => c.url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.describeServer` - ); - expect(describeCall).toBeDefined(); - - // 2. The createAccount call's Authorization Bearer JWT must have - // `aud` === the describeServer-returned DID, NOT cfg.serviceDid. - const createAccountCall = ourCalls.find( - (c) => c.url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.createAccount` - ); - expect(createAccountCall).toBeDefined(); - expect(createAccountCall!.authorization).toMatch(/^Bearer /); - - const jwt = createAccountCall!.authorization!.replace(/^Bearer /, ""); - const payloadSeg = jwt.split(".")[1]!; - const padded = payloadSeg.replace(/-/g, "+").replace(/_/g, "/"); - const padding = "=".repeat((4 - (padded.length % 4)) % 4); - const claims = JSON.parse(atob(padded + padding)) as { aud?: string }; - - expect(claims.aud).toBe(PDS_DESCRIBE_DID); - // Sanity: it is NOT the spaces serviceDid (the previous hardcoded value). - expect(claims.aud).not.toBe(CONFIG.spaces!.authority!.serviceDid); - }); -}); diff --git a/packages/contrail-community/tests/community-publish-401-clears-session.test.ts b/packages/contrail-community/tests/community-publish-401-clears-session.test.ts deleted file mode 100644 index fb4f999..0000000 --- a/packages/contrail-community/tests/community-publish-401-clears-session.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** L6: A 401 from the publish path used to leave the bad session in the - * cache, so every subsequent publish hit the same 401 permanently. The fix - * is small: on 401, drop the cached session row. The next request goes cold - * through ensureSession, which mints a fresh session from the stored app - * password (or fails permanently if the app password itself was revoked). */ - -import { describe, it, expect, beforeEach } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; -import { initSchema } from "@atmo-dev/contrail"; -import { createApp } from "@atmo-dev/contrail"; -import { createCommunityIntegration } from "../src/integration"; -import { resolveConfig } from "@atmo-dev/contrail"; -import type { ContrailConfig } from "@atmo-dev/contrail"; -import { - CommunityAdapter, - CredentialCipher, - RESERVED_KEYS, -} from "../src"; -import { HostedAdapter } from "@atmo-dev/contrail"; -import { buildSpaceUri } from "@atmo-dev/contrail"; - -const ALICE = "did:plc:alice"; -const COMMUNITY_DID = "did:plc:l6comm"; -const HANDLE = "l6.pds.test"; -const PDS = "https://pds.example"; -const MASTER_KEY = new Uint8Array(32).fill(13); -const APP_PASSWORD = "correct-pw"; - -function fakeAuth(spaceServiceDid: string): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { - issuer: did, - audience: spaceServiceDid, - lxm: undefined, - }); - await next(); - }; -} - -async function build(): Promise<{ app: Hono; adapter: CommunityAdapter }> { - const fetchImpl: typeof fetch = (async (input: RequestInfo | URL) => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.href - : input.url; - if (url.endsWith("/xrpc/com.atproto.repo.createRecord")) { - return new Response(JSON.stringify({ error: "AuthRequired" }), { - status: 401, - }); - } - return new Response("not found", { status: 404 }); - }) as typeof fetch; - - const config: ContrailConfig = { - namespace: "test.comm", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: "did:web:test.example#svc", - }, - recordHost: {}, - }, - community: { masterKey: MASTER_KEY, fetch: fetchImpl }, - }; - const db = createSqliteDatabase(":memory:"); - const resolved = resolveConfig(config); - const communityIntegration = createCommunityIntegration({ db, config: resolved }); - await initSchema(db, resolved, { extraSchemas: [communityIntegration.applySchema] }); - const app = createApp(db, resolved, { - spaces: { authMiddleware: fakeAuth(config.spaces!.authority!.serviceDid) }, - community: communityIntegration, - }); - - const cipher = new CredentialCipher(MASTER_KEY); - const community = new CommunityAdapter(db); - const spaces = new HostedAdapter(db, resolved); - await community.createFromProvisioned({ - did: COMMUNITY_DID, - pdsEndpoint: PDS, - handle: HANDLE, - appPasswordEncrypted: await cipher.encrypt(APP_PASSWORD), - createdBy: ALICE, - }); - for (const key of RESERVED_KEYS) { - const uri = buildSpaceUri({ - ownerDid: COMMUNITY_DID, - type: config.spaces!.authority!.type, - key, - }); - await spaces.createSpace({ - uri, - ownerDid: COMMUNITY_DID, - type: config.spaces!.authority!.type, - key, - serviceDid: config.spaces!.authority!.serviceDid, - appPolicyRef: null, - appPolicy: null, - }); - await community.grant({ - spaceUri: uri, - subjectDid: ALICE, - accessLevel: "owner", - grantedBy: ALICE, - }); - await spaces.applyMembershipDiff(uri, [ALICE], [], ALICE); - } - return { app, adapter: community }; -} - -describe("publish path: 401 clears the session cache (L6)", () => { - let app: Hono; - let adapter: CommunityAdapter; - - beforeEach(async () => { - ({ app, adapter } = await build()); - }); - - it("removes the cached session row when createRecord returns 401", async () => { - // Seed a cached session that will be used (and rejected) by createRecord. - await adapter.upsertSession(COMMUNITY_DID, { - accessJwt: "stale-access", - refreshJwt: "stale-refresh", - accessExp: Math.floor(Date.now() / 1000) + 3600, - }); - expect(await adapter.getSession(COMMUNITY_DID)).not.toBeNull(); - - const res = await app.fetch( - new Request("http://localhost/xrpc/test.comm.community.putRecord", { - method: "POST", - headers: { "X-Test-Did": ALICE, "Content-Type": "application/json" }, - body: JSON.stringify({ - communityDid: COMMUNITY_DID, - collection: "app.event.message", - record: { text: "hello" }, - }), - }) - ); - expect(res.status).toBe(502); - - // The stale session must be gone, so the next attempt mints a fresh one. - expect(await adapter.getSession(COMMUNITY_DID)).toBeNull(); - }); -}); diff --git a/packages/contrail-community/tests/community-publishing.test.ts b/packages/contrail-community/tests/community-publishing.test.ts deleted file mode 100644 index ccbac88..0000000 --- a/packages/contrail-community/tests/community-publishing.test.ts +++ /dev/null @@ -1,632 +0,0 @@ -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; -import { initSchema } from "@atmo-dev/contrail"; -import { createApp } from "@atmo-dev/contrail"; -import { resolveConfig } from "@atmo-dev/contrail"; -import type { ContrailConfig, Database } from "@atmo-dev/contrail"; -import { createCommunityIntegration } from "../src/integration"; -import { CommunityAdapter, CredentialCipher, RESERVED_KEYS } from "../src"; -import { HostedAdapter } from "@atmo-dev/contrail"; -import { buildSpaceUri } from "@atmo-dev/contrail"; - -const ALICE = "did:plc:alice"; -const BOB = "did:plc:bob"; -const CHARLIE = "did:plc:charlie"; -const COMMUNITY_DID = "did:plc:pubcomm"; -const PROVISION_COMMUNITY_DID = "did:plc:provcomm"; -const PROVISION_HANDLE = "provcomm.pds.test"; -const PDS_ENDPOINT = "https://pds.example"; - -const MASTER_KEY = new Uint8Array(32).fill(42); - -/** Shared state for asserting PDS proxying happened. */ -const pdsCalls: Array<{ url: string; body: any }> = []; - -const CONFIG: ContrailConfig = { - namespace: "test.comm", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: "did:web:test.example#svc", - }, - recordHost: {}, - }, - community: { - masterKey: MASTER_KEY, - fetch: mockFetch, - resolver: mockResolver(), - }, -}; - -function mockResolver(): any { - return { - resolve: async (did: string) => { - if (did !== COMMUNITY_DID && did !== PROVISION_COMMUNITY_DID) { - throw new Error("unknown did"); - } - return { - id: did, - service: [ - { - id: "#atproto_pds", - type: "AtprotoPersonalDataServer", - serviceEndpoint: PDS_ENDPOINT, - }, - ], - }; - }, - }; -} - -async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const body = init?.body ? JSON.parse(init.body as string) : {}; - pdsCalls.push({ url, body }); - if (url.endsWith("/xrpc/com.atproto.server.createSession")) { - if (body.password === "correct-pw" || body.password === "new-correct-pw") { - // Echo back a DID that matches the identifier so adopt and provision flows - // both look right to any caller checking session.did. - const did = - body.identifier === PROVISION_HANDLE ? PROVISION_COMMUNITY_DID : COMMUNITY_DID; - return new Response( - JSON.stringify({ accessJwt: "a.b.c", refreshJwt: "r.r.r", did }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - return new Response(JSON.stringify({ error: "AuthFailed" }), { status: 401 }); - } - if (url.endsWith("/xrpc/com.atproto.repo.createRecord")) { - return new Response( - JSON.stringify({ - uri: `at://${body.repo}/${body.collection}/fakerkey`, - cid: "bafyfake", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - if (url.endsWith("/xrpc/com.atproto.repo.deleteRecord")) { - return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); - } - return new Response("not found", { status: 404 }); -} - -function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { issuer: did, audience: CONFIG.spaces!.authority!.serviceDid, lxm: undefined }); - await next(); - }; -} - -async function makeApp(): Promise<{ app: Hono; db: Database }> { - const db = createSqliteDatabase(":memory:"); - const resolved = resolveConfig(CONFIG); - const community = createCommunityIntegration({ db, config: resolved }); - await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); - const app = createApp(db, resolved, { - spaces: { authMiddleware: fakeAuth() }, - community, - }); - return { app, db }; -} - -/** Seed a provision-mode community + its reserved spaces with `creator` as - * owner. Mirrors what the adopt/provision routes do via `bootstrapReservedSpaces`, - * but skips the route so we don't have to mock PLC + 5 PDS RPCs. */ -async function seedProvisionCommunity( - db: Database, - creator: string, - password: string -): Promise<void> { - const cipher = new CredentialCipher(MASTER_KEY); - const encrypted = await cipher.encrypt(password); - const community = new CommunityAdapter(db); - const spaces = new HostedAdapter(db, resolveConfig(CONFIG)); - await community.createFromProvisioned({ - did: PROVISION_COMMUNITY_DID, - pdsEndpoint: PDS_ENDPOINT, - handle: PROVISION_HANDLE, - appPasswordEncrypted: encrypted, - createdBy: creator, - }); - for (const key of RESERVED_KEYS) { - const uri = buildSpaceUri({ - ownerDid: PROVISION_COMMUNITY_DID, - type: CONFIG.spaces!.authority!.type, - key, - }); - await spaces.createSpace({ - uri, - ownerDid: PROVISION_COMMUNITY_DID, - type: CONFIG.spaces!.authority!.type, - key, - serviceDid: CONFIG.spaces!.authority!.serviceDid, - appPolicyRef: null, - appPolicy: null, - }); - await community.grant({ - spaceUri: uri, - subjectDid: creator, - accessLevel: "owner", - grantedBy: creator, - }); - await spaces.applyMembershipDiff(uri, [creator], [], creator); - } -} - -async function call( - app: Hono, - method: string, - path: string, - did: string, - body?: any -): Promise<Response> { - const headers: Record<string, string> = { "X-Test-Did": did }; - if (body !== undefined) headers["Content-Type"] = "application/json"; - return await app.fetch( - new Request(`http://localhost${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) - ); -} - -async function adopt(app: Hono, caller: string, password: string) { - const res = await call(app, "POST", "/xrpc/test.comm.community.adopt", caller, { - identifier: COMMUNITY_DID, - appPassword: password, - }); - expect(res.status).toBe(200); -} - -async function grant(app: Hono, caller: string, spaceUri: string, subject: any, accessLevel: string) { - const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", caller, { - spaceUri, - subject, - accessLevel, - }); - expect(res.status).toBe(200); - return res; -} - -describe("community publishing + reauth — stage 3", () => { - let app: Hono; - const publishers = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$publishers`; - const admin = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; - - beforeAll(async () => { - ({ app } = await makeApp()); - await adopt(app, ALICE, "correct-pw"); - }); - - it("community.putRecord proxies to PDS for a $publishers member", async () => { - // Alice is already owner of $publishers from bootstrap; add Bob as plain member. - await grant(app, ALICE, publishers, { did: BOB }, "member"); - - const before = pdsCalls.length; - const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", BOB, { - communityDid: COMMUNITY_DID, - collection: "app.event.message", - record: { text: "hello from the community" }, - }); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - expect(body.uri).toMatch(/^at:\/\//); - - const newCalls = pdsCalls.slice(before); - expect(newCalls.some((c) => c.url.endsWith("/xrpc/com.atproto.server.createSession"))).toBe(true); - expect(newCalls.some((c) => c.url.endsWith("/xrpc/com.atproto.repo.createRecord"))).toBe(true); - }); - - it("non-$publishers member cannot putRecord", async () => { - const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", CHARLIE, { - communityDid: COMMUNITY_DID, - collection: "app.event.message", - record: { text: "nope" }, - }); - expect(res.status).toBe(403); - expect(((await res.json()) as any).reason).toBe("not-in-publishers"); - }); - - it("community.space.putRecord writes in-space with community author (admin+ required)", async () => { - // Create a content space. - const createRes = await call(app, "POST", "/xrpc/test.comm.community.space.create", ALICE, { - communityDid: COMMUNITY_DID, - key: "announcements", - }); - expect(createRes.status).toBe(200); - const spaceUri = ((await createRes.json()) as any).space.uri; - - // Alice (owner, which ≥ admin) writes. - const put = await call(app, "POST", "/xrpc/test.comm.community.space.putRecord", ALICE, { - spaceUri, - collection: "app.event.message", - record: { text: "first post" }, - }); - expect(put.status).toBe(200); - const body = (await put.json()) as any; - expect(body.authorDid).toBe(COMMUNITY_DID); - }); - - it("community.space.putRecord rejects non-admin members", async () => { - const createRes = await call(app, "POST", "/xrpc/test.comm.community.space.create", ALICE, { - communityDid: COMMUNITY_DID, - key: "ann2", - }); - const spaceUri = ((await createRes.json()) as any).space.uri; - await grant(app, ALICE, spaceUri, { did: BOB }, "member"); - - const put = await call(app, "POST", "/xrpc/test.comm.community.space.putRecord", BOB, { - spaceUri, - collection: "app.event.message", - record: { text: "nope" }, - }); - expect(put.status).toBe(403); - }); - - it("setAccessLevel with rank-outranking", async () => { - const createRes = await call(app, "POST", "/xrpc/test.comm.community.space.create", ALICE, { - communityDid: COMMUNITY_DID, - key: "roles-test", - }); - const spaceUri = ((await createRes.json()) as any).space.uri; - await grant(app, ALICE, spaceUri, { did: BOB }, "manager"); - await grant(app, ALICE, spaceUri, { did: CHARLIE }, "member"); - - // Bob (manager) promotes Charlie to manager — OK. - const ok = await call(app, "POST", "/xrpc/test.comm.community.space.setAccessLevel", BOB, { - spaceUri, - subject: { did: CHARLIE }, - accessLevel: "manager", - }); - expect(ok.status).toBe(200); - - // Bob (manager) tries to promote Charlie to admin — rejected. - const nope = await call(app, "POST", "/xrpc/test.comm.community.space.setAccessLevel", BOB, { - spaceUri, - subject: { did: CHARLIE }, - accessLevel: "admin", - }); - expect(nope.status).toBe(403); - }); - - it("getHealth reports healthy", async () => { - const res = await call(app, "GET", `/xrpc/test.comm.community.getHealth?communityDid=${COMMUNITY_DID}`, ALICE); - expect(res.status).toBe(200); - expect(((await res.json()) as any).status).toBe("healthy"); - }); - - it("reauth replaces the stored app password; old password no longer works", async () => { - const reauth = await call(app, "POST", "/xrpc/test.comm.community.reauth", ALICE, { - communityDid: COMMUNITY_DID, - appPassword: "new-correct-pw", - }); - expect(reauth.status).toBe(200); - - // getHealth still works with the new stored password. - const health = await call(app, "GET", `/xrpc/test.comm.community.getHealth?communityDid=${COMMUNITY_DID}`, ALICE); - expect(((await health.json()) as any).status).toBe("healthy"); - }); - - it("reauth requires owner in $admin", async () => { - const res = await call(app, "POST", "/xrpc/test.comm.community.reauth", BOB, { - communityDid: COMMUNITY_DID, - appPassword: "correct-pw", - }); - expect(res.status).toBe(403); - }); - - it("reauth with bad credentials is rejected", async () => { - const res = await call(app, "POST", "/xrpc/test.comm.community.reauth", ALICE, { - communityDid: COMMUNITY_DID, - appPassword: "totally-wrong", - }); - expect(res.status).toBe(401); - }); -}); - -// Build a small base64url-encoded JWT with a given exp claim. The publishing -// path decodes payload.exp to decide whether to reuse a cached session. -function jwtWithExp(expSeconds: number): string { - // base64url("{}") padding stripped — header content irrelevant to our tests. - const header = "eyJhbGciOiJIUzI1NiJ9"; - const payloadJson = JSON.stringify({ exp: expSeconds }); - const payload = btoa(payloadJson) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); - return `${header}.${payload}.sig`; -} - -/** Build an isolated app with a per-test scenario fetch + a fresh provision - * community. The scenario fetch records every call with its url + body + - * authorization header so individual tests can assert exact behavior. */ -async function makeScenarioApp(scenario: { - /** Override response for createSession. Default: success with default JWT. */ - onCreateSession?: () => Response; - /** Override response for refreshSession. Default: 400 (no refresh). */ - onRefreshSession?: () => Response; -}): Promise<{ - app: Hono; - db: Database; - calls: Array<{ url: string; body: any; authorization: string | null }>; -}> { - const calls: Array<{ url: string; body: any; authorization: string | null }> = []; - const scenarioFetch = async ( - input: RequestInfo | URL, - init?: RequestInit - ): Promise<Response> => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const body = init?.body ? JSON.parse(init.body as string) : {}; - const headers = new Headers((init?.headers as HeadersInit) ?? {}); - const authorization = headers.get("authorization"); - calls.push({ url, body, authorization }); - if (url.endsWith("/xrpc/com.atproto.server.createSession")) { - if (scenario.onCreateSession) return scenario.onCreateSession(); - return new Response( - JSON.stringify({ - accessJwt: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), - refreshJwt: "r.r.r", - did: PROVISION_COMMUNITY_DID, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - if (url.endsWith("/xrpc/com.atproto.server.refreshSession")) { - if (scenario.onRefreshSession) return scenario.onRefreshSession(); - return new Response(JSON.stringify({ error: "ExpiredToken" }), { status: 400 }); - } - if (url.endsWith("/xrpc/com.atproto.repo.createRecord")) { - return new Response( - JSON.stringify({ - uri: `at://${body.repo}/${body.collection}/scenkey`, - cid: "bafyfake", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - if (url.endsWith("/xrpc/com.atproto.repo.deleteRecord")) { - return new Response("{}", { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - return new Response("not found", { status: 404 }); - }; - - const cfg: ContrailConfig = { - ...CONFIG, - community: { ...CONFIG.community!, fetch: scenarioFetch }, - }; - const db = createSqliteDatabase(":memory:"); - const resolved = resolveConfig(cfg); - const communityIntegration = createCommunityIntegration({ db, config: resolved }); - await initSchema(db, resolved, { extraSchemas: [communityIntegration.applySchema] }); - const app = createApp(db, resolved, { - spaces: { authMiddleware: fakeAuth() }, - community: communityIntegration, - }); - // Seed provision community + Alice as owner of $publishers. - const cipher = new CredentialCipher(MASTER_KEY); - const encrypted = await cipher.encrypt("correct-pw"); - const community = new CommunityAdapter(db); - const spacesAdp = new HostedAdapter(db, resolved); - await community.createFromProvisioned({ - did: PROVISION_COMMUNITY_DID, - pdsEndpoint: PDS_ENDPOINT, - handle: PROVISION_HANDLE, - appPasswordEncrypted: encrypted, - createdBy: ALICE, - }); - for (const key of RESERVED_KEYS) { - const uri = buildSpaceUri({ - ownerDid: PROVISION_COMMUNITY_DID, - type: CONFIG.spaces!.authority!.type, - key, - }); - await spacesAdp.createSpace({ - uri, - ownerDid: PROVISION_COMMUNITY_DID, - type: CONFIG.spaces!.authority!.type, - key, - serviceDid: CONFIG.spaces!.authority!.serviceDid, - appPolicyRef: null, - appPolicy: null, - }); - await community.grant({ - spaceUri: uri, - subjectDid: ALICE, - accessLevel: "owner", - grantedBy: ALICE, - }); - await spacesAdp.applyMembershipDiff(uri, [ALICE], [], ALICE); - } - return { app, db, calls }; -} - -describe("community publishing — session caching (Task 14)", () => { - it("caches PDS sessions across putRecord calls", async () => { - const { app, calls } = await makeScenarioApp({}); - - for (let i = 0; i < 3; i++) { - const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { - communityDid: PROVISION_COMMUNITY_DID, - collection: "app.event.message", - record: { text: `msg ${i}` }, - }); - expect(res.status).toBe(200); - } - - const createSessionCalls = calls.filter((c) => - c.url.endsWith("/xrpc/com.atproto.server.createSession") - ).length; - const createRecordCalls = calls.filter((c) => - c.url.endsWith("/xrpc/com.atproto.repo.createRecord") - ).length; - expect(createSessionCalls).toBe(1); - expect(createRecordCalls).toBe(3); - }); - - it("considers a session valid when accessExp is in the future", async () => { - const { app, db, calls } = await makeScenarioApp({}); - // Pre-seed cache with a clearly-future expiry. - const community = new CommunityAdapter(db); - const cachedAccess = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); - await community.upsertSession(PROVISION_COMMUNITY_DID, { - accessJwt: cachedAccess, - refreshJwt: "cached-refresh", - accessExp: Math.floor(Date.now() / 1000) + 3600, - }); - - const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { - communityDid: PROVISION_COMMUNITY_DID, - collection: "app.event.message", - record: { text: "uses cached session" }, - }); - expect(res.status).toBe(200); - - const createSessionCalls = calls.filter((c) => - c.url.endsWith("/xrpc/com.atproto.server.createSession") - ).length; - expect(createSessionCalls).toBe(0); - // The createRecord call must have used the cached accessJwt. - const cr = calls.find((c) => c.url.endsWith("/xrpc/com.atproto.repo.createRecord")); - expect(cr).toBeDefined(); - expect(cr!.authorization).toBe(`Bearer ${cachedAccess}`); - }); - - it("refreshes a near-expired session via refreshSession", async () => { - const refreshedAccess = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); - const { app, db, calls } = await makeScenarioApp({ - onRefreshSession: () => - new Response( - JSON.stringify({ accessJwt: refreshedAccess, refreshJwt: "new-refresh" }), - { status: 200, headers: { "content-type": "application/json" } } - ), - }); - const community = new CommunityAdapter(db); - await community.upsertSession(PROVISION_COMMUNITY_DID, { - accessJwt: jwtWithExp(Math.floor(Date.now() / 1000) - 60), - refreshJwt: "old-refresh", - accessExp: Math.floor(Date.now() / 1000) - 60, - }); - - const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { - communityDid: PROVISION_COMMUNITY_DID, - collection: "app.event.message", - record: { text: "after refresh" }, - }); - expect(res.status).toBe(200); - - const createSessionCalls = calls.filter((c) => - c.url.endsWith("/xrpc/com.atproto.server.createSession") - ).length; - const refreshSessionCalls = calls.filter((c) => - c.url.endsWith("/xrpc/com.atproto.server.refreshSession") - ).length; - expect(createSessionCalls).toBe(0); - expect(refreshSessionCalls).toBe(1); - // createRecord must use the refreshed access JWT. - const cr = calls.find((c) => c.url.endsWith("/xrpc/com.atproto.repo.createRecord")); - expect(cr!.authorization).toBe(`Bearer ${refreshedAccess}`); - }); - - it("falls back to createSession when refresh fails", async () => { - const { app, db, calls } = await makeScenarioApp({ - onRefreshSession: () => - new Response(JSON.stringify({ error: "ExpiredToken" }), { status: 400 }), - }); - const community = new CommunityAdapter(db); - await community.upsertSession(PROVISION_COMMUNITY_DID, { - accessJwt: jwtWithExp(Math.floor(Date.now() / 1000) - 60), - refreshJwt: "stale-refresh", - accessExp: Math.floor(Date.now() / 1000) - 60, - }); - - const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { - communityDid: PROVISION_COMMUNITY_DID, - collection: "app.event.message", - record: { text: "fallback to create" }, - }); - expect(res.status).toBe(200); - - const createSessionCalls = calls.filter((c) => - c.url.endsWith("/xrpc/com.atproto.server.createSession") - ).length; - const refreshSessionCalls = calls.filter((c) => - c.url.endsWith("/xrpc/com.atproto.server.refreshSession") - ).length; - expect(refreshSessionCalls).toBe(1); - expect(createSessionCalls).toBe(1); - }); -}); - -describe("community publishing — provision mode", () => { - let app: Hono; - - beforeAll(async () => { - const built = await makeApp(); - app = built.app; - await seedProvisionCommunity(built.db, ALICE, "correct-pw"); - }); - - it("publishes a record under a provision-mode community", async () => { - const before = pdsCalls.length; - const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { - communityDid: PROVISION_COMMUNITY_DID, - collection: "app.event.message", - record: { text: "hello from a provisioned community" }, - }); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - expect(body.uri).toBe(`at://${PROVISION_COMMUNITY_DID}/app.event.message/fakerkey`); - - const newCalls = pdsCalls.slice(before); - expect( - newCalls.some( - (c) => - c.url.endsWith("/xrpc/com.atproto.server.createSession") && - c.body.identifier === PROVISION_HANDLE - ) - ).toBe(true); - expect( - newCalls.some( - (c) => - c.url.endsWith("/xrpc/com.atproto.repo.createRecord") && - c.body.repo === PROVISION_COMMUNITY_DID - ) - ).toBe(true); - }); - - it("deletes a record under a provision-mode community", async () => { - const before = pdsCalls.length; - const res = await call(app, "POST", "/xrpc/test.comm.community.deleteRecord", ALICE, { - communityDid: PROVISION_COMMUNITY_DID, - collection: "app.event.message", - rkey: "fakerkey", - }); - expect(res.status).toBe(200); - expect(((await res.json()) as any).ok).toBe(true); - - const newCalls = pdsCalls.slice(before); - expect( - newCalls.some((c) => c.url.endsWith("/xrpc/com.atproto.repo.deleteRecord")) - ).toBe(true); - }); - - it("reports healthy for a provision-mode community", async () => { - const res = await call( - app, - "GET", - `/xrpc/test.comm.community.getHealth?communityDid=${PROVISION_COMMUNITY_DID}`, - ALICE - ); - expect(res.status).toBe(200); - expect(((await res.json()) as any).status).toBe("healthy"); - }); -}); diff --git a/packages/contrail-community/tests/community-sessions-cache.test.ts b/packages/contrail-community/tests/community-sessions-cache.test.ts deleted file mode 100644 index 6ead80c..0000000 --- a/packages/contrail-community/tests/community-sessions-cache.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { initCommunitySchema } from "../src/schema"; -import { CommunityAdapter } from "../src/adapter"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; - -describe("community_sessions cache", () => { - let adapter: CommunityAdapter; - - beforeEach(async () => { - const db = createSqliteDatabase(":memory:"); - await initCommunitySchema(db); - adapter = new CommunityAdapter(db); - }); - - it("upserts and reads a cached session", async () => { - await adapter.upsertSession("did:plc:x", { - accessJwt: "atok", - refreshJwt: "rtok", - accessExp: 1234, - }); - const got = await adapter.getSession("did:plc:x"); - expect(got).toEqual({ accessJwt: "atok", refreshJwt: "rtok", accessExp: 1234 }); - }); - - it("returns null for missing did", async () => { - const got = await adapter.getSession("did:plc:nope"); - expect(got).toBeNull(); - }); - - it("clears a session", async () => { - await adapter.upsertSession("did:plc:x", { - accessJwt: "a", - refreshJwt: "r", - accessExp: 1, - }); - await adapter.clearSession("did:plc:x"); - expect(await adapter.getSession("did:plc:x")).toBeNull(); - }); - - it("upsert overwrites existing session for the same did", async () => { - await adapter.upsertSession("did:plc:x", { - accessJwt: "old-a", - refreshJwt: "old-r", - accessExp: 100, - }); - await adapter.upsertSession("did:plc:x", { - accessJwt: "new-a", - refreshJwt: "new-r", - accessExp: 200, - }); - const got = await adapter.getSession("did:plc:x"); - expect(got).toEqual({ accessJwt: "new-a", refreshJwt: "new-r", accessExp: 200 }); - }); - - it("isolates sessions across communities", async () => { - await adapter.upsertSession("did:plc:a", { - accessJwt: "a-tok", - refreshJwt: "a-rtok", - accessExp: 1, - }); - await adapter.upsertSession("did:plc:b", { - accessJwt: "b-tok", - refreshJwt: "b-rtok", - accessExp: 2, - }); - expect(await adapter.getSession("did:plc:a")).toEqual({ - accessJwt: "a-tok", - refreshJwt: "a-rtok", - accessExp: 1, - }); - expect(await adapter.getSession("did:plc:b")).toEqual({ - accessJwt: "b-tok", - refreshJwt: "b-rtok", - accessExp: 2, - }); - await adapter.clearSession("did:plc:a"); - expect(await adapter.getSession("did:plc:a")).toBeNull(); - // Clearing one DID must not affect the other. - expect(await adapter.getSession("did:plc:b")).toEqual({ - accessJwt: "b-tok", - refreshJwt: "b-rtok", - accessExp: 2, - }); - }); -}); diff --git a/packages/contrail-community/tests/invite-unified.test.ts b/packages/contrail-community/tests/invite-unified.test.ts deleted file mode 100644 index 32bd6c5..0000000 --- a/packages/contrail-community/tests/invite-unified.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** Unified invite surface: `<ns>.invite.*` dispatches on space ownership. - * - user-owned space → `kind` in create, `addMember` on redeem. - * - community-owned → `accessLevel` in create, `grant`+`reconcile` on redeem. - * - * Both paths through a single endpoint family. */ - -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; -import { initSchema } from "@atmo-dev/contrail"; -import { createApp } from "@atmo-dev/contrail"; -import { resolveConfig } from "@atmo-dev/contrail"; -import type { ContrailConfig } from "@atmo-dev/contrail"; -import { createCommunityIntegration } from "../src/integration"; - -const ALICE = "did:plc:alice"; -const BOB = "did:plc:bob"; -const CHARLIE = "did:plc:charlie"; -const COMMUNITY_DID = "did:plc:acmecommunity"; -const PDS = "https://pds.example"; -const MASTER_KEY = new Uint8Array(32).fill(11); - -const CONFIG: ContrailConfig = { - namespace: "test.inv", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { type: "tools.atmo.event.space", serviceDid: "did:web:test.example#svc" }, - recordHost: {}, - }, - community: { - masterKey: MASTER_KEY, - fetch: mockFetch, - resolver: mockResolver(), - }, -}; - -function mockResolver(): any { - return { - resolve: async (_did: string) => ({ - id: _did, - service: [{ id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: PDS }], - }), - }; -} - -async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/xrpc/com.atproto.server.createSession") && init?.method === "POST") { - return new Response( - JSON.stringify({ accessJwt: "a.b.c", refreshJwt: "r.r.r", did: COMMUNITY_DID }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - return new Response("not found", { status: 404 }); -} - -function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { issuer: did, audience: CONFIG.spaces!.authority!.serviceDid, lxm: undefined }); - await next(); - }; -} - -async function makeApp(): Promise<Hono> { - const db = createSqliteDatabase(":memory:"); - const resolved = resolveConfig(CONFIG); - const community = createCommunityIntegration({ db, config: resolved }); - await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); - return createApp(db, resolved, { - spaces: { authMiddleware: fakeAuth() }, - community, - }); -} - -function call(app: Hono, method: string, path: string, did: string, body?: any): Promise<Response> { - const headers: Record<string, string> = { "X-Test-Did": did }; - if (body !== undefined) headers["Content-Type"] = "application/json"; - return app.fetch( - new Request(`http://localhost${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) - ); -} - -describe("<ns>.invite.* — unified surface", () => { - describe("user-owned space (kind)", () => { - let app: Hono; - let spaceUri: string; - - beforeAll(async () => { - app = await makeApp(); - const res = await call(app, "POST", "/xrpc/test.inv.space.createSpace", ALICE, { key: "lounge" }); - expect(res.status).toBe(200); - spaceUri = ((await res.json()) as any).space.uri; - }); - - it("rejects accessLevel on a user-owned space", async () => { - const res = await call(app, "POST", "/xrpc/test.inv.invite.create", ALICE, { - spaceUri, - accessLevel: "member", - }); - expect(res.status).toBe(400); - expect(((await res.json()) as any).reason).toBe("accessLevel-on-user-space"); - }); - - it("owner creates kind=join; redeemer becomes a member", async () => { - const create = await call(app, "POST", "/xrpc/test.inv.invite.create", ALICE, { spaceUri }); - expect(create.status).toBe(200); - const { token, invite } = (await create.json()) as any; - expect(invite.kind).toBe("join"); - expect(invite.accessLevel).toBeUndefined(); - - const redeem = await call(app, "POST", "/xrpc/test.inv.invite.redeem", BOB, { token }); - expect(redeem.status).toBe(200); - const body = (await redeem.json()) as any; - expect(body.spaceUri).toBe(spaceUri); - expect(body.kind).toBe("join"); - expect(body.accessLevel).toBeUndefined(); - }); - }); - - describe("community-owned space (accessLevel)", () => { - let app: Hono; - let adminUri: string; - let channelUri: string; - - beforeAll(async () => { - app = await makeApp(); - // Adopt a community owned by Alice. - const adopt = await call(app, "POST", "/xrpc/test.inv.community.adopt", ALICE, { - identifier: COMMUNITY_DID, - appPassword: "anything", - }); - expect(adopt.status).toBe(200); - adminUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; - - // Alice (owner in $admin) creates a child space. - const create = await call(app, "POST", "/xrpc/test.inv.community.space.create", ALICE, { - communityDid: COMMUNITY_DID, - key: "general", - }); - expect(create.status).toBe(200); - channelUri = ((await create.json()) as any).space.uri; - }); - - it("rejects kind on a community-owned space", async () => { - const res = await call(app, "POST", "/xrpc/test.inv.invite.create", ALICE, { - spaceUri: channelUri, - kind: "join", - }); - expect(res.status).toBe(400); - expect(((await res.json()) as any).reason).toBe("kind-on-community-space"); - }); - - it("requires accessLevel", async () => { - const res = await call(app, "POST", "/xrpc/test.inv.invite.create", ALICE, { - spaceUri: channelUri, - }); - expect(res.status).toBe(400); - expect(((await res.json()) as any).reason).toBe("accessLevel-required"); - }); - - it("owner creates with accessLevel=member; redeemer is granted that level", async () => { - const create = await call(app, "POST", "/xrpc/test.inv.invite.create", ALICE, { - spaceUri: channelUri, - accessLevel: "member", - }); - expect(create.status).toBe(200); - const { token, invite } = (await create.json()) as any; - expect(invite.accessLevel).toBe("member"); - expect(invite.kind).toBeUndefined(); - - const redeem = await call(app, "POST", "/xrpc/test.inv.invite.redeem", BOB, { token }); - expect(redeem.status).toBe(200); - const body = (await redeem.json()) as any; - expect(body.spaceUri).toBe(channelUri); - expect(body.accessLevel).toBe("member"); - expect(body.communityDid).toBe(COMMUNITY_DID); - - // Bob now shows up on whoami via the ladder. - const w = await call(app, "GET", `/xrpc/test.inv.spaceExt.whoami?spaceUri=${encodeURIComponent(channelUri)}`, BOB); - expect(((await w.json()) as any).accessLevel).toBe("member"); - }); - - it("non-manager cannot create an invite on a community space", async () => { - const res = await call(app, "POST", "/xrpc/test.inv.invite.create", CHARLIE, { - spaceUri: channelUri, - accessLevel: "member", - }); - expect(res.status).toBe(403); - expect(((await res.json()) as any).reason).toBe("manager-required"); - }); - - it("cannot grant a higher access level than caller's own", async () => { - // Create a second space. Grant Charlie as "manager" there, then have him - // try to mint an "admin" invite — should fail. - const create = await call(app, "POST", "/xrpc/test.inv.community.space.create", ALICE, { - communityDid: COMMUNITY_DID, - key: "side", - }); - const sideUri = ((await create.json()) as any).space.uri; - await call(app, "POST", "/xrpc/test.inv.community.space.grant", ALICE, { - spaceUri: sideUri, - subject: { did: CHARLIE }, - accessLevel: "manager", - }); - - const res = await call(app, "POST", "/xrpc/test.inv.invite.create", CHARLIE, { - spaceUri: sideUri, - accessLevel: "admin", - }); - expect(res.status).toBe(403); - expect(((await res.json()) as any).reason).toBe("cannot-grant-higher-than-self"); - }); - }); -}); diff --git a/packages/contrail-community/tests/pds-account-ops.test.ts b/packages/contrail-community/tests/pds-account-ops.test.ts deleted file mode 100644 index 348793b..0000000 --- a/packages/contrail-community/tests/pds-account-ops.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - pdsGetRecommendedDidCredentials, - pdsActivateAccount, -} from "../src/pds"; - -describe("pdsGetRecommendedDidCredentials", () => { - it("issues GET to the identity endpoint with bearer accessJwt and parses response", async () => { - let received: { url: string; init: any } | null = null; - const fetch = (async (url: string, init: any) => { - received = { url, init }; - return new Response( - JSON.stringify({ - rotationKeys: ["did:key:zRot"], - verificationMethods: { atproto: "did:key:zSig" }, - alsoKnownAs: ["at://h.test"], - services: { - atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://pds.test" }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }) as unknown as typeof globalThis.fetch; - - const result = await pdsGetRecommendedDidCredentials( - "https://pds.test", - "AT", - { fetch } - ); - - expect(received!.url).toBe( - "https://pds.test/xrpc/com.atproto.identity.getRecommendedDidCredentials" - ); - // Default fetch method is GET when none specified. - expect(received!.init?.method ?? "GET").toBe("GET"); - // Bearer is the session accessJwt, NOT a service-auth JWT. - expect(received!.init.headers.authorization).toBe("Bearer AT"); - expect(result.rotationKeys).toEqual(["did:key:zRot"]); - expect(result.verificationMethods).toEqual({ atproto: "did:key:zSig" }); - expect(result.alsoKnownAs).toEqual(["at://h.test"]); - expect(result.services).toEqual({ - atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://pds.test" }, - }); - }); - - it("throws with status and body on non-2xx", async () => { - const fetch = (async () => - new Response("session expired", { status: 401 })) as any; - await expect( - pdsGetRecommendedDidCredentials("https://pds.test", "AT", { fetch }) - ).rejects.toThrow(/getRecommendedDidCredentials failed.*401.*session expired/); - }); -}); - -describe("pdsActivateAccount", () => { - it("issues POST to activateAccount with bearer accessJwt and resolves to undefined", async () => { - let received: { url: string; init: any } | null = null; - const fetch = (async (url: string, init: any) => { - received = { url, init }; - return new Response("", { status: 200 }); - }) as unknown as typeof globalThis.fetch; - - const result = await pdsActivateAccount("https://pds.test", "AT", { fetch }); - - expect(received!.url).toBe( - "https://pds.test/xrpc/com.atproto.server.activateAccount" - ); - expect(received!.init.method).toBe("POST"); - // Bearer is the session accessJwt from pdsCreateAccount, NOT a service-auth JWT. - expect(received!.init.headers.authorization).toBe("Bearer AT"); - expect(result).toBeUndefined(); - }); - - it("throws with status and body on non-2xx", async () => { - const fetch = (async () => - new Response("nope", { status: 400 })) as any; - await expect( - pdsActivateAccount("https://pds.test", "AT", { fetch }) - ).rejects.toThrow(/activateAccount failed.*400.*nope/); - }); -}); diff --git a/packages/contrail-community/tests/pds-create-account.test.ts b/packages/contrail-community/tests/pds-create-account.test.ts deleted file mode 100644 index 33ebcd7..0000000 --- a/packages/contrail-community/tests/pds-create-account.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { pdsCreateAccount } from "../src/pds"; - -describe("pdsCreateAccount", () => { - it("posts createAccount with bearer auth and returns session", async () => { - let received: { url: string; init: any } | null = null; - const fetch = (async (url: string, init: any) => { - received = { url, init }; - return new Response( - JSON.stringify({ - accessJwt: "AT", refreshJwt: "RT", handle: "h.test", did: "did:plc:x", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }) as unknown as typeof globalThis.fetch; - - const result = await pdsCreateAccount( - "https://pds.test", - "JWT-VALUE", - { - handle: "h.test", - did: "did:plc:x", - email: "h@x.test", - password: "p", - inviteCode: "code", - }, - { fetch } - ); - - expect(received!.url).toBe("https://pds.test/xrpc/com.atproto.server.createAccount"); - expect(received!.init.method).toBe("POST"); - expect(received!.init.headers.authorization).toBe("Bearer JWT-VALUE"); - expect(JSON.parse(received!.init.body)).toEqual({ - handle: "h.test", - did: "did:plc:x", - email: "h@x.test", - password: "p", - inviteCode: "code", - }); - expect(result.accessJwt).toBe("AT"); - expect(result.did).toBe("did:plc:x"); - }); - - it("strips trailing slash from pdsEndpoint", async () => { - let receivedUrl = ""; - const fetch = (async (url: string) => { - receivedUrl = url; - return new Response( - JSON.stringify({ accessJwt: "AT", refreshJwt: "RT", handle: "h", did: "did:plc:x" }), - { status: 200 } - ); - }) as any; - await pdsCreateAccount( - "https://pds.test/", - "JWT", - { handle: "h", did: "did:plc:x", email: "e", password: "p" }, - { fetch } - ); - expect(receivedUrl).toBe("https://pds.test/xrpc/com.atproto.server.createAccount"); - }); - - it("throws on non-2xx", async () => { - const fetch = (async () => - new Response(JSON.stringify({ error: "InvalidRequest", message: "bad" }), { status: 400 })) as any; - await expect( - pdsCreateAccount( - "https://pds.test", - "x", - { handle: "h", did: "did:plc:x", email: "e", password: "p" }, - { fetch } - ) - ).rejects.toThrow(/createAccount failed.*400.*InvalidRequest/); - }); -}); diff --git a/packages/contrail-community/tests/plc-log-last.test.ts b/packages/contrail-community/tests/plc-log-last.test.ts deleted file mode 100644 index 975ede7..0000000 --- a/packages/contrail-community/tests/plc-log-last.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - cidForOp, - getLastOpCid, - type SignedGenesisOp, - type SignedTombstoneOp, -} from "../src/plc"; - -const GENESIS_OP: SignedGenesisOp = { - type: "plc_operation", - prev: null, - rotationKeys: ["did:key:zQ3shjNSBChNYuYsW41QDdm2D25zmQkdpfhgbaQBRG4ecg7sk"], - verificationMethods: { - atproto: "did:key:zQ3shmefuqey6KqP7M9cwFwywqTVuCZFXcCAGJ5JGktdAUdD2", - }, - alsoKnownAs: ["at://probe.devnet.test"], - services: { - atproto_pds: { - type: "AtprotoPersonalDataServer", - endpoint: "https://devnet.test", - }, - }, - sig: "xEZ7BS7bXJ-7KqExTH158uJFNhcTi21khw-rCHjt70EwGVhftk29Xjf1IR9JGhSmDPE76Xqc01ydF9TmmPHr2w", -}; - -const TOMBSTONE_OP: SignedTombstoneOp = { - type: "plc_tombstone", - prev: "bafyreiabmto3hekxoflemevicopvpud2k6ypf2fkp3v3g6iu36l4wxxfle", - sig: "abc123", -}; - -describe("getLastOpCid", () => { - it("returns the CID computed locally from the PLC log/last op response", async () => { - let calledUrl = ""; - const fakeFetch: typeof fetch = async (input) => { - calledUrl = String(input); - return new Response(JSON.stringify(GENESIS_OP), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }; - const cid = await getLastOpCid("https://plc.test", "did:plc:abc", { - fetch: fakeFetch, - }); - expect(calledUrl).toBe("https://plc.test/did:plc:abc/log/last"); - // PLC returns the bare op (no envelope). The function must compute the - // CID with the same DAG-CBOR encoder cidForOp uses so the value matches - // the CID PLC stored when it accepted the op. - expect(cid).toBe(await cidForOp(GENESIS_OP)); - }); - - it("computes the CID from a tombstone op response too", async () => { - const fakeFetch: typeof fetch = async () => - new Response(JSON.stringify(TOMBSTONE_OP), { - status: 200, - headers: { "content-type": "application/json" }, - }); - const cid = await getLastOpCid("https://plc.test", "did:plc:abc", { - fetch: fakeFetch, - }); - expect(cid).toBe(await cidForOp(TOMBSTONE_OP)); - }); - - it("strips a trailing slash from the directory base", async () => { - let calledUrl = ""; - const fakeFetch: typeof fetch = async (input) => { - calledUrl = String(input); - return new Response(JSON.stringify(GENESIS_OP), { status: 200 }); - }; - await getLastOpCid("https://plc.test/", "did:plc:xyz", { fetch: fakeFetch }); - expect(calledUrl).toBe("https://plc.test/did:plc:xyz/log/last"); - }); - - it("throws on a non-200 response, including the status and body", async () => { - const fakeFetch: typeof fetch = async () => - new Response("not found", { status: 404 }); - await expect( - getLastOpCid("https://plc.test", "did:plc:missing", { fetch: fakeFetch }) - ).rejects.toThrow(/404.*not found/); - }); -}); diff --git a/packages/contrail-community/tests/plc-update-op.test.ts b/packages/contrail-community/tests/plc-update-op.test.ts deleted file mode 100644 index 5f3b765..0000000 --- a/packages/contrail-community/tests/plc-update-op.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - generateKeyPair, - buildGenesisOp, - signGenesisOp, - buildUpdateOp, - signUpdateOp, - cidForOp, -} from "../src/plc"; - -describe("cidForOp", () => { - it("produces a CIDv1 dag-cbor sha256 base32-lower CID starting with bafyrei", async () => { - const kp = await generateKeyPair(); - const unsigned = buildGenesisOp({ - rotationKeys: [kp.publicDidKey], - verificationMethodAtproto: kp.publicDidKey, - alsoKnownAs: ["at://x.test"], - services: { atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://x.test" } }, - }); - const signed = await signGenesisOp(unsigned, kp.privateJwk); - const cid = await cidForOp(signed); - expect(cid).toMatch(/^bafyrei/); - expect(cid.length).toBeGreaterThan(50); - }); -}); - -describe("buildUpdateOp + signUpdateOp", () => { - it("produces a plc_operation with prev set and a sig segment", async () => { - const kp = await generateKeyPair(); - const genesis = buildGenesisOp({ - rotationKeys: [kp.publicDidKey], - verificationMethodAtproto: kp.publicDidKey, - alsoKnownAs: ["at://x.test"], - services: { atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://x.test" } }, - }); - const signedGenesis = await signGenesisOp(genesis, kp.privateJwk); - const prev = await cidForOp(signedGenesis); - - const update = buildUpdateOp({ - prev, - rotationKeys: [kp.publicDidKey, "did:key:zPdsRot"], - verificationMethodAtproto: "did:key:zPdsSig", - alsoKnownAs: ["at://x.test"], - services: { atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://x.test" } }, - }); - - const signedUpdate = await signUpdateOp(update, kp.privateJwk); - expect(signedUpdate.sig).toMatch(/^[A-Za-z0-9_-]+$/); - }); -}); diff --git a/packages/contrail-community/tests/provision-orchestrator.test.ts b/packages/contrail-community/tests/provision-orchestrator.test.ts deleted file mode 100644 index 9dd832c..0000000 --- a/packages/contrail-community/tests/provision-orchestrator.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { initCommunitySchema } from "../src/schema"; -import { CommunityAdapter } from "../src/adapter"; -import { CredentialCipher } from "../src/credentials"; -import { ProvisionOrchestrator } from "../src/provision"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; - -const STUB_ROTATION_KEY = "did:key:zStubCallerRotationKeyForTests"; - -function mockPlc() { - const ops: any[] = []; - return { - ops, - async submit(did: string, op: any) { - ops.push({ did, op }); - return { ok: true }; - }, - }; -} - -function mockPds() { - return { - async createAccount() { - return { - did: "did:plc:x", - handle: "h.test", - accessJwt: "AT", - refreshJwt: "RT", - }; - }, - async getRecommendedDidCredentials() { - return { - rotationKeys: ["did:key:zPdsRot"], - verificationMethods: { atproto: "did:key:zPdsSig" }, - alsoKnownAs: ["at://h.test"], - services: { - atproto_pds: { - type: "AtprotoPersonalDataServer", - endpoint: "https://pds.test", - }, - }, - }; - }, - async activateAccount() { - return; - }, - async createAppPassword() { - return { password: "minted-app-pw" }; - }, - }; -} - -describe("ProvisionOrchestrator", () => { - let adapter: CommunityAdapter; - let cipher: CredentialCipher; - beforeEach(async () => { - const db = createSqliteDatabase(":memory:"); - await initCommunitySchema(db); - cipher = new CredentialCipher(new Uint8Array(32).fill(99)); - adapter = new CommunityAdapter(db); - }); - - it("runs end-to-end and lands at status=activated", async () => { - const orch = new ProvisionOrchestrator({ - adapter, - cipher, - plc: mockPlc(), - pds: mockPds(), - pdsDid: "did:web:pds.test", - }); - - const result = await orch.provision({ - attemptId: "a1", - pdsEndpoint: "https://pds.test", - handle: "h.test", - email: "h@x.test", - password: "p", - inviteCode: "code", - rotationKey: STUB_ROTATION_KEY, - }); - - expect(result.did).toBeTruthy(); - expect(result.status).toBe("activated"); - const row = await adapter.getProvisionAttempt("a1"); - expect(row?.status).toBe("activated"); - expect(row?.encryptedSigningKey).toBeTruthy(); - expect(row?.encryptedRotationKey).toBeTruthy(); - expect(row?.encryptedPassword).toBeTruthy(); - }); - - it("seeds the community_sessions cache with the createAccount JWTs after activation", async () => { - const orch = new ProvisionOrchestrator({ - adapter, - cipher, - plc: mockPlc(), - pds: mockPds(), - pdsDid: "did:web:pds.test", - }); - - const result = await orch.provision({ - attemptId: "a1", - pdsEndpoint: "https://pds.test", - handle: "h.test", - email: "h@x.test", - password: "p", - inviteCode: "code", - rotationKey: STUB_ROTATION_KEY, - }); - - const cached = await adapter.getSession(result.did); - expect(cached).not.toBeNull(); - expect(cached?.accessJwt).toBe("AT"); - expect(cached?.refreshJwt).toBe("RT"); - }); - - it("persists status=genesis_submitted before createAccount runs", async () => { - let createCalled = false; - const pds = { - async createAccount() { - // Inspect state at this exact moment. - const row = await adapter.getProvisionAttempt("a1"); - expect(row?.status).toBe("genesis_submitted"); - createCalled = true; - return { - did: "did:plc:x", - handle: "h.test", - accessJwt: "AT", - refreshJwt: "RT", - }; - }, - async getRecommendedDidCredentials() { - return { - rotationKeys: [], - verificationMethods: { atproto: "did:key:zSig" }, - alsoKnownAs: ["at://h.test"], - services: { - atproto_pds: { type: "x", endpoint: "https://pds.test" }, - }, - }; - }, - async activateAccount() {}, - async createAppPassword() { - return { password: "minted" }; - }, - }; - - const orch = new ProvisionOrchestrator({ - adapter, - cipher, - plc: mockPlc(), - pds, - pdsDid: "did:web:pds.test", - }); - await orch.provision({ - attemptId: "a1", - pdsEndpoint: "https://pds.test", - handle: "h.test", - email: "h@x.test", - password: "p", - rotationKey: STUB_ROTATION_KEY, - }); - expect(createCalled).toBe(true); - }); - - it("marks last_error and rethrows when createAccount fails", async () => { - const pds = { - ...mockPds(), - async createAccount() { - throw new Error("createAccount 400: bad invite"); - }, - } as any; - - const orch = new ProvisionOrchestrator({ - adapter, - cipher, - plc: mockPlc(), - pds, - pdsDid: "did:web:pds.test", - }); - - await expect( - orch.provision({ - attemptId: "a1", - pdsEndpoint: "https://pds.test", - handle: "h.test", - email: "h@x.test", - password: "p", - rotationKey: STUB_ROTATION_KEY, - }) - ).rejects.toThrow(/bad invite/); - const row = await adapter.getProvisionAttempt("a1"); - expect(row?.status).toBe("genesis_submitted"); // last successful step - expect(row?.lastError).toMatch(/bad invite/); - }); - - it("re-invoking with the same attemptId on a fully-completed row returns success without redoing PLC/PDS work", async () => { - // Scenario: the orchestrator finished cleanly (status=activated + - // encryptedPassword set), but a *downstream* step (router's - // createFromProvisioned or bootstrapReservedSpaces) failed and the - // caller retries with the same attemptId. The orchestrator must not - // throw "already exists" — it should report success so the route can - // resume the graduation steps. - const plc = mockPlc(); - const pds: any = mockPds(); - const orch = new ProvisionOrchestrator({ - adapter, - cipher, - plc, - pds, - pdsDid: "did:web:pds.test", - }); - - const first = await orch.provision({ - attemptId: "a1", - pdsEndpoint: "https://pds.test", - handle: "h.test", - email: "h@x.test", - password: "p", - inviteCode: "code", - rotationKey: STUB_ROTATION_KEY, - }); - expect(first.status).toBe("activated"); - const opsAfterFirst = plc.ops.length; - - // Wire a fresh PDS mock whose every method throws — if the retry path - // calls any of them, the test fails loudly. createSession is allowed - // because the C3 retry path is wired for the not-yet-completed case; - // a fully-completed row should NOT hit it either. - const explodingPds: any = { - createAccount: () => { throw new Error("createAccount should not be called on a completed retry"); }, - getRecommendedDidCredentials: () => { throw new Error("getRecommendedDidCredentials should not be called"); }, - activateAccount: () => { throw new Error("activateAccount should not be called"); }, - createAppPassword: () => { throw new Error("createAppPassword should not be called on a completed retry"); }, - createSession: () => { throw new Error("createSession should not be called on a completed retry"); }, - }; - const retryOrch = new ProvisionOrchestrator({ - adapter, - cipher, - plc: { submit: () => { throw new Error("plc.submit should not be called on a completed retry"); } }, - pds: explodingPds, - pdsDid: "did:web:pds.test", - }); - - const second = await retryOrch.provision({ - attemptId: "a1", - pdsEndpoint: "https://pds.test", - handle: "h.test", - email: "h@x.test", - password: "p", - inviteCode: "code", - rotationKey: STUB_ROTATION_KEY, - }); - expect(second.status).toBe("activated"); - expect(second.did).toBe(first.did); - expect(second.attemptId).toBe("a1"); - expect(plc.ops.length).toBe(opsAfterFirst); - }); - -}); diff --git a/packages/contrail-community/tests/provision-self-sovereign.test.ts b/packages/contrail-community/tests/provision-self-sovereign.test.ts deleted file mode 100644 index df41bbe..0000000 --- a/packages/contrail-community/tests/provision-self-sovereign.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { initCommunitySchema } from "../src/schema"; -import { CommunityAdapter } from "../src/adapter"; -import { CredentialCipher } from "../src/credentials"; -import { ProvisionOrchestrator } from "../src/provision"; -import { generateKeyPair } from "../src/plc"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; - -/** Mock PLC client that records every submitted op so tests can inspect the - * genesis op (in particular, its rotationKeys array). */ -function mockPlc() { - const ops: Array<{ did: string; op: any }> = []; - return { - ops, - async submit(did: string, op: any) { - ops.push({ did, op }); - return { ok: true }; - }, - }; -} - -/** Mock PDS client that records calls to createAppPassword so tests can assert - * on its arguments (or its absence). The minted password is deterministic so - * decryption assertions can compare. */ -function mockPds(opts: { mintedPassword?: string } = {}) { - const calls: { createAppPassword: Array<{ pdsUrl: string; accessJwt: string; name: string }> } = { - createAppPassword: [], - }; - return { - calls, - async createAccount() { - return { - did: "did:plc:x", - handle: "h.test", - accessJwt: "AT", - refreshJwt: "RT", - }; - }, - async getRecommendedDidCredentials() { - return { - rotationKeys: ["did:key:zPdsRot"], - verificationMethods: { atproto: "did:key:zPdsSig" }, - alsoKnownAs: ["at://h.test"], - services: { - atproto_pds: { - type: "AtprotoPersonalDataServer", - endpoint: "https://pds.test", - }, - }, - }; - }, - async activateAccount() { - return; - }, - async createAppPassword(input: { pdsUrl: string; accessJwt: string; name: string }) { - calls.createAppPassword.push(input); - return { password: opts.mintedPassword ?? "minted-app-pass-XXXX" }; - }, - }; -} - -describe("ProvisionOrchestrator — caller-supplied rotation key", () => { - let adapter: CommunityAdapter; - let cipher: CredentialCipher; - beforeEach(async () => { - const db = createSqliteDatabase(":memory:"); - await initCommunitySchema(db); - cipher = new CredentialCipher(new Uint8Array(32).fill(99)); - adapter = new CommunityAdapter(db); - }); - - it("genesis includes caller rotation key, mints app password, response carries rootCredentials", async () => { - const callerKeyPair = await generateKeyPair(); - const callerRotationDidKey = callerKeyPair.publicDidKey; - const userPassword = "user-supplied-root-pw"; - const mintedPassword = "minted-app-pw-1234"; - - const plc = mockPlc(); - const pds = mockPds({ mintedPassword }); - - const orch = new ProvisionOrchestrator({ - adapter, - cipher, - plc, - pds, - pdsDid: "did:web:pds.test", - }); - - const result = await orch.provision({ - attemptId: "ss1", - pdsEndpoint: "https://pds.test", - handle: "h.test", - email: "h@x.test", - password: userPassword, - inviteCode: "code", - rotationKey: callerRotationDidKey, - }); - - // Status unchanged in shape. - expect(result.status).toBe("activated"); - expect(result.did).toBeTruthy(); - - // Response carries root credentials so the caller can keep their root password. - expect(result.rootCredentials).toBeDefined(); - expect(result.rootCredentials!.password).toBe(userPassword); - expect(result.rootCredentials!.handle).toBe("h.test"); - expect(typeof result.rootCredentials!.recoveryHint).toBe("string"); - - // Persisted attempt row reaches activated status. - const row = await adapter.getProvisionAttempt("ss1"); - expect(row).toBeTruthy(); - expect(row!.status).toBe("activated"); - - // Genesis op submitted to PLC has BOTH rotation keys, with the caller's first. - expect(plc.ops.length).toBeGreaterThanOrEqual(1); - const genesis = plc.ops[0]!.op; - expect(Array.isArray(genesis.rotationKeys)).toBe(true); - expect(genesis.rotationKeys[0]).toBe(callerRotationDidKey); - expect(genesis.rotationKeys.length).toBe(2); - expect(genesis.rotationKeys[1]).toBeTruthy(); - expect(genesis.rotationKeys[1]).not.toBe(callerRotationDidKey); - - // createAppPassword was invoked post-activation with the session's accessJwt. - expect(pds.calls.createAppPassword.length).toBe(1); - const apCall = pds.calls.createAppPassword[0]!; - expect(apCall.pdsUrl).toBe("https://pds.test"); - expect(apCall.accessJwt).toBe("AT"); - expect(apCall.name).toContain("ss1"); - - // encrypted_password column re-decrypts to the MINTED app password, - // not the user's supplied password. - expect(row!.encryptedPassword).toBeTruthy(); - const decryptedPw = await cipher.decryptString(row!.encryptedPassword!); - expect(decryptedPw).toBe(mintedPassword); - expect(decryptedPw).not.toBe(userPassword); - - // Subordinate rotation private JWK persisted in encrypted_rotation_key - // must NOT decrypt to anything containing the caller's did:key fingerprint. - expect(row!.encryptedRotationKey).toBeTruthy(); - const decryptedRot = await cipher.decryptString(row!.encryptedRotationKey!); - expect(decryptedRot).not.toContain(callerRotationDidKey); - - // Negative invariant: caller's did:key must not appear in any encrypted - // column (after decryption). - const encryptedSigning = row!.encryptedSigningKey; - if (encryptedSigning) { - const decryptedSig = await cipher.decryptString(encryptedSigning); - expect(decryptedSig).not.toContain(callerRotationDidKey); - } - }); - - it("PLC update op preserves caller's rotation key at index 0", async () => { - // H2 regression guard. The update op (plc.ops[1]) must keep the caller's - // did:key as rotationKeys[0]. Without threading it through - // runUpdateAndActivate, the caller's key is dropped and Contrail's - // subordinate becomes the highest-priority rotation key — caller has 72h - // to nullify before losing rotation authority on a DID they own. - const callerKeyPair = await generateKeyPair(); - const callerRotationDidKey = callerKeyPair.publicDidKey; - - const plc = mockPlc(); - const pds = mockPds(); - - const orch = new ProvisionOrchestrator({ - adapter, - cipher, - plc, - pds, - pdsDid: "did:web:pds.test", - }); - - await orch.provision({ - attemptId: "ss-update", - pdsEndpoint: "https://pds.test", - handle: "h.test", - email: "h@x.test", - password: "pw", - inviteCode: "code", - rotationKey: callerRotationDidKey, - }); - - // Genesis op already asserted in the prior test; here we focus on the update op. - expect(plc.ops.length).toBeGreaterThanOrEqual(2); - const update = plc.ops[1]!.op; - expect(Array.isArray(update.rotationKeys)).toBe(true); - expect(update.rotationKeys[0]).toBe(callerRotationDidKey); - // Contrail's subordinate must remain in the chain (we still need to sign - // future update ops). - expect(update.rotationKeys.length).toBeGreaterThanOrEqual(2); - expect(update.rotationKeys.slice(1)).not.toContain(callerRotationDidKey); - // PDS-recommended key is merged in after the contrail subordinate. - expect(update.rotationKeys).toContain("did:key:zPdsRot"); - }); - - it("createAppPassword failure persists last_error at status=activated and throws (no encryptedPassword)", async () => { - const callerKeyPair = await generateKeyPair(); - const plc = mockPlc(); - const pds = { - ...mockPds(), - async createAppPassword(_: any) { - throw new Error("PDS rejected: rate limited"); - }, - }; - - const orch = new ProvisionOrchestrator({ - adapter, - cipher, - plc, - pds: pds as any, - pdsDid: "did:web:pds.test", - }); - - await expect( - orch.provision({ - attemptId: "ss-fail", - pdsEndpoint: "https://pds.test", - handle: "h.test", - email: "h@x.test", - password: "pw", - rotationKey: callerKeyPair.publicDidKey, - }) - ).rejects.toThrow(/createAppPassword/); - - const row = await adapter.getProvisionAttempt("ss-fail"); - expect(row).toBeTruthy(); - expect(row!.status).toBe("activated"); - expect(row!.encryptedPassword).toBeFalsy(); - expect(row!.lastError).toMatch(/createAppPassword/); - }); - - it("retry with same attemptId after createAppPassword failure picks up at createAppPassword (no re-mint, no re-createAccount)", async () => { - // Simulate the failure-then-retry shape: a first provision call got all - // the way to createAppPassword and failed; the caller retries with the - // same attemptId. The orchestrator must NOT re-submit PLC ops, NOT - // re-call createAccount, only run createAppPassword. - const callerKeyPair = await generateKeyPair(); - const callerDidKey = callerKeyPair.publicDidKey; - const mintedPassword = "minted-on-retry-99"; - - // First attempt: createAppPassword throws; everything else succeeds. - const firstPds = { - ...mockPds(), - async createAppPassword(_: any) { - throw new Error("PDS transient: 503"); - }, - }; - const firstPlc = mockPlc(); - const firstOrch = new ProvisionOrchestrator({ - adapter, - cipher, - plc: firstPlc, - pds: firstPlc && (firstPds as any), - pdsDid: "did:web:pds.test", - }); - await expect( - firstOrch.provision({ - attemptId: "ss-retry", - pdsEndpoint: "https://pds.test", - handle: "h.test", - email: "h@x.test", - password: "user-root-pw", - rotationKey: callerDidKey, - }) - ).rejects.toThrow(); - - // Sanity: row is in the failure state we expect. - const failRow = await adapter.getProvisionAttempt("ss-retry"); - expect(failRow!.status).toBe("activated"); - expect(failRow!.encryptedPassword).toBeFalsy(); - expect(firstPlc.ops.length).toBe(2); // genesis + update - - // Second attempt with the SAME attemptId. Use a fresh mock that would - // EXPLODE if createAccount or any PLC op was re-issued. - const retryPlc = { - ops: [] as Array<{ did: string; op: any }>, - async submit(_did: string, _op: any) { - throw new Error("retry must not re-submit PLC ops"); - }, - }; - const retryAppPasswordCalls: any[] = []; - const retryPds = { - async createAccount() { - throw new Error("retry must not re-call createAccount"); - }, - async getRecommendedDidCredentials() { - throw new Error("retry must not re-fetch recommended creds"); - }, - async activateAccount() { - throw new Error("retry must not re-activate"); - }, - async createAppPassword(input: any) { - retryAppPasswordCalls.push(input); - return { password: mintedPassword }; - }, - async createSession(input: { pdsUrl: string; identifier: string; password: string }) { - // Verify the retry uses the user's root password to obtain a fresh - // accessJwt (the cached one from the failed attempt may have expired). - expect(input.password).toBe("user-root-pw"); - return { accessJwt: "AT-fresh", refreshJwt: "RT-fresh", did: "did:plc:x" }; - }, - }; - const retryOrch = new ProvisionOrchestrator({ - adapter, - cipher, - plc: retryPlc as any, - pds: retryPds as any, - pdsDid: "did:web:pds.test", - }); - - const result = await retryOrch.provision({ - attemptId: "ss-retry", - pdsEndpoint: "https://pds.test", - handle: "h.test", - email: "h@x.test", - password: "user-root-pw", - rotationKey: callerDidKey, - }); - - // Retry succeeded. - expect(result.status).toBe("activated"); - expect(result.did).toBe(failRow!.did); // SAME DID, not a new one - expect(result.rootCredentials).toBeDefined(); - expect(retryAppPasswordCalls.length).toBe(1); - - // Row now has the encrypted (minted) password. - const finalRow = await adapter.getProvisionAttempt("ss-retry"); - expect(finalRow!.status).toBe("activated"); - expect(finalRow!.encryptedPassword).toBeTruthy(); - const decrypted = await cipher.decryptString(finalRow!.encryptedPassword!); - expect(decrypted).toBe(mintedPassword); - }); - - it("rejects rotationKey that is not did:key:z…", async () => { - const orch = new ProvisionOrchestrator({ - adapter, - cipher, - plc: mockPlc(), - pds: mockPds(), - pdsDid: "did:web:pds.test", - }); - - await expect( - orch.provision({ - attemptId: "bad1", - pdsEndpoint: "https://pds.test", - handle: "h.test", - email: "h@x.test", - password: "p", - rotationKey: "not-a-did-key", - }) - ).rejects.toThrow(/rotationKey/); - }); -}); diff --git a/packages/contrail-community/tests/realtime-community.test.ts b/packages/contrail-community/tests/realtime-community.test.ts deleted file mode 100644 index c6c6b7a..0000000 --- a/packages/contrail-community/tests/realtime-community.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** Realtime + community integration test. Lives here (not in contrail) so - * contrail's package.json doesn't have to dev-depend on contrail-community - * (which would create a build-graph cycle in turbo). - * - * Tests that `community:<did>` topics expand to the caller's reachable - * community spaces — the cross-cutting concern that needs both modules. */ - -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; -import { - createApp, - initSchema, - resolveConfig, - type ContrailConfig, -} from "@atmo-dev/contrail"; -import { createCommunityIntegration } from "../src/integration"; - -const ALICE = "did:plc:alice"; -const CHARLIE = "did:plc:charlie"; - -const MASTER_KEY = new Uint8Array(32).fill(5); -const REALTIME_SECRET = new Uint8Array(32).fill(9); - -const CONFIG: ContrailConfig = { - namespace: "test.rt", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: "did:web:test.example#svc", - }, - recordHost: {}, - }, - community: { - masterKey: MASTER_KEY, - plcDirectory: "https://plc.test", - fetch: mockFetch, - resolver: mockResolver(), - }, - realtime: { - ticketSecret: REALTIME_SECRET, - keepaliveMs: 60_000, - }, -}; - -function mockResolver(): any { - return { - resolve: async (_did: string) => ({ - id: _did, - service: [ - { id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.test" }, - ], - }), - }; -} - -async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/xrpc/com.atproto.server.createSession")) { - return new Response( - JSON.stringify({ accessJwt: "a.b.c", refreshJwt: "r.r.r", did: "did:plc:community" }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - if (url.startsWith("https://plc.test/")) return new Response("{}", { status: 200 }); - return new Response("not found", { status: 404 }); -} - -function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { issuer: did, audience: CONFIG.spaces!.authority!.serviceDid, lxm: undefined }); - await next(); - }; -} - -async function makeApp(): Promise<Hono> { - const db = createSqliteDatabase(":memory:"); - const resolved = resolveConfig(CONFIG); - const community = createCommunityIntegration({ db, config: resolved }); - await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); - return createApp(db, resolved, { - spaces: { authMiddleware: fakeAuth() }, - community, - }); -} - -function call( - app: Hono, - method: string, - path: string, - did: string | null, - body?: any -): Promise<Response> { - const headers: Record<string, string> = {}; - if (did) headers["X-Test-Did"] = did; - if (body !== undefined) headers["Content-Type"] = "application/json"; - return app.fetch( - new Request(`http://localhost${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) - ); -} - -describe("realtime + community", () => { - let app: Hono; - beforeAll(async () => { - app = await makeApp(); - }); - - it("community:<did> alias expands to reachable spaces", async () => { - // Adopt a community, create a child space, grant Charlie member. - const adoptRes = await call(app, "POST", "/xrpc/test.rt.community.adopt", ALICE, { - identifier: "did:plc:community", - appPassword: "anything", // mockFetch returns 200 for createSession - }); - expect(adoptRes.status).toBe(200); - const { communityDid } = (await adoptRes.json()) as any; - - const c1 = await call(app, "POST", "/xrpc/test.rt.community.space.create", ALICE, { - communityDid, - key: "general", - }); - expect(c1.status).toBe(200); - const general = ((await c1.json()) as any).space.uri as string; - - // Grant Charlie as member in general. - const g = await call(app, "POST", "/xrpc/test.rt.community.space.grant", ALICE, { - spaceUri: general, - subject: { did: CHARLIE }, - accessLevel: "member", - }); - expect(g.status).toBe(200); - - // Charlie mints a community-alias ticket; should expand to [space:general]. - const ticketRes = await call(app, "POST", "/xrpc/test.rt.realtime.ticket", CHARLIE, { - topic: `community:${communityDid}`, - }); - expect(ticketRes.status).toBe(200); - const body = (await ticketRes.json()) as any; - expect(body.topics).toContain(`space:${general}`); - }); -}); diff --git a/packages/contrail-community/tests/schema.test.ts b/packages/contrail-community/tests/schema.test.ts deleted file mode 100644 index e5e52f5..0000000 --- a/packages/contrail-community/tests/schema.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; -import { initCommunitySchema } from "../src/schema"; - -describe("provision_attempts schema", () => { - it("enforces status enum", async () => { - const db = createSqliteDatabase(":memory:"); - await initCommunitySchema(db); - await expect( - db - .prepare( - "INSERT INTO provision_attempts (attempt_id, did, status, created_at, updated_at, pds_endpoint, handle, email) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" - ) - .bind("a1", "did:plc:x", "bogus", 1, 1, "https://pds", "h.test", "x@x") - .run() - ).rejects.toThrow(); - }); -}); diff --git a/packages/contrail-community/tests/service-auth.test.ts b/packages/contrail-community/tests/service-auth.test.ts deleted file mode 100644 index 199e581..0000000 --- a/packages/contrail-community/tests/service-auth.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { mintServiceAuthJwt } from "../src/service-auth"; -import { generateKeyPair } from "../src/plc"; - -function b64urlDecode(s: string): Uint8Array { - const normal = s.replace(/-/g, "+").replace(/_/g, "/"); - const padded = normal + "=".repeat((4 - (normal.length % 4)) % 4); - const bin = atob(padded); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -function decodeJwtJson(seg: string): Record<string, unknown> { - return JSON.parse(new TextDecoder().decode(b64urlDecode(seg))); -} - -/** P-256 curve order; used for low-S threshold. */ -const P256_N = BigInt( - "0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551" -); -const P256_N_HALF = P256_N >> 1n; - -function bytesToBigInt(b: Uint8Array): bigint { - let v = 0n; - for (const byte of b) v = (v << 8n) | BigInt(byte); - return v; -} - -describe("mintServiceAuthJwt", () => { - it("round-trips: signature verifies against the keypair's public key (P1363, not DER)", async () => { - // This is the canary: if the signer accidentally returns DER instead of raw r||s, - // Web Crypto's verify with raw form will reject it — and so will atproto PDSes. - const kp = await generateKeyPair(); - const jwt = await mintServiceAuthJwt({ - privateJwk: kp.privateJwk, - iss: "did:plc:abc", - aud: "did:web:pds.test", - lxm: "com.atproto.server.createAccount", - }); - - // Reconstruct the public JWK from the private JWK (drop d, key_ops, ext). - const priv = kp.privateJwk as Record<string, unknown>; - const publicJwk: JsonWebKey = { - kty: priv.kty as string, - crv: priv.crv as string, - x: priv.x as string, - y: priv.y as string, - }; - const publicKey = await crypto.subtle.importKey( - "jwk", - publicJwk, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["verify"] - ); - - const [h, p, s] = jwt.split("."); - const signedBytes = new TextEncoder().encode(`${h}.${p}`); - const sig = b64urlDecode(s!); - - // P1363 form is exactly 64 bytes for P-256. DER would be ~70-72 and start with 0x30. - expect(sig.length).toBe(64); - - const ok = await crypto.subtle.verify( - { name: "ECDSA", hash: "SHA-256" }, - publicKey, - sig as BufferSource, - signedBytes as BufferSource - ); - expect(ok).toBe(true); - }); - - it("emits low-S signatures across many independent signings", async () => { - // Without normalization, ~50% of ECDSA signatures have s > n/2. - // Across 12 fresh keypairs, the probability of *all* being naturally low-S is - // ~1/4096. If the test ever sees a high-S signature, normalization is broken. - const kp = await generateKeyPair(); - for (let i = 0; i < 12; i++) { - const jwt = await mintServiceAuthJwt({ - privateJwk: kp.privateJwk, - iss: "did:plc:abc", - aud: "did:web:pds.test", - lxm: "com.atproto.server.createAccount", - // Force a fresh signature each iteration; ECDSA k is randomized per sign. - }); - const sig = b64urlDecode(jwt.split(".")[2]!); - const s = bytesToBigInt(sig.slice(32)); - expect(s).toBeLessThanOrEqual(P256_N_HALF); - } - }); - - it("encodes header and claims as base64url JSON with the expected shape", async () => { - const kp = await generateKeyPair(); - const fixedNow = 1_700_000_000_000; - const jwt = await mintServiceAuthJwt({ - privateJwk: kp.privateJwk, - iss: "did:plc:abc", - aud: "did:web:pds.test", - lxm: "com.atproto.server.createAccount", - ttlSec: 60, - now: fixedNow, - }); - - const [h, p] = jwt.split("."); - const header = decodeJwtJson(h!); - const payload = decodeJwtJson(p!); - - // Header MUST be exactly {alg,typ}; presence of kid would change the - // signed bytes and break PDS verification (which doesn't use kid). - expect(header).toEqual({ alg: "ES256", typ: "JWT" }); - - expect(payload.iss).toBe("did:plc:abc"); - expect(payload.aud).toBe("did:web:pds.test"); - expect(payload.lxm).toBe("com.atproto.server.createAccount"); - expect(payload.iat).toBe(Math.floor(fixedNow / 1000)); - expect(payload.exp).toBe(Math.floor(fixedNow / 1000) + 60); - expect(typeof payload.jti).toBe("string"); - expect((payload.jti as string).length).toBeGreaterThan(0); - }); - - it("uses unique jti per call (replay-protection sanity)", async () => { - const kp = await generateKeyPair(); - const mk = () => - mintServiceAuthJwt({ - privateJwk: kp.privateJwk, - iss: "did:plc:abc", - aud: "did:web:pds.test", - lxm: "com.atproto.server.createAccount", - }); - const a = decodeJwtJson((await mk()).split(".")[1]!); - const b = decodeJwtJson((await mk()).split(".")[1]!); - expect(a.jti).not.toBe(b.jti); - }); -}); diff --git a/packages/contrail-community/tsconfig.build.json b/packages/contrail-community/tsconfig.build.json deleted file mode 100644 index 6092abf..0000000 --- a/packages/contrail-community/tsconfig.build.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/packages/contrail-community/tsconfig.json b/packages/contrail-community/tsconfig.json deleted file mode 100644 index 6092abf..0000000 --- a/packages/contrail-community/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/packages/contrail-community/tsup.config.ts b/packages/contrail-community/tsup.config.ts deleted file mode 100644 index cbfbc7e..0000000 --- a/packages/contrail-community/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/index.ts", "src/cli/index.ts"], - format: ["esm"], - dts: true, - sourcemap: true, - clean: true, - tsconfig: "tsconfig.build.json", - external: ["@atmo-dev/contrail", "wrangler"], -}); diff --git a/packages/contrail-community/vitest.config.ts b/packages/contrail-community/vitest.config.ts deleted file mode 100644 index a440f50..0000000 --- a/packages/contrail-community/vitest.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineConfig } from "vitest/config"; -import path from "node:path"; - -const contrailSrc = path.resolve(__dirname, "../contrail/src"); -const baseSrc = path.resolve(__dirname, "../contrail-base/src"); - -// Resolve workspace-internal imports to source so tests don't run through -// dists (where tsup mangles `node:sqlite` → `sqlite`). -export default defineConfig({ - resolve: { - alias: { - "@atmo-dev/contrail-base/sqlite": path.join(baseSrc, "adapters/sqlite.ts"), - "@atmo-dev/contrail-base/postgres": path.join(baseSrc, "adapters/postgres.ts"), - "@atmo-dev/contrail-base": path.join(baseSrc, "index.ts"), - "@atmo-dev/contrail/sqlite": path.join(contrailSrc, "adapters/sqlite.ts"), - "@atmo-dev/contrail/postgres": path.join(contrailSrc, "adapters/postgres.ts"), - "@atmo-dev/contrail": path.join(contrailSrc, "index.ts"), - }, - }, -}); diff --git a/packages/contrail-record-host/CHANGELOG.md b/packages/contrail-record-host/CHANGELOG.md deleted file mode 100644 index 76e3524..0000000 --- a/packages/contrail-record-host/CHANGELOG.md +++ /dev/null @@ -1,80 +0,0 @@ -# @atmo-dev/contrail-record-host - -## 0.12.2 - -### Patch Changes - -- @atmo-dev/contrail-base@0.12.2 - -## 0.12.1 - -### Patch Changes - -- 74a2d3d: Make NSID-keyed collections work through normal ingestion, not just FTS. - - When a collection is keyed directly by its NSID (no short alias, `collection` - field omitted), the value defaulted to `undefined` everywhere it was read. The - records insert and FTS sync were patched via `resolveCollectionKey`, but the - real ingestion entry points still skipped these collections: `getCollectionNsids` - / `getDiscoverableNsids` / `getDependentNsids` produced `undefined` NSIDs (so - Jetstream never subscribed and backfill never ran), `shortNameForNsid` returned - undefined (so `notify` rejected the URI as "collection not tracked"), and - `validateConfig` rejected the config outright (missing `collection`, dotted key - failing short-name validation). - - `CollectionConfig.collection` is now optional. `resolveConfig` normalizes an - omitted `collection` to the map key, `validateConfig` accepts NSID-keyed entries, - and every collection-list / lookup helper resolves the NSID as `collection ?? key` - so the behavior is correct on both raw and resolved configs. - </content> - -- Updated dependencies [833a659] -- Updated dependencies [74a2d3d] -- Updated dependencies [9894787] - - @atmo-dev/contrail-base@0.12.1 - -## 0.12.0 - -### Patch Changes - -- Updated dependencies [6b34d87] - - @atmo-dev/contrail-base@0.12.0 - -## 0.11.0 - -### Patch Changes - -- @atmo-dev/contrail-base@0.11.0 - -## 0.10.0 - -### Patch Changes - -- Updated dependencies [89aee1b] - - @atmo-dev/contrail-base@0.10.0 - -## 0.9.1 - -### Patch Changes - -- Updated dependencies [4c8fedb] - - @atmo-dev/contrail-base@0.9.1 - -## 0.9.0 - -### Patch Changes - -- @atmo-dev/contrail-base@0.9.0 - -## 0.8.0 - -### Patch Changes - -- Updated dependencies [d7e0936] - - @atmo-dev/contrail-base@0.8.0 - -## 0.7.0 - -### Patch Changes - -- @atmo-dev/contrail-base@0.7.0 diff --git a/packages/contrail-record-host/package.json b/packages/contrail-record-host/package.json deleted file mode 100644 index fd21d45..0000000 --- a/packages/contrail-record-host/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@atmo-dev/contrail-record-host", - "version": "0.12.2", - "description": "Default record-host implementation for contrail — stores records and blobs for permissioned spaces, enforces local enrollment as the host's consent layer.", - "type": "module", - "sideEffects": false, - "files": [ - "dist" - ], - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "repository": { - "type": "git", - "url": "https://github.com/flo-bit/contrail.git", - "directory": "packages/contrail-record-host" - }, - "keywords": [ - "atproto", - "contrail" - ], - "scripts": { - "build": "tsup", - "clean": "rm -rf dist", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@atcute/cid": "^2.4.1", - "@atmo-dev/contrail-base": "workspace:*", - "hono": "^4.12.8" - }, - "devDependencies": { - "tsup": "^8.5.0", - "typescript": "^5.7.3" - }, - "license": "MIT" -} diff --git a/packages/contrail-record-host/src/adapter.ts b/packages/contrail-record-host/src/adapter.ts deleted file mode 100644 index 8f9ed33..0000000 --- a/packages/contrail-record-host/src/adapter.ts +++ /dev/null @@ -1,530 +0,0 @@ -/** Default {@link RecordHost} implementation backed by a Database. - * - * Owns the host-side tables — `spaces_records_<short>` (per-collection), - * `spaces_blobs`, `record_host_enrollments` — and exposes record CRUD, - * blob metadata management, enrollment, and `findOrphanBlobs` for GC. - * - * Independent of the authority adapter: takes a Database directly, doesn't - * inherit from anything. Bundles that want a single adapter satisfying both - * roles instantiate this alongside HostedAuthorityAdapter against the same - * DB. The duplication of the underlying tables is fine — `IF NOT EXISTS` - * guards make schema application idempotent. */ - -import type { - ContrailConfig, - Database, - RelationConfig, - ResolvedContrailConfig, - RecordHost, - BlobMetaRow, - CollectionCount, - EnrollmentRow, - ListBlobsOptions, - ListBlobsResult, - ListOptions, - ListResult, - StoredRecord, -} from "@atmo-dev/contrail-base"; -import { - shortNameForNsid, - spacesRecordsTableName, - countColumnName, - groupedCountColumnName, - getRelationField, - getNestedValue, - getDialect, - buildRecordUri, -} from "@atmo-dev/contrail-base"; - -function parseJson<T>(value: unknown): T | null { - if (value == null) return null; - if (typeof value === "string") { - try { - return JSON.parse(value) as T; - } catch { - return null; - } - } - return value as T; -} - -function toNum(v: unknown): number { - return typeof v === "string" ? Number(v) : (v as number); -} - -export function mapBlobMetaRow(row: any): BlobMetaRow { - return { - spaceUri: row.space_uri, - cid: row.cid, - mimeType: row.mime_type, - size: Number(row.size), - authorDid: row.author_did, - createdAt: toNum(row.created_at), - }; -} - -export function mapEnrollmentRow(row: any): EnrollmentRow { - return { - spaceUri: row.space_uri, - authorityDid: row.authority_did, - enrolledAt: toNum(row.enrolled_at), - enrolledBy: row.enrolled_by, - }; -} - -/** Row mapper for per-collection spaces_records_<short> tables. - * `collection` is injected by the caller (known from the table name). */ -export function mapRecordRow(row: any, collection: string): StoredRecord { - return { - spaceUri: row.space_uri, - collection, - authorDid: row.did, - rkey: row.rkey, - cid: row.cid ?? null, - record: parseJson<Record<string, unknown>>(row.record) ?? {}, - createdAt: toNum(row.time_us), - }; -} - -export class HostedRecordHostAdapter implements RecordHost { - constructor( - protected readonly db: Database, - protected readonly config?: ContrailConfig - ) {} - - /** Resolve the per-collection spaces table name, or throw if the collection - * isn't configured (and therefore has no table). */ - protected tableFor(collection: string): string { - if (!this.config) { - throw new Error( - `HostedRecordHostAdapter: config not provided; cannot resolve table for collection ${collection}` - ); - } - const short = shortNameForNsid(this.config, collection); - if (!short) { - throw new Error( - `HostedRecordHostAdapter: collection ${collection} is not configured in this deployment` - ); - } - return spacesRecordsTableName(short); - } - - // ---- Enrollment ---- - - async enroll(input: EnrollmentRow): Promise<void> { - await this.db - .prepare( - `INSERT INTO record_host_enrollments (space_uri, authority_did, enrolled_at, enrolled_by) - VALUES (?, ?, ?, ?) - ON CONFLICT (space_uri) DO UPDATE SET - authority_did = excluded.authority_did, - enrolled_at = excluded.enrolled_at, - enrolled_by = excluded.enrolled_by` - ) - .bind(input.spaceUri, input.authorityDid, input.enrolledAt, input.enrolledBy) - .run(); - } - - async getEnrollment(spaceUri: string): Promise<EnrollmentRow | null> { - const row = await this.db - .prepare(`SELECT * FROM record_host_enrollments WHERE space_uri = ?`) - .bind(spaceUri) - .first<any>(); - return row ? mapEnrollmentRow(row) : null; - } - - async listEnrollments( - options: { authorityDid?: string; limit?: number } = {} - ): Promise<EnrollmentRow[]> { - const limit = Math.min(options.limit ?? 200, 1000); - if (options.authorityDid) { - const { results } = await this.db - .prepare( - `SELECT * FROM record_host_enrollments WHERE authority_did = ? ORDER BY enrolled_at DESC LIMIT ?` - ) - .bind(options.authorityDid, limit) - .all<any>(); - return results.map(mapEnrollmentRow); - } - const { results } = await this.db - .prepare(`SELECT * FROM record_host_enrollments ORDER BY enrolled_at DESC LIMIT ?`) - .bind(limit) - .all<any>(); - return results.map(mapEnrollmentRow); - } - - async removeEnrollment(spaceUri: string): Promise<void> { - await this.db - .prepare(`DELETE FROM record_host_enrollments WHERE space_uri = ?`) - .bind(spaceUri) - .run(); - } - - // ---- Records ---- - - async putRecord(record: StoredRecord): Promise<void> { - const table = this.tableFor(record.collection); - const uri = buildRecordUri(record.authorDid, record.collection, record.rkey); - - const childShort = this.config ? shortNameForNsid(this.config, record.collection) : null; - const prev = childShort - ? await this.db - .prepare(`SELECT record FROM ${table} WHERE space_uri = ? AND did = ? AND rkey = ?`) - .bind(record.spaceUri, record.authorDid, record.rkey) - .first<{ record: unknown } | null>() - : null; - const beforeRecord = parseJson<Record<string, unknown>>(prev?.record ?? null); - - await this.db - .prepare( - `INSERT INTO ${table} (space_uri, uri, did, rkey, cid, record, time_us, indexed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (space_uri, did, rkey) DO UPDATE SET - uri = excluded.uri, - cid = excluded.cid, - record = excluded.record, - time_us = excluded.time_us, - indexed_at = excluded.indexed_at` - ) - .bind( - record.spaceUri, - uri, - record.authorDid, - record.rkey, - record.cid, - JSON.stringify(record.record), - record.createdAt, - Date.now() - ) - .run(); - - if (childShort && this.config) { - await this.recountParentsForSpace( - record.spaceUri, - childShort, - beforeRecord, - record.record, - record.authorDid - ); - } - } - - async getRecord( - spaceUri: string, - collection: string, - authorDid: string, - rkey: string - ): Promise<StoredRecord | null> { - const table = this.tableFor(collection); - const row = await this.db - .prepare( - `SELECT * FROM ${table} - WHERE space_uri = ? AND did = ? AND rkey = ?` - ) - .bind(spaceUri, authorDid, rkey) - .first<any>(); - return row ? mapRecordRow(row, collection) : null; - } - - async listRecords( - spaceUri: string, - collection: string, - options: ListOptions = {} - ): Promise<ListResult> { - const table = this.tableFor(collection); - const limit = Math.min(options.limit ?? 50, 200); - const clauses: string[] = ["space_uri = ?"]; - const params: any[] = [spaceUri]; - - if (options.byUser) { - clauses.push("did = ?"); - params.push(options.byUser); - } - if (options.cursor) { - clauses.push("time_us < ?"); - params.push(Number(options.cursor)); - } - - const sql = `SELECT * FROM ${table} - WHERE ${clauses.join(" AND ")} - ORDER BY time_us DESC - LIMIT ?`; - params.push(limit + 1); - - const { results } = await this.db.prepare(sql).bind(...params).all<any>(); - const records = results.map((r) => mapRecordRow(r, collection)); - let cursor: string | undefined; - if (records.length > limit) { - const next = records.pop()!; - cursor = String(next.createdAt); - } - return { records, cursor }; - } - - async deleteRecord( - spaceUri: string, - collection: string, - authorDid: string, - rkey: string - ): Promise<void> { - const table = this.tableFor(collection); - - const childShort = this.config ? shortNameForNsid(this.config, collection) : null; - const prev = childShort - ? await this.db - .prepare(`SELECT record FROM ${table} WHERE space_uri = ? AND did = ? AND rkey = ?`) - .bind(spaceUri, authorDid, rkey) - .first<{ record: unknown } | null>() - : null; - const beforeRecord = parseJson<Record<string, unknown>>(prev?.record ?? null); - - await this.db - .prepare( - `DELETE FROM ${table} - WHERE space_uri = ? AND did = ? AND rkey = ?` - ) - .bind(spaceUri, authorDid, rkey) - .run(); - - if (childShort && this.config) { - await this.recountParentsForSpace(spaceUri, childShort, beforeRecord, null, authorDid); - } - } - - /** Recompute count columns on parent records in the same space, scoped to the - * targets derived from before/after versions of the written/deleted child record. */ - protected async recountParentsForSpace( - spaceUri: string, - childShort: string, - before: Record<string, unknown> | null, - after: Record<string, unknown> | null, - childDid: string - ): Promise<void> { - if (!this.config) return; - const config = this.config; - const resolved = (config as ResolvedContrailConfig)._resolved; - const childTable = spacesRecordsTableName(childShort); - - type Inbound = { parentShort: string; relationName: string; rel: RelationConfig }; - const inbound: Inbound[] = []; - for (const [parentShort, parentCfg] of Object.entries(config.collections)) { - if (parentCfg.allowInSpaces === false) continue; - for (const [relName, rel] of Object.entries(parentCfg.relations ?? {})) { - if (rel.count === false) continue; - if (rel.collection !== childShort) continue; - inbound.push({ parentShort, relationName: relName, rel }); - } - } - if (inbound.length === 0) return; - - // Deduplicate (parent, relation, target) across before/after. - const keyed = new Map<string, { parentShort: string; relationName: string; rel: RelationConfig; target: string }>(); - for (const { parentShort, relationName, rel } of inbound) { - const field = getRelationField(rel); - const collectTarget = (rec: Record<string, unknown> | null) => { - if (!rec) return; - if (rel.match === "did") { - keyed.set(`${parentShort}:${relationName}:${childDid}`, { - parentShort, relationName, rel, target: childDid, - }); - return; - } - const v = getNestedValue(rec, field); - if (typeof v === "string" && v.length > 0) { - keyed.set(`${parentShort}:${relationName}:${v}`, { - parentShort, relationName, rel, target: v, - }); - } - }; - collectTarget(before); - collectTarget(after); - } - if (keyed.size === 0) return; - - const dialect = getDialect(this.db); - const stmts: ReturnType<Database["prepare"]>[] = []; - - for (const { parentShort, relationName, rel, target } of keyed.values()) { - const parentTable = spacesRecordsTableName(parentShort); - const matchColumn = rel.match === "did" ? "did" : "uri"; - const field = getRelationField(rel); - const countExpr = rel.countDistinct - ? `COUNT(DISTINCT ${rel.countDistinct})` - : "COUNT(*)"; - - const setClauses: string[] = []; - const binds: (string | number)[] = []; - - const totalCol = countColumnName(rel.collection); - setClauses.push( - `${totalCol} = (SELECT ${countExpr} FROM ${childTable} WHERE space_uri = ? AND ${dialect.jsonExtract("record", field)} = ?)` - ); - binds.push(spaceUri, target); - - if (rel.groupBy) { - const mapping = resolved?.relations[parentShort]?.[relationName]; - if (mapping?.groups) { - for (const [groupKey, fullToken] of Object.entries(mapping.groups)) { - const groupCol = groupedCountColumnName(rel.collection, groupKey); - setClauses.push( - `${groupCol} = (SELECT ${countExpr} FROM ${childTable} WHERE space_uri = ? AND ${dialect.jsonExtract("record", field)} = ? AND ${dialect.jsonExtract("record", rel.groupBy)} = ?)` - ); - binds.push(spaceUri, target, fullToken); - } - } - } - - binds.push(spaceUri, target); - stmts.push( - this.db - .prepare( - `UPDATE ${parentTable} SET ${setClauses.join(", ")} WHERE space_uri = ? AND ${matchColumn} = ?` - ) - .bind(...binds) - ); - } - - if (stmts.length > 0) await this.db.batch(stmts); - } - - async listCollections( - spaceUri: string, - options: { byUser?: string } = {} - ): Promise<CollectionCount[]> { - if (!this.config) return []; - const results: CollectionCount[] = []; - for (const [short, colConfig] of Object.entries(this.config.collections)) { - if (colConfig.allowInSpaces === false) continue; - const table = spacesRecordsTableName(short); - const clauses: string[] = ["space_uri = ?"]; - const params: any[] = [spaceUri]; - if (options.byUser) { - clauses.push("did = ?"); - params.push(options.byUser); - } - try { - const row = await this.db - .prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE ${clauses.join(" AND ")}`) - .bind(...params) - .first<{ count: number }>(); - const count = Number(row?.count ?? 0); - if (count > 0) results.push({ collection: colConfig.collection ?? short, count }); - } catch { - // table doesn't exist (collection added after init, or allowInSpaces toggled) — skip - } - } - return results; - } - - // ---- Blobs ---- - - async putBlobMeta(row: BlobMetaRow): Promise<void> { - const sql = `INSERT INTO spaces_blobs (space_uri, cid, mime_type, size, author_did, created_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT (space_uri, cid) DO NOTHING`; - await this.db - .prepare(sql) - .bind(row.spaceUri, row.cid, row.mimeType, row.size, row.authorDid, row.createdAt) - .run(); - } - - async getBlobMeta(spaceUri: string, cid: string): Promise<BlobMetaRow | null> { - const r = await this.db - .prepare(`SELECT * FROM spaces_blobs WHERE space_uri = ? AND cid = ?`) - .bind(spaceUri, cid) - .first<any>(); - return r ? mapBlobMetaRow(r) : null; - } - - async listBlobMeta( - spaceUri: string, - options: ListBlobsOptions = {} - ): Promise<ListBlobsResult> { - const limit = Math.min(options.limit ?? 50, 200); - const clauses: string[] = ["space_uri = ?"]; - const params: any[] = [spaceUri]; - if (options.byUser) { - clauses.push("author_did = ?"); - params.push(options.byUser); - } - if (options.cursor) { - clauses.push("created_at < ?"); - params.push(Number(options.cursor)); - } - const sql = `SELECT * FROM spaces_blobs - WHERE ${clauses.join(" AND ")} - ORDER BY created_at DESC - LIMIT ?`; - params.push(limit + 1); - const { results } = await this.db.prepare(sql).bind(...params).all<any>(); - const blobs = results.map(mapBlobMetaRow); - let cursor: string | undefined; - if (blobs.length > limit) { - const next = blobs.pop()!; - cursor = String(next.createdAt); - } - return { blobs, cursor }; - } - - async deleteBlobMeta(spaceUri: string, cid: string): Promise<void> { - await this.db - .prepare(`DELETE FROM spaces_blobs WHERE space_uri = ? AND cid = ?`) - .bind(spaceUri, cid) - .run(); - } - - async findOrphanBlobs( - spaceUri: string, - cutoff: number, - limit: number - ): Promise<BlobMetaRow[]> { - if (!this.config) return []; - // Gather candidate blobs older than cutoff, then filter out any whose CID - // appears in any record JSON in this space. We use a cheap substring probe - // (LIKE) per collection — false positives are OK because an orphan that - // survives GC just gets collected next cycle; false negatives (deleting - // a referenced blob) would be a bug, and substring search over the full - // CID is safe enough for that. - const { results } = await this.db - .prepare( - `SELECT * FROM spaces_blobs - WHERE space_uri = ? AND created_at < ? - ORDER BY created_at ASC - LIMIT ?` - ) - .bind(spaceUri, cutoff, limit) - .all<any>(); - const candidates = results.map(mapBlobMetaRow); - if (candidates.length === 0) return []; - - const tables: string[] = []; - for (const [short, colConfig] of Object.entries(this.config.collections)) { - if (colConfig.allowInSpaces === false) continue; - tables.push(spacesRecordsTableName(short)); - } - - const orphans: BlobMetaRow[] = []; - for (const blob of candidates) { - let referenced = false; - const pattern = `%${blob.cid}%`; - for (const table of tables) { - try { - const row = await this.db - .prepare( - `SELECT 1 FROM ${table} WHERE space_uri = ? AND record LIKE ? LIMIT 1` - ) - .bind(spaceUri, pattern) - .first<any>(); - if (row) { - referenced = true; - break; - } - } catch { - // table missing — ignore - } - } - if (!referenced) orphans.push(blob); - } - return orphans; - } -} diff --git a/packages/contrail-record-host/src/blob-gc.ts b/packages/contrail-record-host/src/blob-gc.ts deleted file mode 100644 index 19ca37f..0000000 --- a/packages/contrail-record-host/src/blob-gc.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { BlobAdapter, RecordHost } from "@atmo-dev/contrail-base"; -import { blobKey } from "@atmo-dev/contrail-base"; - -export interface BlobGcOptions { - /** Orphan rows created before this timestamp are eligible for deletion. */ - olderThan: number; - /** Maximum number of blobs to delete in this pass. Defaults to 500. */ - batchSize?: number; -} - -export interface BlobGcResult { - deleted: number; - cids: string[]; -} - -/** Delete blob bytes + metadata for any blob older than `olderThan` that - * is not referenced by any record in the space. Safe to run periodically. */ -export async function gcOrphanBlobs( - storage: RecordHost, - blobs: BlobAdapter, - spaceUri: string, - options: BlobGcOptions -): Promise<BlobGcResult> { - const batchSize = options.batchSize ?? 500; - const orphans = await storage.findOrphanBlobs(spaceUri, options.olderThan, batchSize); - if (orphans.length === 0) return { deleted: 0, cids: [] }; - - const keys: string[] = []; - for (const row of orphans) { - keys.push(await blobKey(row.spaceUri, row.cid)); - } - await blobs.delete(keys); - for (const row of orphans) { - await storage.deleteBlobMeta(row.spaceUri, row.cid); - } - return { deleted: orphans.length, cids: orphans.map((o) => o.cid) }; -} diff --git a/packages/contrail-record-host/src/blob-refs.ts b/packages/contrail-record-host/src/blob-refs.ts deleted file mode 100644 index 62cf693..0000000 --- a/packages/contrail-record-host/src/blob-refs.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Walk a record JSON and collect every atproto blob ref. - * - * Blob refs look like: - * { "$type": "blob", "ref": { "$link": "<cid>" }, "mimeType": "...", "size": N } - * - * We return the CID strings. - */ -export function collectBlobCids(value: unknown, out: Set<string> = new Set()): Set<string> { - if (value == null) return out; - if (Array.isArray(value)) { - for (const v of value) collectBlobCids(v, out); - return out; - } - if (typeof value !== "object") return out; - - const obj = value as Record<string, unknown>; - if (obj["$type"] === "blob") { - const ref = obj["ref"] as { $link?: unknown } | undefined; - if (ref && typeof ref["$link"] === "string") out.add(ref["$link"]); - // Don't descend — a blob ref's own shape has no nested blobs. - return out; - } - - for (const v of Object.values(obj)) collectBlobCids(v, out); - return out; -} diff --git a/packages/contrail-record-host/src/index.ts b/packages/contrail-record-host/src/index.ts deleted file mode 100644 index df57f36..0000000 --- a/packages/contrail-record-host/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** @atmo-dev/contrail-record-host — default record-host implementation. - * - * Owns the host-side adapter (records, blobs, enrollment) + DDL + blob-GC. - * Independent of the authority — takes a Database directly, doesn't inherit - * from anything. Bundles can instantiate this alongside HostedAuthorityAdapter - * against the same DB to get full StorageAdapter behavior. */ - -export { - HostedRecordHostAdapter, - mapBlobMetaRow, - mapEnrollmentRow, - mapRecordRow, -} from "./adapter"; - -export { - buildRecordHostBaseSchema, - applyRecordHostSchema, -} from "./schema"; - -export { - gcOrphanBlobs, -} from "./blob-gc"; -export type { BlobGcOptions, BlobGcResult } from "./blob-gc"; - -export { collectBlobCids } from "./blob-refs"; - -export { registerRecordHostRoutes } from "./routes"; - -export { registerRecordHostSyncRoutes } from "./sync"; -export type { RecordHostSyncOptions, SyncEvent } from "./sync"; diff --git a/packages/contrail-record-host/src/routes.ts b/packages/contrail-record-host/src/routes.ts deleted file mode 100644 index 25cd2ab..0000000 --- a/packages/contrail-record-host/src/routes.ts +++ /dev/null @@ -1,508 +0,0 @@ -/** Record-host XRPC routes — record + blob CRUD plus enrollment. - * - * Auth precedence on every route: - * 1. `X-Space-Credential` header (if a verifier is wired and the credential - * is valid) — caller DID = credential `sub`, no clientId. - * 2. Read-route invite token (`?inviteToken=` or `Bearer atmo-invite:...`). - * 3. Service-auth JWT — caller DID = JWT issuer. - * - * When a credential is presented, the record host trusts it: no member - * check, no app-policy check (those happen at issuance time on the - * authority side). Service-auth requests still consult the authority. */ - -import type { Context, Hono, MiddlewareHandler } from "hono"; -import type { - ContrailConfig, - CredentialClaims, - CredentialScope, - CredentialVerifier, - RecordHost, - RecordHostConfig, - ServiceAuth, - SpaceAuthority, -} from "@atmo-dev/contrail-base"; -import { - blobKey, - checkAccess, - checkInviteReadGrant, - DEFAULT_BLOB_MAX_SIZE, - extractInviteToken, - extractSpaceCredential, - hashInviteToken, - nextTid, - parseSpaceUri, -} from "@atmo-dev/contrail-base"; -import { collectBlobCids } from "./blob-refs"; -import { create as createCid, toString as cidToString } from "@atcute/cid"; - -export function registerRecordHostRoutes( - app: Hono, - recordHost: RecordHost, - authority: SpaceAuthority, - recordHostConfig: RecordHostConfig, - config: ContrailConfig, - auth: MiddlewareHandler, - /** Optional credential verifier. When present, the record host accepts - * `X-Space-Credential` as an alternative to a service-auth JWT. */ - credentialVerifier?: CredentialVerifier -): void { - const SPACE = `${config.namespace}.space`; - - /** Auth wrapper: tries credential first, then delegates to JWT auth. */ - const authWithCredential: MiddlewareHandler = async (c, next) => { - const credToken = extractSpaceCredential(c.req.raw); - if (credToken) { - if (!credentialVerifier) { - return c.json( - { error: "AuthRequired", reason: "credential-verifier-not-configured" }, - 401 - ); - } - const result = await credentialVerifier.verify(credToken); - if (!result.ok) { - return c.json({ error: "AuthRequired", reason: result.reason }, 401); - } - c.set("spaceCredential", result.claims); - await next(); - return; - } - return auth(c, next); - }; - - const readAuth: MiddlewareHandler = async (c, next) => { - if (extractInviteToken(c.req.raw)) { - await next(); - return; - } - return authWithCredential(c, next); - }; - - /** Hard gate on every record-host operation: the space must be enrolled. */ - async function requireEnrollment( - c: Context, - spaceUri: string - ): Promise<{ authorityDid: string } | Response> { - const enrollment = await recordHost.getEnrollment(spaceUri); - if (!enrollment) { - return c.json( - { error: "NotFound", reason: "not-enrolled", message: "space is not enrolled on this record host" }, - 404 - ); - } - return enrollment; - } - - // ---- Enrollment endpoint ---- - const RECORD_HOST = `${config.namespace}.recordHost`; - - app.post(`/xrpc/${RECORD_HOST}.enroll`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { spaceUri?: string; authority?: string } - | null; - if (!body?.spaceUri || !body.authority) { - return c.json({ error: "InvalidRequest", message: "spaceUri and authority required" }, 400); - } - const parts = parseSpaceUri(body.spaceUri); - if (!parts) { - return c.json({ error: "InvalidRequest", reason: "malformed-uri" }, 400); - } - if (sa.issuer !== parts.ownerDid) { - return c.json( - { error: "Forbidden", reason: "not-owner" }, - 403 - ); - } - await recordHost.enroll({ - spaceUri: body.spaceUri, - authorityDid: body.authority, - enrolledAt: Date.now(), - enrolledBy: sa.issuer, - }); - return c.json({ ok: true }); - }); - - app.get(`/xrpc/${SPACE}.listRecords`, readAuth, async (c) => { - const spaceUri = c.req.query("spaceUri"); - const collection = c.req.query("collection"); - if (!spaceUri || !collection) { - return c.json({ error: "InvalidRequest", message: "spaceUri and collection required" }, 400); - } - const enrollment = await requireEnrollment(c, spaceUri); - if (enrollment instanceof Response) return enrollment; - - const authz = await authorizeRead(c, authority, spaceUri); - if (authz instanceof Response) return authz; - - if (authz.via === "jwt") { - const sa = authz.sa; - const space = await authority.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - const member = await authority.getMember(spaceUri, sa.issuer); - const result = checkAccess({ - op: "read", - space, - callerDid: sa.issuer, - member, - clientId: sa.clientId, - }); - if (!result.allow) { - return c.json({ error: "Forbidden", reason: result.reason }, 403); - } - } - - const list = await recordHost.listRecords(spaceUri, collection, { - byUser: c.req.query("byUser") ?? undefined, - cursor: c.req.query("cursor") ?? undefined, - limit: c.req.query("limit") ? Number(c.req.query("limit")) : undefined, - }); - return c.json(list); - }); - - app.get(`/xrpc/${SPACE}.getRecord`, readAuth, async (c) => { - const spaceUri = c.req.query("spaceUri"); - const collection = c.req.query("collection"); - const author = c.req.query("author"); - const rkey = c.req.query("rkey"); - if (!spaceUri || !collection || !author || !rkey) { - return c.json({ error: "InvalidRequest", message: "spaceUri, collection, author, rkey required" }, 400); - } - const enrollment = await requireEnrollment(c, spaceUri); - if (enrollment instanceof Response) return enrollment; - - const authz = await authorizeRead(c, authority, spaceUri); - if (authz instanceof Response) return authz; - - if (authz.via === "jwt") { - const sa = authz.sa; - const space = await authority.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - const member = await authority.getMember(spaceUri, sa.issuer); - const result = checkAccess({ - op: "read", - space, - callerDid: sa.issuer, - member, - clientId: sa.clientId, - targetAuthorDid: author, - }); - if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403); - } - - const record = await recordHost.getRecord(spaceUri, collection, author, rkey); - if (!record) return c.json({ error: "NotFound" }, 404); - return c.json({ record }); - }); - - app.post(`/xrpc/${SPACE}.putRecord`, authWithCredential, async (c) => { - const body = (await c.req.json().catch(() => null)) as - | { spaceUri?: string; collection?: string; rkey?: string; record?: Record<string, unknown> } - | null; - if (!body?.spaceUri || !body.collection || !body.record) { - return c.json({ error: "InvalidRequest", message: "spaceUri, collection, record required" }, 400); - } - const enrollment = await requireEnrollment(c, body.spaceUri); - if (enrollment instanceof Response) return enrollment; - - const caller = resolveCaller(c, body.spaceUri, "rw"); - if (caller instanceof Response) return caller; - - if (!caller.viaCredential) { - const space = await authority.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - const member = await authority.getMember(body.spaceUri, caller.callerDid); - const result = checkAccess({ - op: "write", - space, - callerDid: caller.callerDid, - member, - clientId: caller.clientId, - }); - if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403); - } - - if (recordHostConfig.blobs) { - const cids = collectBlobCids(body.record); - for (const cid of cids) { - const meta = await recordHost.getBlobMeta(body.spaceUri, cid); - if (!meta) { - return c.json( - { - error: "InvalidRequest", - reason: "unknown-blob-ref", - message: `Record references blob ${cid} that has not been uploaded to this space.`, - }, - 400 - ); - } - } - } - - const rkey = body.rkey ?? nextTid(); - const now = Date.now(); - await recordHost.putRecord({ - spaceUri: body.spaceUri, - collection: body.collection, - authorDid: caller.callerDid, - rkey, - cid: null, - record: body.record, - createdAt: now, - }); - return c.json({ rkey, authorDid: caller.callerDid, createdAt: now }); - }); - - app.post(`/xrpc/${SPACE}.deleteRecord`, authWithCredential, async (c) => { - const body = (await c.req.json().catch(() => null)) as - | { spaceUri?: string; collection?: string; rkey?: string } - | null; - if (!body?.spaceUri || !body.collection || !body.rkey) { - return c.json({ error: "InvalidRequest", message: "spaceUri, collection, rkey required" }, 400); - } - const enrollment = await requireEnrollment(c, body.spaceUri); - if (enrollment instanceof Response) return enrollment; - - const caller = resolveCaller(c, body.spaceUri, "rw"); - if (caller instanceof Response) return caller; - - if (!caller.viaCredential) { - const space = await authority.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - const member = await authority.getMember(body.spaceUri, caller.callerDid); - const result = checkAccess({ - op: "delete", - space, - callerDid: caller.callerDid, - member, - clientId: caller.clientId, - targetAuthorDid: caller.callerDid, - }); - if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403); - } - - await recordHost.deleteRecord(body.spaceUri, body.collection, caller.callerDid, body.rkey); - return c.json({ ok: true }); - }); - - // Blobs (only registered when a blob adapter is configured) - if (recordHostConfig.blobs) { - const blobsCfg = recordHostConfig.blobs; - const blobAdapter = blobsCfg.adapter; - const maxSize = blobsCfg.maxSize ?? DEFAULT_BLOB_MAX_SIZE; - const accept = blobsCfg.accept; - - app.post(`/xrpc/${SPACE}.uploadBlob`, authWithCredential, async (c) => { - const spaceUri = c.req.query("spaceUri"); - if (!spaceUri) { - return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); - } - const enrollment = await requireEnrollment(c, spaceUri); - if (enrollment instanceof Response) return enrollment; - - const caller = resolveCaller(c, spaceUri, "rw"); - if (caller instanceof Response) return caller; - - if (!caller.viaCredential) { - const space = await authority.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - const member = await authority.getMember(spaceUri, caller.callerDid); - const aclResult = checkAccess({ - op: "write", - space, - callerDid: caller.callerDid, - member, - clientId: caller.clientId, - }); - if (!aclResult.allow) { - return c.json({ error: "Forbidden", reason: aclResult.reason }, 403); - } - } - - const mimeType = c.req.header("content-type") ?? "application/octet-stream"; - if (accept && !accept.includes(mimeType)) { - return c.json( - { error: "InvalidMimeType", message: `MIME type ${mimeType} is not accepted.` }, - 400 - ); - } - - const declaredLen = c.req.header("content-length"); - if (declaredLen && Number(declaredLen) > maxSize) { - return c.json( - { error: "BlobTooLarge", message: `Blob exceeds max size of ${maxSize} bytes.` }, - 413 - ); - } - - const buf = await c.req.arrayBuffer(); - const bytes = new Uint8Array(buf); - if (bytes.byteLength > maxSize) { - return c.json( - { error: "BlobTooLarge", message: `Blob exceeds max size of ${maxSize} bytes.` }, - 413 - ); - } - - const cid = await createCid(0x55, bytes); - const cidString = cidToString(cid); - const key = await blobKey(spaceUri, cidString); - - await blobAdapter.put(key, bytes, { mimeType, size: bytes.byteLength }); - await recordHost.putBlobMeta({ - spaceUri, - cid: cidString, - mimeType, - size: bytes.byteLength, - authorDid: caller.callerDid, - createdAt: Date.now(), - }); - - return c.json({ - blob: { - $type: "blob", - ref: { $link: cidString }, - mimeType, - size: bytes.byteLength, - }, - }); - }); - - app.get(`/xrpc/${SPACE}.getBlob`, readAuth, async (c) => { - const spaceUri = c.req.query("spaceUri"); - const cid = c.req.query("cid"); - if (!spaceUri || !cid) { - return c.json({ error: "InvalidRequest", message: "spaceUri and cid required" }, 400); - } - const enrollment = await requireEnrollment(c, spaceUri); - if (enrollment instanceof Response) return enrollment; - - const authz = await authorizeRead(c, authority, spaceUri); - if (authz instanceof Response) return authz; - - if (authz.via === "jwt") { - const sa = authz.sa; - const space = await authority.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - const member = await authority.getMember(spaceUri, sa.issuer); - const aclResult = checkAccess({ - op: "read", - space, - callerDid: sa.issuer, - member, - clientId: sa.clientId, - }); - if (!aclResult.allow) { - return c.json({ error: "Forbidden", reason: aclResult.reason }, 403); - } - } - - const meta = await recordHost.getBlobMeta(spaceUri, cid); - if (!meta) return c.json({ error: "NotFound" }, 404); - const key = await blobKey(spaceUri, cid); - const bytes = await blobAdapter.get(key); - if (!bytes) return c.json({ error: "NotFound" }, 404); - - return new Response(bytes as BodyInit, { - headers: { - "content-type": meta.mimeType, - "content-length": String(meta.size), - }, - }); - }); - - app.get(`/xrpc/${SPACE}.listBlobs`, authWithCredential, async (c) => { - const spaceUri = c.req.query("spaceUri"); - if (!spaceUri) { - return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); - } - const enrollment = await requireEnrollment(c, spaceUri); - if (enrollment instanceof Response) return enrollment; - - const caller = resolveCaller(c, spaceUri, "read"); - if (caller instanceof Response) return caller; - - if (!caller.viaCredential) { - const space = await authority.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - const member = await authority.getMember(spaceUri, caller.callerDid); - const aclResult = checkAccess({ - op: "read", - space, - callerDid: caller.callerDid, - member, - clientId: caller.clientId, - }); - if (!aclResult.allow) { - return c.json({ error: "Forbidden", reason: aclResult.reason }, 403); - } - } - - const result = await recordHost.listBlobMeta(spaceUri, { - byUser: c.req.query("byUser") ?? undefined, - cursor: c.req.query("cursor") ?? undefined, - limit: c.req.query("limit") ? Number(c.req.query("limit")) : undefined, - }); - return c.json(result); - }); - } -} - -/** Authorize a read request — three valid paths: a verified space credential - * (set by the credential middleware), a read-grant invite token, or a - * service-auth JWT. */ -async function authorizeRead( - c: Context, - authority: SpaceAuthority, - spaceUri: string -): Promise< - | { via: "credential"; claims: CredentialClaims } - | { via: "token" } - | { via: "jwt"; sa: ServiceAuth } - | Response -> { - const cred = c.get("spaceCredential") as CredentialClaims | undefined; - if (cred) { - if (cred.space !== spaceUri) { - return c.json({ error: "Forbidden", reason: "credential-wrong-space" }, 403); - } - return { via: "credential", claims: cred }; - } - const rawToken = extractInviteToken(c.req.raw); - if (rawToken) { - const ok = await checkInviteReadGrant(authority, rawToken, spaceUri, hashInviteToken); - if (!ok) return c.json({ error: "Forbidden", reason: "invalid-invite-token" }, 403); - return { via: "token" }; - } - const sa = c.get("serviceAuth") as ServiceAuth | undefined; - if (sa) return { via: "jwt", sa }; - return c.json( - { error: "AuthRequired", message: "JWT, credential, or read-grant invite token required" }, - 401 - ); -} - -function resolveCaller( - c: Context, - requestSpace: string, - requiredScope: CredentialScope -): { callerDid: string; clientId: string | undefined; viaCredential: boolean } | Response { - const cred = c.get("spaceCredential") as CredentialClaims | undefined; - if (cred) { - if (cred.space !== requestSpace) { - return c.json({ error: "Forbidden", reason: "credential-wrong-space" }, 403); - } - if (requiredScope === "rw" && cred.scope !== "rw") { - return c.json({ error: "Forbidden", reason: "credential-wrong-scope" }, 403); - } - return { callerDid: cred.sub, clientId: undefined, viaCredential: true }; - } - const sa = c.get("serviceAuth") as ServiceAuth | undefined; - if (!sa) return c.json({ error: "AuthRequired", reason: "no-auth" }, 401); - return { callerDid: sa.issuer, clientId: sa.clientId, viaCredential: false }; -} - -function getAuth(c: Context): ServiceAuth { - const auth = c.get("serviceAuth") as ServiceAuth | undefined; - if (!auth) throw new Error("service auth not set"); - return auth; -} diff --git a/packages/contrail-record-host/src/schema.ts b/packages/contrail-record-host/src/schema.ts deleted file mode 100644 index 881d16b..0000000 --- a/packages/contrail-record-host/src/schema.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** Record-host DDL: `spaces_blobs`, `record_host_enrollments`, plus the - * per-collection `spaces_records_<short>` tables. The latter are config- - * driven so they're built via the shared collection-table helper from - * contrail-base, not declared here directly. */ - -import type { Database, ContrailConfig, SqlDialect } from "@atmo-dev/contrail-base"; -import { getDialect } from "@atmo-dev/contrail-base"; - -/** The fixed host tables — independent of the user's collection config. */ -export function buildRecordHostBaseSchema(dialect: SqlDialect): string[] { - return [ - `CREATE TABLE IF NOT EXISTS spaces_blobs ( - space_uri TEXT NOT NULL, - cid TEXT NOT NULL, - mime_type TEXT NOT NULL, - size INTEGER NOT NULL, - author_did TEXT NOT NULL, - created_at ${dialect.bigintType} NOT NULL, - PRIMARY KEY (space_uri, cid) - )`, - `CREATE INDEX IF NOT EXISTS idx_spaces_blobs_author ON spaces_blobs(space_uri, author_did)`, - `CREATE INDEX IF NOT EXISTS idx_spaces_blobs_created ON spaces_blobs(space_uri, created_at)`, - - // Local cache: spaces this host has agreed to store records for, and - // which authority signs credentials for each. Filled by the - // recordHost.enroll endpoint, or auto-populated by the colocated - // authority's createSpace. - `CREATE TABLE IF NOT EXISTS record_host_enrollments ( - space_uri TEXT PRIMARY KEY, - authority_did TEXT NOT NULL, - enrolled_at ${dialect.bigintType} NOT NULL, - enrolled_by TEXT NOT NULL - )`, - `CREATE INDEX IF NOT EXISTS idx_enrollments_authority ON record_host_enrollments(authority_did)`, - ]; -} - -/** SchemaModule-shaped function suitable for `initSchema({ extraSchemas: [...] })`. - * Applies the host tables that don't depend on the collection config. The - * per-collection `spaces_records_<short>` tables are still applied through - * the appview's initSchema path (which knows about collections). */ -export async function applyRecordHostSchema(db: Database): Promise<void> { - const dialect = getDialect(db); - const stmts = buildRecordHostBaseSchema(dialect); - await db.batch(stmts.map((s) => db.prepare(s))); -} - -// Re-export for callers that want a config-driven schema (per-collection -// tables plus blobs/enrollments). Kept as a convenience for split -// deployments where the host doesn't share a DB with the appview. -export type { ContrailConfig }; diff --git a/packages/contrail-record-host/src/sync.ts b/packages/contrail-record-host/src/sync.ts deleted file mode 100644 index 6926824..0000000 --- a/packages/contrail-record-host/src/sync.ts +++ /dev/null @@ -1,331 +0,0 @@ -/** Record-host sync endpoint — streams record events for a specific space. - * - * Two phases per connection: - * 1. **Catch-up**: scan the per-collection `spaces_records_<short>` tables - * for `time_us > since`, emit each row as `record.created`. After each - * batch, emit a `cursor` checkpoint so the client can persist progress. - * 2. **Live**: subscribe to the in-process pubsub for `space:<uri>` and - * forward record.created / record.deleted events. - * - * Phase 7b MVP limitations: - * - Catch-up only sees `record.created`; deletions in the past aren't - * replayed (the row is gone). Live deletions are emitted. - * - Brief race between catch-up end and live subscribe — if a write - * lands in that window, the next reconnect catches it via catch-up. - * - SSE only (no WS). Simpler. WS can be added if needed. - * - * Auth: requires a valid X-Space-Credential whose `space` claim matches - * the requested spaceUri and whose scope is `read` or `rw`. */ - -import type { Hono, MiddlewareHandler } from "hono"; -import type { - ContrailConfig, - CredentialClaims, - CredentialVerifier, - PubSub, - RealtimeEvent, - RecordHost, -} from "@atmo-dev/contrail-base"; -import { - DEFAULT_KEEPALIVE_MS, - extractSpaceCredential, - shortNameForNsid, - spacesRecordsTableName, - spaceTopic, -} from "@atmo-dev/contrail-base"; -import type { Database } from "@atmo-dev/contrail-base"; - -/** Events emitted on the wire. RealtimeEvent kinds (record.created / - * record.deleted) plus our own `cursor` checkpoint. */ -export type SyncEvent = - | RealtimeEvent - | { kind: "cursor"; value: string }; - -export interface RecordHostSyncOptions { - /** Database the host's record tables live on. */ - db: Database; - /** Optional pubsub for live mode. When omitted, sync is catch-up only — - * the stream ends after catch-up rather than tailing for new writes. */ - pubsub?: PubSub | null; - /** Required: verifier for the X-Space-Credential header. */ - credentialVerifier: CredentialVerifier; - /** Page size for catch-up scans. Default 100. */ - batchSize?: number; - /** SSE keepalive interval in ms. Default uses the realtime module's. */ - keepaliveMs?: number; -} - -export function registerRecordHostSyncRoutes( - app: Hono, - recordHost: RecordHost, - config: ContrailConfig, - options: RecordHostSyncOptions -): void { - const RECORD_HOST = `${config.namespace}.recordHost`; - const batchSize = options.batchSize ?? 100; - const keepaliveMs = options.keepaliveMs ?? DEFAULT_KEEPALIVE_MS; - - app.get(`/xrpc/${RECORD_HOST}.sync`, async (c) => { - // ---- Auth: credential required ---- - const credToken = extractSpaceCredential(c.req.raw); - if (!credToken) { - return c.json( - { error: "AuthRequired", reason: "credential-required" }, - 401 - ); - } - const verified = await options.credentialVerifier.verify(credToken); - if (!verified.ok) { - return c.json({ error: "AuthRequired", reason: verified.reason }, 401); - } - const claims = verified.claims; - - const spaceUri = c.req.query("spaceUri"); - if (!spaceUri) { - return c.json( - { error: "InvalidRequest", message: "spaceUri required" }, - 400 - ); - } - if (claims.space !== spaceUri) { - return c.json( - { error: "Forbidden", reason: "credential-wrong-space" }, - 403 - ); - } - - const enrollment = await recordHost.getEnrollment(spaceUri); - if (!enrollment) { - return c.json( - { error: "NotFound", reason: "not-enrolled" }, - 404 - ); - } - - const since = parseSince(c.req.query("since")); - - // Build the SSE response with a hand-rolled stream so we can interleave - // catch-up batches and live events under one cursor sequence. - const ac = new AbortController(); - c.req.raw.signal.addEventListener("abort", () => ac.abort(), { once: true }); - - const stream = new ReadableStream<Uint8Array>({ - async start(controller) { - const enc = new TextEncoder(); - let closed = false; - const close = () => { - if (closed) return; - closed = true; - try { controller.close(); } catch { /* already closed */ } - }; - ac.signal.addEventListener("abort", close, { once: true }); - - const keepalive = setInterval(() => { - if (closed) return; - try { - controller.enqueue(enc.encode(`: keepalive\n\n`)); - } catch { - close(); - } - }, keepaliveMs); - - const writeEvent = (e: SyncEvent) => { - if (closed) return false; - try { - controller.enqueue(enc.encode(frameEvent(e))); - return true; - } catch { - close(); - return false; - } - }; - - try { - controller.enqueue(enc.encode(`: open\n\n`)); - - // ---- Phase 1: catch-up ---- - const lastCursor = await streamCatchup({ - db: options.db, - config, - spaceUri, - since, - batchSize, - writeEvent, - isClosed: () => closed, - }); - if (closed) return; - - // ---- Phase 2: live ---- - if (options.pubsub) { - const liveCutoff = lastCursor ?? since; - for await (const event of options.pubsub.subscribe( - spaceTopic(spaceUri), - ac.signal - )) { - if (closed) break; - // Ignore events older than what catch-up already covered. - const ts = (event as any).payload?.time_us ?? null; - if ( - liveCutoff != null && - typeof ts === "number" && - ts <= liveCutoff - ) { - continue; - } - if ( - event.kind !== "record.created" && - event.kind !== "record.deleted" - ) { - continue; - } - const ok = writeEvent(event); - if (!ok) break; - // Emit a cursor checkpoint after each live event so consumers - // can resume from the latest seen point. - if (typeof ts === "number") { - writeEvent({ kind: "cursor", value: String(ts) }); - } - } - } - } catch (err) { - if (!closed) { - try { - controller.enqueue( - enc.encode( - `event: error\ndata: ${JSON.stringify({ - message: err instanceof Error ? err.message : String(err), - })}\n\n` - ) - ); - } catch { - /* already torn down */ - } - } - } finally { - clearInterval(keepalive); - close(); - } - }, - cancel() { - ac.abort(); - }, - }); - - return new Response(stream, { - status: 200, - headers: { - "content-type": "text/event-stream", - "cache-control": "no-cache, no-transform", - connection: "keep-alive", - "x-accel-buffering": "no", - }, - }); - }); -} - -/** Catch-up phase: scan every configured per-collection spaces_records table - * for rows with `time_us > since`, emit them in time_us order. Returns the - * highest time_us emitted (or null if nothing emitted). */ -async function streamCatchup(args: { - db: Database; - config: ContrailConfig; - spaceUri: string; - since: number | null; - batchSize: number; - writeEvent: (e: SyncEvent) => boolean; - isClosed: () => boolean; -}): Promise<number | null> { - const { db, config, spaceUri, since, batchSize, writeEvent, isClosed } = args; - let highest = since; - - for (const [_short, colCfg] of Object.entries(config.collections)) { - if (colCfg.allowInSpaces === false) continue; - if (isClosed()) return highest; - - const collectionNsid = colCfg.collection ?? _short; - const short = shortNameForNsid(config, collectionNsid); - if (!short) continue; - const table = spacesRecordsTableName(short); - - let cursor = since; - while (true) { - if (isClosed()) return highest; - - let rows: any[]; - try { - const sql = - cursor != null - ? `SELECT * FROM ${table} WHERE space_uri = ? AND time_us > ? ORDER BY time_us LIMIT ?` - : `SELECT * FROM ${table} WHERE space_uri = ? ORDER BY time_us LIMIT ?`; - const result = await (cursor != null - ? db.prepare(sql).bind(spaceUri, cursor, batchSize).all<any>() - : db.prepare(sql).bind(spaceUri, batchSize).all<any>()); - rows = result.results; - } catch { - // Table missing — skip this collection silently. - break; - } - - if (rows.length === 0) break; - - for (const row of rows) { - if (isClosed()) return highest; - const time_us = numericish(row.time_us); - const event: RealtimeEvent = { - topic: spaceTopic(spaceUri), - kind: "record.created", - payload: { - uri: row.uri, - did: row.did, - collection: collectionNsid, - rkey: row.rkey, - cid: row.cid ?? null, - record: parseRecordJson(row.record), - time_us, - space: spaceUri, - }, - ts: Date.now(), - }; - if (!writeEvent(event)) return highest; - if (highest == null || time_us > highest) highest = time_us; - cursor = time_us; - } - - // Cursor checkpoint after the batch. - if (highest != null) { - writeEvent({ kind: "cursor", value: String(highest) }); - } - - if (rows.length < batchSize) break; - } - } - - return highest; -} - -function frameEvent(event: SyncEvent): string { - return `event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`; -} - -function parseSince(raw: string | undefined): number | null { - if (!raw) return null; - const n = Number(raw); - if (Number.isNaN(n) || !Number.isFinite(n)) return null; - return n; -} - -function numericish(v: unknown): number { - return typeof v === "string" ? Number(v) : (v as number); -} - -function parseRecordJson(value: unknown): Record<string, unknown> { - if (value == null) return {}; - if (typeof value === "string") { - try { - return JSON.parse(value) as Record<string, unknown>; - } catch { - return {}; - } - } - return value as Record<string, unknown>; -} diff --git a/packages/contrail-record-host/tsconfig.build.json b/packages/contrail-record-host/tsconfig.build.json deleted file mode 100644 index 6092abf..0000000 --- a/packages/contrail-record-host/tsconfig.build.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/packages/contrail-record-host/tsconfig.json b/packages/contrail-record-host/tsconfig.json deleted file mode 100644 index 6092abf..0000000 --- a/packages/contrail-record-host/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/packages/contrail-record-host/tsup.config.ts b/packages/contrail-record-host/tsup.config.ts deleted file mode 100644 index 7d6b7eb..0000000 --- a/packages/contrail-record-host/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/index.ts"], - format: ["esm"], - dts: true, - sourcemap: true, - clean: true, - tsconfig: "tsconfig.build.json", - external: ["@atmo-dev/contrail-base"], -}); diff --git a/packages/contrail/PERMISSIONED_DATA.md b/packages/contrail/PERMISSIONED_DATA.md deleted file mode 100644 index ead5964..0000000 --- a/packages/contrail/PERMISSIONED_DATA.md +++ /dev/null @@ -1,140 +0,0 @@ -# Permissioned Data - -Contrail ships an opt-in permissioned-spaces subsystem: a write-capable, auth-gated XRPC store for records that can't live on public PDSes. It's designed to be used today and to migrate cleanly to Bluesky's real [permissioned spaces](https://dholms.leaflet.pub/3mhj6bcqats2o) when those ship. - -## Why this exists - -Real atproto permissioned repos don't exist yet. Apps that need private data (invite-only events, team forums, group messaging) either go fully public and gate at the app layer (insecure), or roll their own server (locks data out of atproto entirely). - -Contrail's spaces feature is the middle path: a **centralized, auth-gated store** with the same primitives Bluesky proposed — spaces, member lists, app policies, service-auth tokens — so the data model, XRPC surface, and consent UX match what the protocol will eventually standardize. On migration day, you move data from our hosted DB into user PDSes; the API your app talks to doesn't change. - -**Tradeoff:** data lives in *your* database, not on user PDSes. Fine for interim use when you (or the space owner) run the service. Not a long-term replacement for real permissioned repos. - -## Mental model - -> A **space** is a bag of records with one lock. The **member list** says who has the key. - -- Each space has one owner (DID), one type (classifying NSID), one key (string). Identified by an at-uri: `at://<owner>/<type>/<key>`. -- A space holds records of *any* NSID. Same role a PDS repo plays, scoped to a shared context. -- Every member has full read + write inside the space. No per-member permission axis — membership is the whole ACL. -- Each space has an optional **app policy**: `{ mode: "allow" | "deny", apps: [client_id, …] }` — gates which OAuth clients can act in the space. - -That's it. Every feature (private channels, invite-only threads, shared albums) is one-space-per-permission-boundary with records of whatever NSIDs the app defines. If you need finer-grained access (e.g. "admins" vs "members"), model it as multiple spaces or enforce at the app layer. - -## Design choices worth naming - -**Each permission boundary = its own space.** No nested ACLs, no per-record permissions, no per-collection policies. Matches the blog's model; keeps the library generic. - -**Membership is the whole ACL.** Being in the member list = full read + write. No per-member permission granularity. Apps that need richer roles model them as multiple spaces or check `clientId` / `authorDid` in their handlers. - -**atproto service-auth JWTs.** Verification uses `@atcute/xrpc-server/auth.ServiceJwtVerifier`: validates signature against issuer's DID doc, checks `aud` matches the configured service DID, checks `lxm` covers the method. Same path third-party apps and our own code go through. - -**Per-deployment namespace, not shared transport lexicons.** A deployment owning `com.example` emits `com.example.space.*` XRPCs and a `com.example.permissionSet` — OAuth consent screens show your domain, not a third party's. Client generation pulls lexicons from your repo. - -**Per-collection spaces tables with full parity.** Each collection you declare gets both `records_<short>` (public) and `spaces_records_<short>` (private), with the same count columns and indexes. Filters, sorts, hydration, and references all work in either mode. - -**Unified `listRecords`.** The per-collection endpoint accepts three call shapes: - -| Call | Returns | -| --- | --- | -| No auth, no `spaceUri` | Public records only | -| `?spaceUri=…` + service-auth JWT | Records from that one space (ACL-gated) | -| Service-auth JWT, no `spaceUri` | Public records **unioned** with records from every space the caller is a member of | - -The union path runs the public and per-space queries in parallel and merges with a shared keyset cursor, so filters, sorts, hydration, and references all work across sources. Records from a space carry a `space: <spaceUri>` field in the response. - -**Opaque keyset cursors.** `base64url(JSON({ t, v?, k }))` — tiebreaker `time_us`, sort-key value, and sort-kind tag. Cursors with a `k` that doesn't match the current sort are silently ignored (instead of returning wrong results). This lets one cursor be shared across every sub-query in the union. - -**Invites are stored tokens.** Random 32-byte token returned once on creation; SHA-256 hash is persisted. Redemption is a single atomic UPDATE that increments `used_count` only if `!revoked && !expired && !exhausted`. - -**Two-DB split is opt-in.** `initSchema(db, config, { spacesDb })` and `createApp(db, config, { spacesDb })`. Spaces tables can live on a separate binding for different backup/compliance constraints; defaults to the main DB. - -**Everything is opt-in.** No `config.spaces` set = zero extra tables, zero extra routes, zero new deps activated. - -## Architecture - -``` -src/core/spaces/ - types.ts — SpacesConfig, StorageAdapter, row types - schema.ts — DDL for spaces / spaces_members / spaces_invites base + per-collection tables - adapter.ts — HostedAdapter: CRUD + space-scoped count maintenance on putRecord/deleteRecord - acl.ts — pure checkAccess(): owner / member-read / member-write / app-policy logic - auth.ts — buildVerifier + verifyServiceAuthRequest - router.ts — Hono route registration for <ns>.space.* - invite-token.ts, tid.ts — crypto helpers -``` - -Auth flow (per request): - -1. **`<ns>.space.*` routes**: service-auth middleware validates the JWT (signature, `aud`, `lxm`) and populates `c.var.serviceAuth = { issuer, audience, lxm, clientId }`. Handler fetches space + caller's membership via the adapter, runs `checkAccess`, dispatches to the adapter on allow, returns 403 with a structured `reason` on deny. -2. **Per-collection `listRecords`/`getRecord` with `?spaceUri=…`**: same verify + ACL gate, then dispatches to the adapter (scoped to the one space). -3. **Per-collection `listRecords` with auth but no `spaceUri`**: verify, list caller's member spaces via `adapter.listSpaces({ memberDid })`, run the public + per-space union. - -## Usage - -```ts -import type { ContrailConfig } from "@atmo-dev/contrail"; - -const config: ContrailConfig = { - namespace: "myapp", - collections: { - event: { collection: "community.lexicon.calendar.event", /* ... */ }, - location: { collection: "myapp.event.location" }, - message: { collection: "myapp.event.message" }, - }, - spaces: { - type: "myapp.event.space", // classifying NSID for this kind of space - serviceDid: "did:web:myapp.com", // plain DID, no fragment — see note below - // resolver: optional — defaults to composite did:plc + did:web - // defaultAppPolicy: optional — app-level deny/allow list - }, -}; -``` - -Per-collection opt-out: - -```ts -collections: { - public_only: { collection: "myapp.public", allowInSpaces: false }, -} -``` - -Client-side (from another app consuming a space service): - -```ts -const response = await userPdsClient.post("myapp.space.putRecord", { - headers: { "Atproto-Proxy": "did:web:myapp.com#myapp_space" }, - input: { spaceUri, collection: "myapp.event.message", record: { text: "hi" } }, -}); -``` - -The user's PDS validates OAuth scope against `myapp.permissionSet` (auto-generated), mints a service-auth JWT with `aud=did:web:myapp.com`, forwards to your service, which verifies and executes. - -> **Note on service DIDs.** Use the plain DID (no service fragment) as `serviceDid`. Many PDS implementations reject `aud` values with `#fragment` in `com.atproto.server.getServiceAuth`, and the middleware does strict string equality on `aud`. The fragment form (`did:web:myapp.com#myapp_space`) only belongs in your DID doc's service entry, where PDSes use it to resolve the service endpoint URL for `Atproto-Proxy` routing — that's a separate concern from JWT audience validation. - -## DNS requirements - -Permission sets live under your namespace (`myapp.permissionSet`), which means PDSes need to resolve that NSID via DNS. atproto NSID resolution does **not** walk up subdomains — each emitted lexicon needs its own `TXT` record at `_lexicon.<reversed-domain-path>`. The publish script (`scripts/publish-lexicons.ts`) prints the exact records you need. You must control the domain. - -## What's deliberately not in here - -- **No E2EE.** Data is operator-readable. Appropriate for "invite-only" not "journalists-under-threat." -- **No per-space sharding/replication.** One DB, one operator. -- **No lexicon validation on write.** Add at the app layer if you need it. -- **No moderation/report primitives.** App layer. -- **No FTS in space mode (yet).** Search on `?spaceUri=…` is skipped. A composite-keyed `fts_spaces_<short>` would fix this — flagged as follow-up because the same at-URI can appear in multiple spaces. -- **No hybrid single-record split.** A record is either on a public PDS (contrail-indexed) or inside a space. The unified `listRecords` stitches both sides back together at read time. - -## Migrating to real permissioned spaces - -Design choices made so migration is mostly data movement, not API redesign: - -- **TID rkeys from day one** — already valid atproto rkeys. -- **JSON payloads stored as they'd appear on a PDS** — no shape translation at migration time. -- **Per-member `authorDid` column** — each member can write their slice into their own permissioned repo via `applyWrites`. -- **`exportSpace` API planned** — will dump all records in the shape the PDS will accept. Migration day: loop over members, call export + applyWrites, flip a flag. Keep old DB read-only as fallback. -- **OAuth permission set follows the spec format** — one `permission` entry with `{ resource: "rpc", inheritAud: true, lxm: […] }`, so the same user consent works whether the target is your interim service DID or (eventually) a real permissioned-repo PDS. - -## Status - -Interim by design. If Bluesky ships real permissioned repos and you own the service, plan to migrate. If they don't, this shape is stable: we designed the transport and consent UX to match the protocol proposal, so the API your apps speak doesn't change on migration day. diff --git a/packages/contrail/README.md b/packages/contrail/README.md index 6754a8d..6431a4c 100644 --- a/packages/contrail/README.md +++ b/packages/contrail/README.md @@ -1,444 +1,91 @@ -# Contrail +# `@atmo-dev/contrail` -> [!WARNING] -> Work in progress! Pre-alpha, expect breaking changes. +> Pre-alpha. Expect breaking changes. -A library for indexing AT Protocol records. Define collections — get automatic Jetstream ingestion, PDS backfill, user discovery, typed XRPC endpoints, and (opt-in) permissioned spaces for private records. Works with Cloudflare Workers + D1, SvelteKit, Node.js, or any JavaScript runtime. +One package for indexing and querying public AT Protocol records. -## Install - -```bash -npm install @atmo-dev/contrail -``` - -## Usage +## Basic use ```ts import { Contrail } from "@atmo-dev/contrail"; const contrail = new Contrail({ namespace: "com.example", - db, // any Database-compatible instance (D1, SQLite, etc.) + db, collections: { - event: { // short name → URL path + table suffix - collection: "community.lexicon.calendar.event", // full NSID of the record type + event: { + collection: "community.lexicon.calendar.event", queryable: { - mode: {}, // string → equality filter (?mode=online) - name: {}, // string → equality filter (?name=...) - startsAt: { type: "range" }, // range → min/max filters (?startsAtMin=...&startsAtMax=...) - endsAt: { type: "range" }, + mode: {}, + startsAt: { type: "range" }, }, searchable: ["name", "description"], relations: { rsvps: { - collection: "rsvp", // short name of the child collection + collection: "rsvp", + field: "subject.uri", groupBy: "status", - count: true, groups: { - interested: "community.lexicon.calendar.rsvp#interested", going: "community.lexicon.calendar.rsvp#going", - notgoing: "community.lexicon.calendar.rsvp#notgoing", }, }, }, }, rsvp: { collection: "community.lexicon.calendar.rsvp", - queryable: { - status: {}, - "subject.uri": {}, - }, references: { - event: { - collection: "event", // short name of the referenced collection - field: "subject.uri", - }, + event: { collection: "event", field: "subject.uri" }, }, }, }, }); await contrail.init(); +await contrail.backfillAll({ concurrency: 100 }); ``` -### Query records - -```ts -const { records, cursor } = await contrail.query( - "event", // short name you declared in `collections` - { - filters: { mode: "in-person" }, - sort: { countType: "rsvp", direction: "desc" }, - limit: 20, - } -); -``` - -### Ingest from Jetstream - -```ts -// Run one ingestion cycle (catches up to present, then stops) -await contrail.ingest(); -``` - -### Persistent ingestion +## Query ```ts -// Long-lived Jetstream connection with automatic batching and reconnection -const controller = new AbortController(); -await contrail.runPersistent({ - batchSize: 50, // flush every N events (default: 50) - flushIntervalMs: 5000, // or every N ms (default: 5000) - signal: controller.signal, +const result = await contrail.query("event", { + filters: { mode: "in-person" }, + sort: { countType: "rsvp", direction: "desc" }, + limit: 20, }); ``` -Call `controller.abort()` for graceful shutdown — the current batch is flushed and the cursor is saved. +HTTP routes expose the same pipeline, including relationship/reference hydration, counts, profiles, search, and custom queries. -### Discover users and backfill +## Keep records current ```ts -// Find users from relays -await contrail.discover(); +await contrail.ingest(); // bounded Jetstream cycle -// Backfill their records from PDS -await contrail.backfill({ concurrency: 100 }); - -// Or both in one call — logs progress via config.logger -await contrail.backfillAll({ concurrency: 100 }); -``` - -### Notify of immediate updates - -```ts -// After writing to a user's PDS, tell Contrail to fetch it now -await contrail.notify("at://did:plc:abc/community.lexicon.calendar.rsvp/123"); - -// Batch up to 25 URIs -await contrail.notify([uri1, uri2, uri3]); +await contrail.runPersistent({ + signal: abortController.signal, + batchSize: 50, + flushIntervalMs: 5_000, +}); ``` -### HTTP handler (XRPC endpoints) +After a write to a user's PDS, `contrail.notify(uri)` can fetch the authoritative record immediately. The public notification protocol and transient-failure semantics are under active hardening. -Mount the full XRPC API in any framework: +## HTTP ```ts import { createHandler } from "@atmo-dev/contrail/server"; const handle = createHandler(contrail); -// handle: (Request, db?) => Promise<Response> +const response = await handle(request, db); ``` -**SvelteKit:** - -```ts -// src/routes/xrpc/[...path]/+server.ts -export const GET = ({ request }) => handle(request); -export const POST = ({ request }) => handle(request); -``` - -**Cloudflare Worker:** - -```ts -export default { - async fetch(request, env) { - return handle(request, env.DB); - }, -}; -``` +For Workers, use `createWorker` from `@atmo-dev/contrail/worker`. -### SQLite adapter (Node.js / local dev) +## Adapters ```ts import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; - -const db = createSqliteDatabase("data.db"); -const contrail = new Contrail({ ...config, db }); -``` - -> **Note:** The SQLite adapter uses Node's built-in `node:sqlite` (Node 22+). Full-text search (`searchable`) is not supported with this adapter because `node:sqlite` doesn't include the FTS5 extension. Search works on Cloudflare D1 and PostgreSQL. - -### PostgreSQL adapter (Node.js / server) - -```ts import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import pg from "pg"; - -const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); -const db = createPostgresDatabase(pool); -const contrail = new Contrail({ ...config, db }); -``` - -PostgreSQL uses JSONB for record storage, tsvector generated columns for full-text search (instead of FTS5), and `BIGINT` for timestamp columns. - -## Examples - -### PostgreSQL (Node.js) - -See [`examples/postgres/`](examples/postgres/) for a complete example with Docker Compose, persistent Jetstream ingestion, user discovery/backfill, and an HTTP API server. - -### Cloudflare Workers - -This repo includes a working example that indexes AT Protocol calendar events and RSVPs on Cloudflare Workers + D1. - -### Setup - -```bash -pnpm install -pnpm generate:pull # pull lexicons from network, auto-detect fields, generate types -``` - -### Dev - -```bash -pnpm sync # discover users and backfill records from PDS -pnpm dev:auto # start wrangler dev with auto-ingestion -``` - -### Production - -```bash -npx wrangler d1 create contrail -# Add database_id to wrangler.jsonc -pnpm deploy -pnpm sync # discover + backfill against prod D1 -``` - -Ingestion runs automatically via cron (`*/1 * * * *`). Schema is auto-initialized. - -## Config - -### Collection options - -| Option | Default | Description | -|--------|---------|-------------| -| `queryable` | `{}` | Fields exposed as query filters. `{}` = string equality, `{ type: "range" }` = min/max | -| `discover` | `true` | Find users via relays. `false` = only track known DIDs | -| `relations` | `{}` | Many-to-one relationships with materialized counts | -| `relations.*.field` | `"subject.uri"` | Field in the related record to match against | -| `relations.*.match` | `"uri"` | Match against parent's `"uri"` or `"did"` | -| `relations.*.groupBy` | — | Split counts by this field's value | -| `relations.*.groups` | — | Group value mappings (e.g. `{ going: "collection#going" }`) | -| `relations.*.count` | `true` | Enable materialized count columns on the parent | -| `references` | `{}` | Forward references to other collections for hydration | -| `references.*.collection` | — | Short name of the target collection (key in `collections`) | -| `references.*.field` | — | Field containing the target record's AT URI | -| `queries` | `{}` | Custom query handlers (raw Response) | -| `pipelineQueries` | `{}` | Custom query handlers that go through the standard filter/sort/hydration pipeline | -| `searchable` | disabled | Full-text search fields. SQLite uses FTS5 virtual tables; PostgreSQL uses tsvector generated columns with GIN indexes. Provide `string[]` to enable, omit to disable | -| `collection` | — | Full NSID of the record type this short-name entry indexes (required) | -| `methods` | `["listRecords", "getRecord"]` | XRPC methods to emit for this collection | -| `allowInSpaces` | `true` | When spaces are enabled, emit a parallel `spaces_records_<short>` table | - -### Top-level options - -| Option | Default | Description | -|--------|---------|-------------| -| `namespace` | — | Your reverse-domain namespace (e.g. `"com.example"`) | -| `collections` | — | Collection configurations | -| `profiles` | `["app.bsky.actor.profile"]` | Profile collection NSIDs | -| `relays` | Bluesky relays | Relay URLs for user discovery | -| `jetstreams` | Bluesky Jetstream | Jetstream URLs for real-time ingestion | -| `feeds` | — | Personalized feed configurations | -| `notify` | off | Expose `notifyOfUpdate`. `true` = open, string = `Authorization: Bearer <string>` required | -| `spaces` | — | Permissioned-spaces configuration. See [PERMISSIONED_DATA.md](./PERMISSIONED_DATA.md) | -| `logger` | `console` | Logger instance (`{ log, warn, error }`) | - -### Profiles - -`profiles` is a top-level config array of collection NSIDs that contain profile records (rkey `self`). Defaults to `["app.bsky.actor.profile"]`. These are auto-added to `collections` with `{ discover: false }`. Use `?profiles=true` on any endpoint to include a `profiles` map in the response, keyed by DID, with handle and profile record data. - -## XRPC API - -When using `createHandler`, all endpoints live under the deployment's own namespace at `/xrpc/{namespace}.{...}`: - -| Endpoint | Description | -|----------|-------------| -| `{namespace}.{short}.listRecords` | List/filter records in a collection (keyed by its short name) | -| `{namespace}.{short}.getRecord` | Get single record by URI | -| `{namespace}.getProfile` | Get a user's profile by DID or handle | -| `{namespace}.notifyOfUpdate` | Notify of a record change for immediate indexing | -| `{namespace}.getCursor` | Current cursor position | -| `{namespace}.getOverview` | All collections summary | -| `{namespace}.permissionSet` | OAuth permission-set bundling every method above (auto-generated) | -| `{namespace}.space.*` | Spaces admin, invite, member, record XRPCs (when `spaces` is enabled) | - -### Query parameters - -**Filtering:** - -| Param | Example | Description | -|-------|---------|-------------| -| `actor` | `?actor=did:plc:...` or `?actor=alice.bsky.social` | Filter by DID or handle (triggers on-demand backfill) | -| `profiles` | `?profiles=true` | Include profile + identity info keyed by DID | -| `search` | `?search=meetup` | Full-text search across searchable fields (FTS5, ranked) | -| `{field}` | `?status=going` | Equality filter on queryable string field | -| `{field}Min` | `?startsAtMin=2026-03-16` | Range minimum (datetime/integer fields) | -| `{field}Max` | `?endsAtMax=2026-04-01` | Range maximum (datetime/integer fields) | -| `{rel}CountMin` | `?rsvpsCountMin=10` | Minimum total relation count | -| `{rel}{Group}CountMin` | `?rsvpsGoingCountMin=10` | Minimum relation count for a specific groupBy value | -| `hydrate{Rel}` | `?hydrateRsvps=10` | Embed latest N related records (per group if grouped) | -| `hydrate{Ref}` | `?hydrateEvent=true` | Embed the referenced record | -| `sort` | `?sort=startsAt` | Sort by a queryable field or count (see below) | -| `order` | `?order=asc` | Sort direction: `asc` or `desc` (default depends on field type) | -| `limit` | `?limit=25` | Page size (1-200, default 50) | -| `cursor` | `?cursor=...` | Pagination cursor | - -**Sorting** — `sort` accepts any queryable field param name or a count field: - -``` -?sort=startsAt # by date (default: desc for range fields) -?sort=name&order=asc # by name ascending -?sort=rsvpsCount # by total RSVP count (default: desc) -?sort=rsvpsGoingCount&order=asc # by going count ascending -``` - -**Search** uses SQLite FTS5 or PostgreSQL tsvector for ranked full-text search. To enable, set `searchable: ["field1", "field2"]` on a collection. Supports FTS5 syntax including prefix (`meetup*`), phrases (`"rust meetup"`), and boolean (`rust OR typescript`). Combinable with all other filters. - ``` -?search=meetup # basic search -?search=meetup&mode=online # search + filter -?search=rust*&sort=startsAt&order=asc # search + sort override -``` - -**Hydration** embeds related or referenced records inline: - -``` -?hydrateRsvps=5 # latest 5 RSVPs per group (going, interested, etc.) -?hydrateEvent=true # embed the referenced event record -?hydrateRsvps=5&hydrateEvent=true # combine both -``` - -### Examples (events) - -``` -# Upcoming events with 10+ going RSVPs, with RSVP records and profiles -/xrpc/com.example.event.listRecords?startsAtMin=2026-03-16&rsvpsGoingCountMin=10&hydrateRsvps=5&profiles=true - -# Events for a specific user (by handle) -/xrpc/com.example.event.listRecords?actor=alice.bsky.social&profiles=true -# Single event with counts, RSVPs, and profiles -/xrpc/com.example.event.getRecord?uri=at://did:plc:.../community.lexicon.calendar.event/...&hydrateRsvps=10&profiles=true - -# Search for events by name/description -/xrpc/com.example.event.listRecords?search=meetup&profiles=true - -# RSVPs for a specific event, with the referenced event embedded -/xrpc/com.example.rsvp.listRecords?subjectUri=at://did:plc:.../community.lexicon.calendar.event/...&hydrateEvent=true&profiles=true -``` - -## Notify of Updates - -By default, Contrail ingests from Jetstream every minute (in the Worker example). If your app writes to a user's PDS and needs the change reflected immediately, use `contrail.notify()` or call the XRPC endpoint: - -```ts -// Programmatic -await contrail.notify(uri); - -// Or via HTTP -await fetch("https://your-contrail.workers.dev/xrpc/com.example.notifyOfUpdate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ uri }), -}); -``` - -Contrail fetches the record from the user's PDS and figures out what to do: - -| PDS returns | Already indexed? | Action | -|---|---|---| -| Record (new CID) | No | **Create** — indexes it, recounts relations | -| Record (new CID) | Yes | **Update** — upserts the record, recounts relations | -| Record (same CID) | Yes | **Skip** — nothing changed | -| 404 | Yes | **Delete** — removes it, recounts relations | -| 404 | No | **No-op** | - -When Jetstream later delivers the same event, the duplicate is detected by CID and skipped. - -## Permissioned Data - -Contrail ships an opt-in permissioned-spaces subsystem: an auth-gated store for records that can't live on public PDSes — private events, invite-only groups, members-only chat. Set `config.spaces` and contrail exposes the space XRPCs at `{namespace}.space.*` alongside your public indexer: - -```ts -const contrail = new Contrail({ - namespace: "com.example", - collections: { /* ... */ }, - spaces: { - type: "com.example.event.space", // NSID classifying the kind of space - serviceDid: "did:web:example.com", // your deployment's DID - // `resolver` is optional — defaults to a composite did:plc + did:web resolver. - }, -}); -``` - -Each collection you declare also gets a parallel `spaces_records_<short>` table (opt out per-collection via `allowInSpaces: false`). Auth uses atproto service-auth JWTs via `@atcute/xrpc-server`. Access is a simple `read` / `write` permission per member — the space owner is implicit write. Invites are first-class (generated token, hashed-at-rest, expiry + max-uses + revocation). - -**Unified `listRecords`.** The per-collection `listRecords` endpoint accepts three call shapes: - -| Call | Returns | -| --- | --- | -| No auth, no `spaceUri` | Public records only | -| `?spaceUri=…` + service-auth JWT | Records from that one space (ACL-gated) | -| Service-auth JWT, no `spaceUri` | Public records **unioned** with records from every space the caller is a member of | - -The union path runs the public and per-space queries in parallel and merges with a shared keyset cursor, so filters, sorts (`time`, record-field, count), hydration, and references all work across sources. Records from a space carry a `space: <spaceUri>` field in the response. - -Full design, migration story, and known limits: [PERMISSIONED_DATA.md](./PERMISSIONED_DATA.md). - -## Typesafe Client Usage - -You can get fully typed XRPC queries for any Contrail instance using [`@atcute/lex-cli`](https://github.com/mary-ext/atcute). The lexicon files are committed to the repo, so you can pull them directly via the git source. - -### Setup - -```bash -npm install @atcute/client @atcute/lexicons @atcute/lex-cli -``` - -Create a `lex.config.js` pointing at the Contrail instance's repo: - -```js -import { defineLexiconConfig } from "@atcute/lex-cli"; - -export default defineLexiconConfig({ - outdir: "src/lexicon-types/", - imports: ["@atcute/atproto"], - files: ["lexicons/**/*.json"], - pull: { - outdir: "lexicons/", - sources: [ - { - type: "git", - remote: "https://github.com/USER/REPO.git", // the Contrail instance repo - pattern: ["lexicons-generated/**/*.json", "lexicons-pulled/**/*.json", "lexicons/**/*.json"], - }, - ], - }, -}); -``` - -Then pull and generate: - -```bash -npx lex-cli pull && npx lex-cli generate -``` - -### Usage - -Import the generated types (side-effect import registers them with `@atcute/client`), then query with full type safety: - -```ts -import "./lexicon-types/index.js"; // registers ambient types -import { Client } from "@atcute/client"; - -const rpc = new Client({ handler: simpleFetchHandler({ service: /* your contrail url */ }) }); - -const response = await rpc.get("com.example.rsvp.listRecords", { - params: { status: "going", limit: 10 }, // typed params -}); - -if (response.ok) { - console.log(response.data.records); // typed -} -``` +D1 implements Contrail's database interface directly. diff --git a/packages/contrail/package.json b/packages/contrail/package.json index 53e25d0..ba1c135 100644 --- a/packages/contrail/package.json +++ b/packages/contrail/package.json @@ -66,17 +66,10 @@ "dependencies": { "@atcute/atproto": "^3.1.10", "@atcute/cbor": "^2.3.2", - "@atcute/cid": "^2.4.1", "@atcute/client": "^4.2.1", - "@atcute/identity": "^1.1.4", "@atcute/identity-resolver": "^1.2.2", "@atcute/jetstream": "^1.0.2", "@atcute/lexicons": "^1.2.9", - "@atcute/xrpc-server": "^0.1.12", - "@atmo-dev/contrail-appview": "workspace:*", - "@atmo-dev/contrail-authority": "workspace:*", - "@atmo-dev/contrail-base": "workspace:*", - "@atmo-dev/contrail-record-host": "workspace:*", "cac": "^7.0.0", "hono": "^4.12.8", "jiti": "^2.4.0" diff --git a/packages/contrail/src/adapters/postgres.ts b/packages/contrail/src/adapters/postgres.ts index 6ab2902..ae75c9f 100644 --- a/packages/contrail/src/adapters/postgres.ts +++ b/packages/contrail/src/adapters/postgres.ts @@ -1 +1,93 @@ -export * from "@atmo-dev/contrail-base/postgres"; +import pg from "pg"; +import type { Database, Statement } from "../core/types"; +import { postgresDialect } from "../core/dialect"; + +/** Internal interface for statements that can run on a specific client */ +interface PgStatement extends Statement { + /** Execute on a specific client (used by batch for transaction isolation) */ + _runOn(client: pg.PoolClient): Promise<any>; +} + +/** Column names known to be BIGINT — PostgreSQL returns these as strings */ +const BIGINT_COLUMNS = new Set(["time_us", "indexed_at", "resolved_at"]); + +function normalizeRow(row: any): any { + if (!row) return row; + if (typeof row.record === "object" && row.record !== null) { + row.record = JSON.stringify(row.record); + } + for (const col of BIGINT_COLUMNS) { + if (typeof row[col] === "string") row[col] = Number(row[col]); + } + return row; +} + +export function createPostgresDatabase(pool: pg.Pool): Database { + function rewritePlaceholders(sql: string): string { + let idx = 0; + let inString = false; + let result = ""; + for (let i = 0; i < sql.length; i++) { + const ch = sql[i]; + if (ch === "'" && sql[i - 1] !== "\\") { + inString = !inString; + result += ch; + } else if (ch === "?" && !inString) { + result += `$${++idx}`; + } else { + result += ch; + } + } + return result; + } + + function wrapStatement(sql: string, boundValues: any[] = []): PgStatement { + const pgSql = rewritePlaceholders(sql); + + return { + bind(...values: any[]): PgStatement { + return wrapStatement(sql, values); + }, + async run() { + const result = await pool.query(pgSql, boundValues); + return { changes: result.rowCount }; + }, + async _runOn(client: pg.PoolClient) { + const result = await client.query(pgSql, boundValues); + return { changes: result.rowCount }; + }, + async all<T>() { + const result = await pool.query(pgSql, boundValues); + return { results: result.rows.map(normalizeRow) as T[] }; + }, + async first<T>() { + const result = await pool.query(pgSql, boundValues); + return result.rows[0] ? (normalizeRow(result.rows[0]) as T) : null; + }, + }; + } + + return { + prepare(sql: string): Statement { + return wrapStatement(sql); + }, + async batch(stmts: Statement[]): Promise<any[]> { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const results: any[] = []; + for (const stmt of stmts) { + results.push(await (stmt as PgStatement)._runOn(client)); + } + await client.query("COMMIT"); + return results; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } + }, + dialect: postgresDialect, + }; +} diff --git a/packages/contrail/src/adapters/sqlite.ts b/packages/contrail/src/adapters/sqlite.ts index 42032b3..2f46dce 100644 --- a/packages/contrail/src/adapters/sqlite.ts +++ b/packages/contrail/src/adapters/sqlite.ts @@ -1 +1,39 @@ -export * from "@atmo-dev/contrail-base/sqlite"; +import { DatabaseSync } from "node:sqlite"; +import type { Database, Statement } from "../core/types"; +import { sqliteDialect } from "../core/dialect"; + +export function createSqliteDatabase(path: string): Database { + const raw = new DatabaseSync(path); + raw.exec("PRAGMA journal_mode = WAL"); + + function wrapStatement(sql: string, boundValues: any[] = []): Statement { + return { + bind(...values: any[]): Statement { + return wrapStatement(sql, values); + }, + async run() { + return raw.prepare(sql).run(...boundValues); + }, + async all<T>() { + return { results: raw.prepare(sql).all(...boundValues) as T[] }; + }, + async first<T>() { + return (raw.prepare(sql).get(...boundValues) as T) ?? null; + }, + }; + } + + return { + prepare(sql: string): Statement { + return wrapStatement(sql); + }, + async batch(stmts: Statement[]): Promise<any[]> { + const results: any[] = []; + for (const stmt of stmts) { + results.push(await stmt.run()); + } + return results; + }, + dialect: sqliteDialect, + }; +} diff --git a/packages/contrail/src/cli-config.ts b/packages/contrail/src/cli-config.ts index c422d08..d008281 100644 --- a/packages/contrail/src/cli-config.ts +++ b/packages/contrail/src/cli-config.ts @@ -1,7 +1,6 @@ /** - * Shared config-file discovery + loading for the `contrail` and - * `contrail-lex` CLIs. Exported so downstream CLIs can use the same - * auto-detect convention and fall back to the same list of locations. + * Shared config-file discovery and loading for the `contrail` CLI. + * Exported so downstream tooling can use the same auto-detect convention. * * Not for runtime use by regular contrail apps — they pass `config` * directly to `new Contrail(...)`. This module exists only to support diff --git a/packages/contrail/src/cli.ts b/packages/contrail/src/cli.ts index af648dd..8b1978f 100644 --- a/packages/contrail/src/cli.ts +++ b/packages/contrail/src/cli.ts @@ -10,7 +10,6 @@ import { registerBackfill } from "./cli/commands/backfill.js"; import { registerRefresh } from "./cli/commands/refresh.js"; import { registerDev } from "./cli/commands/dev.js"; import { registerAppendScheduled } from "./cli/commands/append-scheduled.js"; -import { resolveAndLoadConfig } from "./cli/shared.js"; const cli = cac("contrail"); @@ -19,42 +18,6 @@ registerRefresh(cli); registerDev(cli); registerAppendScheduled(cli); -// `reap` lives in @atmo-dev/contrail-community after the PR #30 package split. -// Dynamically import so contrail has no compile-time edge into the community -// package (contrail-community already depends on contrail; a static import -// here would create a build cycle). contrail declares contrail-community as an -// optional peer so a consumer that installs both gets `reap` wired up; when -// it's genuinely absent the subcommand is simply omitted. -try { - const mod = await import("@atmo-dev/contrail-community" as string); - if (typeof mod.registerReap === "function") { - mod.registerReap(cli, { resolveAndLoadConfig }); - } else { - // Loaded, but the expected export is missing — a real packaging problem, - // not "not installed". Surface it so a broken build is debuggable. - console.warn( - "[contrail] @atmo-dev/contrail-community loaded but does not export registerReap; `reap` unavailable." - ); - } -} catch (err) { - const code = (err as { code?: string })?.code; - if (code === "ERR_MODULE_NOT_FOUND") { - // Expected when contrail-community isn't installed alongside contrail. - // Debug-level so it doesn't nag, but is visible under DEBUG diagnostics. - if (process.env.DEBUG) { - console.debug("[contrail] contrail-community not installed; `reap` unavailable."); - } - } else { - // A different failure (broken export, missing transitive dep, syntax - // error) — do NOT swallow it, or `reap` silently vanishes with no clue. - console.warn( - `[contrail] failed to load @atmo-dev/contrail-community for \`reap\`: ${ - err instanceof Error ? err.message : String(err) - }` - ); - } -} - cli.help(); try { diff --git a/packages/contrail/src/cli/shared.ts b/packages/contrail/src/cli/shared.ts index ecb5d2e..17fd8c5 100644 --- a/packages/contrail/src/cli/shared.ts +++ b/packages/contrail/src/cli/shared.ts @@ -9,7 +9,10 @@ import { CONFIG_CANDIDATES_MESSAGE, } from "../cli-config.js"; import type { ContrailConfig } from "../core/types.js"; -import type { CollectionStats, RefreshResult } from "../core/refresh.js"; +import type { + CollectionStats, + RefreshResult, +} from "../core/refresh.js"; export interface ConfigOpts { config?: string; diff --git a/packages/contrail/src/contrail.ts b/packages/contrail/src/contrail.ts index 5162663..1c8ccf7 100644 --- a/packages/contrail/src/contrail.ts +++ b/packages/contrail/src/contrail.ts @@ -1,76 +1,61 @@ -import type { ContrailConfig, Database, ResolvedContrailConfig } from "./core/types"; -import { resolveConfig, validateConfig, optimizeAnalysisLimit } from "./core/types"; +import type { + ContrailConfig, + Database, + ResolvedContrailConfig, +} from "./core/types"; +import { + optimizeAnalysisLimit, + resolveConfig, + validateConfig, +} from "./core/types"; import { initSchema } from "./core/db/schema"; -import { optimizeDatabase } from "./core/db"; -import { queryRecords } from "./core/db/records"; -import type { QueryOptions, SortOption } from "./core/db/records"; -import { runIngestCycle, createIngestState } from "./core/jetstream"; -import type { IngestState } from "./core/jetstream"; -import { discoverDIDs, backfillPending } from "./core/backfill"; -import type { BackfillAllOptions, BackfillProgress } from "./core/backfill"; -import { refresh as runRefresh } from "./core/refresh"; -import type { RefreshOptions, RefreshResult } from "./core/refresh"; -import { processNotifyUris } from "./core/router/notify"; -import type { NotifyResult } from "./core/router/notify"; -import { runPersistent as runPersistentIngestion } from "./core/persistent"; -import type { PersistentIngestOptions } from "./core/persistent"; +import { optimizeDatabase } from "./core/db/optimize"; +import { queryRecords, type QueryOptions } from "./core/db/records"; +import { + createIngestState, + runIngestCycle, + type IngestState, +} from "./core/jetstream"; +import { + backfillPending, + discoverDIDs, + type BackfillAllOptions, +} from "./core/backfill"; +import { + refresh as runRefresh, + type RefreshOptions, + type RefreshResult, +} from "./core/refresh"; +import { + processNotifyUris, + type NotifyResult, +} from "./core/router/notify"; +import { + runPersistent as runPersistentIngestion, + type PersistentIngestOptions, +} from "./core/persistent"; import { runLabelIngestCycle, runPersistentLabels as runPersistentLabelsImpl, type PersistentLabelsOptions, } from "./core/labels/subscribe"; -import type { PubSub } from "./core/realtime/types"; -import { InMemoryPubSub } from "./core/realtime/in-memory"; import { createApp, type CreateAppOptions } from "./core/router"; -import type { CommunityIntegration } from "./core/community-integration"; import type { Hono } from "hono"; -/** Note: `community` is shadowed from ContrailConfig (where it's the - * user-supplied config blob, typed as `unknown`) to be the pre-built - * integration object the Contrail instance actually consumes. */ -export interface ContrailOptions extends Omit<ContrailConfig, "community"> { +export interface ContrailOptions extends ContrailConfig { db?: Database; - /** Optional separate DB for permissioned spaces tables. Defaults to `db`. */ - spacesDb?: Database; - /** Optional user-supplied community config blob. Same shape as - * ContrailConfig.community — the community integration reads this - * via `config.community`. */ - community?: unknown; - /** Optional pre-built community integration. When set, the Contrail - * instance applies its schema during `init()` and forwards it to - * `createApp` so community routes / hooks are wired automatically. - * Construct via `createCommunityIntegration(...)` from - * `@atmo-dev/contrail-community`. */ - communityIntegration?: CommunityIntegration; } export class Contrail { readonly config: ResolvedContrailConfig; private _db?: Database; - private _spacesDb?: Database; - private _community?: CommunityIntegration; private _ingestState: IngestState = createIngestState(); - private _pubsub: PubSub | null = null; constructor(options: ContrailOptions) { - const { db, spacesDb, communityIntegration, ...configInput } = options; - this.config = resolveConfig(configInput as ContrailConfig); + const { db, ...configInput } = options; + this.config = resolveConfig(configInput); validateConfig(this.config); this._db = db; - this._spacesDb = spacesDb; - this._community = communityIntegration; - // Build the pubsub instance up-front so ingestion and HTTP routes share - // it. Caller overrides via `config.realtime.pubsub` (e.g. DurableObject). - if (this.config.realtime) { - this._pubsub = - this.config.realtime.pubsub ?? - new InMemoryPubSub({ queueBound: this.config.realtime.queueBound }); - } - } - - /** The shared realtime pubsub, or null when realtime isn't configured. */ - get pubsub(): PubSub | null { - return this._pubsub; } private getDb(db?: Database): Database { @@ -79,18 +64,9 @@ export class Contrail { return d; } - /** Returns the configured spaces DB (or the main DB if not separately configured). */ - getSpacesDb(db?: Database, spacesDb?: Database): Database { - return spacesDb ?? this._spacesDb ?? this.getDb(db); - } - - /** Initialize the database schema. Must be called before other operations. - * If a separate spacesDb is configured, its tables are initialized on it. */ - async init(db?: Database, spacesDb?: Database): Promise<void> { - const main = this.getDb(db); - const spaces = spacesDb ?? this._spacesDb; - const extraSchemas = this._community ? [this._community.applySchema] : []; - await initSchema(main, this.config, { spacesDb: spaces, extraSchemas }); + /** Initialize the database schema. */ + async init(db?: Database): Promise<void> { + await initSchema(this.getDb(db), this.config); } /** Refresh the SQLite query-planner statistics (bounded `PRAGMA optimize`) so @@ -118,7 +94,7 @@ export class Contrail { async ingest(options?: { timeoutMs?: number }, db?: Database): Promise<void> { const d = this.getDb(db); const tasks: Promise<void>[] = [ - runIngestCycle(d, this.config, options?.timeoutMs, this._ingestState, this._pubsub ?? undefined), + runIngestCycle(d, this.config, options?.timeoutMs, this._ingestState), ]; if (this.config.labels) { tasks.push(runLabelIngestCycle(d, this.config, options?.timeoutMs)); @@ -135,7 +111,6 @@ export class Contrail { runPersistentIngestion(d, this.config, { ...options, logger: this.config.logger, - pubsub: this._pubsub ?? undefined, }), ]; if (this.config.labels) { @@ -289,21 +264,10 @@ export class Contrail { return processNotifyUris(this.getDb(db), this.config, uriList); } - /** Build the Hono app for this Contrail instance. All HTTP routes - * (collection / spaces / community / realtime) are registered here. - * The realtime pubsub on this instance is reused, so subscribers see - * events published from `ingest()` / `runPersistent()` on the same instance. */ + /** Build the Hono app for this Contrail instance. */ app(options: AppOptions = {}): Hono { - const { db, ...appOpts } = options; - const main = this.getDb(db); - const spaces = options.spacesDb ?? this._spacesDb; - return createApp(main, this.config, { - ...appOpts, - spacesDb: spaces, - // Per-call community override falls back to the constructor's. - community: appOpts.community ?? this._community ?? null, - realtime: { ...appOpts.realtime, pubsub: this._pubsub ?? undefined }, - }); + const { db, ...appOptions } = options; + return createApp(this.getDb(db), this.config, appOptions); } /** Fetch-style handler built from `app()`. Use this from SvelteKit / Next / diff --git a/packages/contrail/src/core/backfill.ts b/packages/contrail/src/core/backfill.ts index 551c495..3a7f931 100644 --- a/packages/contrail/src/core/backfill.ts +++ b/packages/contrail/src/core/backfill.ts @@ -1 +1,617 @@ -export * from "@atmo-dev/contrail-appview"; +import type {} from "@atcute/atproto"; +import { type Did } from "@atcute/lexicons"; +import { isDid, isNsid } from "@atcute/lexicons/syntax"; + +import type { Client } from "@atcute/client"; +import type { ContrailConfig, Database, IngestEvent } from "./types"; +import { + getDiscoverableNsids, + getDependentNsids, + DEFAULT_RELAYS, + shortNameForNsid, +} from "./types"; +import { applyEvents, getLastCursor, saveCursor } from "./db"; +import { getClient, getPDS } from "./client"; + +const DEFAULT_TIME_FIELD = "createdAt"; + +/** Parse the record's canonical time (e.g. createdAt) and return microseconds. + * Falls back to `nowUs` when missing/invalid. Clamps to nowUs to avoid + * user-controlled future timestamps pinning records at the top of feeds. */ +function recordTimeUs( + record: unknown, + collection: string, + config: ContrailConfig | undefined, + nowUs: number +): number { + if (!config) return nowUs; + const short = shortNameForNsid(config, collection); + const colCfg = short ? config.collections[short] : undefined; + const field = colCfg?.timeField ?? DEFAULT_TIME_FIELD; + if (field === false) return nowUs; + const raw = + record && typeof record === "object" + ? (record as Record<string, unknown>)[field] + : undefined; + if (typeof raw !== "string") return nowUs; + const ms = Date.parse(raw); + if (!Number.isFinite(ms) || ms <= 0) return nowUs; + const us = ms * 1000; + return us > nowUs ? nowUs : us; +} + +const PAGE_SIZE = 100; +const BATCH_SIZE = 100; +const MAX_RETRIES = 5; + +const REQUEST_TIMEOUT_MS = 10_000; + +async function withRetry<T>( + fn: () => Promise<T>, + label: string, + maxRetries = 3, + timeoutMs = REQUEST_TIMEOUT_MS +): Promise<T> { + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await Promise.race([ + fn(), + new Promise<never>((_, reject) => + setTimeout(() => reject(new Error(`Timeout: ${label}`)), timeoutMs) + ), + ]); + } catch (err) { + lastError = err; + if (attempt < maxRetries) { + const delay = Math.min(1000 * 2 ** attempt, 10000); + await new Promise((r) => setTimeout(r, delay)); + } + } + } + throw lastError; +} + +/** Drop events whose `subjectField` value is a DID we have no identity for. + * One bulk SELECT per call, suitable for use after each backfill page. */ +async function filterEventsBySubject( + db: Database, + events: IngestEvent[], + subjectField: string +): Promise<IngestEvent[]> { + const subjects = new Set<string>(); + const eventSubjects = new Map<string, string>(); + for (const e of events) { + if (!e.record) continue; + let subj: unknown; + try { + subj = JSON.parse(e.record)?.[subjectField]; + } catch { + continue; + } + if (typeof subj === "string" && isDid(subj)) { + subjects.add(subj); + eventSubjects.set(e.uri, subj); + } + } + if (subjects.size === 0) return []; + + const known = new Set<string>(); + const list = [...subjects]; + const CHUNK = 100; + for (let i = 0; i < list.length; i += CHUNK) { + const chunk = list.slice(i, i + CHUNK); + const placeholders = chunk.map(() => "?").join(","); + const rows = await db + .prepare(`SELECT did FROM identities WHERE did IN (${placeholders})`) + .bind(...chunk) + .all<{ did: string }>(); + for (const r of rows.results ?? []) known.add(r.did); + } + + return events.filter((e) => { + const subj = eventSubjects.get(e.uri); + return subj !== undefined && known.has(subj); + }); +} + +async function markFailed( + db: Database, + did: string, + collection: string, + error: string +): Promise<void> { + await db + .prepare( + "UPDATE backfills SET retries = retries + 1, last_error = ? WHERE did = ? AND collection = ?" + ) + .bind(error, did, collection) + .run(); +} + +export interface BackfillOptions { + /** Pre-resolved client — avoids redundant PDS lookups when batching by DID */ + client?: Client; + /** Skip replay detection in applyEvents (safe during initial backfill) */ + skipReplayDetection?: boolean; + /** Max retries per request (default: 3). Set to 0 for single-attempt mode. */ + maxRetries?: number; + /** Per-request timeout in ms (default: 10000). */ + requestTimeout?: number; +} + +export async function backfillUser( + db: Database, + did: string, + collection: string, + deadline: number, + config?: ContrailConfig, + options?: BackfillOptions +): Promise<number> { + if (Date.now() >= deadline) return 0; + + const status = await db + .prepare( + "SELECT completed, pds_cursor, retries FROM backfills WHERE did = ? AND collection = ?" + ) + .bind(did, collection) + .first<{ completed: number; pds_cursor: string | null; retries: number }>(); + + if (status?.completed) return 0; + + if (!status) { + await db + .prepare( + "INSERT INTO backfills (did, collection, completed) VALUES (?, ?, 0) ON CONFLICT DO NOTHING" + ) + .bind(did, collection) + .run(); + } + + let currentCursor: string | undefined = status?.pds_cursor ?? undefined; + const retries = options?.maxRetries ?? 3; + const timeout = options?.requestTimeout ?? REQUEST_TIMEOUT_MS; + + if (!isDid(did)) { + await markFailed(db, did, collection, `Invalid DID: ${did}`); + return 0; + } + + if (!isNsid(collection)) { + await markFailed(db, did, collection, `Invalid NSID: ${collection}`); + return 0; + } + + let client = options?.client; + if (!client) { + try { + client = await withRetry( + () => getClient(did as Did, db, config), + `getClient(${did})`, + Math.min(retries, 1), + timeout + ); + } catch (err) { + await markFailed(db, did, collection, String(err)); + return 0; + } + } + + let totalInserted = 0; + let done = false; + + // Lookup subject filter once: if this collection declares a subjectField, we + // drop records whose subject DID isn't already in our identities table. + const collectionShort = config + ? shortNameForNsid(config, collection) + : undefined; + const subjectField = collectionShort + ? config?.collections[collectionShort]?.subjectField + : undefined; + + try { + while (Date.now() < deadline) { + const response = await withRetry( + () => + client!.get("com.atproto.repo.listRecords", { + params: { + repo: did as Did, + collection, + limit: PAGE_SIZE, + cursor: currentCursor, + }, + }), + `listRecords(${did}/${collection})`, + retries, + timeout + ); + if (!response.ok) { + await markFailed( + db, + did, + collection, + `listRecords status ${response.status}` + ); + return totalInserted; + } + + if (response.data.records.length === 0) { + done = true; + break; + } + + const now = Date.now(); + const nowUs = now * 1000; + let events: IngestEvent[] = response.data.records.map((r) => ({ + uri: r.uri, + did, + collection, + rkey: r.uri.split("/").pop()!, + operation: "create" as const, + cid: r.cid, + record: JSON.stringify(r.value), + time_us: recordTimeUs(r.value, collection, config, nowUs), + indexed_at: nowUs, + })); + + if (subjectField) { + events = await filterEventsBySubject(db, events, subjectField); + } + + if (events.length > 0) { + await applyEvents(db, events, config, { + skipReplayDetection: options?.skipReplayDetection, + skipFeedFanout: true, + // Let sinks bulk-flush differently from live ingestion. + phase: "backfill", + }); + } + totalInserted += events.length; + + currentCursor = response.data.cursor ?? undefined; + + await db + .prepare( + "UPDATE backfills SET pds_cursor = ? WHERE did = ? AND collection = ?" + ) + .bind(currentCursor ?? null, did, collection) + .run(); + + if (!currentCursor) { + done = true; + break; + } + } + } catch (err) { + await markFailed(db, did, collection, String(err)); + return totalInserted; + } + + if (done) { + await db + .prepare( + "UPDATE backfills SET completed = 1 WHERE did = ? AND collection = ?" + ) + .bind(did, collection) + .run(); + } + + return totalInserted; +} + +// --- Bulk backfill (groups by DID, resolves client once) --- + +export interface BackfillProgress { + records: number; + usersComplete: number; + usersTotal: number; + usersFailed: number; +} + +export interface BackfillAllOptions { + concurrency?: number; + onProgress?: (progress: BackfillProgress) => void; +} + +export async function backfillPending( + db: Database, + config: ContrailConfig, + options?: BackfillAllOptions +): Promise<number> { + const concurrency = options?.concurrency ?? 100; + let totalBackfilled = 0; + + // Anchor the jetstream cursor to now if it hasn't been set yet, so records + // emitted during backfill are replayed once jetstream starts. + if ((await getLastCursor(db)) === null) { + await saveCursor(db, Date.now() * 1000); + } + + // Reset retries so users that hit the cap in a prior run get another chance. + await db + .prepare("UPDATE backfills SET retries = 0 WHERE completed = 0") + .run(); + + while (true) { + const pending = await db + .prepare( + "SELECT did, collection FROM backfills WHERE completed = 0 AND retries < ? ORDER BY did" + ) + .bind(MAX_RETRIES) + .all<{ did: string; collection: string }>(); + + const rows = pending.results ?? []; + if (rows.length === 0) break; + + // Group by DID so we resolve PDS once per user + const byDid = new Map<string, string[]>(); + for (const row of rows) { + const cols = byDid.get(row.did) ?? []; + cols.push(row.collection); + byDid.set(row.did, cols); + } + + const dids = [...byDid.keys()]; + + // Resolve PDS endpoints up front (populates the in-memory cache) rather + // than concurrently with the backfill passes below. Overlapping the two put + // identity resolution and record backfill in contention for slingshot at + // once, and the partial responses that produced (a PDS without a handle) + // got persisted and stranded. Resolving first keeps that load separate. + for (let i = 0; i < dids.length; i += 200) { + await Promise.allSettled( + dids.slice(i, i + 200).map((did) => + getPDS(did as Did, db, config).catch(() => {}) + ) + ); + } + + let roundBackfilled = 0; + let usersComplete = 0; + let usersFailed = 0; + const failedDids: string[] = []; + + const FAST_TIMEOUT = 3_000; + + const emitProgress = () => + options?.onProgress?.({ + records: totalBackfilled + roundBackfilled, + usersComplete, + usersTotal: dids.length, + usersFailed, + }); + + // Fast pass: single attempt per user with short timeout + for (let i = 0; i < dids.length; i += concurrency) { + const batch = dids.slice(i, i + concurrency); + + const results = await Promise.allSettled( + batch.map(async (did) => { + let client: Client | undefined; + try { + client = await withRetry( + () => getClient(did as Did, db, config), + `getClient(${did})`, + 0, + FAST_TIMEOUT + ); + } catch { + failedDids.push(did); + return 0; + } + + const cols = byDid.get(did)!; + const counts = await Promise.all( + cols.map((col) => + backfillUser(db, did, col, Infinity, config, { + client, + skipReplayDetection: true, + maxRetries: 0, + requestTimeout: FAST_TIMEOUT, + }).catch(() => { + failedDids.push(did); + return 0; + }) + ) + ); + + usersComplete++; + return counts.reduce((a, b) => a + b, 0); + }) + ); + + for (const r of results) { + if (r.status === "fulfilled") roundBackfilled += r.value; + } + + emitProgress(); + } + + // Retry pass: failed DIDs get retries with backoff, still in concurrent batches + if (failedDids.length > 0) { + const uniqueFailed = [...new Set(failedDids)]; + usersComplete -= uniqueFailed.length; // don't count them yet + + for (let i = 0; i < uniqueFailed.length; i += concurrency) { + const batch = uniqueFailed.slice(i, i + concurrency); + + const results = await Promise.allSettled( + batch.map(async (did) => { + let client: Client | undefined; + try { + client = await withRetry( + () => getClient(did as Did, db, config), + `getClient(${did})`, + 2 + ); + } catch (err) { + for (const col of byDid.get(did)!) { + await markFailed(db, did, col, String(err)); + } + usersFailed++; + usersComplete++; + return 0; + } + + const cols = byDid.get(did)!; + const counts = await Promise.all( + cols.map((col) => + backfillUser(db, did, col, Infinity, config, { + client, + skipReplayDetection: true, + maxRetries: 2, + }) + ) + ); + usersComplete++; + return counts.reduce((a, b) => a + b, 0); + }) + ); + + for (const r of results) { + if (r.status === "fulfilled") roundBackfilled += r.value; + } + + emitProgress(); + } + } + + totalBackfilled += roundBackfilled; + + // If nothing was backfilled this round, we're stuck + if (roundBackfilled === 0) break; + } + + return totalBackfilled; +} + +// --- Discovery --- + +interface DiscoveryPage { + repos: { did: string }[]; + cursor?: string; +} + +async function fetchPage( + relay: string, + collection: string, + cursor?: string +): Promise<DiscoveryPage | null> { + const url = new URL( + `/xrpc/com.atproto.sync.listReposByCollection`, + relay + ); + url.searchParams.set("collection", collection); + url.searchParams.set("limit", "1000"); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + + try { + return await withRetry( + async () => { + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return (await response.json()) as DiscoveryPage; + }, + `fetchPage(${relay}, ${collection})` + ); + } catch (err) { + // Discovery page fetch failed after retries — skip this relay + return null; + } +} + +async function insertDiscoveredDIDs( + db: Database, + dids: string[], + collection: string +): Promise<void> { + if (dids.length === 0) return; + + // Use multi-row INSERT to reduce the number of statements + const CHUNK_SIZE = 50; + for (let i = 0; i < dids.length; i += CHUNK_SIZE) { + const chunk = dids.slice(i, i + CHUNK_SIZE); + const placeholders = chunk.map(() => "(?, ?, 0)").join(", "); + const bindings: string[] = []; + for (const did of chunk) { + bindings.push(did, collection); + } + await db + .prepare( + `INSERT INTO backfills (did, collection, completed) VALUES ${placeholders} ON CONFLICT DO NOTHING` + ) + .bind(...bindings) + .run(); + } +} + +async function saveDiscoveryState( + db: Database, + collection: string, + relay: string, + cursor: string | null, + completed: boolean +): Promise<void> { + await db + .prepare( + "INSERT INTO discovery (collection, relay, cursor, completed) VALUES (?, ?, ?, ?) ON CONFLICT(collection, relay) DO UPDATE SET cursor = excluded.cursor, completed = excluded.completed" + ) + .bind(collection, relay, cursor, completed ? 1 : 0) + .run(); +} + +export async function discoverDIDs( + db: Database, + config: ContrailConfig, + deadline: number +): Promise<string[]> { + const collections = getDiscoverableNsids(config); + const relays = config.relays ?? DEFAULT_RELAYS; + if (relays.length === 0 || collections.length === 0) return []; + + const discovered: string[] = []; + + for (const collection of collections) { + if (Date.now() >= deadline) break; + + let data: DiscoveryPage | null = null; + let relay: string | null = null; + + for (const r of relays) { + const row = await db + .prepare( + "SELECT cursor, completed FROM discovery WHERE collection = ? AND relay = ?" + ) + .bind(collection, r) + .first<{ cursor: string | null; completed: number }>(); + + if (row?.completed) continue; + + data = await fetchPage(r, collection, row?.cursor ?? undefined); + if (data) { + relay = r; + break; + } else { + await saveDiscoveryState(db, collection, r, null, true); + } + } + if (!data || !relay) continue; + + const dids = data.repos?.map((r) => r.did) ?? []; + await insertDiscoveredDIDs(db, dids, collection); + discovered.push(...dids); + + for (const depCollection of getDependentNsids(config)) { + await insertDiscoveredDIDs(db, dids, depCollection); + } + + const completed = !data.cursor; + await saveDiscoveryState(db, collection, relay, data.cursor ?? null, completed); + } + + return discovered; +} diff --git a/packages/contrail/src/core/client.ts b/packages/contrail/src/core/client.ts index 551c495..10c89fa 100644 --- a/packages/contrail/src/core/client.ts +++ b/packages/contrail/src/core/client.ts @@ -1 +1,254 @@ -export * from "@atmo-dev/contrail-appview"; +import { + CompositeDidDocumentResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, + type DidDocumentResolver, +} from "@atcute/identity-resolver"; +import { type Did } from "@atcute/lexicons"; +import { Client, simpleFetchHandler } from "@atcute/client"; +import type {} from "@atcute/atproto"; +import type { ContrailConfig, Database } from "./types"; + +// Slingshot-first PDS resolution with fallback to DID document resolution +const SLINGSHOT_URL = + "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc"; + +export interface ResolvedIdentity { + did: string; + handle: string | null; + pds: string | null; +} + +/** Reject external URLs (PDS, labeler, …) that point to private/internal + * addresses or non-HTTPS. The single SSRF guard shared across packages — + * callers MUST route every externally-resolved endpoint through this so the + * allowlist rules live in exactly one place. + * + * Hostnames in `additionalAllowedHosts` skip both checks. Match is exact, + * case-insensitive (allowlist entries are lowercased on compare; `URL.hostname` + * is already lowercased), and port-agnostic. + * + * Scope: best-effort guard against the obvious internal-address classes + * (private/link-local IPv4 literals, localhost, non-HTTPS). It does NOT + * resolve DNS, so a public hostname that resolves to a private address is not + * caught here, and IPv6 / non-canonical IP encodings are only partially + * covered. Defense-in-depth (egress network policy) is expected when resolver + * inputs are fully untrusted. */ +export function validateExternalUrl(url: string, additionalAllowedHosts?: string[]): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (additionalAllowedHosts?.some((h) => h.toLowerCase() === parsed.hostname)) { + return true; + } + if (parsed.protocol !== "https:") return false; + const host = parsed.hostname; + // Block private/internal IP ranges + if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return false; + if (host.startsWith("10.")) return false; + if (host.startsWith("192.168.")) return false; + if (host.startsWith("169.254.")) return false; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return false; + return true; +} + +async function resolveViaSlingshot( + identifier: string, + slingshotUrl: string, +): Promise<ResolvedIdentity | undefined> { + const url = new URL(slingshotUrl); + url.searchParams.set("identifier", identifier); + + try { + const response = await fetch(url.toString()); + if (!response.ok) return undefined; + const data = (await response.json()) as { + did?: string; + handle?: string; + pds?: string; + }; + if (!data.did && !data.pds) return undefined; + return { + did: data.did ?? identifier, + handle: data.handle ?? null, + pds: data.pds ?? null, + }; + } catch { + return undefined; + } +} + +const DEFAULT_DID_RESOLVER: DidDocumentResolver = new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver(), + }, +}); + +async function getPDSViaDidDoc( + did: Did, + config?: ContrailConfig, +): Promise<string | undefined> { + const resolver = config?.networkOverrides?.resolver ?? DEFAULT_DID_RESOLVER; + const doc = await resolver.resolve(did as Did<"plc"> | Did<"web">); + return doc.service + ?.find((s) => s.id === "#atproto_pds") + ?.serviceEndpoint.toString(); +} + +/** + * Resolve identity info (did, handle, pds) for a DID or handle. + * Uses slingshot first, falls back to DID doc for PDS. + * + * `config?.networkOverrides` (optional): customize the slingshot endpoint, + * the PLC URL used during DID-doc fallback, and/or which hostnames bypass + * the default SSRF guard. Omitting `config` preserves all defaults. + */ +export async function resolvePDS( + identifier: string, + config?: ContrailConfig, +): Promise<ResolvedIdentity | undefined> { + const slingshotUrl = config?.networkOverrides?.slingshotUrl ?? SLINGSHOT_URL; + const allowed = config?.networkOverrides?.additionalAllowedHosts; + const result = await resolveViaSlingshot(identifier, slingshotUrl); + if (result?.pds) { + if (!validateExternalUrl(result.pds, allowed)) return { ...result, pds: null }; + return result; + } + + // Fall back to DID doc resolution (only works for DIDs, not handles) + if (identifier.startsWith("did:")) { + try { + const pds = await getPDSViaDidDoc(identifier as Did, config); + if (pds && validateExternalUrl(pds, allowed)) { + return { + did: identifier, + handle: result?.handle ?? null, + pds, + }; + } + } catch { + // ignore + } + } + + return result; +} + +// In-memory PDS cache with TTL + size limit, plus in-flight deduplication. +// +// Keyed by DID only — this assumes a single, process-wide `networkOverrides` +// config (the deployment model: one resolver + one SSRF allowlist per process). +// Every caller in this monorepo now threads the same in-scope `config`, so a +// config-less and an override-aware resolution can never race for the same DID. +// If a future deployment ever resolves the same DID under differing overrides +// in one process, key these caches by an override fingerprint instead. +const PDS_CACHE_TTL = 60 * 60 * 1000; // 1 hour +const PDS_CACHE_MAX = 10_000; +const pdsCache = new Map<string, { pds: string; at: number }>(); +const pdsInflight = new Map<string, Promise<string | undefined>>(); + +function pdsCacheGet(did: string): string | undefined { + const entry = pdsCache.get(did); + if (!entry) return undefined; + if (Date.now() - entry.at > PDS_CACHE_TTL) { + pdsCache.delete(did); + return undefined; + } + return entry.pds; +} + +function pdsCacheSet(did: string, pds: string): void { + // Evict oldest entries if over limit + if (pdsCache.size >= PDS_CACHE_MAX) { + const first = pdsCache.keys().next().value; + if (first) pdsCache.delete(first); + } + pdsCache.set(did, { pds, at: Date.now() }); +} + +export async function getPDS( + did: Did, + db?: Database, + config?: ContrailConfig, +): Promise<string | undefined> { + const mem = pdsCacheGet(did); + if (mem) return mem; + + // Deduplicate concurrent calls for the same DID + const inflight = pdsInflight.get(did); + if (inflight) return inflight; + + const promise = resolvePDSCached(did, db, config); + pdsInflight.set(did, promise); + try { + return await promise; + } finally { + pdsInflight.delete(did); + } +} + +async function resolvePDSCached( + did: Did, + db?: Database, + config?: ContrailConfig, +): Promise<string | undefined> { + let knownPds: string | undefined; + if (db) { + const cached = await db + .prepare("SELECT pds, handle FROM identities WHERE did = ? AND pds IS NOT NULL") + .bind(did) + .first<{ pds: string; handle: string | null }>(); + if (cached?.pds) { + pdsCacheSet(did, cached.pds); + // A row with both a PDS and a handle is a complete cache hit. A row with + // a PDS but no handle is a *partial* resolution — slingshot can return a + // PDS without a handle under load — so fall through to re-resolve and + // fill the handle instead of stranding the row forever (this DB + // short-circuit previously meant the handle was never backfilled). We + // keep serving the known PDS meanwhile, including if the re-resolve fails. + if (cached.handle) return cached.pds; + knownPds = cached.pds; + } + } + + const resolved = await resolvePDS(did, config); + if (!resolved?.pds) return knownPds; + + pdsCacheSet(did, resolved.pds); + + // Persist to DB for future runs. COALESCE keeps an existing handle when this + // resolution didn't return one, and never nulls a good handle. + if (db) { + await db + .prepare( + "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?) ON CONFLICT(did) DO UPDATE SET pds = excluded.pds, handle = COALESCE(excluded.handle, identities.handle), resolved_at = excluded.resolved_at" + ) + .bind(did, resolved.handle, resolved.pds, Date.now()) + .run(); + } + + return resolved.pds; +} + +export async function getClient( + did: Did, + db?: Database, + config?: ContrailConfig, +): Promise<Client> { + const pds = await getPDS(did, db, config); + if (!pds) throw new Error(`PDS not found for ${did}`); + return new Client({ + handler: simpleFetchHandler({ service: pds }), + }); +} + +/** Test-only: clear module-level PDS caches. Production code MUST NOT call this. + * Exported with a `__` prefix to signal it is not part of the public API. */ +export function __resetPdsCachesForTests(): void { + pdsCache.clear(); + pdsInflight.clear(); +} diff --git a/packages/contrail/src/core/community-integration.ts b/packages/contrail/src/core/community-integration.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/community-integration.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/constellation.ts b/packages/contrail/src/core/constellation.ts index 551c495..5812685 100644 --- a/packages/contrail/src/core/constellation.ts +++ b/packages/contrail/src/core/constellation.ts @@ -1 +1,181 @@ -export * from "@atmo-dev/contrail-appview"; +import { isDid } from "@atcute/lexicons/syntax"; +import type { ContrailConfig, Database, Logger } from "./types"; +import { + DEFAULT_CONSTELLATION_URL, + DEFAULT_FOLLOW_NSID, + recordsTableName, + shortNameForNsid, +} from "./types"; + +const PAGE_LIMIT = 100; +const DID_FILTER_CHUNK = 50; + +interface BacklinksPage { + links?: Array<{ + did?: string; + rkey?: string; + /** Some Constellation versions return the full URI rather than did/rkey split. */ + uri?: string; + }>; + cursor?: string; +} + +function getLogger(config: ContrailConfig): Logger { + return config.logger ?? console; +} + +/** Resolve effective Constellation config; null when disabled. */ +function getConstellationSettings( + config: ContrailConfig +): { url: string; userAgent: string } | null { + const c = config.constellation; + if (c === false) return null; + if (c?.enabled === false) return null; + return { + url: c?.url ?? DEFAULT_CONSTELLATION_URL, + userAgent: c?.userAgent ?? `contrail/${config.namespace}`, + }; +} + +/** Find the configured short name for `app.bsky.graph.follow`, if any. */ +function getFollowShort(config: ContrailConfig): string | null { + const short = shortNameForNsid(config, DEFAULT_FOLLOW_NSID); + return short ?? null; +} + +interface BacklinkRow { + did: string; + rkey: string; + uri: string; +} + +function parseBacklink(entry: NonNullable<BacklinksPage["links"]>[number]): BacklinkRow | null { + if (entry.did && entry.rkey && isDid(entry.did)) { + return { + did: entry.did, + rkey: entry.rkey, + uri: entry.uri ?? `at://${entry.did}/${DEFAULT_FOLLOW_NSID}/${entry.rkey}`, + }; + } + if (entry.uri) { + const m = /^at:\/\/(did:[^/]+)\/[^/]+\/([^/]+)$/.exec(entry.uri); + if (m && isDid(m[1])) { + return { did: m[1], rkey: m[2], uri: entry.uri }; + } + } + return null; +} + +/** Filter a candidate-follower DID list to those already in our identities table. */ +async function filterKnownDids( + db: Database, + candidates: string[] +): Promise<Set<string>> { + const known = new Set<string>(); + for (let i = 0; i < candidates.length; i += DID_FILTER_CHUNK) { + const chunk = candidates.slice(i, i + DID_FILTER_CHUNK); + const placeholders = chunk.map(() => "?").join(","); + const rows = await db + .prepare(`SELECT did FROM identities WHERE did IN (${placeholders})`) + .bind(...chunk) + .all<{ did: string }>(); + for (const r of rows.results ?? []) known.add(r.did); + } + return known; +} + +/** Fetch one page of getBacklinks. Returns null on non-2xx (caller decides whether to bail). */ +async function fetchBacklinksPage( + url: string, + userAgent: string, + subject: string, + cursor?: string +): Promise<BacklinksPage | null> { + const u = new URL("/xrpc/blue.microcosm.links.getBacklinks", url); + u.searchParams.set("subject", subject); + u.searchParams.set("source", `${DEFAULT_FOLLOW_NSID}:.subject`); + u.searchParams.set("limit", String(PAGE_LIMIT)); + if (cursor) u.searchParams.set("cursor", cursor); + try { + const res = await fetch(u.toString(), { + headers: { "user-agent": userAgent, accept: "application/json" }, + }); + if (!res.ok) return null; + return (await res.json()) as BacklinksPage; + } catch { + return null; + } +} + +/** For a newly-known subject DID, find existing followers via Constellation + * and ingest synthesized follow records into the configured follow table. + * Best-effort: failures are logged but not retried (caller can re-trigger). */ +export async function backfillFollowersFromConstellation( + db: Database, + config: ContrailConfig, + subjectDid: string +): Promise<number> { + const settings = getConstellationSettings(config); + if (!settings) return 0; + if (!isDid(subjectDid)) return 0; + const followShort = getFollowShort(config); + if (!followShort) return 0; + const log = getLogger(config); + + const followTable = recordsTableName(followShort); + const recordJson = JSON.stringify({ + $type: DEFAULT_FOLLOW_NSID, + subject: subjectDid, + createdAt: new Date().toISOString(), + }); + const nowUs = Date.now() * 1000; + + let cursor: string | undefined; + let inserted = 0; + let pages = 0; + + while (true) { + const page = await fetchBacklinksPage( + settings.url, + settings.userAgent, + subjectDid, + cursor + ); + if (!page) break; + pages++; + + const rows: BacklinkRow[] = (page.links ?? []) + .map(parseBacklink) + .filter((r): r is BacklinkRow => r !== null && r.did !== subjectDid); + + if (rows.length > 0) { + const known = await filterKnownDids( + db, + rows.map((r) => r.did) + ); + const survivors = rows.filter((r) => known.has(r.did)); + + for (const r of survivors) { + const result = await db + .prepare( + `INSERT INTO ${followTable} (uri, did, rkey, cid, record, time_us, indexed_at) + VALUES (?, ?, ?, NULL, ?, ?, ?) + ON CONFLICT(uri) DO NOTHING` + ) + .bind(r.uri, r.did, r.rkey, recordJson, nowUs, nowUs) + .run(); + inserted += (result as { changes?: number })?.changes ?? 0; + } + } + + cursor = page.cursor ?? undefined; + if (!cursor) break; + } + + if (inserted > 0) { + log.log( + `[constellation] subject=${subjectDid} pages=${pages} inserted=${inserted}` + ); + } + return inserted; +} diff --git a/packages/contrail/src/core/db/index.ts b/packages/contrail/src/core/db/index.ts index 551c495..b7a04cb 100644 --- a/packages/contrail/src/core/db/index.ts +++ b/packages/contrail/src/core/db/index.ts @@ -1 +1,6 @@ -export * from "@atmo-dev/contrail-appview"; +export { initSchema, CONTRAIL_SCHEMA_VERSION } from "./schema"; +export { getMeta, setMeta, getMetaNumber } from "./meta"; +export { optimizeDatabase } from "./optimize"; +export { getLastCursor, saveCursor, applyEvents, lookupExistingRecords, queryRecords, pruneFeedItems, pruneActorFeed, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor } from "./records"; +export type { QueryOptions, SortOption, ExistingRecordInfo, FeedSweepResult } from "./records"; +export type { RecordSource } from "../types"; diff --git a/packages/contrail-appview/src/core/db/meta.ts b/packages/contrail/src/core/db/meta.ts similarity index 100% rename from packages/contrail-appview/src/core/db/meta.ts rename to packages/contrail/src/core/db/meta.ts diff --git a/packages/contrail-appview/src/core/db/optimize.ts b/packages/contrail/src/core/db/optimize.ts similarity index 100% rename from packages/contrail-appview/src/core/db/optimize.ts rename to packages/contrail/src/core/db/optimize.ts diff --git a/packages/contrail/src/core/db/records.ts b/packages/contrail/src/core/db/records.ts index 551c495..3b1a380 100644 --- a/packages/contrail/src/core/db/records.ts +++ b/packages/contrail/src/core/db/records.ts @@ -1 +1,980 @@ -export * from "@atmo-dev/contrail-appview"; +import type { + ContrailConfig, + ResolvedContrailConfig, + RelationConfig, + Database, + Statement, + IngestEvent, + RecordRow, + RecordSource, +} from "../types"; +import type { RecordEvent } from "../sinks/types"; +import { + getNestedValue, + getRelationField, + countColumnName, + groupedCountColumnName, + getFeedFollowShortNames, + recordsTableName, + shortNameForNsid, + resolveCollectionKey, + nsidForShortName, + normalizeFeedTarget, + feedTargetMaxItems, + DEFAULT_FOLLOW_SHORT, +} from "../types"; +import { getSearchableFields, ftsTableName, buildFtsContent } from "../search"; +import { ftsQueryClause, getDialect } from "../dialect"; + +// --- Counts --- + +interface InboundRelation { + /** Short name of the parent collection. */ + parentCollection: string; + relationName: string; + rel: RelationConfig; +} + +/** Find relations that target the given short-named child collection. */ +function getInboundRelations( + config: ContrailConfig, + childShortName: string +): InboundRelation[] { + const results: InboundRelation[] = []; + for (const [colName, colConfig] of Object.entries(config.collections)) { + for (const [relName, rel] of Object.entries(colConfig.relations ?? {})) { + if (rel.collection === childShortName) { + results.push({ parentCollection: colName, relationName: relName, rel }); + } + } + } + return results; +} + +/** + * Collect recount targets from a single event into a shared map. + * The map is keyed by `parentCollection:relationName:targetValue` to deduplicate + * across the entire batch — so 50 RSVPs to the same event produce one recount, not 50. + */ +function collectCountTargets( + event: IngestEvent, + config: ContrailConfig, + existingRecordJson: string | null, + targets: Map<string, { parentCollection: string; relationName: string; rel: RelationConfig; targetValue: string }> +): void { + const childShort = shortNameForNsid(config, event.collection); + if (!childShort) return; + const inbound = getInboundRelations(config, childShort); + if (inbound.length === 0) return; + + const record = event.record ? JSON.parse(event.record) : null; + const existingRecord = existingRecordJson ? JSON.parse(existingRecordJson) : null; + + for (const { parentCollection, relationName, rel } of inbound) { + if (rel.count === false) continue; + + const field = getRelationField(rel); + + const values: string[] = []; + if (record) { + const t = getNestedValue(record, field); + if (t) values.push(t); + } + if (existingRecord) { + const t = getNestedValue(existingRecord, field); + if (t && !values.includes(t)) values.push(t); + } + + for (const targetValue of values) { + const key = `${parentCollection}:${relationName}:${targetValue}`; + if (!targets.has(key)) { + targets.set(key, { parentCollection, relationName, rel, targetValue }); + } + } + } +} + +/** + * Build deduplicated count UPDATE statements from collected targets. + * One UPDATE per unique parent+relation+target, regardless of how many + * events in the batch affected that target. + */ +function buildBatchCountStatements( + db: Database, + config: ContrailConfig, + targets: Map<string, { parentCollection: string; relationName: string; rel: RelationConfig; targetValue: string }> +): Statement[] { + const statements: Statement[] = []; + + for (const { parentCollection, relationName, rel, targetValue } of targets.values()) { + const field = getRelationField(rel); + const matchColumn = rel.match === "did" ? "did" : "uri"; + const childTable = recordsTableName(rel.collection); + const parentTable = recordsTableName(parentCollection); + + const setClauses: string[] = []; + const setBindings: (string | number)[] = []; + + const countExpr = rel.countDistinct + ? `COUNT(DISTINCT ${rel.countDistinct})` + : "COUNT(*)"; + + // Total count + const totalCol = countColumnName(rel.collection); + setClauses.push( + `${totalCol} = (SELECT ${countExpr} FROM ${childTable} WHERE ${getDialect(db).jsonExtract('record', field)} = ?)` + ); + setBindings.push(targetValue); + + // Grouped counts — column names are `count_<child-short>_<group-key>`; match + // against the group's full token value in the record. + if (rel.groupBy) { + const mapping = (config as ResolvedContrailConfig)._resolved?.relations[parentCollection]?.[relationName]; + if (mapping?.groups) { + for (const [groupKey, fullToken] of Object.entries(mapping.groups)) { + const groupCol = groupedCountColumnName(rel.collection, groupKey); + setClauses.push( + `${groupCol} = (SELECT ${countExpr} FROM ${childTable} WHERE ${getDialect(db).jsonExtract('record', field)} = ? AND ${getDialect(db).jsonExtract('record', rel.groupBy)} = ?)` + ); + setBindings.push(targetValue, fullToken); + } + } + } + + if (setClauses.length > 0) { + statements.push( + db + .prepare( + `UPDATE ${parentTable} SET ${setClauses.join(", ")} WHERE ${matchColumn} = ?` + ) + .bind(...setBindings, targetValue) + ); + } + } + + return statements; +} + +// --- FTS --- + +function buildFtsStatements( + db: Database, + event: IngestEvent, + config: ContrailConfig +): Statement[] { + // PostgreSQL: tsvector generated column is auto-maintained, no manual FTS sync + if (getDialect(db).ftsStrategy === "generated-column") return []; + + const short = resolveCollectionKey(config, event.collection); + if (!short) return []; + const colConfig = config.collections[short]; + if (!colConfig) return []; + + const fields = getSearchableFields(short, colConfig); + if (!fields || fields.length === 0) return []; + + const table = ftsTableName(short); + const stmts: Statement[] = []; + + if (event.operation === "delete") { + stmts.push(db.prepare(`DELETE FROM ${table} WHERE uri = ?`).bind(event.uri)); + } else { + const record = event.record ? JSON.parse(event.record) : null; + if (!record) return []; + + // Always delete first so FTS sync is idempotent. The FTS virtual table has no + // uniqueness constraint, so a bare insert appends a duplicate row when one + // already exists. existingMap is unreliable here: backfill runs with + // skipReplayDetection, leaving it empty, so a re-applied record would look new + // and accumulate duplicate rows that fan out the search JOIN. The delete is + // unconditional so it also evicts a stale row when an update clears all + // searchable fields (content is null); only the re-insert is gated on content. + stmts.push(db.prepare(`DELETE FROM ${table} WHERE uri = ?`).bind(event.uri)); + + const content = buildFtsContent(record, fields); + if (content) { + stmts.push( + db.prepare(`INSERT INTO ${table} (uri, content) VALUES (?, ?)`).bind(event.uri, content) + ); + } + } + + return stmts; +} + +// --- Feeds --- + +function buildFeedStatements( + db: Database, + event: IngestEvent, + config: ContrailConfig, + existingRecords: Map<string, string | null> +): Statement[] { + if (!config.feeds) return []; + + const stmts: Statement[] = []; + + const eventShort = shortNameForNsid(config, event.collection); + if (!eventShort) return []; + + for (const [, feedConfig] of Object.entries(config.feeds)) { + const followShort = feedConfig.follow ?? DEFAULT_FOLLOW_SHORT; + const followTable = recordsTableName(followShort); + const targets = feedConfig.targets.map(normalizeFeedTarget); + const targetShorts = targets.map((t) => t.collection); + + // Target collection: fan out to followers + if (targetShorts.includes(eventShort)) { + if (event.operation === "create" || event.operation === "update") { + stmts.push( + db + .prepare( + getDialect(db).insertOrIgnore( + `INSERT INTO feed_items (actor, uri, collection, time_us) + SELECT r.did, ?, ?, ? + FROM ${followTable} r + WHERE ${getDialect(db).jsonExtract('r.record', 'subject')} = ?` + ) + ) + .bind(event.uri, event.collection, event.time_us, event.did) + ); + } else if (event.operation === "delete") { + stmts.push( + db.prepare("DELETE FROM feed_items WHERE uri = ?").bind(event.uri) + ); + } + } + + // Follow collection: handle follow/unfollow + if (eventShort === followShort) { + if (event.operation === "create") { + const record = event.record ? JSON.parse(event.record) : null; + const subject = record?.subject; + if (subject) { + for (const target of targets) { + const targetTable = recordsTableName(target.collection); + const targetNsid = nsidForShortName(config, target.collection) ?? target.collection; + const cap = feedTargetMaxItems(feedConfig, target); + stmts.push( + db + .prepare( + getDialect(db).insertOrIgnore( + `INSERT INTO feed_items (actor, uri, collection, time_us) + SELECT ?, r.uri, ?, r.time_us + FROM ${targetTable} r + WHERE r.did = ? + ORDER BY r.time_us DESC + LIMIT ${cap}` + ) + ) + .bind(event.did, targetNsid, subject) + ); + } + } + } else if (event.operation === "delete") { + const existingRecord = existingRecords.get(event.uri); + if (existingRecord) { + const parsed = JSON.parse(existingRecord); + const subject = parsed?.subject; + if (subject) { + for (const target of targets) { + const targetTable = recordsTableName(target.collection); + stmts.push( + db + .prepare( + `DELETE FROM feed_items WHERE actor = ? AND uri IN ( + SELECT uri FROM ${targetTable} WHERE did = ? + )` + ) + .bind(event.did, subject) + ); + } + } + } + } + } + } + + return stmts; +} + +// --- Feed pruning --- + +/** db.batch chunk size for the sweep — caps statements per transaction. */ +const SWEEP_BATCH_SIZE = 50; +/** Actor page size for the full-table {@link pruneFeedItems} recovery loop. */ +const FEED_PRUNE_RECOVERY_BATCH = 200; + +/** + * Build the bounded per-actor cutoff DELETE for one (actor, collection). + * + * Deletes everything older than the newest `cap` rows, driven directly by + * idx_feed_actor_coll_time(actor, collection, time_us DESC). Cost is + * O(cap + deleted) — never O(table). This is the ONLY prune shape contrail + * issues: an unbounded window/anti-join over the whole table can exhaust D1's + * per-query CPU budget and reset the shared Durable Object, which kills any + * concurrent read against the same SQLite instance. + * + * The cutoff is the cap-th newest row (`OFFSET cap - 1`); we delete strictly + * older rows. Actors with `cap` or fewer rows: the OFFSET subquery yields no + * row, the cutoff is NULL, and `time_us < NULL` matches nothing — a cheap + * index no-op. On a tie at the cutoff time_us we keep the extra rows rather + * than risk deleting a row we meant to keep (feed_items is a cache; a few over + * cap is harmless, dropping a wanted item is not). + */ +function actorCutoffDelete( + db: Database, + actor: string, + collection: string, + cap: number +): Statement { + // Plain `?` placeholders (bound repeatedly) rather than numbered params, so + // the Postgres adapter's positional `?`→`$n` rewrite stays correct. + return db + .prepare( + `DELETE FROM feed_items + WHERE actor = ? AND collection = ? + AND time_us < ( + SELECT time_us FROM feed_items + WHERE actor = ? AND collection = ? + ORDER BY time_us DESC LIMIT 1 OFFSET ? + )` + ) + .bind(actor, collection, actor, collection, Math.max(0, cap - 1)); +} + +/** Prune a single actor's feed for one collection to `cap`. Bounded O(cap). */ +export async function pruneActorFeed( + db: Database, + actor: string, + collection: string, + cap: number +): Promise<number> { + const result = await actorCutoffDelete(db, actor, collection, cap).run(); + return (result as any)?.changes ?? 0; +} + +export interface FeedSweepResult { + /** Rows deleted this slice. */ + pruned: number; + /** Actor to resume after; null once a full pass completed (wrap to start). */ + nextCursor: string | null; + /** True when this slice reached the end of the actor list. */ + done: boolean; +} + +/** + * One bounded slice of a rolling feed-items prune. + * + * Pages at most `actorBudget` distinct actors (resuming after `cursor`, via the + * feed_items (actor, uri) PK) and applies the per-(actor, collection) cutoff + * delete for every cap in `caps`. Every issued statement is index-backed and + * O(cap), so the slice's per-query CPU stays flat no matter how large + * feed_items grows — the property the old global window query lacked. + * + * Drive it across ticks with a persisted cursor (see getFeedPruneCursor): + * feed back `nextCursor` until `done`, at which point the cursor wraps to null + * and the next pass starts from the beginning. Because each pass visits every + * actor, this doubles as the recovery path for an already-bloated table. + */ +export async function sweepFeedItems( + db: Database, + caps: Map<string, number>, + cursor: string | null, + actorBudget: number +): Promise<FeedSweepResult> { + if (caps.size === 0 || actorBudget <= 0) { + return { pruned: 0, nextCursor: null, done: true }; + } + + const actorsRes = cursor + ? await db + .prepare( + "SELECT DISTINCT actor FROM feed_items WHERE actor > ? ORDER BY actor LIMIT ?" + ) + .bind(cursor, actorBudget) + .all<{ actor: string }>() + : await db + .prepare("SELECT DISTINCT actor FROM feed_items ORDER BY actor LIMIT ?") + .bind(actorBudget) + .all<{ actor: string }>(); + + const actors = (actorsRes.results ?? []).map((r) => r.actor); + if (actors.length === 0) { + // Ran off the end (cursor pointed past the last actor) — wrap next tick. + return { pruned: 0, nextCursor: null, done: true }; + } + + const stmts: Statement[] = []; + for (const actor of actors) { + for (const [collection, cap] of caps) { + stmts.push(actorCutoffDelete(db, actor, collection, cap)); + } + } + + let pruned = 0; + for (let i = 0; i < stmts.length; i += SWEEP_BATCH_SIZE) { + const results = await db.batch(stmts.slice(i, i + SWEEP_BATCH_SIZE)); + for (const r of results) pruned += (r as any)?.changes ?? 0; + } + + // A short page means we exhausted the actor list this slice. + const done = actors.length < actorBudget; + return { pruned, nextCursor: done ? null : actors[actors.length - 1], done }; +} + +/** + * Prune the ENTIRE feed_items table to the per-collection `caps` by looping the + * bounded {@link sweepFeedItems} until a full pass completes. + * + * Every statement is O(cap) and safe against D1's per-query CPU limit, but the + * statement count is O(distinct actors), so keep this OFF the hot ingest path — + * the cron/persistent loops issue a single bounded slice per tick instead. Use + * it for one-shot recovery or admin tooling. + */ +export async function pruneFeedItems( + db: Database, + caps: Map<string, number> +): Promise<number> { + let total = 0; + let cursor: string | null = null; + for (;;) { + const res = await sweepFeedItems(db, caps, cursor, FEED_PRUNE_RECOVERY_BATCH); + total += res.pruned; + if (res.done) break; + cursor = res.nextCursor; + } + return total; +} + +// --- Feed prune cursor --- + +/** Last actor swept by the rolling feed prune; null = start of a fresh pass. */ +export async function getFeedPruneCursor(db: Database): Promise<string | null> { + const row = await db + .prepare("SELECT actor FROM feed_prune_cursor WHERE id = 1") + .first<{ actor: string | null }>(); + return row?.actor ?? null; +} + +export async function saveFeedPruneCursor( + db: Database, + actor: string | null +): Promise<void> { + await db + .prepare( + "INSERT INTO feed_prune_cursor (id, actor) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET actor = excluded.actor" + ) + .bind(actor) + .run(); +} + +// --- Cursor --- + +export async function getLastCursor(db: Database): Promise<number | null> { + const row = await db + .prepare("SELECT time_us FROM cursor WHERE id = 1") + .first<{ time_us: number }>(); + return row ? row.time_us : null; +} + +export async function saveCursor( + db: Database, + timeUs: number +): Promise<void> { + await db + .prepare( + "INSERT INTO cursor (id, time_us) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET time_us = excluded.time_us" + ) + .bind(timeUs) + .run(); +} + +// --- Existing record lookup --- + +export interface ExistingRecordInfo { + cid: string | null; + record: string | null; + /** When the row was last written to our DB (microseconds). Populated + * whenever `lookupExistingRecords` runs, regardless of `includeRecord`. */ + indexed_at: number | null; +} + +/** + * Look up existing records for a set of events, grouped by collection. + * Returns a map of uri → { cid, record }. + * When includeRecord is false, record will always be null (saves reading large blobs). + */ +export async function lookupExistingRecords( + db: Database, + events: { uri: string; collection: string }[], + includeRecord: boolean = true, + config?: ContrailConfig +): Promise<Map<string, ExistingRecordInfo>> { + const result = new Map<string, ExistingRecordInfo>(); + if (events.length === 0) return result; + + // Group by short name (config lookup); skip events for collections not in our config. + const byShort = new Map<string, string[]>(); + for (const e of events) { + const short = config ? resolveCollectionKey(config, e.collection) : e.collection; + if (!short) continue; + const uris = byShort.get(short) ?? []; + uris.push(e.uri); + byShort.set(short, uris); + } + + const selectCols = includeRecord ? "uri, cid, record, indexed_at" : "uri, cid, indexed_at"; + for (const [short, uris] of byShort) { + const table = recordsTableName(short); + for (let i = 0; i < uris.length; i += 50) { + const chunk = uris.slice(i, i + 50); + const placeholders = chunk.map(() => "?").join(","); + const rows = await db + .prepare(`SELECT ${selectCols} FROM ${table} WHERE uri IN (${placeholders})`) + .bind(...chunk) + .all<{ + uri: string; + cid: string | null; + record?: string | null; + indexed_at: number | null; + }>(); + for (const row of rows.results ?? []) { + result.set(row.uri, { + cid: row.cid, + record: includeRecord ? (row.record ?? null) : null, + indexed_at: row.indexed_at ?? null, + }); + } + } + } + + return result; +} + +// --- Events --- + +export async function applyEvents( + db: Database, + events: IngestEvent[], + config?: ContrailConfig, + options?: { + skipReplayDetection?: boolean; + skipFeedFanout?: boolean; + /** Pre-fetched existing records — skips the internal lookup when provided */ + existing?: Map<string, ExistingRecordInfo>; + /** Ingest phase forwarded to `config.sinks`. `"live"` for jetstream / + * persistent ingest (default), `"backfill"` for replay / rebuild. */ + phase?: "live" | "backfill"; + } +): Promise<void> { + if (events.length === 0) return; + + const followCollections = config ? getFeedFollowShortNames(config) : []; + const hasCountingRelations = config ? Object.values(config.collections).some(c => + Object.values(c.relations ?? {}).some(r => r.count !== false) + ) : false; + const needRecordContent = followCollections.length > 0 || hasCountingRelations; + + // Use pre-fetched data or look up existing records + let existingMap: Map<string, ExistingRecordInfo>; + if (options?.existing) { + existingMap = options.existing; + } else if (config && !options?.skipReplayDetection) { + existingMap = await lookupExistingRecords(db, events, needRecordContent, config); + } else { + existingMap = new Map(); + } + + const batch: Statement[] = []; + + // Build a record-content map for feed statements (needs string values) + const existingRecordStrings = new Map<string, string | null>(); + for (const [uri, info] of existingMap) { + existingRecordStrings.set(uri, info.record); + } + + // Collect all count recount targets across the batch, deduplicated + const countTargets = new Map<string, { parentCollection: string; relationName: string; rel: RelationConfig; targetValue: string }>(); + + for (const e of events) { + // Event's collection is an NSID. Resolve its storage key from config. + // If no config, treat collection string as-is (for tests that pre-populate tables). + const short = config ? resolveCollectionKey(config, e.collection) : e.collection; + if (!short) { + (config?.logger ?? console).warn( + `[ingest] drop (unknown collection in applyEvents): ${e.operation} ${e.uri} collection=${e.collection}` + ); + continue; + } + const table = recordsTableName(short); + + if (e.operation === "delete") { + batch.push(db.prepare(`DELETE FROM ${table} WHERE uri = ?`).bind(e.uri)); + } else { + batch.push( + db.prepare( + `INSERT INTO ${table} (uri, did, rkey, cid, record, time_us, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(uri) DO UPDATE SET cid = excluded.cid, record = excluded.record, time_us = excluded.time_us, indexed_at = excluded.indexed_at` + ).bind( + e.uri, + e.did, + e.rkey, + e.cid, + e.record, + e.time_us, + e.indexed_at + ) + ); + } + + if (config) { + // Collect count targets (deduplicated across the whole batch) + const existingRecordJson = existingMap.get(e.uri)?.record ?? null; + collectCountTargets(e, config, existingRecordJson, countTargets); + + // Feed fanout still needs replay detection + const existingInfo = existingMap.get(e.uri); + const isReplay = + e.operation === "delete" + ? existingInfo === undefined + : existingInfo?.cid === e.cid; + + if (!isReplay && !options?.skipFeedFanout) { + batch.push(...buildFeedStatements(db, e, config, existingRecordStrings)); + } + batch.push(...buildFtsStatements(db, e, config)); + } + } + + // Build deduplicated count statements — one UPDATE per unique target + if (config) { + batch.push(...buildBatchCountStatements(db, config, countTargets)); + } + + await db.batch(batch); + + // Fan out to write-only sinks (derived indexes, audit logs, webhooks). + // This fires on both the live and backfill + // paths (driven by `options.phase`), carries one deduplicated event per + // record, and isolates failures so a throwing sink never blocks ingestion. + const sinks = config?.sinks; + if (sinks && sinks.length > 0) { + const records: RecordEvent[] = events.map((e) => + e.operation === "delete" + ? { kind: "deleted", uri: e.uri, did: e.did, collection: e.collection, rkey: e.rkey } + : { + kind: "created", + uri: e.uri, + did: e.did, + collection: e.collection, + rkey: e.rkey, + cid: e.cid, + record: e.record ? safeParseJson(e.record) : {}, + time_us: e.time_us, + } + ); + const ctx = { phase: options?.phase ?? "live" } as const; + const logger = config?.logger ?? console; + for (const sink of sinks) { + try { + await sink.onRecords(records, ctx); + } catch (err) { + logger.error("[sink] onRecords failed", err); + } + } + } +} + +function safeParseJson(s: string): Record<string, unknown> { + try { + const v = JSON.parse(s); + return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {}; + } catch { + return {}; + } +} + +// --- Count columns --- + +/** Count column descriptor. `type` is the identifier returned in API responses and + * accepted in countFilters — we keep the full record token for grouped counts so + * callers pass e.g. "community.lexicon.calendar.rsvp#going" and filter/hydrate by it. */ +function getCountColumns( + config: ContrailConfig, + shortName: string +): { type: string; column: string }[] { + const colConfig = config.collections[shortName]; + if (!colConfig?.relations) return []; + const columns: { type: string; column: string }[] = []; + const relMap = (config as ResolvedContrailConfig)._resolved?.relations[shortName] ?? {}; + + for (const [relName, rel] of Object.entries(colConfig.relations)) { + if (rel.count === false) continue; + // Total: identifier is the child's short name; column is `count_<child-short>`. + columns.push({ type: rel.collection, column: countColumnName(rel.collection) }); + const mapping = relMap[relName]; + if (mapping) { + for (const [groupKey, fullToken] of Object.entries(mapping.groups)) { + // Grouped: identifier is the full record token (stable across deployments); + // column is `count_<child-short>_<group-key>`. + columns.push({ + type: fullToken, + column: groupedCountColumnName(rel.collection, groupKey), + }); + } + } + } + return columns; +} + +/** For a given "count type" (short name or full group token), return the DB column. */ +function countColumnForType( + config: ContrailConfig, + shortName: string, + type: string +): string | null { + for (const col of getCountColumns(config, shortName)) { + if (col.type === type) return col.column; + } + return null; +} + +// --- Query --- + +export interface SortOption { + recordField?: string; + countType?: string; + direction: "asc" | "desc"; +} + +/** Opaque keyset cursor. `t` is the tiebreaker (time_us of the last row), + * `v` is the sort-key value (string for record fields, number for counts), + * `k` identifies the sort so we can reject mismatched cursors. */ +interface CursorPayload { + t: number; + v?: string | number; + k: "time" | string; // "time" | `field:<name>` | `count:<type>` +} + +function sortKind(sort?: SortOption): "time" | string { + if (sort?.recordField) return `field:${sort.recordField}`; + if (sort?.countType) return `count:${sort.countType}`; + return "time"; +} + +function encodeCursor(payload: CursorPayload): string { + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); +} + +function decodeCursor(cursor: string): CursorPayload | null { + try { + const json = Buffer.from(cursor, "base64url").toString("utf8"); + const p = JSON.parse(json); + if (typeof p?.t !== "number" || typeof p?.k !== "string") return null; + return p as CursorPayload; + } catch { + return null; + } +} + +export interface QueryOptions { + collection: string; + did?: string; + limit?: number; + cursor?: string; + filters?: Record<string, string>; + rangeFilters?: Record<string, { min?: string; max?: string }>; + countFilters?: Record<string, number>; + sort?: SortOption; + search?: string; + source?: RecordSource; +} + +export async function queryRecords( + db: Database, + config: ContrailConfig, + options: QueryOptions +): Promise<{ records: (RecordRow & { counts?: Record<string, number> })[]; cursor?: string }> { + const { + collection: collectionInput, + did, + limit: rawLimit, + cursor, + filters = {}, + rangeFilters = {}, + countFilters = {}, + sort, + search, + source, + } = options; + + // Accept either the short name (canonical) or the full NSID for convenience. + const collection = + config.collections[collectionInput] + ? collectionInput + : shortNameForNsid(config, collectionInput) ?? collectionInput; + + const table = recordsTableName(collection); + const limit = Math.min(Math.max(1, rawLimit ?? 50), 200); + const conditions: string[] = []; + const bindings: (string | number)[] = []; + + if (source?.conditions) conditions.push(...source.conditions); + if (source?.params) bindings.push(...source.params); + + const countCols = getCountColumns(config, collection); + + if (did) { + conditions.push("r.did = ?"); + bindings.push(did); + } + + // Opaque keyset cursor encoding { t, v?, k }. Silently ignored if it doesn't + // match the current sort — callers shouldn't mix sort params with stale cursors. + const expectedKind = sortKind(sort); + if (cursor) { + const payload = decodeCursor(cursor); + if (payload && payload.k === expectedKind) { + if (sort?.recordField) { + const sortExpr = getDialect(db).jsonExtract('r.record', sort.recordField); + const cmp = sort.direction === "desc" ? "<" : ">"; + conditions.push(`(${sortExpr} ${cmp} ? OR (${sortExpr} = ? AND r.time_us < ?))`); + const v = payload.v ?? ""; + bindings.push(v as string | number, v as string | number, payload.t); + } else if (sort?.countType) { + const sortCol = countColumnForType(config, collection, sort.countType); + if (!sortCol) throw new Error(`Unknown countType: ${sort.countType}`); + const cmp = sort.direction === "desc" ? "<" : ">"; + conditions.push(`(r.${sortCol} ${cmp} ? OR (r.${sortCol} = ? AND r.time_us < ?))`); + const v = Number(payload.v ?? 0); + bindings.push(v, v, payload.t); + } else { + conditions.push("r.time_us < ?"); + bindings.push(payload.t); + } + } + } + + for (const [field, value] of Object.entries(filters)) { + conditions.push(`${getDialect(db).jsonExtract('r.record', field)} = ?`); + bindings.push(value); + } + + for (const [field, range] of Object.entries(rangeFilters)) { + if (range.min != null) { + conditions.push(`${getDialect(db).jsonExtract('r.record', field)} >= ?`); + bindings.push(range.min); + } + if (range.max != null) { + conditions.push(`${getDialect(db).jsonExtract('r.record', field)} <= ?`); + bindings.push(range.max); + } + } + + for (const [type, minCount] of Object.entries(countFilters)) { + const col = countColumnForType(config, collection, type); + if (!col) continue; // unknown count type — skip filter + conditions.push(`r.${col} >= ?`); + bindings.push(minCount); + } + + let ftsJoin = ""; + let ftsClause: ReturnType<typeof ftsQueryClause> | null = null; + if (search) { + const colConfig2 = config.collections[collection]; + const fields = colConfig2 ? getSearchableFields(collection, colConfig2) : null; + if (fields && fields.length > 0) { + ftsClause = ftsQueryClause(getDialect(db), recordsTableName(collection)); + ftsJoin = ftsClause.join; + conditions.push(ftsClause.condition); + // SECURITY: `search` is user input bound as a parameter, not interpolated. + bindings.push(search); + } + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const countSelect = countCols.length > 0 + ? ", " + countCols.map(({ column }) => `r.${column}`).join(", ") + : ""; + const select = `r.uri, r.did, r.rkey, r.cid, r.record, r.time_us, r.indexed_at${countSelect}`; + + const join = [source?.joins, ftsJoin].filter(Boolean).join(" "); + + let orderBy: string; + if (sort?.recordField) { + const dir = sort.direction === "desc" ? "DESC" : "ASC"; + orderBy = `${getDialect(db).jsonExtract('r.record', sort.recordField)} ${dir}, r.time_us DESC`; + } else if (sort?.countType) { + const dir = sort.direction === "desc" ? "DESC" : "ASC"; + const sortCol = countColumnForType(config, collection, sort.countType); + if (!sortCol) throw new Error(`Unknown countType: ${sort.countType}`); + orderBy = `r.${sortCol} ${dir}, r.time_us DESC`; + } else if (ftsClause) { + orderBy = `${ftsClause.orderExpr}, r.time_us DESC`; + // PG ts_rank needs the search term bound again for ORDER BY + if (getDialect(db).ftsStrategy === "generated-column" && search) { + bindings.push(search); + } + } else { + orderBy = "r.time_us DESC"; + } + + bindings.push(limit); + + const query = `SELECT ${select} FROM ${table} r ${join} ${where} ORDER BY ${orderBy} LIMIT ?`; + + const result = await db + .prepare(query) + .bind(...bindings) + .all<any>(); + + const nsid = nsidForShortName(config, collection) ?? collection; + const records = (result.results ?? []).map((row: any) => { + const rec: RecordRow & { counts?: Record<string, number> } = { + uri: row.uri, + did: row.did, + collection: nsid, + rkey: row.rkey, + cid: row.cid, + record: row.record, + time_us: row.time_us, + indexed_at: row.indexed_at, + }; + if (countCols.length > 0) { + const counts: Record<string, number> = {}; + for (const { type, column } of countCols) { + const val = row[column]; + if (val != null && val !== 0) counts[type] = val; + } + if (Object.keys(counts).length > 0) rec.counts = counts; + } + return rec; + }); + + const nextCursor = + records.length === limit + ? buildCursor(records[records.length - 1], sort, expectedKind) + : undefined; + + return { records, cursor: nextCursor }; +} + +/** Build an opaque keyset cursor from the last row of a page. */ +function buildCursor( + row: RecordRow & { counts?: Record<string, number> }, + sort: SortOption | undefined, + kind: string +): string { + const t = Number(row.time_us); + if (sort?.recordField) { + const parsed = row.record ? JSON.parse(row.record) : null; + const v = parsed ? getNestedValue(parsed, sort.recordField) : undefined; + return encodeCursor({ t, v: v == null ? "" : String(v), k: kind }); + } + if (sort?.countType) { + const v = row.counts?.[sort.countType] ?? 0; + return encodeCursor({ t, v, k: kind }); + } + return encodeCursor({ t, k: kind }); +} diff --git a/packages/contrail/src/core/db/schema.ts b/packages/contrail/src/core/db/schema.ts index 551c495..0b6baeb 100644 --- a/packages/contrail/src/core/db/schema.ts +++ b/packages/contrail/src/core/db/schema.ts @@ -1 +1,439 @@ -export * from "@atmo-dev/contrail-appview"; +import type { + ContrailConfig, + Database, + ResolvedContrailConfig, + ResolvedMaps, +} from "../types"; +import type { SqlDialect } from "../dialect"; +import { buildFtsSchema, getDialect, postgresDialect } from "../dialect"; +import { + countColumnName, + getRelationField, + groupedCountColumnName, + recordsTableName, + resolveConfig, +} from "../types"; +import { getSearchableFields } from "../search"; +import { buildLabelsSchema } from "../labels/schema"; +import { getMeta, setMeta } from "./meta"; + +export const CONTRAIL_SCHEMA_VERSION = 2; +const SCHEMA_FINGERPRINT_KEY = "schema_fingerprint"; + +function getResolved(config: ContrailConfig): ResolvedMaps { + return ( + (config as ResolvedContrailConfig)._resolved ?? resolveConfig(config)._resolved + ); +} + +function buildBaseSchema(dialect: SqlDialect): string { + return ` +CREATE TABLE IF NOT EXISTS _contrail_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS backfills ( + did TEXT NOT NULL, + collection TEXT NOT NULL, + completed INTEGER NOT NULL DEFAULT 0, + pds_cursor TEXT, + retries INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + PRIMARY KEY (did, collection) +); +CREATE TABLE IF NOT EXISTS discovery ( + collection TEXT NOT NULL, + relay TEXT NOT NULL, + cursor TEXT, + completed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (collection, relay) +); +CREATE TABLE IF NOT EXISTS cursor ( + id INTEGER PRIMARY KEY CHECK (id = 1), + time_us ${dialect.bigintType} NOT NULL +); +CREATE TABLE IF NOT EXISTS identities ( + did TEXT PRIMARY KEY, + handle TEXT, + pds TEXT, + resolved_at ${dialect.bigintType} NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_identities_handle ON identities(handle); +`; +} + +function sanitizeName(name: string): string { + return name.replace(/[^a-zA-Z0-9]/g, "_"); +} + +export function buildCollectionTables( + config: ContrailConfig, + dialect: SqlDialect, +): string[] { + const statements: string[] = []; + for (const shortName of Object.keys(config.collections)) { + const table = recordsTableName(shortName); + statements.push( + `CREATE TABLE IF NOT EXISTS ${table} ( + uri TEXT PRIMARY KEY, + did TEXT NOT NULL, + rkey TEXT NOT NULL, + cid TEXT, + record ${dialect.recordColumnType}, + time_us ${dialect.bigintType} NOT NULL, + indexed_at ${dialect.bigintType} NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_${sanitizeName(shortName)}_did ON ${table}(did)`, + `CREATE INDEX IF NOT EXISTS idx_${sanitizeName(shortName)}_time ON ${table}(time_us DESC)`, + ); + } + return statements; +} + +export function buildDynamicIndexes( + config: ContrailConfig, + dialect: SqlDialect, +): string[] { + const resolved = getResolved(config); + const indexes: string[] = []; + for (const [collection, colConfig] of Object.entries(config.collections)) { + const table = recordsTableName(collection); + const queryable = + resolved.queryable[collection] ?? colConfig.queryable ?? {}; + for (const field of Object.keys(queryable)) { + indexes.push( + `CREATE INDEX IF NOT EXISTS idx_${sanitizeName(collection)}_${sanitizeName(field)} ON ${table}(${dialect.indexExpression(dialect.jsonExtract("record", field))})`, + ); + } + + for (const relation of Object.values(colConfig.relations ?? {})) { + const on = getRelationField(relation); + const childTable = recordsTableName(relation.collection); + indexes.push( + `CREATE INDEX IF NOT EXISTS idx_${sanitizeName(relation.collection)}_${sanitizeName(on)} ON ${childTable}(${dialect.indexExpression(dialect.jsonExtract("record", on))})`, + ); + } + } + return indexes; +} + +export function buildCountColumns(config: ContrailConfig): string[] { + const resolved = getResolved(config); + const statements: string[] = []; + const addedColumns = new Map<string, Set<string>>(); + + for (const [collection, colConfig] of Object.entries(config.collections)) { + const table = recordsTableName(collection); + const relationMap = resolved.relations[collection] ?? {}; + const tableColumns = addedColumns.get(table) ?? new Set<string>(); + addedColumns.set(table, tableColumns); + + for (const [relationName, relation] of Object.entries( + colConfig.relations ?? {}, + )) { + if (relation.count === false) continue; + const totalColumn = countColumnName(relation.collection); + if (!tableColumns.has(totalColumn)) { + tableColumns.add(totalColumn); + statements.push( + `ALTER TABLE ${table} ADD COLUMN ${totalColumn} INTEGER NOT NULL DEFAULT 0`, + ); + } + statements.push( + `CREATE INDEX IF NOT EXISTS idx_${sanitizeName(collection)}_${totalColumn} ON ${table}(${totalColumn} DESC, time_us DESC)`, + ); + + const mapping = relationMap[relationName]; + if (!mapping) continue; + for (const groupKey of Object.keys(mapping.groups)) { + const groupColumn = groupedCountColumnName( + relation.collection, + groupKey, + ); + if (!tableColumns.has(groupColumn)) { + tableColumns.add(groupColumn); + statements.push( + `ALTER TABLE ${table} ADD COLUMN ${groupColumn} INTEGER NOT NULL DEFAULT 0`, + ); + } + statements.push( + `CREATE INDEX IF NOT EXISTS idx_${sanitizeName(collection)}_${groupColumn} ON ${table}(${groupColumn} DESC, time_us DESC)`, + ); + } + } + } + return statements; +} + +export async function addColumnIfNotExists( + db: Database, + table: string, + column: string, + columnDef: string, +): Promise<void> { + if (getDialect(db) === postgresDialect) { + await db + .prepare( + `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${column} ${columnDef}`, + ) + .run(); + return; + } + + const info = await db + .prepare(`PRAGMA table_info(${table})`) + .all<{ name: string }>(); + if (info.results.some((candidate) => candidate.name === column)) return; + try { + await db + .prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${columnDef}`) + .run(); + } catch (error) { + if (!isDuplicateColumnError(error)) throw error; + } +} + +function isDuplicateColumnError(error: unknown): boolean { + return ( + error instanceof Error && /duplicate column name/i.test(error.message) + ); +} +function isConcurrentCreateError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const code = (error as { code?: unknown }).code; + if (code === "42P07" || code === "42P06") return true; + if (code !== "23505") return false; + const constraint = (error as { constraint?: unknown }).constraint; + return ( + constraint === "pg_type_typname_nsp_index" || + constraint === "pg_class_relname_nsp_index" || + constraint === "pg_namespace_nspname_index" + ); +} + +async function runIdempotentDdl(db: Database, statement: string): Promise<void> { + try { + await db.prepare(statement).run(); + } catch (error) { + if (!isConcurrentCreateError(error)) throw error; + } +} + +export async function applyCountColumns( + db: Database, + config: ContrailConfig, +): Promise<void> { + for (const statement of buildCountColumns(config)) { + const match = statement.match( + /^ALTER TABLE\s+(\S+)\s+ADD COLUMN\s+(\S+)\s+(.+)$/i, + ); + if (match) { + const [, table, column, columnDef] = match; + await addColumnIfNotExists(db, table, column, columnDef); + } else { + await db.prepare(statement).run(); + } + } +} + +function buildFeedTables(config: ContrailConfig, dialect: SqlDialect): string[] { + if (!config.feeds || Object.keys(config.feeds).length === 0) return []; + const statements = [ + `CREATE TABLE IF NOT EXISTS feed_items ( + actor TEXT NOT NULL, + uri TEXT NOT NULL, + collection TEXT NOT NULL, + time_us ${dialect.bigintType} NOT NULL, + PRIMARY KEY (actor, uri) + )`, + "CREATE INDEX IF NOT EXISTS idx_feed_actor_coll_time ON feed_items(actor, collection, time_us DESC)", + "CREATE INDEX IF NOT EXISTS idx_feed_actor_time ON feed_items(actor, time_us DESC)", + `CREATE TABLE IF NOT EXISTS feed_prune_cursor ( + id INTEGER PRIMARY KEY CHECK (id = 1), + actor TEXT + )`, + `CREATE TABLE IF NOT EXISTS feed_backfills ( + actor TEXT NOT NULL, + feed TEXT NOT NULL, + completed INTEGER NOT NULL DEFAULT 0, + retries INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + started_at ${dialect.bigintType}, + PRIMARY KEY (actor, feed) + )`, + ]; + + const followCollections = new Set( + Object.values(config.feeds).map((feed) => feed.follow ?? "follow"), + ); + for (const collection of followCollections) { + statements.push( + `CREATE INDEX IF NOT EXISTS idx_${sanitizeName(collection)}_subject ON ${recordsTableName(collection)}(${dialect.indexExpression(dialect.jsonExtract("record", "subject"))})`, + ); + } + return statements; +} + +export function buildFtsTables( + config: ContrailConfig, + dialect: SqlDialect, +): string[] { + const statements: string[] = []; + for (const [collection, colConfig] of Object.entries(config.collections)) { + const fields = getSearchableFields(collection, colConfig); + if (!fields || fields.length === 0) continue; + statements.push( + ...buildFtsSchema(dialect, recordsTableName(collection), fields), + ); + } + return statements; +} + +interface MigrationOp { + table: string; + column: string; + columnDef: string; + target?: "feeds"; +} + +const MIGRATIONS: MigrationOp[] = [ + { + table: "backfills", + column: "retries", + columnDef: "INTEGER NOT NULL DEFAULT 0", + }, + { table: "backfills", column: "last_error", columnDef: "TEXT" }, + { + table: "feed_backfills", + column: "retries", + columnDef: "INTEGER NOT NULL DEFAULT 0", + target: "feeds", + }, + { + table: "feed_backfills", + column: "last_error", + columnDef: "TEXT", + target: "feeds", + }, + { + table: "feed_backfills", + column: "started_at", + columnDef: "BIGINT", + target: "feeds", + }, +]; + +async function runMigrations( + db: Database, + hasFeeds: boolean, +): Promise<void> { + for (const operation of MIGRATIONS) { + if (operation.target === "feeds" && !hasFeeds) continue; + await addColumnIfNotExists( + db, + operation.table, + operation.column, + operation.columnDef, + ); + } +} + +/** Pluggable schema extension retained for applications with their own tables. */ +export type SchemaModule = (db: Database) => Promise<void>; + +export interface InitSchemaOptions { + extraSchemas?: SchemaModule[]; +} + +function hashStrings(parts: string[]): string { + const joined = parts.join("\0"); + let first = 0x811c9dc5; + let second = 0x01000193; + for (let index = 0; index < joined.length; index++) { + const code = joined.charCodeAt(index); + first = Math.imul(first ^ code, 0x01000193) >>> 0; + second = Math.imul(second ^ code, 0x811c9dc5) >>> 0; + } + return ( + first.toString(16).padStart(8, "0") + + second.toString(16).padStart(8, "0") + ); +} +function schemaFingerprint( + config: ContrailConfig, + dialect: SqlDialect, + ddl: { + base: string[]; + collections: string[]; + indexes: string[]; + feeds: string[]; + fts: string[]; + }, +): string { + return hashStrings([ + `v${CONTRAIL_SCHEMA_VERSION}`, + dialect.bigintType, + config.labels ? "labels" : "", + ...ddl.base, + ...ddl.collections, + ...ddl.indexes, + ...ddl.feeds, + ...ddl.fts, + ...buildCountColumns(config), + ...(config.labels ? buildLabelsSchema(dialect) : []), + JSON.stringify(MIGRATIONS), + ]); +} + +export async function initSchema( + db: Database, + config: ContrailConfig, + options: InitSchemaOptions = {}, +): Promise<void> { + const dialect = getDialect(db); + const base = buildBaseSchema(dialect) + .split(";") + .map((statement) => statement.trim()) + .filter(Boolean); + const collections = buildCollectionTables(config, dialect); + const indexes = buildDynamicIndexes(config, dialect); + const feeds = buildFeedTables(config, dialect); + const fts = buildFtsTables(config, dialect); + const fingerprint = schemaFingerprint(config, dialect, { + base, + collections, + indexes, + feeds, + fts, + }); + + if ((await getMeta(db, SCHEMA_FINGERPRINT_KEY)) === fingerprint) { + for (const apply of options.extraSchemas ?? []) await apply(db); + return; + } + + for (const statement of [...base, ...collections, ...indexes, ...feeds]) { + await runIdempotentDdl(db, statement); + } + + if (config.labels) { + for (const statement of buildLabelsSchema(dialect)) { + await runIdempotentDdl(db, statement); + } + } + + for (const statement of fts) { + try { + await db.prepare(statement).run(); + } catch { + // FTS5 is not available in every SQLite build. + } + } + + const hasFeeds = !!(config.feeds && Object.keys(config.feeds).length > 0); + await runMigrations(db, hasFeeds); + await applyCountColumns(db, config); + + for (const apply of options.extraSchemas ?? []) await apply(db); + await setMeta(db, SCHEMA_FINGERPRINT_KEY, fingerprint); +} diff --git a/packages/contrail/src/core/dialect.ts b/packages/contrail/src/core/dialect.ts index 551c495..e3bb790 100644 --- a/packages/contrail/src/core/dialect.ts +++ b/packages/contrail/src/core/dialect.ts @@ -1 +1,127 @@ -export * from "@atmo-dev/contrail-appview"; +/** Get the dialect from a Database, defaulting to SQLite (for D1 compatibility) */ +export function getDialect(db: { dialect?: SqlDialect }): SqlDialect { + return db.dialect ?? sqliteDialect; +} + +const SAFE_FIELD = /^[a-zA-Z0-9_.]+$/; + +function assertSafeField(field: string): void { + if (!SAFE_FIELD.test(field)) { + throw new Error(`Invalid field name: ${field}`); + } +} + +export interface SqlDialect { + /** json_extract(col, '$.field') or col->>'field' */ + jsonExtract(column: string, field: string): string; + + /** Convert INSERT INTO to ignore-duplicates form. + * SQLite: INSERT INTO → INSERT OR IGNORE INTO + * PG: appends ON CONFLICT DO NOTHING + * Accepts full SQL starting with "INSERT INTO" (works with both VALUES and SELECT). */ + insertOrIgnore(sql: string): string; + + /** Column type for the record column: TEXT (SQLite) or JSONB (PostgreSQL) */ + readonly recordColumnType: string; + + /** FTS strategy: 'virtual-table' (SQLite FTS5) or 'generated-column' (PG tsvector) */ + readonly ftsStrategy: "virtual-table" | "generated-column"; + + /** INTEGER type name — same on both, but PostgreSQL may want BIGINT for time_us */ + readonly integerType: string; + + /** BIGINT type name for timestamps */ + readonly bigintType: string; + + /** Wrap an expression for use in CREATE INDEX — PostgreSQL requires parens around expressions */ + indexExpression(expr: string): string; +} + +export const sqliteDialect: SqlDialect = { + jsonExtract(column: string, field: string): string { + assertSafeField(field); + return `json_extract(${column}, '$.${field}')`; + }, + + insertOrIgnore(sql: string): string { + return sql.replace(/^INSERT INTO/, "INSERT OR IGNORE INTO"); + }, + + recordColumnType: "TEXT", + ftsStrategy: "virtual-table", + integerType: "INTEGER", + bigintType: "INTEGER", + + indexExpression(expr: string): string { + return expr; + }, +}; + +export const postgresDialect: SqlDialect = { + jsonExtract(column: string, field: string): string { + assertSafeField(field); + const parts = field.split("."); + if (parts.length === 1) { + return `${column}->>'${parts[0]}'`; + } + // a.b.c → col->'a'->'b'->>'c' + const intermediate = parts.slice(0, -1).map((p) => `->'${p}'`).join(""); + return `${column}${intermediate}->>'${parts[parts.length - 1]}'`; + }, + + insertOrIgnore(sql: string): string { + return `${sql} ON CONFLICT DO NOTHING`; + }, + + recordColumnType: "JSONB", + ftsStrategy: "generated-column", + integerType: "INTEGER", + bigintType: "BIGINT", + + indexExpression(expr: string): string { + return `(${expr})`; + }, +}; + +/** Generate FTS schema statements based on dialect */ +export function buildFtsSchema( + dialect: SqlDialect, + recordsTable: string, + fields: string[] +): string[] { + if (dialect.ftsStrategy === "virtual-table") { + const ftsTable = recordsTable.replace("records_", "fts_"); + return [ + `CREATE VIRTUAL TABLE IF NOT EXISTS ${ftsTable} USING fts5(uri UNINDEXED, content)` + ]; + } else { + const concatExpr = fields + .map((f) => `COALESCE(${dialect.jsonExtract("record", f)}, '')`) + .join(" || ' ' || "); + return [ + `ALTER TABLE ${recordsTable} ADD COLUMN IF NOT EXISTS search_vector TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', ${concatExpr})) STORED`, + `CREATE INDEX IF NOT EXISTS idx_${recordsTable}_search ON ${recordsTable} USING GIN (search_vector)`, + ]; + } +} + +/** Generate FTS query clause based on dialect */ +export function ftsQueryClause( + dialect: SqlDialect, + recordsTable: string +): { join: string; condition: string; orderExpr: string } { + if (dialect.ftsStrategy === "virtual-table") { + const ftsTable = recordsTable.replace("records_", "fts_"); + return { + join: `JOIN ${ftsTable} fts ON fts.uri = r.uri`, + condition: "fts.content MATCH ?", + orderExpr: "fts.rank", + }; + } else { + return { + join: "", + condition: "r.search_vector @@ plainto_tsquery('english', ?)", + orderExpr: "ts_rank(r.search_vector, plainto_tsquery('english', ?))", + }; + } +} diff --git a/packages/contrail/src/core/identity.ts b/packages/contrail/src/core/identity.ts index 551c495..a18120d 100644 --- a/packages/contrail/src/core/identity.ts +++ b/packages/contrail/src/core/identity.ts @@ -1 +1,186 @@ -export * from "@atmo-dev/contrail-appview"; +import type { Did } from "@atcute/lexicons"; +import type { ContrailConfig, Database, Logger } from "./types"; +import { isDid, isHandle } from "@atcute/lexicons/syntax"; +import { resolvePDS } from "./client"; + +const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours + +export interface Identity { + did: string; + handle: string | null; + pds: string | null; + resolved_at: number; +} + +async function saveIdentity(db: Database, identity: Identity): Promise<void> { + await db + .prepare( + // COALESCE so a null handle/pds from a partial resolution never clobbers + // a previously-resolved value (e.g. refreshStaleIdentities passes through + // a null handle when slingshot omits it). A fresh non-null value still + // overwrites — handle changes apply normally. + "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?) ON CONFLICT(did) DO UPDATE SET handle = COALESCE(excluded.handle, identities.handle), pds = COALESCE(excluded.pds, identities.pds), resolved_at = excluded.resolved_at" + ) + .bind(identity.did, identity.handle, identity.pds, identity.resolved_at) + .run(); +} + +function isStale(resolvedAt: number): boolean { + return Date.now() - resolvedAt >= STALE_MS; +} + +async function fetchAndSave( + db: Database, + identifier: string, + cached?: Identity | null, + config?: ContrailConfig, +): Promise<Identity> { + const resolved = await resolvePDS(identifier, config); + const identity: Identity = { + did: resolved?.did ?? identifier, + handle: resolved?.handle ?? cached?.handle ?? null, + pds: resolved?.pds ?? cached?.pds ?? null, + resolved_at: Date.now(), + }; + await saveIdentity(db, identity); + return identity; +} + +export async function resolveIdentity( + db: Database, + did: Did, + config?: ContrailConfig, +): Promise<Identity> { + const cached = await db + .prepare("SELECT did, handle, pds, resolved_at FROM identities WHERE did = ?") + .bind(did) + .first<Identity>(); + + if (cached && !isStale(cached.resolved_at)) return cached; + + return fetchAndSave(db, did, cached, config); +} + +export async function resolveIdentities( + db: Database, + dids: string[], + config?: ContrailConfig, +): Promise<Map<string, Identity>> { + const map = new Map<string, Identity>(); + if (dids.length === 0) return map; + + // Batch lookup from DB + const BATCH = 50; + for (let i = 0; i < dids.length; i += BATCH) { + const chunk = dids.slice(i, i + BATCH); + const placeholders = chunk.map(() => "?").join(","); + const rows = await db + .prepare(`SELECT did, handle, pds, resolved_at FROM identities WHERE did IN (${placeholders})`) + .bind(...chunk) + .all<Identity>(); + for (const row of rows.results ?? []) { + map.set(row.did, row); + } + } + + // Resolve missing via slingshot directly (no redundant DB lookup) + for (const did of dids) { + if (map.has(did) || !isDid(did)) continue; + try { + const identity = await fetchAndSave(db, did, undefined, config); + map.set(did, identity); + } catch { + // Silently skip unresolvable identities + } + } + + return map; +} + +export async function resolveActor( + db: Database, + actor: string, + config?: ContrailConfig, +): Promise<string | null> { + if (isDid(actor)) return actor; + if (!isHandle(actor)) return null; + + // Look up handle in identities table + const cached = await db + .prepare("SELECT did, resolved_at FROM identities WHERE handle = ?") + .bind(actor) + .first<{ did: string; resolved_at: number }>(); + + if (cached && !isStale(cached.resolved_at)) return cached.did; + + // Resolve via slingshot + const resolved = await resolvePDS(actor, config); + if (!resolved?.did || !isDid(resolved.did)) return null; + + await saveIdentity(db, { + did: resolved.did, + handle: resolved.handle ?? actor, + pds: resolved.pds ?? null, + resolved_at: Date.now(), + }); + + return resolved.did; +} + +/** + * Apply a handle change from a Jetstream `#identity` event. + * + * UPDATE-only — does not create a row for unknown DIDs (we'd lack PDS, and + * partial rows confuse the rest of the pipeline). PDS column is left + * untouched; it gets refreshed lazily via `getPDS` / next slingshot resolve. + */ +export async function applyIdentityEvent( + db: Database, + did: string, + handle: string +): Promise<void> { + await db + .prepare("UPDATE identities SET handle = ?, resolved_at = ? WHERE did = ?") + .bind(handle, Date.now(), did) + .run(); +} + +export async function refreshStaleIdentities( + db: Database, + dids: string[], + config?: ContrailConfig, +): Promise<void> { + if (dids.length === 0) return; + + const unique = [...new Set(dids)].filter(isDid); + if (unique.length === 0) return; + + const staleThreshold = Date.now() - STALE_MS; + const toRefresh: string[] = []; + + const BATCH = 50; + for (let i = 0; i < unique.length; i += BATCH) { + const chunk = unique.slice(i, i + BATCH); + const placeholders = chunk.map(() => "?").join(","); + const rows = await db + .prepare(`SELECT did, resolved_at FROM identities WHERE did IN (${placeholders})`) + .bind(...chunk) + .all<{ did: string; resolved_at: number }>(); + + const found = new Map((rows.results ?? []).map((r) => [r.did, r.resolved_at])); + for (const did of chunk) { + const resolvedAt = found.get(did); + if (resolvedAt === undefined || resolvedAt < staleThreshold) { + toRefresh.push(did); + } + } + } + + for (const did of toRefresh) { + try { + await fetchAndSave(db, did, undefined, config); + } catch { + // Silently skip unresolvable identities + } + } +} diff --git a/packages/contrail/src/core/invite/community-handler.ts b/packages/contrail/src/core/invite/community-handler.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/invite/community-handler.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/invite/index.ts b/packages/contrail/src/core/invite/index.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/invite/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/invite/router.ts b/packages/contrail/src/core/invite/router.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/invite/router.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/invite/token.ts b/packages/contrail/src/core/invite/token.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/invite/token.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/jetstream.ts b/packages/contrail/src/core/jetstream.ts index 551c495..93c9a2b 100644 --- a/packages/contrail/src/core/jetstream.ts +++ b/packages/contrail/src/core/jetstream.ts @@ -1 +1,568 @@ -export * from "@atmo-dev/contrail-appview"; +import { JetstreamSubscription } from "@atcute/jetstream"; +import type { ContrailConfig, IngestEvent, Database, Logger } from "./types"; +import { + getCollectionNsids, + getDependentNsids, + jetstreamUrlOption, + shortNameForNsid, + buildFeedTargetCaps, + getFeedMutatingNsids, + optimizeEnabled, + optimizeIntervalMs, + optimizeAnalysisLimit, +} from "./types"; +import { initSchema, getLastCursor, saveCursor, applyEvents, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor, getMetaNumber, setMeta, optimizeDatabase } from "./db"; +import { refreshStaleIdentities, applyIdentityEvent } from "./identity"; +import { backfillFollowersFromConstellation } from "./constellation"; + +const BATCH_SIZE = 50; +/** Distinct actors pruned per ingest tick by the rolling feed sweep. Each + * actor costs a handful of index-backed O(cap) deletes, so this bounds the + * prune's per-tick CPU regardless of how large feed_items grows. */ +export const FEED_PRUNE_SWEEP_ACTORS = 500; + +/** How long after a completed full pass the recovery sweep becomes due again, + * even when no feed-relevant records are ingested, so over-cap rows that + * predate a config change (e.g. a lowered cap) or a bulk import still drain. + * Once due, the pass advances one slice per tick, so a full pass *completes* + * roughly every (this interval + lap time), where lap time is + * ceil(actors / FEED_PRUNE_SWEEP_ACTORS) ticks. Steady-state pruning is driven + * by ingest; this is only the safety net. */ +export const FEED_PRUNE_RECOVERY_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h + +/** `_contrail_meta` key for the wall-clock (ms) at which the rolling feed sweep + * last *completed a full pass* over every actor, so a recycled cron isolate can + * honor the recovery interval across ticks. Tracking pass completion (not the + * last slice) is what keeps the recovery interval measuring from a real drain + * rather than from one bounded slice — without it, a single slice resets the + * clock and a feed touched just after the cursor passed it could wait many + * intervals to be revisited. */ +const FEED_PRUNE_LAST_FULL_PASS_META = "feed_prune_last_full_pass_ms"; + +/** `_contrail_meta` key for the persisted optimize cadence (so recycled cron + * isolates don't re-run it every tick — the in-memory-state bug we hit with + * the feed prune). Shared by the persistent loop. */ +export const OPTIMIZE_LAST_MS_KEY = "optimize_last_ms"; + +/** Run the opt-in planner-stat maintenance if enabled and its persisted + * interval has elapsed. Bounded + no-op on Postgres (see optimizeDatabase). + * Wrapped by callers so a pragma-unsupported environment can't break ingest. */ +export async function maybeOptimize(db: Database, config: ContrailConfig, log: Logger): Promise<void> { + if (!optimizeEnabled(config)) return; + const last = await getMetaNumber(db, OPTIMIZE_LAST_MS_KEY); + if (Date.now() - (last ?? 0) <= optimizeIntervalMs(config)) return; + // Claim the interval up front so a failing/unsupported pragma can't re-run + // every tick — it retries only after the next interval elapses. + await setMeta(db, OPTIMIZE_LAST_MS_KEY, String(Date.now())); + try { + await optimizeDatabase(db, optimizeAnalysisLimit(config)); + log.log("[maintenance] refreshed planner stats (PRAGMA optimize)"); + } catch (err) { + log.warn(`[maintenance] optimize failed: ${err}`); + } +} + +/** One bounded feed-prune slice: advances the persisted rolling cursor by up to + * {@link FEED_PRUNE_SWEEP_ACTORS} actors and reports whether the slice reached + * the end of the actor list (i.e. a full pass just completed and the cursor + * wrapped). Callers decide WHEN to sweep (ingest-dirty vs recovery); this owns + * the slice + cursor mechanics so the cron loop, the persistent loop, and the + * notify path all prune identically. No-op (done) when no feed caps apply. */ +export async function runFeedPruneSlice( + db: Database, + config: ContrailConfig +): Promise<{ pruned: number; done: boolean }> { + const caps = buildFeedTargetCaps(config); + if (caps.size === 0) return { pruned: 0, done: true }; + const cursor = await getFeedPruneCursor(db); + const { pruned, nextCursor, done } = await sweepFeedItems( + db, + caps, + cursor, + FEED_PRUNE_SWEEP_ACTORS + ); + await saveFeedPruneCursor(db, nextCursor); + return { pruned, done }; +} + +/** Gate and run one feed-prune slice against the *persisted* recovery clock — + * shared by the recycling cron isolate and the stateless `notifyOfUpdate` path + * (the long-lived persistent loop uses its in-memory clocks instead). Slices + * when `feedTouched` (a feed-mutating record was just ingested) or when a full + * pass is overdue, and records pass completion so the recovery clock measures + * from a real drain (one slice per tick until the cursor wraps) rather than + * resetting on a single slice. + * + * The slice advances the shared rolling cursor, which is NOT necessarily the + * actor the mutation touched: a fan-out follower the cursor has already passed + * is pruned by the next pass, up to about one recovery interval later, not + * instantly. That is + * the deliberate trade for a per-tick cost bounded by `FEED_PRUNE_SWEEP_ACTORS` + * rather than by fan-out size (a popular author has unboundedly many followers). + * feed_items is a soft cache, so a follower sitting a few rows over cap until + * the next slice is harmless. No-op when feeds are unconfigured. */ +export async function runGatedFeedPrune( + db: Database, + config: ContrailConfig, + feedTouched: boolean +): Promise<void> { + if (!config.feeds) return; + if (buildFeedTargetCaps(config).size === 0) return; + const nowMs = Date.now(); + const lastFullPassMs = + (await getMetaNumber(db, FEED_PRUNE_LAST_FULL_PASS_META)) ?? 0; + const recoveryDue = nowMs - lastFullPassMs >= FEED_PRUNE_RECOVERY_INTERVAL_MS; + if (!feedTouched && !recoveryDue) return; + const { pruned, done } = await runFeedPruneSlice(db, config); + if (done) await setMeta(db, FEED_PRUNE_LAST_FULL_PASS_META, String(nowMs)); + if (pruned > 0) { + getLogger(config).log( + `Pruned ${pruned} feed items (sweep, reason=${feedTouched ? "ingest" : "recovery"})` + ); + } +} + +/** Mutable state that persists across ingest cycles within the same process. */ +export interface IngestState { + cachedKnownDids?: Set<string>; + schemaInitialized: boolean; + /** Wall-clock of the last feed sweep slice — used only by the long-lived + * persistent loop to throttle ingest-driven slices; the recycling cron + * isolate persists its clocks in `_contrail_meta` instead. */ + lastFeedSweepMs: number; + /** Wall-clock at which the persistent loop last *completed a full sweep pass* + * over every actor. Drives the recovery interval (a fresh pass becomes due + * {@link FEED_PRUNE_RECOVERY_INTERVAL_MS} after the last one completed, then + * laps one slice per tick), independent of the ingest-driven throttle above. + * The cron isolate persists the equivalent in + * `_contrail_meta`. */ + lastFullFeedPassMs: number; + /** Set by the persistent loop when a flushed batch ingested a feed-mutating + * record, so the next sweep window knows there may be prune work. Cleared + * when the sweep runs. The cron path makes the same decision per-tick from + * its `events` array and doesn't need the flag. */ + feedDirty: boolean; +} + +export function createIngestState(): IngestState { + return { + schemaInitialized: false, + lastFeedSweepMs: 0, + lastFullFeedPassMs: 0, + feedDirty: false, + }; +} + +function getLogger(config: ContrailConfig): Logger { + return config.logger ?? console; +} + +/** Sentinel returned by `nextWithDeadline` when the wait timed out. */ +const INGEST_TIMEOUT = Symbol("ingest-timeout"); + +/** Await the iterator's next value, but give up after `ms`. Without this a + * quiet Jetstream (the async iterator blocks forever waiting for an event that + * never arrives) holds the cycle past its safety timeout until the caller's + * hard timeout kills the isolate — so the batch and cursor are never written. */ +function nextWithDeadline<T>( + iterator: AsyncIterator<T>, + ms: number +): Promise<IteratorResult<T> | typeof INGEST_TIMEOUT> { + let timer: ReturnType<typeof setTimeout>; + const next = iterator.next(); + // If the timeout wins this race the next() promise stays pending; swallow a + // later rejection so it can't surface as an unhandled rejection. + next.catch(() => {}); + const timeout = new Promise<typeof INGEST_TIMEOUT>((resolve) => { + timer = setTimeout(() => resolve(INGEST_TIMEOUT), ms); + }); + return Promise.race([next, timeout]).finally(() => clearTimeout(timer)); +} + +export async function ingestEvents( + config: ContrailConfig, + cursor: number | null, + safetyTimeoutMs: number = 25_000, + knownDids?: Set<string> +): Promise<{ + events: IngestEvent[]; + lastCursor: number | null; + newlyKnownDids: string[]; + identityUpdates: Map<string, string>; +}> { + const log = getLogger(config); + const startTimeUs = Date.now() * 1000; + const deadline = Date.now() + safetyTimeoutMs; + const collected: IngestEvent[] = []; + + const collections = getCollectionNsids(config); + const dependentCollections = new Set(getDependentNsids(config)); + const urls = config.jetstreams ?? []; + + let totalCommits = 0; + let filteredUnknownDid = 0; + const filteredDidSamples = new Set<string>(); + let lastYieldedTimeUs: number | null = null; + let firstYieldedTimeUs: number | null = null; + let connectCount = 0; + const seenUris = new Map<string, number>(); // uri -> time_us of first occurrence + const duplicateUris: string[] = []; + const newlyKnownDids = new Set<string>(); + const identityUpdates = new Map<string, string>(); + + const subscription = new JetstreamSubscription({ + // A single-instance config is handed over as a string so @atcute skips its + // array-only first-connect cursor rollback (see jetstreamUrlOption). On the + // cron model that rollback would otherwise re-ingest 10s every cycle. + url: jetstreamUrlOption(urls), + wantedCollections: collections, + ...(cursor !== null ? { cursor } : {}), + onConnectionOpen() { + connectCount++; + log.log( + `[ingest] connected to Jetstream #${connectCount} (url=${urls.join("|")}, cursor=${cursor ?? "none"}, wanted=${collections.join(",")})` + ); + }, + onConnectionClose(event) { + log.log( + `[ingest] disconnected from Jetstream: ${event.code} ${event.reason}` + ); + }, + onConnectionError(event) { + log.error("[ingest] Jetstream error:", event.error); + }, + }); + + const iterator = subscription[Symbol.asyncIterator](); + type Ev = typeof subscription extends AsyncIterable<infer V> ? V : never; + + // Collect or skip a single event. Filtering uses early `return` rather than + // the loop's `continue` so the loop's exit checks still run after a filtered + // event (a stream of all-filtered events must not skip the deadline). + const handleEvent = (event: Ev): void => { + if (event.kind === "commit") { + const { commit } = event; + totalCommits++; + + const uri = `at://${event.did}/${commit.collection}/${commit.rkey}`; + + const short = shortNameForNsid(config, commit.collection); + const collectionCfg = short ? config.collections[short] : undefined; + + if (dependentCollections.has(commit.collection) && knownDids) { + if (!knownDids.has(event.did)) { + filteredUnknownDid++; + if (filteredDidSamples.size < 10) filteredDidSamples.add(event.did); + return; + } + // Subject filter: for collections with subjectField (e.g. follows + // pointing at a `subject` DID), drop records whose subject isn't a + // DID we care about. Trims network-wide social graph to the + // subjects our discoverable users overlap with. + const subjectField = collectionCfg?.subjectField; + if (subjectField && commit.operation !== "delete") { + const subj = (commit.record as Record<string, unknown> | undefined)?.[ + subjectField + ]; + if (typeof subj === "string" && !knownDids.has(subj)) { + return; + } + } + } + + if (collectionCfg?.recordFilter && commit.operation !== "delete") { + const rec = commit.record as Record<string, unknown> | undefined; + let keep = false; + try { + keep = !!(rec && collectionCfg.recordFilter(rec)); + } catch (err) { + log.warn(`[ingest] recordFilter threw for ${uri}: ${err}`); + } + if (!keep) return; + } + + const prev = seenUris.get(uri); + if (prev !== undefined) { + duplicateUris.push(uri); + log.warn( + `[ingest] DUPLICATE in cycle: ${uri} first time_us=${prev}, again=${event.time_us}, delta=${event.time_us - prev}us` + ); + } else { + seenUris.set(uri, event.time_us); + } + + const now = Date.now(); + + collected.push({ + uri, + did: event.did, + time_us: event.time_us, + collection: commit.collection, + operation: commit.operation as "create" | "update" | "delete", + rkey: commit.rkey, + cid: commit.operation === "delete" ? null : commit.cid, + record: + commit.operation === "delete" + ? null + : JSON.stringify(commit.record), + indexed_at: now * 1000, + }); + + log.log( + `[ingest] keep: ${commit.operation} ${uri} time_us=${event.time_us}` + ); + + if (knownDids && !dependentCollections.has(commit.collection)) { + if (!knownDids.has(event.did)) { + knownDids.add(event.did); + newlyKnownDids.add(event.did); + } + } + } else if (event.kind === "identity") { + identityUpdates.set(event.did, event.identity.handle); + } + }; + + for (;;) { + // Run the exit checks BEFORE awaiting the next event and regardless of + // whether the previous event was filtered — otherwise a quiet stream blocks + // forever and an all-filtered flood never reaches the deadline check. + if (Date.now() >= deadline) { + log.log( + `[ingest] safety timeout reached, stopping (deadline=${deadline}, collected=${collected.length})` + ); + break; + } + + const step = await nextWithDeadline(iterator, Math.max(0, deadline - Date.now())); + if (step === INGEST_TIMEOUT) { + log.log( + `[ingest] safety timeout reached, stopping (deadline=${deadline}, collected=${collected.length})` + ); + break; + } + if (step.done) break; + const event = step.value; + + if (firstYieldedTimeUs === null) firstYieldedTimeUs = event.time_us; + lastYieldedTimeUs = event.time_us; + + handleEvent(event); + + if (event.time_us >= startTimeUs) { + log.log( + `[ingest] caught up to present, stopping (last time_us=${event.time_us}, startTimeUs=${startTimeUs})` + ); + break; + } + } + + // Close the subscription's socket, but fire-and-forget: awaiting the + // iterator's return on a quiet stream could itself block (the hang we fix). + Promise.resolve(iterator.return?.()).catch(() => {}); + + if (filteredUnknownDid > 0) { + const sample = [...filteredDidSamples].join(", "); + log.log( + `[ingest] ${filteredUnknownDid} events filtered (unknown did). sample dids: ${sample}` + ); + } + const lastCursor = subscription.cursor || null; + + const cursorGap = + lastCursor !== null && lastYieldedTimeUs !== null + ? lastCursor - lastYieldedTimeUs + : null; + + // Detect the library's internal cursor rollback (picks a different URL → rolls + // back 10s → first event comes in BEFORE the cursor we asked it to start from). + const rolledBackUs = + cursor !== null && firstYieldedTimeUs !== null && firstYieldedTimeUs < cursor + ? cursor - firstYieldedTimeUs + : 0; + + log.log( + `[ingest] jetstream loop done. commits_seen=${totalCommits}, filtered=${filteredUnknownDid}, kept=${collected.length}, dupes=${duplicateUris.length}, connects=${connectCount}, first_yielded=${firstYieldedTimeUs ?? "none"}, last_yielded=${lastYieldedTimeUs ?? "none"}, subscription_cursor=${lastCursor ?? "none"}, cursor_gap=${cursorGap ?? "n/a"}us, rolled_back=${rolledBackUs}us` + ); + + if (cursorGap !== null && cursorGap > 1000) { + log.warn( + `[ingest] CURSOR GAP: subscription cursor is ${cursorGap}us (${Math.floor( + cursorGap / 1000 + )}ms) ahead of last yielded event — buffered events may be dropped` + ); + } + + if (connectCount > 1) { + if (urls.length > 1) { + // Multi-instance pool: each reconnect picks a URL at random, and @atcute + // rolls the cursor back up to 10s on a fresh instance to absorb clock skew. + log.warn( + `[ingest] RECONNECTED ${connectCount} times during cycle across a ${urls.length}-instance pool — each reconnect picks a URL at random and may roll the cursor back up to 10s (rolled_back=${rolledBackUs}us this cycle)` + ); + } else { + // Single fixed instance (see jetstreamUrlOption): reconnects resume on the + // same instance from the saved cursor, so there is no rollback. + log.log( + `[ingest] reconnected ${connectCount} times during cycle to the single fixed instance — no cursor rollback (rolled_back=${rolledBackUs}us)` + ); + } + } + + return { events: collected, lastCursor, newlyKnownDids: [...newlyKnownDids], identityUpdates }; +} + +// Run a full ingest cycle: init schema, load cursor, ingest, apply, save cursor +export async function runIngestCycle( + db: Database, + config: ContrailConfig, + timeoutMs: number = 25_000, + state?: IngestState, +): Promise<void> { + const log = getLogger(config); + const s = state ?? createIngestState(); + + if (!s.schemaInitialized) { + await initSchema(db, config); + s.schemaInitialized = true; + } + + const cursor = await getLastCursor(db); + const collections = getCollectionNsids(config); + const nowUs = Date.now() * 1000; + const lagMs = cursor !== null ? Math.floor((nowUs - cursor) / 1000) : null; + + log.log( + `[ingest] starting cycle. cursor=${cursor ?? "none"}${ + lagMs !== null ? ` (lag=${lagMs}ms)` : "" + }, timeout=${timeoutMs}ms, collections=${collections.join(", ")}` + ); + + // Load known DIDs for filtering dependent collections + const dependentCollections = getDependentNsids(config); + let knownDids: Set<string> | undefined; + + if (dependentCollections.length > 0) { + if (s.cachedKnownDids) { + knownDids = s.cachedKnownDids; + log.log(`Using cached known DIDs (${knownDids.size} users)`); + } else { + const result = await db + .prepare("SELECT did FROM identities") + .all<{ did: string }>(); + knownDids = new Set((result.results ?? []).map((r) => r.did)); + s.cachedKnownDids = knownDids; + log.log(`Loaded ${knownDids.size} known DIDs from database`); + } + } + + const { events, lastCursor, newlyKnownDids, identityUpdates } = await ingestEvents( + config, + cursor, + timeoutMs, + knownDids + ); + + if (events.length > 0) { + const breakdown: Record<string, number> = {}; + for (const e of events) { + const key = `${e.collection}:${e.operation}`; + breakdown[key] = (breakdown[key] ?? 0) + 1; + } + log.log( + `[ingest] received ${events.length} events. breakdown=${JSON.stringify(breakdown)}` + ); + } else { + log.log(`[ingest] received 0 events from Jetstream`); + } + + for (let i = 0; i < events.length; i += BATCH_SIZE) { + const batch = events.slice(i, i + BATCH_SIZE); + await applyEvents(db, batch, config); + } + + // Apply handle changes from #identity events. UPDATE-only, so unknown + // DIDs are no-ops — we don't want to create partial rows lacking PDS. + if (identityUpdates.size > 0) { + for (const [did, handle] of identityUpdates) { + try { + await applyIdentityEvent(db, did, handle); + } catch (err) { + log.warn(`[ingest] identity update failed for ${did}: ${err}`); + } + } + log.log(`[ingest] applied ${identityUpdates.size} identity event(s)`); + } + + // Persist the cursor BEFORE the best-effort enrichment tail below. Records are + // already durably applied (applyEvents) and handle changes recorded, so the + // cursor's forward progress is real and must be committed now. The steps that + // follow — refreshStaleIdentities especially — make per-DID network calls and + // can run long; if the cron isolate is aborted (e.g. a scheduled-invocation + // deadline) while they run, an un-saved cursor makes the next cycle re-drain the + // identical window forever. Identity refresh is idempotent and staleness-driven, + // so deferring it past the save costs nothing. + if (lastCursor !== null) { + await saveCursor(db, lastCursor); + log.log( + `[ingest] saved cursor=${lastCursor} (advanced ${ + cursor !== null ? lastCursor - cursor : "n/a" + }us)` + ); + } else { + log.log(`[ingest] no cursor returned from subscription; not saving`); + } + + // Refresh stale/missing identities for DIDs in this batch (best-effort; runs + // after the cursor save so its network latency can't strand forward progress). + const uniqueDids = [...new Set(events.map((e) => e.did))]; + if (uniqueDids.length > 0) { + try { + await refreshStaleIdentities(db, uniqueDids, config); + } catch (err) { + log.warn(`Identity refresh failed: ${err}`); + } + } + + // Newly-discovered DIDs: ask Constellation for back-edges so they + // immediately appear in existing followers' feeds (best-effort, opt-out). + if (config.feeds && newlyKnownDids.length > 0) { + for (const subj of newlyKnownDids) { + try { + await backfillFollowersFromConstellation(db, config, subj); + } catch (err) { + log.warn(`[constellation] subject=${subj} failed: ${err}`); + } + } + } + + // Prune feed_items to per-collection caps with a bounded, cursored sweep. + // Every statement is index-backed and O(cap) (see sweepFeedItems), so it can + // never exhaust D1's per-query CPU budget and reset the shared DO — unlike + // the old global window+anti-join. The cron isolate recycles each tick, so the + // sweep cursor and the recovery clock both live in the DB. + // + // A feed only goes over cap right after a row is inserted, and rows are only + // inserted for feed-mutating collections (event fan-out, follow backfill). So + // we skip the sweep entirely on ticks that ingested nothing feed-relevant — + // the overwhelming majority — and otherwise advance one bounded slice. + // + // The recovery clock tracks when a *full pass* over every actor last + // completed, not the last slice: while a pass is overdue we keep slicing every + // tick (bounded cost) until the cursor wraps, then reset the clock. That bounds + // the worst-case time an over-cap feed waits to be revisited — a feed touched + // just after the cursor passed it, a lowered cap, or a bulk import all drain + // within one recovery interval plus the pass's lap time, instead of stalling + // for many intervals (one slice per interval) as a per-slice clock would. + if (config.feeds) { + const feedMutatingNsids = getFeedMutatingNsids(config); + const feedTouched = events.some((e) => feedMutatingNsids.has(e.collection)); + await runGatedFeedPrune(db, config, feedTouched); + } + + // Opt-in planner-stat maintenance (gated + persisted cadence; no-op unless + // config.maintenance.optimize is set). + await maybeOptimize(db, config, log); + + log.log(`[ingest] cycle complete. stored=${events.length}`); +} diff --git a/packages/contrail/src/core/labels/apply.ts b/packages/contrail/src/core/labels/apply.ts index 551c495..cbf5558 100644 --- a/packages/contrail/src/core/labels/apply.ts +++ b/packages/contrail/src/core/labels/apply.ts @@ -1 +1,64 @@ -export * from "@atmo-dev/contrail-appview"; +import type { Database, Statement } from "../types"; + +/** Wire shape of a single `com.atproto.label.defs#label` entry. Field names + * match the spec exactly. We accept the spec's ISO-8601 strings and + * convert to unix seconds at the storage boundary. */ +export interface IncomingLabel { + src: string; + uri: string; + val: string; + cid?: string; + neg?: boolean; + exp?: string; + cts: string; + sig?: Uint8Array; +} + +/** Upsert a batch of labels. Idempotent on `(src, uri, val, cts)`. Bad rows + * (missing required fields, unparseable timestamps) are dropped silently; + * we don't want one malformed label to abort an entire labeler frame. */ +export async function applyLabels( + db: Database, + labels: IncomingLabel[], +): Promise<number> { + if (labels.length === 0) return 0; + const stmts: Statement[] = []; + let kept = 0; + for (const l of labels) { + if (!l.src || !l.uri || !l.val || !l.cts) continue; + const cts = isoToUnixSec(l.cts); + if (cts == null) continue; + const exp = l.exp ? isoToUnixSec(l.exp) : null; + stmts.push( + db + .prepare( + `INSERT INTO labels (src, uri, val, cid, neg, exp, cts, sig) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(src, uri, val, cts) DO UPDATE SET + cid = excluded.cid, + neg = excluded.neg, + exp = excluded.exp, + sig = excluded.sig`, + ) + .bind( + l.src, + l.uri, + l.val, + l.cid ?? null, + l.neg ? 1 : 0, + exp, + cts, + l.sig ?? null, + ), + ); + kept++; + } + if (stmts.length > 0) await db.batch(stmts); + return kept; +} + +function isoToUnixSec(iso: string): number | null { + const ms = Date.parse(iso); + if (!Number.isFinite(ms)) return null; + return Math.floor(ms / 1000); +} diff --git a/packages/contrail/src/core/labels/hydrate.ts b/packages/contrail/src/core/labels/hydrate.ts index 551c49528106738c1198722f3089b7f856050fe3..02b947a44ef764f5c31f64fefd468727f76fd7d1 100644 GIT binary patch literal 3547 zcmd1IEyyn_Q7EY_NL8p-a7iplOiC<HRj5@+E6UGRP}0-W2T2txS#v>ERx2bWmLzAS zrg-KBmZla}A}cM*FD*$e($7fEDM&2>>EY7X(ozV|EK1ca&PXgsRY=P(Qb<(Ds7xtJ zEJ;mK$Vp5}%~5#NFhwC3Y-X`SLUMktUSdf>QGQ9j9#~p0B{i*B8O%)JQqWRRNXtyk zNzqNqFVY3G6pHf|lJoP5OLJ3;iWQO*^Arjai;ER9OB9MriV`!^GfEUnGK%s`(=!xG z@)e*)D+IfE7wai#>2sx4fc>ADSCU$kmYAHX-~n}<57=$hTnY-sMafnm!!q;It+^Bw zN{cd)xn+qt$lT=26nkVoC;<HPaw-)Hic*VH^GX!TGg9*uN{UKT!PYA%<fW$DTY&{Y zhJ%!Q2K(z;m>C%ADCFm6mVm+utS2vDA+@3)v#3%bvsj@xwFIg^wW0vSLYN5($wjG& zC7Jno3MHAjsl_FUxdl*N$tA@ISJrZIA^abdSPu4jP=2`rIlhM(t5BnmSDKRp@>E`G zx|Kp+X>L+#5r~^wQ2^s2DFV9)rl^(+oO0Y!OOi7nsiYX1c8XI=6!Oy)ic6ESQj<%H zbrjMvb4pT+KyjO#n3I!Qq+YC$n4Fwi017v7s!1&>)&VD+<oukR#Dd~fg#?Y_qGTOV zMCvG%CFW=*D3p{I<fIlWBvmTpB$lKWmnbA8mlP-HC?w{kD5MnS7r+#hXXF>Bf(?VJ z%Pdw%R7l86O}7O_X@WvgYDrOIGAPLP6oOJqN{jM93JOw-bfGR*$W1KJ<5JK98xjol z1vuFzr79$r=!OP)7AxczDI_Hpr7F01x)g)6M@dGiLUCelszOO(QckKuMt)98u|iUQ zNd`DNQz2<MvA8lXSs|@74{Sdymw_X*Sc6MJAtlKQp22lM=^AP~EXJZ^L87q0LlrGb zP0lY$an4L}sthg4w6{|5O)Rh}E-A{)OV@$s4QO22>2PUUDFhVd=VlhC+6001!BwJU zuIN}hJ8%-qOjFQ+22ZhGPHJ9yNrr;0t*wHALQM@kGEpQnK>=M_l&4T#Yt6-_prEg> z;F6kBT967)7^uPK;_0H0n^>ukSWu9fSfr4dr;w16nNpkpj^M;%1yJ}x0v}>nVrfZ6 zevvg;931WtNw68o`MIf((lIBqxI{r47SMWJ3gCjMM4>b<vk)G&whGaDdU|=O<qE;6 zC2((Q#)5JJsN8}mcZ4NbROPV904awXUyzfSoC-<;paRbpY#U6wUT$K6hK8nst(}6B zy^^M0R(@ulhLVnwCc?J7{PN(`WCdFV-^7v(y|kSC{2~pP#FA9Ky!>(vO$B`gLjwZ? zO<1TpW#*-1=A{=alw~HO7yOAusa9am6%?hWWmf1Y=;`StXO!k;$Lc8rl!A)gRE31p zih=}4m_jqER#IwOeo?9xIBXN*<30W2;}aB+`~)h{LGh6Uv05R&C?&NBRzX0F0!ar4 z`k?3nD?`LVQGR)`f~`Vgd17V>n!|07ia0w^#sFmvP|{aWNC<ZIadi#>75RwrK}R7u zGerkfQ0st7U>$|zl41q7Ab($I$;71q0pT95L9QUJ3Z8xn8Y<ODek!ijgsD<caP)Ig z&`7N)Q1A>^@C)_vQSc7}nX6!@V2`G%v?vp779^B36CjQRr6*7lgGIWI0whks*;fIa z9$={tqyZk60h!6!;82H_T}Aoj3I(Y}n8l%<LP%z6u|j?xq!?5vN-fUMDN9vIs)PoA zVs>U;I@l<X86_EsB?^f}MVV#bDjifBXXb%r3&9P9qSWHjoDxVumI6{$q??jjkeZj0 znpXlbFsZaep$JsqW#%aqfXmP1RBMIeg4AS%l>F4<JoOTVl+?7$yi|pf%v9Z^qExU0 zK#>h<oG5swRw~#+OCdx;hxpQ|5+V!AfS@wVJGD|r0VM%|^9HD4q5;W{ATQ;oDS#4? zCb)P~fM)A#ur&!P)kXQ`dc{S_wG0quX;CJOS(cbnn*eeGOiw{kW`2=^EwowzJ4P=( zwL~L3wGwQvf<k7Rf`%eQSxpVdRy|Piva?kHi-9<rD7uRwx^+NG!A8`A3P6a{^Gi#t z6bKZ#3bx=vQvqC`qIwxA#PrG%b4pW-H8f#C0CFbC-+8I&nxOC~$;>NFwFVWu&?+xA z1>_EJQS9vLqEL{TXQhw`$sr2h6q1*in+mE7LD8C-mYJ*osZ$g{suK$eav(`Ar&6IL zADmb8^&$GoQj3Z+^Yio+Jk!953R2C3TY0G|3W-o-bdxhvKy@p)L<f}*Mfv6G#V~_F zcBEt$C#DyrrWWf!s`ShfcpU+5C%}^$$T1*SfNg`?4l}4cRlO(`TKQz=rRym;CMV~Y z=9TE?q?V=TK)hS5paH6Hpf#GNLULkJQKdp&KFFsqa|$x^K(!Dev6rVR<b#@t$r-81 z+0aHJsM3ej(F)0+%D+6bBtsz)WDF?jLy`_CPC#B)Q-cNstd53794sY5LfbhrMZp$L zB{)HX%qcC(1g9uaaR!cWaODlsqG$`MXCQenMFH6^1&|a-b8==1Qo;cH6Pz+(_JhON zN&%$>07{EsmC!~u$Q)493@Qe0X@kT{i!wna8OUAW<~B&IEHMWr25vf*6o=${7Uye# z<dRE@H6fC<kjx17O@6r^*pXnDfS90=fC!?h0+r2*w&3O~DDNeu=IDWhY!#60NUbP< z29`#CX-PE5+R~!TSOt4~TZQOYO}&EB;tUP2RbXY{uqXnz2o>^6ORTwSxwzm>HJG`j wd6^Z#smZX`u_mky1}=u6nn48_sJ_;KDpt^f)Yy6@`JTc4!QdvDh9<~-0O=5WfdBvi literal 44 zcmYeTD9A4=QP5IIE6UGRP;y8t$<5bINiEY)&d)0;O3cjBO)MxV%S<g-vgQH+V0;gI diff --git a/packages/contrail/src/core/labels/resolve.ts b/packages/contrail/src/core/labels/resolve.ts index 551c495..af673d9 100644 --- a/packages/contrail/src/core/labels/resolve.ts +++ b/packages/contrail/src/core/labels/resolve.ts @@ -1 +1,155 @@ -export * from "@atmo-dev/contrail-appview"; +import { + CompositeDidDocumentResolver, + type DidDocumentResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, +} from "@atcute/identity-resolver"; +import type { Did } from "@atcute/lexicons"; +import type { Database } from "../types"; +import { validateExternalUrl } from "../client"; + +/** Optional network-override knobs accepted by labeler-endpoint resolution. + * Mirrors the `ContrailConfig.networkOverrides` shape — kept narrow here so + * callers can pass `config.networkOverrides` directly without re-shaping. + * Omitting the object preserves the previous public-internet behavior. */ +export interface LabelerResolveOverrides { + /** DID document resolver used when looking up the labeler service entry. + * When unset, falls back to a default composite (PLC + Web) pointing at the + * upstream PLC directory. Trusted; not SSRF-checked. + * Mirrors the resolver-injection pattern in `core/client.ts`. */ + resolver?: DidDocumentResolver; + /** Hostnames (DNS names or IP literals) to allow past the default SSRF + * guard when validating a resolved labeler endpoint. Match is exact, + * case-insensitive, port-agnostic. */ + additionalAllowedHosts?: string[]; +} + +/** Reject endpoint URLs that point to private/internal addresses or non-HTTPS. + * Thin alias for the single shared SSRF guard {@link validateExternalUrl} in + * `contrail-base` — labeler endpoints are validated by the exact same rules as + * PDS endpoints, so the allowlist logic must live in one place. Kept exported + * under this name for existing callers/tests. */ +export const validateEndpointUrl = validateExternalUrl; + +const DEFAULT_DID_RESOLVER: DidDocumentResolver = new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver(), + }, +}); + +/** Look up the labeler service endpoint from a DID. + * Reads the DID doc's `service[id="#atproto_labeler"].serviceEndpoint`. + * + * `networkOverrides` (optional): customize the DID resolver used during the + * lookup, and/or which hostnames bypass the default SSRF guard. Omitting it + * preserves the original public-internet behavior. */ +export async function resolveLabelerEndpoint( + did: string, + networkOverrides?: LabelerResolveOverrides, +): Promise<string | null> { + if (!did.startsWith("did:plc:") && !did.startsWith("did:web:")) return null; + const resolver = networkOverrides?.resolver ?? DEFAULT_DID_RESOLVER; + try { + const doc = await resolver.resolve(did as Did<"plc"> | Did<"web">); + const endpoint = doc.service + ?.find((s) => s.id === "#atproto_labeler") + ?.serviceEndpoint?.toString(); + if (!endpoint) return null; + if (!validateEndpointUrl(endpoint, networkOverrides?.additionalAllowedHosts ?? [])) { + return null; + } + return endpoint; + } catch { + return null; + } +} + +/** State row for a labeler — the per-DID equivalent of the singleton + * jetstream `cursor` table, with cached endpoint to avoid repeated DID-doc + * fetches. */ +export interface LabelerState { + did: string; + cursor: number; + endpoint: string | null; + resolved_at: number | null; +} + +const ENDPOINT_TTL_MS = 6 * 60 * 60 * 1000; // 6h, matches the recommended client cache for label-defs + +/** Get cached `(endpoint, cursor)` for a labeler. Resolves endpoint on + * cache miss or staleness; persists endpoint + resolved_at back to the DB + * so subsequent ingest cycles avoid the network round-trip. + * + * `networkOverrides` (optional): forwarded to `resolveLabelerEndpoint` for + * the cache-miss/stale path. Has no effect when `endpointOverride` is set + * or when a fresh cached endpoint is used. */ +export async function getLabelerState( + db: Database, + did: string, + endpointOverride: string | undefined, + networkOverrides?: LabelerResolveOverrides, +): Promise<LabelerState | null> { + const row = await db + .prepare( + "SELECT did, cursor, endpoint, resolved_at FROM labeler_cursors WHERE did = ?", + ) + .bind(did) + .first<LabelerState>(); + + let endpoint = endpointOverride ?? row?.endpoint ?? null; + const stale = + !row?.resolved_at || Date.now() - row.resolved_at > ENDPOINT_TTL_MS; + + if (!endpoint || (!endpointOverride && stale)) { + endpoint = await resolveLabelerEndpoint(did, networkOverrides); + if (!endpoint) return null; + const now = Date.now(); + await db + .prepare( + `INSERT INTO labeler_cursors (did, cursor, endpoint, resolved_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(did) DO UPDATE SET endpoint = excluded.endpoint, resolved_at = excluded.resolved_at`, + ) + .bind(did, row?.cursor ?? 0, endpoint, now) + .run(); + return { + did, + cursor: row?.cursor ?? 0, + endpoint, + resolved_at: now, + }; + } + + return row ?? { did, cursor: 0, endpoint, resolved_at: null }; +} + +/** Persist the highest seen seq number for a labeler. Idempotent; + * the next ingest cycle resumes from `cursor + 1` via the `?cursor=` param. */ +export async function saveLabelerCursor( + db: Database, + did: string, + cursor: number, +): Promise<void> { + await db + .prepare( + `INSERT INTO labeler_cursors (did, cursor) + VALUES (?, ?) + ON CONFLICT(did) DO UPDATE SET cursor = excluded.cursor`, + ) + .bind(did, cursor) + .run(); +} + +/** Reset cursor to 0 — used in response to `#info { name: "OutdatedCursor" }` + * frames, which signal that the labeler's seq history was rewound. */ +export async function resetLabelerCursor(db: Database, did: string): Promise<void> { + await db + .prepare( + `INSERT INTO labeler_cursors (did, cursor) + VALUES (?, 0) + ON CONFLICT(did) DO UPDATE SET cursor = 0`, + ) + .bind(did) + .run(); +} diff --git a/packages/contrail/src/core/labels/schema.ts b/packages/contrail/src/core/labels/schema.ts index 551c495..486ce27 100644 --- a/packages/contrail/src/core/labels/schema.ts +++ b/packages/contrail/src/core/labels/schema.ts @@ -1 +1,30 @@ -export * from "@atmo-dev/contrail-appview"; +import type { SqlDialect } from "../dialect"; + +/** DDL for the labels module. Single `labels` table covers record-level + * (uri starts with `at://`) and account-level (uri is a bare DID) entries — + * the spec collapses both into the same row shape. `labeler_cursors` + * mirrors the role of the singleton `cursor` table for jetstream, but + * per-labeler. */ +export function buildLabelsSchema(dialect: SqlDialect): string[] { + return [ + `CREATE TABLE IF NOT EXISTS labels ( + src TEXT NOT NULL, + uri TEXT NOT NULL, + val TEXT NOT NULL, + cid TEXT, + neg INTEGER NOT NULL DEFAULT 0, + exp ${dialect.bigintType}, + cts ${dialect.bigintType} NOT NULL, + sig BLOB, + PRIMARY KEY (src, uri, val, cts) + )`, + `CREATE INDEX IF NOT EXISTS idx_labels_uri ON labels(uri)`, + `CREATE INDEX IF NOT EXISTS idx_labels_src_cts ON labels(src, cts DESC)`, + `CREATE TABLE IF NOT EXISTS labeler_cursors ( + did TEXT PRIMARY KEY, + cursor ${dialect.bigintType} NOT NULL DEFAULT 0, + endpoint TEXT, + resolved_at ${dialect.bigintType} + )`, + ]; +} diff --git a/packages/contrail/src/core/labels/select.ts b/packages/contrail/src/core/labels/select.ts index 551c495..da89d9c 100644 --- a/packages/contrail/src/core/labels/select.ts +++ b/packages/contrail/src/core/labels/select.ts @@ -1 +1,64 @@ -export * from "@atmo-dev/contrail-appview"; +import type { LabelsConfig } from "./types"; +import { DEFAULT_LABELS_MAX_PER_REQUEST } from "./types"; + +/** Pick which labelers to honor for this request. + * + * Order of precedence: + * 1. `atproto-accept-labelers` header (atproto spec) + * 2. `?labelers=` query param (fallback for SSE/WS where headers are awkward) + * 3. `config.defaults` (operator policy) + * 4. every entry in `config.sources` + * + * Each candidate DID is checked against `config.sources`. Unknowns are + * dropped — we only have rows for labelers we've subscribed to. + * + * Header values can carry `;param` modifiers (e.g. `did:plc:...;redact`); + * v1 strips and ignores those — only the bare DID is honored. */ +export interface SelectedLabelers { + /** DIDs to use for hydration this request. */ + accepted: string[]; +} + +export function selectAcceptedLabelers( + headerValue: string | null | undefined, + paramValue: string | null | undefined, + cfg: LabelsConfig, +): SelectedLabelers { + const cap = cfg.maxPerRequest ?? DEFAULT_LABELS_MAX_PER_REQUEST; + const known = new Set(cfg.sources.map((s) => s.did)); + + const fromCaller = parseLabelerList(headerValue) ?? parseLabelerList(paramValue); + + let candidates: string[]; + if (fromCaller && fromCaller.length > 0) { + candidates = fromCaller; + } else { + candidates = (cfg.defaults ?? cfg.sources.map((s) => s.did)).slice(); + } + + const accepted: string[] = []; + const seen = new Set<string>(); + for (const did of candidates) { + if (seen.has(did)) continue; + seen.add(did); + if (known.has(did)) accepted.push(did); + if (accepted.length >= cap) break; + } + + return { accepted }; +} + +/** Parse a comma-separated DID list. Returns null when the input is empty + * or undefined so callers can distinguish "absent" from "empty list" (the + * latter — `atproto-accept-labelers: ` — is technically valid and means + * "no labelers"; we treat it the same as absent for ergonomics). */ +function parseLabelerList(value: string | null | undefined): string[] | null { + if (!value) return null; + const out: string[] = []; + for (const raw of value.split(",")) { + // Drop `;param` modifiers from the spec (e.g. `;redact`). v1 ignores them. + const head = raw.split(";")[0]!.trim(); + if (head.startsWith("did:")) out.push(head); + } + return out.length > 0 ? out : null; +} diff --git a/packages/contrail/src/core/labels/subscribe.ts b/packages/contrail/src/core/labels/subscribe.ts index 551c495..1351d01 100644 --- a/packages/contrail/src/core/labels/subscribe.ts +++ b/packages/contrail/src/core/labels/subscribe.ts @@ -1 +1,333 @@ -export * from "@atmo-dev/contrail-appview"; +import { decodeFirst } from "@atcute/cbor"; +import type { ContrailConfig, Database, Logger } from "../types"; +import type { LabelerSource } from "./types"; +import { applyLabels, type IncomingLabel } from "./apply"; +import { + getLabelerState, + resetLabelerCursor, + saveLabelerCursor, +} from "./resolve"; + +const DEFAULT_CYCLE_TIMEOUT_MS = 25_000; +const DEFAULT_BATCH_SIZE = 100; +const DEFAULT_FLUSH_INTERVAL_MS = 5_000; + +function getLogger(config: ContrailConfig): Logger { + return config.logger ?? console; +} + +/** One catch-up cycle for every configured labeler. Designed to fit inside a + * Cloudflare Workers cron tick — we drain frames until the labeler has no + * more buffered events for us, or `timeoutMs` is reached, then save cursor + * and disconnect. Mirrors the shape of `runIngestCycle` for jetstream. */ +export async function runLabelIngestCycle( + db: Database, + config: ContrailConfig, + timeoutMs = DEFAULT_CYCLE_TIMEOUT_MS, +): Promise<void> { + if (!config.labels) return; + const log = getLogger(config); + const deadline = Date.now() + timeoutMs; + + for (const source of config.labels.sources) { + if (Date.now() >= deadline) { + log.log(`[labels] cycle deadline hit before processing ${source.did}`); + break; + } + const remaining = Math.max(2_000, deadline - Date.now()); + try { + await pumpOneLabeler( + db, + source, + log, + remaining, + /* persistent */ false, + {}, + config.networkOverrides, + ); + } catch (err) { + log.warn(`[labels] cycle for ${source.did} failed: ${err}`); + } + } +} + +export interface PersistentLabelsOptions { + signal?: AbortSignal; + batchSize?: number; + flushIntervalMs?: number; + logger?: Logger; +} + +/** Long-lived equivalent — keeps one socket per labeler open forever, with + * exponential backoff reconnect. Mirrors `runPersistent` for jetstream. */ +export async function runPersistentLabels( + db: Database, + config: ContrailConfig, + options: PersistentLabelsOptions = {}, +): Promise<void> { + if (!config.labels) return; + const log = options.logger ?? config.logger ?? console; + const signal = options.signal; + + const tasks = config.labels.sources.map((source) => + runOneLabelerForever(db, source, log, signal, options, config.networkOverrides), + ); + await Promise.all(tasks); +} + +async function runOneLabelerForever( + db: Database, + source: LabelerSource, + log: Logger, + signal: AbortSignal | undefined, + options: PersistentLabelsOptions, + networkOverrides: ContrailConfig["networkOverrides"], +): Promise<void> { + let attempts = 0; + while (!signal?.aborted) { + try { + await pumpOneLabeler( + db, + source, + log, + /* timeoutMs */ Infinity, + true, + { + signal, + batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE, + flushIntervalMs: options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS, + }, + networkOverrides, + ); + attempts = 0; + } catch (err) { + if (signal?.aborted) break; + log.error(`[labels] ${source.did} stream error: ${err}`); + const delay = Math.min(1_000 * 2 ** attempts, 30_000); + attempts++; + log.log(`[labels] ${source.did} reconnecting in ${delay}ms (attempt ${attempts})`); + await new Promise((r) => setTimeout(r, delay)); + } + } +} + +interface PumpOptions { + signal?: AbortSignal; + batchSize?: number; + flushIntervalMs?: number; +} + +/** Open a `subscribeLabels` WebSocket, drain frames into a buffer, flush + * the buffer to `labels` in batches, and persist the seq cursor. Returns + * when: + * - the labeler closes the socket cleanly (caught up + no more events) + * - `timeoutMs` is reached (cron mode) + * - `signal` is aborted (persistent mode) + * - an error tears the socket down (caller may retry) */ +async function pumpOneLabeler( + db: Database, + source: LabelerSource, + log: Logger, + timeoutMs: number, + persistent: boolean, + pumpOpts: PumpOptions = {}, + networkOverrides?: ContrailConfig["networkOverrides"], +): Promise<void> { + const state = await getLabelerState(db, source.did, source.endpoint, networkOverrides); + if (!state) { + log.warn(`[labels] could not resolve labeler endpoint for ${source.did}; skipping`); + return; + } + + // First-time policy: cursor 0 = "from the beginning" if backfill is on + // (default), null = "from now" otherwise. After the first cycle we always + // resume from the saved cursor — `backfill` only flips the start point. + const isFirstRun = state.cursor === 0 && state.resolved_at === null; + const backfill = source.backfill !== false; + const startCursor = isFirstRun && !backfill ? null : state.cursor; + + const url = buildWsUrl(state.endpoint!, startCursor); + log.log(`[labels] connecting to ${source.did} (cursor=${startCursor ?? "now"})`); + + const ws = new WebSocket(url); + ws.binaryType = "arraybuffer"; + + const buffer: IncomingLabel[] = []; + let highestSeq = state.cursor; + let flushing = false; + let resolveDone!: () => void; + let rejectDone!: (err: unknown) => void; + const done = new Promise<void>((res, rej) => { + resolveDone = res; + rejectDone = rej; + }); + + const flush = async () => { + if (buffer.length === 0 || flushing) return; + flushing = true; + const batch = buffer.splice(0); + try { + const kept = await applyLabels(db, batch); + if (highestSeq > state.cursor) { + await saveLabelerCursor(db, source.did, highestSeq); + state.cursor = highestSeq; + } + log.log( + `[labels] ${source.did} flushed ${kept}/${batch.length} labels, cursor=${highestSeq}`, + ); + } catch (err) { + log.error(`[labels] ${source.did} flush failed: ${err}`); + } finally { + flushing = false; + } + }; + + const batchSize = pumpOpts.batchSize ?? DEFAULT_BATCH_SIZE; + const flushInterval = pumpOpts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS; + const flushTimer = setInterval(() => { + flush().catch(() => {}); + }, flushInterval); + + const cleanup = () => { + clearInterval(flushTimer); + try { + ws.close(); + } catch { + /* already closed */ + } + }; + + // External abort (persistent mode) — close socket gracefully. + const abortHandler = () => { + cleanup(); + flush().finally(() => resolveDone()); + }; + pumpOpts.signal?.addEventListener("abort", abortHandler, { once: true }); + + // Cron-mode time budget — close socket gracefully when reached. + let deadlineTimer: ReturnType<typeof setTimeout> | undefined; + if (Number.isFinite(timeoutMs)) { + deadlineTimer = setTimeout(() => { + log.log(`[labels] ${source.did} cycle deadline reached, closing`); + cleanup(); + flush().finally(() => resolveDone()); + }, timeoutMs); + } + + ws.addEventListener("error", (ev) => { + cleanup(); + if (deadlineTimer) clearTimeout(deadlineTimer); + pumpOpts.signal?.removeEventListener("abort", abortHandler); + rejectDone(new Error(`WebSocket error: ${(ev as ErrorEvent)?.message ?? "unknown"}`)); + }); + + ws.addEventListener("close", () => { + if (deadlineTimer) clearTimeout(deadlineTimer); + pumpOpts.signal?.removeEventListener("abort", abortHandler); + flush().finally(() => { + clearInterval(flushTimer); + resolveDone(); + }); + }); + + ws.addEventListener("message", async (ev) => { + let bytes: Uint8Array; + if (ev.data instanceof ArrayBuffer) { + bytes = new Uint8Array(ev.data); + } else if (ev.data instanceof Uint8Array) { + bytes = ev.data; + } else { + // Binary-only protocol — text frames shouldn't arrive. + return; + } + const frame = decodeFrame(bytes); + if (!frame) return; + + if (frame.t === "#labels") { + const seq = Number(frame.payload?.seq ?? 0); + const rawLabels = Array.isArray(frame.payload?.labels) ? frame.payload.labels : []; + for (const raw of rawLabels) { + const lab = normalizeLabel(raw, source.did); + if (lab) buffer.push(lab); + } + if (Number.isFinite(seq) && seq > highestSeq) highestSeq = seq; + if (buffer.length >= batchSize) { + flush().catch(() => {}); + } + } else if (frame.t === "#info") { + const name = String(frame.payload?.name ?? ""); + log.log(`[labels] ${source.did} info: ${name}`); + if (name === "OutdatedCursor") { + // Labeler rewound its log — discard our cursor and let the next + // run start from the beginning. We don't reconnect here; the + // caller (or the persistent loop) will pick up the reset on retry. + await resetLabelerCursor(db, source.did); + cleanup(); + } + } else if (frame.op === -1) { + log.warn(`[labels] ${source.did} error frame: ${JSON.stringify(frame.payload)}`); + cleanup(); + } + }); + + // Workers WebSocket doesn't always emit `open`; just await `done` directly. + await done; +} + +function buildWsUrl(httpEndpoint: string, cursor: number | null): string { + const u = new URL("/xrpc/com.atproto.label.subscribeLabels", httpEndpoint); + // wss:// for HTTPS endpoints — the protocol on the labeler service is + // expected to be HTTPS already (validated at resolution time). + u.protocol = u.protocol === "https:" ? "wss:" : "ws:"; + if (cursor !== null) u.searchParams.set("cursor", String(cursor)); + return u.toString(); +} + +interface DecodedFrame { + op: number; + t: string | undefined; + payload: Record<string, unknown>; +} + +/** Decode an atproto subscription frame: two consecutive CBOR objects. + * Header `{ op, t? }`, payload — shape depends on `t`. Returns null on + * decode failure or non-object frames. */ +function decodeFrame(bytes: Uint8Array): DecodedFrame | null { + try { + const [header, rest] = decodeFirst(bytes); + if (!header || typeof header !== "object") return null; + const op = typeof (header as { op?: number }).op === "number" ? (header as { op: number }).op : 1; + const t = typeof (header as { t?: string }).t === "string" ? (header as { t: string }).t : undefined; + const [payload] = decodeFirst(rest); + if (!payload || typeof payload !== "object") return null; + return { op, t, payload: payload as Record<string, unknown> }; + } catch { + return null; + } +} + +/** Coerce a wire `Label` object into our `IncomingLabel` shape. Returns + * null when required fields are missing — we'd rather skip a row than + * insert one with placeholder values. */ +function normalizeLabel(raw: unknown, expectedSrc: string): IncomingLabel | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as Record<string, unknown>; + const src = typeof r.src === "string" ? r.src : null; + const uri = typeof r.uri === "string" ? r.uri : null; + const val = typeof r.val === "string" ? r.val : null; + const cts = typeof r.cts === "string" ? r.cts : null; + if (!src || !uri || !val || !cts) return null; + // A labeler shouldn't emit labels under a different `src` than its own + // DID — drop them rather than poison our table with cross-issuer rows. + if (src !== expectedSrc) return null; + return { + src, + uri, + val, + cts, + cid: typeof r.cid === "string" ? r.cid : undefined, + neg: r.neg === true, + exp: typeof r.exp === "string" ? r.exp : undefined, + sig: r.sig instanceof Uint8Array ? r.sig : undefined, + }; +} diff --git a/packages/contrail/src/core/labels/types.ts b/packages/contrail/src/core/labels/types.ts index 551c495..0194f95 100644 --- a/packages/contrail/src/core/labels/types.ts +++ b/packages/contrail/src/core/labels/types.ts @@ -1 +1,55 @@ -export * from "@atmo-dev/contrail-appview"; +import type { Database } from "../types"; + +/** A labeler the operator wants contrail to track. */ +export interface LabelerSource { + /** Labeler DID — `did:plc:...` or `did:web:...`. */ + did: string; + /** Override the service endpoint resolution. Otherwise resolved from the + * DID doc's `service[id="#atproto_labeler"].serviceEndpoint`. */ + endpoint?: string; + /** Backfill from `cursor=0` on first sight. Defaults to true. Set false + * for "start from now" — useful for very chatty labelers. */ + backfill?: boolean; +} + +export interface LabelsConfig { + /** Labelers to subscribe to and index. */ + sources: LabelerSource[]; + /** DIDs honored when the caller sends no `atproto-accept-labelers` / + * `?labelers=`. Defaults to every entry in `sources`. Set `[]` for + * opt-in-only — clients see no labels unless they ask. */ + defaults?: string[]; + /** Per-request cap. Default: 20 (matches Bluesky). */ + maxPerRequest?: number; +} + +export const DEFAULT_LABELS_MAX_PER_REQUEST = 20; + +/** A single label as stored. Matches `com.atproto.label.defs#label`. */ +export interface LabelRow { + /** Issuing labeler DID. */ + src: string; + /** Subject — at-URI for record labels, plain DID for account labels. */ + uri: string; + /** Label value — kebab-case, ≤128 bytes per spec. */ + val: string; + /** Optional CID pin to a specific record version. */ + cid: string | null; + /** When true, retracts a previously-emitted label for the same (src, uri, val). */ + neg: boolean; + /** Expiry, unix seconds. Past this, hydration drops the row. */ + exp: number | null; + /** Creation timestamp, unix seconds — what we collapse on. */ + cts: number; + /** Raw signature bytes. Stored when present so we can re-emit later; + * not verified in v1. */ + sig: Uint8Array | null; +} + +/** Per-labeler state row — endpoint cache and last-seen seq cursor. */ +export interface LabelerCursorRow { + did: string; + cursor: number; + endpoint: string | null; + resolved_at: number | null; +} diff --git a/packages/contrail/src/core/persistent.ts b/packages/contrail/src/core/persistent.ts index 551c495..bfd69dc 100644 --- a/packages/contrail/src/core/persistent.ts +++ b/packages/contrail/src/core/persistent.ts @@ -1 +1,344 @@ -export * from "@atmo-dev/contrail-appview"; +import type { JetstreamSubscription } from "@atcute/jetstream"; +import type { ContrailConfig, IngestEvent, Database, Logger, ResolvedContrailConfig } from "./types"; +import { + getCollectionNsids, + getDependentNsids, + buildFeedTargetCaps, + getFeedMutatingNsids, + jetstreamUrlOption, + resolveConfig, + shortNameForNsid, +} from "./types"; +import { initSchema, getLastCursor, saveCursor, applyEvents } from "./db"; +import { refreshStaleIdentities, applyIdentityEvent } from "./identity"; +import { backfillFollowersFromConstellation } from "./constellation"; +import { + createIngestState, + runFeedPruneSlice, + FEED_PRUNE_RECOVERY_INTERVAL_MS, + maybeOptimize, +} from "./jetstream"; +import type { IngestState } from "./jetstream"; + +/** How often the long-lived persistent loop runs a bounded feed sweep. The + * process stays resident, so this in-memory throttle is reliable here (unlike + * the recycling cron isolate). */ +const FEED_SWEEP_INTERVAL_MS = 10_000; + +export interface PersistentIngestOptions { + batchSize?: number; + flushIntervalMs?: number; + signal?: AbortSignal; + /** Override subscription creation for testing */ + createSubscription?: (cursor: number | null) => JetstreamSubscription; + logger?: Logger; +} + +function getLogger(config: ContrailConfig, options?: PersistentIngestOptions): Logger { + return options?.logger ?? config.logger ?? console; +} + +export async function runPersistent( + db: Database, + config: ContrailConfig, + options?: PersistentIngestOptions, +): Promise<void> { + // Internals (applyEvents, count updates, query planning) read `_resolved` + // and silently skip features when it's missing. The Contrail class resolves + // in its constructor; callers using this raw export must also get a resolved + // config, so do it defensively here. resolveConfig is idempotent. + if (!(config as ResolvedContrailConfig)._resolved) { + config = resolveConfig(config); + } + const log = getLogger(config, options); + const batchSize = options?.batchSize ?? 50; + const flushIntervalMs = options?.flushIntervalMs ?? 5_000; + const signal = options?.signal; + const state = createIngestState(); + + // Init schema once + if (!state.schemaInitialized) { + await initSchema(db, config); + state.schemaInitialized = true; + } + + // Load known DIDs for dependent collection filtering + const dependentCollections: Set<string> = new Set(getDependentNsids(config)); + let knownDids: Set<string> | undefined; + if (dependentCollections.size > 0) { + const result = await db + .prepare("SELECT did FROM identities") + .all<{ did: string }>(); + knownDids = new Set((result.results ?? []).map((r) => r.did)); + state.cachedKnownDids = knownDids; + log.log(`Loaded ${knownDids.size} known DIDs from database`); + } + + const collections = getCollectionNsids(config); + let reconnectAttempts = 0; + + while (!signal?.aborted) { + const cursor = await getLastCursor(db); + log.log(`Starting persistent ingestion. Cursor: ${cursor ?? "none"}, Collections: ${collections.join(", ")}`); + + try { + await streamAndFlush(db, config, cursor, { + batchSize, + flushIntervalMs, + signal, + collections, + dependentCollections, + knownDids, + newlyKnownDids: new Set<string>(), + state, + log, + createSubscription: options?.createSubscription, + }); + reconnectAttempts = 0; + } catch (err) { + if (signal?.aborted) break; + log.error(`Jetstream connection error: ${err}`); + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30_000); + reconnectAttempts++; + log.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})...`); + await new Promise((r) => setTimeout(r, delay)); + } + } + + log.log("Persistent ingestion stopped"); +} + +interface StreamOptions { + batchSize: number; + flushIntervalMs: number; + signal?: AbortSignal; + collections: string[]; + dependentCollections: Set<string>; + knownDids?: Set<string>; + /** DIDs that crossed from unknown→known during this stream's lifetime. + * Drained on each flush so Constellation reverse-lookups can run for them. */ + newlyKnownDids?: Set<string>; + state: IngestState; + log: Logger; + createSubscription?: (cursor: number | null) => any; +} + +async function streamAndFlush( + db: Database, + config: ContrailConfig, + cursor: number | null, + opts: StreamOptions, +): Promise<void> { + const { batchSize, flushIntervalMs, signal, collections, dependentCollections, knownDids, state, log } = opts; + + const subscription = opts.createSubscription + ? opts.createSubscription(cursor) + : new (await import("@atcute/jetstream")).JetstreamSubscription({ + // Single-instance config → string, so @atcute skips its array-only + // first-connect cursor rollback (see jetstreamUrlOption). + url: jetstreamUrlOption(config.jetstreams ?? []), + wantedCollections: collections, + ...(cursor !== null ? { cursor } : {}), + onConnectionOpen() { log.log("Connected to Jetstream"); }, + onConnectionClose(event: any) { log.log(`Disconnected: ${event.code} ${event.reason}`); }, + onConnectionError(event: any) { log.error("Jetstream error:", event.error); }, + }); + + const buffer: IngestEvent[] = []; + // Guards against overlap between the periodic timer flush and a main-loop + // batchSize-driven flush. The main loop only ever awaits flush() sequentially, + // but the setInterval callback is a second entry point on another tick. + let flushing = false; + + const flush = async () => { + if (flushing) return; + flushing = true; + + try { + if (buffer.length > 0) { + const batch = buffer.splice(0); + await applyEvents(db, batch, config); + + // A feed can only go over cap right after a feed-mutating record is + // applied, so remember whether this batch had one. The sweep below uses + // it to prune promptly (see the cron path in jetstream.ts). + if (config.feeds) { + const feedMutatingNsids = getFeedMutatingNsids(config); + if (batch.some((e) => feedMutatingNsids.has(e.collection))) { + state.feedDirty = true; + } + } + + const lastTimeUs = Math.max(...batch.map((e) => e.time_us)); + await saveCursor(db, lastTimeUs); + + const uniqueDids = [...new Set(batch.map((e) => e.did))]; + if (uniqueDids.length > 0) { + try { + await refreshStaleIdentities(db, uniqueDids, config); + } catch (err) { + log.warn(`Identity refresh failed: ${err}`); + } + } + + // Drain newly-known DIDs and ask Constellation for back-edges. + if (config.feeds && opts.newlyKnownDids && opts.newlyKnownDids.size > 0) { + const drained = [...opts.newlyKnownDids]; + opts.newlyKnownDids.clear(); + for (const subj of drained) { + try { + await backfillFollowersFromConstellation(db, config, subj); + } catch (err) { + log.warn(`[constellation] subject=${subj} failed: ${err}`); + } + } + } + + // Opt-in planner-stat maintenance (gated + persisted cadence). + await maybeOptimize(db, config, log); + + log.log(`Flushed ${batch.length} events. Cursor: ${lastTimeUs}`); + } + + // Bounded, cursored feed prune (see sweepFeedItems / runFeedPruneSlice). + // Runs whether or not this tick had events: ingest-dirty windows prune + // promptly (throttled by the sweep interval), and the recovery interval + // still fires on a fully idle stream — the timer drives this flush, and + // the old "return early when the buffer is empty" path starved recovery, + // so over-cap rows from a lowered cap or a bulk import never drained while + // the stream was quiet. The recovery clock tracks the last *completed* + // full pass (not the last slice), so an overdue pass keeps slicing each + // tick until the cursor wraps rather than advancing one slice per interval. + if (config.feeds) { + const caps = buildFeedTargetCaps(config); + if (caps.size > 0) { + const now = Date.now(); + const dirtyDue = + state.feedDirty && now - state.lastFeedSweepMs > FEED_SWEEP_INTERVAL_MS; + const recoveryDue = + now - state.lastFullFeedPassMs > FEED_PRUNE_RECOVERY_INTERVAL_MS; + if (dirtyDue || recoveryDue) { + const { pruned, done } = await runFeedPruneSlice(db, config); + if (done) state.lastFullFeedPassMs = now; + if (dirtyDue) { + state.feedDirty = false; + state.lastFeedSweepMs = now; + } + if (pruned > 0) { + log.log( + `Pruned ${pruned} feed items (sweep, reason=${dirtyDue ? "ingest" : "recovery"})` + ); + } + } + } + } + } finally { + flushing = false; + } + }; + + // Periodic flush decoupled from the main loop. Runs even when Jetstream is + // idle, which is the whole point — without it, buffered events strand until + // the next event or abort. Errors log and retry next interval rather than + // propagate, so transient DB hiccups don't force a reconnect. + const flushTimer = setInterval(() => { + flush().catch((err) => log.error(`Timer flush failed: ${err}`)); + }, flushIntervalMs); + + const onAbort = () => { + clearInterval(flushTimer); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + const iterator = subscription[Symbol.asyncIterator](); + + try { + while (!signal?.aborted) { + // Per-iteration abort race so the handler can be removed synchronously + // after the race settles — otherwise addEventListener calls accumulate on + // the signal across the streamAndFlush lifetime. + let abortHandler!: () => void; + const abortPromise = new Promise<IteratorResult<any>>((resolve) => { + abortHandler = () => resolve({ value: undefined, done: true }); + signal?.addEventListener("abort", abortHandler, { once: true }); + }); + + let result: IteratorResult<any>; + try { + result = await Promise.race([iterator.next(), abortPromise]); + } finally { + signal?.removeEventListener("abort", abortHandler); + } + + if (result.done) break; + const event = result.value; + + if (event.kind === "commit") { + const { commit } = event; + + const short = shortNameForNsid(config, commit.collection); + const collectionCfg = short ? config.collections[short] : undefined; + + if (dependentCollections.has(commit.collection) && knownDids) { + if (!knownDids.has(event.did)) continue; + // Subject filter: skip records whose subject DID isn't known. + const subjectField = collectionCfg?.subjectField; + if (subjectField && commit.operation !== "delete") { + const subj = (commit.record as Record<string, unknown> | undefined)?.[ + subjectField + ]; + if (typeof subj === "string" && !knownDids.has(subj)) continue; + } + } + + if (collectionCfg?.recordFilter && commit.operation !== "delete") { + const rec = commit.record as Record<string, unknown> | undefined; + let keep = false; + try { + keep = !!(rec && collectionCfg.recordFilter(rec)); + } catch (err) { + log.warn(`recordFilter threw for ${commit.collection}/${commit.rkey}: ${err}`); + } + if (!keep) continue; + } + + const now = Date.now(); + const uri = `at://${event.did}/${commit.collection}/${commit.rkey}`; + + buffer.push({ + uri, + did: event.did, + time_us: event.time_us, + collection: commit.collection, + operation: commit.operation as "create" | "update" | "delete", + rkey: commit.rkey, + cid: commit.operation === "delete" ? null : commit.cid, + record: commit.operation === "delete" ? null : JSON.stringify(commit.record), + indexed_at: now * 1000, + }); + + if (knownDids && !dependentCollections.has(commit.collection)) { + if (!knownDids.has(event.did)) { + knownDids.add(event.did); + opts.newlyKnownDids?.add(event.did); + } + } + } else if (event.kind === "identity") { + try { + await applyIdentityEvent(db, event.did, event.identity.handle); + } catch (err) { + log.warn(`Identity update failed for ${event.did}: ${err}`); + } + } + + if (buffer.length >= batchSize) { + await flush(); + } + } + } finally { + clearInterval(flushTimer); + signal?.removeEventListener("abort", onAbort); + await iterator.return?.({ value: undefined, done: true }); + await flush(); + } +} diff --git a/packages/contrail/src/core/realtime/durable-object.ts b/packages/contrail/src/core/realtime/durable-object.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/realtime/durable-object.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/realtime/in-memory.ts b/packages/contrail/src/core/realtime/in-memory.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/realtime/in-memory.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/realtime/index.ts b/packages/contrail/src/core/realtime/index.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/realtime/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/realtime/merge.ts b/packages/contrail/src/core/realtime/merge.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/realtime/merge.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/realtime/publishing-adapter.ts b/packages/contrail/src/core/realtime/publishing-adapter.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/realtime/publishing-adapter.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/realtime/query-filter.ts b/packages/contrail/src/core/realtime/query-filter.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/realtime/query-filter.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/realtime/resolve.ts b/packages/contrail/src/core/realtime/resolve.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/realtime/resolve.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/realtime/router.ts b/packages/contrail/src/core/realtime/router.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/realtime/router.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/realtime/sse.ts b/packages/contrail/src/core/realtime/sse.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/realtime/sse.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/realtime/ticket.ts b/packages/contrail/src/core/realtime/ticket.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/realtime/ticket.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/realtime/types.ts b/packages/contrail/src/core/realtime/types.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/realtime/types.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/realtime/websocket.ts b/packages/contrail/src/core/realtime/websocket.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/realtime/websocket.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/refresh.ts b/packages/contrail/src/core/refresh.ts index 551c495..2c8a343 100644 --- a/packages/contrail/src/core/refresh.ts +++ b/packages/contrail/src/core/refresh.ts @@ -1 +1,267 @@ -export * from "@atmo-dev/contrail-appview"; +import type {} from "@atcute/atproto"; +/** + * Fresh refresh: re-walk every known DID's PDS for every configured collection + * and reconcile against what's in our DB. Unlike `backfillPending`, this + * ignores the `backfills` state machine — it's a "check what we might have + * missed" pass, not a resumable bulk load. + * + * Two categories of delta are counted: + * - missing — the PDS has a record we don't + * - staleUpdates — we have the same URI but a different CID, *and* our + * copy's `indexed_at` is older than `ignoreWindowMs` + * + * The ignore window exists because Jetstream can run ~seconds behind the + * PDS; without the window, "in-sync but racy" writes would show up as + * misses every run. Records inside the window are still applied (they + * might be legit updates), just not counted toward stats. + * + * Typical uses: + * - dev: "I ran backfillAll on Monday, haven't touched it for a week, + * how much did jetstream miss?" + * - prod: "we had jetstream outage yesterday, what did we drop?" + */ +import { type Did, type Nsid } from "@atcute/lexicons"; +import { isDid, isNsid } from "@atcute/lexicons/syntax"; + +import type { Client } from "@atcute/client"; +import type { ContrailConfig, Database, IngestEvent } from "./types.js"; +import { applyEvents, lookupExistingRecords } from "./db/records.js"; +import { getClient } from "./client.js"; + +const PAGE_SIZE = 100; +const REQUEST_TIMEOUT_MS = 10_000; + +async function withTimeout<T>(fn: () => Promise<T>, ms: number): Promise<T> { + return Promise.race([ + fn(), + new Promise<never>((_, rej) => + setTimeout(() => rej(new Error(`timeout after ${ms}ms`)), ms) + ), + ]); +} + +export interface CollectionStats { + /** Record exists on PDS but was absent from our DB. */ + missing: number; + /** Record exists in our DB with a different CID than the PDS, and our + * copy was written before the ignore window. */ + staleUpdates: number; + /** Record is present and matches (same CID, or within ignore window). */ + inSync: number; +} + +export interface RefreshProgress { + usersComplete: number; + usersTotal: number; + usersFailed: number; + recordsScanned: number; +} + +export interface RefreshResult { + /** Per-NSID stats. */ + byCollection: Record<string, CollectionStats>; + /** Sum across every NSID. */ + total: CollectionStats; + usersScanned: number; + usersFailed: number; + /** Effective ignore window used for classification, in ms. */ + ignoreWindowMs: number; + /** Wall-clock runtime, in ms. */ + elapsedMs: number; +} + +export interface RefreshOptions { + /** How many DIDs to fan out against in parallel. Default: 50. */ + concurrency?: number; + /** Records whose local `indexed_at` is within this window of `now` are + * still upserted but excluded from `staleUpdates` counts — guards + * against jetstream being briefly behind the PDS. Default: 60_000 ms. */ + ignoreWindowMs?: number; + /** Override which NSIDs to walk. Default: every `config.collections[*].collection`. */ + nsids?: string[]; + /** Optional progress callback (fires per completed DID). */ + onProgress?: (p: RefreshProgress) => void; + /** Max attempts per listRecords request. Default: 3. */ + maxRetries?: number; + /** Per-request timeout in ms. Default: 10000. */ + requestTimeout?: number; +} + +function emptyStats(): CollectionStats { + return { missing: 0, staleUpdates: 0, inSync: 0 }; +} + +export async function refresh( + db: Database, + config: ContrailConfig, + options?: RefreshOptions +): Promise<RefreshResult> { + const concurrency = options?.concurrency ?? 50; + const ignoreWindowMs = options?.ignoreWindowMs ?? 60_000; + const requestTimeout = options?.requestTimeout ?? REQUEST_TIMEOUT_MS; + const maxRetries = options?.maxRetries ?? 3; + const startedAt = Date.now(); + + // Default to every configured collection NSID. Profiles are already + // included because `resolveConfig` adds them to `config.collections`. + const nsids = + options?.nsids ?? + Object.entries(config.collections).map(([short, c]) => c.collection ?? short); + + const byCollection: Record<string, CollectionStats> = {}; + for (const nsid of nsids) byCollection[nsid] = emptyStats(); + const total: CollectionStats = emptyStats(); + + // Known DIDs = every author we've ever written for. `backfills` is a + // superset (it also includes failed/pending users that we never got + // records from), which is actually what we want — if we tried and + // failed before, we might succeed now. + const didRows = await db + .prepare("SELECT DISTINCT did FROM backfills") + .all<{ did: string }>(); + const dids = (didRows.results ?? []) + .map((r) => r.did) + .filter((d) => isDid(d)); + + const usersTotal = dids.length; + let usersComplete = 0; + let usersFailed = 0; + let recordsScanned = 0; + + const ignoreBeforeUs = (Date.now() - ignoreWindowMs) * 1000; + + const processDid = async (did: string): Promise<void> => { + let client: Client; + try { + client = await withTimeout( + () => getClient(did as Did, db, config), + requestTimeout + ); + } catch { + usersFailed++; + return; + } + + for (const nsid of nsids) { + if (!isNsid(nsid)) continue; + let cursor: string | undefined; + while (true) { + let pageRecords: Array<{ uri: string; cid: string; value: unknown }>; + let nextCursor: string | undefined; + try { + // Retry listRecords: transient PDS failures are expected during refresh + let attempt = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const res = await withTimeout( + () => + client.get("com.atproto.repo.listRecords", { + params: { + repo: did as Did, + collection: nsid as Nsid, + limit: PAGE_SIZE, + cursor, + }, + }), + requestTimeout + ); + if (!res.ok) { + // 400s on a collection the user doesn't have are fine; stop + // paging this collection for this user. + pageRecords = []; + nextCursor = undefined; + break; + } + pageRecords = res.data.records; + nextCursor = res.data.cursor ?? undefined; + break; + } catch (err) { + if (attempt >= maxRetries) throw err; + attempt++; + await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); + } + } + } catch { + // Give up on this collection for this user; keep going. + break; + } + + if (pageRecords.length === 0) break; + + const now = Date.now(); + const events: IngestEvent[] = pageRecords.map((r) => ({ + uri: r.uri, + did, + collection: nsid, + rkey: r.uri.split("/").pop()!, + operation: "create" as const, + cid: r.cid, + record: JSON.stringify(r.value), + time_us: now * 1000, + indexed_at: now * 1000, + })); + + const existing = await lookupExistingRecords( + db, + events.map((e) => ({ uri: e.uri, collection: e.collection })), + false, + config + ); + + for (const ev of events) { + const ex = existing.get(ev.uri); + if (!ex) { + byCollection[nsid].missing++; + total.missing++; + } else if (ex.cid !== ev.cid) { + const inWindow = + ex.indexed_at !== null && ex.indexed_at >= ignoreBeforeUs; + if (inWindow) { + byCollection[nsid].inSync++; + total.inSync++; + } else { + byCollection[nsid].staleUpdates++; + total.staleUpdates++; + } + } else { + byCollection[nsid].inSync++; + total.inSync++; + } + } + + // Upsert everything — even records "inside the ignore window" + // might genuinely have a new CID; we just don't count them as a + // miss-signal. Skip feed fanout since this is a catch-up, not a + // user-visible write. + await applyEvents(db, events, config, { skipFeedFanout: true }); + recordsScanned += events.length; + + cursor = nextCursor; + if (!cursor) break; + } + } + + usersComplete++; + options?.onProgress?.({ + usersComplete, + usersTotal, + usersFailed, + recordsScanned, + }); + }; + + for (let i = 0; i < dids.length; i += concurrency) { + const batch = dids.slice(i, i + concurrency); + await Promise.allSettled(batch.map(processDid)); + } + + return { + byCollection, + total, + usersScanned: usersComplete, + usersFailed, + ignoreWindowMs, + elapsedMs: Date.now() - startedAt, + }; +} diff --git a/packages/contrail/src/core/router/admin.ts b/packages/contrail/src/core/router/admin.ts index 551c495..a3b754b 100644 --- a/packages/contrail/src/core/router/admin.ts +++ b/packages/contrail/src/core/router/admin.ts @@ -1 +1,44 @@ -export * from "@atmo-dev/contrail-appview"; +import type { Hono } from "hono"; +import type { ContrailConfig, Database } from "../types"; +import { getCollectionShortNames, recordsTableName, nsidForShortName } from "../types"; +import { getLastCursor } from "../db"; + +export function registerAdminRoutes( + app: Hono, + db: Database, + config: ContrailConfig +): void { + const ns = config.namespace; + + app.get(`/xrpc/${ns}.getCursor`, async (c) => { + const cursor = await getLastCursor(db); + if (cursor === null) return c.json({ cursor: null }); + + const dateMs = Math.floor(cursor / 1000); + return c.json({ + time_us: cursor, + date: new Date(dateMs).toISOString(), + seconds_ago: Math.floor((Date.now() - dateMs) / 1000), + }); + }); + + app.get(`/xrpc/${ns}.getOverview`, async (c) => { + const collections: { collection: string; records: number; unique_users: number }[] = []; + + for (const short of getCollectionShortNames(config)) { + const table = recordsTableName(short); + const nsid = nsidForShortName(config, short) ?? short; + const row = await db + .prepare(`SELECT COUNT(*) as records, COUNT(DISTINCT did) as unique_users FROM ${table}`) + .first<{ records: number; unique_users: number }>(); + if (row) { + collections.push({ collection: nsid, records: row.records, unique_users: row.unique_users }); + } + } + + return c.json({ + total_records: collections.reduce((sum, col) => sum + col.records, 0), + collections, + }); + }); +} diff --git a/packages/contrail/src/core/router/collection.ts b/packages/contrail/src/core/router/collection.ts index 551c495..b6724f1 100644 --- a/packages/contrail/src/core/router/collection.ts +++ b/packages/contrail/src/core/router/collection.ts @@ -1 +1,517 @@ -export * from "@atmo-dev/contrail-appview"; +import type { Context, Hono } from "hono"; +import type { + ContrailConfig, + Database, + QueryableField, + RecordRow, + RecordSource, + RelationConfig, + ResolvedContrailConfig, +} from "../types"; +import { + countColumnName, + getCollectionMethods, + getCollectionShortNames, + groupedCountColumnName, + nsidForShortName, + recordsTableName, +} from "../types"; +import { queryRecords } from "../db"; +import type { SortOption } from "../db/records"; +import { backfillUser } from "../backfill"; +import { + parseHydrateParams, + resolveHydrates, + resolveReferences, +} from "./hydrate"; +import { collectDids, resolveProfiles } from "./profiles"; +import { resolveActor } from "../identity"; +import type { FormattedRecord } from "./helpers"; +import { fieldToParam, formatRecord, parseIntParam } from "./helpers"; +import { hydrateLabels } from "../labels/hydrate"; +import { selectAcceptedLabelers } from "../labels/select"; +import { parseResourceUri } from "@atcute/lexicons/syntax"; + +export async function runPipeline( + db: Database, + config: ContrailConfig, + collection: string, + params: URLSearchParams, + source?: RecordSource, + headers?: Headers, +): Promise<{ + records: FormattedRecord[]; + cursor?: string; + profiles?: any[]; + labelersApplied?: string[]; +}> { + const colConfig = config.collections[collection]; + if (!colConfig) throw new Error(`Unknown collection: ${collection}`); + + const relations = colConfig.relations ?? {}; + const references = colConfig.references ?? {}; + const queryableFields: Record<string, QueryableField> = + (config as ResolvedContrailConfig)._resolved?.queryable[collection] ?? + colConfig.queryable ?? + {}; + + const limit = parseIntParam(params.get("limit"), 50); + const cursor = params.get("cursor") || undefined; + const actor = params.get("actor") || params.get("did") || undefined; + const wantProfiles = params.get("profiles") === "true"; + + let did: string | undefined; + if (actor) { + const resolved = await resolveActor(db, actor, config); + if (!resolved) throw new Error("Could not resolve actor"); + did = resolved; + const nsid = nsidForShortName(config, collection) ?? collection; + await backfillUser(db, did, nsid, Date.now() + 3_000, config, { + maxRetries: 0, + requestTimeout: 3_000, + }); + } + + const filters: Record<string, string> = {}; + const rangeFilters: Record<string, { min?: string; max?: string }> = {}; + for (const [field, fieldConfig] of Object.entries(queryableFields)) { + const param = fieldToParam(field); + if (fieldConfig.type === "range") { + const min = params.get(`${param}Min`); + const max = params.get(`${param}Max`); + if (min || max) { + rangeFilters[field] = {}; + if (min) rangeFilters[field].min = min; + if (max) rangeFilters[field].max = max; + } + } else { + const value = params.get(param); + if (value) filters[field] = value; + } + } + + const countFilters: Record<string, number> = {}; + const relMap = + (config as ResolvedContrailConfig)._resolved?.relations[collection] ?? {}; + for (const [relName, rel] of Object.entries(relations)) { + const totalMin = parseIntParam(params.get(`${relName}CountMin`)); + if (totalMin != null) countFilters[rel.collection] = totalMin; + const mapping = relMap[relName]; + if (mapping) { + const capitalize = (value: string): string => + value.charAt(0).toUpperCase() + value.slice(1); + for (const [shortName, fullToken] of Object.entries(mapping.groups)) { + const value = parseIntParam( + params.get(`${relName}${capitalize(shortName)}CountMin`), + ); + if (value != null) countFilters[fullToken] = value; + } + } + } + + let sort: SortOption | undefined; + const sortParam = params.get("sort"); + if (sortParam) { + const orderParam = params.get("order"); + const fieldEntry = Object.entries(queryableFields).find( + ([field]) => fieldToParam(field) === sortParam, + ); + if (fieldEntry) { + const defaultDirection = + fieldEntry[1].type === "range" ? "desc" : "asc"; + const direction = + orderParam === "asc" || orderParam === "desc" + ? orderParam + : defaultDirection; + sort = { recordField: fieldEntry[0], direction }; + } else { + const direction = orderParam === "asc" ? "asc" : "desc"; + const capitalize = (value: string): string => + value.charAt(0).toUpperCase() + value.slice(1); + for (const [relName, rel] of Object.entries(relations)) { + if (sortParam === `${relName}Count`) { + sort = { countType: rel.collection, direction }; + break; + } + const mapping = relMap[relName]; + if (!mapping) continue; + for (const [shortName, fullToken] of Object.entries(mapping.groups)) { + if (sortParam === `${relName}${capitalize(shortName)}Count`) { + sort = { countType: fullToken, direction }; + break; + } + } + if (sort) break; + } + } + } + + const result = await queryRecords(db, config, { + collection, + did, + limit, + cursor, + filters, + rangeFilters, + countFilters, + sort, + search: params.get("search") || undefined, + source, + }); + + const rows = result.records; + const hydrateRequested = parseHydrateParams(params, relations, references); + const hydrates = await resolveHydrates( + db, + relations, + hydrateRequested.relations, + rows, + config, + ); + const refs = await resolveReferences( + db, + references, + hydrateRequested.references, + rows, + config, + ); + + const formattedRecords: FormattedRecord[] = rows.map((row) => { + const formatted = formatRecord(row); + flattenCounts(formatted, row.counts, relations); + const hydratedRelations = hydrates[row.uri]; + if (hydratedRelations) { + for (const [name, groups] of Object.entries(hydratedRelations)) { + formatted[name] = groups; + } + } + const hydratedReferences = refs[row.uri]; + if (hydratedReferences) { + for (const [name, record] of Object.entries(hydratedReferences)) { + formatted[name] = record; + } + } + return formatted; + }); + const allDids = collectDids(rows, hydrates); + const profileMap = wantProfiles + ? await resolveProfiles(db, config, allDids) + : undefined; + + let labelersApplied: string[] | undefined; + if (config.labels) { + const selection = selectAcceptedLabelers( + headers?.get("atproto-accept-labelers") ?? null, + params.get("labelers"), + config.labels, + ); + if (selection.accepted.length > 0) { + const subjects = [...formattedRecords.map((record) => record.uri), ...allDids]; + const cidByUri = new Map<string, string | null>(); + for (const record of formattedRecords) cidByUri.set(record.uri, record.cid); + const labelsByUri = await hydrateLabels( + db, + subjects, + selection.accepted, + cidByUri, + ); + for (const record of formattedRecords) { + const labels = labelsByUri[record.uri]; + if (labels && labels.length > 0) record.labels = labels; + } + if (profileMap) { + for (const entries of Object.values(profileMap)) { + for (const entry of entries) { + const labels = labelsByUri[entry.did]; + if (labels && labels.length > 0) entry.labels = labels; + } + } + } + labelersApplied = selection.accepted; + } + } + + return { + records: formattedRecords, + cursor: result.cursor, + ...(profileMap ? { profiles: Object.values(profileMap).flat() } : {}), + ...(labelersApplied ? { labelersApplied } : {}), + }; +} + +function jsonWithLabelers( + c: Context, + result: { labelersApplied?: string[] } & Record<string, unknown>, +) { + const { labelersApplied, ...body } = result; + if (labelersApplied && labelersApplied.length > 0) { + c.header("atproto-content-labelers", labelersApplied.join(",")); + } + return c.json(body); +} + +export function registerCollectionRoutes( + app: Hono, + db: Database, + config: ContrailConfig, +): void { + const ns = config.namespace; + + for (const collection of getCollectionShortNames(config)) { + const colConfig = config.collections[collection]; + if (!colConfig) continue; + const methods = getCollectionMethods(colConfig); + + if (methods.includes("listRecords")) { + app.get(`/xrpc/${ns}.${collection}.listRecords`, async (c) => { + const params = new URL(c.req.url).searchParams; + try { + const result = await runPipeline( + db, + config, + collection, + params, + undefined, + c.req.raw.headers, + ); + return jsonWithLabelers(c, result); + } catch (error: any) { + if (error.message === "Could not resolve actor") { + return c.json({ error: error.message }, 400); + } + throw error; + } + }); + } + + if (methods.includes("getRecord")) { + app.get(`/xrpc/${ns}.${collection}.getRecord`, async (c) => { + const rawUri = c.req.query("uri"); + if (!rawUri) return c.json({ error: "uri parameter required" }, 400); + + const parsed = parseResourceUri(rawUri); + if (!parsed.ok || parsed.value.rkey === undefined) { + return c.json( + { + error: "InvalidRequest", + message: "uri must be at://<actor>/<collection>/<rkey>", + }, + 400, + ); + } + const did = await resolveActor(db, parsed.value.repo, config); + if (!did) return c.json({ error: "Could not resolve actor" }, 400); + const uri = `at://${did}/${parsed.value.collection}/${parsed.value.rkey}`; + + const relations = colConfig.relations ?? {}; + const references = colConfig.references ?? {}; + const relMap = + (config as ResolvedContrailConfig)._resolved?.relations[collection] ?? + {}; + const countCols = getRelationCountColumns(relations, relMap); + const selectCols = [ + "uri", + "did", + "rkey", + "cid", + "record", + "time_us", + "indexed_at", + ...countCols.map(({ column }) => column), + ].join(", "); + const row = await db + .prepare( + `SELECT ${selectCols} FROM ${recordsTableName(collection)} WHERE uri = ?`, + ) + .bind(uri) + .first<any>(); + + if (!row) return c.json({ error: "Record not found" }, 404); + + const nsid = nsidForShortName(config, collection) ?? collection; + const formatted = formatRecord({ ...row, collection: nsid }); + flattenCounts(formatted, extractCounts(row, relations), relations); + + const params = new URL(c.req.url).searchParams; + const hydrateRequested = parseHydrateParams( + params, + relations, + references, + ); + const hydrates = await resolveHydrates( + db, + relations, + hydrateRequested.relations, + [row], + config, + ); + const refs = await resolveReferences( + db, + references, + hydrateRequested.references, + [row], + config, + ); + const hydratedRelations = hydrates[row.uri]; + if (hydratedRelations) { + for (const [name, groups] of Object.entries(hydratedRelations)) { + formatted[name] = groups; + } + } + const hydratedReferences = refs[row.uri]; + if (hydratedReferences) { + for (const [name, record] of Object.entries(hydratedReferences)) { + formatted[name] = record; + } + } + const allDids = collectDids([row], hydrates); + const profileMap = + params.get("profiles") === "true" + ? await resolveProfiles(db, config, allDids) + : undefined; + + let labelersApplied: string[] | undefined; + if (config.labels) { + const selection = selectAcceptedLabelers( + c.req.raw.headers.get("atproto-accept-labelers"), + params.get("labelers"), + config.labels, + ); + if (selection.accepted.length > 0) { + const labelsByUri = await hydrateLabels( + db, + [row.uri, ...allDids], + selection.accepted, + new Map<string, string | null>([[row.uri, row.cid]]), + ); + const labels = labelsByUri[row.uri]; + if (labels && labels.length > 0) formatted.labels = labels; + if (profileMap) { + for (const entries of Object.values(profileMap)) { + for (const entry of entries) { + const profileLabels = labelsByUri[entry.did]; + if (profileLabels && profileLabels.length > 0) { + entry.labels = profileLabels; + } + } + } + } + labelersApplied = selection.accepted; + } + } + if (labelersApplied) { + c.header("atproto-content-labelers", labelersApplied.join(",")); + } + + return c.json({ + ...formatted, + ...(profileMap ? { profiles: Object.values(profileMap).flat() } : {}), + }); + }); + } + + for (const [queryName, handler] of Object.entries(colConfig.queries ?? {})) { + app.get(`/xrpc/${ns}.${collection}.${queryName}`, async (c) => { + const params = new URL(c.req.url).searchParams; + return handler(db, params, config); + }); + } + + for (const [queryName, handler] of Object.entries( + colConfig.pipelineQueries ?? {}, + )) { + app.get(`/xrpc/${ns}.${collection}.${queryName}`, async (c) => { + const params = new URL(c.req.url).searchParams; + try { + const source = await handler(db, params, config); + const result = await runPipeline( + db, + config, + collection, + params, + source, + c.req.raw.headers, + ); + return jsonWithLabelers(c, result); + } catch (error: any) { + if (error.message === "Could not resolve actor") { + return c.json({ error: error.message }, 400); + } + throw error; + } + }); + } + } +} + +function getRelationCountColumns( + relations: Record<string, RelationConfig>, + relMap: Record<string, any>, +): { column: string }[] { + const columns: { column: string }[] = []; + for (const [relName, relation] of Object.entries(relations)) { + if (relation.count === false) continue; + columns.push({ column: countColumnName(relation.collection) }); + const mapping = relMap[relName]; + if (mapping?.groups) { + for (const groupKey of Object.keys(mapping.groups)) { + columns.push({ + column: groupedCountColumnName(relation.collection, groupKey), + }); + } + } + } + return columns; +} + +function extractCounts( + row: any, + relations: Record<string, RelationConfig>, +): Record<string, number> | undefined { + const counts: Record<string, number> = {}; + for (const relation of Object.values(relations)) { + if (relation.count === false) continue; + const totalColumn = countColumnName(relation.collection); + if (row[totalColumn] != null && row[totalColumn] !== 0) { + counts[relation.collection] = row[totalColumn]; + } + if (relation.groups) { + for (const [groupKey, fullToken] of Object.entries(relation.groups)) { + const column = groupedCountColumnName(relation.collection, groupKey); + if (row[column] != null && row[column] !== 0) { + counts[fullToken] = row[column]; + } + } + } + } + return Object.keys(counts).length > 0 ? counts : undefined; +} + +function flattenCounts( + formatted: FormattedRecord, + counts: Record<string, number> | undefined, + relations: Record<string, RelationConfig>, +): void { + if (!counts) return; + const capitalize = (value: string): string => + value.charAt(0).toUpperCase() + value.slice(1); + const collectionToRelName: Record<string, string> = {}; + const tokenToField: Record<string, string> = {}; + + for (const [relName, relation] of Object.entries(relations)) { + collectionToRelName[relation.collection] = relName; + if (relation.groups) { + for (const [shortName, fullToken] of Object.entries(relation.groups)) { + tokenToField[fullToken] = `${relName}${capitalize(shortName)}Count`; + } + } + } + + for (const [type, count] of Object.entries(counts)) { + if (collectionToRelName[type]) { + formatted[`${collectionToRelName[type]}Count`] = count; + } else if (tokenToField[type]) { + formatted[tokenToField[type]] = count; + } + } +} diff --git a/packages/contrail/src/core/router/feed.ts b/packages/contrail/src/core/router/feed.ts index 551c495..8ef149e 100644 --- a/packages/contrail/src/core/router/feed.ts +++ b/packages/contrail/src/core/router/feed.ts @@ -1 +1,299 @@ -export * from "@atmo-dev/contrail-appview"; +import type { Context, Hono } from "hono"; +import type { + ContrailConfig, + Database, + FeedConfig, + FeedTargetConfig, +} from "../types"; +import { getDialect } from "../dialect"; +import { + DEFAULT_FOLLOW_SHORT, + feedTargetMaxItems, + normalizeFeedTarget, + recordsTableName, + shortNameForNsid, +} from "../types"; +import { resolveActor } from "../identity"; +import { backfillUser } from "../backfill"; +import { runPipeline } from "./collection"; + +const BACKFILL_TIMEOUT_MS = 30_000; +const BACKFILL_REQUEST_TIMEOUT_MS = 10_000; +const BACKFILL_MAX_RETRIES = 3; +/** Re-arm a stuck in-progress row after this long (covers process crashes mid-backfill). */ +const BACKFILL_STALE_MS = 5 * 60 * 1000; + +interface FeedBackfillStatus { + completed: number; + retries: number; + last_error: string | null; + started_at: number | null; +} + +/** Schedule async work, preferring waitUntil on Cloudflare Workers so the + * runtime keeps the request alive until the promise settles. Falls back to + * fire-and-forget with a logged catch. */ +function scheduleBackground( + c: Context, + config: ContrailConfig, + task: () => Promise<unknown> +): void { + const log = config.logger ?? console; + const promise = task().catch((err) => + log.error(`[feed] background task failed: ${err}`) + ); + try { + c.executionCtx.waitUntil(promise); + } catch { + // No executionCtx (Node/Bun); promise runs detached. + } +} + +/** Run the bootstrap copy + per-target prune. Returns rows inserted. */ +async function bootstrapFeedItems( + db: Database, + config: ContrailConfig, + actor: string, + feedConfig: FeedConfig +): Promise<number> { + const followShort = feedConfig.follow ?? DEFAULT_FOLLOW_SHORT; + const followTable = recordsTableName(followShort); + const targets = feedConfig.targets.map(normalizeFeedTarget); + let totalInserted = 0; + + for (const target of targets) { + const targetTable = recordsTableName(target.collection); + const targetCfg = config.collections[target.collection]; + if (!targetCfg) continue; + const cap = feedTargetMaxItems(feedConfig, target); + + const insert = await db + .prepare( + getDialect(db).insertOrIgnore( + `INSERT INTO feed_items (actor, uri, collection, time_us) + SELECT ?, r.uri, ?, r.time_us + FROM ${targetTable} r + WHERE r.did IN ( + SELECT ${getDialect(db).jsonExtract("f.record", "subject")} + FROM ${followTable} f + WHERE f.did = ? + ) + ORDER BY r.time_us DESC + LIMIT ${cap}` + ) + ) + .bind(actor, targetCfg.collection, actor) + .run(); + totalInserted += (insert as { changes?: number })?.changes ?? 0; + + // Per-target prune so high-volume targets don't squeeze out lower-volume ones. + await db + .prepare( + `DELETE FROM feed_items WHERE actor = ? AND collection = ? AND uri NOT IN ( + SELECT uri FROM feed_items WHERE actor = ? AND collection = ? + ORDER BY time_us DESC LIMIT ? + )` + ) + .bind(actor, targetCfg.collection, actor, targetCfg.collection, cap) + .run(); + } + + return totalInserted; +} + +/** Run a full backfill cycle: walk follow records → bootstrap feed_items → mark complete. + * Updates feed_backfills row with retries/last_error on failure. */ +async function runFeedBackfill( + db: Database, + config: ContrailConfig, + actor: string, + feedName: string, + feedConfig: FeedConfig +): Promise<void> { + const followShort = feedConfig.follow ?? DEFAULT_FOLLOW_SHORT; + const followCfg = config.collections[followShort]; + if (!followCfg) return; + + try { + const inserted = await backfillUser( + db, + actor, + followCfg.collection ?? followShort, + Date.now() + BACKFILL_TIMEOUT_MS, + config, + { + skipReplayDetection: true, + maxRetries: BACKFILL_MAX_RETRIES, + requestTimeout: BACKFILL_REQUEST_TIMEOUT_MS, + } + ); + + // Bootstrap from whatever follow records we now have (may be from this + // backfill, from earlier live ingest, or both). + await bootstrapFeedItems(db, config, actor, feedConfig); + + // Only mark complete if the underlying follow backfill actually finished + // (backfills.completed = 1). Avoids the old bug where timeouts/empty + // walks would lock the user out of any retry. + const followStatus = await db + .prepare( + "SELECT completed FROM backfills WHERE did = ? AND collection = ?" + ) + .bind(actor, followCfg.collection) + .first<{ completed: number }>(); + + if (followStatus?.completed) { + await db + .prepare( + "UPDATE feed_backfills SET completed = 1, last_error = NULL WHERE actor = ? AND feed = ?" + ) + .bind(actor, feedName) + .run(); + } else { + // Walk didn't complete (timeout/error), but record the partial progress + // so the next request retries. + await db + .prepare( + "UPDATE feed_backfills SET retries = retries + 1, started_at = NULL, last_error = ? WHERE actor = ? AND feed = ?" + ) + .bind( + `follow backfill incomplete (inserted=${inserted})`, + actor, + feedName + ) + .run(); + } + } catch (err) { + await db + .prepare( + "UPDATE feed_backfills SET retries = retries + 1, started_at = NULL, last_error = ? WHERE actor = ? AND feed = ?" + ) + .bind(String(err), actor, feedName) + .run(); + } +} + +/** Decide whether to (re)kick off a background backfill, and do so if needed. + * Always returns immediately so the request path stays cheap. */ +async function maybeBackfillFeed( + c: Context, + db: Database, + config: ContrailConfig, + actor: string, + feedName: string, + feedConfig: FeedConfig +): Promise<void> { + const status = await db + .prepare( + "SELECT completed, retries, last_error, started_at FROM feed_backfills WHERE actor = ? AND feed = ?" + ) + .bind(actor, feedName) + .first<FeedBackfillStatus>(); + + if (status?.completed) return; + + const now = Date.now(); + + // Skip if a backfill is already in flight (started_at recently set) — avoids + // duplicate work from concurrent requests for the same actor. + if (status?.started_at && now - status.started_at < BACKFILL_STALE_MS) return; + + // Either no row, or stale started_at. Claim it. + if (!status) { + await db + .prepare( + "INSERT INTO feed_backfills (actor, feed, completed, started_at) VALUES (?, ?, 0, ?) ON CONFLICT DO NOTHING" + ) + .bind(actor, feedName, now) + .run(); + } else { + await db + .prepare( + "UPDATE feed_backfills SET started_at = ? WHERE actor = ? AND feed = ?" + ) + .bind(now, actor, feedName) + .run(); + } + + scheduleBackground(c, config, () => + runFeedBackfill(db, config, actor, feedName, feedConfig) + ); +} + +export function registerFeedRoutes( + app: Hono, + db: Database, + config: ContrailConfig +): void { + if (!config.feeds) return; + + const ns = config.namespace; + + app.get(`/xrpc/${ns}.getFeed`, async (c) => { + const params = new URL(c.req.url).searchParams; + const feedName = params.get("feed"); + const actor = params.get("actor"); + + if (!feedName || !actor) { + return c.json({ error: "feed and actor parameters required" }, 400); + } + + const feedConfig = config.feeds![feedName]; + if (!feedConfig) { + return c.json({ error: "Unknown feed" }, 404); + } + + const did = await resolveActor(db, actor, config); + if (!did) return c.json({ error: "Could not resolve actor" }, 400); + + await maybeBackfillFeed(c, db, config, did, feedName, feedConfig); + + const targets = feedConfig.targets.map(normalizeFeedTarget); + if (targets.length === 0) { + return c.json({ error: "Feed has no targets configured" }, 500); + } + // Wire-level `collection` is an NSID (matches the generated lex enum and + // what's stored in feed_items.collection). Internally runPipeline expects + // the short name, so translate. + const requestedRaw = params.get("collection"); + let requestedShort: string; + if (!requestedRaw) { + requestedShort = targets[0].collection; + } else if (targets.some((t) => t.collection === requestedRaw)) { + // Tolerate callers passing the short name directly. + requestedShort = requestedRaw; + } else { + const asShort = shortNameForNsid(config, requestedRaw); + if (asShort && targets.some((t) => t.collection === asShort)) { + requestedShort = asShort; + } else { + return c.json({ error: "Collection not in feed targets" }, 400); + } + } + + // Strip feed-specific params so runPipeline doesn't misinterpret them + // (e.g. "actor" in feeds means "whose feed", not "filter by record creator") + const pipelineParams = new URLSearchParams(params); + pipelineParams.delete("feed"); + pipelineParams.delete("actor"); + pipelineParams.delete("collection"); + + const source = { + joins: "JOIN feed_items f ON r.uri = f.uri", + conditions: ["f.actor = ?"], + params: [did], + }; + + try { + const result = await runPipeline(db, config, requestedShort, pipelineParams, source); + return c.json(result); + } catch (e: any) { + if (e.message === "Could not resolve actor") { + return c.json({ error: e.message }, 400); + } + throw e; + } + }); +} + +export type { FeedTargetConfig }; diff --git a/packages/contrail/src/core/router/helpers.ts b/packages/contrail/src/core/router/helpers.ts index 551c495..1ccd3bd 100644 --- a/packages/contrail/src/core/router/helpers.ts +++ b/packages/contrail/src/core/router/helpers.ts @@ -1 +1,66 @@ -export * from "@atmo-dev/contrail-appview"; +import type { Database, RecordRow } from "../types"; + +export interface FormattedRecord { + uri: string; + cid: string | null; + value: unknown; + did: string; + collection: string; + rkey: string; + time_us: number; + [key: string]: unknown; +} + +export function formatRecord(row: RecordRow): FormattedRecord { + let value: unknown = null; + if (row.record) { + try { + value = JSON.parse(row.record); + } catch { + value = row.record; + } + } + return { + uri: row.uri, + cid: row.cid, + value, + did: row.did, + collection: row.collection, + rkey: row.rkey, + time_us: row.time_us, + }; +} + +export function parseIntParam( + value: string | null | undefined, + defaultValue?: number +): number | undefined { + if (!value) return defaultValue; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? defaultValue : parsed; +} + +export function fieldToParam(field: string): string { + return field.replace(/\.(\w)/g, (_, c) => c.toUpperCase()); +} + +const BATCH_SIZE = 50; + +export async function batchedInQuery<T>( + db: Database, + sql: string, + prefixBindings: (string | number)[], + inValues: string[] +): Promise<T[]> { + const results: T[] = []; + for (let i = 0; i < inValues.length; i += BATCH_SIZE) { + const chunk = inValues.slice(i, i + BATCH_SIZE); + const query = sql.replace("__IN__", chunk.map(() => "?").join(",")); + const rows = await db + .prepare(query) + .bind(...prefixBindings, ...chunk) + .all<T>(); + results.push(...(rows.results ?? [])); + } + return results; +} diff --git a/packages/contrail/src/core/router/hydrate.ts b/packages/contrail/src/core/router/hydrate.ts index 551c495..7763a5d 100644 --- a/packages/contrail/src/core/router/hydrate.ts +++ b/packages/contrail/src/core/router/hydrate.ts @@ -1 +1,184 @@ -export * from "@atmo-dev/contrail-appview"; +import type { + ContrailConfig, + Database, + RecordRow, + ReferenceConfig, + RelationConfig, +} from "../types"; +import { getDialect } from "../dialect"; +import { + getNestedValue, + getRelationField, + nsidForShortName, + recordsTableName, +} from "../types"; +import { batchedInQuery, formatRecord } from "./helpers"; + +export function parseHydrateParams( + params: URLSearchParams, + relations: Record<string, RelationConfig>, + references: Record<string, ReferenceConfig>, +): { relations: Record<string, number>; references: Set<string> } { + const relationHydrates: Record<string, number> = {}; + const referenceHydrates = new Set<string>(); + const capitalize = (value: string): string => + value.charAt(0).toUpperCase() + value.slice(1); + + for (const relationName of Object.keys(relations)) { + const value = params.get(`hydrate${capitalize(relationName)}`); + if (!value) continue; + const limit = Number.parseInt(value, 10); + if (!Number.isNaN(limit) && limit > 0) { + relationHydrates[relationName] = Math.min(limit, 50); + } + } + + for (const referenceName of Object.keys(references)) { + const value = params.get(`hydrate${capitalize(referenceName)}`); + if (value === "true" || value === "1") { + referenceHydrates.add(referenceName); + } + } + + return { relations: relationHydrates, references: referenceHydrates }; +} + +export type HydrateResult = Record< + string, + Record<string, any[] | Record<string, any[]>> +>; + +export async function resolveHydrates( + db: Database, + relations: Record<string, RelationConfig>, + requested: Record<string, number>, + records: RecordRow[], + config?: ContrailConfig, +): Promise<HydrateResult> { + if (Object.keys(requested).length === 0 || records.length === 0) return {}; + + const grouped: Record<string, Record<string, Record<string, any[]>>> = {}; + for (const [relationName, hydrateLimit] of Object.entries(requested)) { + const relation = relations[relationName]; + const field = getRelationField(relation); + const matchMode = relation.match ?? "uri"; + const matchValues = + matchMode === "did" + ? [...new Set(records.map((record) => record.did))] + : records.map((record) => record.uri); + if (matchValues.length === 0) continue; + + const groupCount = relation.groupBy ? 10 : 1; + const maxRows = matchValues.length * hydrateLimit * groupCount; + const relatedRows = await batchedInQuery< + Omit<RecordRow, "collection"> + >( + db, + `SELECT uri, did, rkey, record, time_us + FROM ${recordsTableName(relation.collection)} + WHERE ${getDialect(db).jsonExtract("record", field)} IN (__IN__) + ORDER BY time_us DESC + LIMIT ${maxRows}`, + [], + matchValues, + ); + + for (const row of relatedRows) { + const value = row.record ? JSON.parse(row.record) : null; + const matchedValue = getNestedValue(value, field); + if (!matchedValue) continue; + const parentUris = + matchMode === "did" + ? records + .filter((record) => record.did === matchedValue) + .map((record) => record.uri) + : [matchedValue]; + const groupValue = relation.groupBy + ? String(getNestedValue(value, relation.groupBy) ?? "other") + : "_flat"; + + for (const parentUri of parentUris) { + grouped[parentUri] ??= {}; + grouped[parentUri][relationName] ??= {}; + grouped[parentUri][relationName][groupValue] ??= []; + const group = grouped[parentUri][relationName][groupValue]; + if (group.length >= hydrateLimit) continue; + group.push( + formatRecord({ + ...(row as any), + collection: config + ? nsidForShortName(config, relation.collection) ?? + relation.collection + : relation.collection, + } as RecordRow), + ); + } + } + } + + const result: HydrateResult = {}; + for (const [uri, hydratedRelations] of Object.entries(grouped)) { + result[uri] = {}; + for (const [relationName, groups] of Object.entries(hydratedRelations)) { + result[uri][relationName] = relations[relationName].groupBy + ? groups + : (groups._flat ?? []); + } + } + return result; +} + +export type ReferenceResult = Record<string, Record<string, any>>; + +export async function resolveReferences( + db: Database, + references: Record<string, ReferenceConfig>, + requested: Set<string>, + records: RecordRow[], + config?: ContrailConfig, +): Promise<ReferenceResult> { + if (requested.size === 0 || records.length === 0) return {}; + + const result: ReferenceResult = {}; + for (const referenceName of requested) { + const reference = references[referenceName]; + if (!reference) continue; + + const targetMap = new Map<string, string[]>(); + for (const record of records) { + const value = record.record ? JSON.parse(record.record) : null; + const targetUri = value + ? getNestedValue(value, reference.field) + : null; + if (!targetUri) continue; + const parentUris = targetMap.get(targetUri) ?? []; + parentUris.push(record.uri); + targetMap.set(targetUri, parentUris); + } + + const targetUris = [...targetMap.keys()]; + if (targetUris.length === 0) continue; + const rows = await batchedInQuery<Omit<RecordRow, "collection">>( + db, + `SELECT uri, did, rkey, record, time_us + FROM ${recordsTableName(reference.collection)} + WHERE uri IN (__IN__)`, + [], + targetUris, + ); + + const referenceNsid = config + ? nsidForShortName(config, reference.collection) ?? reference.collection + : reference.collection; + for (const row of rows) { + for (const parentUri of targetMap.get(row.uri) ?? []) { + result[parentUri] ??= {}; + result[parentUri][referenceName] = formatRecord({ + ...(row as any), + collection: referenceNsid, + } as RecordRow); + } + } + } + return result; +} diff --git a/packages/contrail/src/core/router/index.ts b/packages/contrail/src/core/router/index.ts index 551c495..05e2874 100644 --- a/packages/contrail/src/core/router/index.ts +++ b/packages/contrail/src/core/router/index.ts @@ -1 +1,88 @@ -export * from "@atmo-dev/contrail-appview"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import type { ContrailConfig, Database } from "../types"; +import { normalizeProfileConfig } from "../types"; +import { backfillUser } from "../backfill"; +import { hydrateLabels } from "../labels/hydrate"; +import { selectAcceptedLabelers } from "../labels/select"; +import { resolveActor } from "../identity"; +import { registerAdminRoutes } from "./admin"; +import { registerCollectionRoutes } from "./collection"; +import { registerFeedRoutes } from "./feed"; +import { registerNotifyRoute } from "./notify"; +import { resolveProfiles } from "./profiles"; + +export interface CreateAppOptions { + /** Lexicon JSON documents to expose from the deployment. */ + lexicons?: object[]; +} + +export function createApp( + db: Database, + config: ContrailConfig, + options: CreateAppOptions = {}, +): Hono { + const app = new Hono(); + app.use("*", cors()); + + app.get("/", (c) => c.json({ status: "ok" })); + app.get("/health", (c) => c.json({ status: "ok" })); + app.get("/xrpc/_health", (c) => c.json({ status: "ok" })); + + const ns = config.namespace; + if (options.lexicons && options.lexicons.length > 0) { + const lexicons = options.lexicons; + app.get(`/xrpc/${ns}.lexicons`, (c) => c.json({ lexicons })); + } + + app.get(`/xrpc/${ns}.getProfile`, async (c) => { + const actor = c.req.query("actor"); + if (!actor) return c.json({ error: "actor parameter required" }, 400); + + const did = await resolveActor(db, actor, config); + if (!did) return c.json({ error: "Could not resolve actor" }, 400); + + for (const profile of (config.profiles ?? []).map(normalizeProfileConfig)) { + await backfillUser( + db, + did, + profile.collection, + Date.now() + 3_000, + config, + { maxRetries: 0, requestTimeout: 3_000 }, + ); + } + + const profileMap = await resolveProfiles(db, config, [did]); + const profiles = profileMap[did]; + if (!profiles || profiles.length === 0) { + return c.json({ error: "Profile not found" }, 404); + } + + if (config.labels) { + const params = new URL(c.req.url).searchParams; + const selection = selectAcceptedLabelers( + c.req.raw.headers.get("atproto-accept-labelers"), + params.get("labelers"), + config.labels, + ); + if (selection.accepted.length > 0) { + const labelsByUri = await hydrateLabels(db, [did], selection.accepted); + const labels = labelsByUri[did]; + if (labels && labels.length > 0) { + for (const profile of profiles) profile.labels = labels; + } + c.header("atproto-content-labelers", selection.accepted.join(",")); + } + } + + return c.json({ profiles }); + }); + + registerAdminRoutes(app, db, config); + registerCollectionRoutes(app, db, config); + registerFeedRoutes(app, db, config); + registerNotifyRoute(app, db, config); + + return app; +} diff --git a/packages/contrail/src/core/router/notify.ts b/packages/contrail/src/core/router/notify.ts index 551c495..f6c51c3 100644 --- a/packages/contrail/src/core/router/notify.ts +++ b/packages/contrail/src/core/router/notify.ts @@ -1 +1,197 @@ -export * from "@atmo-dev/contrail-appview"; +import type { Hono } from "hono"; +import type { Database, ContrailConfig, IngestEvent } from "../types"; +import { shortNameForNsid, getFeedMutatingNsids } from "../types"; +import { applyEvents, lookupExistingRecords } from "../db/records"; +import { runGatedFeedPrune } from "../jetstream"; +import { getPDS } from "../client"; +import type { Did } from "@atcute/lexicons"; +import { parseCanonicalResourceUri } from "@atcute/lexicons/syntax"; + +/** Parse a canonical (DID-authority) record AT-URI into its components, or null + * if it isn't a valid full record URI. Backed by atcute's validator, which + * also enforces the DID / NSID / record-key character classes. */ +export function parseAtUri(uri: string): { did: string; collection: string; rkey: string } | null { + const parsed = parseCanonicalResourceUri(uri); + if (!parsed.ok) return null; + const { repo, collection, rkey } = parsed.value; + return { did: repo, collection, rkey }; +} + +/** + * Fetch a single record from the user's PDS. + * Returns the record + cid on success, null if not found. + */ +async function fetchRecordFromPDS( + pds: string, + did: string, + collection: string, + rkey: string +): Promise<{ value: unknown; cid: string } | null> { + const url = new URL(`/xrpc/com.atproto.repo.getRecord`, pds); + url.searchParams.set("repo", did); + url.searchParams.set("collection", collection); + url.searchParams.set("rkey", rkey); + + const res = await fetch(url.toString()); + if (!res.ok) return null; + + const data = (await res.json()) as { value?: unknown; cid?: string }; + if (!data.value || !data.cid) return null; + return { value: data.value, cid: data.cid }; +} + +export interface NotifyResult { + indexed: number; + deleted: number; + errors?: string[]; +} + +/** + * Process notify URIs: fetch from PDS, detect changes, apply events. + * Shared by both the Hono route and the Contrail.notify() method. + */ +export async function processNotifyUris( + db: Database, + config: ContrailConfig, + uris: string[] +): Promise<NotifyResult> { + const events: IngestEvent[] = []; + const errors: string[] = []; + + // Validate and parse all URIs first + const validUris: { uri: string; parsed: { did: string; collection: string; rkey: string } }[] = []; + for (const uri of uris) { + const parsed = parseAtUri(uri); + if (!parsed) { + errors.push(`invalid AT URI: ${uri}`); + continue; + } + // `parsed.collection` is an NSID; look up the matching short name. + if (!shortNameForNsid(config, parsed.collection)) { + errors.push(`collection not tracked: ${parsed.collection}`); + continue; + } + validUris.push({ uri, parsed }); + } + + // Single batch lookup for all existing records (cid + record in one query) + const existing = await lookupExistingRecords( + db, + validUris.map(({ uri, parsed }) => ({ uri, collection: parsed.collection })), + true, + config + ); + + for (const { uri, parsed } of validUris) { + const pds = await getPDS(parsed.did as Did, db, config); + if (!pds) { + errors.push(`could not resolve PDS for ${parsed.did}`); + continue; + } + + const result = await fetchRecordFromPDS( + pds, + parsed.did, + parsed.collection, + parsed.rkey + ); + + const now = Date.now() * 1000; // microseconds + const existingInfo = existing.get(uri); + + if (result) { + if (existingInfo?.cid === result.cid) { + // Same CID — nothing changed + continue; + } + + events.push({ + uri, + did: parsed.did, + collection: parsed.collection, + rkey: parsed.rkey, + operation: existingInfo ? "update" : "create", + cid: result.cid, + record: JSON.stringify(result.value), + time_us: now, + indexed_at: now, + }); + } else if (existingInfo) { + // Record gone from PDS but exists locally — delete it. + events.push({ + uri, + did: parsed.did, + collection: parsed.collection, + rkey: parsed.rkey, + operation: "delete", + cid: null, + record: existingInfo.record, + time_us: now, + indexed_at: now, + }); + } + } + + if (events.length > 0) { + // Pass pre-fetched existing records so applyEvents skips re-querying + await applyEvents(db, events, config, { existing }); + } + + // applyEvents fans these records into feed_items exactly like the cron and + // persistent ingest paths, so prune here too — otherwise a notify-only + // deployment (no jetstream loop) would never sweep. Run the recovery-aware + // gate on every call, not only when records changed: a notify-only deployment + // that receives no-op notifications (a same-CID re-notify produces no events) + // must still be able to advance an overdue recovery pass. `feedTouched` is + // true only when this call actually applied a feed-mutating record. + if (config.feeds) { + const feedMutatingNsids = getFeedMutatingNsids(config); + const feedTouched = events.some((e) => feedMutatingNsids.has(e.collection)); + await runGatedFeedPrune(db, config, feedTouched); + } + + return { + indexed: events.filter((e) => e.operation === "create" || e.operation === "update").length, + deleted: events.filter((e) => e.operation === "delete").length, + errors: errors.length > 0 ? errors : undefined, + }; +} + +export function registerNotifyRoute( + app: Hono, + db: Database, + config: ContrailConfig +) { + // Endpoint is off by default. Set config.notify to true or a secret string to enable. + if (!config.notify) return; + + const ns = config.namespace; + const secret = typeof config.notify === "string" ? config.notify : null; + + app.post(`/xrpc/${ns}.notifyOfUpdate`, async (c) => { + if (secret) { + const auth = c.req.header("Authorization"); + if (auth !== `Bearer ${secret}`) { + return c.json({ error: "unauthorized" }, 401); + } + } + + const body = await c.req.json<{ uri?: string; uris?: string[] }>().catch(() => null); + const uris: string[] = []; + + if (body?.uris && Array.isArray(body.uris)) { + uris.push(...body.uris); + } else if (body?.uri) { + uris.push(body.uri); + } else { + return c.json({ error: "uri or uris required" }, 400); + } + + if (uris.length > 25) { + return c.json({ error: "max 25 URIs per request" }, 400); + } + + const result = await processNotifyUris(db, config, uris); + return c.json(result); + }); +} diff --git a/packages/contrail/src/core/router/profiles.ts b/packages/contrail/src/core/router/profiles.ts index 551c495..8a95c82 100644 --- a/packages/contrail/src/core/router/profiles.ts +++ b/packages/contrail/src/core/router/profiles.ts @@ -1 +1,181 @@ -export * from "@atmo-dev/contrail-appview"; +import type { Database, ContrailConfig, RecordRow, ProfileConfig } from "../types"; +import { recordsTableName, normalizeProfileConfig } from "../types"; +import { resolveIdentities } from "../identity"; +import { getPDS } from "../client"; +import type { Did } from "@atcute/lexicons"; +import { batchedInQuery } from "./helpers"; + +export interface ProfileEntry { + did: string; + handle: string | null; + uri?: string; + cid?: string | null; + value?: unknown; + collection?: string; + rkey?: string; + /** Hydrated by the labels module when the caller has accepted-labelers + * active and there are matching labels on this DID. */ + labels?: unknown; +} + +export function collectDids( + records: RecordRow[], + hydrates: Record<string, Record<string, any[] | Record<string, any[]>>> +): string[] { + const dids = new Set(records.map((r) => r.did)); + for (const rels of Object.values(hydrates)) { + for (const value of Object.values(rels)) { + const items = Array.isArray(value) + ? value + : Object.values(value).flat(); + for (const item of items) { + if (item.did) dids.add(item.did); + } + } + } + return [...dids]; +} + +export async function resolveProfiles( + db: Database, + config: ContrailConfig, + dids: string[] +): Promise<Record<string, ProfileEntry[]>> { + if (dids.length === 0 || !config.profiles || config.profiles.length === 0) { + return {}; + } + + const profileConfigs = config.profiles.map(normalizeProfileConfig); + const result: Record<string, ProfileEntry[]> = {}; + + // Batch-lookup profile records for each configured profile collection + for (const pc of profileConfigs) { + const { collection, rkey: configRkey, shortName } = pc; + const rkey = configRkey ?? "self"; + const table = recordsTableName(shortName ?? collection); + const uris = dids.map((did) => `at://${did}/${collection}/${rkey}`); + + const rows = await batchedInQuery<Omit<RecordRow, "collection">>( + db, + `SELECT uri, did, rkey, cid, record FROM ${table} WHERE uri IN (__IN__)`, + [], + uris + ); + + for (const row of rows) { + let value: unknown = null; + if (row.record) { + try { + value = JSON.parse(row.record); + } catch { + value = row.record; + } + } + if (!result[row.did]) result[row.did] = []; + result[row.did].push({ + did: row.did, + handle: null, // filled below + uri: row.uri, + collection, + rkey: row.rkey, + cid: row.cid, + value, + }); + } + } + + // Resolve identities for all DIDs + const identities = await resolveIdentities(db, dids, config); + + // Fetch missing profile records from PDS on demand + const missingDids = dids.filter((d) => !result[d]); + if (missingDids.length > 0 && profileConfigs.length > 0) { + const fetched = await fetchMissingProfiles(db, config, missingDids); + for (const [did, entries] of Object.entries(fetched)) { + if (!result[did]) result[did] = []; + result[did].push(...entries); + } + } + + // Fill in handles and create entries for DIDs without profile records + for (const did of dids) { + const identity = identities.get(did); + const handle = identity?.handle ?? null; + + if (result[did]) { + for (const entry of result[did]) { + entry.handle = handle; + } + } else { + result[did] = [{ did, handle }]; + } + } + + return result; +} + +/** + * Fetch profile records from PDS for DIDs not yet in the index. + * Fetches in parallel across all configured profile collections, + * indexes the results into D1 for future requests. + */ +async function fetchMissingProfiles( + db: Database, + config: ContrailConfig, + dids: string[] +): Promise<Record<string, ProfileEntry[]>> { + const result: Record<string, ProfileEntry[]> = {}; + const profileConfigs = config.profiles!.map(normalizeProfileConfig); + + await Promise.all( + dids.flatMap((did) => + profileConfigs.map(async (pc) => { + const { collection, rkey: configRkey, shortName } = pc; + const rkey = configRkey ?? "self"; + const table = recordsTableName(shortName ?? collection); + try { + const pds = await getPDS(did as Did, db, config); + if (!pds) return; + + const url = new URL("/xrpc/com.atproto.repo.getRecord", pds); + url.searchParams.set("repo", did); + url.searchParams.set("collection", collection); + url.searchParams.set("rkey", rkey); + + const res = await fetch(url.toString()); + if (!res.ok) return; + + const data = (await res.json()) as { uri?: string; value?: unknown; cid?: string }; + if (!data.value || !data.cid) return; + + const uri = data.uri ?? `at://${did}/${collection}/${rkey}`; + const record = data.value; + const cid = data.cid; + + // Index into D1 for future requests + await db + .prepare( + `INSERT INTO ${table} (uri, did, rkey, cid, record, time_us, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(uri) DO UPDATE SET cid = excluded.cid, record = excluded.record, indexed_at = excluded.indexed_at` + ) + .bind(uri, did, rkey, cid, JSON.stringify(record), Date.now() * 1000, Date.now()) + .run(); + + if (!result[did]) result[did] = []; + result[did].push({ + did, + handle: null, + uri, + collection, + rkey, + cid, + value: record, + }); + } catch { + // Skip failures silently + } + }) + ) + ); + + return result; +} diff --git a/packages/contrail/src/core/search.ts b/packages/contrail/src/core/search.ts index 551c495..a33dc5e 100644 --- a/packages/contrail/src/core/search.ts +++ b/packages/contrail/src/core/search.ts @@ -1 +1,31 @@ -export * from "@atmo-dev/contrail-appview"; +import type { CollectionConfig } from "./types"; +import { getNestedValue } from "./types"; + +/** + * Resolve which fields are searchable for a collection. + * Returns null if search is disabled or no fields found. + */ +export function getSearchableFields( + collection: string, + colConfig: CollectionConfig +): string[] | null { + if (!Array.isArray(colConfig.searchable)) return null; + return colConfig.searchable.length > 0 ? colConfig.searchable : null; +} + +/** Sanitized FTS table name for a collection. */ +export function ftsTableName(collection: string): string { + return `fts_${collection.replace(/[^a-zA-Z0-9]/g, "_")}`; +} + +/** Extract searchable field values from a record and join them into a single string. */ +export function buildFtsContent(record: unknown, fields: string[]): string | null { + const parts: string[] = []; + for (const field of fields) { + const value = getNestedValue(record, field); + if (typeof value === "string" && value.length > 0) { + parts.push(value); + } + } + return parts.length > 0 ? parts.join(" ") : null; +} diff --git a/packages/contrail/src/core/sinks/types.ts b/packages/contrail/src/core/sinks/types.ts new file mode 100644 index 0000000..59e5a15 --- /dev/null +++ b/packages/contrail/src/core/sinks/types.ts @@ -0,0 +1,31 @@ +/** Write-only, post-commit observers of applied public records. */ + +export interface SinkContext { + /** `live` for normal ingestion, `backfill` for replay or rebuild work. */ + phase: "live" | "backfill"; +} + +/** One upsert or deletion event per applied record. */ +export type RecordEvent = + | { + kind: "created"; + uri: string; + did: string; + collection: string; + rkey: string; + cid: string | null; + record: Record<string, unknown>; + time_us: number; + } + | { + kind: "deleted"; + uri: string; + did: string; + collection: string; + rkey: string; + }; + +export interface Sink { + /** Runs after a committed batch. Errors are logged without stopping ingestion. */ + onRecords(events: RecordEvent[], context: SinkContext): Promise<void> | void; +} diff --git a/packages/contrail/src/core/spaces/acl.ts b/packages/contrail/src/core/spaces/acl.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/spaces/acl.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/spaces/adapter.ts b/packages/contrail/src/core/spaces/adapter.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/spaces/adapter.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/spaces/auth.ts b/packages/contrail/src/core/spaces/auth.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/spaces/auth.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/spaces/binding.ts b/packages/contrail/src/core/spaces/binding.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/spaces/binding.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/spaces/blob-adapter.ts b/packages/contrail/src/core/spaces/blob-adapter.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/spaces/blob-adapter.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/spaces/blob-gc.ts b/packages/contrail/src/core/spaces/blob-gc.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/spaces/blob-gc.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/spaces/blob-refs.ts b/packages/contrail/src/core/spaces/blob-refs.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/spaces/blob-refs.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/spaces/credentials.ts b/packages/contrail/src/core/spaces/credentials.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/spaces/credentials.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/spaces/in-process.ts b/packages/contrail/src/core/spaces/in-process.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/spaces/in-process.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/spaces/router.ts b/packages/contrail/src/core/spaces/router.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/spaces/router.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/spaces/schema.ts b/packages/contrail/src/core/spaces/schema.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/spaces/schema.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/spaces/tid.ts b/packages/contrail/src/core/spaces/tid.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/spaces/tid.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/spaces/types.ts b/packages/contrail/src/core/spaces/types.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/spaces/types.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/spaces/uri.ts b/packages/contrail/src/core/spaces/uri.ts deleted file mode 100644 index 551c495..0000000 --- a/packages/contrail/src/core/spaces/uri.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@atmo-dev/contrail-appview"; diff --git a/packages/contrail/src/core/types.ts b/packages/contrail/src/core/types.ts index 551c495..b131715 100644 --- a/packages/contrail/src/core/types.ts +++ b/packages/contrail/src/core/types.ts @@ -1 +1,733 @@ -export * from "@atmo-dev/contrail-appview"; +import type { SqlDialect } from "./dialect"; + +// Database interface — D1 implements this natively +export interface Database { + prepare(sql: string): Statement; + batch(stmts: Statement[]): Promise<any[]>; + dialect?: SqlDialect; +} + +export interface Statement { + bind(...values: any[]): Statement; + run(): Promise<any>; + all<T = any>(): Promise<{ results: T[] }>; + first<T = any>(): Promise<T | null>; +} + +// Config types + +export interface QueryableField { + type?: "range"; +} + +export interface RelationConfig { + /** Short name of the child collection (a key in `collections`). */ + collection: string; + field?: string; + match?: "uri" | "did"; + groupBy?: string; + /** Enable materialized count columns on the parent. Defaults to true. */ + count?: boolean; + /** Count distinct values of a field (e.g. "did" for unique users) instead of total records. */ + countDistinct?: string; + /** Pre-resolved group mappings: shortName → full token (e.g. { going: "community.lexicon.calendar.rsvp#going" }). Auto-computed from groupBy if omitted. */ + groups?: Record<string, string>; +} + +/** A forward reference: this collection's records point at another collection. */ +export interface ReferenceConfig { + /** Short name of the target collection. */ + collection: string; + /** Field on this collection's records containing the target URI. */ + field: string; +} + +export type CustomQueryHandler = ( + db: Database, + params: URLSearchParams, + config: ContrailConfig +) => Promise<Response>; + +export interface RecordSource { + joins?: string; + conditions?: string[]; + params?: (string | number)[]; +} + +export type PipelineQueryHandler = ( + db: Database, + params: URLSearchParams, + config: ContrailConfig +) => Promise<RecordSource>; + +export interface FeedTargetConfig { + /** Short name of the target collection. */ + collection: string; + /** Per-target item cap. Falls back to FeedConfig.maxItems if unset. */ + maxItems?: number; +} + +export interface FeedConfig { + /** Short name of the follow collection. Defaults to "follow" + * (auto-added with NSID `app.bsky.graph.follow`, `discover: false`). */ + follow?: string; + /** Target collections to fan out to. Each entry is either a short name + * or `{ collection, maxItems? }` for per-target caps. */ + targets: (string | FeedTargetConfig)[]; + /** Default per-target item cap when a target doesn't specify its own + * (default: 200). Oldest items per (actor, collection) are pruned. */ + maxItems?: number; +} + +export const DEFAULT_FEED_MAX_ITEMS = 200; +export const DEFAULT_FOLLOW_NSID = "app.bsky.graph.follow"; +export const DEFAULT_FOLLOW_SHORT = "follow"; + +/** Normalize a feed target entry to FeedTargetConfig. */ +export function normalizeFeedTarget( + t: string | FeedTargetConfig +): FeedTargetConfig { + return typeof t === "string" ? { collection: t } : t; +} + +/** Resolve a feed's per-target item cap, falling back to FeedConfig.maxItems then global default. */ +export function feedTargetMaxItems( + feed: FeedConfig, + target: FeedTargetConfig +): number { + return target.maxItems ?? feed.maxItems ?? DEFAULT_FEED_MAX_ITEMS; +} + +/** Build a Map<target-NSID, maxItems> across all configured feeds, taking the + * largest cap if the same target collection appears in multiple feeds. */ +export function buildFeedTargetCaps( + config: ContrailConfig +): Map<string, number> { + const caps = new Map<string, number>(); + if (!config.feeds) return caps; + for (const feed of Object.values(config.feeds)) { + for (const t of feed.targets) { + const target = normalizeFeedTarget(t); + const colCfg = config.collections[target.collection]; + if (!colCfg) continue; + const cap = feedTargetMaxItems(feed, target); + const nsid = colCfg.collection ?? target.collection; + const existing = caps.get(nsid) ?? 0; + if (cap > existing) caps.set(nsid, cap); + } + } + return caps; +} + +export type CollectionMethod = "listRecords" | "getRecord"; +export const DEFAULT_COLLECTION_METHODS: CollectionMethod[] = [ + "listRecords", + "getRecord", +]; + +export interface CollectionConfig { + /** Full NSID of the record type this collection indexes. May be omitted when + * the collection's map key is itself the full NSID (an "NSID-keyed" config); + * `resolveConfig` normalizes the omitted value to that key. */ + collection?: string; + /** Include this collection in Jetstream ingest / discovery (default true). + * Set false for dependent collections (auto-fetched on demand). */ + discover?: boolean; + queryable?: Record<string, QueryableField>; + relations?: Record<string, RelationConfig>; + /** Forward references: fields on this collection's records that point at another collection. */ + references?: Record<string, ReferenceConfig>; + queries?: Record<string, CustomQueryHandler>; + pipelineQueries?: Record<string, PipelineQueryHandler>; + /** FTS5 search fields. Provide an array of field names to enable full-text search. Omit or set to false to disable. */ + searchable?: string[] | false; + /** XRPC methods to emit. Defaults to ['listRecords', 'getRecord']. */ + methods?: CollectionMethod[]; + /** JSON field on the record used as the canonical event time, parsed and + * written into `time_us` during backfill (and clamped to now). Default + * `"createdAt"`. Set to `false` to disable parsing and keep ingest time. */ + timeField?: string | false; + /** JSON field on the record holding a DID that this record points at + * (e.g. `"subject"` for follows). When set on a `discover: false` + * collection, ingest also drops records whose subject DID is not in + * knownDids — useful for trimming network-wide social graphs to the + * subjects we care about. */ + subjectField?: string; + /** Per-record predicate run during ingest. Returning false drops the + * record before it hits the buffer / DB. Runs only for create/update; + * deletes always pass through (the delete may target a record that *did* + * pass an earlier version of the filter). Thrown errors are caught, + * logged, and treated as "drop". Note: Jetstream filters only by + * `wantedCollections`, so non-matching records still travel over the wire + * — this trims what gets persisted, not bandwidth. */ + recordFilter?: (record: Record<string, unknown>) => boolean; +} + +export interface ProfileConfig { + /** Full NSID of the profile record type. */ + collection: string; + /** Short name used for table/endpoint naming. Defaults to the NSID's last segment. */ + shortName?: string; + rkey?: string; // defaults to "self" +} + +export const DEFAULT_PROFILES: ProfileConfig[] = [ + { collection: "app.bsky.actor.profile", shortName: "profile" }, +]; + +/** Normalize a profiles config entry (string or object) into ProfileConfig. */ +export function normalizeProfileConfig( + p: string | ProfileConfig +): ProfileConfig { + if (typeof p === "string") { + return { collection: p, shortName: deriveShortName(p) }; + } + return { ...p, shortName: p.shortName ?? deriveShortName(p.collection) }; +} + +/** Last NSID segment, used as fallback short name. */ +export function deriveShortName(nsid: string): string { + const parts = nsid.split("."); + return parts[parts.length - 1] ?? nsid; +} + +export const DEFAULT_JETSTREAMS = [ + "wss://jetstream1.us-east.bsky.network", +]; + +/** + * Shape a configured jetstream list for `@atcute/jetstream`'s `url` option. + * + * @atcute distinguishes a string url (one fixed instance) from an array url (a + * pool it picks from at random each connect). For an array it seeds + * `#lastUsedUrl=''` and rolls the cursor back 10s on the first connect, to absorb + * clock skew between whichever pooled instances a resumed cursor may have + * crossed. A string takes no rollback: a single instance emits a monotonic + * cursor, so resuming at the saved value on that same instance can't skip its own + * events — there is no second instance to be skewed against. + * + * Contrail's cron ingestion rebuilds the subscription every cycle, so for a + * single-instance config that "first-connect" rollback fires *every* cycle and + * redundantly re-ingests the last 10s. Collapsing a one-element pool to a string + * matches @atcute's own single-instance semantics and drops that dead margin; a + * real pool (2+) stays an array so the cross-instance rollback is preserved. + */ +export function jetstreamUrlOption(jetstreams: string[]): string | string[] { + return jetstreams.length === 1 ? jetstreams[0] : jetstreams; +} + +export const DEFAULT_RELAYS = [ + "https://relay1.us-east.bsky.network" +]; + +export interface Logger { + log(...args: any[]): void; + warn(...args: any[]): void; + error(...args: any[]): void; +} + +export interface ContrailConfig { + namespace: string; + /** Collections to index, keyed by short name. Short names become endpoint URL segments + * (`<namespace>.<short>.listRecords`) and table suffixes (`records_<short>`). */ + collections: Record<string, CollectionConfig>; + profiles?: (string | ProfileConfig)[]; + relays?: string[]; + /** Jetstream endpoints to ingest from (defaults to {@link DEFAULT_JETSTREAMS}). + * Prefer a single endpoint: one instance has no clock skew, so `@atcute` takes + * no cursor rollback (see {@link jetstreamUrlOption}) — important for the cron + * model, which rebuilds the subscription every cycle. Use 2+ only for failover + * across interchangeable endpoints, and ideally only with a persistent + * connection (`runPersistent`), where the per-switch 10s skew rollback fires + * about once rather than every cycle. */ + jetstreams?: string[]; + feeds?: Record<string, FeedConfig>; + logger?: Logger; + /** Expose the notifyOfUpdate HTTP endpoint. Off by default. + * Set to `true` for open access, or a string to require `Authorization: Bearer <secret>`. */ + notify?: boolean | string; + /** Write-only, post-commit observers of applied records — derived indexes, + * audit logs, webhook fan-outs. Each fires after every `applyEvents()` commit + * on both live and backfill paths, with failures isolated so a throwing + * sink never blocks ingestion. */ + sinks?: import("./sinks/types").Sink[]; + /** Labels module configuration. When set, contrail subscribes to the + * configured labelers, indexes their labels into a single `labels` table, + * and hydrates `record.labels` onto `listRecords` / `getRecord` / profile + * responses gated by the caller's `atproto-accept-labelers` header. */ + labels?: import("./labels/types").LabelsConfig; + /** Constellation-backed reverse-follower lookup (default: enabled). + * When a DID is first seen producing a discoverable record, contrail + * queries Constellation for follow records pointing at that DID and + * ingests synthesized rows for any follower already in our identities + * table. Lets newcomers immediately appear in existing users' feeds. */ + constellation?: ConstellationConfig | false; + /** Network overrides for private-network or test deployments. + * All subfields default to current public-internet behavior; + * omitting `networkOverrides` entirely preserves current behavior. + * + * SECURITY: `resolver` and `slingshotUrl` are taken at face value and are + * NOT validated against the SSRF guard — the consumer is trusted to + * configure them. Only the PDS URL returned downstream is validated, + * and only `additionalAllowedHosts` widens that PDS validator. There is + * no "disable SSRF" flag. */ + networkOverrides?: { + /** DID document resolver used during the DID-doc PDS fallback. When + * unset, contrail constructs a default `CompositeDidDocumentResolver` + * with PLC + Web methods pointing at the upstream PLC directory. + * Pass a custom resolver to point at a private PLC mirror, inject a + * custom fetch (mTLS, retry, instrumentation), or swap in an + * alternative DID method composition. */ + resolver?: import("@atcute/identity-resolver").DidDocumentResolver; + /** Slingshot identity resolver URL override. Trusted; not SSRF-checked. + * Default: https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc */ + slingshotUrl?: string; + /** Hostnames (DNS names or IP literals) to allow past the default SSRF + * guard when validating a resolved PDS URL. + * For listed hostnames, the non-HTTPS + private-CIDR checks are skipped. + * For all other hostnames, the default validator runs unchanged. + * Match semantics: exact hostname, case-insensitive (entries are + * lowercased on comparison; `URL.hostname` is already lowercased), + * port-agnostic. + * Example: ["pds.dev.svc.cluster.local"]. */ + additionalAllowedHosts?: string[]; + }; + /** Optional background database maintenance. All off by default. */ + maintenance?: MaintenanceConfig; +} + +export interface MaintenanceConfig { + /** Periodically refresh the SQLite query planner's statistics so + * multi-predicate queries pick the selective index instead of the planner's + * default heuristic (measured ~50x fewer rows read on a 2-predicate query). + * Off by default — it's a DB write + CPU and shouldn't change behavior for + * existing consumers unless enabled. `true` uses defaults; pass an object to + * tune. No-op on Postgres, where autovacuum/autoanalyze handles this. */ + optimize?: boolean | MaintenanceOptimizeConfig; +} + +export interface MaintenanceOptimizeConfig { + /** Minimum gap between optimize runs (default: 24h). Planner stats change + * slowly, so daily is plenty. */ + intervalMs?: number; + /** `PRAGMA analysis_limit` — bounds the work per run so it can't exceed + * D1's per-query CPU budget and reset the shared DO (default: 400). */ + analysisLimit?: number; +} + +export const DEFAULT_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60 * 1000; +export const DEFAULT_ANALYSIS_LIMIT = 400; + +/** Whether the opt-in planner-stat maintenance is enabled. */ +export function optimizeEnabled(config: ContrailConfig): boolean { + return !!config.maintenance?.optimize; +} + +/** Resolved optimize interval (ms), falling back to the 24h default. */ +export function optimizeIntervalMs(config: ContrailConfig): number { + const o = config.maintenance?.optimize; + if (o && typeof o === "object" && o.intervalMs != null) return o.intervalMs; + return DEFAULT_OPTIMIZE_INTERVAL_MS; +} + +/** Resolved `analysis_limit` for optimize, falling back to the default. */ +export function optimizeAnalysisLimit(config: ContrailConfig): number { + const o = config.maintenance?.optimize; + if (o && typeof o === "object" && o.analysisLimit != null) return o.analysisLimit; + return DEFAULT_ANALYSIS_LIMIT; +} + +export interface ConstellationConfig { + /** Override the default Constellation instance URL. */ + url?: string; + /** Sent as the User-Agent header per Constellation's request that + * callers identify themselves. Defaults to `contrail/<namespace>`. */ + userAgent?: string; + /** Set false to disable lookups while keeping the table around. */ + enabled?: boolean; +} + +export const DEFAULT_CONSTELLATION_URL = "https://constellation.microcosm.blue"; + +export interface ResolvedRelation { + /** Short name of the child collection. */ + collection: string; + groupBy: string; + groups: Record<string, string>; // shortName → full token value +} + +export interface ResolvedMaps { + queryable: Record<string, Record<string, QueryableField>>; + relations: Record<string, Record<string, ResolvedRelation>>; + /** Reverse map: full record NSID → short name. */ + nsidToShort: Record<string, string>; +} + +/** Config after resolveConfig() — has computed queryable/relation maps attached. */ +export interface ResolvedContrailConfig extends ContrailConfig { + _resolved: ResolvedMaps; +} + +/** + * Resolve config: apply defaults, auto-add profile collections, compute queryable maps. + */ +export function resolveConfig(config: ContrailConfig): ResolvedContrailConfig { + const profiles = (config.profiles ?? DEFAULT_PROFILES).map( + normalizeProfileConfig + ); + const collections: Record<string, CollectionConfig> = {}; + + // Normalize an omitted `collection` (NSID-keyed config) to the map key, then + // default `discover: false` for any collection whose NSID lives under the + // `app.bsky.*` namespace, since these are external/network-wide records that + // would otherwise blow up storage if left discoverable. + for (const [short, rawC] of Object.entries(config.collections)) { + const c = + rawC.collection === undefined ? { ...rawC, collection: short } : rawC; + collections[short] = + c.discover === undefined && c.collection!.startsWith("app.bsky.") + ? { ...c, discover: false } + : c; + } + + for (const p of profiles) { + const short = p.shortName!; + if (!collections[short]) { + collections[short] = { collection: p.collection, discover: false }; + } + } + + // Auto-add a follow collection for any feed that doesn't declare one. + // Default short name `follow` → `app.bsky.graph.follow`, with a `subject` + // filter so we only persist follows pointing at known DIDs. + const feeds = config.feeds; + if (feeds) { + const usedFollowShorts = new Set<string>(); + for (const [, feed] of Object.entries(feeds)) { + const shortName = feed.follow ?? DEFAULT_FOLLOW_SHORT; + usedFollowShorts.add(shortName); + } + for (const short of usedFollowShorts) { + if (!collections[short]) { + collections[short] = { + collection: DEFAULT_FOLLOW_NSID, + discover: false, + subjectField: "subject", + }; + } + } + } + + const base = { + ...config, + collections, + profiles, + jetstreams: config.jetstreams ?? DEFAULT_JETSTREAMS, + relays: config.relays ?? DEFAULT_RELAYS, + logger: config.logger ?? console, + }; + + return { + ...base, + _resolved: _resolveQueryableMaps(base), + }; +} + +function _resolveQueryableMaps(config: ContrailConfig): ResolvedMaps { + const queryable: Record<string, Record<string, QueryableField>> = {}; + const relations: Record<string, Record<string, ResolvedRelation>> = {}; + const nsidToShort: Record<string, string> = {}; + + for (const [short, colConfig] of Object.entries(config.collections)) { + nsidToShort[colConfig.collection ?? short] = short; + + if (colConfig.queryable) { + queryable[short] = colConfig.queryable; + } + + if (colConfig.relations) { + for (const [relName, rel] of Object.entries(colConfig.relations)) { + if (!rel.groupBy) continue; + const groups: Record<string, string> = rel.groups ? { ...rel.groups } : {}; + if (Object.keys(groups).length > 0) { + if (!relations[short]) relations[short] = {}; + relations[short][relName] = { + collection: rel.collection, + groupBy: rel.groupBy, + groups, + }; + } + } + } + } + + return { queryable, relations, nsidToShort }; +} + +export function getFeedFollowShortNames(config: ContrailConfig): string[] { + if (!config.feeds) return []; + return [ + ...new Set( + Object.values(config.feeds).map((f) => f.follow ?? DEFAULT_FOLLOW_SHORT) + ), + ]; +} + +/** Alias for getFeedFollowShortNames. */ +export const getFeedFollowCollections = getFeedFollowShortNames; + +/** + * NSIDs whose ingest can mutate `feed_items`: feed *target* collections (a + * create/update fans out to followers, a delete tears the item down) and feed + * *follow* collections (a follow backfills the follower's feed, an unfollow + * removes it). These are the only records that can push a feed over its cap, so + * a tick that ingested none of them cannot have created prune work — callers use + * this to skip the feed sweep on idle ticks. Returns an empty set when no feeds + * are configured. */ +export function getFeedMutatingNsids(config: ContrailConfig): Set<string> { + const nsids = new Set<string>(); + if (!config.feeds) return nsids; + for (const targetNsid of buildFeedTargetCaps(config).keys()) { + nsids.add(targetNsid); + } + for (const short of getFeedFollowShortNames(config)) { + const nsid = nsidForShortName(config, short); + if (nsid) nsids.add(nsid); + } + return nsids; +} + +// Record types + +export interface RecordRow { + uri: string; + did: string; + collection: string; // full NSID + rkey: string; + cid: string | null; + record: string | null; + time_us: number; + indexed_at: number; +} + +export interface IngestEvent { + uri: string; + did: string; + collection: string; // full NSID + rkey: string; + operation: "create" | "update" | "delete"; + cid: string | null; + record: string | null; + time_us: number; + indexed_at: number; +} + +// Validation + +const SAFE_FIELD_NAME = /^[a-zA-Z0-9_.]+$/; +const SAFE_SHORT_NAME = /^[a-zA-Z][a-zA-Z0-9]*$/; + +export function validateFieldName(field: string): string { + if (!SAFE_FIELD_NAME.test(field)) { + throw new Error(`Invalid field name: ${field}`); + } + return field; +} + +function validateShortName(short: string): void { + if (!SAFE_SHORT_NAME.test(short)) { + throw new Error( + `Invalid collection short name: "${short}". Must be alphanumeric, starting with a letter.` + ); + } +} + +export function validateConfig(config: ContrailConfig): void { + const shortNames = new Set<string>(); + for (const [short, colConfig] of Object.entries(config.collections)) { + // NSID-keyed collections use the map key as the NSID (no short alias), so + // the key legitimately contains dots and must skip short-name validation. + const nsidKeyed = + colConfig.collection === undefined || colConfig.collection === short; + if (!nsidKeyed) validateShortName(short); + if (shortNames.has(short)) { + throw new Error(`Duplicate collection short name: ${short}`); + } + shortNames.add(short); + + for (const field of Object.keys(colConfig.queryable ?? {})) { + validateFieldName(field); + } + for (const [, rel] of Object.entries(colConfig.relations ?? {})) { + if (rel.field) validateFieldName(rel.field); + if (rel.groupBy) validateFieldName(rel.groupBy); + if (rel.countDistinct) validateFieldName(rel.countDistinct); + if (!config.collections[rel.collection]) { + throw new Error( + `Relation in "${short}" references unknown collection short name "${rel.collection}"` + ); + } + } + for (const [, ref] of Object.entries(colConfig.references ?? {})) { + validateFieldName(ref.field); + if (!config.collections[ref.collection]) { + throw new Error( + `Reference in "${short}" references unknown collection short name "${ref.collection}"` + ); + } + } + if (Array.isArray(colConfig.searchable)) { + for (const field of colConfig.searchable) { + validateFieldName(field); + } + } + } + + if (config.feeds) { + for (const [feedName, feed] of Object.entries(config.feeds)) { + const followShort = feed.follow ?? DEFAULT_FOLLOW_SHORT; + if (!config.collections[followShort]) { + throw new Error( + `Feed "${feedName}" references unknown follow collection "${followShort}"` + ); + } + for (const t of feed.targets) { + const targetShort = normalizeFeedTarget(t).collection; + if (!config.collections[targetShort]) { + throw new Error( + `Feed "${feedName}" references unknown target collection "${targetShort}"` + ); + } + } + } + } + +} + +// Helpers + +export function getNestedValue(obj: any, path: string): any { + let current = obj; + for (const key of path.split(".")) { + if (current == null) return undefined; + current = current[key]; + } + return current; +} + +const DEFAULT_RELATION_FIELD = "subject.uri"; + +export function getRelationField(rel: RelationConfig): string { + return rel.field ?? DEFAULT_RELATION_FIELD; +} + +/** Sanitize a short name for use in SQL identifiers (already-validated; kept for paranoia). */ +function sanitizeIdentifier(name: string): string { + return name.replace(/[^a-zA-Z0-9]/g, "_"); +} + +/** Total-count column name for a relation targeting the given short name. */ +export function countColumnName(childShortName: string): string { + return "count_" + sanitizeIdentifier(childShortName); +} + +/** Grouped-count column name: `count_<child-short>_<groupKey>`. */ +export function groupedCountColumnName( + childShortName: string, + groupKey: string +): string { + return `count_${sanitizeIdentifier(childShortName)}_${sanitizeIdentifier(groupKey)}`; +} + +/** Table name for a collection's records. */ +export function recordsTableName(shortName: string): string { + return "records_" + sanitizeIdentifier(shortName); +} + +/** All collection short names. */ +export function getCollectionShortNames(config: ContrailConfig): string[] { + return Object.keys(config.collections); +} + +/** Alias: collection short names (same as getCollectionShortNames). */ +export const getCollectionNames = getCollectionShortNames; + +/** All indexed record NSIDs (what Jetstream filters on). For NSID-keyed + * collections (omitted `collection`), the map key is the NSID. */ +export function getCollectionNsids(config: ContrailConfig): string[] { + return Object.entries(config.collections).map(([short, c]) => c.collection ?? short); +} + +export function getDependentShortNames(config: ContrailConfig): string[] { + return Object.entries(config.collections) + .filter(([, c]) => c.discover === false) + .map(([name]) => name); +} + +export function getDiscoverableShortNames(config: ContrailConfig): string[] { + return Object.entries(config.collections) + .filter(([, c]) => c.discover !== false) + .map(([name]) => name); +} + +/** Aliases for readability elsewhere. These return short names (new semantic). */ +export const getDependentCollections = getDependentShortNames; +export const getDiscoverableCollections = getDiscoverableShortNames; + +/** Short names of collections the user declared with `discover !== false`, mapped to NSIDs. */ +export function getDiscoverableNsids(config: ContrailConfig): string[] { + return Object.entries(config.collections) + .filter(([, c]) => c.discover !== false) + .map(([short, c]) => c.collection ?? short); +} + +export function getDependentNsids(config: ContrailConfig): string[] { + return Object.entries(config.collections) + .filter(([, c]) => c.discover === false) + .map(([short, c]) => c.collection ?? short); +} + +/** Short name for a record NSID, if known. */ +export function shortNameForNsid( + config: ContrailConfig, + nsid: string +): string | undefined { + const resolved = (config as ResolvedContrailConfig)._resolved; + if (resolved?.nsidToShort) return resolved.nsidToShort[nsid]; + for (const [short, c] of Object.entries(config.collections)) { + if ((c.collection ?? short) === nsid) return short; + } + return undefined; +} + +/** The config key a collection's rows are stored under: its short alias when + * one exists, otherwise the NSID itself when the config is keyed directly by + * NSID. Returns null when the collection is unknown. Use this wherever you need + * the storage key (records insert, FTS, existing-record lookup). Unlike + * {@link shortNameForNsid}, which only reports an alias and so returns + * undefined for NSID-keyed configs. */ +export function resolveCollectionKey( + config: ContrailConfig, + nsid: string +): string | null { + return ( + shortNameForNsid(config, nsid) ?? + (config.collections[nsid] ? nsid : null) + ); +} + +/** Full NSID for a collection short name. For NSID-keyed collections (omitted + * `collection`), the short name is itself the NSID. */ +export function nsidForShortName( + config: ContrailConfig, + short: string +): string | undefined { + const c = config.collections[short]; + if (!c) return undefined; + return c.collection ?? short; +} + +/** The methods a collection should expose via XRPC. */ +export function getCollectionMethods(cfg: CollectionConfig): CollectionMethod[] { + return cfg.methods ?? DEFAULT_COLLECTION_METHODS; +} diff --git a/packages/contrail/src/index.ts b/packages/contrail/src/index.ts index a5ccd69..464dfb5 100644 --- a/packages/contrail/src/index.ts +++ b/packages/contrail/src/index.ts @@ -1,193 +1,43 @@ +/** Contrail's public API. */ export { Contrail } from "./contrail"; -export type { ContrailOptions } from "./contrail"; - -export type { - ContrailConfig, - CollectionConfig, - RelationConfig, - ReferenceConfig, - QueryableField, - FeedConfig, - Database, - Statement, - Logger, - IngestEvent, - RecordRow, - RecordSource, - ResolvedContrailConfig, - ResolvedMaps, - ResolvedRelation, - CustomQueryHandler, - PipelineQueryHandler, -} from "./core/types"; - -export { resolveConfig, validateConfig } from "./core/types"; - -export type { QueryOptions, SortOption } from "./core/db/records"; -export type { BackfillProgress, BackfillAllOptions } from "./core/backfill"; -export type { NotifyResult } from "./core/router/notify"; -export type { - RefreshOptions, - RefreshResult, - RefreshProgress, - CollectionStats, -} from "./core/refresh"; - -export { runPersistent } from "./core/persistent"; -export type { PersistentIngestOptions } from "./core/persistent"; - -// Spaces -export type { - SpacesConfig, - AuthorityConfig, - RecordHostConfig, - SpaceAuthority, - RecordHost, - AppPolicy, - AppPolicyMode, - SpaceRow, - SpaceMemberRow, - StoredRecord, - StorageAdapter, - ListOptions, - ListResult, - ListSpacesOptions, - CollectionCount, - InviteRow, - CreateInviteInput, - RedeemInviteResult, -} from "./core/spaces/types"; -export { HostedAdapter } from "./core/spaces/adapter"; -export { nextTid } from "./core/spaces/tid"; -export { generateInviteToken, hashInviteToken, mintInviteToken } from "./core/invite"; -export { - MemoryBlobAdapter, - R2BlobAdapter, - blobKey, -} from "./core/spaces/blob-adapter"; -export type { BlobAdapter, BlobUploadMeta, R2BucketLike } from "./core/spaces/blob-adapter"; -export type { SpacesBlobsConfig, BlobMetaRow } from "./core/spaces/types"; - -// Space credentials -export { - generateAuthoritySigningKey, - signCredential, - issueCredential, - verifyCredential, - decodeUnverifiedClaims, - createInProcessVerifier, - createBindingCredentialVerifier, -} from "./core/spaces/credentials"; -export type { - CredentialClaims, - CredentialKeyMaterial, - CredentialScope, - CredentialVerifier, - VerifyOk, - VerifyErr, - VerifyOptions, -} from "./core/spaces/credentials"; - -// Binding + key resolution -export { - createLocalBindingResolver, - createEnrollmentBindingResolver, - createOwnerSelfBindingResolver, - createCompositeBindingResolver, - createPdsBindingResolver, - createDidDocBindingResolver, - createLocalKeyResolver, - createDidDocKeyResolver, - createCompositeKeyResolver, -} from "./core/spaces/binding"; -export type { BindingResolver, KeyResolver } from "./core/spaces/binding"; - -// Route registration — exposed for split deployments where authority and -// record host run as separate Hono apps. Consumers wire them onto bare Honos -// individually instead of going through createApp's umbrella. -export { - registerAuthorityRoutes, - registerRecordHostRoutes, -} from "./core/spaces/router"; -export type { EnrollmentRow } from "./core/spaces/types"; -export type { WhoamiExtension } from "./core/spaces/router"; - -// Internal-but-exposed bits — extension packages (community, etc.) need -// these to wire themselves up. Consumer apps generally don't. -export { buildSpaceUri, parseSpaceUri } from "./core/spaces/uri"; -export type { ServiceAuth } from "./core/spaces/auth"; -export { buildVerifier, createServiceAuthMiddleware } from "./core/spaces/auth"; -export { getDialect } from "./core/dialect"; -export type { SqlDialect } from "./core/dialect"; - -// App + schema wiring — extension packages and tests use these. -export { createApp } from "./core/router"; -export type { CreateAppOptions, SpacesContext } from "./core/router"; -export { initSchema } from "./core/db/schema"; -export type { InitSchemaOptions } from "./core/db/schema"; - -// Community integration interfaces — contrail core defines the shapes -// extension packages implement. The @atmo-dev/contrail-community package -// provides the concrete implementations. -export type { - CommunityIntegration, - CommunityProbe, -} from "./core/community-integration"; -export type { - CommunityInviteHandler, - HandlerResponse, -} from "./core/invite/community-handler"; -export type { SchemaModule } from "./core/db/schema"; - -// Realtime -export type { - PubSub, - RealtimeConfig, - RealtimeEvent, - RealtimeEventKind, -} from "./core/realtime/types"; -export { - InMemoryPubSub, - DurableObjectPubSub, - RealtimePubSubDO, - TicketSigner, - wrapWithPublishing, - sseResponse, - pumpWebSocket, - mergeAsyncIterables, - resolveTopicForCaller, - actorTopic, - collectionTopic, - communityTopic, - spaceTopic, - registerRealtimeRoutes, -} from "./core/realtime"; -export type { - DurableObjectId, - DurableObjectNamespace, - DurableObjectStub, - DurableObjectState, -} from "./core/realtime/durable-object"; - -// Labels -export type { - LabelsConfig, - LabelerSource, - LabelRow, - LabelerCursorRow, -} from "./core/labels/types"; -export type { HydratedLabel } from "./core/labels/hydrate"; -export { hydrateLabels } from "./core/labels/hydrate"; -export { selectAcceptedLabelers } from "./core/labels/select"; -export { applyLabels } from "./core/labels/apply"; -export type { IncomingLabel } from "./core/labels/apply"; -export { - runLabelIngestCycle, - runPersistentLabels, -} from "./core/labels/subscribe"; -export type { PersistentLabelsOptions } from "./core/labels/subscribe"; -export { resolveLabelerEndpoint } from "./core/labels/resolve"; - -// Community has moved to @atmo-dev/contrail-community. Import from there: -// import { createCommunityIntegration, CommunityAdapter, ProvisionOrchestrator, ... } from "@atmo-dev/contrail-community"; -// const app = createApp(db, config, { community: createCommunityIntegration(...) }); +export type { AppOptions, ContrailOptions } from "./contrail"; + +// Configuration, storage, identity, and dialects. +export * from "./core/types"; +export * from "./core/dialect"; +export * from "./core/identity"; +export * from "./core/client"; +export * from "./core/sinks/types"; + +// Ingestion and maintenance. +export * from "./core/jetstream"; +export * from "./core/persistent"; +export * from "./core/backfill"; +export * from "./core/refresh"; +export * from "./core/search"; +export * from "./core/constellation"; + +// Database. +export * from "./core/db/schema"; +export * from "./core/db/records"; +export * from "./core/db/meta"; +export * from "./core/db/optimize"; + +// HTTP and query pipeline. +export * from "./core/router"; +export * from "./core/router/notify"; +export * from "./core/router/profiles"; +export * from "./core/router/feed"; +export * from "./core/router/admin"; +export * from "./core/router/collection"; +export * from "./core/router/hydrate"; +export * from "./core/router/helpers"; + +// Labels. +export * from "./core/labels/types"; +export * from "./core/labels/hydrate"; +export * from "./core/labels/select"; +export * from "./core/labels/apply"; +export * from "./core/labels/subscribe"; +export * from "./core/labels/resolve"; +export * from "./core/labels/schema"; diff --git a/packages/contrail/src/server.ts b/packages/contrail/src/server.ts index fd1eb03..faf4d14 100644 --- a/packages/contrail/src/server.ts +++ b/packages/contrail/src/server.ts @@ -1,69 +1,33 @@ import { Client } from "@atcute/client"; -import type { Contrail } from "./contrail"; import type { Database } from "./core/types"; -import { markInProcess } from "./core/spaces/in-process"; +import type { Contrail } from "./contrail"; -/** - * Create an HTTP handler from a Contrail instance. - * - * Thin wrapper over `contrail.handler()` that accepts per-request DB overrides - * (useful on Cloudflare Workers where the DB binding lives on the request env). - * - * const handle = createHandler(contrail); - * // SvelteKit: export const GET = ({ request }) => handle(request); - * // Workers: return handle(request, env.DB, env.SPACES_DB); - * - * For most cases, prefer `contrail.handler()` directly when DBs are bound at - * construction time. - */ export interface CreateHandlerOptions { - /** Bundled lexicon JSONs — if provided, served at `/lexicons` so - * consumer apps can fetch + typegen against this deployment. Generate - * with `contrail-lex generate` and import from `lexicons/generated`. */ + /** Bundled Lexicon documents exposed by the HTTP app. */ lexicons?: object[]; } +/** Create a fetch handler, optionally accepting a per-request database binding. */ export function createHandler( contrail: Contrail, - options: CreateHandlerOptions = {} -): (request: Request, db?: Database, spacesDb?: Database) => Promise<Response> { - // When no per-request DBs are provided, build the app once and reuse it. + options: CreateHandlerOptions = {}, +): (request: Request, db?: Database) => Promise<Response> { let cached: ((request: Request) => Promise<Response>) | null = null; - return (request: Request, db?: Database, spacesDb?: Database) => { - if (db || spacesDb) { - return contrail.handler({ db, spacesDb, lexicons: options.lexicons })(request); + return (request: Request, db?: Database) => { + if (db) { + return contrail.handler({ db, lexicons: options.lexicons })(request); } cached ??= contrail.handler({ lexicons: options.lexicons }); return cached(request); }; } -/** - * Fully typed `@atcute/client` Client that routes XRPC calls through a - * contrail handler in-process — no HTTP roundtrip, no JWT minting. - * - * Pass `did` to act as that user; omit for anonymous calls (public endpoints - * only). Authentication is via the in-process WeakMap marker, which is - * unforgeable across network boundaries. - * - * // Same-process on Cloudflare Workers (per-request DB): - * const client = createServerClient( - * (req) => contrail.handler({ db: env.DB })(req), - * session.did, - * ); - * await client.post('tools.atmo.chat.space.putRecord', { input: { ... } }); - */ +/** Create an Atcute client that sends requests directly to a Contrail handler. */ export function createServerClient( - handle: (req: Request) => Promise<Response>, - did?: string + handle: (request: Request) => Promise<Response>, ): Client { return new Client({ - handler: async (pathname, init) => { - const req = new Request(new URL(pathname, "http://localhost"), init); - if (did !== undefined) markInProcess(req, did); - return handle(req); - }, + handler: async (pathname, init) => + handle(new Request(new URL(pathname, "http://localhost"), init)), }); } - -export { markInProcess }; diff --git a/packages/contrail/src/worker/index.ts b/packages/contrail/src/worker/index.ts index 26a412f..4c1e72d 100644 --- a/packages/contrail/src/worker/index.ts +++ b/packages/contrail/src/worker/index.ts @@ -21,9 +21,7 @@ import type { ContrailConfig, Database } from "../core/types.js"; export interface CreateWorkerOptions { /** D1 binding name in wrangler env. Default: `"DB"`. */ binding?: string; - /** Bundled lexicon JSON — if provided, exposes them at `/lexicons` - * so consumer apps can typegen against the deployed service. Generate - * with `contrail-lex generate` (emits `lexicons/generated/index.ts`). */ + /** Bundled Lexicon documents to expose for application type generation. */ lexicons?: object[]; /** Runs once per isolate, after schema init, before handling the first * request. Use for app-specific setup that needs a live DB handle. */ diff --git a/packages/contrail/src/workers/backfill.ts b/packages/contrail/src/workers/backfill.ts index 49edc70..9fe4dff 100644 --- a/packages/contrail/src/workers/backfill.ts +++ b/packages/contrail/src/workers/backfill.ts @@ -14,7 +14,10 @@ import { Contrail } from "../contrail.js"; import type { ContrailConfig, Database } from "../core/types.js"; import type { BackfillAllOptions } from "../core/backfill.js"; -import type { RefreshOptions, RefreshResult } from "../core/refresh.js"; +import type { + RefreshOptions, + RefreshResult, +} from "../core/refresh.js"; interface WranglerCommon { config: ContrailConfig; diff --git a/packages/contrail/tests/client.test.ts b/packages/contrail/tests/client.test.ts index 7aa2143..7713f0a 100644 --- a/packages/contrail/tests/client.test.ts +++ b/packages/contrail/tests/client.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { resolvePDS, getClient, getPDS, __resetPdsCachesForTests } from "../src/core/client"; +import { resolvePDS, getClient, getPDS, __resetPdsCachesForTests } from "../src/index"; import { type DidDocumentResolver } from "@atcute/identity-resolver"; import { createTestDbWithSchema } from "./helpers"; import type { Did } from "@atcute/lexicons"; diff --git a/packages/contrail/tests/contrail-entry.test.ts b/packages/contrail/tests/contrail-entry.test.ts deleted file mode 100644 index f447a00..0000000 --- a/packages/contrail/tests/contrail-entry.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** Sanity-checks for the unified Contrail entry point: - * - `contrail.app()` returns a Hono instance with routes wired - * - `contrail.handler()` is a fetch-compatible function - * - the shared pubsub is threaded through so subscribing via the app - * receives events published via ingest-path helpers on the same instance. */ - -import { describe, it, expect } from "vitest"; -import { Contrail } from "../src/contrail"; -import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { applyEvents } from "../src/core/db/records"; -import type { IngestEvent } from "../src/core/types"; - -const MSG_NSID = "app.event.message"; - -describe("Contrail.app() / Contrail.handler()", () => { - it("handler serves the health endpoint", async () => { - const db = createSqliteDatabase(":memory:"); - const contrail = new Contrail({ - namespace: "test.entry", - collections: { message: { collection: MSG_NSID } }, - db, - }); - await contrail.init(); - - const handle = contrail.handler(); - const res = await handle(new Request("http://localhost/health")); - expect(res.status).toBe(200); - expect(((await res.json()) as any).status).toBe("ok"); - }); - - it("shares its pubsub: events from applyEvents reach subscribers of contrail.app()", async () => { - const db = createSqliteDatabase(":memory:"); - const contrail = new Contrail({ - namespace: "test.entry", - collections: { message: { collection: MSG_NSID } }, - realtime: { ticketSecret: new Uint8Array(32).fill(1), keepaliveMs: 60_000 }, - db, - }); - await contrail.init(); - expect(contrail.pubsub).toBeTruthy(); - - const app = contrail.app(); - const ac = new AbortController(); - const res = await app.fetch( - new Request( - `http://localhost/xrpc/test.entry.realtime.subscribe?topic=${encodeURIComponent("collection:" + MSG_NSID)}`, - { signal: ac.signal } - ) - ); - expect(res.status).toBe(200); - const reader = res.body!.getReader(); - - // Use the same pubsub via the exposed getter — this is the promise - // of "one Contrail, one pubsub, one app". - const e: IngestEvent = { - uri: `at://did:plc:a/${MSG_NSID}/1`, - did: "did:plc:a", - time_us: 1, - collection: MSG_NSID, - operation: "create", - rkey: "1", - cid: "bafy", - record: JSON.stringify({ text: "shared" }), - indexed_at: Date.now() * 1000, - }; - await applyEvents(db, [e], contrail.config, { pubsub: contrail.pubsub ?? undefined }); - - // SSE frames separated by "\n\n". Skip any that don't carry a data: line - // (comments / keepalives). - const decoder = new TextDecoder(); - let buf = ""; - let payload: any = null; - while (!payload) { - const sep = buf.indexOf("\n\n"); - if (sep < 0) { - const { value, done } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - continue; - } - const frame = buf.slice(0, sep); - buf = buf.slice(sep + 2); - const dataLine = frame.split("\n").find((l) => l.startsWith("data:")); - if (dataLine) { - try { payload = JSON.parse(dataLine.slice(5).trim()); } catch {} - } - } - ac.abort(); - reader.cancel().catch(() => {}); - - expect(payload).toBeTruthy(); - expect(payload.kind).toBe("record.created"); - expect(payload.payload.did).toBe("did:plc:a"); - }); -}); diff --git a/packages/contrail/tests/dialect.test.ts b/packages/contrail/tests/dialect.test.ts index b8343b2..cc5a372 100644 --- a/packages/contrail/tests/dialect.test.ts +++ b/packages/contrail/tests/dialect.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { sqliteDialect, postgresDialect, buildFtsSchema, ftsQueryClause } from "../src/core/dialect"; +import { sqliteDialect, postgresDialect, buildFtsSchema, ftsQueryClause } from "../src/index"; import { createSqliteDatabase } from "../src/adapters/sqlite"; describe("sqliteDialect", () => { diff --git a/packages/contrail/tests/feed-prune-guardrail.test.ts b/packages/contrail/tests/feed-prune-guardrail.test.ts index a538902..2eaf7b7 100644 --- a/packages/contrail/tests/feed-prune-guardrail.test.ts +++ b/packages/contrail/tests/feed-prune-guardrail.test.ts @@ -5,15 +5,15 @@ import type { Statement, ResolvedContrailConfig, IngestEvent, -} from "../src/core/types"; -import { resolveConfig } from "../src/core/types"; -import { initSchema } from "../src/core/db/schema"; +} from "../src/index"; +import { resolveConfig } from "../src/index"; +import { initSchema } from "../src/index"; import { applyEvents, sweepFeedItems, pruneActorFeed, pruneFeedItems, -} from "../src/core/db/records"; +} from "../src/index"; // --------------------------------------------------------------------------- // Guardrail: no contrail-issued maintenance statement may be unbounded-O(n) diff --git a/packages/contrail/tests/feed-prune.test.ts b/packages/contrail/tests/feed-prune.test.ts index 7054372..70c314b 100644 --- a/packages/contrail/tests/feed-prune.test.ts +++ b/packages/contrail/tests/feed-prune.test.ts @@ -1,16 +1,16 @@ import { describe, it, expect, beforeEach } from "vitest"; import { createSqliteDatabase } from "../src/adapters/sqlite"; -import type { Database, ResolvedContrailConfig } from "../src/core/types"; -import { resolveConfig } from "../src/core/types"; -import { initSchema } from "../src/core/db/schema"; +import type { Database, ResolvedContrailConfig } from "../src/index"; +import { resolveConfig } from "../src/index"; +import { initSchema } from "../src/index"; import { pruneActorFeed, sweepFeedItems, pruneFeedItems, getFeedPruneCursor, saveFeedPruneCursor, -} from "../src/core/db/records"; -import { runGatedFeedPrune } from "../src/core/jetstream"; +} from "../src/index"; +import { runGatedFeedPrune } from "../src/index"; const EVENT = "community.lexicon.calendar.event"; const RSVP = "community.lexicon.calendar.rsvp"; diff --git a/packages/contrail/tests/get-record-handle.test.ts b/packages/contrail/tests/get-record-handle.test.ts index 8b3978d..1aac656 100644 --- a/packages/contrail/tests/get-record-handle.test.ts +++ b/packages/contrail/tests/get-record-handle.test.ts @@ -4,9 +4,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { Contrail } from "../src/contrail"; import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { applyEvents } from "../src/core/db/records"; -import { __resetPdsCachesForTests } from "../src/core/client"; -import type { Database, IngestEvent } from "../src/core/types"; +import { applyEvents } from "../src/index"; +import { __resetPdsCachesForTests } from "../src/index"; +import type { Database, IngestEvent } from "../src/index"; const COLL = "com.example.event"; const AUTHOR = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; diff --git a/packages/contrail/tests/helpers.test.ts b/packages/contrail/tests/helpers.test.ts index be66975..47f8c16 100644 --- a/packages/contrail/tests/helpers.test.ts +++ b/packages/contrail/tests/helpers.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { formatRecord, parseIntParam, fieldToParam } from "../src/core/router/helpers"; -import type { RecordRow } from "../src/core/types"; +import { formatRecord, parseIntParam, fieldToParam } from "../src/index"; +import type { RecordRow } from "../src/index"; describe("formatRecord", () => { it("parses JSON record string", () => { diff --git a/packages/contrail/tests/helpers.ts b/packages/contrail/tests/helpers.ts index e41c753..ea81dd7 100644 --- a/packages/contrail/tests/helpers.ts +++ b/packages/contrail/tests/helpers.ts @@ -1,8 +1,8 @@ import { createSqliteDatabase } from "../src/adapters/sqlite"; -import type { Database, IngestEvent, ResolvedContrailConfig } from "../src/core/types"; -import { resolveConfig } from "../src/core/types"; -import { initSchema } from "../src/core/db/schema"; -import { applyEvents as coreApplyEvents, type ExistingRecordInfo } from "../src/core/db/records"; +import type { Database, IngestEvent, ResolvedContrailConfig } from "../src/index"; +import { resolveConfig } from "../src/index"; +import { initSchema } from "../src/index"; +import { applyEvents as coreApplyEvents, type ExistingRecordInfo } from "../src/index"; export function createTestDb(): Database { return createSqliteDatabase(":memory:"); diff --git a/packages/contrail/tests/hydrate.test.ts b/packages/contrail/tests/hydrate.test.ts index eb174b5..fb41a1a 100644 --- a/packages/contrail/tests/hydrate.test.ts +++ b/packages/contrail/tests/hydrate.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest"; -import type { Database, RecordRow, RelationConfig, ReferenceConfig } from "../src/core/types"; -import { recordsTableName } from "../src/core/types"; -import { parseHydrateParams, resolveHydrates, resolveReferences } from "../src/core/router/hydrate"; +import type { Database, RecordRow, RelationConfig, ReferenceConfig } from "../src/index"; +import { recordsTableName } from "../src/index"; +import { parseHydrateParams, resolveHydrates, resolveReferences } from "../src/index"; import { createTestDbWithSchema, makeEvent, TEST_CONFIG } from "./helpers"; import { applyEvents } from "./helpers"; diff --git a/packages/contrail/tests/identity-config.test.ts b/packages/contrail/tests/identity-config.test.ts index aa5df42..b47a932 100644 --- a/packages/contrail/tests/identity-config.test.ts +++ b/packages/contrail/tests/identity-config.test.ts @@ -4,8 +4,8 @@ import { resolveIdentities, resolveActor, refreshStaleIdentities, -} from "../src/core/identity"; -import { __resetPdsCachesForTests } from "../src/core/client"; +} from "../src/index"; +import { __resetPdsCachesForTests } from "../src/index"; import { createTestDbWithSchema } from "./helpers"; import type { Did } from "@atcute/lexicons"; diff --git a/packages/contrail/tests/identity-handle.test.ts b/packages/contrail/tests/identity-handle.test.ts index 8d42f0c..5483903 100644 --- a/packages/contrail/tests/identity-handle.test.ts +++ b/packages/contrail/tests/identity-handle.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { getPDS, __resetPdsCachesForTests } from "../src/core/client"; -import { refreshStaleIdentities } from "../src/core/identity"; +import { getPDS, __resetPdsCachesForTests } from "../src/index"; +import { refreshStaleIdentities } from "../src/index"; import { createTestDbWithSchema } from "./helpers"; -import type { Database } from "../src/core/types"; +import type { Database } from "../src/index"; import type { Did } from "@atcute/lexicons"; // Root-cause coverage for PR #42: identities that end up with a PDS but no diff --git a/packages/contrail/tests/ingest-hang.test.ts b/packages/contrail/tests/ingest-hang.test.ts index 348bd62..44301e1 100644 --- a/packages/contrail/tests/ingest-hang.test.ts +++ b/packages/contrail/tests/ingest-hang.test.ts @@ -27,9 +27,9 @@ vi.mock("@atcute/jetstream", () => { return { JetstreamSubscription: MockJetstreamSubscription }; }); -import { ingestEvents } from "../src/core/jetstream"; -import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig } from "../src/core/types"; +import { ingestEvents } from "../src/index"; +import { resolveConfig } from "../src/index"; +import type { ContrailConfig } from "../src/index"; const silentLogger = { log() {}, warn() {}, error() {} }; diff --git a/packages/contrail/tests/jetstream-rollback-behavior.test.ts b/packages/contrail/tests/jetstream-rollback-behavior.test.ts index 74c07fa..ec4ea9d 100644 --- a/packages/contrail/tests/jetstream-rollback-behavior.test.ts +++ b/packages/contrail/tests/jetstream-rollback-behavior.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { JetstreamSubscription } from "@atcute/jetstream"; -import { jetstreamUrlOption } from "../src/core/types"; +import { jetstreamUrlOption } from "../src/index"; // This test does NOT mock @atcute. It drives the real JetstreamSubscription and // captures the actual cursor it would connect with, to PIN the assumption our diff --git a/packages/contrail/tests/jetstream-single-url.test.ts b/packages/contrail/tests/jetstream-single-url.test.ts index 3d707af..23cc167 100644 --- a/packages/contrail/tests/jetstream-single-url.test.ts +++ b/packages/contrail/tests/jetstream-single-url.test.ts @@ -23,9 +23,9 @@ vi.mock("@atcute/jetstream", () => { return { JetstreamSubscription: MockJetstreamSubscription }; }); -import { ingestEvents } from "../src/core/jetstream"; -import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig } from "../src/core/types"; +import { ingestEvents } from "../src/index"; +import { resolveConfig } from "../src/index"; +import type { ContrailConfig } from "../src/index"; const silentLogger = { log() {}, warn() {}, error() {} }; diff --git a/packages/contrail/tests/jetstream-url-option.test.ts b/packages/contrail/tests/jetstream-url-option.test.ts index 04cc2c0..296ae57 100644 --- a/packages/contrail/tests/jetstream-url-option.test.ts +++ b/packages/contrail/tests/jetstream-url-option.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { jetstreamUrlOption } from "../src/core/types"; +import { jetstreamUrlOption } from "../src/index"; // @atcute/jetstream treats a string url and an array url differently *by design*: // an array is a pool of interchangeable instances, so it seeds `#lastUsedUrl=''` diff --git a/packages/contrail/tests/labels-resolve.test.ts b/packages/contrail/tests/labels-resolve.test.ts index 80ca73d..c9c8247 100644 --- a/packages/contrail/tests/labels-resolve.test.ts +++ b/packages/contrail/tests/labels-resolve.test.ts @@ -8,7 +8,7 @@ import { import { resolveLabelerEndpoint, validateEndpointUrl, -} from "../src/core/labels/resolve"; +} from "../src/index"; describe("validateEndpointUrl additionalAllowedHosts", () => { it("rejects pds.dev.svc.cluster.local without override (HTTP + private hostname)", () => { diff --git a/packages/contrail/tests/labels-router.test.ts b/packages/contrail/tests/labels-router.test.ts index 86c0dc1..863b77c 100644 --- a/packages/contrail/tests/labels-router.test.ts +++ b/packages/contrail/tests/labels-router.test.ts @@ -6,9 +6,9 @@ import { describe, it, expect } from "vitest"; import { Contrail } from "../src/contrail"; import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { applyEvents } from "../src/core/db/records"; -import { applyLabels } from "../src/core/labels/apply"; -import type { IngestEvent } from "../src/core/types"; +import { applyEvents } from "../src/index"; +import { applyLabels } from "../src/index"; +import type { IngestEvent } from "../src/index"; const COLL = "com.example.event"; const SRC_A = "did:plc:labelerA"; diff --git a/packages/contrail/tests/labels.test.ts b/packages/contrail/tests/labels.test.ts index 899bb08..fc03726 100644 --- a/packages/contrail/tests/labels.test.ts +++ b/packages/contrail/tests/labels.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema } from "../src/core/db/schema"; -import { resolveConfig, type ContrailConfig } from "../src/core/types"; -import { applyLabels } from "../src/core/labels/apply"; -import { hydrateLabels } from "../src/core/labels/hydrate"; -import { selectAcceptedLabelers } from "../src/core/labels/select"; -import type { LabelsConfig } from "../src/core/labels/types"; +import { initSchema } from "../src/index"; +import { resolveConfig, type ContrailConfig } from "../src/index"; +import { applyLabels } from "../src/index"; +import { hydrateLabels } from "../src/index"; +import { selectAcceptedLabelers } from "../src/index"; +import type { LabelsConfig } from "../src/index"; const SRC_A = "did:plc:labelerA"; const SRC_B = "did:plc:labelerB"; diff --git a/packages/contrail/tests/lexicons-endpoint.test.ts b/packages/contrail/tests/lexicons-endpoint.test.ts index 47a4208..15106e7 100644 --- a/packages/contrail/tests/lexicons-endpoint.test.ts +++ b/packages/contrail/tests/lexicons-endpoint.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { createApp } from "../src/core/router"; +import { createApp } from "../src/index"; import { createTestDbWithSchema, TEST_CONFIG } from "./helpers"; describe("/xrpc/<ns>.lexicons endpoint", () => { diff --git a/packages/contrail/tests/maintenance-optimize.test.ts b/packages/contrail/tests/maintenance-optimize.test.ts index 0a1afe8..e9c3b32 100644 --- a/packages/contrail/tests/maintenance-optimize.test.ts +++ b/packages/contrail/tests/maintenance-optimize.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi } from "vitest"; import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema, optimizeDatabase, getMetaNumber } from "../src/core/db"; -import { maybeOptimize } from "../src/core/jetstream"; -import { resolveConfig } from "../src/core/types"; +import { initSchema, optimizeDatabase, getMetaNumber } from "../src/index"; +import { maybeOptimize } from "../src/index"; +import { resolveConfig } from "../src/index"; const BASE = { namespace: "com.example", diff --git a/packages/contrail/tests/network-overrides-appview.test.ts b/packages/contrail/tests/network-overrides-appview.test.ts index 1110a57..576c29e 100644 --- a/packages/contrail/tests/network-overrides-appview.test.ts +++ b/packages/contrail/tests/network-overrides-appview.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createApp } from "../src/core/router"; -import { runIngestCycle } from "../src/core/jetstream"; -import { __resetPdsCachesForTests } from "../src/core/client"; +import { createApp } from "../src/index"; +import { runIngestCycle } from "../src/index"; +import { __resetPdsCachesForTests } from "../src/index"; import { createTestDbWithSchema, TEST_CONFIG } from "./helpers"; -import type { ContrailConfig } from "../src/core/types"; +import type { ContrailConfig } from "../src/index"; // Mock the Jetstream subscription so `runIngestCycle` ingests one synthetic // commit without opening a real WebSocket. Everything else in the appview diff --git a/packages/contrail/tests/network-overrides.test.ts b/packages/contrail/tests/network-overrides.test.ts index 011ab76..1f5af5d 100644 --- a/packages/contrail/tests/network-overrides.test.ts +++ b/packages/contrail/tests/network-overrides.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import http from "node:http"; import type { AddressInfo } from "node:net"; -import { resolvePDS, getClient, __resetPdsCachesForTests } from "../src/core/client"; -import { refreshStaleIdentities } from "../src/core/identity"; +import { resolvePDS, getClient, __resetPdsCachesForTests } from "../src/index"; +import { refreshStaleIdentities } from "../src/index"; import { createTestDbWithSchema } from "./helpers"; -import type { ContrailConfig } from "../src/core/types"; +import type { ContrailConfig } from "../src/index"; import type { Did } from "@atcute/lexicons"; import { CompositeDidDocumentResolver, diff --git a/packages/contrail/tests/notify.test.ts b/packages/contrail/tests/notify.test.ts index 89faf69..671e04d 100644 --- a/packages/contrail/tests/notify.test.ts +++ b/packages/contrail/tests/notify.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; -import type { Database } from "../src/core/types"; -import { resolveConfig } from "../src/core/types"; +import type { Database } from "../src/index"; +import { resolveConfig } from "../src/index"; import { applyEvents, createTestDb, createTestDbWithSchema, makeEvent, TEST_CONFIG } from "./helpers"; -import { initSchema } from "../src/core/db/schema"; -import { parseAtUri } from "../src/core/router/notify"; -import { createApp } from "../src/core/router/index"; -import { queryRecords } from "../src/core/db/records"; +import { initSchema } from "../src/index"; +import { parseAtUri } from "../src/index"; +import { createApp } from "../src/index"; +import { queryRecords } from "../src/index"; import type { Hono } from "hono"; const NOTIFY_CONFIG = { ...TEST_CONFIG, notify: true }; diff --git a/packages/contrail/tests/persistent.test.ts b/packages/contrail/tests/persistent.test.ts index f8ef807..1691f1b 100644 --- a/packages/contrail/tests/persistent.test.ts +++ b/packages/contrail/tests/persistent.test.ts @@ -1,15 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { ContrailConfig, Database } from "../src/core/types"; -import { resolveConfig } from "../src/core/types"; +import type { ContrailConfig, Database } from "../src/index"; +import { resolveConfig } from "../src/index"; import { createTestDb, createTestDbWithSchema, TEST_CONFIG } from "./helpers"; -import { runPersistent } from "../src/core/persistent"; -import { getLastCursor, queryRecords } from "../src/core/db/records"; -import { initSchema } from "../src/core/db/schema"; +import { runPersistent } from "../src/index"; +import { getLastCursor, queryRecords } from "../src/index"; +import { initSchema } from "../src/index"; -// Identity helpers live in @atmo-dev/contrail-base post-split. Mock there. const applyIdentityEventMock = vi.fn().mockResolvedValue(undefined); -vi.mock("@atmo-dev/contrail-base", async (importOriginal) => { - const actual = await importOriginal<typeof import("@atmo-dev/contrail-base")>(); +vi.mock("../src/core/identity", async (importOriginal) => { + const actual = await importOriginal<typeof import("../src/core/identity")>(); return { ...actual, refreshStaleIdentities: vi.fn().mockResolvedValue(undefined), diff --git a/packages/contrail/tests/postgres-concurrent-init.test.ts b/packages/contrail/tests/postgres-concurrent-init.test.ts index 4545a0d..c24d355 100644 --- a/packages/contrail/tests/postgres-concurrent-init.test.ts +++ b/packages/contrail/tests/postgres-concurrent-init.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import pg from "pg"; import { createPostgresDatabase } from "../src/adapters/postgres"; -import { initSchema } from "../src/core/db/schema"; -import { resolveConfig } from "../src/core/types"; +import { initSchema } from "../src/index"; +import { resolveConfig } from "../src/index"; /** * Postgres-dialect concurrent-init race. diff --git a/packages/contrail/tests/postgres-e2e.test.ts b/packages/contrail/tests/postgres-e2e.test.ts index 49af95f..967a873 100644 --- a/packages/contrail/tests/postgres-e2e.test.ts +++ b/packages/contrail/tests/postgres-e2e.test.ts @@ -10,16 +10,16 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import pg from "pg"; import { createPostgresDatabase } from "../src/adapters/postgres"; -import { initSchema } from "../src/core/db/schema"; +import { initSchema } from "../src/index"; import { applyEvents, queryRecords, getLastCursor, saveCursor, -} from "../src/core/db/records"; -import { resolveConfig } from "../src/core/types"; -import type { Database } from "../src/core/types"; -import { resolveHydrates, resolveReferences } from "../src/core/router/hydrate"; +} from "../src/index"; +import { resolveConfig } from "../src/index"; +import type { Database } from "../src/index"; +import { resolveHydrates, resolveReferences } from "../src/index"; import { makeEvent } from "./helpers"; const TEST_CONFIG = resolveConfig({ diff --git a/packages/contrail/tests/postgres.test.ts b/packages/contrail/tests/postgres.test.ts index 5042e6a..cacfde1 100644 --- a/packages/contrail/tests/postgres.test.ts +++ b/packages/contrail/tests/postgres.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import pg from "pg"; import { createPostgresDatabase } from "../src/adapters/postgres"; -import { initSchema } from "../src/core/db/schema"; -import { applyEvents, queryRecords, getLastCursor, saveCursor } from "../src/core/db/records"; -import { resolveConfig } from "../src/core/types"; +import { initSchema } from "../src/index"; +import { applyEvents, queryRecords, getLastCursor, saveCursor } from "../src/index"; +import { resolveConfig } from "../src/index"; import { makeEvent } from "./helpers"; const TEST_CONFIG = resolveConfig({ diff --git a/packages/contrail/tests/realtime-durable-object.test.ts b/packages/contrail/tests/realtime-durable-object.test.ts deleted file mode 100644 index 3953e52..0000000 --- a/packages/contrail/tests/realtime-durable-object.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** Unit tests for the DO class and the DO client adapter. - * - * We don't run wrangler here — instead we hand-roll a tiny fake `DurableObjectState` - * and fake WebSocket pair so we can drive the class's public `fetch` method - * directly. That's enough to verify routing, fan-out, kick-on-remove, and the - * client-side SSE pull iterator. */ - -import { describe, it, expect } from "vitest"; -import { - DurableObjectPubSub, - RealtimePubSubDO, - type DurableObjectState, - type DurableObjectNamespace, - type DurableObjectStub, -} from "../src/core/realtime/durable-object"; -import type { RealtimeEvent } from "../src/core/realtime/types"; - -// ---- Fakes ----------------------------------------------------------------- - -class FakeWebSocket { - public readonly sent: string[] = []; - public closedCode: number | undefined; - private attachment: unknown = null; - serializeAttachment(a: unknown): void { - this.attachment = a; - } - deserializeAttachment(): unknown { - return this.attachment; - } - send(s: string): void { - this.sent.push(s); - } - close(code?: number): void { - this.closedCode = code; - } - // For the test harness — required so the class can upgrade an incoming request. - accept(): void {} -} - -class FakeState implements DurableObjectState { - private readonly sockets: FakeWebSocket[] = []; - acceptWebSocket(ws: FakeWebSocket): void { - this.sockets.push(ws); - } - getWebSockets(): FakeWebSocket[] { - return this.sockets; - } -} - -function mkEvent(overrides: Partial<RealtimeEvent> = {}): RealtimeEvent { - return { - topic: "space:at://x/y/z", - kind: "record.created", - payload: { - uri: "at://did:plc:x/c/r", - did: "did:plc:x", - collection: "c", - rkey: "r", - cid: null, - record: {}, - time_us: 1, - space: "at://x/y/z", - }, - ts: 1, - ...(overrides as any), - }; -} - -// ---- DO class tests -------------------------------------------------------- - -describe("RealtimePubSubDO — publish/fanout", () => { - it("fans out publishEvent to all accepted websockets", async () => { - const state = new FakeState(); - const doInstance = new RealtimePubSubDO(state); - - for (const did of ["did:plc:a", "did:plc:b"]) { - doInstance.acceptWebSocketSubscriber(new FakeWebSocket(), did); - } - - doInstance.publishEvent(mkEvent({ ts: 42 })); - - const sockets = state.getWebSockets(); - expect(sockets).toHaveLength(2); - for (const ws of sockets) { - expect(ws.sent).toHaveLength(1); - expect(JSON.parse(ws.sent[0]!).ts).toBe(42); - } - }); - - it("kicks WS whose attachment.did matches a member.removed event", async () => { - const state = new FakeState(); - const doInstance = new RealtimePubSubDO(state); - - doInstance.acceptWebSocketSubscriber(new FakeWebSocket(), "did:plc:kickme"); - doInstance.acceptWebSocketSubscriber(new FakeWebSocket(), "did:plc:stay"); - - doInstance.publishEvent({ - topic: "space:x", - kind: "member.removed", - payload: { space: "at://x/y/z", did: "did:plc:kickme" }, - ts: 1, - }); - - const [kicked, staying] = state.getWebSockets(); - expect(kicked!.closedCode).toBe(4003); - expect(staying!.closedCode).toBeUndefined(); - }); - - it("delivers SSE to pull-style subscribers and closes the stream on self-kick", async () => { - const state = new FakeState(); - const doInstance = new RealtimePubSubDO(state); - - const subRes = doInstance.openSseResponse("did:plc:self"); - expect(subRes.status).toBe(200); - const reader = subRes.body!.getReader(); - - const collect = async (target: number) => { - const decoder = new TextDecoder(); - const events: RealtimeEvent[] = []; - let buf = ""; - while (events.length < target) { - const { value, done } = await reader.read(); - if (value) buf += decoder.decode(value, { stream: true }); - if (done) break; - while (true) { - const sep = buf.indexOf("\n\n"); - if (sep < 0) break; - const frame = buf.slice(0, sep); - buf = buf.slice(sep + 2); - for (const line of frame.split("\n")) { - if (line.startsWith("data:")) { - try { events.push(JSON.parse(line.slice(5).trim())); } catch {} - } - } - } - } - return events; - }; - - // Publish a regular event, then a self-targeting member.removed. - doInstance.publishEvent(mkEvent({ ts: 7 })); - doInstance.publishEvent({ - topic: "space:x", - kind: "member.removed", - payload: { space: "at://x/y/z", did: "did:plc:self" }, - ts: 8, - }); - - const events = await collect(2); - expect(events.map((e) => e.kind)).toEqual(["record.created", "member.removed"]); - - // Stream should be closed after the self-kick. - const tail = await reader.read(); - expect(tail.done).toBe(true); - }); -}); - -// ---- Client adapter tests -------------------------------------------------- - -describe("DurableObjectPubSub — client adapter", () => { - it("routes publish to the correct DO by topic name", async () => { - const calls: Array<{ name: string; url: string; init?: RequestInit }> = []; - const stubFor = (name: string): DurableObjectStub => ({ - async fetch(input, init) { - const url = typeof input === "string" ? input : (input as URL | Request).toString(); - calls.push({ name, url, init }); - return new Response("{}", { status: 200 }); - }, - }); - const ns: DurableObjectNamespace = { - idFromName: (n: string) => ({ toString: () => n }), - get: (id) => stubFor(id.toString()), - }; - const ps = new DurableObjectPubSub(ns); - - await ps.publish(mkEvent({ topic: "space:a" })); - await ps.publish(mkEvent({ topic: "community:c1" })); - - expect(calls).toHaveLength(2); - expect(calls[0]!.name).toBe("space:a"); - expect(calls[0]!.url).toContain("/publish"); - expect(calls[1]!.name).toBe("community:c1"); - }); - - it("subscribes via SSE and pulls parsed events", async () => { - // Simulate a DO that streams two SSE events, then ends. - const stream = new ReadableStream<Uint8Array>({ - start(controller) { - const enc = new TextEncoder(); - controller.enqueue(enc.encode(": open\n\n")); - controller.enqueue( - enc.encode(`event: record.created\ndata: ${JSON.stringify(mkEvent({ ts: 1 }))}\n\n`) - ); - controller.enqueue( - enc.encode(`event: record.created\ndata: ${JSON.stringify(mkEvent({ ts: 2 }))}\n\n`) - ); - controller.close(); - }, - }); - const stubFor = (): DurableObjectStub => ({ - async fetch() { - return new Response(stream, { status: 200 }); - }, - }); - const ns: DurableObjectNamespace = { - idFromName: (n) => ({ toString: () => n }), - get: () => stubFor(), - }; - const ps = new DurableObjectPubSub(ns); - - const received: RealtimeEvent[] = []; - for await (const e of ps.subscribe("space:a")) { - received.push(e); - } - expect(received.map((e) => e.ts)).toEqual([1, 2]); - }); - - it("ends the pull iterator when return() is called", async () => { - let canceled = false; - const stream = new ReadableStream<Uint8Array>({ - start() { - /* never emits */ - }, - cancel() { - canceled = true; - }, - }); - const ns: DurableObjectNamespace = { - idFromName: (n) => ({ toString: () => n }), - get: () => ({ - async fetch() { - return new Response(stream, { status: 200 }); - }, - }), - }; - const ps = new DurableObjectPubSub(ns); - const iter = ps.subscribe("space:a")[Symbol.asyncIterator](); - // Kick off open() so reader exists, then explicitly return. - const next = iter.next(); - // Let the iterator attach its reader before returning. - await new Promise((r) => setTimeout(r, 0)); - await iter.return!(); - const r = await next; - expect(r.done).toBe(true); - expect(canceled).toBe(true); - }); -}); diff --git a/packages/contrail/tests/realtime-e2e.test.ts b/packages/contrail/tests/realtime-e2e.test.ts deleted file mode 100644 index 01d1767..0000000 --- a/packages/contrail/tests/realtime-e2e.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema } from "../src/core/db/schema"; -import { createApp } from "../src/core/router"; -import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig } from "../src/core/types"; -import type { RealtimeEvent } from "../src/core/realtime/types"; - -const ALICE = "did:plc:alice"; -const BOB = "did:plc:bob"; - -const REALTIME_SECRET = new Uint8Array(32).fill(9); - -const CONFIG: ContrailConfig = { - namespace: "test.rt", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: "did:web:test.example#svc", - }, - recordHost: {}, - }, - realtime: { - ticketSecret: REALTIME_SECRET, - keepaliveMs: 60_000, - }, -}; - -function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { issuer: did, audience: CONFIG.spaces!.authority!.serviceDid, lxm: undefined }); - await next(); - }; -} - -async function makeApp(): Promise<Hono> { - const db = createSqliteDatabase(":memory:"); - const resolved = resolveConfig(CONFIG); - await initSchema(db, resolved); - return createApp(db, resolved, { - spaces: { authMiddleware: fakeAuth() }, - }); -} - -function call( - app: Hono, - method: string, - path: string, - did: string | null, - body?: any, - extraHeaders: Record<string, string> = {} -): Promise<Response> { - const headers: Record<string, string> = { ...extraHeaders }; - if (did) headers["X-Test-Did"] = did; - if (body !== undefined) headers["Content-Type"] = "application/json"; - return app.fetch( - new Request(`http://localhost${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) - ); -} - -/** Open an SSE stream and return an async iterator over decoded events. */ -async function openSse( - app: Hono, - path: string, - did: string | null = null -): Promise<{ - res: Response; - events: AsyncIterator<RealtimeEvent>; - close: () => void; -}> { - const ac = new AbortController(); - const headers: Record<string, string> = {}; - if (did) headers["X-Test-Did"] = did; - const res = await app.fetch( - new Request(`http://localhost${path}`, { method: "GET", headers, signal: ac.signal }) - ); - if (!res.ok) { - throw new Error(`SSE open failed: ${res.status} ${await res.text()}`); - } - const reader = res.body!.getReader(); - const decoder = new TextDecoder(); - let buf = ""; - const iter: AsyncIterator<RealtimeEvent> = { - async next() { - // Read frames until we get an event with valid JSON data. - while (true) { - // Parse any complete frames already buffered. - while (true) { - const sep = buf.indexOf("\n\n"); - if (sep < 0) break; - const frame = buf.slice(0, sep); - buf = buf.slice(sep + 2); - const lines = frame.split("\n"); - let data: string | null = null; - for (const line of lines) { - if (line.startsWith(":")) continue; // comment / keepalive - if (line.startsWith("data:")) data = line.slice(5).trim(); - } - if (data) { - return { value: JSON.parse(data) as RealtimeEvent, done: false }; - } - } - const r = await reader.read(); - if (r.done) return { value: undefined, done: true }; - buf += decoder.decode(r.value, { stream: true }); - } - }, - async return() { - ac.abort(); - try { await reader.cancel(); } catch { /* */ } - return { value: undefined, done: true }; - }, - }; - return { res, events: iter, close: () => { ac.abort(); reader.cancel().catch(() => {}); } }; -} - -async function createSpace(app: Hono, owner: string, key?: string): Promise<string> { - const res = await call(app, "POST", "/xrpc/test.rt.space.createSpace", owner, { key }); - expect(res.status).toBe(200); - return ((await res.json()) as any).space.uri; -} - -async function mintTicket(app: Hono, did: string, topic: string): Promise<{ ticket: string; topics: string[] }> { - const res = await call(app, "POST", "/xrpc/test.rt.realtime.ticket", did, { topic }); - expect(res.status).toBe(200); - return (await res.json()) as any; -} - -describe("realtime e2e (in-memory pubsub, SSE transport)", () => { - let app: Hono; - - beforeAll(async () => { - app = await makeApp(); - }); - - it("ticket+SSE: owner subscribes and receives record.created", async () => { - const spaceUri = await createSpace(app, ALICE); - const { ticket } = await mintTicket(app, ALICE, `space:${spaceUri}`); - const { events, close } = await openSse( - app, - `/xrpc/test.rt.realtime.subscribe?ticket=${encodeURIComponent(ticket)}` - ); - // Publish a record. - const put = await call(app, "POST", "/xrpc/test.rt.space.putRecord", ALICE, { - spaceUri, - collection: "app.event.message", - record: { text: "hello" }, - }); - expect(put.status).toBe(200); - const next = await events.next(); - expect(next.done).toBe(false); - const event = next.value as RealtimeEvent & { kind: "record.created" }; - expect(event.kind).toBe("record.created"); - // Fat payload: subscribers can render the new record without a follow-up fetch. - expect(event.payload.record).toEqual({ text: "hello" }); - expect(event.payload.collection).toBe("app.event.message"); - expect(event.payload.did).toBe(ALICE); - close(); - }); - - it("non-member cannot mint a ticket for a space they don't belong to", async () => { - const spaceUri = await createSpace(app, ALICE, "members-only"); - const res = await call(app, "POST", "/xrpc/test.rt.realtime.ticket", BOB, { - topic: `space:${spaceUri}`, - }); - expect(res.status).toBe(403); - }); - - it("JWT path (bot): subscribe without a ticket using Authorization-equivalent", async () => { - const spaceUri = await createSpace(app, ALICE, "bot-space"); - const { events, close } = await openSse( - app, - `/xrpc/test.rt.realtime.subscribe?topic=${encodeURIComponent("space:" + spaceUri)}`, - ALICE - ); - await call(app, "POST", "/xrpc/test.rt.space.putRecord", ALICE, { - spaceUri, - collection: "app.event.message", - record: { text: "from-bot" }, - }); - const next = await events.next(); - expect((next.value as RealtimeEvent).kind).toBe("record.created"); - close(); - }); - - it("collection filter drops non-matching record events", async () => { - const spaceUri = await createSpace(app, ALICE, "filter-space"); - const { events, close } = await openSse( - app, - `/xrpc/test.rt.realtime.subscribe?topic=${encodeURIComponent("space:" + spaceUri)}&collection=app.event.message`, - ALICE - ); - // Write one matching record — should arrive. - await call(app, "POST", "/xrpc/test.rt.space.putRecord", ALICE, { - spaceUri, - collection: "app.event.message", - record: { text: "match" }, - }); - const ev = await events.next(); - expect((ev.value as RealtimeEvent).kind).toBe("record.created"); - expect((ev.value as RealtimeEvent & { kind: "record.created" }).payload.collection).toBe( - "app.event.message" - ); - close(); - }); - - it("member.removed kicks the subscriber's stream", async () => { - const spaceUri = await createSpace(app, ALICE, "kick-space"); - // Add Bob as a member. - await call(app, "POST", "/xrpc/test.rt.space.addMember", ALICE, { spaceUri, did: BOB }); - const { ticket } = await mintTicket(app, BOB, `space:${spaceUri}`); - const { events, close } = await openSse( - app, - `/xrpc/test.rt.realtime.subscribe?ticket=${encodeURIComponent(ticket)}` - ); - // Remove Bob. - const rm = await call(app, "POST", "/xrpc/test.rt.space.removeMember", ALICE, { - spaceUri, - did: BOB, - }); - expect(rm.status).toBe(200); - // The next event Bob receives should be his own member.removed, and then - // the stream should close. - const ev = await events.next(); - expect(ev.done).toBe(false); - expect((ev.value as RealtimeEvent).kind).toBe("member.removed"); - const done = await events.next(); - expect(done.done).toBe(true); - close(); - }); - - it("invalid/expired ticket is rejected", async () => { - const res = await app.fetch( - new Request(`http://localhost/xrpc/test.rt.realtime.subscribe?ticket=bad.blob`, { - method: "GET", - }) - ); - expect(res.status).toBe(401); - }); -}); diff --git a/packages/contrail/tests/realtime-foundations.test.ts b/packages/contrail/tests/realtime-foundations.test.ts deleted file mode 100644 index c6987d6..0000000 --- a/packages/contrail/tests/realtime-foundations.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { InMemoryPubSub } from "../src/core/realtime/in-memory"; -import { TicketSigner } from "../src/core/realtime/ticket"; -import type { RealtimeEvent } from "../src/core/realtime/types"; -import { spaceTopic } from "../src/core/realtime/types"; - -const SECRET = new Uint8Array(32).fill(3); - -function mk(topic: string, n: number): RealtimeEvent { - return { - topic, - kind: "record.created", - payload: { - uri: `at://did:plc:x/c/${n}`, - did: "did:plc:x", - collection: "c", - rkey: String(n), - cid: null, - record: {}, - time_us: n, - space: "at://x/y/z", - }, - ts: n, - }; -} - -describe("InMemoryPubSub", () => { - it("delivers events to a subscriber on the same topic", async () => { - const ps = new InMemoryPubSub(); - const topic = spaceTopic("at://x/y/z"); - const it = ps.subscribe(topic)[Symbol.asyncIterator](); - await ps.publish(mk(topic, 1)); - const r = await it.next(); - expect(r.done).toBe(false); - expect((r.value as RealtimeEvent).ts).toBe(1); - await it.return?.(); - }); - - it("fans out to multiple subscribers on the same topic", async () => { - const ps = new InMemoryPubSub(); - const topic = spaceTopic("at://x/y/z"); - const a = ps.subscribe(topic)[Symbol.asyncIterator](); - const b = ps.subscribe(topic)[Symbol.asyncIterator](); - await ps.publish(mk(topic, 42)); - const [ra, rb] = await Promise.all([a.next(), b.next()]); - expect((ra.value as RealtimeEvent).ts).toBe(42); - expect((rb.value as RealtimeEvent).ts).toBe(42); - await a.return?.(); - await b.return?.(); - }); - - it("does not cross topics", async () => { - const ps = new InMemoryPubSub(); - const topic = spaceTopic("at://x/y/z"); - const other = spaceTopic("at://x/y/other"); - const it = ps.subscribe(topic)[Symbol.asyncIterator](); - await ps.publish(mk(other, 99)); - await ps.publish(mk(topic, 1)); - const r = await it.next(); - expect((r.value as RealtimeEvent).ts).toBe(1); - await it.return?.(); - }); - - it("drops oldest when queue bound is reached", async () => { - const ps = new InMemoryPubSub({ queueBound: 2 }); - const topic = spaceTopic("at://x/y/z"); - const it = ps.subscribe(topic)[Symbol.asyncIterator](); - // Fill past the bound before the consumer starts reading. - for (let i = 1; i <= 5; i++) await ps.publish(mk(topic, i)); - const seen: number[] = []; - for (let i = 0; i < 2; i++) { - const r = await it.next(); - seen.push((r.value as RealtimeEvent).ts); - } - // Queue bound = 2: the last two events survive (3 was dropped before 4, etc.). - expect(seen).toEqual([4, 5]); - await it.return?.(); - }); - - it("cleans up subscribers on return()", async () => { - const ps = new InMemoryPubSub(); - const topic = spaceTopic("at://x/y/z"); - const it = ps.subscribe(topic)[Symbol.asyncIterator](); - expect(ps.subscriberCount(topic)).toBe(1); - await it.return?.(); - expect(ps.subscriberCount(topic)).toBe(0); - }); - - it("cleans up subscribers when the abort signal fires", async () => { - const ps = new InMemoryPubSub(); - const topic = spaceTopic("at://x/y/z"); - const ac = new AbortController(); - const iter = ps.subscribe(topic, ac.signal); - const it = iter[Symbol.asyncIterator](); - // Start a pending read, then abort. - const next = it.next(); - ac.abort(); - const r = await next; - expect(r.done).toBe(true); - expect(ps.subscriberCount(topic)).toBe(0); - }); -}); - -describe("TicketSigner", () => { - it("round-trips a signed ticket", async () => { - const signer = new TicketSigner(SECRET); - const t = await signer.sign({ topics: ["space:a", "space:b"], did: "did:plc:bob", ttlMs: 60_000 }); - const payload = await signer.verify(t); - expect(payload).not.toBeNull(); - expect(payload!.did).toBe("did:plc:bob"); - expect(payload!.topics).toEqual(["space:a", "space:b"]); - }); - - it("rejects a tampered payload", async () => { - const signer = new TicketSigner(SECRET); - const t = await signer.sign({ topics: ["space:a"], did: "did:plc:bob", ttlMs: 60_000 }); - // Replace payload half while keeping original sig — corrupt. - const tampered = t.replace(/^[^.]+/, "AAAA"); - expect(await signer.verify(tampered)).toBeNull(); - }); - - it("rejects signatures from a different secret", async () => { - const a = new TicketSigner(SECRET); - const b = new TicketSigner(new Uint8Array(32).fill(4)); - const t = await a.sign({ topics: ["space:a"], did: "did:plc:bob", ttlMs: 60_000 }); - expect(await b.verify(t)).toBeNull(); - }); - - it("rejects expired tickets", async () => { - const signer = new TicketSigner(SECRET); - const t = await signer.sign({ topics: ["space:a"], did: "did:plc:bob", ttlMs: -1 }); - expect(await signer.verify(t)).toBeNull(); - }); - - it("rejects malformed tickets", async () => { - const signer = new TicketSigner(SECRET); - expect(await signer.verify("")).toBeNull(); - expect(await signer.verify("nodot")).toBeNull(); - expect(await signer.verify("abc.def")).toBeNull(); - }); - - it("accepts secrets as base64 / hex strings", async () => { - const hex = "03".repeat(32); - const signer = new TicketSigner(hex); - const t = await signer.sign({ topics: ["x"], did: "d", ttlMs: 60_000 }); - expect(await signer.verify(t)).not.toBeNull(); - }); -}); diff --git a/packages/contrail/tests/realtime-public.test.ts b/packages/contrail/tests/realtime-public.test.ts deleted file mode 100644 index 34b13f3..0000000 --- a/packages/contrail/tests/realtime-public.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** Realtime — public topics (`collection:<nsid>`, `actor:<did>`): - * - Subscribe requires no auth. - * - Events are published by `applyEvents` (the jetstream/public-record path), - * not by the spaces adapter. Spaces can be entirely absent from config. */ - -import { describe, it, expect } from "vitest"; -import { Hono } from "hono"; -import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema } from "../src/core/db/schema"; -import { applyEvents } from "../src/core/db/records"; -import { createApp } from "../src/core/router"; -import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig, IngestEvent } from "../src/core/types"; -import { InMemoryPubSub } from "../src/core/realtime/in-memory"; -import type { RealtimeEvent } from "../src/core/realtime/types"; - -const REALTIME_SECRET = new Uint8Array(32).fill(9); - -const ALICE = "did:plc:alice"; -const MSG_NSID = "app.event.message"; - -function baseConfig(pubsub: InMemoryPubSub): ContrailConfig { - return { - namespace: "test.pub", - collections: { message: { collection: MSG_NSID } }, - realtime: { - ticketSecret: REALTIME_SECRET, - pubsub, - keepaliveMs: 60_000, - }, - }; -} - -/** Open SSE and return an async iterator over decoded events. */ -async function openSse(app: Hono, path: string): Promise<{ - events: AsyncIterator<RealtimeEvent>; - close: () => void; -}> { - const ac = new AbortController(); - const res = await app.fetch( - new Request(`http://localhost${path}`, { method: "GET", signal: ac.signal }) - ); - if (!res.ok) throw new Error(`SSE open failed: ${res.status} ${await res.text()}`); - const reader = res.body!.getReader(); - const decoder = new TextDecoder(); - let buf = ""; - const iter: AsyncIterator<RealtimeEvent> = { - async next() { - while (true) { - const sep = buf.indexOf("\n\n"); - if (sep >= 0) { - const frame = buf.slice(0, sep); - buf = buf.slice(sep + 2); - for (const line of frame.split("\n")) { - if (line.startsWith("data:")) { - try { return { done: false, value: JSON.parse(line.slice(5).trim()) }; } catch {} - } - } - continue; - } - const { value, done } = await reader.read(); - if (done) return { done: true, value: undefined as any }; - buf += decoder.decode(value, { stream: true }); - } - }, - }; - return { - events: iter, - close: () => { ac.abort(); reader.cancel().catch(() => {}); }, - }; -} - -describe("realtime — public topics work without spaces and without auth", () => { - it("subscribes to collection:<nsid> without auth and receives jetstream events", async () => { - const db = createSqliteDatabase(":memory:"); - const pubsub = new InMemoryPubSub(); - const config = resolveConfig(baseConfig(pubsub)); - await initSchema(db, config); - const app = createApp(db, config); - - const { events, close } = await openSse( - app, - `/xrpc/test.pub.realtime.subscribe?topic=${encodeURIComponent("collection:" + MSG_NSID)}` - ); - - // Simulate a public record landing via jetstream. - const e: IngestEvent = { - uri: `at://${ALICE}/${MSG_NSID}/abc`, - did: ALICE, - time_us: 1_700_000_000_000_000, - collection: MSG_NSID, - operation: "create", - rkey: "abc", - cid: "bafytest", - record: JSON.stringify({ text: "hi" }), - indexed_at: Date.now() * 1000, - }; - await applyEvents(db, [e], config, { pubsub }); - - const next = await events.next(); - expect(next.done).toBe(false); - const event = next.value as RealtimeEvent & { kind: "record.created" }; - expect(event.kind).toBe("record.created"); - expect(event.payload.uri).toBe(e.uri); - expect(event.payload.did).toBe(ALICE); - expect(event.payload.collection).toBe(MSG_NSID); - expect(event.payload.record).toEqual({ text: "hi" }); - expect((event.payload as any).space).toBeUndefined(); - close(); - }); - - it("actor:<did> is subscribable without auth and receives events for that DID", async () => { - const db = createSqliteDatabase(":memory:"); - const pubsub = new InMemoryPubSub(); - const config = resolveConfig(baseConfig(pubsub)); - await initSchema(db, config); - const app = createApp(db, config); - - const { events, close } = await openSse( - app, - `/xrpc/test.pub.realtime.subscribe?topic=${encodeURIComponent("actor:" + ALICE)}` - ); - - const e: IngestEvent = { - uri: `at://${ALICE}/${MSG_NSID}/xyz`, - did: ALICE, - time_us: 1_700_000_000_000_000, - collection: MSG_NSID, - operation: "create", - rkey: "xyz", - cid: "bafy", - record: JSON.stringify({ text: "from actor feed" }), - indexed_at: Date.now() * 1000, - }; - await applyEvents(db, [e], config, { pubsub }); - - const next = await events.next(); - expect(next.done).toBe(false); - const event = next.value as RealtimeEvent & { kind: "record.created" }; - expect(event.topic).toBe(`actor:${ALICE}`); - expect(event.payload.did).toBe(ALICE); - close(); - }); - - it("private topic without auth middleware returns 400 with a helpful error", async () => { - const db = createSqliteDatabase(":memory:"); - const pubsub = new InMemoryPubSub(); - const config = resolveConfig(baseConfig(pubsub)); - await initSchema(db, config); - const app = createApp(db, config); - - const res = await app.fetch( - new Request( - `http://localhost/xrpc/test.pub.realtime.subscribe?topic=${encodeURIComponent("space:at://x/y/z")}` - ) - ); - expect(res.status).toBe(400); - const body = (await res.json()) as any; - expect(body.reason).toBe("private-topic-without-auth"); - }); -}); diff --git a/packages/contrail/tests/records.test.ts b/packages/contrail/tests/records.test.ts index 893b1d0..16e86ac 100644 --- a/packages/contrail/tests/records.test.ts +++ b/packages/contrail/tests/records.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest"; -import type { Database } from "../src/core/types"; +import type { Database } from "../src/index"; import { applyEvents, createTestDbWithSchema, makeEvent, TEST_CONFIG } from "./helpers"; -import { queryRecords, getLastCursor, saveCursor } from "../src/core/db/records"; +import { queryRecords, getLastCursor, saveCursor } from "../src/index"; let db: Database; diff --git a/packages/contrail/tests/refresh.test.ts b/packages/contrail/tests/refresh.test.ts index 67f8e6f..3ee34fb 100644 --- a/packages/contrail/tests/refresh.test.ts +++ b/packages/contrail/tests/refresh.test.ts @@ -1,18 +1,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { refresh } from "../src/core/refresh"; +import { refresh } from "../src/index"; import { applyEvents, createTestDbWithSchema, makeEvent, TEST_CONFIG } from "./helpers"; -import type { Database } from "../src/core/types"; +import type { Database } from "../src/index"; // We mock the PDS client so refresh() can be exercised without network IO. // Each test sets the desired pageRecords for a given (did, collection) via // the `pages` map below. const pages = new Map<string, Array<{ uri: string; cid: string; value: object }>>(); -// `getClient` lives in @atmo-dev/contrail-base after the package split. -// `refresh` (now in contrail-appview) imports it via its own shim that -// ultimately resolves to base — so we mock the base export directly. -vi.mock("@atmo-dev/contrail-base", async (importOriginal) => { - const actual = await importOriginal<typeof import("@atmo-dev/contrail-base")>(); +vi.mock("../src/core/client", async (importOriginal) => { + const actual = await importOriginal<typeof import("../src/core/client")>(); return { ...actual, getClient: vi.fn(async (_did: string) => ({ diff --git a/packages/contrail/tests/resolve-collection-key.test.ts b/packages/contrail/tests/resolve-collection-key.test.ts index a29795a..1ec5a6c 100644 --- a/packages/contrail/tests/resolve-collection-key.test.ts +++ b/packages/contrail/tests/resolve-collection-key.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { resolveConfig, resolveCollectionKey } from "../src/core/types"; +import { resolveConfig, resolveCollectionKey } from "../src/index"; // resolveCollectionKey returns the key a collection's rows are stored under: // its short alias when one exists, otherwise the NSID itself when the config is diff --git a/packages/contrail/tests/schema-fingerprint-gate.test.ts b/packages/contrail/tests/schema-fingerprint-gate.test.ts index 5d845ef..bd31c61 100644 --- a/packages/contrail/tests/schema-fingerprint-gate.test.ts +++ b/packages/contrail/tests/schema-fingerprint-gate.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from "vitest"; import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema, getMeta } from "../src/core/db"; -import { resolveConfig } from "../src/core/types"; -import type { Database, Statement } from "../src/core/types"; +import { initSchema, getMeta } from "../src/index"; +import { resolveConfig } from "../src/index"; +import type { Database, Statement } from "../src/index"; // initSchema replays ~40 DDL statements serially on every call; on recycled // Workers isolates that's hundreds of ms of cold-start round-trips. The diff --git a/packages/contrail/tests/schema-idempotency.test.ts b/packages/contrail/tests/schema-idempotency.test.ts index 9151132..b11ffc8 100644 --- a/packages/contrail/tests/schema-idempotency.test.ts +++ b/packages/contrail/tests/schema-idempotency.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; -import { initSchema, addColumnIfNotExists } from "../src/core/db/schema"; -import type { Database } from "../src/core/types"; -import { resolveConfig } from "../src/core/types"; +import { initSchema, addColumnIfNotExists } from "../src/index"; +import type { Database } from "../src/index"; +import { resolveConfig } from "../src/index"; import { createTestDb, TEST_CONFIG } from "./helpers"; /** diff --git a/packages/contrail/tests/schema.test.ts b/packages/contrail/tests/schema.test.ts index 9b5aafa..f2d3dc6 100644 --- a/packages/contrail/tests/schema.test.ts +++ b/packages/contrail/tests/schema.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { initSchema } from "../src/core/db/schema"; +import { initSchema } from "../src/index"; import { createTestDb, TEST_CONFIG } from "./helpers"; describe("initSchema", () => { diff --git a/packages/contrail/tests/search.test.ts b/packages/contrail/tests/search.test.ts index 66afe9e..f7f645b 100644 --- a/packages/contrail/tests/search.test.ts +++ b/packages/contrail/tests/search.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; -import type { Database } from "../src/core/types"; -import { resolveConfig } from "../src/core/types"; +import type { Database } from "../src/index"; +import { resolveConfig } from "../src/index"; import { createTestDb, makeEvent } from "./helpers"; -import { initSchema } from "../src/core/db/schema"; -import { applyEvents, queryRecords } from "../src/core/db/records"; +import { initSchema } from "../src/index"; +import { applyEvents, queryRecords } from "../src/index"; // Detect FTS5 support at module level (node:sqlite doesn't include it) let hasFts = false; diff --git a/packages/contrail/tests/sinks.test.ts b/packages/contrail/tests/sinks.test.ts index 0e397c3..e9534b4 100644 --- a/packages/contrail/tests/sinks.test.ts +++ b/packages/contrail/tests/sinks.test.ts @@ -9,16 +9,16 @@ import { describe, it, expect } from "vitest"; import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema } from "../src/core/db/schema"; -import { applyEvents, queryRecords } from "../src/core/db/records"; -import { resolveConfig } from "../src/core/types"; +import { initSchema } from "../src/index"; +import { applyEvents, queryRecords } from "../src/index"; +import { resolveConfig } from "../src/index"; import type { ContrailConfig, IngestEvent, RecordEvent, Sink, SinkContext, -} from "../src/core/types"; +} from "../src/index"; const ALICE = "did:plc:alice"; const EVENT_NSID = "community.lexicon.calendar.event"; diff --git a/packages/contrail/tests/spaces-acl.test.ts b/packages/contrail/tests/spaces-acl.test.ts deleted file mode 100644 index a3ee3d5..0000000 --- a/packages/contrail/tests/spaces-acl.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { checkAccess } from "../src/core/spaces/acl"; -import type { SpaceMemberRow, SpaceRow } from "../src/core/spaces/types"; - -function mkSpace(overrides: Partial<SpaceRow> = {}): SpaceRow { - return { - uri: "ats://did:plc:alice/tools.atmo.event.space/s1", - ownerDid: "did:plc:alice", - type: "tools.atmo.event.space", - key: "s1", - serviceDid: "did:web:example.com#svc", - appPolicyRef: null, - appPolicy: null, - createdAt: 1, - deletedAt: null, - ...overrides, - }; -} - -function mkMember(did: string): SpaceMemberRow { - return { spaceUri: "x", did, addedAt: 1, addedBy: null }; -} - -describe("spaces acl", () => { - it("owner can read/write without a member row", () => { - const s = mkSpace(); - for (const op of ["read", "write"] as const) { - const r = checkAccess({ - op, - space: s, - callerDid: "did:plc:alice", - member: null, - }); - expect(r.allow).toBe(true); - } - }); - - it("owner can delete own record without a member row", () => { - const s = mkSpace(); - const r = checkAccess({ - op: "delete", - space: s, - callerDid: "did:plc:alice", - member: null, - targetAuthorDid: "did:plc:alice", - }); - expect(r.allow).toBe(true); - }); - - it("non-member cannot read", () => { - const s = mkSpace(); - const r = checkAccess({ - op: "read", - space: s, - callerDid: "did:plc:bob", - member: null, - }); - expect(r.allow).toBe(false); - expect((r as any).reason).toBe("not-member"); - }); - - it("member can read and write (no perm tiering)", () => { - const s = mkSpace(); - for (const op of ["read", "write"] as const) { - const r = checkAccess({ - op, - space: s, - callerDid: "did:plc:bob", - member: mkMember("did:plc:bob"), - }); - expect(r.allow).toBe(true); - } - }); - - it("non-member cannot write", () => { - const s = mkSpace(); - const r = checkAccess({ - op: "write", - space: s, - callerDid: "did:plc:bob", - member: null, - }); - expect(r.allow).toBe(false); - expect((r as any).reason).toBe("not-member"); - }); - - it("delete own: member can delete own record", () => { - const s = mkSpace(); - const r = checkAccess({ - op: "delete", - space: s, - callerDid: "did:plc:bob", - member: mkMember("did:plc:bob"), - targetAuthorDid: "did:plc:bob", - }); - expect(r.allow).toBe(true); - }); - - it("delete other's: member cannot delete someone else's record", () => { - const s = mkSpace(); - const r = checkAccess({ - op: "delete", - space: s, - callerDid: "did:plc:bob", - member: mkMember("did:plc:bob"), - targetAuthorDid: "did:plc:charlie", - }); - expect(r.allow).toBe(false); - expect((r as any).reason).toBe("not-own-record"); - }); - - it("owner cannot delete someone else's record (no bypass)", () => { - const s = mkSpace(); - const r = checkAccess({ - op: "delete", - space: s, - callerDid: "did:plc:alice", - member: null, - targetAuthorDid: "did:plc:bob", - }); - expect(r.allow).toBe(false); - expect((r as any).reason).toBe("not-own-record"); - }); - - it("delete by non-member: denied as not-member, not not-own-record", () => { - const s = mkSpace(); - const r = checkAccess({ - op: "delete", - space: s, - callerDid: "did:plc:bob", - member: null, - targetAuthorDid: "did:plc:bob", - }); - expect(r.allow).toBe(false); - expect((r as any).reason).toBe("not-member"); - }); - - it("app policy: allow-mode with apps[] denylists those apps", () => { - const s = mkSpace({ appPolicy: { mode: "allow", apps: ["blocked.app"] } }); - const ok = checkAccess({ - op: "read", - space: s, - callerDid: "did:plc:alice", - member: null, - clientId: "fine.app", - }); - expect(ok.allow).toBe(true); - - const blocked = checkAccess({ - op: "read", - space: s, - callerDid: "did:plc:alice", - member: null, - clientId: "blocked.app", - }); - expect(blocked.allow).toBe(false); - expect((blocked as any).reason).toBe("app-not-allowed"); - }); - - it("app policy: deny-mode with apps[] allowlists those apps", () => { - const s = mkSpace({ appPolicy: { mode: "deny", apps: ["trusted.app"] } }); - const ok = checkAccess({ - op: "read", - space: s, - callerDid: "did:plc:alice", - member: null, - clientId: "trusted.app", - }); - expect(ok.allow).toBe(true); - - const blocked = checkAccess({ - op: "read", - space: s, - callerDid: "did:plc:alice", - member: null, - clientId: "anon.app", - }); - expect(blocked.allow).toBe(false); - expect((blocked as any).reason).toBe("app-not-allowed"); - }); -}); diff --git a/packages/contrail/tests/spaces-auth.test.ts b/packages/contrail/tests/spaces-auth.test.ts deleted file mode 100644 index 805c844..0000000 --- a/packages/contrail/tests/spaces-auth.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - PlcDidDocumentResolver, - CompositeDidDocumentResolver, - WebDidDocumentResolver, -} from "@atcute/identity-resolver"; -import { buildVerifier } from "../src/core/spaces/auth"; -import type { AuthorityConfig } from "../src/core/spaces/types"; - -const CUSTOM_PLC = "http://custom-plc.test"; - -function makeAuthority(overrides: Partial<AuthorityConfig> = {}): AuthorityConfig { - return { - type: "tools.atmo.event.space", - serviceDid: "did:web:authority.test", - ...overrides, - } as AuthorityConfig; -} - -// ServiceJwtVerifier from @atcute/xrpc-server@0.1.12 exposes the resolver as -// the public instance field `didDocResolver` (verified against -// node_modules/.../auth/jwt-verifier.d.ts). The plan's hint at `.resolver` was -// a guess — we use the real field here so the test verifies the resolver -// actually wired into the verifier instance. -describe("buildVerifier resolver precedence", () => { - it("uses AuthorityConfig.resolver when provided (most-specific wins)", () => { - const specific = new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver({ apiUrl: "http://specific.test" }), - web: new WebDidDocumentResolver(), - }, - }); - const network = new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver({ apiUrl: CUSTOM_PLC }), - web: new WebDidDocumentResolver(), - }, - }); - const verifier = buildVerifier(makeAuthority({ resolver: specific }), { - resolver: network, - }); - expect(verifier.didDocResolver).toBe(specific); - }); - - it("falls back to networkOverrides.resolver when authority resolver is absent", () => { - const network = new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver({ apiUrl: CUSTOM_PLC }), - web: new WebDidDocumentResolver(), - }, - }); - const verifier = buildVerifier(makeAuthority(), { resolver: network }); - expect(verifier.didDocResolver).toBe(network); - }); - - it("falls back to default composite when both are absent", () => { - const verifier = buildVerifier(makeAuthority(), {}); - expect(verifier.didDocResolver).toBeInstanceOf(CompositeDidDocumentResolver); - }); - - it("treats omitted second arg the same as empty networkOverrides (backward-compat)", () => { - const verifier = buildVerifier(makeAuthority()); - expect(verifier.didDocResolver).toBeInstanceOf(CompositeDidDocumentResolver); - }); -}); diff --git a/packages/contrail/tests/spaces-binding.test.ts b/packages/contrail/tests/spaces-binding.test.ts deleted file mode 100644 index d121df4..0000000 --- a/packages/contrail/tests/spaces-binding.test.ts +++ /dev/null @@ -1,501 +0,0 @@ -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema } from "../src/core/db/schema"; -import { createApp } from "../src/core/router"; -import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig } from "../src/core/types"; -import { - createLocalBindingResolver, - createOwnerSelfBindingResolver, - createCompositeBindingResolver, - createPdsBindingResolver, - createDidDocBindingResolver, - createLocalKeyResolver, - createDidDocKeyResolver, - createCompositeKeyResolver, -} from "../src/core/spaces/binding"; -import { - generateAuthoritySigningKey, - issueCredential, - createBindingCredentialVerifier, -} from "../src/core/spaces/credentials"; -import type { CredentialKeyMaterial } from "../src/core/spaces/credentials"; - -const ALICE = "did:plc:alice"; -const SERVICE_DID = "did:web:test.example#svc"; - -let SIGNING: CredentialKeyMaterial; - -beforeAll(async () => { - SIGNING = await generateAuthoritySigningKey(); -}); - -const SPACE_URI = "ats://did:plc:alice/com.example.event.space/main"; - -describe("BindingResolver — basic resolvers", () => { - it("Local always returns the configured DID", async () => { - const r = createLocalBindingResolver({ authorityDid: SERVICE_DID }); - expect(await r.resolveAuthority(SPACE_URI)).toBe(SERVICE_DID); - expect(await r.resolveAuthority("ats://did:plc:bob/x/y")).toBe(SERVICE_DID); - }); - - it("OwnerSelf parses the owner from the URI", async () => { - const r = createOwnerSelfBindingResolver(); - expect(await r.resolveAuthority(SPACE_URI)).toBe(ALICE); - expect(await r.resolveAuthority("not a space uri")).toBeNull(); - }); - - it("Composite returns the first non-null result", async () => { - const r = createCompositeBindingResolver([ - { resolveAuthority: async () => null }, - { resolveAuthority: async () => "did:web:second" }, - { resolveAuthority: async () => "did:web:third" }, - ]); - expect(await r.resolveAuthority(SPACE_URI)).toBe("did:web:second"); - }); - - it("Composite returns null if every resolver returns null", async () => { - const r = createCompositeBindingResolver([ - { resolveAuthority: async () => null }, - { resolveAuthority: async () => null }, - ]); - expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); - }); -}); - -describe("BindingResolver — PDS record", () => { - function mockResolver(opts: { - pdsEndpoint?: string; - fail?: boolean; - }): any { - return { - resolve: async (did: string) => { - if (opts.fail) throw new Error("nope"); - return { - id: did, - service: opts.pdsEndpoint - ? [ - { - id: "#atproto_pds", - type: "AtprotoPersonalDataServer", - serviceEndpoint: opts.pdsEndpoint, - }, - ] - : [], - }; - }, - }; - } - - function mockFetch(map: Map<string, any>): typeof fetch { - return (async (url: string) => { - const u = String(url); - const found = [...map.entries()].find(([k]) => u.startsWith(k)); - if (!found) return new Response("not found", { status: 404 }); - return new Response(JSON.stringify(found[1]), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }) as typeof fetch; - } - - it("reads `authority` from a declaration record", async () => { - const fetch = mockFetch( - new Map([ - [ - "https://pds.test/xrpc/com.atproto.repo.getRecord", - { - uri: "at://did:plc:alice/com.example.event.space/main", - value: { - $type: "com.example.event.space", - authority: "did:web:custom-authority.example", - recordHost: "did:web:host.example", - createdAt: "2026-04-30T00:00:00Z", - }, - }, - ], - ]) - ); - const r = createPdsBindingResolver({ - resolver: mockResolver({ pdsEndpoint: "https://pds.test" }), - fetch, - }); - expect(await r.resolveAuthority(SPACE_URI)).toBe("did:web:custom-authority.example"); - }); - - it("returns null when the PDS record is missing", async () => { - const fetch = mockFetch(new Map()); // 404 for everything - const r = createPdsBindingResolver({ - resolver: mockResolver({ pdsEndpoint: "https://pds.test" }), - fetch, - }); - expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); - }); - - it("returns null when the record has no authority field", async () => { - const fetch = mockFetch( - new Map([ - [ - "https://pds.test/xrpc/com.atproto.repo.getRecord", - { value: { $type: "x", createdAt: "..." } }, - ], - ]) - ); - const r = createPdsBindingResolver({ - resolver: mockResolver({ pdsEndpoint: "https://pds.test" }), - fetch, - }); - expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); - }); - - it("returns null when the owner DID doc has no PDS endpoint", async () => { - const fetch = mockFetch(new Map()); - const r = createPdsBindingResolver({ - resolver: mockResolver({}), - fetch, - }); - expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); - }); - - it("returns null when the record's $type doesn't match the URI's type", async () => { - const fetch = mockFetch( - new Map([ - [ - "https://pds.test/xrpc/com.atproto.repo.getRecord", - { - value: { - $type: "com.attacker.fake.type", - authority: "did:web:authority.example", - createdAt: "2026-04-30T00:00:00Z", - }, - }, - ], - ]) - ); - const r = createPdsBindingResolver({ - resolver: mockResolver({ pdsEndpoint: "https://pds.test" }), - fetch, - }); - expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); - }); - - it("returns null when createdAt is missing or non-string", async () => { - const fetch = mockFetch( - new Map([ - [ - "https://pds.test/xrpc/com.atproto.repo.getRecord", - { - value: { - $type: "com.example.event.space", - authority: "did:web:authority.example", - // createdAt deliberately omitted - }, - }, - ], - ]) - ); - const r = createPdsBindingResolver({ - resolver: mockResolver({ pdsEndpoint: "https://pds.test" }), - fetch, - }); - expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); - }); - - it("returns null when authority isn't a well-formed DID", async () => { - const fetch = mockFetch( - new Map([ - [ - "https://pds.test/xrpc/com.atproto.repo.getRecord", - { - value: { - $type: "com.example.event.space", - authority: "did:fake!!://garbage", - createdAt: "2026-04-30T00:00:00Z", - }, - }, - ], - ]) - ); - const r = createPdsBindingResolver({ - resolver: mockResolver({ pdsEndpoint: "https://pds.test" }), - fetch, - }); - expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); - }); -}); - -describe("BindingResolver — DID-doc service entry", () => { - function mockResolver(serviceDid: string | null): any { - return { - resolve: async (did: string) => ({ - id: did, - service: serviceDid - ? [ - { - id: "#atproto_space_authority", - type: "AtprotoSpaceAuthority", - serviceEndpoint: serviceDid, - }, - ] - : [], - }), - }; - } - - it("reads the #atproto_space_authority service entry", async () => { - const r = createDidDocBindingResolver({ - resolver: mockResolver("did:web:authority.example"), - }); - expect(await r.resolveAuthority(SPACE_URI)).toBe("did:web:authority.example"); - }); - - it("returns null when the service entry is absent", async () => { - const r = createDidDocBindingResolver({ resolver: mockResolver(null) }); - expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); - }); - - it("rejects URL-shaped service endpoints (must be a DID)", async () => { - const r = createDidDocBindingResolver({ - resolver: mockResolver("https://authority.example.com"), - }); - expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); - }); -}); - -describe("KeyResolver", () => { - it("Local matches by DID", async () => { - const r = createLocalKeyResolver({ - authorityDid: SERVICE_DID, - publicKey: SIGNING.publicKey, - }); - expect(await r.resolveKey(SERVICE_DID, undefined)).toEqual(SIGNING.publicKey); - expect(await r.resolveKey("did:web:other", undefined)).toBeNull(); - }); - - it("DidDoc finds the verification method matching kid", async () => { - const otherKey = await generateAuthoritySigningKey(); - const resolver = { - resolve: async (did: string) => ({ - id: did, - verificationMethod: [ - { - id: `${did}#atproto_space_authority`, - type: "JsonWebKey2020", - controller: did, - publicKeyJwk: SIGNING.publicKey, - }, - { - id: `${did}#another-key`, - type: "JsonWebKey2020", - controller: did, - publicKeyJwk: otherKey.publicKey, - }, - ], - }), - }; - const r = createDidDocKeyResolver({ resolver: resolver as any }); - const key = await r.resolveKey( - "did:web:authority.example", - "did:web:authority.example#atproto_space_authority" - ); - expect(key).toEqual(SIGNING.publicKey); - }); - - it("DidDoc returns the second key when kid points there", async () => { - const otherKey = await generateAuthoritySigningKey(); - const resolver = { - resolve: async (did: string) => ({ - id: did, - verificationMethod: [ - { - id: `${did}#atproto_space_authority`, - type: "JsonWebKey2020", - controller: did, - publicKeyJwk: SIGNING.publicKey, - }, - { - id: `${did}#another-key`, - type: "JsonWebKey2020", - controller: did, - publicKeyJwk: otherKey.publicKey, - }, - ], - }), - }; - const r = createDidDocKeyResolver({ resolver: resolver as any }); - const key = await r.resolveKey( - "did:web:authority.example", - "did:web:authority.example#another-key" - ); - expect(key).toEqual(otherKey.publicKey); - }); - - it("Composite walks resolvers in order", async () => { - const fallback = await generateAuthoritySigningKey(); - const r = createCompositeKeyResolver([ - { resolveKey: async () => null }, - { resolveKey: async () => fallback.publicKey }, - ]); - expect(await r.resolveKey("did:any", undefined)).toEqual(fallback.publicKey); - }); -}); - -describe("createBindingCredentialVerifier — composes binding + key resolvers", () => { - it("verifies a credential whose iss matches the binding-resolved authority", async () => { - const verifier = createBindingCredentialVerifier({ - bindings: createLocalBindingResolver({ authorityDid: SERVICE_DID }), - keys: createLocalKeyResolver({ - authorityDid: SERVICE_DID, - publicKey: SIGNING.publicKey, - }), - }); - const { credential } = await issueCredential( - { iss: SERVICE_DID, sub: ALICE, space: SPACE_URI, scope: "rw", ttlMs: 60_000 }, - SIGNING - ); - const result = await verifier.verify(credential); - expect(result.ok).toBe(true); - }); - - it("rejects when credential iss disagrees with the binding", async () => { - const verifier = createBindingCredentialVerifier({ - bindings: createLocalBindingResolver({ authorityDid: SERVICE_DID }), - keys: createLocalKeyResolver({ - authorityDid: SERVICE_DID, - publicKey: SIGNING.publicKey, - }), - }); - const { credential } = await issueCredential( - { - iss: "did:web:imposter.example", - sub: ALICE, - space: SPACE_URI, - scope: "rw", - ttlMs: 60_000, - }, - SIGNING - ); - const result = await verifier.verify(credential); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.reason).toBe("unknown-issuer"); - }); - - it("rejects when no resolver knows the authority", async () => { - const verifier = createBindingCredentialVerifier({ - bindings: { resolveAuthority: async () => null }, - keys: { resolveKey: async () => null }, - }); - const { credential } = await issueCredential( - { iss: SERVICE_DID, sub: ALICE, space: SPACE_URI, scope: "rw", ttlMs: 60_000 }, - SIGNING - ); - const result = await verifier.verify(credential); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.reason).toBe("unknown-issuer"); - }); -}); - -describe("end-to-end — record host accepts credential from external authority", () => { - function makeConfig(): ContrailConfig { - return { - namespace: "test.binding", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: SERVICE_DID, - // No `signing` — this deployment is record-host only, accepting - // credentials issued by an external authority. - }, - recordHost: {}, - }, - }; - } - - function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { - issuer: did, - audience: SERVICE_DID, - lxm: undefined, - clientId: c.req.header("X-Test-App") ?? undefined, - }); - await next(); - }; - } - - it("verifies a credential issued by a separate authority via injected verifier", async () => { - // Set up: the record-host's verifier knows about an external authority - // (did:web:external-authority.example) and where to find its public key. - // No PDS / DID-doc fetches — the verifier is configured directly. - const externalAuthority = "did:web:external-authority.example"; - const externalKey = SIGNING; // simulate operator-provided key material - - const verifier = createBindingCredentialVerifier({ - bindings: createLocalBindingResolver({ authorityDid: externalAuthority }), - keys: createLocalKeyResolver({ - authorityDid: externalAuthority, - publicKey: externalKey.publicKey, - }), - }); - - const db = createSqliteDatabase(":memory:"); - const cfg = makeConfig(); - const resolved = resolveConfig(cfg); - await initSchema(db, resolved); - const app = createApp(db, resolved, { - spaces: { - authMiddleware: fakeAuth(), - credentialVerifier: verifier, - }, - }); - - // Create a space directly in the local authority's tables. (In a real - // split deployment, the authority would do this; the record host would - // just enroll. Phase 5 introduces enrollment — for now, we use the - // local authority routes as a stand-in.) - const create = await app.fetch( - new Request(`http://localhost/xrpc/test.binding.space.createSpace`, { - method: "POST", - headers: { "X-Test-Did": ALICE, "Content-Type": "application/json" }, - body: "{}", - }) - ); - expect(create.status).toBe(200); - const uri = ((await create.json()) as any).space.uri; - - // External authority signs a credential. - const { credential } = await issueCredential( - { - iss: externalAuthority, - sub: ALICE, - space: uri, - scope: "rw", - ttlMs: 60_000, - }, - externalKey - ); - - // Record host accepts it on putRecord with no service-auth JWT. - const put = await app.fetch( - new Request(`http://localhost/xrpc/test.binding.space.putRecord`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Space-Credential": credential, - }, - body: JSON.stringify({ - spaceUri: uri, - collection: "app.event.message", - record: { $type: "app.event.message", text: "hello" }, - }), - }) - ); - expect(put.status).toBe(200); - expect(((await put.json()) as any).authorDid).toBe(ALICE); - }); -}); diff --git a/packages/contrail/tests/spaces-blobs.test.ts b/packages/contrail/tests/spaces-blobs.test.ts deleted file mode 100644 index f4c4dee..0000000 --- a/packages/contrail/tests/spaces-blobs.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema } from "../src/core/db/schema"; -import { createApp } from "../src/core/router"; -import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig } from "../src/core/types"; -import { MemoryBlobAdapter } from "../src/core/spaces/blob-adapter"; -import { HostedAdapter } from "../src/core/spaces/adapter"; -import { gcOrphanBlobs } from "../src/core/spaces/blob-gc"; - -const ALICE = "did:plc:alice"; -const BOB = "did:plc:bob"; -const CHARLIE = "did:plc:charlie"; - -function makeConfig(blobs: MemoryBlobAdapter, maxSize = 2 * 1024 * 1024): ContrailConfig { - return { - namespace: "test.blobs", - collections: { - photo: { collection: "app.event.photo" }, - }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: "did:web:test.example#svc", - }, - recordHost: { - blobs: { adapter: blobs, maxSize }, - }, - }, - }; -} - -function fakeAuth(aud: string): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { - issuer: did, - audience: aud, - lxm: undefined, - clientId: c.req.header("X-Test-App") ?? undefined, - }); - await next(); - }; -} - -async function makeApp( - blobs: MemoryBlobAdapter, - maxSize = 2 * 1024 * 1024 -): Promise<{ app: Hono; db: any; config: ReturnType<typeof resolveConfig> }> { - const db = createSqliteDatabase(":memory:"); - const cfg = makeConfig(blobs, maxSize); - const resolved = resolveConfig(cfg); - await initSchema(db, resolved); - const app = createApp(db, resolved, { - spaces: { authMiddleware: fakeAuth(cfg.spaces!.authority!.serviceDid) }, - }); - return { app, db, config: resolved }; -} - -function call( - app: Hono, - method: string, - path: string, - did: string, - body?: BodyInit, - contentType?: string -): Promise<Response> { - const headers: Record<string, string> = { "X-Test-Did": did }; - if (contentType) headers["Content-Type"] = contentType; - return app.fetch( - new Request(`http://localhost${path}`, { - method, - headers, - body, - }) - ); -} - -async function callJson( - app: Hono, - method: string, - path: string, - did: string, - body?: any -): Promise<Response> { - return call( - app, - method, - path, - did, - body === undefined ? undefined : JSON.stringify(body), - body === undefined ? undefined : "application/json" - ); -} - -async function createSpace(app: Hono, owner: string, key: string): Promise<string> { - const res = await callJson(app, "POST", "/xrpc/test.blobs.space.createSpace", owner, { key }); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - return body.space.uri; -} - -describe("spaces blobs", () => { - let app: Hono; - let blobs: MemoryBlobAdapter; - let spaceUri: string; - - beforeAll(async () => { - blobs = new MemoryBlobAdapter(); - const out = await makeApp(blobs); - app = out.app; - spaceUri = await createSpace(app, ALICE, "album"); - }); - - it("owner uploads a blob and gets back a valid BlobRef", async () => { - const bytes = new TextEncoder().encode("hello world"); - const res = await call( - app, - "POST", - `/xrpc/test.blobs.space.uploadBlob?spaceUri=${encodeURIComponent(spaceUri)}`, - ALICE, - bytes, - "image/png" - ); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - expect(body.blob.$type).toBe("blob"); - expect(body.blob.mimeType).toBe("image/png"); - expect(body.blob.size).toBe(bytes.byteLength); - expect(typeof body.blob.ref.$link).toBe("string"); - expect(body.blob.ref.$link.startsWith("b")).toBe(true); - }); - - it("non-member cannot upload", async () => { - const bytes = new TextEncoder().encode("intruder"); - const res = await call( - app, - "POST", - `/xrpc/test.blobs.space.uploadBlob?spaceUri=${encodeURIComponent(spaceUri)}`, - BOB, - bytes, - "image/png" - ); - expect(res.status).toBe(403); - }); - - it("upload is rejected when bytes exceed maxSize", async () => { - const tiny = new MemoryBlobAdapter(); - const { app: smallApp } = await makeApp(tiny, 64); - const uri = await createSpace(smallApp, ALICE, "small"); - const bytes = new Uint8Array(128); - const res = await call( - smallApp, - "POST", - `/xrpc/test.blobs.space.uploadBlob?spaceUri=${encodeURIComponent(uri)}`, - ALICE, - bytes, - "application/octet-stream" - ); - expect(res.status).toBe(413); - }); - - it("getBlob returns bytes for a member, 403 for non-member, 404 for bogus cid", async () => { - const payload = new TextEncoder().encode("download me"); - const up = await call( - app, - "POST", - `/xrpc/test.blobs.space.uploadBlob?spaceUri=${encodeURIComponent(spaceUri)}`, - ALICE, - payload, - "text/plain" - ); - const { blob } = (await up.json()) as any; - const cid = blob.ref.$link; - - // Member (owner) — gets bytes back. - const okRes = await call( - app, - "GET", - `/xrpc/test.blobs.space.getBlob?spaceUri=${encodeURIComponent(spaceUri)}&cid=${cid}`, - ALICE - ); - expect(okRes.status).toBe(200); - expect(okRes.headers.get("content-type")).toBe("text/plain"); - const got = new Uint8Array(await okRes.arrayBuffer()); - expect(new TextDecoder().decode(got)).toBe("download me"); - - // Non-member — 403. - const deny = await call( - app, - "GET", - `/xrpc/test.blobs.space.getBlob?spaceUri=${encodeURIComponent(spaceUri)}&cid=${cid}`, - CHARLIE - ); - expect(deny.status).toBe(403); - - // Made-up CID — 404 even for the owner (never enumerate). - const miss = await call( - app, - "GET", - `/xrpc/test.blobs.space.getBlob?spaceUri=${encodeURIComponent(spaceUri)}&cid=bafynotareal`, - ALICE - ); - expect(miss.status).toBe(404); - }); - - it("putRecord rejects records referencing blobs that were not uploaded here", async () => { - const res = await callJson(app, "POST", "/xrpc/test.blobs.space.putRecord", ALICE, { - spaceUri, - collection: "app.event.photo", - record: { - caption: "bogus", - image: { - $type: "blob", - ref: { $link: "bafynotareal" }, - mimeType: "image/png", - size: 10, - }, - }, - }); - expect(res.status).toBe(400); - const body = (await res.json()) as any; - expect(body.reason).toBe("unknown-blob-ref"); - }); - - it("putRecord accepts records referencing a previously uploaded blob", async () => { - const bytes = new TextEncoder().encode("real image bytes"); - const up = await call( - app, - "POST", - `/xrpc/test.blobs.space.uploadBlob?spaceUri=${encodeURIComponent(spaceUri)}`, - ALICE, - bytes, - "image/png" - ); - const { blob } = (await up.json()) as any; - - const res = await callJson(app, "POST", "/xrpc/test.blobs.space.putRecord", ALICE, { - spaceUri, - collection: "app.event.photo", - record: { caption: "ok", image: blob }, - }); - expect(res.status).toBe(200); - }); - - it("listBlobs returns metadata for members", async () => { - const res = await call( - app, - "GET", - `/xrpc/test.blobs.space.listBlobs?spaceUri=${encodeURIComponent(spaceUri)}`, - ALICE - ); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - expect(Array.isArray(body.blobs)).toBe(true); - expect(body.blobs.length).toBeGreaterThan(0); - const row = body.blobs[0]; - expect(row).toHaveProperty("cid"); - expect(row).toHaveProperty("mimeType"); - expect(row).toHaveProperty("size"); - expect(row).toHaveProperty("authorDid"); - }); - - it("gcOrphanBlobs deletes unreferenced blobs older than cutoff but keeps referenced ones", async () => { - const isolatedBlobs = new MemoryBlobAdapter(); - const { app: isoApp, db } = await makeApp(isolatedBlobs); - const uri = await createSpace(isoApp, ALICE, "gc-test"); - const storage = new HostedAdapter(db, makeConfig(isolatedBlobs)); - - // Upload two blobs; reference only one of them. - const keepBytes = new TextEncoder().encode("keep me"); - const dropBytes = new TextEncoder().encode("drop me"); - - const keepUp = await call( - isoApp, - "POST", - `/xrpc/test.blobs.space.uploadBlob?spaceUri=${encodeURIComponent(uri)}`, - ALICE, - keepBytes, - "image/png" - ); - const dropUp = await call( - isoApp, - "POST", - `/xrpc/test.blobs.space.uploadBlob?spaceUri=${encodeURIComponent(uri)}`, - ALICE, - dropBytes, - "image/png" - ); - const keep = (await keepUp.json()) as any; - const drop = (await dropUp.json()) as any; - - // Reference the "keep" blob from a record. - const put = await callJson(isoApp, "POST", "/xrpc/test.blobs.space.putRecord", ALICE, { - spaceUri: uri, - collection: "app.event.photo", - record: { caption: "keep", image: keep.blob }, - }); - expect(put.status).toBe(200); - - // Run GC with a cutoff far in the future so every blob is eligible by age. - const result = await gcOrphanBlobs(storage, isolatedBlobs, uri, { - olderThan: Date.now() + 60_000, - }); - - expect(result.deleted).toBe(1); - expect(result.cids).toContain(drop.blob.ref.$link); - expect(result.cids).not.toContain(keep.blob.ref.$link); - - // The "keep" blob is still retrievable; the dropped one is gone. - const okRes = await call( - isoApp, - "GET", - `/xrpc/test.blobs.space.getBlob?spaceUri=${encodeURIComponent(uri)}&cid=${keep.blob.ref.$link}`, - ALICE - ); - expect(okRes.status).toBe(200); - const missRes = await call( - isoApp, - "GET", - `/xrpc/test.blobs.space.getBlob?spaceUri=${encodeURIComponent(uri)}&cid=${drop.blob.ref.$link}`, - ALICE - ); - expect(missRes.status).toBe(404); - }); -}); diff --git a/packages/contrail/tests/spaces-credentials.test.ts b/packages/contrail/tests/spaces-credentials.test.ts deleted file mode 100644 index ec3c428..0000000 --- a/packages/contrail/tests/spaces-credentials.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema } from "../src/core/db/schema"; -import { createApp } from "../src/core/router"; -import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig } from "../src/core/types"; -import { - generateAuthoritySigningKey, - signCredential, - verifyCredential, - issueCredential, -} from "../src/core/spaces/credentials"; -import type { CredentialKeyMaterial } from "../src/core/spaces/credentials"; - -const ALICE = "did:plc:alice"; -const BOB = "did:plc:bob"; -const CHARLIE = "did:plc:charlie"; - -const SERVICE_DID = "did:web:test.example#svc"; - -let SIGNING: CredentialKeyMaterial; - -beforeAll(async () => { - SIGNING = await generateAuthoritySigningKey(); -}); - -function makeConfig(): ContrailConfig { - return { - namespace: "test.cred", - collections: { - message: { collection: "app.event.message" }, - }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: SERVICE_DID, - signing: SIGNING, - credentialTtlMs: 60_000, - }, - recordHost: {}, - }, - }; -} - -function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { - issuer: did, - audience: SERVICE_DID, - lxm: undefined, - clientId: c.req.header("X-Test-App") ?? undefined, - }); - await next(); - }; -} - -async function makeApp(): Promise<Hono> { - const db = createSqliteDatabase(":memory:"); - const cfg = makeConfig(); - const resolved = resolveConfig(cfg); - await initSchema(db, resolved); - return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); -} - -function call( - app: Hono, - method: string, - path: string, - did: string | null, - body?: any, - extraHeaders?: Record<string, string> -) { - const headers: Record<string, string> = { ...(extraHeaders ?? {}) }; - if (did) headers["X-Test-Did"] = did; - if (body !== undefined) headers["Content-Type"] = "application/json"; - return app.fetch( - new Request(`http://localhost${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) - ); -} - -async function createSpace(app: Hono, owner: string): Promise<string> { - const res = await call(app, "POST", "/xrpc/test.cred.space.createSpace", owner, {}); - expect(res.status).toBe(200); - return ((await res.json()) as any).space.uri; -} - -async function getCredential(app: Hono, did: string, spaceUri: string): Promise<string> { - const res = await call(app, "POST", "/xrpc/test.cred.space.getCredential", did, { spaceUri }); - expect(res.status).toBe(200); - return ((await res.json()) as any).credential; -} - -describe("space credentials — sign/verify primitives", () => { - it("round-trips via verifyCredential", async () => { - const { credential } = await issueCredential( - { - iss: SERVICE_DID, - sub: ALICE, - space: "ats://did:plc:alice/test/main", - scope: "rw", - ttlMs: 60_000, - }, - SIGNING - ); - const result = await verifyCredential(credential, { - expectedSpace: "ats://did:plc:alice/test/main", - resolveKey: async () => SIGNING.publicKey, - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.claims.iss).toBe(SERVICE_DID); - expect(result.claims.sub).toBe(ALICE); - expect(result.claims.scope).toBe("rw"); - } - }); - - it("rejects expired credentials", async () => { - const past = Math.floor(Date.now() / 1000) - 10; - const credential = await signCredential( - { - iss: SERVICE_DID, - sub: ALICE, - space: "ats://x/y/z", - scope: "rw", - iat: past - 60, - exp: past, - }, - SIGNING - ); - const result = await verifyCredential(credential, { - expectedSpace: "ats://x/y/z", - resolveKey: async () => SIGNING.publicKey, - }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.reason).toBe("expired"); - }); - - it("rejects wrong-space credentials", async () => { - const { credential } = await issueCredential( - { iss: SERVICE_DID, sub: ALICE, space: "ats://a/b/c", scope: "rw", ttlMs: 60_000 }, - SIGNING - ); - const result = await verifyCredential(credential, { - expectedSpace: "ats://different/space/here", - resolveKey: async () => SIGNING.publicKey, - }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.reason).toBe("wrong-space"); - }); - - it("rejects credentials signed by a different key", async () => { - const { credential } = await issueCredential( - { iss: SERVICE_DID, sub: ALICE, space: "ats://x/y/z", scope: "rw", ttlMs: 60_000 }, - SIGNING - ); - const otherKey = await generateAuthoritySigningKey(); - const result = await verifyCredential(credential, { - expectedSpace: "ats://x/y/z", - resolveKey: async () => otherKey.publicKey, - }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.reason).toBe("bad-signature"); - }); - - it("rejects unknown issuer", async () => { - const { credential } = await issueCredential( - { iss: SERVICE_DID, sub: ALICE, space: "ats://x/y/z", scope: "rw", ttlMs: 60_000 }, - SIGNING - ); - const result = await verifyCredential(credential, { - expectedSpace: "ats://x/y/z", - resolveKey: async () => null, - }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.reason).toBe("unknown-issuer"); - }); - - it("scope=rw rejects read-only credentials when verifier requires rw", async () => { - const { credential } = await issueCredential( - { iss: SERVICE_DID, sub: ALICE, space: "ats://x/y/z", scope: "read", ttlMs: 60_000 }, - SIGNING - ); - const result = await verifyCredential(credential, { - expectedSpace: "ats://x/y/z", - requiredScope: "rw", - resolveKey: async () => SIGNING.publicKey, - }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.reason).toBe("wrong-scope"); - }); -}); - -describe("space credentials — getCredential / refreshCredential endpoints", () => { - it("issues a credential for a space owner", async () => { - const app = await makeApp(); - const uri = await createSpace(app, ALICE); - const res = await call(app, "POST", "/xrpc/test.cred.space.getCredential", ALICE, { - spaceUri: uri, - }); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - expect(body.credential).toBeTypeOf("string"); - expect(body.expiresAt).toBeTypeOf("number"); - expect(body.expiresAt).toBeGreaterThan(Date.now()); - }); - - it("issues a credential for a member who isn't the owner", async () => { - const app = await makeApp(); - const uri = await createSpace(app, ALICE); - await call(app, "POST", "/xrpc/test.cred.space.addMember", ALICE, { - spaceUri: uri, - did: BOB, - }); - const res = await call(app, "POST", "/xrpc/test.cred.space.getCredential", BOB, { - spaceUri: uri, - }); - expect(res.status).toBe(200); - }); - - it("denies non-members", async () => { - const app = await makeApp(); - const uri = await createSpace(app, ALICE); - const res = await call(app, "POST", "/xrpc/test.cred.space.getCredential", CHARLIE, { - spaceUri: uri, - }); - expect(res.status).toBe(403); - expect((await res.json()).reason).toBe("not-member"); - }); - - it("refreshes an unexpired credential", async () => { - const app = await makeApp(); - const uri = await createSpace(app, ALICE); - const cred = await getCredential(app, ALICE, uri); - - const res = await call(app, "POST", "/xrpc/test.cred.space.refreshCredential", null, { - credential: cred, - }); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - expect(body.credential).toBeTypeOf("string"); - expect(body.credential).not.toBe(cred); // fresh iat/exp → different signature - }); - - it("refresh rejects when the holder is no longer a member", async () => { - const app = await makeApp(); - const uri = await createSpace(app, ALICE); - await call(app, "POST", "/xrpc/test.cred.space.addMember", ALICE, { - spaceUri: uri, - did: BOB, - }); - const cred = await getCredential(app, BOB, uri); - // Owner kicks Bob out. - const removeRes = await call(app, "POST", "/xrpc/test.cred.space.removeMember", ALICE, { - spaceUri: uri, - did: BOB, - }); - expect(removeRes.status).toBe(200); - - const res = await call(app, "POST", "/xrpc/test.cred.space.refreshCredential", null, { - credential: cred, - }); - expect(res.status).toBe(403); - }); -}); - -describe("space credentials — record host accepts X-Space-Credential", () => { - it("putRecord works with a credential and no service-auth JWT", async () => { - const app = await makeApp(); - const uri = await createSpace(app, ALICE); - const cred = await getCredential(app, ALICE, uri); - - const res = await call( - app, - "POST", - "/xrpc/test.cred.space.putRecord", - null, // no X-Test-Did header - { - spaceUri: uri, - collection: "app.event.message", - record: { $type: "app.event.message", text: "hi from credential" }, - }, - { "X-Space-Credential": cred } - ); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - expect(body.authorDid).toBe(ALICE); - expect(body.rkey).toBeTypeOf("string"); - }); - - it("listRecords works with a credential", async () => { - const app = await makeApp(); - const uri = await createSpace(app, ALICE); - const cred = await getCredential(app, ALICE, uri); - // Plant a record via the JWT path so listRecords has something to return. - await call( - app, - "POST", - "/xrpc/test.cred.space.putRecord", - ALICE, - { - spaceUri: uri, - collection: "app.event.message", - record: { $type: "app.event.message", text: "one" }, - } - ); - - const res = await call( - app, - "GET", - `/xrpc/test.cred.space.listRecords?spaceUri=${encodeURIComponent(uri)}&collection=app.event.message`, - null, - undefined, - { "X-Space-Credential": cred } - ); - expect(res.status).toBe(200); - expect(((await res.json()) as any).records.length).toBe(1); - }); - - it("rejects credential issued for a different space", async () => { - const app = await makeApp(); - const uriA = await createSpace(app, ALICE); - // Alice can create a second one; key auto-generated, owner is implicit member. - const uriB = await createSpace(app, ALICE); - const credForA = await getCredential(app, ALICE, uriA); - - const res = await call( - app, - "POST", - "/xrpc/test.cred.space.putRecord", - null, - { - spaceUri: uriB, - collection: "app.event.message", - record: { $type: "app.event.message", text: "wrong space" }, - }, - { "X-Space-Credential": credForA } - ); - expect(res.status).toBe(403); - expect((await res.json()).reason).toBe("credential-wrong-space"); - }); - - it("rejects malformed credentials", async () => { - const app = await makeApp(); - const uri = await createSpace(app, ALICE); - const res = await call( - app, - "POST", - "/xrpc/test.cred.space.putRecord", - null, - { - spaceUri: uri, - collection: "app.event.message", - record: { $type: "app.event.message", text: "x" }, - }, - { "X-Space-Credential": "not-a-jwt" } - ); - expect(res.status).toBe(401); - expect((await res.json()).reason).toBe("malformed"); - }); - - it("rejects credentials forged with an unknown issuer", async () => { - const app = await makeApp(); - const uri = await createSpace(app, ALICE); - const otherKey = await generateAuthoritySigningKey(); - const { credential } = await issueCredential( - { - iss: "did:web:attacker.example", - sub: ALICE, - space: uri, - scope: "rw", - ttlMs: 60_000, - }, - otherKey - ); - const res = await call( - app, - "POST", - "/xrpc/test.cred.space.putRecord", - null, - { - spaceUri: uri, - collection: "app.event.message", - record: { $type: "app.event.message", text: "x" }, - }, - { "X-Space-Credential": credential } - ); - expect(res.status).toBe(401); - expect((await res.json()).reason).toBe("unknown-issuer"); - }); -}); diff --git a/packages/contrail/tests/spaces-e2e.test.ts b/packages/contrail/tests/spaces-e2e.test.ts deleted file mode 100644 index e70f9ec..0000000 --- a/packages/contrail/tests/spaces-e2e.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema } from "../src/core/db/schema"; -import { createApp } from "../src/core/router"; -import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig } from "../src/core/types"; - -const ALICE = "did:plc:alice"; -const BOB = "did:plc:bob"; -const CHARLIE = "did:plc:charlie"; - -const CONFIG: ContrailConfig = { - namespace: "test.spaces", - collections: { - location: { collection: "app.event.location" }, - message: { collection: "app.event.message" }, - ticket: { collection: "app.event.ticket" }, - }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: "did:web:test.example#svc", - }, - recordHost: {}, - }, -}; - -/** Fake auth middleware: reads X-Test-Did header to impersonate a caller. */ -function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { - issuer: did, - audience: CONFIG.spaces!.authority!.serviceDid, - lxm: undefined, - clientId: c.req.header("X-Test-App") ?? undefined, - }); - await next(); - }; -} - -async function makeApp(): Promise<Hono> { - const db = createSqliteDatabase(":memory:"); - const resolved = resolveConfig(CONFIG); - await initSchema(db, resolved); - return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); -} - -async function makeSplitDbApp(): Promise<{ app: Hono; db: any; spacesDb: any }> { - const db = createSqliteDatabase(":memory:"); - const spacesDb = createSqliteDatabase(":memory:"); - const resolved = resolveConfig(CONFIG); - await initSchema(db, resolved, { spacesDb }); - const app = createApp(db, resolved, { - spaces: { authMiddleware: fakeAuth() }, - spacesDb, - }); - return { app, db, spacesDb }; -} - -async function asJson(res: Response): Promise<any> { - return res.json(); -} - -function call( - app: Hono, - method: string, - path: string, - did: string, - body?: any, - app_?: string -): Promise<Response> { - const headers: Record<string, string> = { "X-Test-Did": did }; - if (app_) headers["X-Test-App"] = app_; - if (body !== undefined) headers["Content-Type"] = "application/json"; - return app.fetch( - new Request(`http://localhost${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) - ); -} - -describe("spaces e2e", () => { - let app: Hono; - let spaceUri: string; - - beforeAll(async () => { - app = await makeApp(); - - // Alice creates a space - const res = await call(app, "POST", "/xrpc/test.spaces.space.createSpace", ALICE, { - key: "birthday-2026", - }); - expect(res.status).toBe(200); - const { space } = await asJson(res); - spaceUri = space.uri; - expect(spaceUri).toBe(`ats://${ALICE}/tools.atmo.event.space/birthday-2026`); - }); - - it("owner can write a location record", async () => { - const res = await call(app, "POST", "/xrpc/test.spaces.space.putRecord", ALICE, { - spaceUri, - collection: "app.event.location", - record: { address: "123 Main St" }, - }); - expect(res.status).toBe(200); - const body = await asJson(res); - expect(body.rkey).toBeTruthy(); - expect(body.authorDid).toBe(ALICE); - }); - - it("non-member cannot read location", async () => { - const res = await call( - app, - "GET", - `/xrpc/test.spaces.space.listRecords?spaceUri=${encodeURIComponent(spaceUri)}&collection=app.event.location`, - BOB - ); - expect(res.status).toBe(403); - const body = await asJson(res); - expect(body.reason).toBe("not-member"); - }); - - it("non-member cannot write a message", async () => { - const res = await call(app, "POST", "/xrpc/test.spaces.space.putRecord", BOB, { - spaceUri, - collection: "app.event.message", - record: { text: "spam" }, - }); - expect(res.status).toBe(403); - }); - - it("owner adds Bob as member", async () => { - const res = await call(app, "POST", "/xrpc/test.spaces.space.addMember", ALICE, { - spaceUri, - did: BOB, - }); - expect(res.status).toBe(200); - }); - - it("Bob can now read location", async () => { - const res = await call( - app, - "GET", - `/xrpc/test.spaces.space.listRecords?spaceUri=${encodeURIComponent(spaceUri)}&collection=app.event.location`, - BOB - ); - expect(res.status).toBe(200); - const body = await asJson(res); - expect(body.records.length).toBe(1); - expect(body.records[0].record.address).toBe("123 Main St"); - }); - - it("Bob can write his own message; Alice and Bob can both read", async () => { - const put = await call(app, "POST", "/xrpc/test.spaces.space.putRecord", BOB, { - spaceUri, - collection: "app.event.message", - record: { text: "see you there!" }, - }); - expect(put.status).toBe(200); - - const listAsAlice = await call( - app, - "GET", - `/xrpc/test.spaces.space.listRecords?spaceUri=${encodeURIComponent(spaceUri)}&collection=app.event.message`, - ALICE - ); - expect(listAsAlice.status).toBe(200); - const body = await asJson(listAsAlice); - expect(body.records.length).toBe(1); - expect(body.records[0].record.text).toBe("see you there!"); - expect(body.records[0].authorDid).toBe(BOB); - }); - - it("member-own: Alice writes two tickets, Bob only sees his own", async () => { - // Alice (owner) writes two tickets — one for Bob, one for Charlie. Write requires owner. - // Problem: authorDid is always the JWT issuer (Alice), so both tickets are authored by Alice. - // member-own read means each member only sees records they AUTHORED. Since Alice authored both, - // Bob would see nothing. This surfaces a design question; for this test we'll switch ticket's - // write to "member" so each member writes their own. - // We're not mutating config mid-test here — skipping for now. - expect(true).toBe(true); - }); - - it("per-collection listRecords with ?spaceUri= requires auth", async () => { - // Public path (no spaceUri) works without auth; adding spaceUri forces the - // service-auth JWT path. With no valid JWT, 401. - const res = await app.fetch( - new Request( - `http://localhost/xrpc/${CONFIG.namespace}.location.listRecords?spaceUri=${encodeURIComponent(spaceUri)}` - ) - ); - expect([401, 501]).toContain(res.status); - }); - - it("Charlie (not a member) cannot list messages", async () => { - const res = await call( - app, - "GET", - `/xrpc/test.spaces.space.listRecords?spaceUri=${encodeURIComponent(spaceUri)}&collection=app.event.message`, - CHARLIE - ); - expect(res.status).toBe(403); - }); - - it("split DBs: spaces tables live only on spacesDb", async () => { - const { app: splitApp, db: mainDb, spacesDb } = await makeSplitDbApp(); - - // Spaces table should exist on spacesDb, not on main DB - const onSpaces = await spacesDb.prepare( - "SELECT name FROM sqlite_master WHERE type='table' AND name='spaces'" - ).first(); - expect(onSpaces).toBeTruthy(); - - const onMain = await mainDb.prepare( - "SELECT name FROM sqlite_master WHERE type='table' AND name='spaces'" - ).first(); - expect(onMain).toBeNull(); - - // End-to-end works: createSpace, putRecord, listRecords - const create = await splitApp.fetch( - new Request("http://localhost/xrpc/test.spaces.space.createSpace", { - method: "POST", - headers: { "X-Test-Did": ALICE, "Content-Type": "application/json" }, - body: JSON.stringify({ key: "split-test" }), - }) - ); - expect(create.status).toBe(200); - const { space } = await create.json() as any; - - const put = await splitApp.fetch( - new Request("http://localhost/xrpc/test.spaces.space.putRecord", { - method: "POST", - headers: { "X-Test-Did": ALICE, "Content-Type": "application/json" }, - body: JSON.stringify({ - spaceUri: space.uri, collection: "app.event.location", - record: { address: "split-DB lane" }, - }), - }) - ); - expect(put.status).toBe(200); - - const list = await splitApp.fetch( - new Request( - `http://localhost/xrpc/test.spaces.space.listRecords?spaceUri=${encodeURIComponent(space.uri)}&collection=app.event.location`, - { headers: { "X-Test-Did": ALICE } } - ) - ); - const { records } = await list.json() as any; - expect(records[0].record.address).toBe("split-DB lane"); - }); - - it("getRecord: Bob fetches Alice's location record directly", async () => { - const listRes = await call( - app, - "GET", - `/xrpc/test.spaces.space.listRecords?spaceUri=${encodeURIComponent(spaceUri)}&collection=app.event.location`, - BOB - ); - const list = await asJson(listRes); - const rkey = list.records[0].rkey; - - const res = await call( - app, - "GET", - `/xrpc/test.spaces.space.getRecord?spaceUri=${encodeURIComponent(spaceUri)}&collection=app.event.location&author=${ALICE}&rkey=${rkey}`, - BOB - ); - expect(res.status).toBe(200); - const body = await asJson(res); - expect(body.record.record.address).toBe("123 Main St"); - }); - - it("listSpaces?owner=<did> 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); - }); -}); diff --git a/packages/contrail/tests/spaces-enrollment.test.ts b/packages/contrail/tests/spaces-enrollment.test.ts deleted file mode 100644 index 78cc8b0..0000000 --- a/packages/contrail/tests/spaces-enrollment.test.ts +++ /dev/null @@ -1,434 +0,0 @@ -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema } from "../src/core/db/schema"; -import { createApp } from "../src/core/router"; -import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig } from "../src/core/types"; -import { - registerAuthorityRoutes, - registerRecordHostRoutes, -} from "../src/core/spaces/router"; -import { HostedAdapter } from "../src/core/spaces/adapter"; -import { - buildVerifier, - createServiceAuthMiddleware, -} from "../src/core/spaces/auth"; -import { - generateAuthoritySigningKey, - issueCredential, - createBindingCredentialVerifier, -} from "../src/core/spaces/credentials"; -import { - createEnrollmentBindingResolver, - createLocalKeyResolver, -} from "../src/core/spaces/binding"; -import type { CredentialKeyMaterial } from "../src/core/spaces/credentials"; - -const ALICE = "did:plc:alice"; -const BOB = "did:plc:bob"; -const SERVICE_DID = "did:web:test.example#svc"; - -let SIGNING: CredentialKeyMaterial; - -beforeAll(async () => { - SIGNING = await generateAuthoritySigningKey(); -}); - -function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { - issuer: did, - audience: SERVICE_DID, - lxm: undefined, - clientId: c.req.header("X-Test-App") ?? undefined, - }); - await next(); - }; -} - -function call( - app: Hono, - method: string, - path: string, - did: string | null, - body?: any, - extraHeaders?: Record<string, string> -) { - const headers: Record<string, string> = { ...(extraHeaders ?? {}) }; - if (did) headers["X-Test-Did"] = did; - if (body !== undefined) headers["Content-Type"] = "application/json"; - return app.fetch( - new Request(`http://localhost${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) - ); -} - -// --------------------------------------------------------------------------- -// In-process: createSpace auto-enrolls; non-enrolled spaces 404 -// --------------------------------------------------------------------------- - -describe("auto-enrollment via createSpace", () => { - function makeConfig(): ContrailConfig { - return { - namespace: "test.enroll", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: SERVICE_DID, - signing: SIGNING, - }, - recordHost: {}, - }, - }; - } - - async function makeApp(): Promise<Hono> { - const db = createSqliteDatabase(":memory:"); - const cfg = makeConfig(); - const resolved = resolveConfig(cfg); - await initSchema(db, resolved); - return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); - } - - it("createSpace creates an enrollment row alongside the space row", async () => { - const app = await makeApp(); - const create = await call(app, "POST", "/xrpc/test.enroll.space.createSpace", ALICE, {}); - expect(create.status).toBe(200); - const uri = ((await create.json()) as any).space.uri; - - // Subsequent putRecord should succeed because the space is enrolled. - const put = await call(app, "POST", "/xrpc/test.enroll.space.putRecord", ALICE, { - spaceUri: uri, - collection: "app.event.message", - record: { $type: "app.event.message", text: "hi" }, - }); - expect(put.status).toBe(200); - }); - - it("explicit enroll via the endpoint is idempotent", async () => { - const app = await makeApp(); - const create = await call(app, "POST", "/xrpc/test.enroll.space.createSpace", ALICE, {}); - const uri = ((await create.json()) as any).space.uri; - - // Re-enrolling the same space (e.g. to update the authority binding). - const reenroll = await call(app, "POST", "/xrpc/test.enroll.recordHost.enroll", ALICE, { - spaceUri: uri, - authority: SERVICE_DID, - }); - expect(reenroll.status).toBe(200); - expect(((await reenroll.json()) as any).ok).toBe(true); - }); - - it("non-owner callers cannot enroll", async () => { - const app = await makeApp(); - const create = await call(app, "POST", "/xrpc/test.enroll.space.createSpace", ALICE, {}); - const uri = ((await create.json()) as any).space.uri; - - const res = await call(app, "POST", "/xrpc/test.enroll.recordHost.enroll", BOB, { - spaceUri: uri, - authority: SERVICE_DID, - }); - expect(res.status).toBe(403); - expect((await res.json()).reason).toBe("not-owner"); - }); -}); - -// --------------------------------------------------------------------------- -// Split deployment: authority on machine A, record host on machine B -// --------------------------------------------------------------------------- - -describe("split deployment — authority and record host on separate apps", () => { - function buildAuthorityApp(db: any): Hono { - const cfg: ContrailConfig = { - namespace: "test.split", - collections: { message: { collection: "app.event.message" } }, - // Authority-only deployment: no recordHost configured. - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: SERVICE_DID, - signing: SIGNING, - }, - }, - }; - const resolved = resolveConfig(cfg); - const adapter = new HostedAdapter(db, resolved); - const app = new Hono(); - registerAuthorityRoutes( - app, - adapter, - cfg.spaces!.authority!, - resolved, - fakeAuth(), - undefined, - null // no local record host → no auto-enroll - ); - return app; - } - - function buildRecordHostApp(db: any): Hono { - const cfg: ContrailConfig = { - namespace: "test.split", - collections: { message: { collection: "app.event.message" } }, - spaces: { - // Authority config still required to provide the auth middleware - // and serviceDid context, but signing can be omitted (host doesn't - // sign — it verifies). - authority: { - type: "tools.atmo.event.space", - serviceDid: SERVICE_DID, - }, - recordHost: {}, - }, - }; - const resolved = resolveConfig(cfg); - const adapter = new HostedAdapter(db, resolved); - const verifier = createBindingCredentialVerifier({ - bindings: createEnrollmentBindingResolver({ recordHost: adapter }), - keys: createLocalKeyResolver({ - authorityDid: SERVICE_DID, - publicKey: SIGNING.publicKey, - }), - }); - const app = new Hono(); - registerRecordHostRoutes( - app, - adapter, - adapter, - cfg.spaces!.recordHost!, - resolved, - fakeAuth(), - verifier - ); - return app; - } - - it("end-to-end: create on authority, enroll on host, write+read via credential", async () => { - // Two physically-separate DBs — proves the host doesn't peek into the - // authority's storage to know about spaces. - const authorityDb = createSqliteDatabase(":memory:"); - const hostDb = createSqliteDatabase(":memory:"); - const cfg: ContrailConfig = { - namespace: "test.split", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { type: "tools.atmo.event.space", serviceDid: SERVICE_DID, signing: SIGNING }, - recordHost: {}, - }, - }; - const resolved = resolveConfig(cfg); - await initSchema(authorityDb, resolved); - await initSchema(hostDb, resolved); - - const authorityApp = buildAuthorityApp(authorityDb); - const hostApp = buildRecordHostApp(hostDb); - - // 1. Authority creates a space — auto-enroll did NOT happen because the - // authority has no local record host. - const create = await call(authorityApp, "POST", "/xrpc/test.split.space.createSpace", ALICE, {}); - expect(create.status).toBe(200); - const uri = ((await create.json()) as any).space.uri; - - // 2. Host rejects writes for the not-yet-enrolled space. - // The credential's binding resolver (EnrollmentBindingResolver) - // finds no enrollment → returns null → verifier 401 "unknown-issuer". - // This is a different layer than the requireEnrollment 404 (which - // fires on JWT/non-credential paths), but both block writes. - const cred1 = await call(authorityApp, "POST", "/xrpc/test.split.space.getCredential", ALICE, { - spaceUri: uri, - }); - const credential = ((await cred1.json()) as any).credential; - const earlyPut = await call( - hostApp, - "POST", - "/xrpc/test.split.space.putRecord", - null, - { spaceUri: uri, collection: "app.event.message", record: { $type: "app.event.message", text: "x" } }, - { "X-Space-Credential": credential } - ); - expect(earlyPut.status).toBe(401); - expect((await earlyPut.json()).reason).toBe("unknown-issuer"); - - // 3. Owner enrolls the space on the host. - const enroll = await call(hostApp, "POST", "/xrpc/test.split.recordHost.enroll", ALICE, { - spaceUri: uri, - authority: SERVICE_DID, - }); - expect(enroll.status).toBe(200); - - // 4. Now the host accepts writes via credential. - const put = await call( - hostApp, - "POST", - "/xrpc/test.split.space.putRecord", - null, - { spaceUri: uri, collection: "app.event.message", record: { $type: "app.event.message", text: "hello" } }, - { "X-Space-Credential": credential } - ); - expect(put.status).toBe(200); - expect(((await put.json()) as any).authorDid).toBe(ALICE); - - // 5. Read back via credential. - const list = await call( - hostApp, - "GET", - `/xrpc/test.split.space.listRecords?spaceUri=${encodeURIComponent(uri)}&collection=app.event.message`, - null, - undefined, - { "X-Space-Credential": credential } - ); - expect(list.status).toBe(200); - expect(((await list.json()) as any).records.length).toBe(1); - }); - - it("host rejects credentials whose iss doesn't match the enrollment", async () => { - const authorityDb = createSqliteDatabase(":memory:"); - const hostDb = createSqliteDatabase(":memory:"); - const cfg: ContrailConfig = { - namespace: "test.split", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { type: "tools.atmo.event.space", serviceDid: SERVICE_DID, signing: SIGNING }, - recordHost: {}, - }, - }; - const resolved = resolveConfig(cfg); - await initSchema(authorityDb, resolved); - await initSchema(hostDb, resolved); - - const authorityApp = buildAuthorityApp(authorityDb); - const hostApp = buildRecordHostApp(hostDb); - - const create = await call(authorityApp, "POST", "/xrpc/test.split.space.createSpace", ALICE, {}); - const uri = ((await create.json()) as any).space.uri; - await call(hostApp, "POST", "/xrpc/test.split.recordHost.enroll", ALICE, { - spaceUri: uri, - authority: SERVICE_DID, - }); - - // A credential signed by a *different* DID with the same key, but iss - // doesn't match the enrolled authority. - const { credential } = await issueCredential( - { - iss: "did:web:imposter.example", - sub: ALICE, - space: uri, - scope: "rw", - ttlMs: 60_000, - }, - SIGNING - ); - const res = await call( - hostApp, - "POST", - "/xrpc/test.split.space.putRecord", - null, - { spaceUri: uri, collection: "app.event.message", record: { $type: "app.event.message", text: "x" } }, - { "X-Space-Credential": credential } - ); - expect(res.status).toBe(401); - expect((await res.json()).reason).toBe("unknown-issuer"); - }); - - it("non-enrolled spaces 404 with not-enrolled on JWT-path reads", async () => { - // Service-auth JWTs go through requireEnrollment in the route handler, - // so a non-enrolled space gives the explicit "not-enrolled" reason. - const authorityDb = createSqliteDatabase(":memory:"); - const hostDb = createSqliteDatabase(":memory:"); - const cfg: ContrailConfig = { - namespace: "test.split", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { type: "tools.atmo.event.space", serviceDid: SERVICE_DID, signing: SIGNING }, - recordHost: {}, - }, - }; - const resolved = resolveConfig(cfg); - await initSchema(authorityDb, resolved); - await initSchema(hostDb, resolved); - const authorityApp = buildAuthorityApp(authorityDb); - const hostApp = buildRecordHostApp(hostDb); - const create = await call(authorityApp, "POST", "/xrpc/test.split.space.createSpace", ALICE, {}); - const uri = ((await create.json()) as any).space.uri; - - const list = await call( - hostApp, - "GET", - `/xrpc/test.split.space.listRecords?spaceUri=${encodeURIComponent(uri)}&collection=app.event.message`, - ALICE - ); - expect(list.status).toBe(404); - expect((await list.json()).reason).toBe("not-enrolled"); - }); - - it("a third party cannot enroll a space by claiming to be the authority", async () => { - // Regression: the enroll handler used to accept either owner-signed - // OR authority-self-attested calls. That let any DID claim "I am the - // authority for ats://<victim>/..." and rebind the space. - const authorityDb = createSqliteDatabase(":memory:"); - const hostDb = createSqliteDatabase(":memory:"); - const cfg: ContrailConfig = { - namespace: "test.split", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { type: "tools.atmo.event.space", serviceDid: SERVICE_DID, signing: SIGNING }, - recordHost: {}, - }, - }; - const resolved = resolveConfig(cfg); - await initSchema(authorityDb, resolved); - await initSchema(hostDb, resolved); - - const authorityApp = buildAuthorityApp(authorityDb); - const hostApp = buildRecordHostApp(hostDb); - const create = await call(authorityApp, "POST", "/xrpc/test.split.space.createSpace", ALICE, {}); - const uri = ((await create.json()) as any).space.uri; - - // SERVICE_DID self-attests as the authority for Alice's space. - // Must be rejected — only the owner can enroll. - const enroll = await call(hostApp, "POST", "/xrpc/test.split.recordHost.enroll", SERVICE_DID, { - spaceUri: uri, - authority: SERVICE_DID, - }); - expect(enroll.status).toBe(403); - expect((await enroll.json()).reason).toBe("not-owner"); - }); - - it("the owner can enroll their space designating a separate authority", async () => { - // Positive: the owner-signed path lets Alice point her space at the - // configured authority service (the canonical split-deployment flow). - const authorityDb = createSqliteDatabase(":memory:"); - const hostDb = createSqliteDatabase(":memory:"); - const cfg: ContrailConfig = { - namespace: "test.split", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { type: "tools.atmo.event.space", serviceDid: SERVICE_DID, signing: SIGNING }, - recordHost: {}, - }, - }; - const resolved = resolveConfig(cfg); - await initSchema(authorityDb, resolved); - await initSchema(hostDb, resolved); - - const authorityApp = buildAuthorityApp(authorityDb); - const hostApp = buildRecordHostApp(hostDb); - const create = await call(authorityApp, "POST", "/xrpc/test.split.space.createSpace", ALICE, {}); - const uri = ((await create.json()) as any).space.uri; - - const enroll = await call(hostApp, "POST", "/xrpc/test.split.recordHost.enroll", ALICE, { - spaceUri: uri, - authority: SERVICE_DID, - }); - expect(enroll.status).toBe(200); - expect(((await enroll.json()) as any).ok).toBe(true); - }); -}); diff --git a/packages/contrail/tests/spaces-invites.test.ts b/packages/contrail/tests/spaces-invites.test.ts deleted file mode 100644 index d4e0406..0000000 --- a/packages/contrail/tests/spaces-invites.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema } from "../src/core/db/schema"; -import { createApp } from "../src/core/router"; -import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig } from "../src/core/types"; -import { generateInviteToken, hashInviteToken } from "../src/core/invite/token"; - -const ALICE = "did:plc:alice"; -const BOB = "did:plc:bob"; -const CHARLIE = "did:plc:charlie"; - -const CONFIG: ContrailConfig = { - namespace: "test.spaces", - collections: { - message: { collection: "app.event.message" }, - }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: "did:web:test.example#svc", - }, - recordHost: {}, - }, -}; - -function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { - issuer: did, - audience: CONFIG.spaces!.authority!.serviceDid, - lxm: undefined, - }); - await next(); - }; -} - -function call(app: Hono, method: string, path: string, did: string, body?: any) { - return app.fetch( - new Request(`http://localhost${path}`, { - method, - headers: { - "X-Test-Did": did, - ...(body !== undefined ? { "Content-Type": "application/json" } : {}), - }, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) - ); -} - -describe("invite token helpers", () => { - it("generates random tokens of consistent length", () => { - const a = generateInviteToken(); - const b = generateInviteToken(); - expect(a).not.toBe(b); - expect(a.length).toBeGreaterThanOrEqual(40); - expect(/^[A-Za-z0-9_-]+$/.test(a)).toBe(true); - }); - - it("hashes deterministically", async () => { - const token = generateInviteToken(); - const h1 = await hashInviteToken(token); - const h2 = await hashInviteToken(token); - expect(h1).toBe(h2); - expect(h1).toMatch(/^[a-f0-9]{64}$/); - }); -}); - -describe("invite e2e", () => { - let app: Hono; - let spaceUri: string; - - beforeAll(async () => { - const db = createSqliteDatabase(":memory:"); - const resolved = resolveConfig(CONFIG); - await initSchema(db, resolved); - app = createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); - - const res = await call(app, "POST", "/xrpc/test.spaces.space.createSpace", ALICE, { - key: "party", - }); - spaceUri = ((await res.json()) as any).space.uri; - }); - - it("non-owner cannot create an invite", async () => { - const res = await call(app, "POST", "/xrpc/test.spaces.invite.create", BOB, { - spaceUri, - }); - expect(res.status).toBe(403); - }); - - it("owner creates an invite and Bob redeems it to become a member", async () => { - const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { - spaceUri, - }); - expect(create.status).toBe(200); - const { token, invite } = (await create.json()) as any; - expect(token).toBeTruthy(); - expect(invite.tokenHash).toBeTruthy(); - expect(invite.spaceUri).toBe(spaceUri); - expect(invite.usedCount).toBe(0); - - const redeem = await call(app, "POST", "/xrpc/test.spaces.invite.redeem", BOB, { token }); - expect(redeem.status).toBe(200); - const body = (await redeem.json()) as any; - expect(body.spaceUri).toBe(spaceUri); - - // Bob is now a member — can write a message - const put = await call(app, "POST", "/xrpc/test.spaces.space.putRecord", BOB, { - spaceUri, - collection: "app.event.message", - record: { text: "yay" }, - }); - expect(put.status).toBe(200); - }); - - it("single-use invite rejects second redemption", async () => { - const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { - spaceUri, maxUses: 1, - }); - const { token } = (await create.json()) as any; - - const first = await call(app, "POST", "/xrpc/test.spaces.invite.redeem", BOB, { token }); - expect(first.status).toBe(200); - - const second = await call(app, "POST", "/xrpc/test.spaces.invite.redeem", CHARLIE, { token }); - expect(second.status).toBe(400); - const body = (await second.json()) as any; - expect(body.reason).toBe("expired-revoked-or-exhausted"); - }); - - it("expired invite rejects redemption", async () => { - const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { - spaceUri, expiresAt: Date.now() - 1000, - }); - const { token } = (await create.json()) as any; - const res = await call(app, "POST", "/xrpc/test.spaces.invite.redeem", CHARLIE, { token }); - expect(res.status).toBe(400); - }); - - it("revoked invite rejects redemption and list filters it by default", async () => { - const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { - spaceUri, - }); - const { token, invite } = (await create.json()) as any; - - const revoke = await call(app, "POST", "/xrpc/test.spaces.invite.revoke", ALICE, { - spaceUri, tokenHash: invite.tokenHash, - }); - expect(revoke.status).toBe(200); - expect(((await revoke.json()) as any).ok).toBe(true); - - const tryRedeem = await call(app, "POST", "/xrpc/test.spaces.invite.redeem", CHARLIE, { token }); - expect(tryRedeem.status).toBe(400); - - const listActive = await call(app, "GET", `/xrpc/test.spaces.invite.list?spaceUri=${encodeURIComponent(spaceUri)}`, ALICE); - const activeHashes = ((await listActive.json()) as any).invites.map((i: any) => i.tokenHash); - expect(activeHashes).not.toContain(invite.tokenHash); - - const listAll = await call(app, "GET", `/xrpc/test.spaces.invite.list?spaceUri=${encodeURIComponent(spaceUri)}&includeRevoked=true`, ALICE); - const allHashes = ((await listAll.json()) as any).invites.map((i: any) => i.tokenHash); - expect(allHashes).toContain(invite.tokenHash); - }); - - it("non-owner cannot list or revoke invites", async () => { - const listRes = await call(app, "GET", `/xrpc/test.spaces.invite.list?spaceUri=${encodeURIComponent(spaceUri)}`, BOB); - expect(listRes.status).toBe(403); - - const revokeRes = await call(app, "POST", "/xrpc/test.spaces.invite.revoke", BOB, { - spaceUri, tokenHash: "nonexistent", - }); - expect(revokeRes.status).toBe(403); - }); - - it("read-token grants anonymous read but cannot be redeemed", async () => { - // Owner writes a message so there's something to read. - await call(app, "POST", "/xrpc/test.spaces.space.putRecord", ALICE, { - spaceUri, collection: "app.event.message", record: { text: "hello world" }, - }); - - const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { - spaceUri, kind: "read", - }); - const { token, invite } = (await create.json()) as any; - expect(invite.kind).toBe("read"); - - // Anonymous request (no X-Test-Did header) reads via ?inviteToken= - const url = `/xrpc/test.spaces.space.listRecords?spaceUri=${encodeURIComponent(spaceUri)}&collection=app.event.message&inviteToken=${token}`; - const anon = await app.fetch(new Request(`http://localhost${url}`)); - expect(anon.status).toBe(200); - const body = (await anon.json()) as any; - expect(body.records.length).toBeGreaterThan(0); - - // Read token cannot be redeemed for membership. - const redeem = await call(app, "POST", "/xrpc/test.spaces.invite.redeem", CHARLIE, { token }); - expect(redeem.status).toBe(400); - }); - - it("read-join token grants anonymous read AND can be redeemed for membership", async () => { - const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { - spaceUri, kind: "read-join", - }); - const { token, invite } = (await create.json()) as any; - expect(invite.kind).toBe("read-join"); - - // Anonymous read works. - const url = `/xrpc/test.spaces.space.listRecords?spaceUri=${encodeURIComponent(spaceUri)}&collection=app.event.message&inviteToken=${token}`; - const anon = await app.fetch(new Request(`http://localhost${url}`)); - expect(anon.status).toBe(200); - - // Same token can also be redeemed by a signed-in user. - const redeem = await call(app, "POST", "/xrpc/test.spaces.invite.redeem", CHARLIE, { token }); - expect(redeem.status).toBe(200); - }); - - it("revoked read-token rejects anonymous read", async () => { - const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { - spaceUri, kind: "read", - }); - const { token, invite } = (await create.json()) as any; - await call(app, "POST", "/xrpc/test.spaces.invite.revoke", ALICE, { - spaceUri, tokenHash: invite.tokenHash, - }); - const url = `/xrpc/test.spaces.space.listRecords?spaceUri=${encodeURIComponent(spaceUri)}&collection=app.event.message&inviteToken=${token}`; - const res = await app.fetch(new Request(`http://localhost${url}`)); - expect(res.status).toBe(403); - }); - - it("read-token does not grant write", async () => { - const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { - spaceUri, kind: "read", - }); - const { token } = (await create.json()) as any; - // Anonymous put with the token in the query — write routes don't honor read-tokens. - const url = `/xrpc/test.spaces.space.putRecord?inviteToken=${token}`; - const res = await app.fetch( - new Request(`http://localhost${url}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ spaceUri, collection: "app.event.message", record: { text: "nope" } }), - }) - ); - expect(res.status).toBe(401); - }); -}); diff --git a/packages/contrail/tests/spaces-manifest-appview.test.ts b/packages/contrail/tests/spaces-manifest-appview.test.ts deleted file mode 100644 index 4d50cf7..0000000 --- a/packages/contrail/tests/spaces-manifest-appview.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** Appview-side manifest consumption: cross-space listRecords union path - * honoring `X-Membership-Manifest`. */ - -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema } from "../src/core/db/schema"; -import { createApp } from "../src/core/router"; -import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig } from "../src/core/types"; -import { - generateAuthoritySigningKey, - issueMembershipManifest, - markInProcess, -} from "@atmo-dev/contrail-base"; -import type { CredentialKeyMaterial } from "@atmo-dev/contrail-base"; - -const ALICE = "did:plc:alice"; -const BOB = "did:plc:bob"; -const CHARLIE = "did:plc:charlie"; - -const SERVICE_DID = "did:web:test.example#svc"; -const SPACE_TYPE = "tools.atmo.event.space"; - -let SIGNING: CredentialKeyMaterial; - -beforeAll(async () => { - SIGNING = await generateAuthoritySigningKey(); -}); - -function makeConfig(): ContrailConfig { - return { - namespace: "test.man2", - collections: { - message: { collection: "app.event.message" }, - }, - spaces: { - authority: { - type: SPACE_TYPE, - serviceDid: SERVICE_DID, - signing: SIGNING, - }, - recordHost: {}, - }, - }; -} - -async function makeApp(): Promise<Hono> { - const db = createSqliteDatabase(":memory:"); - const cfg = makeConfig(); - const resolved = resolveConfig(cfg); - await initSchema(db, resolved); - return createApp(db, resolved); -} - -/** Make a Request marked with an in-process principal so the union path's - * `verifyServiceAuthRequest` returns the right caller without minting a real - * JWT or wiring up a key resolver. */ -function inProc(url: string, did: string, headers: Record<string, string> = {}): Request { - const req = new Request(url, { headers }); - return markInProcess(req, did); -} - -async function createSpace(app: Hono, owner: string, key: string): Promise<string> { - const res = await app.fetch( - markInProcess( - new Request("http://localhost/xrpc/test.man2.space.createSpace", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key }), - }), - owner - ) - ); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - return body.space.uri; -} - -async function plant( - app: Hono, - did: string, - spaceUri: string, - text: string -): Promise<void> { - const res = await app.fetch( - markInProcess( - new Request("http://localhost/xrpc/test.man2.space.putRecord", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - spaceUri, - collection: "app.event.message", - record: { $type: "app.event.message", text }, - }), - }), - did - ) - ); - expect(res.status).toBe(200); -} - -async function mintManifest(sub: string, spaces: string[]): Promise<string> { - const { manifest } = await issueMembershipManifest( - { iss: SERVICE_DID, sub, spaces, ttlMs: 60_000 }, - SIGNING - ); - return manifest; -} - -describe("appview union listRecords — manifest-driven", () => { - it("uses manifest space list when valid + sub matches caller", async () => { - const app = await makeApp(); - // Alice owns one space, Bob owns another. - const aliceSpace = await createSpace(app, ALICE, "alice-only"); - const bobSpace = await createSpace(app, BOB, "bob-only"); - await plant(app, ALICE, aliceSpace, "from-alice"); - await plant(app, BOB, bobSpace, "from-bob"); - - // Alice presents a manifest covering ONLY bobSpace (doesn't matter that - // she's not actually a member — the manifest is the source of truth here). - // The authority would never sign such a manifest in practice, but the - // appview's contract is: trust the verified manifest's claims. - const manifest = await mintManifest(ALICE, [bobSpace]); - - const res = await app.fetch( - inProc( - "http://localhost/xrpc/test.man2.message.listRecords", - ALICE, - { "X-Membership-Manifest": manifest } - ) - ); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - const texts = body.records.map((r: any) => r.value.text); - expect(texts).toContain("from-bob"); - expect(texts).not.toContain("from-alice"); - }); - - it("rejects manifest whose sub does not match the caller", async () => { - const app = await makeApp(); - const aliceSpace = await createSpace(app, ALICE, "alice-only"); - await plant(app, ALICE, aliceSpace, "from-alice"); - - // Bob's manifest, presented by Alice → should 403. - const bobManifest = await mintManifest(BOB, [aliceSpace]); - - const res = await app.fetch( - inProc( - "http://localhost/xrpc/test.man2.message.listRecords", - ALICE, - { "X-Membership-Manifest": bobManifest } - ) - ); - expect(res.status).toBe(403); - expect((await res.json() as any).reason).toBe("manifest-sub-mismatch"); - }); - - it("rejects an unsigned/forged manifest", async () => { - const app = await makeApp(); - const otherKey = await generateAuthoritySigningKey(); - const { manifest } = await issueMembershipManifest( - { iss: SERVICE_DID, sub: ALICE, spaces: [], ttlMs: 60_000 }, - otherKey - ); - const res = await app.fetch( - inProc( - "http://localhost/xrpc/test.man2.message.listRecords", - ALICE, - { "X-Membership-Manifest": manifest } - ) - ); - expect(res.status).toBe(401); - expect((await res.json() as any).reason).toBe("bad-signature"); - }); - - it("falls back to local listSpaces when no manifest is present", async () => { - const app = await makeApp(); - const aliceSpace = await createSpace(app, ALICE, "alice-only"); - const bobSpace = await createSpace(app, BOB, "bob-only"); - await plant(app, ALICE, aliceSpace, "from-alice"); - await plant(app, BOB, bobSpace, "from-bob"); - - // Alice queries with no manifest → local listSpaces returns aliceSpace only. - const res = await app.fetch( - inProc("http://localhost/xrpc/test.man2.message.listRecords", ALICE) - ); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - const texts = body.records.map((r: any) => r.value.text); - expect(texts).toContain("from-alice"); - expect(texts).not.toContain("from-bob"); - }); - - it("anonymous (no auth, no manifest) gets public results — no 401", async () => { - const app = await makeApp(); - const aliceSpace = await createSpace(app, ALICE, "alice-only"); - await plant(app, ALICE, aliceSpace, "from-alice"); - - const res = await app.fetch( - new Request("http://localhost/xrpc/test.man2.message.listRecords") - ); - // No auth header, no manifest → drops through to anonymous public path. - expect(res.status).toBe(200); - const body = (await res.json()) as any; - // Private space records aren't visible publicly. - const texts = (body.records ?? []).map((r: any) => r.value?.text); - expect(texts).not.toContain("from-alice"); - }); -}); diff --git a/packages/contrail/tests/spaces-manifest.test.ts b/packages/contrail/tests/spaces-manifest.test.ts deleted file mode 100644 index 3b600f5..0000000 --- a/packages/contrail/tests/spaces-manifest.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema } from "../src/core/db/schema"; -import { createApp } from "../src/core/router"; -import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig } from "../src/core/types"; -import { - generateAuthoritySigningKey, - signMembershipManifest, - verifyMembershipManifest, - decodeUnverifiedManifest, - issueMembershipManifest, -} from "@atmo-dev/contrail-base"; -import type { CredentialKeyMaterial } from "@atmo-dev/contrail-base"; - -const ALICE = "did:plc:alice"; -const BOB = "did:plc:bob"; -const CHARLIE = "did:plc:charlie"; - -const SERVICE_DID = "did:web:test.example#svc"; - -let SIGNING: CredentialKeyMaterial; - -beforeAll(async () => { - SIGNING = await generateAuthoritySigningKey(); -}); - -function makeConfig(overrides?: Partial<ContrailConfig["spaces"] extends { authority?: infer A } ? A : never>): ContrailConfig { - return { - namespace: "test.man", - collections: { - message: { collection: "app.event.message" }, - }, - spaces: { - authority: { - type: "tools.atmo.event.space", - serviceDid: SERVICE_DID, - signing: SIGNING, - manifestTtlMs: 60_000, - ...(overrides ?? {}), - }, - recordHost: {}, - }, - }; -} - -function fakeAuth(): MiddlewareHandler { - return async (c, next) => { - const did = c.req.header("X-Test-Did"); - if (!did) return c.json({ error: "AuthRequired" }, 401); - c.set("serviceAuth", { - issuer: did, - audience: SERVICE_DID, - lxm: undefined, - clientId: c.req.header("X-Test-App") ?? undefined, - }); - await next(); - }; -} - -async function makeApp(cfg: ContrailConfig = makeConfig()): Promise<Hono> { - const db = createSqliteDatabase(":memory:"); - const resolved = resolveConfig(cfg); - await initSchema(db, resolved); - return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); -} - -function call(app: Hono, method: string, path: string, did: string | null, body?: any) { - const headers: Record<string, string> = {}; - if (did) headers["X-Test-Did"] = did; - if (body !== undefined) headers["Content-Type"] = "application/json"; - return app.fetch( - new Request(`http://localhost${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) - ); -} - -async function createSpace(app: Hono, owner: string): Promise<string> { - const res = await call(app, "POST", "/xrpc/test.man.space.createSpace", owner, {}); - expect(res.status).toBe(200); - return ((await res.json()) as any).space.uri; -} - -describe("membership manifest — sign/verify primitives", () => { - it("round-trips via verifyMembershipManifest", async () => { - const { manifest } = await issueMembershipManifest( - { - iss: SERVICE_DID, - sub: ALICE, - spaces: ["ats://a/x/1", "ats://a/x/2"], - ttlMs: 60_000, - }, - SIGNING - ); - const result = await verifyMembershipManifest(manifest, { - resolveKey: async (iss) => (iss === SERVICE_DID ? SIGNING.publicKey : null), - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.claims.iss).toBe(SERVICE_DID); - expect(result.claims.sub).toBe(ALICE); - expect(result.claims.spaces).toEqual(["ats://a/x/1", "ats://a/x/2"]); - } - }); - - it("rejects expired manifests", async () => { - const past = Math.floor(Date.now() / 1000) - 10; - const manifest = await signMembershipManifest( - { - iss: SERVICE_DID, - sub: ALICE, - spaces: ["ats://a/x/1"], - iat: past - 60, - exp: past, - }, - SIGNING - ); - const result = await verifyMembershipManifest(manifest, { - resolveKey: async () => SIGNING.publicKey, - }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.reason).toBe("expired"); - }); - - it("rejects bad signatures", async () => { - const { manifest } = await issueMembershipManifest( - { iss: SERVICE_DID, sub: ALICE, spaces: [], ttlMs: 60_000 }, - SIGNING - ); - const otherKey = await generateAuthoritySigningKey(); - const result = await verifyMembershipManifest(manifest, { - resolveKey: async () => otherKey.publicKey, - }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.reason).toBe("bad-signature"); - }); - - it("rejects unknown issuer", async () => { - const { manifest } = await issueMembershipManifest( - { iss: SERVICE_DID, sub: ALICE, spaces: [], ttlMs: 60_000 }, - SIGNING - ); - const result = await verifyMembershipManifest(manifest, { - resolveKey: async () => null, - }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.reason).toBe("unknown-issuer"); - }); - - it("decodeUnverifiedManifest peeks without verifying", async () => { - const { manifest } = await issueMembershipManifest( - { iss: SERVICE_DID, sub: ALICE, spaces: ["ats://a/x/1"], ttlMs: 60_000 }, - SIGNING - ); - const claims = decodeUnverifiedManifest(manifest); - expect(claims).not.toBeNull(); - expect(claims!.sub).toBe(ALICE); - expect(claims!.spaces).toEqual(["ats://a/x/1"]); - }); -}); - -describe("membership manifest — getMembershipManifest endpoint", () => { - it("returns a manifest covering owned + joined spaces", async () => { - const app = await makeApp(); - // Alice owns 2; Bob joins one of them. - const uriA1 = await createSpace(app, ALICE); - const uriA2 = await createSpace(app, ALICE); - await call(app, "POST", "/xrpc/test.man.space.addMember", ALICE, { - spaceUri: uriA1, - did: BOB, - }); - // Bob also owns one. - const uriB1 = await createSpace(app, BOB); - - const res = await call(app, "POST", "/xrpc/test.man.space.getMembershipManifest", BOB); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - expect(body.manifest).toBeTypeOf("string"); - expect(body.expiresAt).toBeTypeOf("number"); - expect(body.truncated).toBe(false); - - // Verify signature + payload. - const result = await verifyMembershipManifest(body.manifest, { - resolveKey: async (iss) => (iss === SERVICE_DID ? SIGNING.publicKey : null), - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.claims.iss).toBe(SERVICE_DID); - expect(result.claims.sub).toBe(BOB); - expect(result.claims.spaces.sort()).toEqual([uriA1, uriB1].sort()); - } - - // Alice's membership shouldn't be visible to Bob's manifest. - if (result.ok) expect(result.claims.spaces).not.toContain(uriA2); - }); - - it("returns an empty spaces array for a user with no memberships", async () => { - const app = await makeApp(); - const res = await call(app, "POST", "/xrpc/test.man.space.getMembershipManifest", CHARLIE); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - const result = await verifyMembershipManifest(body.manifest, { - resolveKey: async () => SIGNING.publicKey, - }); - expect(result.ok).toBe(true); - if (result.ok) expect(result.claims.spaces).toEqual([]); - }); - - it("dedupes — owner who is also a member appears once", async () => { - const app = await makeApp(); - const uri = await createSpace(app, ALICE); - // createSpace internally addMembers the owner; both scopes will include it. - const res = await call(app, "POST", "/xrpc/test.man.space.getMembershipManifest", ALICE); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - const result = await verifyMembershipManifest(body.manifest, { - resolveKey: async () => SIGNING.publicKey, - }); - expect(result.ok).toBe(true); - if (result.ok) expect(result.claims.spaces).toEqual([uri]); - }); - - it("sets truncated:true when over manifestMaxSpaces", async () => { - const cfg = makeConfig(); - cfg.spaces!.authority!.manifestMaxSpaces = 2; - const app = await makeApp(cfg); - await createSpace(app, ALICE); - await createSpace(app, ALICE); - await createSpace(app, ALICE); - - const res = await call(app, "POST", "/xrpc/test.man.space.getMembershipManifest", ALICE); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - expect(body.truncated).toBe(true); - const result = await verifyMembershipManifest(body.manifest, { - resolveKey: async () => SIGNING.publicKey, - }); - expect(result.ok).toBe(true); - if (result.ok) expect(result.claims.spaces.length).toBe(2); - }); - - it("requires auth", async () => { - const app = await makeApp(); - const res = await call(app, "POST", "/xrpc/test.man.space.getMembershipManifest", null); - expect(res.status).toBe(401); - }); - - it("returns 501 when authority is not configured to sign", async () => { - const cfg = makeConfig(); - delete cfg.spaces!.authority!.signing; - const app = await makeApp(cfg); - const res = await call(app, "POST", "/xrpc/test.man.space.getMembershipManifest", ALICE); - expect(res.status).toBe(501); - }); -}); diff --git a/packages/contrail/tests/sync-e2e.test.ts b/packages/contrail/tests/sync-e2e.test.ts deleted file mode 100644 index 154385f..0000000 --- a/packages/contrail/tests/sync-e2e.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -/** End-to-end multi-host sync test. - * - * Two Contrail instances (host A and appview B) running in one process, - * each with its own SQLite DB. Host A creates a space and writes records. - * Appview B opens a sync stream against host A and ingests the records - * into its own tables. We verify B can query the records locally. - * - * The "network" between them is host A's hono fetch, threaded through - * appview B's fetch parameter. Real deployments would use the actual - * fetch over HTTPS. */ - -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema } from "../src/core/db/schema"; -import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig } from "../src/core/types"; -import { HostedAdapter } from "../src/core/spaces/adapter"; -import { registerRecordHostSyncRoutes } from "@atmo-dev/contrail-record-host"; -import { runRecordHostSync, applyRecordSyncSchema } from "@atmo-dev/contrail-appview"; -import { - generateAuthoritySigningKey, - issueCredential, - createInProcessVerifier, - InMemoryPubSub, -} from "@atmo-dev/contrail-base"; -import type { CredentialKeyMaterial } from "@atmo-dev/contrail-base"; -import { wrapWithPublishing } from "../src/core/realtime/publishing-adapter"; - -const ALICE = "did:plc:alice"; -const SERVICE_DID = "did:web:test.example#svc"; -const SPACE_TYPE = "tools.atmo.event.space"; -const SPACE_KEY = "main"; -const SPACE_URI = `ats://${ALICE}/${SPACE_TYPE}/${SPACE_KEY}`; - -let SIGNING: CredentialKeyMaterial; - -beforeAll(async () => { - SIGNING = await generateAuthoritySigningKey(); -}); - -const HOST_CONFIG: ContrailConfig = { - namespace: "test.sync", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { type: SPACE_TYPE, serviceDid: SERVICE_DID }, - recordHost: {}, - }, -}; - -async function makeHost(): Promise<{ - app: Hono; - adapter: HostedAdapter; - db: any; -}> { - const db = createSqliteDatabase(":memory:"); - const cfg = { ...HOST_CONFIG }; - cfg.spaces!.authority!.signing = SIGNING; - const resolved = resolveConfig(cfg); - await initSchema(db, resolved); - - const baseAdapter = new HostedAdapter(db, resolved); - const pubsub = new InMemoryPubSub(); - const adapter = wrapWithPublishing(baseAdapter, pubsub) as HostedAdapter; - - // Provision a space + enroll on this host. - await adapter.createSpace({ - uri: SPACE_URI, - ownerDid: ALICE, - type: SPACE_TYPE, - key: SPACE_KEY, - serviceDid: SERVICE_DID, - appPolicyRef: null, - appPolicy: null, - }); - await adapter.addMember(SPACE_URI, ALICE, ALICE); - await adapter.enroll({ - spaceUri: SPACE_URI, - authorityDid: SERVICE_DID, - enrolledAt: Date.now(), - enrolledBy: ALICE, - }); - - const app = new Hono(); - const verifier = createInProcessVerifier({ - authorityDid: SERVICE_DID, - publicKey: SIGNING.publicKey, - }); - registerRecordHostSyncRoutes(app, adapter, resolved, { - db, - pubsub, - credentialVerifier: verifier, - keepaliveMs: 60_000, - batchSize: 50, - }); - - return { app, adapter, db }; -} - -async function makeAppview(): Promise<{ db: any; adapter: HostedAdapter; resolved: any }> { - const db = createSqliteDatabase(":memory:"); - const cfg = { ...HOST_CONFIG }; - cfg.spaces!.authority!.signing = SIGNING; - const resolved = resolveConfig(cfg); - await initSchema(db, resolved, { - extraSchemas: [applyRecordSyncSchema], - }); - const adapter = new HostedAdapter(db, resolved); - return { db, adapter, resolved }; -} - -async function mintCredentialForAlice(): Promise<string> { - const { credential } = await issueCredential( - { - iss: SERVICE_DID, - sub: ALICE, - space: SPACE_URI, - scope: "rw", - ttlMs: 60_000, - }, - SIGNING - ); - return credential; -} - -describe("recordHost.sync — end-to-end (host → appview)", () => { - it("appview ingests historical records into its own tables", async () => { - const host = await makeHost(); - const appview = await makeAppview(); - - // Plant 3 records on the host. - const t0 = Date.now(); - for (let i = 0; i < 3; i++) { - await host.adapter.putRecord({ - spaceUri: SPACE_URI, - collection: "app.event.message", - authorDid: ALICE, - rkey: `rk${i}`, - cid: null, - record: { $type: "app.event.message", text: `msg-${i}` }, - createdAt: t0 + i, - }); - } - - // Appview opens a sync stream with a 200ms abort — long enough for - // catch-up to drain, then we tear down before the live phase blocks. - const ac = new AbortController(); - const credential = await mintCredentialForAlice(); - const cursors: string[] = []; - - const syncPromise = runRecordHostSync( - { - hostUrl: "http://host", - spaceUri: SPACE_URI, - authorityDid: SERVICE_DID, - credential, - }, - { - db: appview.db, - config: appview.resolved, - recordHost: appview.adapter, - // Route fetch to host A's hono app instead of the network. - fetch: ((input: any, init?: any) => { - const req = typeof input === "string" || input instanceof URL - ? new Request(input, init) - : (input as Request); - return host.app.fetch(req); - }) as typeof fetch, - signal: ac.signal, - onCursor: (c) => { - cursors.push(c); - // Once we've seen at least one cursor checkpoint, catch-up is - // making progress. Schedule abort to break out of live mode. - setTimeout(() => ac.abort(), 50); - }, - } - ).catch((err) => { - // AbortError is expected; rethrow other errors. - if (err.name !== "AbortError") throw err; - }); - - await syncPromise; - - // Verify the appview's local tables now have the host's records. - const ingested = await appview.adapter.listRecords( - SPACE_URI, - "app.event.message" - ); - expect(ingested.records).toHaveLength(3); - expect(ingested.records.map((r) => r.rkey).sort()).toEqual([ - "rk0", - "rk1", - "rk2", - ]); - expect(cursors.length).toBeGreaterThan(0); - - // Cursor was persisted in the subscriptions table. - const subRow = await appview.db - .prepare( - `SELECT cursor FROM record_sync_subscriptions WHERE host_url = ? AND space_uri = ?` - ) - .bind("http://host", SPACE_URI) - .first<{ cursor: string | null }>(); - expect(subRow?.cursor).toBeTruthy(); - - // Auto-enrolled on the appview side. - const enrollment = await appview.adapter.getEnrollment(SPACE_URI); - expect(enrollment).toBeTruthy(); - expect(enrollment?.authorityDid).toBe(SERVICE_DID); - }); - - it("appview resumes from persisted cursor — no re-emission of old records", async () => { - const host = await makeHost(); - const appview = await makeAppview(); - const credential = await mintCredentialForAlice(); - - // First batch. - const t0 = Date.now(); - await host.adapter.putRecord({ - spaceUri: SPACE_URI, - collection: "app.event.message", - authorDid: ALICE, - rkey: "first", - cid: null, - record: { text: "first" }, - createdAt: t0, - }); - - const networkFetch = ((input: any, init?: any) => { - const req = typeof input === "string" || input instanceof URL - ? new Request(input, init) - : (input as Request); - return host.app.fetch(req); - }) as typeof fetch; - - const ac1 = new AbortController(); - const seenCount: { count: number } = { count: 0 }; - await runRecordHostSync( - { - hostUrl: "http://host", - spaceUri: SPACE_URI, - authorityDid: SERVICE_DID, - credential, - }, - { - db: appview.db, - config: appview.resolved, - recordHost: appview.adapter, - fetch: networkFetch, - signal: ac1.signal, - onCursor: () => { - seenCount.count++; - setTimeout(() => ac1.abort(), 30); - }, - } - ).catch((err) => { - if (err.name !== "AbortError") throw err; - }); - - // Insert second batch on the host AFTER the first sync completed. - await host.adapter.putRecord({ - spaceUri: SPACE_URI, - collection: "app.event.message", - authorDid: ALICE, - rkey: "second", - cid: null, - record: { text: "second" }, - createdAt: t0 + 100, - }); - - // Second sync run — should pick up only the new record, using the - // persisted cursor. - const ac2 = new AbortController(); - const ingestedRkeys: string[] = []; - // Wrap putRecord to observe what gets re-ingested. - const origPut = appview.adapter.putRecord.bind(appview.adapter); - appview.adapter.putRecord = async (record) => { - ingestedRkeys.push(record.rkey); - return origPut(record); - }; - - await runRecordHostSync( - { - hostUrl: "http://host", - spaceUri: SPACE_URI, - authorityDid: SERVICE_DID, - credential, - }, - { - db: appview.db, - config: appview.resolved, - recordHost: appview.adapter, - fetch: networkFetch, - signal: ac2.signal, - onCursor: () => setTimeout(() => ac2.abort(), 30), - } - ).catch((err) => { - if (err.name !== "AbortError") throw err; - }); - - // The second run should have ingested ONLY "second" (not re-ingested "first"). - expect(ingestedRkeys).toEqual(["second"]); - }); -}); diff --git a/packages/contrail/tests/sync-host.test.ts b/packages/contrail/tests/sync-host.test.ts deleted file mode 100644 index e07e5a3..0000000 --- a/packages/contrail/tests/sync-host.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** Tests for the recordHost.sync SSE endpoint. */ - -import { describe, it, expect, beforeAll } from "vitest"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { initSchema } from "../src/core/db/schema"; -import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig } from "../src/core/types"; -import { HostedAdapter } from "../src/core/spaces/adapter"; -import { registerRecordHostSyncRoutes } from "@atmo-dev/contrail-record-host"; -import { - generateAuthoritySigningKey, - issueCredential, - createInProcessVerifier, - InMemoryPubSub, - spaceTopic, -} from "@atmo-dev/contrail-base"; -import type { CredentialKeyMaterial } from "@atmo-dev/contrail-base"; -import { wrapWithPublishing } from "../src/core/realtime/publishing-adapter"; - -const ALICE = "did:plc:alice"; -const SERVICE_DID = "did:web:test.example#svc"; -const SPACE_TYPE = "tools.atmo.event.space"; - -let SIGNING: CredentialKeyMaterial; - -beforeAll(async () => { - SIGNING = await generateAuthoritySigningKey(); -}); - -const CONFIG: ContrailConfig = { - namespace: "test.sync", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { - type: SPACE_TYPE, - serviceDid: SERVICE_DID, - signing: undefined as any, // filled in beforeAll - }, - recordHost: {}, - }, -}; - -async function makeHost(): Promise<{ - app: Hono; - adapter: HostedAdapter; - pubsub: InMemoryPubSub; - spaceUri: string; -}> { - const db = createSqliteDatabase(":memory:"); - const cfg = { ...CONFIG }; - cfg.spaces!.authority!.signing = SIGNING; - const resolved = resolveConfig(cfg); - await initSchema(db, resolved); - - const baseAdapter = new HostedAdapter(db, resolved); - const pubsub = new InMemoryPubSub(); - // Wrap so writes also publish onto pubsub topics — mirrors what the - // umbrella createApp does in realtime mode. - const adapter = wrapWithPublishing(baseAdapter, pubsub) as HostedAdapter; - - // Create a space + enroll - const spaceUri = `ats://${ALICE}/${SPACE_TYPE}/main`; - await adapter.createSpace({ - uri: spaceUri, - ownerDid: ALICE, - type: SPACE_TYPE, - key: "main", - serviceDid: SERVICE_DID, - appPolicyRef: null, - appPolicy: null, - }); - await adapter.addMember(spaceUri, ALICE, ALICE); - await adapter.enroll({ - spaceUri, - authorityDid: SERVICE_DID, - enrolledAt: Date.now(), - enrolledBy: ALICE, - }); - - // Build the SSE app. - const app = new Hono(); - const verifier = createInProcessVerifier({ - authorityDid: SERVICE_DID, - publicKey: SIGNING.publicKey, - }); - registerRecordHostSyncRoutes(app, adapter, resolved, { - db, - pubsub, - credentialVerifier: verifier, - keepaliveMs: 60_000, - }); - - return { app, adapter, pubsub, spaceUri }; -} - -async function mintCredential(spaceUri: string): Promise<string> { - const { credential } = await issueCredential( - { - iss: SERVICE_DID, - sub: ALICE, - space: spaceUri, - scope: "rw", - ttlMs: 60_000, - }, - SIGNING - ); - return credential; -} - -/** Read the SSE response, return the first N parsed events. */ -async function readEvents( - res: Response, - count: number, - timeoutMs = 2000 -): Promise<Array<{ kind: string; payload?: any; value?: string }>> { - const reader = res.body!.getReader(); - const decoder = new TextDecoder(); - const events: Array<{ kind: string; payload?: any; value?: string }> = []; - let buf = ""; - const start = Date.now(); - while (events.length < count) { - if (Date.now() - start > timeoutMs) { - throw new Error(`timeout waiting for ${count} events; got ${events.length}`); - } - const { done, value } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - while (true) { - const idx = buf.indexOf("\n\n"); - if (idx < 0) break; - const block = buf.slice(0, idx); - buf = buf.slice(idx + 2); - // Skip comment-only blocks (`: open`, `: keepalive`). - const dataLine = block - .split("\n") - .find((l) => l.startsWith("data:")); - if (!dataLine) continue; - const json = dataLine.slice(5).trim(); - if (!json) continue; - events.push(JSON.parse(json)); - if (events.length >= count) break; - } - } - await reader.cancel().catch(() => {}); - return events; -} - -describe("recordHost.sync — catch-up phase", () => { - it("emits historical records as record.created in time_us order", async () => { - const { app, adapter, spaceUri } = await makeHost(); - - // Plant 3 records with deterministic timestamps. - const now = Date.now(); - for (let i = 0; i < 3; i++) { - await adapter.putRecord({ - spaceUri, - collection: "app.event.message", - authorDid: ALICE, - rkey: `rk${i}`, - cid: null, - record: { $type: "app.event.message", text: `msg-${i}` }, - createdAt: now + i, - }); - } - - const credential = await mintCredential(spaceUri); - const res = await app.fetch( - new Request( - `http://localhost/xrpc/test.sync.recordHost.sync?spaceUri=${encodeURIComponent(spaceUri)}`, - { headers: { "X-Space-Credential": credential } } - ) - ); - expect(res.status).toBe(200); - - // Expect 3 record.created + at least one cursor checkpoint. - const events = await readEvents(res, 4); - const records = events.filter((e) => e.kind === "record.created"); - expect(records).toHaveLength(3); - expect(records.map((e) => e.payload.rkey)).toEqual(["rk0", "rk1", "rk2"]); - expect(records.every((e) => e.payload.space === spaceUri)).toBe(true); - const cursors = events.filter((e) => e.kind === "cursor"); - expect(cursors.length).toBeGreaterThan(0); - }); - - it("respects the since cursor — records at or before are skipped", async () => { - const { app, adapter, spaceUri } = await makeHost(); - - const t0 = 1_700_000_000_000; - await adapter.putRecord({ - spaceUri, - collection: "app.event.message", - authorDid: ALICE, - rkey: "old", - cid: null, - record: { text: "old" }, - createdAt: t0, - }); - await adapter.putRecord({ - spaceUri, - collection: "app.event.message", - authorDid: ALICE, - rkey: "new", - cid: null, - record: { text: "new" }, - createdAt: t0 + 100, - }); - - const credential = await mintCredential(spaceUri); - const res = await app.fetch( - new Request( - `http://localhost/xrpc/test.sync.recordHost.sync?spaceUri=${encodeURIComponent(spaceUri)}&since=${t0}`, - { headers: { "X-Space-Credential": credential } } - ) - ); - expect(res.status).toBe(200); - - const events = await readEvents(res, 2); - const records = events.filter((e) => e.kind === "record.created"); - expect(records).toHaveLength(1); - expect(records[0]!.payload.rkey).toBe("new"); - }); -}); - -describe("recordHost.sync — auth + enrollment guards", () => { - it("rejects without a credential", async () => { - const { app, spaceUri } = await makeHost(); - const res = await app.fetch( - new Request( - `http://localhost/xrpc/test.sync.recordHost.sync?spaceUri=${encodeURIComponent(spaceUri)}` - ) - ); - expect(res.status).toBe(401); - expect((await res.json() as any).reason).toBe("credential-required"); - }); - - it("rejects credential whose space doesn't match", async () => { - const { app, spaceUri } = await makeHost(); - const wrongSpaceCred = await issueCredential( - { - iss: SERVICE_DID, - sub: ALICE, - space: "ats://did:plc:alice/x/y", - scope: "rw", - ttlMs: 60_000, - }, - SIGNING - ); - const res = await app.fetch( - new Request( - `http://localhost/xrpc/test.sync.recordHost.sync?spaceUri=${encodeURIComponent(spaceUri)}`, - { headers: { "X-Space-Credential": wrongSpaceCred.credential } } - ) - ); - expect(res.status).toBe(403); - expect((await res.json() as any).reason).toBe("credential-wrong-space"); - }); - - it("rejects un-enrolled spaces", async () => { - const { app } = await makeHost(); - const otherSpaceUri = `ats://${ALICE}/${SPACE_TYPE}/different`; - const cred = await issueCredential( - { - iss: SERVICE_DID, - sub: ALICE, - space: otherSpaceUri, - scope: "rw", - ttlMs: 60_000, - }, - SIGNING - ); - const res = await app.fetch( - new Request( - `http://localhost/xrpc/test.sync.recordHost.sync?spaceUri=${encodeURIComponent(otherSpaceUri)}`, - { headers: { "X-Space-Credential": cred.credential } } - ) - ); - expect(res.status).toBe(404); - expect((await res.json() as any).reason).toBe("not-enrolled"); - }); -}); diff --git a/packages/contrail/tests/types.test.ts b/packages/contrail/tests/types.test.ts index 7c7fc26..c485fd6 100644 --- a/packages/contrail/tests/types.test.ts +++ b/packages/contrail/tests/types.test.ts @@ -12,7 +12,7 @@ import { getDiscoverableNsids, getDependentNsids, shortNameForNsid, -} from "../src/core/types"; +} from "../src/index"; describe("validateFieldName", () => { it("accepts simple field names", () => { diff --git a/packages/contrail/tests/worker.test.ts b/packages/contrail/tests/worker.test.ts index e31b6fd..98ce8e9 100644 --- a/packages/contrail/tests/worker.test.ts +++ b/packages/contrail/tests/worker.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { createWorker } from "../src/worker"; import { createSqliteDatabase } from "../src/adapters/sqlite"; -import type { ContrailConfig } from "../src/core/types"; +import type { ContrailConfig } from "../src/index"; const MINIMAL_CONFIG: ContrailConfig = { namespace: "com.example", diff --git a/packages/contrail/vitest.config.ts b/packages/contrail/vitest.config.ts index 7f32f75..1542c86 100644 --- a/packages/contrail/vitest.config.ts +++ b/packages/contrail/vitest.config.ts @@ -1,27 +1,9 @@ import { defineConfig } from "vitest/config"; -import path from "node:path"; - -const baseSrc = path.resolve(__dirname, "../contrail-base/src"); -const authoritySrc = path.resolve(__dirname, "../contrail-authority/src"); -const recordHostSrc = path.resolve(__dirname, "../contrail-record-host/src"); -const appviewSrc = path.resolve(__dirname, "../contrail-appview/src"); export default defineConfig({ test: { include: ["tests/**/*.test.ts"], - // PostgreSQL tests share a single database and cannot run in parallel + // PostgreSQL tests share one database and cannot run in parallel. fileParallelism: false, }, - resolve: { - alias: { - // Resolve workspace-internal contrail-* subpaths to source so tests - // don't run through the dists (where tsup drops `node:` prefixes). - "@atmo-dev/contrail-base/sqlite": path.join(baseSrc, "adapters/sqlite.ts"), - "@atmo-dev/contrail-base/postgres": path.join(baseSrc, "adapters/postgres.ts"), - "@atmo-dev/contrail-base": path.join(baseSrc, "index.ts"), - "@atmo-dev/contrail-authority": path.join(authoritySrc, "index.ts"), - "@atmo-dev/contrail-record-host": path.join(recordHostSrc, "index.ts"), - "@atmo-dev/contrail-appview": path.join(appviewSrc, "index.ts"), - }, - }, }); diff --git a/packages/lexicons/CHANGELOG.md b/packages/lexicons/CHANGELOG.md deleted file mode 100644 index 310d7cd..0000000 --- a/packages/lexicons/CHANGELOG.md +++ /dev/null @@ -1,233 +0,0 @@ -# @atmo-dev/contrail-lexicons - -## 0.4.15 - -### Patch Changes - -- @atmo-dev/contrail@0.12.2 - -## 0.4.14 - -### Patch Changes - -- 74a2d3d: Make NSID-keyed collections work through normal ingestion, not just FTS. - - When a collection is keyed directly by its NSID (no short alias, `collection` - field omitted), the value defaulted to `undefined` everywhere it was read. The - records insert and FTS sync were patched via `resolveCollectionKey`, but the - real ingestion entry points still skipped these collections: `getCollectionNsids` - / `getDiscoverableNsids` / `getDependentNsids` produced `undefined` NSIDs (so - Jetstream never subscribed and backfill never ran), `shortNameForNsid` returned - undefined (so `notify` rejected the URI as "collection not tracked"), and - `validateConfig` rejected the config outright (missing `collection`, dotted key - failing short-name validation). - - `CollectionConfig.collection` is now optional. `resolveConfig` normalizes an - omitted `collection` to the map key, `validateConfig` accepts NSID-keyed entries, - and every collection-list / lookup helper resolves the NSID as `collection ?? key` - so the behavior is correct on both raw and resolved configs. - </content> - - - @atmo-dev/contrail@0.12.1 - -## 0.4.13 - -### Patch Changes - -- @atmo-dev/contrail@0.12.0 - -## 0.4.12 - -### Patch Changes - -- @atmo-dev/contrail@0.11.0 - -## 0.4.11 - -### Patch Changes - -- @atmo-dev/contrail@0.10.0 - -## 0.4.10 - -### Patch Changes - -- @atmo-dev/contrail@0.9.1 - -## 0.4.9 - -### Patch Changes - -- @atmo-dev/contrail@0.9.0 - -## 0.4.8 - -### Patch Changes - -- Updated dependencies [bea0dd2] - - @atmo-dev/contrail@0.8.0 - -## 0.4.7 - -### Patch Changes - -- Updated dependencies [7e3145b] - - @atmo-dev/contrail@0.7.0 - -## 0.4.6 - -### Patch Changes - -- Updated dependencies [af24714] - - @atmo-dev/contrail@0.6.0 - -## 0.4.5 - -### Patch Changes - -- Include `app.bsky.graph.follow` in the generated `lex.config.js` pull list when a feed leaves `FeedConfig.follow` unset. Mirrors the runtime default that `resolveConfig` auto-adds, so `lex-cli pull` fetches the schema instead of skipping it. - -## 0.4.4 - -### Patch Changes - -- 1a6d8cf: Handle the new `FeedConfig.targets` shape (`string | { collection, maxItems? }`) when generating the feed lexicon and computing pull NSIDs, and fall back to the default `"follow"` short name when `FeedConfig.follow` is unset. -- Updated dependencies [1a6d8cf] - - @atmo-dev/contrail@0.5.0 - -## 0.4.3 - -### Patch Changes - -- 9cca8cb: fix: resolve `feeds[*].follow` short names to NSIDs when emitting `lex.config.js`. previously the generator pushed the raw short name (e.g. `"follow"`) into `pull.sources[0].nsids`, causing `lex-cli pull` to fail with `ValitaError: must be valid nsid`. now matches the existing `collections` / `profiles` resolution path; feeds pointing at unknown collections are skipped instead of leaking `undefined`. - -## 0.4.2 - -### Patch Changes - -- Updated dependencies [8513b3e] - - @atmo-dev/contrail@0.4.2 - -## 0.4.1 - -### Patch Changes - -- Updated dependencies [0e6ba77] - - @atmo-dev/contrail@0.4.1 - -## 0.4.0 - -### Minor Changes - -- 469bf65: permissioned spaces now use the `ats://` scheme instead of `at://`. tracks the [permissioned data spec](https://dholms.leaflet.pub/3mhj6bcqats2o), which floats `ats://` as a distinct scheme so spaces can't be confused with atproto record URIs at any layer (logs, query params, dispatch, error messages). - - ``` - - at://did:plc:alice/com.example.event.space/birthday - + ats://did:plc:alice/com.example.event.space/birthday - ``` - - what changed: - - - `buildSpaceUri` / `parseSpaceUri` (`@atmo-dev/contrail`) emit / accept `ats://`. anything else returns `null` from `parseSpaceUri`. - - generated lexicons no longer claim `format: "at-uri"` on `spaceUri` params, on the `space` record-output field, or on `spaceView.uri` — they're plain `string`. (atproto's `at-uri` format would reject `ats://`.) regenerate committed `lexicons/generated/*` with `contrail-lex generate`; downstream `lex-cli generate` then emits `v.string()` instead of `v.resourceUriString()` for those fields. - - realtime topics are unchanged in shape (`space:<uri>`), but `<uri>` is now an `ats://` URI. - - record URIs (the `uri` on a record, the `appPolicyRef` field, `notifyOfUpdate` payloads) keep `at://` — those are still atproto record URIs. - - **breaking.** anywhere you build a space URI by string concatenation (`` `at://${did}/${type}/${key}` ``), switch to `ats://` or call `buildSpaceUri()`. anywhere you persist space URIs in your own DB, migrate (`UPDATE … SET space_uri = REPLACE(space_uri, 'at://', 'ats://') WHERE space_uri LIKE 'at://%'`). - -### Patch Changes - -- Updated dependencies [469bf65] -- Updated dependencies [469bf65] - - @atmo-dev/contrail@0.4.0 - -## 0.3.0 - -### Minor Changes - -- f8fa672: align the `listRecords` / `getRecord` response envelope with atproto's `com.atproto.repo.*`. the field that carries the record value is now `value`, not `record`. - - **before** (contrail-specific): - - ```jsonc - { "records": [{ "uri", "did", "collection", "rkey", "cid", "record": {...}, "time_us" }] } - ``` - - **after** (atproto-compatible plus extras): - - ```jsonc - { "records": [{ "uri", "cid", "value": {...}, "did", "collection", "rkey", "time_us" }] } - ``` - - changes: - - - `#record` def now requires `["uri", "cid", "value"]` (matches atproto's standard `com.atproto.repo.listRecords#record`). `did`/`collection`/`rkey`/`time_us` remain in the response but are optional. - - `getRecord` top-level output requires `["uri", "value"]` (matches atproto's `com.atproto.repo.getRecord`). - - profile entries in `?profiles=true` responses use `value` instead of `record` for the profile record body. - - realtime watch events (`record.created`, `snapshot.record`, `hydration.added`) — the inner record payload's body field is now `value`. - - `@atmo-dev/contrail-sync`: `WatchRecord.value` (was `record`); `addOptimistic({ value })` (was `record`). - - **breaking.** anywhere you read `r.record` from a contrail response, rename to `r.value`. anywhere you call `addOptimistic({ record: ... })`, switch to `addOptimistic({ value: ... })`. regenerate committed `lexicons/generated/*` in each deployment — the new shape will be advertised on next `contrail-lex generate` run. - -- ad3063a: two new DX pieces: - - **`@atmo-dev/contrail/worker`** exports `createWorker(config, options?)` — a prebuilt Cloudflare Workers entry that collapses the ~12-line `{ fetch, scheduled }` boilerplate to one line: - - ```ts - import { createWorker } from "@atmo-dev/contrail/worker"; - import { config } from "./contrail.config"; - import { lexicons } from "../lexicons/generated"; - - export default createWorker(config, { lexicons }); - ``` - - options: `binding` (D1 binding name, default `"DB"`), `lexicons` (see below), `onInit` (one-shot app-specific setup). - - **`/xrpc/<ns>.lexicons` endpoint + `contrail-lex pull-service`** lets consumer apps typegen against a deployed contrail over HTTP, no PDS or DNS required: - - - `contrail-lex generate` now emits a barrel `lexicons/generated/index.ts` that imports every lexicon the deployment speaks: generated + pulled + custom. The pulled lexicons are needed so consumer typegen can resolve `$ref`s out of the generated schemas. - - Pass `{ lexicons }` to `createWorker` (or `createHandler(contrail, { lexicons })`) and the service exposes them at `GET /xrpc/<namespace>.lexicons`. - - From a consumer app: - ```bash - contrail-lex pull-service https://my-contrail.dev/xrpc/com.example.lexicons - # or - contrail-lex pull-service https://my-contrail.dev --namespace com.example - ``` - Fetches the manifest, writes each lexicon under `lexicons/pulled/`. Then `npx lex-cli generate` emits TS types. - - Path 1 of 4 of a set of DX improvements — path 2 (consumer typegen) works end-to-end but assumes the operator has regenerated. Paths 3 (one-command deploy) and 4 (fully vendored worker) are deferred. - -- b81038c: add `contrail-lex publish` subcommand. wraps `publishLexicons` so you can push lexicon JSON to a PDS without writing a script: - - ```bash - contrail-lex publish <handle> <app-password> - # or via env: - LEXICON_ACCOUNT_IDENTIFIER=you.bsky.social LEXICON_ACCOUNT_PASSWORD=xxxx contrail-lex publish - ``` - - supports `--generated-dir` (default `lexicons-generated`), `--skip-confirm` (for CI), and `--dry-run` (print what would be published + the DNS records needed, no writes, credentials not required). - -### Patch Changes - -- f8fa672: the lexicon generator previously auto-detected queryable fields by walking the pulled record schema (string → equality, datetime → range, etc.) and merged them with the user's explicit `queryable` before emitting `listRecords.json`. - - problem: the runtime does **not** auto-detect — it only honors fields explicitly declared in `colConfig.queryable`. So the generated lexicon advertised filter params (e.g. `?mode=online`, `?status=going`) that the server silently ignored. Clients would pass them and get unfiltered results back. - - fix: the generator now only emits what the user declared. the lexicon matches the runtime. one source of truth. - - if you were relying on the phantom params, add the fields explicitly to your config's `queryable` map. if you weren't, nothing changes except smaller, more honest `listRecords.json` files on the next `contrail-lex generate` run. - -- b81038c: fix `contrail-lex --config <path.ts>` with plain TS files. previously broke with `ERR_UNKNOWN_FILE_EXTENSION` under plain node — bare `contrail-lex` invocation couldn't load TS configs. now uses `jiti` to handle TS + ESM + CJS transparently, so invocations like `contrail-lex all --config src/config.ts` work without needing tsx/ts-node preregistered. -- Updated dependencies [f8fa672] -- Updated dependencies [b81038c] -- Updated dependencies [ad3a61d] -- Updated dependencies [ad3063a] -- Updated dependencies [b81038c] -- Updated dependencies [3ee5ed4] - - @atmo-dev/contrail@0.3.0 - -## 0.2.0 - -### Minor Changes - -- 97bd494: split packages, monorepo diff --git a/packages/lexicons/README.md b/packages/lexicons/README.md deleted file mode 100644 index 84776a1..0000000 --- a/packages/lexicons/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# @atmo-dev/contrail-lexicons - -Generate atproto lexicon JSON from a [contrail](https://www.npmjs.com/package/@atmo-dev/contrail) config, and (optionally) drive the full `@atcute/lex-cli` pull + type-generation pipeline from a single command. - -## Install - -```bash -pnpm add -D @atmo-dev/contrail-lexicons @atcute/lex-cli -``` - -`@atcute/lex-cli` is a peer dep — you pin the version. - -## CLI - -```bash -contrail-lex generate # emit lexicon JSON from your Contrail config -contrail-lex pull # wraps `lex-cli pull` -contrail-lex types # wraps `lex-cli generate` -contrail-lex all # generate → pull → generate → pull → types -contrail-lex all --no-types # skip the final type-generation step -``` - -The CLI auto-detects your Contrail config at `contrail.config.ts`, `app/config.ts`, or `src/lib/contrail/config.ts` (first match wins). Override with `--config <path>`. - -For anything the `all` subcommand doesn't cover — custom output dirs, extra pull sources, multiple `lex.config.js` files — call `lex-cli` directly. This package doesn't hide or replace it. - -## Programmatic API - -```ts -import { generateLexicons } from "@atmo-dev/contrail-lexicons"; - -const generated = generateLexicons({ - config, - rootDir: process.cwd(), - outputDir: "lexicons-generated", -}); -``` - -See also: `extractXrpcMethods`, `listXrpcMethods`, `publishLexicons`. diff --git a/packages/lexicons/lexicon-templates/community/adopt.json b/packages/lexicons/lexicon-templates/community/adopt.json deleted file mode 100644 index 1a9441a..0000000 --- a/packages/lexicons/lexicon-templates/community/adopt.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.adopt", - "defs": { - "main": { - "type": "procedure", - "description": "Adopt an existing ATProto account as a community identity. The provided app password is verified by creating a session, then stored encrypted. Creates the reserved `$admin` and `$publishers` spaces with the caller as `owner` in both.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["identifier", "appPassword"], - "properties": { - "identifier": { "type": "string", "description": "Handle or DID of the account to adopt." }, - "appPassword": { "type": "string", "description": "App password for the account; verified by createSession and stored encrypted." } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["communityDid"], - "properties": { - "communityDid": { "type": "string", "format": "did" } - } - } - }, - "errors": [ - { "name": "InvalidRequest" }, - { "name": "Unauthorized" }, - { "name": "AlreadyExists" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/defs.json b/packages/lexicons/lexicon-templates/community/defs.json deleted file mode 100644 index d98b57c..0000000 --- a/packages/lexicons/lexicon-templates/community/defs.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.defs", - "description": "Shared types for community-module XRPC methods.", - "defs": { - "accessLevel": { - "type": "string", - "knownValues": ["member", "manager", "admin", "owner"], - "description": "Module-internal access levels that govern community operations on a given space. Ordered: member < manager < admin < owner." - }, - "communityView": { - "type": "object", - "required": ["did", "mode", "createdAt"], - "properties": { - "did": { "type": "string", "format": "did" }, - "mode": { "type": "string", "knownValues": ["adopt", "mint"] }, - "identifier": { "type": "string", "description": "Handle or DID at adoption time (adopt only)." }, - "createdAt": { "type": "integer" } - } - }, - "spaceView": { - "type": "object", - "required": ["uri", "ownerDid", "type", "key", "serviceDid", "createdAt"], - "properties": { - "uri": { "type": "string" }, - "ownerDid": { "type": "string", "format": "did" }, - "type": { "type": "string", "format": "nsid" }, - "key": { "type": "string" }, - "serviceDid": { "type": "string" }, - "createdAt": { "type": "integer" } - } - }, - "subject": { - "type": "object", - "description": "Exactly one of `did` or `spaceUri` must be set.", - "properties": { - "did": { "type": "string", "format": "did" }, - "spaceUri": { "type": "string" } - } - }, - "memberRow": { - "type": "object", - "required": ["subject", "accessLevel", "grantedBy", "grantedAt"], - "properties": { - "subject": { "type": "ref", "ref": "#subject" }, - "accessLevel": { "type": "ref", "ref": "#accessLevel" }, - "grantedBy": { "type": "string", "format": "did" }, - "grantedAt": { "type": "integer" } - } - }, - "flatMember": { - "type": "object", - "required": ["did", "addedAt"], - "properties": { - "did": { "type": "string", "format": "did" }, - "addedAt": { "type": "integer" } - } - }, - "inviteView": { - "type": "object", - "required": ["tokenHash", "spaceUri", "accessLevel", "createdBy", "createdAt", "usedCount"], - "properties": { - "tokenHash": { "type": "string", "description": "SHA-256 of the raw token. Stable id for list/revoke; never grants access on its own." }, - "spaceUri": { "type": "string" }, - "accessLevel": { "type": "ref", "ref": "#accessLevel" }, - "createdBy": { "type": "string", "format": "did" }, - "createdAt": { "type": "integer" }, - "expiresAt": { "type": "integer", "description": "Unix ms. Omitted for no expiry." }, - "maxUses": { "type": "integer", "description": "Omitted for unlimited." }, - "usedCount": { "type": "integer" }, - "revokedAt": { "type": "integer", "description": "Unix ms. Omitted if not revoked." }, - "note": { "type": "string" } - } - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/delete.json b/packages/lexicons/lexicon-templates/community/delete.json deleted file mode 100644 index dffb31c..0000000 --- a/packages/lexicons/lexicon-templates/community/delete.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.delete", - "defs": { - "main": { - "type": "procedure", - "description": "Soft-delete a community. Also soft-deletes all spaces owned by the community. Caller must have `owner` in the community's `$admin` space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["communityDid"], - "properties": { - "communityDid": { "type": "string", "format": "did" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { "ok": { "type": "boolean" } } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/deleteRecord.json b/packages/lexicons/lexicon-templates/community/deleteRecord.json deleted file mode 100644 index cd10cad..0000000 --- a/packages/lexicons/lexicon-templates/community/deleteRecord.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.deleteRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Delete a public record authored by the community from the community's PDS. Adopted communities only. Caller must be `member` or higher in the community's `$publishers` space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["communityDid", "collection", "rkey"], - "properties": { - "communityDid": { "type": "string", "format": "did" }, - "collection": { "type": "string", "format": "nsid" }, - "rkey": { "type": "string" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { "ok": { "type": "boolean" } } - } - }, - "errors": [ - { "name": "InvalidRequest" }, - { "name": "NotFound" }, - { "name": "Forbidden" }, - { "name": "NotSupported" }, - { "name": "UpstreamFailure" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/getHealth.json b/packages/lexicons/lexicon-templates/community/getHealth.json deleted file mode 100644 index 4994700..0000000 --- a/packages/lexicons/lexicon-templates/community/getHealth.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.getHealth", - "defs": { - "main": { - "type": "query", - "description": "Check whether stored credentials for the community are still usable. Adopted: attempts a live session creation. Minted: verifies the signing key can be decrypted. Caller must have at least `member` in the community's `$admin` space.", - "parameters": { - "type": "params", - "required": ["communityDid"], - "properties": { - "communityDid": { "type": "string", "format": "did" } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["status"], - "properties": { - "status": { - "type": "string", - "knownValues": ["healthy", "degraded", "expired"] - } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/list.json b/packages/lexicons/lexicon-templates/community/list.json deleted file mode 100644 index abff4f0..0000000 --- a/packages/lexicons/lexicon-templates/community/list.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.list", - "defs": { - "main": { - "type": "query", - "description": "List communities where the actor has any access level in any community-owned space. Defaults to the JWT issuer when `actor` is omitted.", - "parameters": { - "type": "params", - "properties": { - "actor": { "type": "string", "format": "did", "description": "DID to query (defaults to the JWT issuer)." } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["communities"], - "properties": { - "communities": { - "type": "array", - "items": { "type": "ref", "ref": "tools.atmo.community.defs#communityView" } - } - } - } - } - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/mint.json b/packages/lexicons/lexicon-templates/community/mint.json deleted file mode 100644 index 303b703..0000000 --- a/packages/lexicons/lexicon-templates/community/mint.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.mint", - "defs": { - "main": { - "type": "procedure", - "description": "Mint a fresh did:plc for a new community. Contrail generates three P-256 keypairs (signing, contrail rotation, creator rotation), submits a genesis op to the PLC directory, and stores the signing + contrail-rotation keys encrypted. The creator's rotation key is returned once as a recovery secret and never stored.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "handle": { "type": "string", "description": "Optional handle to include in alsoKnownAs (as at://<handle>)." }, - "pdsEndpoint": { "type": "string", "description": "Optional PDS endpoint to include as the atproto_pds service." } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["communityDid", "recoveryKey"], - "properties": { - "communityDid": { "type": "string", "format": "did" }, - "recoveryKey": { "type": "unknown", "description": "Creator's rotation key as a private JWK. Shown once — cannot be retrieved later." } - } - } - }, - "errors": [ - { "name": "UpstreamFailure" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/putRecord.json b/packages/lexicons/lexicon-templates/community/putRecord.json deleted file mode 100644 index 4f8ae01..0000000 --- a/packages/lexicons/lexicon-templates/community/putRecord.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.putRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Publish a public record authored by the community via the community's PDS. Adopted communities only. Caller must be `member` or higher in the community's `$publishers` space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["communityDid", "collection", "record"], - "properties": { - "communityDid": { "type": "string", "format": "did" }, - "collection": { "type": "string", "format": "nsid" }, - "rkey": { "type": "string" }, - "record": { "type": "unknown" }, - "validate": { "type": "boolean" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "uri": { "type": "string", "format": "at-uri" }, - "cid": { "type": "string", "format": "cid" } - } - } - }, - "errors": [ - { "name": "InvalidRequest" }, - { "name": "NotFound" }, - { "name": "Forbidden" }, - { "name": "NotSupported" }, - { "name": "UpstreamFailure" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/reauth.json b/packages/lexicons/lexicon-templates/community/reauth.json deleted file mode 100644 index bc5d441..0000000 --- a/packages/lexicons/lexicon-templates/community/reauth.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.reauth", - "defs": { - "main": { - "type": "procedure", - "description": "Replace the stored app password for an adopted community. Caller must have `owner` access in the community's `$admin` space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["communityDid", "appPassword"], - "properties": { - "communityDid": { "type": "string", "format": "did" }, - "appPassword": { "type": "string" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { "ok": { "type": "boolean" } } - } - }, - "errors": [ - { "name": "InvalidRequest" }, - { "name": "NotFound" }, - { "name": "Forbidden" }, - { "name": "Unauthorized" }, - { "name": "NotSupported" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/space/create.json b/packages/lexicons/lexicon-templates/community/space/create.json deleted file mode 100644 index 1210cea..0000000 --- a/packages/lexicons/lexicon-templates/community/space/create.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.space.create", - "defs": { - "main": { - "type": "procedure", - "description": "Create a community-owned space (group/role/channel). Caller needs `admin` or higher in the community's `$admin` space. Reserved keys (`$admin`, `$publishers`, …) are rejected here.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["communityDid"], - "properties": { - "communityDid": { "type": "string", "format": "did" }, - "key": { "type": "string", "description": "Space key. Auto-generated if omitted." } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["space"], - "properties": { - "space": { "type": "ref", "ref": "tools.atmo.community.defs#spaceView" } - } - } - }, - "errors": [ - { "name": "InvalidRequest" }, - { "name": "NotFound" }, - { "name": "Forbidden" }, - { "name": "AlreadyExists" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/space/delete.json b/packages/lexicons/lexicon-templates/community/space/delete.json deleted file mode 100644 index 90b0a2a..0000000 --- a/packages/lexicons/lexicon-templates/community/space/delete.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.space.delete", - "defs": { - "main": { - "type": "procedure", - "description": "Soft-delete a community-owned space. Caller must be `owner` on the space, or `admin`+ in the community's `$admin` space. Reserved spaces cannot be deleted.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { "ok": { "type": "boolean" } } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/space/deleteRecord.json b/packages/lexicons/lexicon-templates/community/space/deleteRecord.json deleted file mode 100644 index 0662555..0000000 --- a/packages/lexicons/lexicon-templates/community/space/deleteRecord.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.space.deleteRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Delete an in-space record authored by the community DID. Caller must be `admin` or higher in the target space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri", "collection", "rkey"], - "properties": { - "spaceUri": { "type": "string" }, - "collection": { "type": "string", "format": "nsid" }, - "rkey": { "type": "string" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { "ok": { "type": "boolean" } } - } - }, - "errors": [ - { "name": "InvalidRequest" }, - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/space/grant.json b/packages/lexicons/lexicon-templates/community/space/grant.json deleted file mode 100644 index 1795576..0000000 --- a/packages/lexicons/lexicon-templates/community/space/grant.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.space.grant", - "defs": { - "main": { - "type": "procedure", - "description": "Grant or upsert a subject's access level on a space. Caller needs `manager` or higher; the granted level cannot exceed the caller's own. A subject can be a DID (member) or another space (delegated membership).", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri", "subject", "accessLevel"], - "properties": { - "spaceUri": { "type": "string" }, - "subject": { "type": "ref", "ref": "tools.atmo.community.defs#subject" }, - "accessLevel": { "type": "ref", "ref": "tools.atmo.community.defs#accessLevel" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { "ok": { "type": "boolean" } } - } - }, - "errors": [ - { "name": "InvalidRequest" }, - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/space/listMembers.json b/packages/lexicons/lexicon-templates/community/space/listMembers.json deleted file mode 100644 index f804c39..0000000 --- a/packages/lexicons/lexicon-templates/community/space/listMembers.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.space.listMembers", - "defs": { - "main": { - "type": "query", - "description": "List members of a community-owned space. By default returns the raw access-level rows; with `flatten=true` returns the flattened DID list (delegated memberships resolved).", - "parameters": { - "type": "params", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string" }, - "flatten": { "type": "boolean", "description": "If true, return the flat DID list instead of raw rows." } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "rows": { - "type": "array", - "items": { "type": "ref", "ref": "tools.atmo.community.defs#memberRow" }, - "description": "Present when flatten=false." - }, - "members": { - "type": "array", - "items": { "type": "ref", "ref": "tools.atmo.community.defs#flatMember" }, - "description": "Present when flatten=true." - } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/space/putRecord.json b/packages/lexicons/lexicon-templates/community/space/putRecord.json deleted file mode 100644 index d39f640..0000000 --- a/packages/lexicons/lexicon-templates/community/space/putRecord.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.space.putRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Write an in-space record authored by the community DID (rather than the caller). Caller must be `admin` or higher in the target space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri", "collection", "record"], - "properties": { - "spaceUri": { "type": "string" }, - "collection": { "type": "string", "format": "nsid" }, - "rkey": { "type": "string" }, - "record": { "type": "unknown" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["rkey", "authorDid", "createdAt"], - "properties": { - "rkey": { "type": "string" }, - "authorDid": { "type": "string", "format": "did" }, - "createdAt": { "type": "integer" } - } - } - }, - "errors": [ - { "name": "InvalidRequest" }, - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/space/resync.json b/packages/lexicons/lexicon-templates/community/space/resync.json deleted file mode 100644 index c622eb5..0000000 --- a/packages/lexicons/lexicon-templates/community/space/resync.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.space.resync", - "defs": { - "main": { - "type": "procedure", - "description": "Manually recompute `spaces_members` for a community-owned space. Useful after a crash mid-reconcile leaves stale membership. Caller must have `admin` or higher.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { "ok": { "type": "boolean" } } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/space/revoke.json b/packages/lexicons/lexicon-templates/community/space/revoke.json deleted file mode 100644 index 377f063..0000000 --- a/packages/lexicons/lexicon-templates/community/space/revoke.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.space.revoke", - "defs": { - "main": { - "type": "procedure", - "description": "Remove a subject's access grant from a space. Caller must have `manager` or higher and outrank the subject's current level.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri", "subject"], - "properties": { - "spaceUri": { "type": "string" }, - "subject": { "type": "ref", "ref": "tools.atmo.community.defs#subject" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { "ok": { "type": "boolean" } } - } - }, - "errors": [ - { "name": "InvalidRequest" }, - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/community/space/setAccessLevel.json b/packages/lexicons/lexicon-templates/community/space/setAccessLevel.json deleted file mode 100644 index c2877c3..0000000 --- a/packages/lexicons/lexicon-templates/community/space/setAccessLevel.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.space.setAccessLevel", - "defs": { - "main": { - "type": "procedure", - "description": "Change the access level of an existing grant. Caller must outrank both the old and new level.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri", "subject", "accessLevel"], - "properties": { - "spaceUri": { "type": "string" }, - "subject": { "type": "ref", "ref": "tools.atmo.community.defs#subject" }, - "accessLevel": { "type": "ref", "ref": "tools.atmo.community.defs#accessLevel" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { "ok": { "type": "boolean" } } - } - }, - "errors": [ - { "name": "InvalidRequest" }, - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/invite/create.json b/packages/lexicons/lexicon-templates/invite/create.json deleted file mode 100644 index 7d73f0e..0000000 --- a/packages/lexicons/lexicon-templates/invite/create.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.invite.create", - "defs": { - "main": { - "type": "procedure", - "description": "Create an invite for a space. The service dispatches on space ownership: user-owned spaces take `kind` (default `join`); community-owned spaces take `accessLevel`. Exactly one of `kind` / `accessLevel` must be set. Returns the raw token once; only the hash is stored.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string" }, - "kind": { "type": "string", "knownValues": ["join", "read", "read-join"], "description": "For user-owned spaces. join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join." }, - "accessLevel": { "type": "string", "knownValues": ["member", "manager", "admin", "owner"], "description": "For community-owned spaces. The access level granted on redemption — the creator's own level caps what they can grant." }, - "expiresAt": { "type": "integer", "description": "Unix ms timestamp. Omit for no expiry." }, - "maxUses": { "type": "integer", "minimum": 1 }, - "note": { "type": "string", "maxLength": 500 } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["token", "invite"], - "properties": { - "token": { "type": "string", "description": "Raw token. Shown once — cannot be retrieved later." }, - "invite": { "type": "ref", "ref": "tools.atmo.invite.defs#inviteView" } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" }, - { "name": "InvalidRequest" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/invite/defs.json b/packages/lexicons/lexicon-templates/invite/defs.json deleted file mode 100644 index a93cedd..0000000 --- a/packages/lexicons/lexicon-templates/invite/defs.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.invite.defs", - "defs": { - "inviteView": { - "type": "object", - "description": "An invite row as exposed to clients. Either `kind` (user-owned space) or `accessLevel` (community-owned space) is set, never both.", - "required": ["tokenHash", "spaceUri", "createdBy", "createdAt", "usedCount"], - "properties": { - "tokenHash": { "type": "string", "description": "Stable identifier for list/revoke operations." }, - "spaceUri": { "type": "string" }, - "kind": { "type": "string", "knownValues": ["join", "read", "read-join"], "description": "Set for user-owned spaces. Absent for community-owned." }, - "accessLevel": { "type": "string", "knownValues": ["member", "manager", "admin", "owner"], "description": "Set for community-owned spaces. Absent for user-owned." }, - "createdBy": { "type": "string", "format": "did" }, - "createdAt": { "type": "integer", "description": "Unix ms." }, - "expiresAt": { "type": "integer", "description": "Unix ms. Omitted for no expiry." }, - "maxUses": { "type": "integer", "minimum": 1 }, - "usedCount": { "type": "integer" }, - "revokedAt": { "type": "integer", "description": "Unix ms. Omitted if not revoked." }, - "note": { "type": "string", "maxLength": 500 } - } - } - } -} diff --git a/packages/lexicons/lexicon-templates/invite/list.json b/packages/lexicons/lexicon-templates/invite/list.json deleted file mode 100644 index da5264f..0000000 --- a/packages/lexicons/lexicon-templates/invite/list.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.invite.list", - "defs": { - "main": { - "type": "query", - "description": "List invites for a space. User-owned spaces: owner-only. Community-owned spaces: manager+.", - "parameters": { - "type": "params", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string" }, - "includeRevoked": { "type": "boolean", "default": false } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["invites"], - "properties": { - "invites": { - "type": "array", - "items": { "type": "ref", "ref": "tools.atmo.invite.defs#inviteView" } - } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/invite/redeem.json b/packages/lexicons/lexicon-templates/invite/redeem.json deleted file mode 100644 index 9556fc4..0000000 --- a/packages/lexicons/lexicon-templates/invite/redeem.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.invite.redeem", - "defs": { - "main": { - "type": "procedure", - "description": "Redeem an invite token. User-owned spaces: caller becomes a member. Community-owned spaces: caller is granted the invite's access level.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["token"], - "properties": { - "token": { "type": "string" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string" }, - "kind": { "type": "string", "description": "Set for user-owned spaces — echoes the invite kind consumed." }, - "accessLevel": { "type": "string", "description": "Set for community-owned spaces — the level granted." }, - "communityDid": { "type": "string", "format": "did", "description": "Set for community-owned spaces." } - } - } - }, - "errors": [ - { "name": "InvalidInvite" }, - { "name": "NotFound" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/invite/revoke.json b/packages/lexicons/lexicon-templates/invite/revoke.json deleted file mode 100644 index 2d1fabf..0000000 --- a/packages/lexicons/lexicon-templates/invite/revoke.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.invite.revoke", - "defs": { - "main": { - "type": "procedure", - "description": "Revoke an invite by tokenHash. User-owned spaces: owner-only. Community-owned spaces: invite creator OR manager+ on the target space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["tokenHash"], - "properties": { - "spaceUri": { "type": "string", "description": "Optional — ownership is inferred from the invite row; required for user-owned spaces for a sanity check." }, - "tokenHash": { "type": "string" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { - "ok": { "type": "boolean" } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/realtime/subscribe.json b/packages/lexicons/lexicon-templates/realtime/subscribe.json deleted file mode 100644 index fe0f8a7..0000000 --- a/packages/lexicons/lexicon-templates/realtime/subscribe.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.realtime.subscribe", - "defs": { - "main": { - "type": "query", - "description": "Open a streaming subscription. Responds with Server-Sent Events by default, or upgrades to WebSocket when the request carries `Upgrade: websocket`. Auth via `?ticket=` (browser) or `Authorization: Bearer <JWT>` (server-side). The stream closes with a final `member.removed` event if the caller loses access mid-stream.", - "parameters": { - "type": "params", - "properties": { - "ticket": { "type": "string", "description": "Subscription ticket from `.ticket`. Required if no Authorization header." }, - "topic": { "type": "string", "description": "Optional: restrict the stream to a single topic (must be in the ticket). Required on the JWT path." }, - "collection": { "type": "string", "format": "nsid", "description": "Optional: drop `record.*` events whose collection doesn't match." } - } - }, - "errors": [ - { "name": "AuthRequired" }, - { "name": "Forbidden" }, - { "name": "InvalidRequest" }, - { "name": "NotFound" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/realtime/ticket.json b/packages/lexicons/lexicon-templates/realtime/ticket.json deleted file mode 100644 index 3fa9010..0000000 --- a/packages/lexicons/lexicon-templates/realtime/ticket.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.realtime.ticket", - "defs": { - "main": { - "type": "procedure", - "description": "Mint a short-lived subscription ticket for the given topic. The caller's access is checked and — for `community:<did>` topics — expanded to the concrete list of `space:<uri>` topics the caller can see. Server-side callers may skip tickets and use their JWT directly on `.subscribe`.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["topic"], - "properties": { - "topic": { - "type": "string", - "description": "Requested topic. One of `space:<uri>`, `community:<did>`, `actor:<did>` (self-only in v1)." - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ticket", "topics", "expiresAt"], - "properties": { - "ticket": { "type": "string", "description": "HMAC-signed opaque token. Passed as `?ticket=` on `.subscribe`." }, - "topics": { "type": "array", "items": { "type": "string" }, "description": "Concrete delivery topics authorized by this ticket." }, - "expiresAt": { "type": "integer", "description": "Unix ms." } - } - } - }, - "errors": [ - { "name": "Forbidden" }, - { "name": "NotFound" }, - { "name": "NotSupported" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/recordHost/enroll.json b/packages/lexicons/lexicon-templates/recordHost/enroll.json deleted file mode 100644 index 2e7bc66..0000000 --- a/packages/lexicons/lexicon-templates/recordHost/enroll.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.recordHost.enroll", - "defs": { - "main": { - "type": "procedure", - "description": "Enroll a space on this record host. The host stores `(spaceUri, authority)` in its local enrollment table; subsequent record / blob operations for this space go through, and the credential verifier uses the enrolled authority as the canonical binding source. Caller must be either the space owner OR the declared authority — the host treats either as a sufficient signal of consent. Idempotent: re-enrolling updates the authority binding.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri", "authority"], - "properties": { - "spaceUri": { "type": "string" }, - "authority": { - "type": "string", - "format": "did", - "description": "DID authorized to sign credentials for this space." - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { - "ok": { "type": "boolean" } - } - } - }, - "errors": [ - { "name": "InvalidRequest" }, - { "name": "Forbidden", "description": "Caller is neither the space owner nor the declared authority." } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/recordHost/sync.json b/packages/lexicons/lexicon-templates/recordHost/sync.json deleted file mode 100644 index 795092f..0000000 --- a/packages/lexicons/lexicon-templates/recordHost/sync.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.recordHost.sync", - "defs": { - "main": { - "type": "subscription", - "description": "SSE stream of record events for a specific space. Emits a catch-up phase (every record with `time_us > since`, in time_us order) followed by a live phase (record.created / record.deleted as they happen). Cursor checkpoints are emitted after each catch-up batch and after each live event so consumers can persist progress and resume on reconnect. Auth: requires X-Space-Credential whose `space` claim matches `spaceUri`.", - "parameters": { - "type": "params", - "required": ["spaceUri"], - "properties": { - "spaceUri": { - "type": "string", - "description": "Space to sync." - }, - "since": { - "type": "string", - "description": "Opaque cursor (last `value` from a `cursor` event). Server interprets as time_us; events with time_us > since are emitted." - } - } - }, - "message": { - "schema": { - "type": "union", - "refs": [ - "#recordCreated", - "#recordDeleted", - "#cursor" - ] - } - } - }, - "recordCreated": { - "type": "object", - "required": ["topic", "kind", "payload", "ts"], - "properties": { - "topic": { "type": "string" }, - "kind": { "type": "string", "const": "record.created" }, - "payload": { - "type": "object", - "required": ["uri", "did", "collection", "rkey", "record", "time_us", "space"], - "properties": { - "uri": { "type": "string" }, - "did": { "type": "string", "format": "did" }, - "collection": { "type": "string", "format": "nsid" }, - "rkey": { "type": "string" }, - "cid": { "type": "string", "format": "cid" }, - "record": { "type": "unknown" }, - "time_us": { "type": "integer" }, - "space": { "type": "string" } - } - }, - "ts": { "type": "integer" } - } - }, - "recordDeleted": { - "type": "object", - "required": ["topic", "kind", "payload", "ts"], - "properties": { - "topic": { "type": "string" }, - "kind": { "type": "string", "const": "record.deleted" }, - "payload": { - "type": "object", - "required": ["uri", "did", "collection", "rkey", "space"], - "properties": { - "uri": { "type": "string" }, - "did": { "type": "string", "format": "did" }, - "collection": { "type": "string", "format": "nsid" }, - "rkey": { "type": "string" }, - "space": { "type": "string" } - } - }, - "ts": { "type": "integer" } - } - }, - "cursor": { - "type": "object", - "required": ["kind", "value"], - "description": "Checkpoint: persist `value` and pass it as `since` on next reconnect to resume.", - "properties": { - "kind": { "type": "string", "const": "cursor" }, - "value": { "type": "string" } - } - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/addMember.json b/packages/lexicons/lexicon-templates/spaces/addMember.json deleted file mode 100644 index fb41b0d..0000000 --- a/packages/lexicons/lexicon-templates/spaces/addMember.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.addMember", - "defs": { - "main": { - "type": "procedure", - "description": "Add a member to a space. Caller must be the space owner.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri", "did"], - "properties": { - "spaceUri": { "type": "string" }, - "did": { "type": "string", "format": "did" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { - "ok": { "type": "boolean" } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/createSpace.json b/packages/lexicons/lexicon-templates/spaces/createSpace.json deleted file mode 100644 index a3f516b..0000000 --- a/packages/lexicons/lexicon-templates/spaces/createSpace.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.createSpace", - "defs": { - "main": { - "type": "procedure", - "description": "Create a new space owned by the JWT issuer. The caller is added as an owner-perm member.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "type": { "type": "string", "format": "nsid", "description": "Space type NSID. Defaults to the service's configured type." }, - "key": { "type": "string", "description": "Space key. Auto-generated (TID) if omitted." }, - "appPolicyRef": { "type": "string", "format": "at-uri" }, - "appPolicy": { "type": "ref", "ref": "tools.atmo.space.defs#appPolicy" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["space"], - "properties": { - "space": { "type": "ref", "ref": "tools.atmo.space.defs#spaceView" } - } - } - }, - "errors": [ - { "name": "AlreadyExists" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/declaration.json b/packages/lexicons/lexicon-templates/spaces/declaration.json deleted file mode 100644 index 0a5572b..0000000 --- a/packages/lexicons/lexicon-templates/spaces/declaration.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.declaration", - "description": "PDS-record-as-discovery: when present at `at://<owner>/<type>/<key>` (i.e. paired with the space URI by NSID + rkey), this record declares which DID is authorized to issue credentials for the space and which DID hosts its records. The space owner's PDS write is the cryptographic proof of authorization — the binding resolver fetches this record to verify a credential's `iss` against the user's wishes, no DID-doc edits required. App-defined space-type lexicons MAY embed these fields directly instead of writing a separate record under this NSID.", - "defs": { - "main": { - "type": "record", - "key": "any", - "record": { - "type": "object", - "required": ["authority", "createdAt"], - "properties": { - "authority": { - "type": "string", - "format": "did", - "description": "DID authorized to sign credentials for this space (`iss` on emitted JWTs). May equal the space owner DID for the self-issuing case." - }, - "recordHost": { - "type": "string", - "format": "did", - "description": "DID of the record host where records for this space live. Clients use this to know where to send writes; verifiers ignore it." - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/defs.json b/packages/lexicons/lexicon-templates/spaces/defs.json deleted file mode 100644 index dd8b7ca..0000000 --- a/packages/lexicons/lexicon-templates/spaces/defs.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.defs", - "description": "Shared types for permissioned-space XRPC methods.", - "defs": { - "spaceView": { - "type": "object", - "required": ["uri", "ownerDid", "type", "key", "serviceDid", "createdAt"], - "properties": { - "uri": { "type": "string" }, - "ownerDid": { "type": "string", "format": "did" }, - "type": { "type": "string", "format": "nsid" }, - "key": { "type": "string" }, - "serviceDid": { "type": "string" }, - "appPolicyRef": { "type": "string", "format": "at-uri" }, - "createdAt": { "type": "integer" }, - "appPolicy": { "type": "ref", "ref": "#appPolicy", "description": "Owner-only" } - } - }, - "memberView": { - "type": "object", - "required": ["did", "addedAt"], - "properties": { - "did": { "type": "string", "format": "did" }, - "addedAt": { "type": "integer" }, - "addedBy": { "type": "string", "format": "did" } - } - }, - "recordView": { - "type": "object", - "required": ["spaceUri", "collection", "authorDid", "rkey", "record", "createdAt"], - "properties": { - "spaceUri": { "type": "string" }, - "collection": { "type": "string", "format": "nsid" }, - "authorDid": { "type": "string", "format": "did" }, - "rkey": { "type": "string" }, - "cid": { "type": "string", "format": "cid" }, - "record": { "type": "unknown" }, - "createdAt": { "type": "integer" } - } - }, - "appPolicy": { - "type": "object", - "required": ["mode", "apps"], - "properties": { - "mode": { "type": "string", "knownValues": ["allow", "deny"], "description": "'allow' = default-allow with apps[] as denylist; 'deny' = default-deny with apps[] as allowlist." }, - "apps": { "type": "array", "items": { "type": "string" } } - } - }, - "blobInfo": { - "type": "object", - "required": ["cid", "mimeType", "size", "authorDid", "createdAt"], - "properties": { - "cid": { "type": "string", "format": "cid" }, - "mimeType": { "type": "string" }, - "size": { "type": "integer" }, - "authorDid": { "type": "string", "format": "did" }, - "createdAt": { "type": "integer" } - } - }, - "inviteView": { - "type": "object", - "required": ["tokenHash", "spaceUri", "kind", "usedCount", "createdBy", "createdAt"], - "properties": { - "tokenHash": { "type": "string" }, - "spaceUri": { "type": "string" }, - "kind": { "type": "string", "knownValues": ["join", "read", "read-join"] }, - "expiresAt": { "type": "integer" }, - "maxUses": { "type": "integer" }, - "usedCount": { "type": "integer" }, - "createdBy": { "type": "string", "format": "did" }, - "createdAt": { "type": "integer" }, - "revokedAt": { "type": "integer" }, - "note": { "type": "string" } - } - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/deleteRecord.json b/packages/lexicons/lexicon-templates/spaces/deleteRecord.json deleted file mode 100644 index dde2845..0000000 --- a/packages/lexicons/lexicon-templates/spaces/deleteRecord.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.deleteRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Delete a record from a space. Callers can delete their own records; the space owner can delete any record (via a separate admin path).", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri", "collection", "rkey"], - "properties": { - "spaceUri": { "type": "string" }, - "collection": { "type": "string", "format": "nsid" }, - "rkey": { "type": "string" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { "ok": { "type": "boolean" } } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/ext/whoami.json b/packages/lexicons/lexicon-templates/spaces/ext/whoami.json deleted file mode 100644 index 6c4225b..0000000 --- a/packages/lexicons/lexicon-templates/spaces/ext/whoami.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.spaceExt.whoami", - "defs": { - "main": { - "type": "query", - "description": "Caller's relationship to a space. For user-owned spaces membership is binary (`isMember`). For community-owned spaces `accessLevel` is also returned, resolved through the community's access-level ladder (including delegated grants).", - "parameters": { - "type": "params", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string" } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["isOwner", "isMember"], - "properties": { - "isOwner": { "type": "boolean" }, - "isMember": { "type": "boolean" }, - "accessLevel": { "type": "string", "knownValues": ["member", "manager", "admin", "owner"], "description": "Only set for community-owned spaces. Null when the caller has no resolvable access." } - } - } - }, - "errors": [ - { "name": "NotFound" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/getBlob.json b/packages/lexicons/lexicon-templates/spaces/getBlob.json deleted file mode 100644 index d299bbc..0000000 --- a/packages/lexicons/lexicon-templates/spaces/getBlob.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.getBlob", - "defs": { - "main": { - "type": "query", - "description": "Read a blob from a space. Requires read access via a service-auth JWT or a read-grant invite token.", - "parameters": { - "type": "params", - "required": ["spaceUri", "cid"], - "properties": { - "spaceUri": { "type": "string" }, - "cid": { "type": "string", "format": "cid" }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access." - } - } - }, - "output": { "encoding": "*/*" }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/getCredential.json b/packages/lexicons/lexicon-templates/spaces/getCredential.json deleted file mode 100644 index e24e79b..0000000 --- a/packages/lexicons/lexicon-templates/spaces/getCredential.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.getCredential", - "defs": { - "main": { - "type": "procedure", - "description": "Mint a short-lived space credential for a member of `spaceUri`. The caller is identified by their service-auth JWT; the credential's `sub` is set to that DID, scoped 'rw'. Use the returned credential as the `X-Space-Credential` header on subsequent requests instead of minting a fresh service-auth JWT each time. Refresh via `refreshCredential` before expiry.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["credential", "expiresAt"], - "properties": { - "credential": { - "type": "string", - "description": "Compact JWS (ES256) signed by the authority's key. Header `kid` references the verification method on the authority's DID document." - }, - "expiresAt": { - "type": "integer", - "description": "Expiry as ms since epoch." - } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" }, - { "name": "NotImplemented", "description": "Authority is not configured to sign credentials." } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/getMembershipManifest.json b/packages/lexicons/lexicon-templates/spaces/getMembershipManifest.json deleted file mode 100644 index 7061df7..0000000 --- a/packages/lexicons/lexicon-templates/spaces/getMembershipManifest.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.getMembershipManifest", - "defs": { - "main": { - "type": "procedure", - "description": "Mint a signed list of every space the caller is a member of (or owns) according to this authority. Appviews can carry the manifest on inbound requests so unioned listRecords queries are filtered against the caller's bounded slice without the appview having to sync the authority's full member list. Same key material as `getCredential`; different payload (an array of space URIs rather than a single space + scope). Truncated to `manifestMaxSpaces` (default 500) — the response sets `truncated: true` when the cap was hit.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": {} - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["manifest", "expiresAt", "truncated"], - "properties": { - "manifest": { - "type": "string", - "description": "Compact JWS (ES256) signed by the authority's key. Payload claims: `iss` (authority DID), `sub` (caller DID), `spaces` (array of space URIs), `iat`, `exp`." - }, - "expiresAt": { - "type": "integer", - "description": "Expiry as ms since epoch." - }, - "truncated": { - "type": "boolean", - "description": "True if the caller is in more spaces than fit in one manifest. The remainder won't be unioned in queries until the next refresh." - } - } - } - }, - "errors": [ - { "name": "NotImplemented", "description": "Authority is not configured to sign manifests." } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/getRecord.json b/packages/lexicons/lexicon-templates/spaces/getRecord.json deleted file mode 100644 index b02540d..0000000 --- a/packages/lexicons/lexicon-templates/spaces/getRecord.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.getRecord", - "defs": { - "main": { - "type": "query", - "description": "Get a single record from a space.", - "parameters": { - "type": "params", - "required": ["spaceUri", "collection", "author", "rkey"], - "properties": { - "spaceUri": { "type": "string" }, - "collection": { "type": "string", "format": "nsid" }, - "author": { "type": "string", "format": "did" }, - "rkey": { "type": "string" }, - "inviteToken": { "type": "string", "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["record"], - "properties": { - "record": { "type": "ref", "ref": "tools.atmo.space.defs#recordView" } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/getSpace.json b/packages/lexicons/lexicon-templates/spaces/getSpace.json deleted file mode 100644 index f08d0a1..0000000 --- a/packages/lexicons/lexicon-templates/spaces/getSpace.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.getSpace", - "defs": { - "main": { - "type": "query", - "description": "Get metadata for a single space. Caller must be a member, the owner, or hold a read-grant invite token.", - "parameters": { - "type": "params", - "required": ["uri"], - "properties": { - "uri": { "type": "string" }, - "inviteToken": { "type": "string", "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["space"], - "properties": { - "space": { "type": "ref", "ref": "tools.atmo.space.defs#spaceView" } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/leaveSpace.json b/packages/lexicons/lexicon-templates/spaces/leaveSpace.json deleted file mode 100644 index 6deba95..0000000 --- a/packages/lexicons/lexicon-templates/spaces/leaveSpace.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.leaveSpace", - "defs": { - "main": { - "type": "procedure", - "description": "Remove the caller from a space's member list. The owner cannot leave — they must delete the space instead.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { - "ok": { "type": "boolean" } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "InvalidRequest", "description": "Raised if the caller is the space owner." } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/listBlobs.json b/packages/lexicons/lexicon-templates/spaces/listBlobs.json deleted file mode 100644 index 67ebd75..0000000 --- a/packages/lexicons/lexicon-templates/spaces/listBlobs.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.listBlobs", - "defs": { - "main": { - "type": "query", - "description": "List blob metadata for a space. Members only.", - "parameters": { - "type": "params", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string" }, - "byUser": { "type": "string", "format": "did", "description": "Only blobs uploaded by this DID." }, - "limit": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 }, - "cursor": { "type": "string" } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["blobs"], - "properties": { - "blobs": { "type": "array", "items": { "type": "ref", "ref": "tools.atmo.space.defs#blobInfo" } }, - "cursor": { "type": "string" } - } - } - } - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/listMembers.json b/packages/lexicons/lexicon-templates/spaces/listMembers.json deleted file mode 100644 index 0d911e8..0000000 --- a/packages/lexicons/lexicon-templates/spaces/listMembers.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.listMembers", - "defs": { - "main": { - "type": "query", - "description": "List members of a space. Caller must be a member or the owner.", - "parameters": { - "type": "params", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string" } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["members"], - "properties": { - "members": { - "type": "array", - "items": { "type": "ref", "ref": "tools.atmo.space.defs#memberView" } - } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/listRecords.json b/packages/lexicons/lexicon-templates/spaces/listRecords.json deleted file mode 100644 index 0ffbb79..0000000 --- a/packages/lexicons/lexicon-templates/spaces/listRecords.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.listRecords", - "defs": { - "main": { - "type": "query", - "description": "List records of a given collection within a space. Access is governed by the space's collection policy.", - "parameters": { - "type": "params", - "required": ["spaceUri", "collection"], - "properties": { - "spaceUri": { "type": "string" }, - "collection": { "type": "string", "format": "nsid" }, - "byUser": { "type": "string", "format": "did", "description": "Only return records authored by this DID." }, - "cursor": { "type": "string" }, - "limit": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 }, - "inviteToken": { "type": "string", "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["records"], - "properties": { - "records": { - "type": "array", - "items": { "type": "ref", "ref": "tools.atmo.space.defs#recordView" } - }, - "cursor": { "type": "string" } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/listSpaces.json b/packages/lexicons/lexicon-templates/spaces/listSpaces.json deleted file mode 100644 index 4a70b9d..0000000 --- a/packages/lexicons/lexicon-templates/spaces/listSpaces.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.listSpaces", - "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. 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 } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaces"], - "properties": { - "spaces": { - "type": "array", - "items": { "type": "ref", "ref": "tools.atmo.space.defs#spaceView" } - }, - "cursor": { "type": "string" } - } - } - } - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/putRecord.json b/packages/lexicons/lexicon-templates/spaces/putRecord.json deleted file mode 100644 index 8c339e8..0000000 --- a/packages/lexicons/lexicon-templates/spaces/putRecord.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.putRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Write a record into a space. The author is always the JWT issuer. If rkey is omitted, a TID is generated.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri", "collection", "record"], - "properties": { - "spaceUri": { "type": "string" }, - "collection": { "type": "string", "format": "nsid" }, - "rkey": { "type": "string" }, - "record": { "type": "unknown" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["rkey", "authorDid", "createdAt"], - "properties": { - "rkey": { "type": "string" }, - "authorDid": { "type": "string", "format": "did" }, - "createdAt": { "type": "integer" } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/refreshCredential.json b/packages/lexicons/lexicon-templates/spaces/refreshCredential.json deleted file mode 100644 index 7e8bbfb..0000000 --- a/packages/lexicons/lexicon-templates/spaces/refreshCredential.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.refreshCredential", - "defs": { - "main": { - "type": "procedure", - "description": "Refresh an unexpired space credential. The current credential must verify and the bearer must still be a member. Returns a fresh credential (same scope, same sub, same space) with a new expiry. No service-auth JWT required — the credential itself is the authentication.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["credential"], - "properties": { - "credential": { "type": "string" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["credential", "expiresAt"], - "properties": { - "credential": { "type": "string" }, - "expiresAt": { "type": "integer" } - } - } - }, - "errors": [ - { "name": "InvalidRequest" }, - { "name": "InvalidCredential" }, - { "name": "NotFound" }, - { "name": "Forbidden" }, - { "name": "NotImplemented" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/removeMember.json b/packages/lexicons/lexicon-templates/spaces/removeMember.json deleted file mode 100644 index cee918f..0000000 --- a/packages/lexicons/lexicon-templates/spaces/removeMember.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.removeMember", - "defs": { - "main": { - "type": "procedure", - "description": "Remove a member from a space. Owner only. Cannot remove the owner.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri", "did"], - "properties": { - "spaceUri": { "type": "string" }, - "did": { "type": "string", "format": "did" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { - "ok": { "type": "boolean" } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" }, - { "name": "InvalidRequest" } - ] - } - } -} diff --git a/packages/lexicons/lexicon-templates/spaces/uploadBlob.json b/packages/lexicons/lexicon-templates/spaces/uploadBlob.json deleted file mode 100644 index 26a03ee..0000000 --- a/packages/lexicons/lexicon-templates/spaces/uploadBlob.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.space.uploadBlob", - "defs": { - "main": { - "type": "procedure", - "description": "Upload a blob into a space. Returns a standard atproto BlobRef that records in this space can then reference. The record will be rejected at putRecord time if it references a blob that was not uploaded to the same space.", - "parameters": { - "type": "params", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string" } - } - }, - "input": { "encoding": "*/*" }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["blob"], - "properties": { - "blob": { "type": "blob" } - } - } - }, - "errors": [ - { "name": "Forbidden" }, - { "name": "BlobTooLarge" }, - { "name": "InvalidMimeType" } - ] - } - } -} diff --git a/packages/lexicons/package.json b/packages/lexicons/package.json deleted file mode 100644 index 0cab6b9..0000000 --- a/packages/lexicons/package.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "@atmo-dev/contrail-lexicons", - "version": "0.4.15", - "description": "Generate atproto lexicon JSON (and optionally TypeScript types via @atcute/lex-cli) from a Contrail config.", - "type": "module", - "files": [ - "dist", - "lexicon-templates/**/*.json" - ], - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./generate": { - "types": "./dist/generate.d.ts", - "import": "./dist/generate.js" - }, - "./publish": { - "types": "./dist/publish.d.ts", - "import": "./dist/publish.js" - }, - "./lexicon-templates/*.json": "./lexicon-templates/*.json" - }, - "bin": { - "contrail-lex": "./dist/cli.js" - }, - "repository": { - "type": "git", - "url": "https://github.com/flo-bit/contrail.git", - "directory": "packages/lexicons" - }, - "keywords": [ - "atproto", - "contrail", - "lexicon", - "codegen" - ], - "scripts": { - "build": "tsup", - "clean": "rm -rf dist", - "typecheck": "tsc --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@atcute/lex-cli": "^2.5.3", - "@atmo-dev/contrail": "workspace:*" - }, - "devDependencies": { - "@types/node": "^25.5.0", - "tsup": "^8.5.0", - "typescript": "^5.7.3", - "vitest": "^4.1.0" - }, - "license": "MIT" -} diff --git a/packages/lexicons/src/cli.ts b/packages/lexicons/src/cli.ts deleted file mode 100644 index 081c01a..0000000 --- a/packages/lexicons/src/cli.ts +++ /dev/null @@ -1,285 +0,0 @@ -#!/usr/bin/env node -/** - * contrail-lex — CLI for generating, typegenning, and publishing lexicons. - * - * Usage from a consumer project: - * - * contrail-lex generate [--config <path>] # lexicon JSON only - * contrail-lex pull # wraps `lex-cli pull` - * contrail-lex types # wraps `lex-cli generate` - * contrail-lex all [--no-types] [--config ...] # generate → pull → types - * contrail-lex publish [handle] [password] # publish lexicons to a PDS - * contrail-lex pull-service <url> # fetch lexicons from a deployed contrail - * - * Config auto-detects at ./contrail.config.ts, ./app/config.ts, or - * ./src/lib/contrail/config.ts (first match wins); override with --config. - * The file must default-export or named-export `config: ContrailConfig`. - */ -import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { resolve, join, dirname } from "node:path"; -import { - findConfigFile, - loadConfig, - CONFIG_CANDIDATES_MESSAGE, -} from "@atmo-dev/contrail/cli-config"; -import { generateLexicons } from "./generate.js"; -import { publishLexicons } from "./publish.js"; - -type Subcommand = "generate" | "pull" | "types" | "all" | "publish" | "pull-service" | "help"; - -const USAGE = `contrail-lex <subcommand> [options] - -Subcommands: - generate Emit lexicon JSON from Contrail config - pull Pull external lexicons (wraps \`lex-cli pull\`) - types Generate TS types from lexicon JSON (wraps \`lex-cli generate\`) - all generate → pull → generate → pull → types (full pipeline) - publish Publish lexicon JSON as com.atproto.lexicon.schema records on a PDS - pull-service Fetch lexicons from a deployed contrail \`/lexicons\` endpoint - help Print this message - -Options: - --config <path> Path to Contrail config file. Default: auto-detect. - --root <path> Project root (where lexicons/ and node_modules/ live). Default: CWD. - --no-types In \`all\`, skip the final type-generation step. - --generated-dir For \`publish\`: dir of JSON to publish. Default: lexicons/generated. - --skip-confirm For \`publish\`: skip the "do you control these zones?" prompt. - --dry-run For \`publish\`: print what would be published + the DNS records needed, no writes. - --out <dir> For \`pull-service\`: where to write fetched lexicons. Default: lexicons/pulled. - --namespace <ns> For \`pull-service\`: construct the URL as \`<base>/xrpc/<ns>.lexicons\`. Or pass a full URL. - -Environment variables (for \`publish\`): - LEXICON_ACCOUNT_IDENTIFIER handle or DID (falls back to positional arg 1) - LEXICON_ACCOUNT_PASSWORD app password (falls back to positional arg 2) -`; - -function parseArgs(argv: string[]): { - cmd: Subcommand; - config?: string; - root: string; - withTypes: boolean; - generatedDir: string; - skipConfirm: boolean; - dryRun: boolean; - out?: string; - namespace?: string; - positional: string[]; -} { - const args = argv.slice(2); - const cmd = (args.shift() ?? "help") as Subcommand; - let config: string | undefined; - let root = process.cwd(); - let withTypes = true; - let generatedDir = "lexicons/generated"; - let skipConfirm = false; - let dryRun = false; - let out: string | undefined; - let namespace: string | undefined; - const positional: string[] = []; - for (let i = 0; i < args.length; i++) { - const a = args[i]; - if (a === "--config") config = args[++i]; - else if (a === "--root") root = args[++i]; - else if (a === "--no-types") withTypes = false; - else if (a === "--generated-dir") generatedDir = args[++i]; - else if (a === "--skip-confirm") skipConfirm = true; - else if (a === "--dry-run") dryRun = true; - else if (a === "--out") out = args[++i]; - else if (a === "--namespace") namespace = args[++i]; - else if (a === "-h" || a === "--help") - return { cmd: "help", root, withTypes: true, generatedDir, skipConfirm, dryRun, out, namespace, positional }; - else positional.push(a); - } - return { cmd, config, root, withTypes, generatedDir, skipConfirm, dryRun, out, namespace, positional }; -} - - -function runLexCli(args: string[], cwd: string): number { - const result = spawnSync("npx", ["lex-cli", ...args], { - cwd, - stdio: "inherit", - shell: process.platform === "win32", - }); - return result.status ?? 1; -} - -async function cmdGenerate(configPath: string, root: string): Promise<void> { - const config = await loadConfig(configPath); - generateLexicons({ - config, - rootDir: root, - outputDir: join(root, "lexicons", "generated"), - writeRuntimeFiles: true, - }); -} - -async function cmdPublish( - root: string, - generatedDir: string, - positional: string[], - skipConfirm: boolean, - dryRun: boolean -): Promise<number> { - const identifier = positional[0] ?? process.env.LEXICON_ACCOUNT_IDENTIFIER; - const password = positional[1] ?? process.env.LEXICON_ACCOUNT_PASSWORD; - if (!dryRun && (!identifier || !password)) { - console.error( - "missing credentials. pass positional <handle> <app-password> or set\n" + - "LEXICON_ACCOUNT_IDENTIFIER and LEXICON_ACCOUNT_PASSWORD.\n" + - "(not required with --dry-run)" - ); - return 1; - } - const dir = resolve(root, generatedDir); - if (!existsSync(dir)) { - console.error(`generated lexicons dir not found: ${dir}`); - return 1; - } - const result = await publishLexicons({ - generatedDir: dir, - identifier, - password, - skipConfirm, - dryRun, - }); - if (!dryRun) { - console.log( - `published ${result.published} lexicon(s)` + - (result.failed.length ? `, ${result.failed.length} failed` : "") - ); - } - return result.failed.length ? 1 : 0; -} - -interface LexiconDoc { - id?: string; - [k: string]: unknown; -} - -async function cmdPullService( - root: string, - outDir: string, - namespace: string | undefined, - positional: string[] -): Promise<number> { - const url = positional[0]; - if (!url) { - console.error("usage: contrail-lex pull-service <url> [--namespace <ns>]"); - console.error( - " <url> is either a full URL to the lexicon endpoint\n" + - " e.g. https://my-contrail.dev/xrpc/com.example.lexicons\n" + - " or a base URL combined with --namespace:\n" + - " contrail-lex pull-service https://my-contrail.dev --namespace com.example" - ); - return 1; - } - let endpoint: string; - if (url.includes("/xrpc/")) { - endpoint = url; - } else if (namespace) { - endpoint = `${url.replace(/\/$/, "")}/xrpc/${namespace}.lexicons`; - } else { - console.error( - "pull-service: pass a full URL containing /xrpc/<ns>.lexicons, or use --namespace <ns> to construct one." - ); - return 1; - } - console.log(`fetching ${endpoint}…`); - - let docs: LexiconDoc[]; - try { - const res = await fetch(endpoint); - if (!res.ok) { - console.error(`request failed: ${res.status} ${res.statusText}`); - return 1; - } - const body = (await res.json()) as { lexicons?: LexiconDoc[] }; - docs = body.lexicons ?? []; - } catch (err) { - console.error(`fetch error: ${(err as Error).message}`); - return 1; - } - - if (docs.length === 0) { - console.error( - "no lexicons in response. the service must pass { lexicons } to createWorker() " + - "(emit with `contrail-lex generate` and import from ./lexicons/generated)." - ); - return 1; - } - - const absOut = resolve(root, outDir); - mkdirSync(absOut, { recursive: true }); - let written = 0; - for (const doc of docs) { - if (!doc.id || typeof doc.id !== "string") continue; - const filePath = join(absOut, ...doc.id.split(".")) + ".json"; - mkdirSync(dirname(filePath), { recursive: true }); - writeFileSync(filePath, JSON.stringify(doc, null, 2) + "\n"); - written++; - } - console.log(`wrote ${written} lexicon(s) to ${absOut}`); - console.log("run `npx lex-cli generate` (or `contrail-lex types`) to emit TS types."); - return 0; -} - -async function main(): Promise<number> { - const { cmd, config, root, withTypes, generatedDir, skipConfirm, dryRun, out, namespace, positional } = - parseArgs(process.argv); - - if (cmd === "help") { - process.stdout.write(USAGE); - return 0; - } - - if (cmd === "pull") return runLexCli(["pull"], root); - if (cmd === "types") return runLexCli(["generate"], root); - if (cmd === "publish") - return cmdPublish(root, generatedDir, positional, skipConfirm, dryRun); - if (cmd === "pull-service") - return cmdPullService(root, out ?? "lexicons/pulled", namespace, positional); - - const configPath = findConfigFile(root, config); - if (!configPath) { - console.error( - "Could not find a Contrail config. Pass --config <path> or place one at\n" + - ` ${CONFIG_CANDIDATES_MESSAGE}` - ); - return 1; - } - - if (cmd === "generate") { - await cmdGenerate(configPath, root); - return 0; - } - - if (cmd === "all") { - // Two-pass generate + pull is deliberate: the first `generate` emits any - // record-type placeholders that `lex-cli pull` resolves, and the second - // pass picks up the pulled data. Mirrors the historical `generate:pull` - // script shape. - await cmdGenerate(configPath, root); - let rc = runLexCli(["pull"], root); - if (rc !== 0) return rc; - await cmdGenerate(configPath, root); - rc = runLexCli(["pull"], root); - if (rc !== 0) return rc; - if (withTypes) { - rc = runLexCli(["generate"], root); - if (rc !== 0) return rc; - } - return 0; - } - - console.error(USAGE); - return 1; -} - -main().then( - (code) => process.exit(code), - (err) => { - console.error(err); - process.exit(1); - } -); diff --git a/packages/lexicons/src/generate.ts b/packages/lexicons/src/generate.ts deleted file mode 100644 index d6069a0..0000000 --- a/packages/lexicons/src/generate.ts +++ /dev/null @@ -1,1137 +0,0 @@ -/** - * Core lexicon generation logic, importable for testing. - * - * Builds lexicon JSON objects from a ContrailConfig. Separated from the - * script entry point so tests can call it with custom configs and output dirs. - */ - -import { writeFileSync, mkdirSync, rmSync, existsSync, readFileSync, readdirSync } from "fs"; -import { join, relative } from "path"; -import type { ContrailConfig } from "@atmo-dev/contrail"; - -/** Return the sorted list of XRPC method NSIDs (queries + procedures) in a - * generated lexicon set — the same list that ends up in `<ns>.authFull`'s - * `lxm`. Use when the permission set isn't published yet and you need the - * scoped method list inline (e.g. in an OAuth client config). - * - * Filters `defs.main.type === "query" | "procedure"`. Sorted alphabetically - * so the output is stable across runs. */ -export function extractXrpcMethods(generated: Record<string, object>): string[] { - const methods: string[] = []; - for (const [nsid, doc] of Object.entries(generated)) { - const mainType = (doc as { defs?: { main?: { type?: string } } })?.defs?.main?.type; - if (mainType === "query" || mainType === "procedure") methods.push(nsid); - } - return methods.sort(); -} - -/** Generate lexicons in-memory and return the sorted method NSID list. - * Convenience wrapper over `generateLexicons` + `extractXrpcMethods` for - * callers that don't care about the full lexicon map. */ -export function listXrpcMethods( - config: ContrailConfig, - options: { rootDir: string; lexiconDirs?: string[] } -): string[] { - const generated = generateLexicons({ - config, - rootDir: options.rootDir, - lexiconDirs: options.lexiconDirs, - quiet: true, - }); - return extractXrpcMethods(generated); -} - -export interface GenerateOptions { - config: ContrailConfig; - /** Root project directory (for finding lexicon source files). */ - rootDir: string; - /** Directory to write generated lexicons into (will be cleaned first). Omit for in-memory only. */ - outputDir?: string; - /** Additional lexicon source directories to search for collection schemas. */ - lexiconDirs?: string[]; - /** If true, also writes lex.config.js and queryable.generated.ts. */ - writeRuntimeFiles?: boolean; - /** Suppress console output. */ - quiet?: boolean; -} - -function fieldToParam(field: string): string { - return field.replace(/\.(\w)/g, (_, c) => c.toUpperCase()); -} - -/** Locate a shipped lexicon-template subdir (`spaces`/`community`/`realtime`). - * Works in contrail's own repo and in downstream projects that depend on - * @atmo-dev/contrail. All three module templates live under one - * `lexicon-templates/` parent; each subdir is instantiated by the generator - * into its own `<ns>.*` namespace. */ -function findTemplatesDir( - rootDir: string, - module: "spaces" | "community" | "realtime" | "invite" -): string | null { - // Templates are shipped with @atmo-dev/contrail-lexicons. Look in the - // user project's node_modules first, then fall back to the package's own - // directory (for development in this monorepo, where the templates live - // next to this file). - const candidates = [ - join(rootDir, "lexicon-templates", module), - join(rootDir, "node_modules/@atmo-dev/contrail-lexicons/lexicon-templates", module), - // When running from the package's own dist/ (ESM), __dirname is unavailable; - // rely on import.meta.url. - new URL(`../lexicon-templates/${module}`, import.meta.url).pathname, - ]; - for (const p of candidates) { - if (existsSync(p)) return p; - } - return null; -} - -const findSpaceTemplatesDir = (rootDir: string) => findTemplatesDir(rootDir, "spaces"); -const findCommunityTemplatesDir = (rootDir: string) => findTemplatesDir(rootDir, "community"); -const findRealtimeTemplatesDir = (rootDir: string) => findTemplatesDir(rootDir, "realtime"); -const findInviteTemplatesDir = (rootDir: string) => findTemplatesDir(rootDir, "invite"); - -/** Yield all JSON files under a directory (recursive). */ -function* walkJson(dir: string): Generator<string> { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) yield* walkJson(full); - else if (entry.isFile() && entry.name.endsWith(".json")) yield full; - } -} - -interface QueryableField { - type?: "range"; -} - -interface CountField { - name: string; - description: string; -} - -interface RelationDef { - relName: string; - collection: string; - groupBy?: string; - groups: Record<string, string>; -} - -interface ReferenceDef { - refName: string; - collection: string; -} - -export function generateLexicons(options: GenerateOptions): Record<string, object> { - const { config, rootDir, outputDir, quiet } = options; - const lexiconDirs = options.lexiconDirs ?? [ - join(rootDir, "lexicons", "custom"), - join(rootDir, "lexicons", "pulled"), - ]; - - const log = quiet ? () => {} : console.log; - const generated: Record<string, object> = {}; - - // --- Helpers that depend on lexiconDirs --- - - function findCollectionLexicon(collection: string): string | null { - const segments = collection.split("."); - for (const dir of lexiconDirs) { - const filePath = join(dir, ...segments) + ".json"; - if (existsSync(filePath)) return filePath; - } - return null; - } - - function getKnownValues(collection: string, fieldName: string): string[] { - const filePath = findCollectionLexicon(collection); - if (!filePath) return []; - try { - const doc = JSON.parse(readFileSync(filePath, "utf-8")); - const props = doc.defs?.main?.record?.properties; - if (!props) return []; - const field = props[fieldName]; - if (!field) return []; - if (Array.isArray(field.knownValues)) return field.knownValues; - return []; - } catch { - return []; - } - } - - function getCollectionLexiconRef(collection: string): string | null { - const filePath = findCollectionLexicon(collection); - if (!filePath) return null; - try { - const doc = JSON.parse(readFileSync(filePath, "utf-8")); - if (doc.defs?.main) return `${collection}#main`; - } catch {} - return null; - } - - function getRecordObjectSchema(collection: string): any | null { - const filePath = findCollectionLexicon(collection); - if (!filePath) return null; - try { - const doc = JSON.parse(readFileSync(filePath, "utf-8")); - const main = doc.defs?.main; - if (main?.type === "record" && main.record) return main.record; - return null; - } catch { - return null; - } - } - - // --- Writing --- - - if (outputDir) { - rmSync(outputDir, { recursive: true, force: true }); - } - - function writeLexicon(nsid: string, doc: object) { - if (outputDir) { - const filePath = join(outputDir, ...nsid.split(".")) + ".json"; - mkdirSync(join(filePath, ".."), { recursive: true }); - writeFileSync(filePath, JSON.stringify(doc, null, 2) + "\n"); - } - generated[nsid] = doc; - log(` ${nsid}`); - } - - // --- Building --- - - function buildRecordDef( - collectionRef: string | null, - countFields?: CountField[], - relationDefs?: RelationDef[], - referenceDefs?: ReferenceDef[] - ) { - // Response shape mirrors atproto's `com.atproto.repo.listRecords#record` - // ({ uri, cid, value }) and extends it with contrail-specific fields. - // Required list + field name match the standard so generic atproto - // clients can consume contrail responses without custom handling. - const properties: Record<string, any> = { - uri: { type: "string", format: "at-uri" }, - cid: { type: "string", format: "cid" }, - value: collectionRef ? { type: "ref", ref: collectionRef } : { type: "unknown" }, - did: { type: "string", format: "did" }, - collection: { type: "string", format: "nsid" }, - rkey: { type: "string" }, - time_us: { type: "integer" }, - ...(config.spaces - ? { - space: { - type: "string", - description: "Present when the record was read from a permissioned space; its value is the `ats://` space URI.", - }, - } - : {}), - }; - if (countFields) { - for (const cf of countFields) { - properties[cf.name] = { type: "integer", description: cf.description }; - } - } - if (relationDefs && relationDefs.length > 0) { - for (const rd of relationDefs) { - const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - if (rd.groupBy && Object.keys(rd.groups).length > 0) { - properties[rd.relName] = { type: "ref", ref: `#hydrate${cap(rd.relName)}` }; - } else { - properties[rd.relName] = { type: "array", items: { type: "ref", ref: `#hydrate${cap(rd.relName)}Record` } }; - } - } - } - if (referenceDefs && referenceDefs.length > 0) { - for (const rd of referenceDefs) { - const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - properties[rd.refName] = { type: "ref", ref: `#ref${cap(rd.refName)}Record` }; - } - } - // Required list is a superset of atproto's `#record` (`uri`, `cid`, `value`). - // Contrail always populates the extras too, so including them here gives - // contrail-aware clients strong typing without breaking atproto clients - // that only check the standard three. - return { - type: "object", - required: ["uri", "cid", "value", "did", "collection", "rkey", "time_us"], - properties, - }; - } - - function buildHydrateDefs(relationDefs: RelationDef[]): Record<string, any> { - const defs: Record<string, any> = {}; - const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - for (const rd of relationDefs) { - const relCollectionRef = getCollectionLexiconRef(rd.collection); - const recordDefName = `hydrate${cap(rd.relName)}Record`; - defs[recordDefName] = { - type: "object", - required: ["uri", "did", "collection", "rkey", "time_us"], - properties: { - uri: { type: "string", format: "at-uri" }, - did: { type: "string", format: "did" }, - collection: { type: "string", format: "nsid" }, - rkey: { type: "string" }, - cid: { type: "string" }, - record: relCollectionRef ? { type: "ref", ref: relCollectionRef } : { type: "unknown" }, - time_us: { type: "integer" }, - ...(config.spaces - ? { - space: { - type: "string", - description: "Present when the record was read from a permissioned space; `ats://` URI.", - }, - } - : {}), - }, - }; - if (rd.groupBy && Object.keys(rd.groups).length > 0) { - const groupDefName = `hydrate${cap(rd.relName)}`; - const groupProperties: Record<string, any> = {}; - for (const shortName of Object.keys(rd.groups)) { - groupProperties[shortName] = { type: "array", items: { type: "ref", ref: `#${recordDefName}` } }; - } - groupProperties["other"] = { type: "array", items: { type: "ref", ref: `#${recordDefName}` } }; - defs[groupDefName] = { type: "object", properties: groupProperties }; - } - } - return defs; - } - - function buildReferenceDefs(referenceDefs: ReferenceDef[]): Record<string, any> { - const defs: Record<string, any> = {}; - const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - for (const rd of referenceDefs) { - const refCollectionRef = getCollectionLexiconRef(rd.collection); - const recordDefName = `ref${cap(rd.refName)}Record`; - defs[recordDefName] = { - type: "object", - required: ["uri", "did", "collection", "rkey", "time_us"], - properties: { - uri: { type: "string", format: "at-uri" }, - did: { type: "string", format: "did" }, - collection: { type: "string", format: "nsid" }, - rkey: { type: "string" }, - cid: { type: "string" }, - record: refCollectionRef ? { type: "ref", ref: refCollectionRef } : { type: "unknown" }, - time_us: { type: "integer" }, - ...(config.spaces - ? { - space: { - type: "string", - description: "Present when the record was read from a permissioned space; `ats://` URI.", - }, - } - : {}), - }, - }; - } - return defs; - } - - function profileDefs() { - const profiles: string[] = (config.profiles ?? ["app.bsky.actor.profile"]).map( - (p) => (typeof p === "string" ? p : p.collection) - ); - const extraDefs: Record<string, any> = {}; - const objectRefs: string[] = []; - for (const col of profiles) { - const schema = getRecordObjectSchema(col); - if (!schema) continue; - const defName = col.split(".").map((p, i) => i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1)).join(""); - extraDefs[defName] = schema; - objectRefs.push(`#${defName}`); - } - let valueField: any; - if (objectRefs.length === 1) valueField = { type: "ref", ref: objectRefs[0] }; - else if (objectRefs.length > 1) valueField = { type: "union", refs: objectRefs }; - else valueField = { type: "unknown" }; - return { - profileEntry: { - type: "object", - required: ["did"], - properties: { - did: { type: "string", format: "did" }, - handle: { type: "string" }, - uri: { type: "string", format: "at-uri" }, - cid: { type: "string", format: "cid" }, - value: valueField, - collection: { type: "string", format: "nsid" }, - rkey: { type: "string" }, - }, - }, - ...extraDefs, - }; - } - - function tokenShortName(token: string): string { - const hash = token.indexOf("#"); - return hash !== -1 ? token.slice(hash + 1) : token; - } - - // --- Generate --- - - const ns = config.namespace; - - log("Generating admin endpoints..."); - - writeLexicon(`${ns}.getCursor`, { - lexicon: 1, id: `${ns}.getCursor`, - defs: { main: { type: "query", description: "Get the current cursor position", output: { encoding: "application/json", schema: { type: "object", properties: { time_us: { type: "integer" }, date: { type: "string" }, seconds_ago: { type: "integer" } } } } } }, - }); - - writeLexicon(`${ns}.getOverview`, { - lexicon: 1, id: `${ns}.getOverview`, - defs: { main: { type: "query", description: "Get an overview of all indexed collections", output: { encoding: "application/json", schema: { type: "object", required: ["total_records", "collections"], properties: { total_records: { type: "integer" }, collections: { type: "array", items: { type: "ref", ref: "#collectionStats" } } } } } }, collectionStats: { type: "object", required: ["collection", "records", "unique_users"], properties: { collection: { type: "string" }, records: { type: "integer" }, unique_users: { type: "integer" } } } }, - }); - - - writeLexicon(`${ns}.getProfile`, { - lexicon: 1, id: `${ns}.getProfile`, - defs: { main: { type: "query", description: "Get a user's profiles by DID or handle", parameters: { type: "params", required: ["actor"], properties: { actor: { type: "string", format: "at-identifier", description: "DID or handle of the user" } } }, output: { encoding: "application/json", schema: { type: "object", required: ["profiles"], properties: { profiles: { type: "array", items: { type: "ref", ref: "#profileEntry" } } } } } }, ...profileDefs() }, - }); - - writeLexicon(`${ns}.notifyOfUpdate`, { - lexicon: 1, id: `${ns}.notifyOfUpdate`, - defs: { main: { type: "procedure", description: "Notify of a record change for immediate indexing. Fetches the record from the user's PDS and indexes (or deletes) it.", input: { encoding: "application/json", schema: { type: "object", properties: { uri: { type: "string", format: "at-uri", description: "Single AT URI to fetch and index" }, uris: { type: "array", items: { type: "string", format: "at-uri" }, maxLength: 25, description: "Batch of AT URIs to fetch and index (max 25)" } } } }, output: { encoding: "application/json", schema: { type: "object", required: ["indexed", "deleted"], properties: { indexed: { type: "integer", description: "Number of records created or updated" }, deleted: { type: "integer", description: "Number of records deleted (not found on PDS)" }, errors: { type: "array", items: { type: "string" }, description: "Errors for individual URIs that could not be processed" } } } } } }, - }); - - // --- Feeds --- - - if (config.feeds && Object.keys(config.feeds).length > 0) { - log("Generating feed endpoint..."); - - const feedNames = Object.keys(config.feeds); - // feedConfig.targets are short names (or `{ collection, maxItems? }`); - // expose NSIDs in the lexicon since the `collection` param filters by - // the record's NSID at the wire level. - const allTargets = [ - ...new Set( - Object.values(config.feeds).flatMap((f) => - f.targets.map((t) => (typeof t === "string" ? t : t.collection)) - ) - ), - ]; - const allTargetNsids = allTargets - .map((t) => config.collections[t]?.collection) - .filter((n): n is string => !!n); - - const feedParams: Record<string, any> = { - feed: { type: "string", knownValues: feedNames, description: "Feed name" }, - actor: { type: "string", format: "at-identifier", description: "DID or handle of the requesting user" }, - collection: { type: "string", knownValues: allTargetNsids, description: "Filter by target collection (defaults to first target)" }, - limit: { type: "integer", minimum: 1, maximum: 200, default: 50 }, - cursor: { type: "string" }, - profiles: { type: "boolean", description: "Include profile + identity info keyed by DID" }, - }; - - const feedSortableValues: string[] = []; - const feedHydrateDefs: Record<string, any> = {}; - const feedRefDefs: Record<string, any> = {}; - const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - - for (const targetCol of allTargets) { - const targetConfig = config.collections[targetCol]; - if (!targetConfig) continue; - const targetNsid = targetConfig.collection ?? targetCol; - - const merged = targetConfig.queryable ?? {}; - - // Search - if (Array.isArray(targetConfig.searchable) && targetConfig.searchable.length > 0 && !feedParams["search"]) { - feedParams["search"] = { type: "string", description: "Full-text search" }; - } - - // Queryable fields - for (const [field, fieldConfig] of Object.entries(merged)) { - const param = fieldToParam(field); - if (fieldConfig.type === "range") { - if (!feedParams[`${param}Min`]) { - feedParams[`${param}Min`] = { type: "string", description: `Minimum value for ${field}` }; - feedParams[`${param}Max`] = { type: "string", description: `Maximum value for ${field}` }; - } - } else { - if (!feedParams[param]) { - feedParams[param] = { type: "string", description: `Filter by ${field}` }; - } - } - feedSortableValues.push(fieldToParam(field)); - } - - // Relations (counts + hydration) - for (const [relName, rel] of Object.entries(targetConfig.relations ?? {})) { - if (!feedParams[`${relName}CountMin`]) { - feedParams[`${relName}CountMin`] = { type: "integer", description: `Minimum total ${relName} count` }; - feedSortableValues.push(`${relName}Count`); - } - if (!feedParams[`hydrate${cap(relName)}`]) { - feedParams[`hydrate${cap(relName)}`] = { type: "integer", minimum: 1, maximum: 50, description: `Number of ${relName} records to embed per record` }; - } - - if (rel.groupBy) { - const relNsid = config.collections[rel.collection]?.collection ?? rel.collection; - const knownValues = getKnownValues(relNsid, rel.groupBy); - for (const token of knownValues) { - const gShort = tokenShortName(token); - const paramName = `${relName}${cap(gShort)}CountMin`; - if (!feedParams[paramName]) { - feedParams[paramName] = { type: "integer", description: `Minimum ${relName} count where ${rel.groupBy} = ${gShort}` }; - feedSortableValues.push(`${relName}${cap(gShort)}Count`); - } - } - } - } - - // References (hydration params) - for (const [refName] of Object.entries(targetConfig.references ?? {})) { - if (!feedParams[`hydrate${cap(refName)}`]) { - feedParams[`hydrate${cap(refName)}`] = { type: "boolean", description: `Embed the referenced ${refName} record` }; - } - } - } - - // Sort/order params - const uniqueSortable = [...new Set(feedSortableValues)]; - if (uniqueSortable.length > 0) { - feedParams["sort"] = { type: "string", knownValues: uniqueSortable, description: "Field to sort by (default: time_us)" }; - feedParams["order"] = { type: "string", knownValues: ["asc", "desc"], description: "Sort direction" }; - } - - // Build a record def per target collection for the union - const feedRecordDefs: Record<string, any> = {}; - const feedRecordRefs: string[] = []; - - for (const targetCol of allTargets) { - const targetConfig = config.collections[targetCol]; - if (!targetConfig) continue; - const targetNsid = targetConfig.collection ?? targetCol; - - const collectionRef = getCollectionLexiconRef(targetNsid); - - const countFields: CountField[] = []; - const relationDefs: RelationDef[] = []; - const referenceDefs: ReferenceDef[] = []; - - for (const [relName, rel] of Object.entries(targetConfig.relations ?? {})) { - countFields.push({ name: `${relName}Count`, description: `Total ${relName} count` }); - const relNsid = config.collections[rel.collection]?.collection ?? rel.collection; - const groupMapping: Record<string, string> = {}; - if (rel.groupBy) { - for (const token of getKnownValues(relNsid, rel.groupBy)) { - const gShort = tokenShortName(token); - groupMapping[gShort] = token; - countFields.push({ name: `${relName}${cap(gShort)}Count`, description: `${relName} count where ${rel.groupBy} = ${gShort}` }); - } - } - relationDefs.push({ relName, collection: relNsid, groupBy: rel.groupBy, groups: groupMapping }); - } - - for (const [refName, ref] of Object.entries(targetConfig.references ?? {})) { - const refNsid = config.collections[ref.collection]?.collection ?? ref.collection; - referenceDefs.push({ refName, collection: refNsid }); - } - - const defName = `feedRecord_${targetCol.replace(/[^a-zA-Z0-9]/g, "_")}`; - feedRecordDefs[defName] = buildRecordDef(collectionRef, countFields, relationDefs, referenceDefs); - feedRecordRefs.push(`#${defName}`); - - // Add hydrate + reference defs (includes grouped wrappers) - Object.assign(feedHydrateDefs, buildHydrateDefs(relationDefs)); - Object.assign(feedRefDefs, buildReferenceDefs(referenceDefs)); - } - - const recordsItems = feedRecordRefs.length === 1 - ? { type: "ref", ref: feedRecordRefs[0] } - : { type: "union", refs: feedRecordRefs }; - - writeLexicon(`${ns}.getFeed`, { - lexicon: 1, id: `${ns}.getFeed`, - defs: { - main: { - type: "query", - description: "Get a personalized feed based on followed users' activity", - parameters: { type: "params", required: ["feed", "actor"], properties: feedParams }, - output: { - encoding: "application/json", - schema: { - type: "object", - required: ["records"], - properties: { - records: { type: "array", items: recordsItems }, - cursor: { type: "string" }, - profiles: { type: "array", items: { type: "ref", ref: "#profileEntry" } }, - }, - }, - }, - }, - ...feedRecordDefs, - ...feedHydrateDefs, - ...feedRefDefs, - ...profileDefs(), - }, - }); - } - - // --- Per-collection --- - - log("Generating collection endpoints..."); - - const resolvedQueryableMap: Record<string, Record<string, { type?: "range" }>> = {}; - const resolvedRelationsMap: Record<string, Record<string, { collection: string; groupBy: string; groups: Record<string, string> }>> = {}; - - for (const [shortName, colConfig] of Object.entries(config.collections)) { - const collection = colConfig.collection ?? shortName; // full NSID for lexicon refs - const collectionRef = getCollectionLexiconRef(collection); - - const merged = colConfig.queryable ?? {}; - resolvedQueryableMap[shortName] = merged; - - // --- listRecords --- - const listParams: Record<string, any> = { - limit: { type: "integer", minimum: 1, maximum: 200, default: 50 }, - cursor: { type: "string" }, - actor: { type: "string", format: "at-identifier", description: "Filter by DID or handle (triggers on-demand backfill)" }, - profiles: { type: "boolean", description: "Include profile + identity info keyed by DID" }, - ...(config.spaces - ? { - spaceUri: { - type: "string", - description: "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token). `ats://` URI.", - }, - byUser: { - type: "string", - format: "did", - description: "Only used with spaceUri — filter to records authored by this DID.", - }, - inviteToken: { - type: "string", - description: "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied.", - }, - } - : {}), - }; - - // Search param - if (Array.isArray(colConfig.searchable) && colConfig.searchable.length > 0) { - listParams["search"] = { - type: "string", - description: `Full-text search across: ${colConfig.searchable.join(", ")}`, - }; - } - - for (const [field, fieldConfig] of Object.entries(merged)) { - const param = fieldToParam(field); - if (fieldConfig.type === "range") { - listParams[`${param}Min`] = { type: "string", description: `Minimum value for ${field}` }; - listParams[`${param}Max`] = { type: "string", description: `Maximum value for ${field}` }; - } else { - listParams[param] = { type: "string", description: `Filter by ${field}` }; - } - } - - const countFields: CountField[] = []; - const relationDefs: RelationDef[] = []; - const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - - for (const [relName, rel] of Object.entries(colConfig.relations ?? {})) { - countFields.push({ name: `${relName}Count`, description: `Total ${relName} count` }); - listParams[`${relName}CountMin`] = { type: "integer", description: `Minimum total ${relName} count` }; - listParams[`hydrate${cap(relName)}`] = { type: "integer", minimum: 1, maximum: 50, description: `Number of ${relName} records to embed per record` }; - - const relNsid = config.collections[rel.collection]?.collection ?? rel.collection; - const groupMapping: Record<string, string> = {}; - if (rel.groupBy) { - const knownValues = getKnownValues(relNsid, rel.groupBy); - for (const token of knownValues) { - const gShort = tokenShortName(token); - groupMapping[gShort] = token; - countFields.push({ name: `${relName}${cap(gShort)}Count`, description: `${relName} count where ${rel.groupBy} = ${gShort}` }); - listParams[`${relName}${cap(gShort)}CountMin`] = { type: "integer", description: `Minimum ${relName} count where ${rel.groupBy} = ${gShort}` }; - } - if (!resolvedRelationsMap[shortName]) resolvedRelationsMap[shortName] = {}; - resolvedRelationsMap[shortName][relName] = { collection: rel.collection, groupBy: rel.groupBy, groups: groupMapping }; - } - - relationDefs.push({ relName, collection: relNsid, groupBy: rel.groupBy, groups: groupMapping }); - } - - const referenceDefs: ReferenceDef[] = []; - for (const [refName, ref] of Object.entries(colConfig.references ?? {})) { - const refNsid = config.collections[ref.collection]?.collection ?? ref.collection; - referenceDefs.push({ refName, collection: refNsid }); - } - for (const refName of Object.keys(colConfig.references ?? {})) { - listParams[`hydrate${cap(refName)}`] = { type: "boolean", description: `Embed the referenced ${refName} record` }; - } - - const sortableValues: string[] = []; - for (const field of Object.keys(merged)) sortableValues.push(fieldToParam(field)); - for (const cf of countFields) sortableValues.push(cf.name); - if (sortableValues.length > 0) { - listParams["sort"] = { type: "string", knownValues: sortableValues, description: "Field to sort by (default: time_us)" }; - listParams["order"] = { type: "string", knownValues: ["asc", "desc"], description: "Sort direction (default: desc for dates/numbers/counts, asc for strings)" }; - } - - const hydrateDefs = buildHydrateDefs(relationDefs); - const refDefs = buildReferenceDefs(referenceDefs); - - const methods = colConfig.methods ?? ["listRecords", "getRecord"]; - if (methods.includes("listRecords")) { - writeLexicon(`${ns}.${shortName}.listRecords`, { - lexicon: 1, id: `${ns}.${shortName}.listRecords`, - defs: { - main: { type: "query", description: `Query ${collection} records with filters`, parameters: { type: "params", properties: listParams }, output: { encoding: "application/json", schema: { type: "object", required: ["records"], properties: { records: { type: "array", items: { type: "ref", ref: "#record" } }, cursor: { type: "string" }, profiles: { type: "array", items: { type: "ref", ref: "#profileEntry" } } } } } }, - record: buildRecordDef(collectionRef, countFields, relationDefs, referenceDefs), - ...hydrateDefs, ...refDefs, ...profileDefs(), - }, - }); - - // watchRecords — realtime subscription over the same query shape. - // Only emitted when realtime is configured. SSE-only (subscription - // lexicon doesn't cleanly model custom event framing, so we describe it - // as a query with a streaming output and document the event kinds.) - if (config.realtime) { - writeLexicon(`${ns}.${shortName}.watchRecords`, { - lexicon: 1, - id: `${ns}.${shortName}.watchRecords`, - defs: { - main: { - type: "query", - description: - `Subscribe to a live ${collection} query. Returns Server-Sent Events: ` + - `\`snapshot.start\`, \`snapshot.record\` (N of), \`snapshot.end\`, ` + - `then \`record.created\` / \`record.deleted\` as records enter/leave the result, ` + - `plus a final \`member.removed\` if the caller loses access mid-stream. ` + - `v1 requires spaceUri; cross-space watch is deferred.`, - parameters: { type: "params", properties: listParams }, - output: { - encoding: "text/event-stream", - schema: { type: "object", properties: {} }, - }, - errors: [ - { name: "InvalidRequest" }, - { name: "AuthRequired" }, - { name: "Forbidden" }, - { name: "NotFound" }, - { name: "NotSupported" }, - ], - }, - }, - }); - } - } - - // --- getRecord --- - const getParams: Record<string, any> = { - uri: { type: "string", format: "at-uri", description: "AT URI of the record" }, - profiles: { type: "boolean", description: "Include profile + identity info keyed by DID" }, - ...(config.spaces - ? { - spaceUri: { - type: "string", - description: "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token). `ats://` URI.", - }, - inviteToken: { - type: "string", - description: "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied.", - }, - } - : {}), - }; - for (const rd of relationDefs) { - getParams[`hydrate${cap(rd.relName)}`] = { type: "integer", minimum: 1, maximum: 50, description: `Number of ${rd.relName} records to embed` }; - } - for (const refName of Object.keys(colConfig.references ?? {})) { - getParams[`hydrate${cap(refName)}`] = { type: "boolean", description: `Embed the referenced ${refName} record` }; - } - - if (methods.includes("getRecord")) { - writeLexicon(`${ns}.${shortName}.getRecord`, { - lexicon: 1, id: `${ns}.${shortName}.getRecord`, - defs: { - main: { type: "query", description: `Get a single ${collection} record by AT URI`, parameters: { type: "params", required: ["uri"], properties: getParams }, output: { encoding: "application/json", schema: { type: "object", required: ["uri", "value", "did", "collection", "rkey", "time_us"], properties: { ...buildRecordDef(collectionRef, countFields, relationDefs, referenceDefs).properties, profiles: { type: "array", items: { type: "ref", ref: "#profileEntry" } } } } } }, - ...hydrateDefs, ...refDefs, ...profileDefs(), - }, - }); - } - - for (const queryName of Object.keys(colConfig.queries ?? {})) { - writeLexicon(`${ns}.${shortName}.${queryName}`, { - lexicon: 1, id: `${ns}.${shortName}.${queryName}`, - defs: { main: { type: "query", description: `Custom query: ${queryName}`, output: { encoding: "application/json", schema: { type: "object", properties: {} } } } }, - }); - } - } - - // --- Spaces: instantiate library templates under <ns>.space.* and - // <ns>.spaceExt.* (contrail-specific extras — invites, whoami). - // Splitting the namespace keeps the `space.*` surface aligned with the - // permissioned-data spec; extras move when the spec does not. --- - - if (config.spaces) { - log("Generating space endpoints..."); - const templatesDir = findSpaceTemplatesDir(rootDir); - if (!templatesDir) { - log(" (space templates not found — skipping)"); - } else { - // Order matters: more specific prefix first so `spaceExt` isn't matched - // by the `space` rule. - const prefixes: Array<[string, string]> = [ - ["tools.atmo.spaceExt", `${ns}.spaceExt`], - ["tools.atmo.space", `${ns}.space`], - ]; - const templateIdRe = /^tools\.atmo\.space(Ext)?(\.[A-Za-z0-9.]+)?$/; - const idReplace = (id: string) => { - for (const [from, to] of prefixes) { - if (id === from || id.startsWith(from + ".")) { - return to + id.slice(from.length); - } - } - return id; - }; - - const rewriteRefs = (obj: any): any => { - if (Array.isArray(obj)) return obj.map(rewriteRefs); - if (obj && typeof obj === "object") { - const out: any = {}; - for (const [k, v] of Object.entries(obj)) { - if (k === "ref" && typeof v === "string") { - out[k] = idReplace(v); - } else if (k === "id" && typeof v === "string" && templateIdRe.test(v)) { - out[k] = idReplace(v); - } else { - out[k] = rewriteRefs(v); - } - } - return out; - } - return obj; - }; - - for (const file of walkJson(templatesDir)) { - const doc = JSON.parse(readFileSync(file, "utf-8")); - if (typeof doc.id !== "string" || !templateIdRe.test(doc.id)) continue; - const newId = idReplace(doc.id); - const rewritten = rewriteRefs({ ...doc, id: newId }); - writeLexicon(newId, rewritten); - } - } - } - - // --- Community: instantiate library templates under <ns>.community.* --- - - if (config.community) { - log("Generating community endpoints..."); - const templatesDir = findCommunityTemplatesDir(rootDir); - if (!templatesDir) { - log(" (community templates not found — skipping)"); - } else { - const templateIdRe = /^tools\.atmo\.community(\.[A-Za-z0-9.]+)?$/; - const idReplace = (id: string) => - id.startsWith("tools.atmo.community") - ? id.replace(/^tools\.atmo\.community/, `${ns}.community`) - : id; - - const rewriteRefs = (obj: any): any => { - if (Array.isArray(obj)) return obj.map(rewriteRefs); - if (obj && typeof obj === "object") { - const out: any = {}; - for (const [k, v] of Object.entries(obj)) { - if (k === "ref" && typeof v === "string" && v.startsWith("tools.atmo.community")) { - out[k] = v.replace(/^tools\.atmo\.community/, `${ns}.community`); - } else if (k === "id" && typeof v === "string" && templateIdRe.test(v)) { - out[k] = idReplace(v); - } else { - out[k] = rewriteRefs(v); - } - } - return out; - } - return obj; - }; - - for (const file of walkJson(templatesDir)) { - const doc = JSON.parse(readFileSync(file, "utf-8")); - if (typeof doc.id !== "string" || !templateIdRe.test(doc.id)) continue; - const newId = idReplace(doc.id); - const rewritten = rewriteRefs({ ...doc, id: newId }); - writeLexicon(newId, rewritten); - } - } - } - - // --- Realtime: instantiate library templates under <ns>.realtime.* --- - - if (config.realtime) { - log("Generating realtime endpoints..."); - const templatesDir = findRealtimeTemplatesDir(rootDir); - if (!templatesDir) { - log(" (realtime templates not found — skipping)"); - } else { - const templateIdRe = /^tools\.atmo\.realtime(\.[A-Za-z0-9.]+)?$/; - const idReplace = (id: string) => - id.startsWith("tools.atmo.realtime") - ? id.replace(/^tools\.atmo\.realtime/, `${ns}.realtime`) - : id; - - const rewriteRefs = (obj: any): any => { - if (Array.isArray(obj)) return obj.map(rewriteRefs); - if (obj && typeof obj === "object") { - const out: any = {}; - for (const [k, v] of Object.entries(obj)) { - if (k === "ref" && typeof v === "string" && v.startsWith("tools.atmo.realtime")) { - out[k] = v.replace(/^tools\.atmo\.realtime/, `${ns}.realtime`); - } else if (k === "id" && typeof v === "string" && templateIdRe.test(v)) { - out[k] = idReplace(v); - } else { - out[k] = rewriteRefs(v); - } - } - return out; - } - return obj; - }; - - for (const file of walkJson(templatesDir)) { - const doc = JSON.parse(readFileSync(file, "utf-8")); - if (typeof doc.id !== "string" || !templateIdRe.test(doc.id)) continue; - const newId = idReplace(doc.id); - const rewritten = rewriteRefs({ ...doc, id: newId }); - writeLexicon(newId, rewritten); - } - } - } - - // --- Invites: a single <ns>.invite.* family that dispatches on space - // ownership. Emitted whenever spaces is configured (user-owned spaces - // can always issue invites); community-owned spaces add the - // `accessLevel` branch inside the same handlers. --- - - if (config.spaces) { - log("Generating invite endpoints..."); - const templatesDir = findInviteTemplatesDir(rootDir); - if (!templatesDir) { - log(" (invite templates not found — skipping)"); - } else { - const templateIdRe = /^tools\.atmo\.invite(\.[A-Za-z0-9.]+)?$/; - const idReplace = (id: string) => - id.startsWith("tools.atmo.invite") - ? id.replace(/^tools\.atmo\.invite/, `${ns}.invite`) - : id; - - const rewriteRefs = (obj: any): any => { - if (Array.isArray(obj)) return obj.map(rewriteRefs); - if (obj && typeof obj === "object") { - const out: any = {}; - for (const [k, v] of Object.entries(obj)) { - if (k === "ref" && typeof v === "string" && v.startsWith("tools.atmo.invite")) { - out[k] = v.replace(/^tools\.atmo\.invite/, `${ns}.invite`); - } else if (k === "id" && typeof v === "string" && templateIdRe.test(v)) { - out[k] = idReplace(v); - } else { - out[k] = rewriteRefs(v); - } - } - return out; - } - return obj; - }; - - for (const file of walkJson(templatesDir)) { - const doc = JSON.parse(readFileSync(file, "utf-8")); - if (typeof doc.id !== "string" || !templateIdRe.test(doc.id)) continue; - const newId = idReplace(doc.id); - const rewritten = rewriteRefs({ ...doc, id: newId }); - writeLexicon(newId, rewritten); - } - } - } - - // --- Permission set --- - // Permission-set lexicons (https://atproto.com/guides/permission-sets) can - // only reference NSIDs under the same namespace as the set itself, which - // matches what we emit here: everything under `<ns>.*`. - - { - log("Generating permission set..."); - const methodNsids: string[] = []; - for (const [nsid, doc] of Object.entries(generated)) { - const mainType = (doc as any)?.defs?.main?.type; - if (mainType === "query" || mainType === "procedure") { - methodNsids.push(nsid); - } - } - methodNsids.sort(); - - const psConfig = config.permissionSet ?? {}; - const nsPrefix = `${ns}.`; - - // Auto-include any configured collections that live under the namespace. - // Cross-namespace collections (e.g. `app.bsky.*`) can't appear here — - // permission-set lexicons can only reference NSIDs in their own namespace — - // so callers must declare those as separate scopes in the OAuth client config. - const autoCollections = Object.entries(config.collections) - .map(([short, c]) => c.collection ?? short) - .filter((nsid) => nsid === ns || nsid.startsWith(nsPrefix)) - .sort(); - - // Permission-set lexicons can only reference NSIDs under their own namespace. - // Validate `additional` entries before we emit and produce an invalid schema. - for (const [i, perm] of (psConfig.additional ?? []).entries()) { - const p = perm as { resource?: string; lxm?: string[]; collection?: string[] }; - const offending: string[] = []; - for (const nsid of p.lxm ?? []) { - if (nsid !== ns && !nsid.startsWith(nsPrefix)) offending.push(nsid); - } - for (const nsid of p.collection ?? []) { - if (nsid !== ns && !nsid.startsWith(nsPrefix)) offending.push(nsid); - } - if (offending.length > 0) { - throw new Error( - `permissionSet.additional[${i}] (${p.resource}) references NSIDs outside '${ns}': ` + - offending.join(", ") + - `. Permission-set lexicons can only reference NSIDs in their own namespace — ` + - `declare those as standalone scopes in your OAuth client config instead.` - ); - } - } - - writeLexicon(`${ns}.authFull`, { - lexicon: 1, - id: `${ns}.authFull`, - defs: { - main: { - type: "permission-set", - title: psConfig.title ?? ns, - description: - psConfig.description ?? `Full access to the ${ns} service`, - permissions: [ - { - type: "permission", - resource: "rpc", - // `aud: "*"` grants the user consent to call these methods on - // *any* service DID — so one consent covers dev (tunnel DID) and - // prod (published DID) without re-consenting. `inheritAud: true` - // would be correct if the include: scope carried an aud param, - // but consent UIs drop `?aud=*` on include: lines in practice. - aud: "*", - lxm: methodNsids, - }, - ...(autoCollections.length > 0 - ? [{ type: "permission" as const, resource: "repo" as const, collection: autoCollections }] - : []), - ...(psConfig.additional ?? []), - ], - }, - }, - }); - } - - // --- Runtime files (only when called from script) --- - if (options.writeRuntimeFiles) { - // lex.config.js - const collectionNsids = Object.values(config.collections).map((c) => c.collection); - const pulledFiles = [...scanLexiconsDir(lexiconDirs), ...scanLexiconsDir([])].flat(); - const allRefs = new Set<string>(); - for (const file of pulledFiles) { - for (const ref of findRefsInLexicon(file)) allRefs.add(ref); - } - const profileNsids: string[] = (config.profiles ?? ["app.bsky.actor.profile"]).map( - (p) => (typeof p === "string" ? p : p.collection) - ); - // f.follow is a short name (a key in config.collections), not an NSID — resolve - // it before pushing into the pull list, otherwise lex-cli pull rejects it as - // "must be valid nsid". When `follow` is unset, contrail's resolveConfig - // auto-adds an `app.bsky.graph.follow` collection at runtime; mirror that - // here so the generated pull list still includes it. - const DEFAULT_FOLLOW_NSID = "app.bsky.graph.follow"; - const feedFollowNsids = config.feeds - ? Object.values(config.feeds) - .map((f) => { - if (!f.follow) return DEFAULT_FOLLOW_NSID; - return config.collections[f.follow]?.collection; - }) - .filter((nsid): nsid is string => typeof nsid === "string") - : []; - const pullNsids = new Set([...collectionNsids, ...profileNsids, ...feedFollowNsids]); - for (const ref of allRefs) { - if (!ref.startsWith("com.atproto.")) pullNsids.add(ref); - } - const sortedNsids = [...pullNsids].sort(); - const lexConfigContent = `import { defineLexiconConfig } from "@atcute/lex-cli";\n\nexport default defineLexiconConfig({\n files: ["lexicons/custom/**/*.json", "lexicons/pulled/**/*.json", "lexicons/generated/**/*.json"],\n outdir: "src/lexicon-types/",\n imports: ["@atcute/atproto"],\n pull: {\n outdir: "lexicons/pulled/",\n sources: [\n {\n type: "atproto",\n mode: "nsids",\n nsids: ${JSON.stringify(sortedNsids, null, 10).replace(/^/gm, " ").trim()},\n },\n ],\n },\n});\n`; - writeFileSync(join(rootDir, "lex.config.js"), lexConfigContent); - log(`\nGenerated lex.config.js with ${sortedNsids.length} pull NSIDs`); - - } - - // --- Bundle manifest --- - // Emit `index.ts` alongside the generated JSONs, importing every lexicon - // this deployment speaks: generated + pulled + custom. Consumers pass it - // to `createWorker(config, { lexicons })` to expose the full set at - // `/xrpc/<ns>.lexicons` — the pulled ones are needed so consumer typegen - // can resolve `$ref`s from the generated schemas. - if (outputDir) { - const paths = new Set<string>(); - - // Generated JSONs live in outputDir itself. - for (const nsid of Object.keys(generated)) { - paths.add("./" + nsid.split(".").join("/") + ".json"); - } - - // Pulled + custom JSONs live in the configured lexiconDirs (outside outputDir). - for (const dir of lexiconDirs) { - if (!existsSync(dir)) continue; - for (const file of walkJson(dir)) { - let rel = relative(outputDir, file); - if (!rel.startsWith(".")) rel = "./" + rel; - paths.add(rel); - } - } - - const sorted = [...paths].sort(); - const imports = sorted.map((p, i) => `import _${i} from "${p}";`).join("\n"); - const exportArray = `export const lexicons: object[] = [${sorted.map((_, i) => `_${i}`).join(", ")}];\n`; - const header = - "// Auto-generated by @atmo-dev/contrail-lexicons. Do not edit.\n" + - "// Pass `lexicons` to `createWorker(config, { lexicons })` to expose them\n" + - "// at `/xrpc/<namespace>.lexicons` for consumer apps to typegen against.\n\n"; - writeFileSync(join(outputDir, "index.ts"), header + imports + "\n\n" + exportArray); - } - - log("\nDone!"); - return generated; -} - -// --- Helpers used by writeRuntimeFiles --- - -function scanLexiconsDir(dirs: string[]): string[] { - const files: string[] = []; - for (const dir of dirs) { - if (!existsSync(dir)) continue; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) files.push(...scanLexiconsDir([fullPath])); - else if (entry.name.endsWith(".json")) files.push(fullPath); - } - } - return files; -} - -function findRefsInLexicon(filePath: string): string[] { - try { - const content = readFileSync(filePath, "utf-8"); - const refs: string[] = []; - const refPattern = /"ref":\s*"([a-z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9]+)+)(?:#\w+)?"/g; - let match; - while ((match = refPattern.exec(content)) !== null) refs.push(match[1]); - const refsArrayPattern = /"refs":\s*\[([^\]]+)\]/g; - while ((match = refsArrayPattern.exec(content)) !== null) { - const inner = match[1]; - const nsidPattern = /"([a-z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9]+)+)(?:#\w+)?"/g; - let innerMatch; - while ((innerMatch = nsidPattern.exec(inner)) !== null) refs.push(innerMatch[1]); - } - return refs; - } catch { - return []; - } -} diff --git a/packages/lexicons/src/index.ts b/packages/lexicons/src/index.ts deleted file mode 100644 index 18dd5b6..0000000 --- a/packages/lexicons/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - generateLexicons, - extractXrpcMethods, - listXrpcMethods, -} from "./generate.js"; -export type { GenerateOptions } from "./generate.js"; - -export { publishLexicons, nsidAuthority, nsidRootDomain } from "./publish.js"; -export type { PublishOptions, Session } from "./publish.js"; diff --git a/packages/lexicons/src/publish.ts b/packages/lexicons/src/publish.ts deleted file mode 100644 index 0ffbaf1..0000000 --- a/packages/lexicons/src/publish.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Publish locally-generated lexicons to a PDS as `com.atproto.lexicon.schema` - * records. Each lexicon ends up at `at://<did>/com.atproto.lexicon.schema/<nsid>`. - * - * Exposed as a library function so downstream deployments can wrap it with a - * one-line script pointing at their own `lexicons/generated/` directory. - */ - -import { readFileSync, readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; -import { createInterface } from "node:readline/promises"; -import { stdin as input, stdout as output } from "node:process"; - -export interface Session { - did: string; - accessJwt: string; - pdsEndpoint: string; -} - -async function login(identifier: string, password: string): Promise<Session> { - const resolveRes = await fetch( - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(identifier)}` - ); - let did: string; - if (resolveRes.ok) { - did = ((await resolveRes.json()) as { did: string }).did; - } else if (identifier.startsWith("did:")) { - did = identifier; - } else { - throw new Error(`Could not resolve handle: ${identifier}`); - } - - const docUrl = did.startsWith("did:plc:") - ? `https://plc.directory/${did}` - : did.startsWith("did:web:") - ? `https://${did.slice("did:web:".length)}/.well-known/did.json` - : null; - if (!docUrl) throw new Error(`Unsupported DID method: ${did}`); - - const docRes = await fetch(docUrl); - if (!docRes.ok) throw new Error(`Could not fetch DID doc for ${did}`); - const doc = (await docRes.json()) as { - service?: { id: string; type: string; serviceEndpoint: string }[]; - }; - const pds = doc.service?.find( - (s) => s.id.endsWith("#atproto_pds") || s.type === "AtprotoPersonalDataServer" - )?.serviceEndpoint; - if (!pds) throw new Error(`No PDS service entry in DID doc for ${did}`); - - const sessionRes = await fetch(`${pds}/xrpc/com.atproto.server.createSession`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identifier, password }), - }); - if (!sessionRes.ok) { - throw new Error(`Login failed: ${sessionRes.status} ${await sessionRes.text()}`); - } - const session = (await sessionRes.json()) as { did: string; accessJwt: string }; - return { did: session.did, accessJwt: session.accessJwt, pdsEndpoint: pds }; -} - -function* walkJson(dir: string): Generator<string> { - for (const name of readdirSync(dir)) { - const full = join(dir, name); - if (statSync(full).isDirectory()) yield* walkJson(full); - else if (name.endsWith(".json")) yield full; - } -} - -async function putLexiconRecord( - session: Session, - lexicon: { id: string; [k: string]: unknown } -): Promise<void> { - const body = { - repo: session.did, - collection: "com.atproto.lexicon.schema", - rkey: lexicon.id, - record: { $type: "com.atproto.lexicon.schema", ...lexicon }, - }; - const res = await fetch(`${session.pdsEndpoint}/xrpc/com.atproto.repo.putRecord`, { - method: "POST", - headers: { - Authorization: `Bearer ${session.accessJwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - if (!res.ok) { - throw new Error(`putRecord failed for ${lexicon.id}: ${res.status} ${await res.text()}`); - } -} - -/** DNS authority an NSID resolves to: all segments except the final (name), - * read right-to-left. Example: `rsvp.atmo.space.deleteRecord` → `space.atmo.rsvp` - * (client looks up TXT at `_lexicon.space.atmo.rsvp`). Resolution does not walk - * up — each distinct authority needs its own TXT record. */ -export function nsidAuthority(nsid: string): string { - const parts = nsid.split("."); - if (parts.length < 2) return nsid; - return parts.slice(0, -1).reverse().join("."); -} - -/** Root DNS zone for an NSID — the first two segments reversed. */ -export function nsidRootDomain(nsid: string): string { - const parts = nsid.split("."); - if (parts.length < 2) return nsid; - return `${parts[1]}.${parts[0]}`; -} - -export interface PublishOptions { - /** Directory to walk for `*.json` lexicon files. */ - generatedDir: string; - /** PDS account handle or DID. Not required when `dryRun` is true. */ - identifier?: string; - /** App password (or equivalent on a non-bsky PDS). Not required when `dryRun` is true. */ - password?: string; - /** Skip the interactive "do you control these zones?" prompt. */ - skipConfirm?: boolean; - /** Print what would be published + the DNS records that would be required, - * then return without logging in or writing anything to the PDS. */ - dryRun?: boolean; -} - -export async function publishLexicons(opts: PublishOptions): Promise<{ - published: number; - failed: string[]; - authorities: string[]; - session: Session; -}> { - const lexicons: { id: string; [k: string]: unknown }[] = []; - for (const file of walkJson(opts.generatedDir)) { - const doc = JSON.parse(readFileSync(file, "utf-8")); - if (typeof doc.id === "string") lexicons.push(doc); - } - lexicons.sort((a, b) => a.id.localeCompare(b.id)); - - const rootDomains = new Set<string>(); - const authorities = new Set<string>(); - for (const lex of lexicons) { - rootDomains.add(nsidRootDomain(lex.id)); - authorities.add(nsidAuthority(lex.id)); - } - const sortedAuthorities = [...authorities].sort(); - - const header = opts.dryRun - ? `[dry-run] Would publish ${lexicons.length} lexicons from ${opts.generatedDir}:` - : `About to publish ${lexicons.length} lexicons from ${opts.generatedDir}.`; - console.log(`${header}\n`); - if (opts.dryRun) { - for (const lex of lexicons) console.log(` ${lex.id}`); - console.log(""); - } - console.log( - `⚠ Do you control ${rootDomains.size === 1 ? "the DNS zone" : "these DNS zones"} below?` - ); - for (const root of [...rootDomains].sort()) console.log(` ${root}`); - console.log( - "\n You'll need to add TXT records under that zone so clients can\n" + - " resolve the NSIDs. If you don't control it, the records will\n" + - " sit on your PDS but won't be authoritative.\n\n" + - " ⚠ Specifically: permission sets (OAuth `include:` scopes) will\n" + - " not work without DNS resolution — the user's PDS fetches the\n" + - " permission-set lexicon by resolving the NSID, and resolution\n" + - " requires a valid TXT record.\n" - ); - - if (opts.dryRun) { - console.log("─────────────────────────────────────────────────"); - console.log("DNS TXT records that would be needed for NSID resolution"); - console.log("─────────────────────────────────────────────────"); - console.log( - "Atproto NSID resolution does NOT walk up — each distinct authority\n" + - "needs its own TXT record. One record per unique authority below.\n" + - "(DID would be filled in at publish time, once logged in.)\n" - ); - for (const authority of sortedAuthorities) { - console.log(` host: _lexicon.${authority}`); - console.log(` value: did=<your-did-here>`); - console.log(""); - } - console.log(`Total: ${sortedAuthorities.length} DNS TXT record(s) to add.`); - console.log("\n[dry-run] No PDS login or writes performed."); - return { published: 0, failed: [], authorities: sortedAuthorities, session: null as never }; - } - - if (!opts.identifier || !opts.password) { - throw new Error("`identifier` and `password` are required when `dryRun` is false"); - } - - if (!opts.skipConfirm) { - const rl = createInterface({ input, output }); - const answer = (await rl.question("Continue? [y/N] ")).trim().toLowerCase(); - rl.close(); - if (answer !== "y" && answer !== "yes") { - console.log("Aborted."); - return { published: 0, failed: [], authorities: sortedAuthorities, session: null as never }; - } - } - - console.log(`\nLogging in as ${opts.identifier}…`); - const session = await login(opts.identifier, opts.password); - console.log(` DID: ${session.did}`); - console.log(` PDS: ${session.pdsEndpoint}\n`); - console.log(`Publishing ${lexicons.length} lexicons…\n`); - - let ok = 0; - const failed: string[] = []; - for (const lex of lexicons) { - try { - await putLexiconRecord(session, lex); - console.log(` ✓ ${lex.id}`); - ok++; - } catch (err) { - console.error(` ✗ ${lex.id}: ${(err as Error).message}`); - failed.push(lex.id); - } - } - - console.log( - `\nPublished ${ok}/${lexicons.length} lexicons${failed.length > 0 ? ` (failures: ${failed.join(", ")})` : ""}.` - ); - - console.log("\n─────────────────────────────────────────────────"); - console.log("DNS TXT records needed for NSID resolution"); - console.log("─────────────────────────────────────────────────"); - console.log( - "Atproto NSID resolution does NOT walk up — each distinct authority\n" + - "needs its own TXT record. One record per unique authority below.\n" - ); - for (const authority of sortedAuthorities) { - console.log(` host: _lexicon.${authority}`); - console.log(` value: did=${session.did}`); - console.log(""); - } - console.log(`Total: ${sortedAuthorities.length} DNS TXT record(s) to add.`); - - return { published: ok, failed, authorities: sortedAuthorities, session }; -} diff --git a/packages/lexicons/tests/generate.test.ts b/packages/lexicons/tests/generate.test.ts deleted file mode 100644 index 73d0eb0..0000000 --- a/packages/lexicons/tests/generate.test.ts +++ /dev/null @@ -1,456 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { join } from "path"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "fs"; -import { tmpdir } from "os"; -import { generateLexicons, extractXrpcMethods, listXrpcMethods } from "../src/generate"; -import type { ContrailConfig } from "@atmo-dev/contrail"; - -const ROOT_DIR = join(__dirname, ".."); - -function getParams(lexicon: any): Record<string, any> { - return lexicon?.defs?.main?.parameters?.properties ?? {}; -} - -function getInputSchema(lexicon: any): any { - return lexicon?.defs?.main?.input?.schema; -} - -function getOutputSchema(lexicon: any): any { - return lexicon?.defs?.main?.output?.schema; -} - -function generate(config: ContrailConfig) { - return generateLexicons({ - config, - rootDir: ROOT_DIR, - lexiconDirs: [], - quiet: true, - }); -} - -// --- Test configs --- - -const BASIC_CONFIG: ContrailConfig = { - namespace: "test.app", - collections: { - post: { - collection: "com.example.post", - queryable: { - title: {}, - body: {}, - createdAt: { type: "range" }, - }, - }, - }, -}; - -const RELATIONS_CONFIG: ContrailConfig = { - namespace: "test.app", - collections: { - post: { - collection: "com.example.post", - queryable: { title: {} }, - relations: { - likes: { - collection: "like", - }, - }, - }, - like: { - collection: "com.example.like", - queryable: { status: {} }, - references: { - post: { - collection: "post", - field: "subject.uri", - }, - }, - }, - }, -}; - -const SEARCH_EXPLICIT_CONFIG: ContrailConfig = { - namespace: "test.app", - collections: { - post: { - collection: "com.example.post", - queryable: { - title: {}, - body: {}, - category: {}, - createdAt: { type: "range" }, - }, - searchable: ["title", "body"], - }, - }, -}; - -const SEARCH_DISABLED_CONFIG: ContrailConfig = { - namespace: "test.app", - collections: { - post: { - collection: "com.example.post", - queryable: { title: {}, body: {} }, - searchable: false, - }, - }, -}; - -const SEARCH_AUTO_CONFIG: ContrailConfig = { - namespace: "test.app", - collections: { - post: { - collection: "com.example.post", - queryable: { - title: {}, - body: {}, - score: { type: "range" }, - }, - }, - }, -}; - -describe("basic generation", () => { - let lexicons: Record<string, any>; - - beforeAll(() => { - lexicons = generate(BASIC_CONFIG); - }); - - it("generates admin endpoints", () => { - expect(lexicons["test.app.getCursor"]).toBeDefined(); - expect(lexicons["test.app.getOverview"]).toBeDefined(); - }); - - it("generates getProfile", () => { - const lex = lexicons["test.app.getProfile"]; - expect(lex).toBeDefined(); - const params = getParams(lex); - expect(params.actor).toBeDefined(); - expect(params.actor.format).toBe("at-identifier"); - }); - - it("generates notifyOfUpdate as a procedure", () => { - const lex = lexicons["test.app.notifyOfUpdate"]; - expect(lex).toBeDefined(); - expect(lex.defs.main.type).toBe("procedure"); - - const input = getInputSchema(lex); - expect(input.properties.uri).toBeDefined(); - expect(input.properties.uri.format).toBe("at-uri"); - expect(input.properties.uris.type).toBe("array"); - expect(input.properties.uris.maxLength).toBe(25); - - const output = getOutputSchema(lex); - expect(output.required).toContain("indexed"); - expect(output.required).toContain("deleted"); - expect(output.properties.errors.type).toBe("array"); - }); - - it("generates listRecords under <ns>.<short>.listRecords", () => { - const params = getParams(lexicons["test.app.post.listRecords"]); - expect(params.limit).toBeDefined(); - expect(params.cursor).toBeDefined(); - expect(params.actor).toBeDefined(); - expect(params.profiles).toBeDefined(); - expect(params.title).toBeDefined(); - expect(params.bodyParam ?? params.body).toBeDefined(); - expect(params.createdAtMin).toBeDefined(); - expect(params.createdAtMax).toBeDefined(); - }); - - it("generates getRecord under <ns>.<short>.getRecord", () => { - const lex = lexicons["test.app.post.getRecord"]; - expect(lex).toBeDefined(); - const params = getParams(lex); - expect(params.uri).toBeDefined(); - expect(params.uri.required ?? lex.defs.main.parameters.required).toContain("uri"); - }); - - it("does not emit the old NSID-based endpoint paths", () => { - expect(lexicons["com.example.post.listRecords"]).toBeUndefined(); - expect(lexicons["com.example.post.getRecord"]).toBeUndefined(); - }); -}); - -describe("relations and references", () => { - let lexicons: Record<string, any>; - - beforeAll(() => { - lexicons = generate(RELATIONS_CONFIG); - }); - - it("includes relation count params", () => { - const params = getParams(lexicons["test.app.post.listRecords"]); - expect(params.likesCountMin).toBeDefined(); - expect(params.hydrateLikes).toBeDefined(); - }); - - it("includes reference hydrate params on child", () => { - const params = getParams(lexicons["test.app.like.listRecords"]); - expect(params.hydratePost).toBeDefined(); - }); -}); - -describe("search: explicit fields", () => { - let lexicons: Record<string, any>; - beforeAll(() => { - lexicons = generate(SEARCH_EXPLICIT_CONFIG); - }); - - it("exposes search param", () => { - const params = getParams(lexicons["test.app.post.listRecords"]); - expect(params.search).toBeDefined(); - expect(params.search.description).toContain("title"); - expect(params.search.description).toContain("body"); - }); -}); - -describe("search: disabled", () => { - it("does not include search param", () => { - const lexicons = generate(SEARCH_DISABLED_CONFIG); - const params = getParams(lexicons["test.app.post.listRecords"]); - expect(params.search).toBeUndefined(); - }); -}); - -describe("search: no searchable field configured", () => { - it("does not include search param when searchable is omitted", () => { - const lexicons = generate(SEARCH_AUTO_CONFIG); - const params = getParams(lexicons["test.app.post.listRecords"]); - expect(params.search).toBeUndefined(); - }); -}); - -describe("extractXrpcMethods / listXrpcMethods", () => { - it("extracts only queries and procedures from a generated lexicon map", () => { - const lexicons = generate(BASIC_CONFIG); - const methods = extractXrpcMethods(lexicons); - // Sorted; includes admin + profile + listRecords/getRecord for the collection. - expect(methods).toEqual([...methods].sort()); - expect(methods).toContain("test.app.post.listRecords"); - expect(methods).toContain("test.app.post.getRecord"); - expect(methods).toContain("test.app.getProfile"); - // Does not include non-method defs (e.g. `defs`, records, permission-set). - expect(methods).not.toContain("test.app.authFull"); - }); - - it("listXrpcMethods matches the authFull lxm list for the same config", () => { - const methods = listXrpcMethods(BASIC_CONFIG, { rootDir: ROOT_DIR, lexiconDirs: [] }); - const lexicons = generate(BASIC_CONFIG); - const ps = (lexicons["test.app.authFull"] as any).defs.main.permissions[0]; - expect(ps.lxm).toEqual(methods); - }); - - it("authFull auto-includes same-namespace collections in a repo permission", () => { - const config: ContrailConfig = { - namespace: "test.app", - collections: { - local: { collection: "test.app.thing" }, - external: { collection: "xyz.other.thing" }, - }, - }; - const lexicons = generate(config); - const perms = (lexicons["test.app.authFull"] as any).defs.main.permissions; - const repoEntry = perms.find((p: any) => p.resource === "repo"); - expect(repoEntry).toBeDefined(); - expect(repoEntry.collection).toEqual(["test.app.thing"]); - }); - - it("authFull omits the repo permission entry when no collections match the namespace", () => { - const config: ContrailConfig = { - namespace: "test.app", - collections: { - external: { collection: "xyz.other.thing" }, - }, - }; - const lexicons = generate(config); - const perms = (lexicons["test.app.authFull"] as any).defs.main.permissions; - expect(perms.find((p: any) => p.resource === "repo")).toBeUndefined(); - }); - - it("includes realtime + community + spaces endpoints when those modules are enabled", () => { - const config: ContrailConfig = { - namespace: "test.comm", - collections: { message: { collection: "app.event.message" } }, - spaces: { - authority: { type: "tools.atmo.event.space", serviceDid: "did:web:test.example#svc" }, - recordHost: {}, - }, - community: { masterKey: new Uint8Array(32).fill(1) }, - realtime: { ticketSecret: new Uint8Array(32).fill(2) }, - }; - const methods = listXrpcMethods(config, { rootDir: ROOT_DIR, lexiconDirs: [] }); - expect(methods).toContain("test.comm.space.createSpace"); - expect(methods).toContain("test.comm.community.adopt"); - expect(methods).toContain("test.comm.realtime.ticket"); - expect(methods).toContain("test.comm.realtime.subscribe"); - }); -}); - -describe("manifest emission (lexicons/generated/index.ts)", () => { - let workdir: string; - let outDir: string; - - beforeAll(() => { - workdir = mkdtempSync(join(tmpdir(), "contrail-manifest-")); - outDir = join(workdir, "lexicons", "generated"); - }); - afterAll(() => { - rmSync(workdir, { recursive: true, force: true }); - }); - - it("emits index.ts with imports for every generated lexicon", () => { - generateLexicons({ - config: BASIC_CONFIG, - rootDir: workdir, - outputDir: outDir, - lexiconDirs: [], // skip pulled/custom for this test - quiet: true, - }); - const manifest = readFileSync(join(outDir, "index.ts"), "utf-8"); - - // One import per generated NSID — at minimum: collection-level + admin endpoints. - expect(manifest).toMatch(/import _0 from "\.\/test\/app\/.*\.json";/); - expect(manifest).toContain('export const lexicons: object[] ='); - - // Sorted, so getCursor (admin) appears before post.* (collection). - const importLines = manifest.split("\n").filter((l) => l.startsWith("import ")); - const paths = importLines.map((l) => l.match(/from "(.*?)"/)?.[1] ?? ""); - expect(paths).toEqual([...paths].sort()); - }); - - it("includes pulled lexicons in the manifest (with relative paths)", () => { - // Set up a fake pulled dir with one lexicon. - const pulledDir = join(workdir, "lexicons", "pulled"); - mkdirSync(join(pulledDir, "app", "bsky", "actor"), { recursive: true }); - writeFileSync( - join(pulledDir, "app", "bsky", "actor", "profile.json"), - JSON.stringify({ lexicon: 1, id: "app.bsky.actor.profile", defs: {} }) - ); - - rmSync(outDir, { recursive: true, force: true }); - generateLexicons({ - config: BASIC_CONFIG, - rootDir: workdir, - outputDir: outDir, - lexiconDirs: [pulledDir], - quiet: true, - }); - const manifest = readFileSync(join(outDir, "index.ts"), "utf-8"); - - // Pulled lexicon path is relative from outputDir, so it walks `..`. - expect(manifest).toContain('import _0 from "../pulled/app/bsky/actor/profile.json";'); - // Generated lexicons are still there with `./` paths. - expect(manifest).toMatch(/import _\d+ from "\.\/test\/app\//); - }); - - it("dedupes if the same path is somehow listed twice", () => { - rmSync(outDir, { recursive: true, force: true }); - generateLexicons({ - config: BASIC_CONFIG, - rootDir: workdir, - outputDir: outDir, - lexiconDirs: [], - quiet: true, - }); - const manifest = readFileSync(join(outDir, "index.ts"), "utf-8"); - const importLines = manifest.split("\n").filter((l) => l.startsWith("import ")); - const paths = importLines.map((l) => l.match(/from "(.*?)"/)?.[1] ?? ""); - expect(new Set(paths).size).toBe(paths.length); - }); - - it("does not emit a manifest when outputDir is omitted (in-memory only)", () => { - const isolatedDir = mkdtempSync(join(tmpdir(), "contrail-no-output-")); - try { - generateLexicons({ - config: BASIC_CONFIG, - rootDir: isolatedDir, - // no outputDir - lexiconDirs: [], - quiet: true, - }); - expect(existsSync(join(isolatedDir, "lexicons"))).toBe(false); - } finally { - rmSync(isolatedDir, { recursive: true, force: true }); - } - }); -}); - -describe("runtime files: lex.config.js pull NSIDs", () => { - let workdir: string; - - beforeAll(() => { - workdir = mkdtempSync(join(tmpdir(), "contrail-feedpull-")); - }); - afterAll(() => { - rmSync(workdir, { recursive: true, force: true }); - }); - - function readPullNsids(): string[] { - const lexConfig = readFileSync(join(workdir, "lex.config.js"), "utf-8"); - const match = lexConfig.match(/nsids:\s*(\[[\s\S]*?\])/); - if (!match) throw new Error("could not locate pull nsids array in lex.config.js"); - return JSON.parse(match[1]); - } - - it("resolves feed.follow short names to NSIDs (regression: lex-cli pull rejects bare short names)", () => { - const config: ContrailConfig = { - namespace: "test.app", - collections: { - follow: { collection: "app.bsky.graph.follow" }, - event: { collection: "community.lexicon.calendar.event" }, - }, - feeds: { - network: { follow: "follow", targets: ["event"] }, - }, - }; - - generateLexicons({ - config, - rootDir: workdir, - lexiconDirs: [], - writeRuntimeFiles: true, - quiet: true, - }); - - const nsids = readPullNsids(); - - // Every entry must be a valid NSID — at least one dot, and never equal a feed short name. - const feedShortNames = Object.keys(config.collections); - for (const nsid of nsids) { - expect(nsid).toMatch(/\./); - expect(feedShortNames).not.toContain(nsid); - } - - // The follow collection's NSID is included via the feeds path. - expect(nsids).toContain("app.bsky.graph.follow"); - }); - - it("skips feeds whose follow short name is missing from collections (no undefineds in pull list)", () => { - const config = { - namespace: "test.app", - collections: { - event: { collection: "community.lexicon.calendar.event" }, - }, - feeds: { - broken: { follow: "doesNotExist", targets: ["event"] }, - }, - } as unknown as ContrailConfig; - - generateLexicons({ - config, - rootDir: workdir, - lexiconDirs: [], - writeRuntimeFiles: true, - quiet: true, - }); - - const nsids = readPullNsids(); - for (const nsid of nsids) { - expect(typeof nsid).toBe("string"); - expect(nsid).toMatch(/\./); - } - expect(nsids).not.toContain("doesNotExist"); - }); -}); diff --git a/packages/lexicons/tsconfig.build.json b/packages/lexicons/tsconfig.build.json deleted file mode 100644 index 374bac5..0000000 --- a/packages/lexicons/tsconfig.build.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/lexicons/tsconfig.json b/packages/lexicons/tsconfig.json deleted file mode 100644 index 822a105..0000000 --- a/packages/lexicons/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "types": ["node"] - }, - "include": ["src", "tests"] -} diff --git a/packages/lexicons/tsup.config.ts b/packages/lexicons/tsup.config.ts deleted file mode 100644 index b478313..0000000 --- a/packages/lexicons/tsup.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: [ - "src/index.ts", - "src/generate.ts", - "src/publish.ts", - "src/cli.ts", - ], - format: ["esm"], - dts: true, - sourcemap: true, - clean: true, - tsconfig: "tsconfig.build.json", -}); diff --git a/packages/lexicons/vitest.config.ts b/packages/lexicons/vitest.config.ts deleted file mode 100644 index 19384e8..0000000 --- a/packages/lexicons/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["tests/**/*.test.ts"], - }, -}); diff --git a/packages/sync/CHANGELOG.md b/packages/sync/CHANGELOG.md deleted file mode 100644 index 8e0b9e1..0000000 --- a/packages/sync/CHANGELOG.md +++ /dev/null @@ -1,188 +0,0 @@ -# @atmo-dev/contrail-sync - -## 0.12.2 - -## 0.12.1 - -## 0.12.0 - -## 0.11.0 - -## 0.10.0 - -## 0.9.1 - -## 0.9.0 - -## 0.8.0 - -## 0.7.0 - -### Minor Changes - -- 7e3145b: Spaces refactor: split authority + record host into independently runnable - roles, add space credentials, extract community into its own package. - - **Breaking — config shape** - - `spaces` is no longer flat — split into `authority` and `recordHost`: - - ```ts - // before - spaces: { - type: "com.example.event.space", - serviceDid: "did:web:example.com", - blobs: { adapter, maxSize }, - } - - // after - spaces: { - authority: { - type: "com.example.event.space", - serviceDid: "did:web:example.com", - signing: await generateAuthoritySigningKey(), - }, - recordHost: { - blobs: { adapter, maxSize }, - }, - } - ``` - - **Breaking — community moved to its own package** - - Community has been extracted to `@atmo-dev/contrail-community`. Wire it via - `createCommunityIntegration`: - - ```ts - import { Contrail, resolveConfig } from "@atmo-dev/contrail"; - import { createCommunityIntegration } from "@atmo-dev/contrail-community"; - - const resolved = resolveConfig(config); - const communityIntegration = createCommunityIntegration({ - db, - config: resolved, - }); - const contrail = new Contrail({ ...config, communityIntegration }); - ``` - - The community config (`config.community`) stays the same; only the wiring - moves. Imports of `CommunityAdapter`, `registerCommunityRoutes`, - `reconcile`, etc. now come from `@atmo-dev/contrail-community` instead of - `@atmo-dev/contrail`. - - **New — space credentials (`X-Space-Credential`)** - - The space authority issues short-lived ES256 JWTs (default 2h TTL) via - `<ns>.space.getCredential` and `refreshCredential`. The record host accepts - them on read/write paths in lieu of per-request service-auth JWTs. Skips - DID-doc fetches and member checks; the credential's signature is the proof. - - Generate a signing key once at deploy time: - - ```ts - import { generateAuthoritySigningKey } from "@atmo-dev/contrail"; - const signing = await generateAuthoritySigningKey(); - // Store the JWK; pass to spaces.authority.signing. - ``` - - **New — binding resolution** - - Verifiers can resolve "which authority signs for this space?" from three - sources, in order: local enrollment table, PDS records at - `at://<owner>/<type>/<key>`, DID-doc `#atproto_space_authority` service - entry, owner-self fallback. Lets user-owned DIDs authorize a third-party - authority via a normal PDS write — no DID-doc surgery. - - **New — independent deployments + enrollment** - - The authority and record host can run as separate processes/operators. - A new `<ns>.recordHost.enroll` endpoint lets owners (or authorities) - register a space onto a host. In-process deployments auto-enroll on - `createSpace`; nothing changes for single-instance setups. - - See `docs/10-deployment-shapes.md` for all-in-one / authority-only / - host-only configurations and when to choose each. - - **Migration** - - For most deployments running spaces today, the migration is: - - 1. Update the config: split `spaces.{type, serviceDid, blobs}` into - `spaces.authority.{type, serviceDid}` and `spaces.recordHost.{blobs}`. - 2. Generate and store an authority signing key - (`generateAuthoritySigningKey()`); add to `spaces.authority.signing`. - 3. If using community: install `@atmo-dev/contrail-community`, build - `createCommunityIntegration({ db, config })`, pass via - `new Contrail({ communityIntegration })` (or `createApp({ community })`). - - Existing service-auth JWT clients keep working as a fallback path. - Migrate to space credentials when convenient — exchange a JWT for a - credential once via `getCredential`, then reuse it. - -## 0.4.0 - -### Minor Changes - -- 469bf65: unify the per-space marker field on records as `space` everywhere. previously `listRecords` / `getRecord` HTTP responses used `space: <spaceUri>` while watch events and `WatchRecord` exposed it as `_space`. the underscored form was inconsistent with the surrounding fields (`uri`, `cid`, `did`, etc.) and forced consumers to remember which path produced which name. - - **breaking.** anywhere you read `r._space` on a `WatchRecord` (or a watch event payload's `record._space` / `child._space`), rename to `r.space`. drop-in. - - ```ts - // before - if (record._space) ... - - // after - if (record.space) ... - ``` - - no migration needed for `listRecords` / `getRecord` consumers — that path was already `space`. - -- 469bf65: permissioned spaces now use the `ats://` scheme instead of `at://`. tracks the [permissioned data spec](https://dholms.leaflet.pub/3mhj6bcqats2o), which floats `ats://` as a distinct scheme so spaces can't be confused with atproto record URIs at any layer (logs, query params, dispatch, error messages). - - ``` - - at://did:plc:alice/com.example.event.space/birthday - + ats://did:plc:alice/com.example.event.space/birthday - ``` - - what changed: - - - `buildSpaceUri` / `parseSpaceUri` (`@atmo-dev/contrail`) emit / accept `ats://`. anything else returns `null` from `parseSpaceUri`. - - generated lexicons no longer claim `format: "at-uri"` on `spaceUri` params, on the `space` record-output field, or on `spaceView.uri` — they're plain `string`. (atproto's `at-uri` format would reject `ats://`.) regenerate committed `lexicons/generated/*` with `contrail-lex generate`; downstream `lex-cli generate` then emits `v.string()` instead of `v.resourceUriString()` for those fields. - - realtime topics are unchanged in shape (`space:<uri>`), but `<uri>` is now an `ats://` URI. - - record URIs (the `uri` on a record, the `appPolicyRef` field, `notifyOfUpdate` payloads) keep `at://` — those are still atproto record URIs. - - **breaking.** anywhere you build a space URI by string concatenation (`` `at://${did}/${type}/${key}` ``), switch to `ats://` or call `buildSpaceUri()`. anywhere you persist space URIs in your own DB, migrate (`UPDATE … SET space_uri = REPLACE(space_uri, 'at://', 'ats://') WHERE space_uri LIKE 'at://%'`). - -## 0.3.0 - -### Minor Changes - -- f8fa672: align the `listRecords` / `getRecord` response envelope with atproto's `com.atproto.repo.*`. the field that carries the record value is now `value`, not `record`. - - **before** (contrail-specific): - - ```jsonc - { "records": [{ "uri", "did", "collection", "rkey", "cid", "record": {...}, "time_us" }] } - ``` - - **after** (atproto-compatible plus extras): - - ```jsonc - { "records": [{ "uri", "cid", "value": {...}, "did", "collection", "rkey", "time_us" }] } - ``` - - changes: - - - `#record` def now requires `["uri", "cid", "value"]` (matches atproto's standard `com.atproto.repo.listRecords#record`). `did`/`collection`/`rkey`/`time_us` remain in the response but are optional. - - `getRecord` top-level output requires `["uri", "value"]` (matches atproto's `com.atproto.repo.getRecord`). - - profile entries in `?profiles=true` responses use `value` instead of `record` for the profile record body. - - realtime watch events (`record.created`, `snapshot.record`, `hydration.added`) — the inner record payload's body field is now `value`. - - `@atmo-dev/contrail-sync`: `WatchRecord.value` (was `record`); `addOptimistic({ value })` (was `record`). - - **breaking.** anywhere you read `r.record` from a contrail response, rename to `r.value`. anywhere you call `addOptimistic({ record: ... })`, switch to `addOptimistic({ value: ... })`. regenerate committed `lexicons/generated/*` in each deployment — the new shape will be advertised on next `contrail-lex generate` run. - -## 0.2.0 - -### Minor Changes - -- 97bd494: split packages, monorepo diff --git a/packages/sync/README.md b/packages/sync/README.md deleted file mode 100644 index 42c66fc..0000000 --- a/packages/sync/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# @atmo-dev/contrail-sync - -Client-side reactive watch-store over [contrail](https://www.npmjs.com/package/@atmo-dev/contrail)'s `watchRecords` endpoints. - -- SSE (default) or WebSocket transports. WS handshake produces a watch-scoped ticket so Cloudflare Durable Objects can hibernate idle connections. -- Optimistic updates (`addOptimistic` / `markFailed` / `removeOptimistic`). -- Automatic reconciliation across reconnects: stale records stay visible until a fresh snapshot arrives, then entries the server didn't re-send are evicted. -- Optional persistent cache (IndexedDB adapter included) for instant first paint. - -```ts -import { createWatchStore } from "@atmo-dev/contrail-sync"; -import { createIndexedDBCache } from "@atmo-dev/contrail-sync/cache-idb"; - -const store = createWatchStore({ - url: "/xrpc/com.example.message.watchRecords?roomUri=at://...", - transport: "ws", - mintTicket: async () => (await fetch("/api/ticket")).then((r) => r.text()), - cache: createIndexedDBCache(), -}); - -store.subscribe(({ records, status }) => { - // re-render -}); - -store.start(); -``` - -Framework-agnostic: wrap the subscribable store in your framework's reactive primitives (Svelte `$state`, React `useSyncExternalStore`, Vue `ref`, etc). diff --git a/packages/sync/package.json b/packages/sync/package.json deleted file mode 100644 index 559587d..0000000 --- a/packages/sync/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@atmo-dev/contrail-sync", - "version": "0.12.2", - "description": "Client-side reactive watch-store over contrail's watchRecords endpoints. SSE + WebSocket transports, optimistic updates, optional IndexedDB cache.", - "type": "module", - "sideEffects": false, - "files": [ - "dist" - ], - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./cache-idb": { - "types": "./dist/cache-idb.d.ts", - "import": "./dist/cache-idb.js" - } - }, - "repository": { - "type": "git", - "url": "https://github.com/flo-bit/contrail.git", - "directory": "packages/sync" - }, - "keywords": [ - "atproto", - "contrail", - "sync", - "realtime", - "watch" - ], - "scripts": { - "build": "tsup", - "clean": "rm -rf dist", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "tsup": "^8.5.0", - "typescript": "^5.7.3" - }, - "license": "MIT" -} diff --git a/packages/sync/src/cache-idb.ts b/packages/sync/src/cache-idb.ts deleted file mode 100644 index 2b2f474..0000000 --- a/packages/sync/src/cache-idb.ts +++ /dev/null @@ -1,91 +0,0 @@ -/// <reference lib="dom" /> -/** IndexedDB-backed implementation of `WatchCache`. Browser-only. - * - * Single object store keyed by the watch query's `cacheKey` (defaults to - * the watch URL). Values are `{records, updatedAt}`. Persists across page - * reloads; cleared by the user clearing site data. */ - -import type { WatchCache, WatchRecord } from "./index.js"; - -interface StoredEntry { - records: WatchRecord[]; - updatedAt: number; -} - -export interface IndexedDBCacheOptions { - /** Database name. Default: 'contrail-watch-cache'. Bump this (or delete - * via DevTools) if you need to invalidate all cached watch data. */ - dbName?: string; - /** Object store name within the DB. Default: 'watches'. */ - storeName?: string; - /** IDB schema version. Default: 1. Only bump when changing - * storeName / schema. */ - version?: number; -} - -export function createIndexedDBCache( - options: IndexedDBCacheOptions = {} -): WatchCache { - const dbName = options.dbName ?? "contrail-watch-cache"; - const storeName = options.storeName ?? "watches"; - const version = options.version ?? 1; - - let dbPromise: Promise<IDBDatabase> | null = null; - const openDb = (): Promise<IDBDatabase> => { - if (dbPromise) return dbPromise; - dbPromise = new Promise<IDBDatabase>((resolve, reject) => { - if (typeof indexedDB === "undefined") { - reject(new Error("IndexedDB not available in this environment")); - return; - } - const req = indexedDB.open(dbName, version); - req.onupgradeneeded = () => { - const db = req.result; - if (!db.objectStoreNames.contains(storeName)) { - db.createObjectStore(storeName); - } - }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - req.onblocked = () => - reject(new Error("IDB open blocked (another tab holds an older version)")); - }); - return dbPromise; - }; - - return { - async read(key) { - try { - const db = await openDb(); - return await new Promise<WatchRecord[] | null>((resolve, reject) => { - const tx = db.transaction(storeName, "readonly"); - const store = tx.objectStore(storeName); - const req = store.get(key); - req.onsuccess = () => { - const entry = req.result as StoredEntry | undefined; - resolve(entry?.records ?? null); - }; - req.onerror = () => reject(req.error); - }); - } catch { - return null; - } - }, - async write(key, records) { - try { - const db = await openDb(); - await new Promise<void>((resolve, reject) => { - const tx = db.transaction(storeName, "readwrite"); - const store = tx.objectStore(storeName); - const entry: StoredEntry = { records, updatedAt: Date.now() }; - store.put(entry, key); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - tx.onabort = () => reject(tx.error ?? new Error("tx aborted")); - }); - } catch { - // Intentional: caching is best-effort; swallow. - } - }, - }; -} diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts deleted file mode 100644 index 947e0bd..0000000 --- a/packages/sync/src/index.ts +++ /dev/null @@ -1,593 +0,0 @@ -/** Client-side sync engine over `watchRecords`. - * - * Consumes the event stream emitted by a contrail `<ns>.<collection>.watchRecords` - * endpoint and maintains a keyed, reactive-ish record store. The core is - * framework-agnostic — you get a subscribable store; wrap it in your - * framework's reactive primitives. - * - * Two transports: - * - `sse` (default) — opens an EventSource against `url`. Simplest. - * - `ws` — two-step: HTTP GET to `url&mode=ws` for the snapshot + wsUrl, - * then WebSocket upgrade. On Cloudflare this routes the live stream - * through a Durable Object with hibernation, so idle connections cost - * near-zero at scale. */ - -export interface WatchRecord { - uri: string; - cid?: string | null; - value: Record<string, unknown>; - did: string; - rkey: string; - collection: string; - time_us?: number; - indexed_at?: number; - /** Set when the record originates from a per-space table. */ - space?: string; - /** Present on optimistic entries added via `addOptimistic` — not set by - * records arriving from the stream. Auto-dropped when a real record - * with the same rkey arrives via `record.created`. */ - optimistic?: "pending" | "failed"; - /** Error attached via `markFailed` after a failed mutation. */ - optimisticError?: Error; - /** Additional hydrated relations / references populated server-side. */ - [k: string]: unknown; -} - -export interface WatchStoreOptions { - /** Fully-qualified URL of the `watchRecords` endpoint, including query params. */ - url: string; - /** Transport. Default: 'sse'. Use 'ws' on Cloudflare for DO-terminated - * long-lived subscriptions that hibernate while idle. */ - transport?: "sse" | "ws"; - /** Watch-scoped ticket auth for the connection. Sent as `?ticket=...` - * on the SSE connection or on the `mode=ws` handshake fetch. - * - * - `string` — used as-is on every connect (good for one-shot SSR-minted - * tokens; reconnects after expiry will surface a 401). - * - `() => Promise<string>` — called once per connect attempt; a fresh - * ticket is minted for every (re)connect. - * - omitted — public (no-auth) endpoints. - * - * For atproto-based services, tickets are typically minted server-side - * via `<ns>.realtime.ticket` (or via an app-specific route that runs - * the `mode=ws` handshake in-process and returns the signed ticket). */ - mintTicket?: string | (() => Promise<string>); - /** Custom compare. Default: sort by `time_us` descending (newest first), - * tie-breaking by rkey. */ - compareRecords?: (a: WatchRecord, b: WatchRecord) => number; - /** Reconnect on error with backoff. Default: true, 1s → 30s exponential. */ - reconnect?: boolean; - /** Optional persistent cache. When provided, the store loads cached - * records at `start()` for instant first paint, then reconciles against - * the live snapshot as usual. Writes are debounced and exclude - * optimistic entries. */ - cache?: WatchCache; - /** Key for this query in the cache. Defaults to `url`. */ - cacheKey?: string; - /** Max records retained in the cache per key. Excess records (by sort - * order) are dropped on write. Default: 200. */ - cacheMaxRecords?: number; - /** Optional logger for debug output. */ - logger?: { - log?: (...args: unknown[]) => void; - warn?: (...args: unknown[]) => void; - error?: (...args: unknown[]) => void; - }; -} - -/** Adapter interface for persisting the last-seen records of a watch query. - * Implementations can back this with IndexedDB, localStorage, fs, etc. - * Errors should be caught internally — caching is a best-effort optimization - * and must never break the live stream. */ -export interface WatchCache { - read(key: string): Promise<WatchRecord[] | null>; - write(key: string, records: WatchRecord[]): Promise<void>; -} - -export type WatchStoreStatus = - | "idle" - | "connecting" - | "snapshot" - | "live" - | "reconnecting" - | "closed"; - -export interface WatchStore { - readonly records: ReadonlyArray<WatchRecord>; - readonly status: WatchStoreStatus; - readonly error: Error | null; - /** Subscribe to changes. Called once with the current state on subscribe. */ - subscribe(listener: (state: WatchStoreState) => void): () => void; - /** Open the connection and begin consuming. Safe to call multiple times. */ - start(): void; - /** Close the connection and clear the store. */ - stop(): void; - - /** Insert an optimistic record immediately, before the server confirms. - * The entry is merged into `.records` with `optimistic: 'pending'`. When - * a real record arrives via the stream with the same `rkey`, the - * optimistic entry is dropped automatically. */ - addOptimistic(input: { - rkey: string; - did: string; - collection?: string; - value: Record<string, unknown>; - time_us?: number; - uri?: string; - }): void; - /** Flip an optimistic entry to `optimistic: 'failed'` and attach an error. - * No-op if no optimistic entry matches. */ - markFailed(rkey: string, err: Error): void; - /** Remove an optimistic entry (explicit rollback). */ - removeOptimistic(rkey: string): void; -} - -export interface WatchStoreState { - records: ReadonlyArray<WatchRecord>; - status: WatchStoreStatus; - error: Error | null; -} - -// --------------------------------------------------------------------------- - -const defaultCompare = (a: WatchRecord, b: WatchRecord): number => { - const at = a.time_us ?? 0; - const bt = b.time_us ?? 0; - if (at !== bt) return bt - at; - return a.rkey < b.rkey ? 1 : a.rkey > b.rkey ? -1 : 0; -}; - -export function createWatchStore(options: WatchStoreOptions): WatchStore { - const compare = options.compareRecords ?? defaultCompare; - const reconnect = options.reconnect !== false; - const transport = options.transport ?? "sse"; - const log = options.logger ?? {}; - const cache = options.cache ?? null; - const cacheKey = options.cacheKey ?? options.url; - const cacheMax = options.cacheMaxRecords ?? 200; - - const byKey = new Map<string, WatchRecord>(); - /** Optimistic entries keyed by rkey. Merged into `.records`; dropped when - * a real record with the same rkey arrives via `record.created`. */ - const optimisticByRkey = new Map<string, WatchRecord>(); - let sorted: WatchRecord[] = []; - let status: WatchStoreStatus = "idle"; - let error: Error | null = null; - const listeners = new Set<(state: WatchStoreState) => void>(); - - // Debounced cache writer. Schedules one write after the current burst of - // state changes settles, to avoid writing on every incoming event. - let cacheWriteTimer: ReturnType<typeof setTimeout> | null = null; - const scheduleCacheWrite = () => { - if (!cache) return; - if (cacheWriteTimer) return; - cacheWriteTimer = setTimeout(() => { - cacheWriteTimer = null; - void flushCache(); - }, 200); - }; - const flushCache = async () => { - if (!cache) return; - // Snapshot the current server-confirmed records (exclude optimistic) - // and trim to cacheMax. - const confirmed: WatchRecord[] = []; - for (const r of byKey.values()) confirmed.push(r); - confirmed.sort(compare); - const trimmed = confirmed.slice(0, cacheMax); - try { - await cache.write(cacheKey, trimmed); - } catch (err) { - log.warn?.("cache write failed", err); - } - }; - - // Per-snapshot reconcile: during a snapshot we track which keys were - // included, and on snapshot.end we evict any stale keys the previous - // snapshot had but this one didn't. Keeps data visible across reconnects - // and prevents stale records accumulating forever. - let snapshotSeen: Set<string> | null = null; - - let es: EventSource | null = null; - let ws: WebSocket | null = null; - let started = false; - let stopped = false; - let backoffMs = 1000; - - const stateSnapshot = (): WatchStoreState => ({ records: sorted, status, error }); - - const notify = () => { - const s = stateSnapshot(); - for (const l of listeners) l(s); - scheduleCacheWrite(); - }; - - const resort = () => { - const merged: WatchRecord[] = []; - for (const r of byKey.values()) merged.push(r); - for (const r of optimisticByRkey.values()) merged.push(r); - sorted = merged.sort(compare); - }; - - const setStatus = (next: WatchStoreStatus, nextError: Error | null = null) => { - status = next; - error = nextError; - notify(); - }; - - const key = (r: { did?: string; rkey?: string; uri?: string }): string => { - if (r.uri) return r.uri; - if (r.did && r.rkey) return `${r.did}/${r.rkey}`; - throw new Error("record must have uri or did+rkey"); - }; - - const applySnapshotRecord = (record: WatchRecord) => { - const k = key(record); - byKey.set(k, record); - snapshotSeen?.add(k); - // If this rkey was optimistic, it's now server-confirmed. - optimisticByRkey.delete(record.rkey); - }; - - const applyCreated = (record: WatchRecord) => { - // Drop any optimistic entry with the same rkey — the server-confirmed - // record replaces it. - optimisticByRkey.delete(record.rkey); - byKey.set(key(record), record); - resort(); - notify(); - }; - - const applyDeleted = (info: { uri?: string; did?: string; rkey?: string }) => { - const k = key(info); - if (!byKey.delete(k)) return; - resort(); - notify(); - }; - - const applyHydrationAdded = ( - parentUri: string, - relation: string, - child: WatchRecord - ) => { - const parent = byKey.get(parentUri); - if (!parent) return; - const existing = - ((parent as Record<string, unknown>)[relation] as WatchRecord[] | undefined) ?? []; - if (existing.some((c) => c.rkey === child.rkey)) return; - (parent as Record<string, unknown>)[relation] = [...existing, child]; - resort(); - notify(); - }; - - const applyHydrationRemoved = ( - parentUri: string, - relation: string, - childRkey: string - ) => { - const parent = byKey.get(parentUri); - if (!parent) return; - const existing = (parent as Record<string, unknown>)[relation] as - | WatchRecord[] - | undefined; - if (!existing) return; - const filtered = existing.filter((c) => c.rkey !== childRkey); - if (filtered.length === existing.length) return; - (parent as Record<string, unknown>)[relation] = filtered; - resort(); - notify(); - }; - - // Dispatch a single decoded event envelope to the store. Shared by both - // transports — SSE event handlers and WS message handlers both funnel here. - type IncomingMessage = - | { kind: "snapshot.start"; data: unknown } - | { kind: "snapshot.record"; data: { record: WatchRecord } } - | { kind: "snapshot.end"; data: unknown } - | { kind: "record.created"; data: { record: WatchRecord } } - | { kind: "record.deleted"; data: { uri?: string; did?: string; rkey?: string } } - | { - kind: "hydration.added"; - data: { parentUri: string; relation: string; child: WatchRecord }; - } - | { - kind: "hydration.removed"; - data: { parentUri: string; relation: string; childRkey: string }; - } - | { kind: "member.removed"; data: unknown }; - - const handleMessage = (msg: IncomingMessage) => { - switch (msg.kind) { - case "snapshot.start": - setStatus("snapshot"); - backoffMs = 1000; - // Begin reconcile pass. Stale records from the previous session - // stay visible until snapshot.end replaces them. - snapshotSeen = new Set(); - break; - case "snapshot.record": - applySnapshotRecord(msg.data.record); - break; - case "snapshot.end": { - // Evict anything we had before this snapshot that the server - // didn't re-send. Preserves continuity across reconnects - // while still dropping records that were deleted while we - // were disconnected. - if (snapshotSeen) { - for (const k of Array.from(byKey.keys())) { - if (!snapshotSeen.has(k)) byKey.delete(k); - } - snapshotSeen = null; - } - resort(); - setStatus("live"); - break; - } - case "record.created": - applyCreated(msg.data.record); - break; - case "record.deleted": - applyDeleted(msg.data); - break; - case "hydration.added": - applyHydrationAdded(msg.data.parentUri, msg.data.relation, msg.data.child); - break; - case "hydration.removed": - applyHydrationRemoved(msg.data.parentUri, msg.data.relation, msg.data.childRkey); - break; - case "member.removed": - byKey.clear(); - sorted = []; - setStatus("closed"); - closeConnections(); - break; - } - }; - - const closeConnections = () => { - if (es) { - es.close(); - es = null; - } - if (ws) { - try { - ws.close(); - } catch { - /* ignore */ - } - ws = null; - } - }; - - const resolveTicket = async (): Promise<string | null> => { - const src = options.mintTicket; - if (!src) return null; - return typeof src === "string" ? src : await src(); - }; - - const openSse = async () => { - let url = options.url; - const ticket = await resolveTicket(); - if (ticket) { - const sep = url.includes("?") ? "&" : "?"; - url += `${sep}ticket=${encodeURIComponent(ticket)}`; - } - const source = new EventSource(url); - es = source; - - const on = (kind: IncomingMessage["kind"]) => - source.addEventListener(kind, (e) => { - try { - const data = JSON.parse((e as MessageEvent).data); - handleMessage({ kind, data } as IncomingMessage); - } catch (err) { - log.warn?.(`${kind} parse failed`, err); - } - }); - - on("snapshot.start"); - on("snapshot.record"); - on("snapshot.end"); - on("record.created"); - on("record.deleted"); - on("hydration.added"); - on("hydration.removed"); - on("member.removed"); - - source.addEventListener("error", () => { - if (source.readyState === EventSource.CLOSED) { - if (stopped) return; - setStatus("reconnecting", new Error("stream closed")); - scheduleReconnect(); - } - }); - }; - - const openWs = async () => { - // Step 1: snapshot + handshake. Authenticated by the watch-scoped - // ticket from `mintTicket`, passed as `?ticket=...`. The server - // returns a ticket bound to (did, spaceUri, querySpec); we pass that - // back embedded in the wsUrl on the WS upgrade so the WS itself - // doesn't need any other auth. - let snapshotUrl = options.url; - const sep = snapshotUrl.includes("?") ? "&" : "?"; - snapshotUrl += `${sep}mode=ws`; - const ticket = await resolveTicket(); - if (ticket) { - snapshotUrl += `&ticket=${encodeURIComponent(ticket)}`; - } - const res = await fetch(snapshotUrl, { headers: { accept: "application/json" } }); - if (!res.ok) throw new Error(`snapshot fetch failed (${res.status})`); - const handshake = (await res.json()) as { - transport: "ws"; - snapshot: { records: WatchRecord[]; cursor?: string }; - wsUrl: string; - /** Watch-scoped ticket the server embeds in `wsUrl` for WS auth. - * Opaque to the client; passed back verbatim on upgrade. */ - ticket?: string; - /** Unix ms captured by the server before the snapshot ran. The - * server embeds this in wsUrl as `?sinceTs=...` so the DO can - * replay events from the race window on connect. */ - sinceTs?: number; - }; - - // Apply snapshot in-order. - setStatus("snapshot"); - for (const record of handshake.snapshot.records) applySnapshotRecord(record); - resort(); - backoffMs = 1000; - - // Step 2: open the WS. `wsUrl` is a relative path from the server; - // resolve it against the page origin (not against options.url — both - // are relative and `new URL(relative, relative)` throws, which - // previously manifested as the connection indicator being stuck on - // "connecting" while the engine re-fetched snapshots in a tight - // loop). The server embeds the handshake ticket in it when issued. - const g = globalThis as { location?: { origin?: string } }; - const base = g.location?.origin ?? "http://localhost"; - const wsHref = new URL(handshake.wsUrl, base); - wsHref.protocol = wsHref.protocol === "https:" ? "wss:" : "ws:"; - const socket = new WebSocket(wsHref.toString()); - ws = socket; - - socket.addEventListener("open", () => { - setStatus("live"); - }); - - socket.addEventListener("message", (e) => { - try { - const parsed = JSON.parse((e as MessageEvent).data as string); - if (!parsed || typeof parsed !== "object" || !parsed.kind) return; - handleMessage(parsed as IncomingMessage); - } catch (err) { - log.warn?.("ws message parse failed", err); - } - }); - - socket.addEventListener("close", () => { - if (stopped) return; - setStatus("reconnecting", new Error("ws closed")); - scheduleReconnect(); - }); - - socket.addEventListener("error", (err) => { - log.warn?.("ws error", err); - }); - }; - - const openOnce = async () => { - setStatus("connecting"); - try { - if (transport === "ws") await openWs(); - else await openSse(); - } catch (err) { - setStatus( - "reconnecting", - err instanceof Error ? err : new Error(String(err)) - ); - scheduleReconnect(); - } - }; - - const scheduleReconnect = () => { - if (!reconnect || stopped) { - setStatus("closed"); - return; - } - const delay = Math.min(backoffMs, 30_000); - backoffMs = Math.min(backoffMs * 2, 30_000); - setTimeout(() => { - if (stopped) return; - // Keep existing records visible; `snapshot.end` will reconcile - // away anything that disappeared while we were offline. - void openOnce(); - }, delay); - }; - - return { - get records() { - return sorted; - }, - get status() { - return status; - }, - get error() { - return error; - }, - subscribe(listener) { - listeners.add(listener); - listener(stateSnapshot()); - return () => listeners.delete(listener); - }, - start() { - if (started) return; - started = true; - stopped = false; - void (async () => { - // Cache warm: populate from disk before opening the connection - // so listeners see instant records on first paint. - if (cache) { - try { - const cached = await cache.read(cacheKey); - if (cached && cached.length > 0 && !stopped) { - for (const r of cached) byKey.set(key(r), r); - resort(); - notify(); - } - } catch (err) { - log.warn?.("cache read failed", err); - } - } - if (stopped) return; - void openOnce(); - })(); - }, - stop() { - stopped = true; - closeConnections(); - if (cacheWriteTimer) { - clearTimeout(cacheWriteTimer); - cacheWriteTimer = null; - } - // Best-effort final flush so the cache reflects the last-known - // confirmed state before teardown. - void flushCache(); - byKey.clear(); - optimisticByRkey.clear(); - sorted = []; - setStatus("closed"); - }, - addOptimistic(input) { - const now = Date.now(); - const record: WatchRecord = { - uri: input.uri ?? `at://${input.did}/${input.collection ?? ""}/${input.rkey}`, - did: input.did, - rkey: input.rkey, - collection: input.collection ?? "", - value: input.value, - time_us: input.time_us ?? now * 1000, - indexed_at: now, - cid: null, - optimistic: "pending" - }; - optimisticByRkey.set(input.rkey, record); - resort(); - notify(); - }, - markFailed(rkey, err) { - const existing = optimisticByRkey.get(rkey); - if (!existing) return; - optimisticByRkey.set(rkey, { - ...existing, - optimistic: "failed", - optimisticError: err - }); - resort(); - notify(); - }, - removeOptimistic(rkey) { - if (!optimisticByRkey.delete(rkey)) return; - resort(); - notify(); - } - }; -} diff --git a/packages/sync/tsconfig.build.json b/packages/sync/tsconfig.build.json deleted file mode 100644 index 6092abf..0000000 --- a/packages/sync/tsconfig.build.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/packages/sync/tsconfig.json b/packages/sync/tsconfig.json deleted file mode 100644 index 6092abf..0000000 --- a/packages/sync/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/packages/sync/tsup.config.ts b/packages/sync/tsup.config.ts deleted file mode 100644 index ebf5058..0000000 --- a/packages/sync/tsup.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/index.ts", "src/cache-idb.ts"], - format: ["esm"], - dts: true, - sourcemap: true, - clean: true, - tsconfig: "tsconfig.build.json", -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3966d34..f73345e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,9 +27,6 @@ importers: '@atcute/lex-cli': specifier: ^2.8.1 version: 2.8.1 - '@atmo-dev/contrail-lexicons': - specifier: workspace:* - version: link:../../packages/lexicons '@cloudflare/workers-types': specifier: ^4.20250124.0 version: 4.20260424.1 @@ -40,173 +37,6 @@ importers: specifier: ^4.63.0 version: 4.84.1(@cloudflare/workers-types@4.20260424.1) - apps/contrail-e2e: - dependencies: - '@atmo-dev/contrail': - specifier: workspace:* - version: link:../../packages/contrail - '@atmo-dev/contrail-community': - specifier: workspace:* - version: link:../../packages/contrail-community - pg: - specifier: ^8.20.0 - version: 8.20.0 - devDependencies: - '@atcute/atproto': - specifier: ^3.1.10 - version: 3.1.11 - '@atcute/client': - specifier: ^4.2.1 - version: 4.2.1 - '@atcute/identity-resolver': - specifier: ^1.2.2 - version: 1.2.2(@atcute/identity@1.1.4) - '@atcute/jetstream': - specifier: ^1.1.2 - version: 1.1.2 - '@atcute/lexicons': - specifier: ^1.3.0 - version: 1.3.0 - '@types/pg': - specifier: ^8.20.0 - version: 8.20.0 - typescript: - specifier: ^5.9.3 - version: 5.9.3 - vitest: - specifier: ^4.1.0 - version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) - - apps/group-chat: - dependencies: - '@atcute/jetstream': - specifier: ^1.1.2 - version: 1.1.2 - '@atmo-dev/contrail': - specifier: workspace:* - version: link:../../packages/contrail - '@atmo-dev/contrail-community': - specifier: workspace:* - version: link:../../packages/contrail-community - '@atmo-dev/contrail-lexicons': - specifier: workspace:* - version: link:../../packages/lexicons - '@atmo-dev/contrail-sync': - specifier: workspace:* - version: link:../../packages/sync - '@foxui/core': - specifier: ^0.9.1 - version: 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) - '@foxui/social': - specifier: ^0.8.10 - version: 0.8.10(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) - '@foxui/time': - specifier: ^0.8.5 - version: 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) - valibot: - specifier: ^1.3.1 - version: 1.3.1(typescript@6.0.3) - devDependencies: - '@atcute/atproto': - specifier: ^3.1.10 - version: 3.1.11 - '@atcute/bluesky': - specifier: ^3.3.0 - version: 3.3.3 - '@atcute/client': - specifier: ^4.2.1 - version: 4.2.1 - '@atcute/identity-resolver': - specifier: ^1.2.2 - version: 1.2.2(@atcute/identity@1.1.4) - '@atcute/lex-cli': - specifier: ^2.5.3 - version: 2.8.1 - '@atcute/lexicon-doc': - specifier: ^2.1.2 - version: 2.2.0 - '@atcute/lexicons': - specifier: ^1.2.9 - version: 1.3.0 - '@atcute/oauth-node-client': - specifier: ^1.1.0 - version: 1.1.0 - '@atcute/tid': - specifier: ^1.1.2 - version: 1.1.2 - '@cloudflare/workers-types': - specifier: ^4.20260317.1 - version: 4.20260424.1 - '@eslint/compat': - specifier: ^2.0.3 - version: 2.0.5(eslint@10.2.1(jiti@2.6.1)) - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.2.1(jiti@2.6.1)) - '@sveltejs/adapter-cloudflare': - specifier: ^7.2.8 - version: 7.2.8(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1)) - '@sveltejs/kit': - specifier: ^2.55.0 - version: 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) - '@sveltejs/vite-plugin-svelte': - specifier: ^7.0.0 - version: 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) - '@tailwindcss/forms': - specifier: ^0.5.11 - version: 0.5.11(tailwindcss@4.2.4) - '@tailwindcss/vite': - specifier: ^4.2.2 - version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) - bits-ui: - specifier: ^2.16.4 - version: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) - eslint: - specifier: ^10.1.0 - version: 10.2.1(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@10.2.1(jiti@2.6.1)) - eslint-plugin-svelte: - specifier: ^3.16.0 - version: 3.17.1(eslint@10.2.1(jiti@2.6.1))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) - globals: - specifier: ^17.4.0 - version: 17.5.0 - prettier: - specifier: ^3.8.1 - version: 3.8.3 - prettier-plugin-svelte: - specifier: ^3.5.1 - version: 3.5.1(prettier@3.8.3)(svelte@5.55.5(@typescript-eslint/types@8.59.0)) - prettier-plugin-tailwindcss: - specifier: ^0.7.2 - version: 0.7.3(prettier-plugin-svelte@3.5.1(prettier@3.8.3)(svelte@5.55.5(@typescript-eslint/types@8.59.0)))(prettier@3.8.3) - svelte: - specifier: ^5.55.0 - version: 5.55.5(@typescript-eslint/types@8.59.0) - svelte-check: - specifier: ^4.4.5 - version: 4.4.6(picomatch@4.0.4)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3) - tailwindcss: - specifier: ^4.2.2 - version: 4.2.4 - tsx: - specifier: ^4.21.0 - version: 4.21.0 - typescript: - specifier: ^6.0.2 - version: 6.0.3 - typescript-eslint: - specifier: ^8.57.2 - version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) - vite: - specifier: ^8.0.3 - version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) - wrangler: - specifier: ^4.78.0 - version: 4.84.1(@cloudflare/workers-types@4.20260424.1) - apps/postgres: dependencies: '@atmo-dev/contrail': @@ -234,9 +64,6 @@ importers: '@atmo-dev/contrail': specifier: workspace:* version: link:../../packages/contrail - '@atmo-dev/contrail-lexicons': - specifier: workspace:* - version: link:../../packages/lexicons '@foxui/core': specifier: ^0.9.1 version: 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) @@ -358,15 +185,9 @@ importers: '@atcute/cbor': specifier: ^2.3.2 version: 2.3.2 - '@atcute/cid': - specifier: ^2.4.1 - version: 2.4.1 '@atcute/client': specifier: ^4.2.1 version: 4.2.1 - '@atcute/identity': - specifier: ^1.1.4 - version: 1.1.4 '@atcute/identity-resolver': specifier: ^1.2.2 version: 1.2.2(@atcute/identity@1.1.4) @@ -376,21 +197,6 @@ importers: '@atcute/lexicons': specifier: ^1.2.9 version: 1.3.0 - '@atcute/xrpc-server': - specifier: ^0.1.12 - version: 0.1.12 - '@atmo-dev/contrail-appview': - specifier: workspace:* - version: link:../contrail-appview - '@atmo-dev/contrail-authority': - specifier: workspace:* - version: link:../contrail-authority - '@atmo-dev/contrail-base': - specifier: workspace:* - version: link:../contrail-base - '@atmo-dev/contrail-record-host': - specifier: workspace:* - version: link:../contrail-record-host cac: specifier: ^7.0.0 version: 7.0.0 @@ -426,228 +232,6 @@ importers: specifier: ^4.63.0 version: 4.84.1(@cloudflare/workers-types@4.20260424.1) - packages/contrail-appview: - dependencies: - '@atcute/atproto': - specifier: ^3.1.10 - version: 3.1.11 - '@atcute/cbor': - specifier: ^2.3.2 - version: 2.3.2 - '@atcute/cid': - specifier: ^2.4.1 - version: 2.4.1 - '@atcute/client': - specifier: ^4.2.1 - version: 4.2.1 - '@atcute/identity': - specifier: ^1.1.4 - version: 1.1.4 - '@atcute/identity-resolver': - specifier: ^1.2.2 - version: 1.2.2(@atcute/identity@1.1.4) - '@atcute/jetstream': - specifier: ^1.0.2 - version: 1.1.2 - '@atcute/lexicons': - specifier: ^1.2.9 - version: 1.3.0 - '@atcute/xrpc-server': - specifier: ^0.1.12 - version: 0.1.12 - '@atmo-dev/contrail-authority': - specifier: workspace:* - version: link:../contrail-authority - '@atmo-dev/contrail-base': - specifier: workspace:* - version: link:../contrail-base - '@atmo-dev/contrail-record-host': - specifier: workspace:* - version: link:../contrail-record-host - hono: - specifier: ^4.12.8 - version: 4.12.15 - devDependencies: - '@types/node': - specifier: ^25.5.0 - version: 25.6.0 - tsup: - specifier: ^8.5.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) - typescript: - specifier: ^5.7.3 - version: 5.9.3 - - packages/contrail-authority: - dependencies: - '@atcute/cid': - specifier: ^2.4.1 - version: 2.4.1 - '@atcute/lexicons': - specifier: ^1.2.9 - version: 1.3.0 - '@atmo-dev/contrail-base': - specifier: workspace:* - version: link:../contrail-base - hono: - specifier: ^4.12.8 - version: 4.12.15 - devDependencies: - tsup: - specifier: ^8.5.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) - typescript: - specifier: ^5.7.3 - version: 5.9.3 - - packages/contrail-base: - dependencies: - '@atcute/atproto': - specifier: ^3.1.10 - version: 3.1.11 - '@atcute/cid': - specifier: ^2.4.1 - version: 2.4.1 - '@atcute/client': - specifier: ^4.2.1 - version: 4.2.1 - '@atcute/identity': - specifier: ^1.1.4 - version: 1.1.4 - '@atcute/identity-resolver': - specifier: ^1.2.2 - version: 1.2.2(@atcute/identity@1.1.4) - '@atcute/lexicons': - specifier: ^1.2.9 - version: 1.3.0 - '@atcute/xrpc-server': - specifier: ^0.1.12 - version: 0.1.12 - hono: - specifier: ^4.12.8 - version: 4.12.15 - devDependencies: - '@types/node': - specifier: ^25.5.0 - version: 25.6.0 - '@types/pg': - specifier: ^8.20.0 - version: 8.20.0 - pg: - specifier: ^8.20.0 - version: 8.20.0 - tsup: - specifier: ^8.5.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) - typescript: - specifier: ^5.7.3 - version: 5.9.3 - - packages/contrail-community: - dependencies: - '@atcute/atproto': - specifier: ^3.1.10 - version: 3.1.11 - '@atcute/cbor': - specifier: ^2.3.2 - version: 2.3.2 - '@atcute/identity': - specifier: ^1.1.4 - version: 1.1.4 - '@atcute/identity-resolver': - specifier: ^1.2.2 - version: 1.2.2(@atcute/identity@1.1.4) - '@atcute/lexicons': - specifier: ^1.2.9 - version: 1.3.0 - '@atcute/xrpc-server': - specifier: ^0.1.12 - version: 0.1.12 - '@atmo-dev/contrail': - specifier: workspace:* - version: link:../contrail - '@atmo-dev/contrail-base': - specifier: workspace:* - version: link:../contrail-base - cac: - specifier: ^7.0.0 - version: 7.0.0 - hono: - specifier: ^4.12.8 - version: 4.12.15 - wrangler: - specifier: ^4.0.0 - version: 4.84.1(@cloudflare/workers-types@4.20260424.1) - devDependencies: - '@types/node': - specifier: ^25.5.0 - version: 25.6.0 - '@types/pg': - specifier: ^8.20.0 - version: 8.20.0 - pg: - specifier: ^8.20.0 - version: 8.20.0 - tsup: - specifier: ^8.5.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) - typescript: - specifier: ^5.7.3 - version: 5.9.3 - vitest: - specifier: ^4.1.0 - version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) - - packages/contrail-record-host: - dependencies: - '@atcute/cid': - specifier: ^2.4.1 - version: 2.4.1 - '@atmo-dev/contrail-base': - specifier: workspace:* - version: link:../contrail-base - hono: - specifier: ^4.12.8 - version: 4.12.15 - devDependencies: - tsup: - specifier: ^8.5.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) - typescript: - specifier: ^5.7.3 - version: 5.9.3 - - packages/lexicons: - dependencies: - '@atcute/lex-cli': - specifier: ^2.5.3 - version: 2.8.1 - '@atmo-dev/contrail': - specifier: workspace:* - version: link:../contrail - devDependencies: - '@types/node': - specifier: ^25.5.0 - version: 25.6.0 - tsup: - specifier: ^8.5.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) - typescript: - specifier: ^5.7.3 - version: 5.9.3 - vitest: - specifier: ^4.1.0 - version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) - - packages/sync: - devDependencies: - tsup: - specifier: ^8.5.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) - typescript: - specifier: ^5.7.3 - version: 5.9.3 - packages: '@atcute/atproto@3.1.11': @@ -740,9 +324,6 @@ packages: '@atcute/varint@2.0.0': resolution: {integrity: sha512-CEY/oVK/nVpL4e5y3sdenLETDL6/Xu5xsE/0TupK+f0Yv8jcD60t2gD8SHROWSvUwYLdkjczLCSA7YrtnjCzWw==} - '@atcute/xrpc-server@0.1.12': - resolution: {integrity: sha512-70KIerQlljp5+s6t0u6YNN9klEboQUZa2hhoi/hmXIO1cIKEORettTMctnyjfcCJaSfAuj42dxPu51GTZBlm8w==} - '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -3993,18 +3574,6 @@ snapshots: '@atcute/varint@2.0.0': {} - '@atcute/xrpc-server@0.1.12': - dependencies: - '@atcute/cbor': 2.3.2 - '@atcute/crypto': 2.4.1 - '@atcute/identity': 1.1.4 - '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) - '@atcute/lexicons': 1.3.0 - '@atcute/multibase': 1.2.0 - '@atcute/uint8array': 1.1.1 - '@badrap/valita': 0.4.6 - nanoid: 5.1.9 - '@babel/runtime@7.29.2': {} '@badrap/valita@0.4.6': {} diff --git a/refs/community-spec-mapping.md b/refs/community-spec-mapping.md deleted file mode 100644 index a3ff8e8..0000000 --- a/refs/community-spec-mapping.md +++ /dev/null @@ -1,139 +0,0 @@ -# Communities: mapping to the Arbiter design post - -This is the map between contrail's community module and the Arbiter design -sketched at [zicklag.leaflet.pub/3mjrvb5pul224](https://zicklag.leaflet.pub/3mjrvb5pul224) (zicklag / Roomy, -April 2026). The post is an early design note and will likely evolve — so -will this doc. The goal is to make it obvious, when the standard firms up, -where contrail already lines up and where it needs to change. - -Contrail's community module layers community-owned spaces and tiered -access-level management on top of the [spaces](./spaces-spec-mapping.md) -module. Ownership alone — spaces owned by a community DID — is the signal -that a space is community-managed. All the Arbiter's "group management" -complexity lives here; the spaces module stays close to the rough -permissioned-data spec with no knowledge of access levels or delegation. - ---- - -## Concept-by-concept alignment - -| Arbiter concept | Contrail | Alignment | Notes | -| ------------------------------------------- | ------------------------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------- | -| Community as a DID | `communities.did` | ✅ | 1:1 | -| Mint a fresh did:plc for a community | `community.mint` → P-256 keypairs + genesis op + plc.directory POST | ✅ | Post uses secp256k1; we use P-256 (both spec-valid, we avoid the dep) | -| Adopt an existing account | `community.adopt` (app password) | ➕ | Not in the post; contrail addition — app passwords avoid OAuth's periodic re-auth, useful for machine-operated accounts | -| Creator-held rotation key (recovery) | Returned once by `community.mint` as `recoveryKey` | ✅ | Never stored; caller must save it | -| Groups-are-spaces | Community-owned rows in `spaces` | ✅ | No separate groups table; every group is a space | -| `$admin` reserved space | Auto-created on community creation | ✅ | Keyed by the literal string `$admin`; cannot be deleted | -| `$publishers` reserved space | Auto-created on community creation | ➕ | Not in the post; contrail extension for the "publish public records as the community" capability | -| Delegated membership (space → space) | `community_access_levels.subject_kind = 'space'` | ✅ | Recursive resolution with cycle guard + depth cap | -| Access-level ladder | `member` / `manager` / `admin` / `owner` | ⚠️ | 4 levels vs the post's 8 — post-levels 2–3 (add/remove members) collapse into `manager`; levels 4–5 collapse into `admin` | -| Read-Member-List (pre-member tier) | _none_ | ❌ | Post's level 1; skipped in v1 | -| Add-Members vs Remove-Members split | Bundled into `manager` | ⚠️ | Post treats them separately | -| Configure-Space | `admin` in that space | ✅ | Post's level 5 | -| Create-Spaces in `$admin` | `admin` in `$admin` | ✅ | Post's level 6 | -| Remove-Space | `owner` in target space OR `admin` in `$admin` | ✅ | Post's level 7 | -| Owner | `owner`; only meaningful in `$admin` for owner-management | ✅ | Post's level 8 | -| Push model for membership lists | Reconciler writes `spaces_members` after each change | ➕ | Post doesn't specify a sync direction; push keeps spaces read-path zero-overhead | -| Cross-community / cross-arbiter delegation | _same-contrail only_ | ❌ | v1 constraint. Private-membership federation is deferred until the cross-instance distribution shape firms up | -| Invites as a separate service | `community.invite.*` (create/list/revoke/redeem) | ➕ | Built-in — tokens pre-sign a grant, redeemer gets the encoded access level. SHA-256 at rest, atomic redeem. | -| Writing records under the arbiter's account | `community.space.putRecord` (in-space) + `community.putRecord` (public) | ✅ | In-space: `admin+`. Public: `member+` in `$publishers` — routes through adopted community's PDS | -| Public membership-list flag | _none_ | ❌ | Post allows spaces to expose membership publicly. Ruled out for cross-instance federation (privacy) | -| Space credential (from arbiter) | _none_ | ⚠️ | Covered by the spaces module's service-auth story, not the community module | - -Legend: ✅ aligned · ⚠️ partial / different granularity · ❌ missing · ➕ contrail extension (not in the post) - ---- - -## XRPC surface - -All endpoints emitted under `<config.namespace>.community.*` from templates -in `lexicon-templates/community/`. Distinct from `<ns>.space.*` so the -user-managed spaces surface stays clean. Community-managed spaces live -under `<ns>.community.space.*` so the endpoint name reveals whether -membership is user-controlled or community-controlled. - -### Community lifecycle -- `community.adopt` · `community.mint` · `community.reauth` · `community.delete` -- `community.list` · `community.getHealth` - -### Space (group / role / channel) lifecycle -- `community.space.create` · `community.space.delete` - -### Membership -- `community.space.grant` · `community.space.revoke` · `community.space.setAccessLevel` -- `community.space.listMembers` (`?flatten=true` for the resolved DID list) · `community.space.resync` -- `community.space.whoami` — caller's effective access level in a space - -### Invites -- `community.invite.create` · `community.invite.list` · `community.invite.revoke` · `community.invite.redeem` - -### Publishing -- `community.putRecord` · `community.deleteRecord` (public records via the community's PDS; adopted only) -- `community.space.putRecord` · `community.space.deleteRecord` (in-space records authored by the community DID) - ---- - -## Contrail extensions over the post - -Labelled ➕ in the alignment table, worth calling out explicitly so -future renames against a standard are easy: - -- **`adopt` mode** for communities backed by a regular ATProto account. - App passwords are the default because they don't have the periodic - re-auth ceremony OAuth does, which matters for machine-operated - community accounts. -- **`$publishers` reserved space.** The post doesn't model "publish public - records as the community" — we gate that capability via a second - reserved space so it's orthogonal to `$admin` governance without - widening the access ladder. -- **Push-based reconciliation into `spaces_members`.** The post doesn't - specify sync direction; push keeps the spaces read path O(1) and lets - the spaces module stay oblivious to delegation. - ---- - -## Migration readiness - -Hasn't shipped as a standard yet. When it does, likely churn areas: - -1. **Level renames / additions.** Our 4 are a coalesce of the post's 8. - If a standard lands with finer tiers, add rows to the enum and - migrate existing values — `community_access_levels.access_level` is - a plain text column. -2. **Cross-instance delegation.** Our v1 is local-only. When private - cross-contrail federation is figured out (encrypted membership - records, authenticated pull, something else), the `subject_space_uri` - field already supports remote URIs structurally — only the - resolution path needs to change. -3. **Invites.** When the post's invite service firms up, reuse the - spaces invite primitive or fork it. Adding an invite service as a - member of `$admin` with `manager` access is the model; no schema - change anticipated. -4. **Publishing for minted communities.** Currently returns - `NotSupported`. When contrail-as-PDS or PDS-less minted publishing - is defined, drop the check. - ---- - -## Design decisions worth preserving - -- **Spaces doesn't know the community module exists.** Dependency is - one-way: community writes to spaces tables; the spaces module never - imports from `src/core/community/`. Preserve this — it's what keeps - the spaces surface close to the permissioned-data spec. -- **Ownership is the signal.** Community-owned = owner DID is in - `communities`. No flag columns on `spaces`. Preserve this — adding a - delegation flag re-introduces a schema hook we deliberately dropped. -- **Access levels govern arbiter operations only.** Never leak them - into record-level ACLs or app-level role systems. App roles belong - as records, not as levels. -- **Orthogonal capabilities go in reserved `$`-spaces, not new - ladder rungs.** `$publishers` is the first; `$moderators`, - `$billing`, etc. can follow. This keeps the ladder small and - extensions additive. -- **Push, not pull, for `spaces_members`.** Read-path overhead has to - stay zero; complexity belongs in the writer. -- **Single-instance is a feature, not a gap.** Federation is the - hard problem; defer cleanly with a local-only assertion at grant - time until private-distribution shape is clear. diff --git a/refs/spaces-later.md b/refs/spaces-later.md deleted file mode 100644 index 47a0714..0000000 --- a/refs/spaces-later.md +++ /dev/null @@ -1,154 +0,0 @@ -# Spaces: things to revisit later - -Deferred items from the spaces design review. Not blocking shipping; keep an -eye on these as usage grows or as the permissioned-data spec firms up. See -also [spaces-spec-mapping.md](./spaces-spec-mapping.md). - -Items resolved by the six-phase refactor (credential flow, host/authority -split, enrollment, community as separate package, etc.) have been removed -from this list — see the spec-mapping doc for the post-refactor state. - -## Hydrated members endpoint - -`space.listMembers` today returns raw `{did, addedAt, addedBy}` rows. -Every client ends up wanting profile hydration (handle, displayName, avatar). -Add `space.getMembers` (or extend `listMembers`) with: -- Cursor-based pagination (current endpoint is unbounded) -- Optional `hydrate=true` that joins against the configured profile collection -- Sort options (joined-at, alphabetical by handle) - -## DID-doc publication helper - -The authority's signing key needs to be published in its DID document under -`#atproto_space_authority` (verification method) — that's how external -verifiers find it. Today the deployer does this manually: -- For `did:web:contrail.example.com`: edit `.well-known/did.json`. -- For `did:plc`: a PLC operation signed with the rotation key. - -A `contrail authority publish-key` CLI subcommand could: -- For `did:web`, emit the verification-method JSON to stdout for the deployer to drop into their DID doc. -- For `did:plc`, build and submit the PLC operation. - -Without this, external verifiers can't validate the authority's credentials. -The in-process default works either way (host has the key directly). - -## Auto-wiring discovery resolvers - -`createPdsBindingResolver` and `createDidDocBindingResolver` are exported but -not wired into the default verifier. Today the in-process verifier uses only -`Local` (configured authority) + `Enrollment` (locally consented spaces). - -For deployments that want to accept credentials from any authority that's -properly bound on a user's PDS or DID doc, the deployer composes the -resolvers manually (see [deployment-shapes](../docs/10-deployment-shapes.md)). - -A higher-level config knob — `spaces.recordHost.acceptExternalAuthorities: true` -or similar — could auto-wire the full discovery chain. Decide whether the -fast-path (Local+Enrollment only) or the universal-path (full chain) is the -right default once we have real cross-host deployments. - -## More tests - -Phase 3-5 added good coverage for credentials, binding, enrollment. Gaps that -predate the refactor and still apply: - -- Non-owner calling `createSpace` (should succeed — anyone can create their own). -- App policy enforcement in both `allow` and `deny` modes (clientId checks). -- `deleteRecord` by owner on another author's record (should fail). -- Re-querying a soft-deleted space returns NotFound. -- `leaveSpace` by owner (should error). -- `whoami` for owner, member, non-member, with and without community integration. - -## Config-change behavior - -What happens today if a deployment: - -- Adds a new collection after spaces already contain data? The per-collection - table (`spaces_records_<short>`) won't exist until schema init re-runs. - `listCollections` swallows the missing-table error, but `putRecord` / - `listRecords` will throw. Document and/or auto-create on demand. -- Toggles `allowInSpaces: false` on an existing collection? Table stays but - routes stop dispatching. Orphaned data. -- Renames a collection's `collection` NSID? `shortNameForNsid` may change, - so the derived table name changes — existing records become unreachable. - -Need a config-drift audit (or migration) story. - -## Verify `clientId` actually flows through - -`checkAccess` uses `ServiceAuth.clientId` for app policy checks. Confirm: - -- JWT verifier actually extracts `client_id` from real atproto service tokens - (not just our test fixture). -- App policy with a populated `apps[]` blocks/allows correctly in practice. -- Empty `apps[]` under `mode: "deny"` blocks everyone (is that what we want?). - -If `clientId` is `undefined` in the wild, app policy is decorative. - -App policy is also currently checked at credential-issuance time but not -enforced again on the record host. For very long-lived credentials (>2h), an -app removed from the allowlist could continue acting until expiry. The TTL is -the spec's revocation bound; live with it. - -## Join requests (spec-adjacent, not in spec) - -The rough spec punts invite/onboarding mechanics to apps. A natural fit given -our invite system: a fourth kind `request` where `redeem` creates a pending -row for the owner to approve. Likely wants: - -- `space.requestJoin` → creates pending row -- `space.listJoinRequests` (owner) → pending rows -- `space.approveJoinRequest` / `space.denyJoinRequest` - -Should this live under `<ns>.space.*` or `<ns>.spaceExt.*`? - -## Authority migration - -A space's authority can change in principle — the `recordHost.enroll` row maps -`spaceUri → authorityDid`, re-enroll with a new authority and credentials -from the new authority will start verifying. But: - -- Existing credentials from the old authority don't auto-revoke; they expire - within their TTL. -- The PDS-record / DID-doc discovery sources (if used) need updating in lockstep. -- No helper API for this — the deployer or owner does each step manually. - -A `<ns>.recordHost.transferAuthority` endpoint could automate the wire-level -parts (re-enroll, optionally short-circuit credential cache). - -## Multi-authority spaces - -In principle the architecture allows several authorities to all sign for one -space (replication / failover scenarios). The host's enrollment is 1:1 today -(one authority per space) but could become 1:N with a small schema change. -Spec is silent. Defer until a real use case. - -## Ownership transfer - -Dropped for now. The space URI is `ats://<ownerDid>/<type>/<key>` — owner DID -is baked into the URI, and every record/member/invite row keys off that URI -string. Transferring would mean either rewriting every referencing row in a -transaction (and breaking external refs to the old URI) or decoupling storage -from URI with an internal stable space id (bigger refactor). Revisit once the -spec pins down whether ownership transfer exists and what the URI authority -is supposed to be post-transfer. - -## Real-time over credentials - -Realtime tickets are signed by `realtime.ticketSecret`, not by the space -authority. If the host/authority split goes far enough that they're operated -by different parties, the realtime ticket model may need rethinking — does -the host mint tickets it then validates itself, or does the authority issue -realtime grants the host honors? Today both run in one process so it doesn't -matter. - ---- - -## Resolved by the six-phase refactor (kept for history) - -- ~~Space-credential flow~~ — done in phase 3. -- ~~Binding resolution (PDS records, DID-doc service entries)~~ — done in phase 4. -- ~~Independent host/authority deployments~~ — done in phase 5. -- ~~Real-time SSE / subscriptions~~ — landed via the realtime module. -- ~~Namespace split for contrail-specific extras~~ — `<ns>.spaceExt.*` shipped. -- ~~Community as separate package~~ — done in phase 6 (`@atmo-dev/contrail-community`). diff --git a/refs/spaces-spec-mapping.md b/refs/spaces-spec-mapping.md deleted file mode 100644 index 70d697b..0000000 --- a/refs/spaces-spec-mapping.md +++ /dev/null @@ -1,133 +0,0 @@ -# Spaces: mapping to the atproto permissioned-data rough spec - -This is the map between contrail's spaces implementation and the rough spec -sketched at [dholms.leaflet.pub/3mhj6bcqats2o](https://dholms.leaflet.pub/3mhj6bcqats2o) (Daniel Holmgren, -March 2026). The spec is explicitly low-confidence and subject to change — so -is this doc. The goal is to make it obvious, when the real spec lands, where -contrail already lines up and where it needs to change. - -Contrail is a backend-in-a-bottle / simple appview, not a PDS. For permissioned -data it currently stores everything in its own database; the long-term plan -is to switch permissioned reads to come from users' PDSes once the -protocol-level flow is shipped (same story we already have for public records -via jetstream). - -The spaces implementation went through a six-phase refactor (phases 1–6, -documented in conversation history) that aligned the architecture with the -spec and split community out into its own package. This doc reflects the -post-refactor state. - ---- - -## Concept-by-concept alignment - -| Spec concept | Contrail | Alignment | Notes | -| ------------------------------ | --------------------------------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------- | -| Space owner (DID) | `spaces.owner_did` | ✅ | 1:1 | -| Space type (NSID) | `spaces.type` | ✅ | 1:1 | -| Space key / skey | `spaces.key` | ✅ | TID-generated when caller omits it | -| Record addressing 6-tuple | `(owner, type, key, author-did, collection, rkey)` | ✅ | Storage is keyed by `(space_uri, did, rkey)`; `space_uri` encodes the first 3 | -| `ats://` URI scheme | `ats://<owner>/<type>/<key>` | ✅ | Centralized in `src/core/spaces/uri.ts` | -| Single ACL = member list | `spaces_members (did)` | ✅ | Membership is binary. Owner is implicit member. No read/write tiering. | -| Member list `(did, read\|write)` tuples | binary membership only | ⚠️ | Spec says read/write tiered. Pragmatic divergence; flip if/when spec firms | -| Member list as PDS record | server-side state on authority | ⚠️ | Spec says published on owner's PDS, synced. Future migration; the authority caches today | -| Space credential (2–4h token) | ES256 JWTs via `<ns>.space.getCredential` / `refreshCredential` | ✅ | Phase 3. Default 2h TTL. `iss` = authority DID; signed with the authority's published key | -| Credential signed by owner key | signed by **issuer** DID; binding from PDS record / DID-doc / owner-self | ⚠️ ext | Spec says owner-key. We extend so user-owned DIDs can authorize a separate issuer without DID-doc surgery | -| App allow/deny | `appPolicy {mode, apps[]}` | ✅ | Matches default-allow / default-deny. Checked at credential issuance only | -| Discovery via DID doc | `#atproto_space_authority` service entry resolver | ✅ | Phase 4. Plus PDS-record fallback (extension) and owner-self fallback | -| Permissioned repo per user | single DB (`spaces_records_<short>`) | ⚠️ | Structurally compatible — keyed per `(space, author)`. Federation is future | -| ECMH commit / sync log | _none_ | ❌ | Out of scope until federated sync exists | -| Pull-based sync, write notifs | _none_ | ❌ | Same | -| Authority model for record URI | sidestepped (records keyed, not URI-addressed) | ✅ | Spec is undecided; we don't commit either way | -| Managing app routing | _none (join-requests etc. not modeled yet)_ | ⚠️ | See [spaces-later.md](./spaces-later.md) | -| Host/AppView split | `spaces.authority` + `spaces.recordHost` independently runnable | ➕ | Phase 5. Spec implies but doesn't fully model. See [deployment-shapes](../docs/10-deployment-shapes.md) | -| Enrollment as host consent | `<ns>.recordHost.enroll` + `record_host_enrollments` table | ➕ | Phase 5. Spec doesn't address consent — we add explicit binding registration | - -Legend: ✅ aligned · ⚠️ pragmatic divergence · ❌ unimplemented (deliberate) · ➕ extension over the spec - ---- - -## Endpoints - -All endpoints are emitted under `<config.namespace>.*` from templates in -`packages/lexicons/lexicon-templates/`. - -### Authority (`<ns>.space.*` — spec-aligned) -- `createSpace` `getSpace` `listSpaces` `deleteSpace` -- `listMembers` `addMember` `removeMember` `leaveSpace` -- `getCredential` `refreshCredential` - -### Record host -- `<ns>.space.putRecord` `deleteRecord` `getRecord` `listRecords` -- `<ns>.space.uploadBlob` `getBlob` `listBlobs` (optional) -- `<ns>.recordHost.enroll` - -### Contrail extras (`<ns>.spaceExt.*`) -Clearly-off-spec features that don't map cleanly to the rough spec. -- `whoami` — caller's relationship to a space (owner / member / extension fields) - -### Invites (`<ns>.invite.*`) -- `create` `redeem` `revoke` `list` - -Invites have three kinds: `join`, `read`, `read-join`. Spec defers -invite/onboarding mechanics to apps; we ship a working primitive because -every consumer needs one. - -### Collection integration -Per-collection `listRecords` / `getRecord` accept `?spaceUri=` (space-scoped) -and optional `?inviteToken=`. Without `spaceUri`, authenticated callers get -public + own-member-spaces union (see `src/core/router/collection.ts`). - ---- - -## What changed in the six-phase refactor - -| Phase | Brought us | Notes | -|---|---|---| -| 1 | `SpaceAuthority` + `RecordHost` interface boundary | Pure refactor; `StorageAdapter` is their union | -| 2 | Spaces no longer imports community | Whoami extension hook + `CommunityInviteHandler` interface | -| 3 | Credential issuance + verification | ES256 JWTs, `X-Space-Credential` header, in-process verifier | -| 4 | Binding resolution | PDS-record + DID-doc resolvers; `iss != owner` allowed via the binding | -| 5 | Independent deployment + enrollment | Authority and host runnable as separate processes; `recordHost.enroll` consent | -| 6 | Community as separate package | `@atmo-dev/contrail-community` with integration interface | - -After phase 6 the architecture maps to the spec roughly as: - -``` - spec concept contrail mapping - ──────────── ──────────────── - "space host" ─→ space authority (signs creds, holds members) - "permissioned repo" ─→ record host (stores records, enrolls spaces) - "external space hosts" ─→ binding resolver chain (multi-authority support) - "managing app routing" ─→ not yet (deferred — spaces-later.md) -``` - ---- - -## Migration readiness - -What still needs to change when the real spec lands: - -1. **Member list moves to PDS records.** The spec says it's a record on the owner's PDS, synced. Our authority holds it server-side. Future: a watcher consumes member-list records via Jetstream and reconciles into `spaces_members`. Auth-side `addMember` becomes a PDS write rather than an internal API. - -2. **Records federate from user PDSes.** Today the record host *is* the source of truth. When permissioned-repos ship, records federate; the host becomes an aggregator. Storage schema (`spaces_records_<short>`, keyed per `(space, author)`) already supports this — the change is in the write path, not the read path. - -3. **ECMH commits & sync log.** Greenfield. Required for federation. - -4. **Endpoint naming.** Spec doesn't pin XRPC names. When it does, rename lexicon template files + routes. No storage churn. - -5. **Possibly: `(did, read|write)` member tuples.** If the spec stays at tiered membership and doesn't move to binary, add an `access` column on `spaces_members` and branch the ACL check in `acl.ts`. One-day change. - -6. **Possibly: credential `iss = owner DID`.** If the spec forbids the issuer-DID indirection we use for user-owned DIDs, fall back to "owner adds a host-controlled verification method to their DID doc" (HappyView's hidden assumption). Operationally heavier; we hold the looser reading until forced to tighten. - ---- - -## Design decisions worth preserving - -- **Keep the member list as the single ACL.** Don't add roles or per-collection policies just because it's easy — the spec is emphatic that the member list is _the_ ACL. -- **Membership is binary, not tiered.** Previously had `perms: "read" | "write"` per member row; collapsed to plain membership because the rough spec is moving toward "member = access, apps filter writes." Delete keeps the owner / own-record rule, but that's about *which records you can affect*, not a permission tier on the member row. -- **Don't over-engineer the space row with pre-emptive extension columns.** Previously had `member_list_ref` as a hook for externally-managed membership; dropped because the community-module case is handled via ownership (community-owned spaces are managed by the community module, no flag column needed). If a future need for external membership sources shows up, add the column then. -- **Keep `spaceExt.whoami`, `space.leaveSpace`, and the invite endpoints clearly labeled as contrail extras in docs.** If the spec ends up naming some of them, renaming is cheap; relying on them from the base spec isn't. -- **Don't mint a canonical record URI.** The spec is undecided on the authority (user DID vs space owner DID); storing records by tuple avoids picking. -- **Enrollment is the host's source of truth.** Even when PDS records and DID-doc service entries declare authority bindings, the host's local enrollment is what actually gates record acceptance. Keeps the host's consent explicit and prevents abuse of the open-ended discovery layer. -- **Keep the issuer-DID indirection as an extension, not a hard architectural choice.** The credential verifier supports `iss == owner` (literal-spec) and `iss != owner` (with binding). If the spec forbids the latter, we degrade gracefully. diff --git a/todo/communities-as-labelers.md b/todo/communities-as-labelers.md deleted file mode 100644 index fa57ca4..0000000 --- a/todo/communities-as-labelers.md +++ /dev/null @@ -1,187 +0,0 @@ -# Communities as labelers - -## Context - -Layer 1 of the labels module ships in v0.4: contrail subscribes to external -labelers, indexes their labels, hydrates `record.labels` onto responses -(see `docs/08-labels.md`). The next interesting question is whether -contrail-managed *community* DIDs can themselves act as labelers. - -This is a feature that doesn't really exist in atproto today: most labelers -are individual operator accounts. Group-controlled, role-gated labeling -where the labeler DID is shared by a moderation team — and from the outside -looks like any other atproto labeler — falls out almost for free from -combining the existing community module with the labels machinery. - -## What a labeler needs - -Three independent contracts in atproto-native fashion: - -1. **Advertise** — `#atproto_labeler` service entry in the DID doc so - clients can find the WS endpoint. Optionally, an - `app.bsky.labeler.service` record with custom-value metadata - (display name, blur policy, severity). -2. **Author** — a way to create signed labels with monotonic per-`src` - `seq` numbers, retractable via `neg`. -3. **Serve** — `com.atproto.label.queryLabels` (paginated reads) and - `com.atproto.label.subscribeLabels` (CBOR-framed WS firehose). - -We have most of (2) already — the `labels` table is the storage. We'd -just be writing to it from a new XRPC instead of from the WS consumer. -(3) is layer 1 in reverse: same wire format, our side. (1) is the only -genuinely new thing, and even that mostly reuses existing community-signing -infrastructure. - -## Mint vs adopt - -- **Mint**: contrail holds a rotation key, so updating the DID doc to - add `#atproto_labeler` is a routine signed PLC operation — same path - the mint flow already uses. ✅ Supported in v1. -- **Adopt**: we hold an app password, not the rotation keys. We can - write `app.bsky.labeler.service` to the existing PDS, but cannot - update the DID doc to add the service entry. The owner has to do - that manually (one-shot PLC operation, documented). 🟡 Documented - manual step. - -## The PDS gotcha (resolved) - -Earlier sketch assumed we'd publish `app.bsky.labeler.service` to the -community's repo. But minted communities have no PDS — they're DIDs in -PLC with no repo. Spent a while exploring "contrail hosts a tiny repo -for minted communities" (option B) and concluded the simpler answer: -**most labelers don't need a service record**. - -In atproto, you don't browse a directory of labelers — you find them -out-of-band and pass the DID to your client. The DID doc service entry -is what makes the labeler reachable; the service record is just metadata -for custom label values. - -For standard atproto label values (`!hide`, `!warn`, -`!no-unauthenticated`, `porn`, `nudity`, `graphic-media`, `sexual`, -etc.) clients have hardcoded behavior. The service record only matters -for **custom** label values, where without it third-party clients -display the raw string with default styling. - -So we tier the implementation: - -| Tier | Mechanism | Custom values | Labeler-name in clients | -|---|---|---|---| -| 1 | DID doc service entry only | raw strings | "anonymous" | -| 2 | Tier 1 + hosted service-record endpoint | full metadata | yes | -| 3 | Tier 2 + general mini-PDS | n/a | (out of scope) | - -**Tier 1 is shippable as v1.** Tier 2 is small and additive — one read -endpoint, one write XRPC, one new table — when someone actually wants -custom values. Tier 3 (general mini-PDS for minted communities) is a -separate architecture question that should ride or die on its own -merits, not on labels: it brings in the firehose-participation problem, -MST + commits, blob storage, and operator-as-durability-promise. Out -of scope. - -## Sketch — Tier 1 surface - -**Setup (admin / owner):** -``` -community.becomeLabeler { communityDid } - → updates DID doc (PLC op) to add service[id="#atproto_labeler", - serviceEndpoint=<our service URL>] -community.unbecomeLabeler { communityDid } - → reverse -``` - -**Authoring (configurable level — default `moderator`):** -``` -community.label.create { - communityDid, - subject: { uri: "at://...", cid?: "..." } | { did: "did:plc:..." }, - val, - exp? // ISO-8601 -} -community.label.negate { communityDid, subject, val } -community.label.list { communityDid, cursor?, limit? } // moderation UI -``` - -A `community.label.create` call: - -1. Verifies caller's access level via the existing community ACL. -2. Optionally checks `val` against a per-community allowlist (Tier 2). -3. Builds the label object, signs with the community's signing key - (existing `CredentialCipher` + signer). -4. Inserts into `labels` with `src = communityDid` and a freshly - minted per-src `seq`. -5. Publishes to a `labels:<communityDid>` pubsub topic for live - subscribers. - -**Public, proxy-routed:** -``` -com.atproto.label.queryLabels ?uriPatterns=...&sources=did:plc:... -com.atproto.label.subscribeLabels WS, ?cursor=N -``` - -Both read `Atproto-Proxy: <communityDid>#atproto_labeler` to know which -community-as-labeler the caller is asking about, then filter -`labels WHERE src = ?` and serve. Live mode subscribes to -`labels:<communityDid>` and emits CBOR frames — structurally the -inverse of layer 1's WS consumer, with the same frame protocol. - -## Storage additions - -```sql -ALTER TABLE labels ADD COLUMN seq INTEGER; --- nullable: NULL for ingested-from-remote rows, allocated for our own --- outbound labels. seq IS NOT NULL is the marker for "we wrote this." - -CREATE TABLE labeler_outbound ( - src TEXT PRIMARY KEY, -- community DID acting as labeler - next_seq INTEGER NOT NULL DEFAULT 1 -); --- atomic increment per label publish; serves as the seq source for --- subscribeLabels. -``` - -Skip ingesting our own outbound stream — one extra check in the WS -consumer (`if known-community-DID, skip subscription`). - -## Suggested order - -Each step is independently shippable: - -1. **`labels.seq` column + `labeler_outbound` counter.** Schema-only; - no behavioural change. -2. **`community.label.create / .negate / .list`.** Operator-internal - use only — labels go into the table, hydration starts surfacing them - on caller responses, but no external discovery yet. Useful by itself - if the operator's own appview is the only consumer. -3. **`community.becomeLabeler`.** DID-doc update. Now external clients - can discover. -4. **`com.atproto.label.queryLabels` (proxy-routed).** External read - API. Most clients use this for one-shot lookups. -5. **`com.atproto.label.subscribeLabels` (WS).** External firehose. - Reuses the realtime pubsub. -6. **Realtime topic + live emission.** Wires `labels:<communityDid>` - events into `subscribeLabels` for live updates. - -Roughly 1–2 days of work per step. - -## Open questions - -1. **Which access level can label?** Add a config field: - `community.labelers: ["moderator", "admin", "owner"]` ranked. - Default to `manager` upward. -2. **Per-community label-value allowlist?** Bluesky enforces "labelers - can only emit values they declared in their service record." Without - a service record (Tier 1), there's nothing to enforce against. When - we layer Tier 2, plumb the allowlist check through. -3. **Permission set hook.** Auto-generated `<namespace>.permissionSet` - should pick up `community.label.create` etc. so OAuth consent - renders correctly. Should fall out of the existing permission-set - generator since it already enumerates all XRPCs. - -## What this gets you that's actually new - -A moderation team gets actual access-level structure (junior moderators -can label `!warn` only, senior can label anything, owners can rotate -keys), and from the outside looks like a single labeler DID — Bluesky's -app, other appviews, third-party clients all consume it normally. That's -a reasonably interesting primitive that falls out almost for free from -layers 1 + community. diff --git a/todo/cross-space-watch-records.md b/todo/cross-space-watch-records.md deleted file mode 100644 index 3f10c65..0000000 --- a/todo/cross-space-watch-records.md +++ /dev/null @@ -1,120 +0,0 @@ -# Cross-space watchRecords (actor-scoped live queries) - -## Context - -Today `<collection>.watchRecords` is per-space only — the endpoint rejects -requests without `spaceUri`: - -```ts -// src/core/router/collection.ts:563-568 -if (!spaceUri) { - return c.json( - { error: "InvalidRequest", message: "spaceUri required (cross-space watch is deferred)" }, - 400 - ); -} -``` - -This means the app's `createWatchQuery` helper only works for queries -scoped to one permissioned space. `listRecords` has no such restriction — -it already supports `?actor=<did>` for cross-space listings. - -## The concrete gap in the group-chat example - -The channel list spans multiple spaces: each channel lives in its own -space, authored by the community DID. Current flow: - -- **SSR**: `+layout.server.ts` calls `<ns>.channel.listRecords` with - `actor=<communityDid>`. -- **Live updates**: `connectCommunityRealtime` in `realtime.svelte.ts` - opens an EventSource on `<ns>.realtime.subscribe?ticket=...` keyed to - `community:<did>`, filters for `tools.atmo.chat.channel` events, and - calls `invalidateAll()` to re-run the loader. - -That works, but it means: -- Two realtime mechanisms in the app (watchRecords for messages, raw - `realtime.subscribe` + `invalidateAll` for channels). -- Channel list is re-fetched from the server on every update rather than - being incrementally maintained client-side. - -## The fix - -Allow `watchRecords` to be actor-scoped, mirroring `listRecords`: - -- Remove the "spaceUri required" gate when `actor` is supplied. -- Snapshot: reuse the existing `runPipeline` path (already handles - actor-filtered + permissioned-space union for the caller). -- Live topic: `actor:<did>` (contrail already exposes `actorTopic`) - or `community:<did>` (via `communityTopic` when the actor is a - registered community DID — the ticket endpoint already resolves this). -- Ticket minting: the `<ns>.realtime.ticket` endpoint already accepts - `{ topic: "community:<did>" }` and returns a scoped ticket. - -Once done, the app reads: - -```ts -const channelsQuery = $derived( - createWatchQuery({ - endpoint: 'tools.atmo.chat.channel', - params: { actor: data.communityDid, limit: 100 } - }) -); -``` - -And `connectCommunityRealtime` + the `invalidateAll` dance goes away -(channel list is maintained incrementally; messages already use their -own per-space watch query). - -## Open question to resolve before implementing - -**ACL on the live stream.** `listRecords` filters per-space membership -on the query (via `runPipeline`'s `memberDid` path). For `watchRecords` -in this mode, events come from the pubsub via the `community:<did>` (or -`actor:<did>`) topic. **It needs to be verified** that the stream -actually filters events against the caller's per-space memberships — -otherwise a non-member could see `record.created` for a private channel -they're not in. - -Audit targets: -- `src/core/realtime/publishing-adapter.ts` — what topics does it publish - a space write to? If it publishes to `community:<did>` unconditionally, - that event is delivered to every community-topic subscriber regardless - of their per-space access. -- `src/core/realtime/router.ts` / `resolveTopicForCaller` — does the - ticket issuer verify the caller's membership at ticket-mint time, or - is `community:<did>` treated as a public-ish topic available to anyone - who can name the community? -- `src/core/router/collection.ts:653-697` — the watchRecords WS path - filters by `querySpec`; the SSE path has its own filtering. Either - way, filtering happens server-side; make sure it evaluates - per-space membership, not just collection/actor match. - -If the existing filter story is insufficient, a small gate needs to be -added: each event that leaves the stream is checked against the -caller's membership of the event's `space`, and dropped if the caller -has no access. - -## Scope estimate - -- If ACL is already correct: ~1 hour. Small change to remove the - spaceUri gate and wire actor → topic resolution. -- If ACL filter needs adding: ~half-day. Introduce a per-event - membership check in the stream's filter path and cache memberships - on the connection to keep it cheap. - -## Out of scope for this work - -- Combining multiple `actor` values (single-actor queries are enough - for the community-channels use case). -- Hydration relations across the actor-scoped snapshot — same machinery - as today; just confirm it works without `spaceUri` context. - -## Cleanup after this ships - -- Delete `connectCommunityRealtime` from the example's - `lib/rooms/realtime.svelte.ts` (and its ticket fetching helper). -- `+layout.server.ts` stops fetching channels and pass them via page - data; the layout uses `createWatchQuery` directly. -- Consider whether `channelMessages` (the per-space message store) is - still needed — it's there for the prior SSR-message pattern; messages - are now read directly from `messagesQuery.records`. Probably delete. diff --git a/todo/realtime-do-dev-wiring.md b/todo/realtime-do-dev-wiring.md deleted file mode 100644 index af22e81..0000000 --- a/todo/realtime-do-dev-wiring.md +++ /dev/null @@ -1,104 +0,0 @@ -# Fix DO wiring in Vite dev (currently papered over with a `dev` flag) - -## Context - -In `examples/sveltekit-group-chat/src/lib/contrail/index.ts` the realtime -PubSub is selected with: - -```ts -const pubsub = dev ? new InMemoryPubSub() : new DurableObjectPubSub(env.REALTIME); -``` - -That `dev` check is standing in for "the `RealtimePubSubDO` class isn't -actually registered with miniflare right now." It happens to line up with -Vite dev today, but it's not really a dev-vs-prod concern — it's a -build-wiring concern. - -### Why the DO isn't available in Vite dev - -- `wrangler.jsonc` declares the DO binding `REALTIME` with - `class_name: "RealtimePubSubDO"` and `main: ".svelte-kit/cloudflare/_worker.js"`. -- `@sveltejs/adapter-cloudflare@7.x` sets up miniflare via `platformProxy` - automatically in dev, so `platform.env.REALTIME` is bound. -- But the class lives inside the built worker. It's re-exported into - `_worker.js` by `scripts/append-scheduled.ts` *after* `pnpm build`. -- Vite dev doesn't run that post-build step, so the DO binding points at - nothing and `stub.fetch(...)` crashes with `Internal Error`. - -## Three fixes, smallest → largest - -### 1. App-level probe (smallest) - -In the example only. Add a helper that tries a cheap `stub.fetch(health)` -at bundle-build time and falls back to `InMemoryPubSub` if it throws. - -```ts -async function pickPubSub(ns: DurableObjectNamespace | undefined): Promise<PubSub> { - if (!ns) return new InMemoryPubSub(); - try { - const stub = ns.get(ns.idFromName('__probe__')); - const res = await stub.fetch('http://probe/__health', { signal: AbortSignal.timeout(250) }); - if (res.ok) return new DurableObjectPubSub(ns); - } catch {} - return new InMemoryPubSub(); -} -``` - -- Pro: no library change, no build change. -- Con: async bundle construction (currently sync), every app using contrail - has to reinvent this. - -### 2. Library helper with probe (medium, recommended) - -Ship the probe in `@atmo-dev/contrail` as something like: - -```ts -export async function resolveRealtimePubSub( - ns?: DurableObjectNamespace, - fallback: () => PubSub = () => new InMemoryPubSub(), -): Promise<PubSub> -``` - -Every app using contrail gets the correct behavior for free. Still async -construction, but now that lives at the library boundary where it's -easier to swallow. - -- Pro: reusable primitive, fixes the smell at the right layer. -- Con: contrail now has opinions about "is the DO actually usable"; async - bundle setup ripples to every consumer (our current `getBundle` is sync). - -### 3. Proper dev-prod parity (largest, most correct) - -Actually register the DO class with miniflare in Vite dev so `env.REALTIME` -works identically to prod. - -- Restructure the DO re-export so it lives at a stable source path (no - post-build patching). -- Configure `adapter({ platformProxy: { ... } })` with miniflare options - that point at that stable entry for DO hosting. Likely needs a - dedicated DO worker script in miniflare's `workers` config. -- Adjust `scripts/append-scheduled.ts` so it no longer duplicates the - re-export; it only keeps the `scheduled` handler append. -- Drop the `dev ? InMemory : DO` branch in the example entirely — always - `new DurableObjectPubSub(env.REALTIME)`. - -- Pro: dev === prod, no branching anywhere, also unblocks WS-in-dev - (which today forces `transport: dev ? 'sse' : 'ws'` in - `+page.svelte`). -- Con: touches build config, wrangler config, and adapter-cloudflare - internals. Non-trivial to get right; worth its own session. - -## Related cleanup (do in the same pass) - -- Once the DO works in dev, drop `transport: dev ? 'sse' : 'ws'` at - `src/routes/c/[communityDid]/[channelKey]/+page.svelte` — always `'ws'`. -- Remove the `dev` import from `src/lib/contrail/index.ts` and - `+page.svelte` (only remaining consumer). -- Revisit whether `InMemoryPubSub` should stay exported at all from the - example config; if option 3 lands, it's only needed for non-CF hosts. - -## Recommendation - -Go with **(2)** next. It's the right layer, and the async-construction -cost is modest (one-time probe at first use). Tackle (3) when we do the -broader "unify transport" pass. -- 2.51.2