diff --git a/apps/group-chat/package.json b/apps/group-chat/package.json index 54b950a..a773f5f 100644 --- a/apps/group-chat/package.json +++ b/apps/group-chat/package.json @@ -60,6 +60,7 @@ "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", diff --git a/apps/group-chat/src/lib/contrail/index.ts b/apps/group-chat/src/lib/contrail/index.ts index 85a6aa3..8baf918 100644 --- a/apps/group-chat/src/lib/contrail/index.ts +++ b/apps/group-chat/src/lib/contrail/index.ts @@ -4,12 +4,14 @@ import { 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'; @@ -65,7 +67,9 @@ function build(env: Env): Bundle { } }; - const contrail = new Contrail(config); + 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); diff --git a/packages/contrail-community/package.json b/packages/contrail-community/package.json new file mode 100644 index 0000000..2fae478 --- /dev/null +++ b/packages/contrail-community/package.json @@ -0,0 +1,52 @@ +{ + "name": "@atmo-dev/contrail-community", + "version": "0.1.0", + "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" + } + }, + "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:*", + "hono": "^4.12.8" + }, + "devDependencies": { + "tsup": "^8.5.0", + "typescript": "^5.7.3", + "vitest": "^4.1.0" + }, + "license": "MIT" +} diff --git a/packages/contrail/src/core/community/acl.ts b/packages/contrail-community/src/acl.ts similarity index 100% rename from packages/contrail/src/core/community/acl.ts rename to packages/contrail-community/src/acl.ts diff --git a/packages/contrail/src/core/community/adapter.ts b/packages/contrail-community/src/adapter.ts similarity index 99% rename from packages/contrail/src/core/community/adapter.ts rename to packages/contrail-community/src/adapter.ts index 1587e63..586fa88 100644 --- a/packages/contrail/src/core/community/adapter.ts +++ b/packages/contrail-community/src/adapter.ts @@ -1,4 +1,4 @@ -import type { Database } from "../types"; +import type { Database } from "@atmo-dev/contrail"; import type { AccessLevel, AccessLevelRow, diff --git a/packages/contrail/src/core/community/credentials.ts b/packages/contrail-community/src/credentials.ts similarity index 100% rename from packages/contrail/src/core/community/credentials.ts rename to packages/contrail-community/src/credentials.ts diff --git a/packages/contrail/src/core/community/index.ts b/packages/contrail-community/src/index.ts similarity index 77% rename from packages/contrail/src/core/community/index.ts rename to packages/contrail-community/src/index.ts index fd0cc53..d349351 100644 --- a/packages/contrail/src/core/community/index.ts +++ b/packages/contrail-community/src/index.ts @@ -35,3 +35,9 @@ export { jwkToDidKey, } from "./plc"; export type { KeyPair, GenesisOpInput, UnsignedGenesisOp, SignedGenesisOp } 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"; diff --git a/packages/contrail-community/src/integration.ts b/packages/contrail-community/src/integration.ts new file mode 100644 index 0000000..9dca172 --- /dev/null +++ b/packages/contrail-community/src/integration.ts @@ -0,0 +1,80 @@ +/** 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); + 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/src/core/community/invite-handler.ts b/packages/contrail-community/src/invite-handler.ts similarity index 97% rename from packages/contrail/src/core/community/invite-handler.ts rename to packages/contrail-community/src/invite-handler.ts index 0b41f8a..9f27f1d 100644 --- a/packages/contrail/src/core/community/invite-handler.ts +++ b/packages/contrail-community/src/invite-handler.ts @@ -5,10 +5,10 @@ import type { CommunityInviteHandler, HandlerResponse, -} from "../invite/community-handler"; -import { mintInviteToken } from "../invite/token"; + SpaceAuthority, +} from "@atmo-dev/contrail"; +import { mintInviteToken } from "@atmo-dev/contrail"; import type { CommunityAdapter } from "./adapter"; -import type { SpaceAuthority } from "../spaces/types"; import { resolveEffectiveLevel } from "./acl"; import { reconcile } from "./reconcile"; import type { AccessLevel, CommunityInviteRow } from "./types"; diff --git a/packages/contrail/src/core/community/pds.ts b/packages/contrail-community/src/pds.ts similarity index 100% rename from packages/contrail/src/core/community/pds.ts rename to packages/contrail-community/src/pds.ts diff --git a/packages/contrail/src/core/community/plc.ts b/packages/contrail-community/src/plc.ts similarity index 100% rename from packages/contrail/src/core/community/plc.ts rename to packages/contrail-community/src/plc.ts diff --git a/packages/contrail/src/core/community/reconcile.ts b/packages/contrail-community/src/reconcile.ts similarity index 96% rename from packages/contrail/src/core/community/reconcile.ts rename to packages/contrail-community/src/reconcile.ts index 5fde1d3..1b0bbb0 100644 --- a/packages/contrail/src/core/community/reconcile.ts +++ b/packages/contrail-community/src/reconcile.ts @@ -1,4 +1,4 @@ -import type { SpaceAuthority } from "../spaces/types"; +import type { SpaceAuthority } from "@atmo-dev/contrail"; import type { CommunityAdapter } from "./adapter"; import { flattenEffectiveMembers } from "./acl"; diff --git a/packages/contrail/src/core/community/router.ts b/packages/contrail-community/src/router.ts similarity index 99% rename from packages/contrail/src/core/community/router.ts rename to packages/contrail-community/src/router.ts index 84e86d1..23c3123 100644 --- a/packages/contrail/src/core/community/router.ts +++ b/packages/contrail-community/src/router.ts @@ -1,9 +1,11 @@ import type { Context, Hono, MiddlewareHandler } from "hono"; -import type { ContrailConfig, Database } from "../types"; -import type { ServiceAuth } from "../spaces/auth"; -import type { StorageAdapter as SpacesAdapter } from "../spaces/types"; -import { buildSpaceUri } from "../spaces/uri"; -import { HostedAdapter } from "../spaces/adapter"; +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 } from "./pds"; @@ -43,7 +45,7 @@ export function registerCommunityRoutes( verifier: ServiceJwtVerifier; } | null ): void { - const cfg = config.community; + 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"); diff --git a/packages/contrail/src/core/community/schema.ts b/packages/contrail-community/src/schema.ts similarity index 93% rename from packages/contrail/src/core/community/schema.ts rename to packages/contrail-community/src/schema.ts index b7d382c..cdcd56e 100644 --- a/packages/contrail/src/core/community/schema.ts +++ b/packages/contrail-community/src/schema.ts @@ -1,6 +1,5 @@ -import type { Database } from "../types"; -import { getDialect } from "../dialect"; -import type { SqlDialect } from "../dialect"; +import type { Database, SqlDialect } from "@atmo-dev/contrail"; +import { getDialect } from "@atmo-dev/contrail"; export function buildCommunitySchema(dialect: SqlDialect): string[] { return [ diff --git a/packages/contrail/src/core/community/types.ts b/packages/contrail-community/src/types.ts similarity index 98% rename from packages/contrail/src/core/community/types.ts rename to packages/contrail-community/src/types.ts index 7959e72..7f74698 100644 --- a/packages/contrail/src/core/community/types.ts +++ b/packages/contrail-community/src/types.ts @@ -1,4 +1,4 @@ -import type { Database } from "../types"; +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. diff --git a/packages/contrail/src/core/community/whoami.ts b/packages/contrail-community/src/whoami.ts similarity index 94% rename from packages/contrail/src/core/community/whoami.ts rename to packages/contrail-community/src/whoami.ts index 4a75a34..5f06123 100644 --- a/packages/contrail/src/core/community/whoami.ts +++ b/packages/contrail-community/src/whoami.ts @@ -2,7 +2,7 @@ * for community-owned spaces. Returns null for non-community spaces so the * spaces module's default binary-membership logic runs. */ -import type { WhoamiExtension } from "../spaces/router"; +import type { WhoamiExtension } from "@atmo-dev/contrail"; import type { CommunityAdapter } from "./adapter"; import { resolveEffectiveLevel } from "./acl"; diff --git a/packages/contrail/tests/community-delegation.test.ts b/packages/contrail-community/tests/community-delegation.test.ts similarity index 94% rename from packages/contrail/tests/community-delegation.test.ts rename to packages/contrail-community/tests/community-delegation.test.ts index 1514964..5449e7c 100644 --- a/packages/contrail/tests/community-delegation.test.ts +++ b/packages/contrail-community/tests/community-delegation.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect, beforeAll, beforeEach } 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 { 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"; @@ -79,8 +80,12 @@ function fakeAuth(): MiddlewareHandler { async function makeApp(): Promise { const db = createSqliteDatabase(":memory:"); const resolved = resolveConfig(CONFIG); - await initSchema(db, resolved); - return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); + const community = createCommunityIntegration({ db, config: resolved }); + await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); + return createApp(db, resolved, { + spaces: { authMiddleware: fakeAuth() }, + community, + }); } function call( diff --git a/packages/contrail/tests/community-e2e.test.ts b/packages/contrail-community/tests/community-e2e.test.ts similarity index 93% rename from packages/contrail/tests/community-e2e.test.ts rename to packages/contrail-community/tests/community-e2e.test.ts index db008e6..95d6048 100644 --- a/packages/contrail/tests/community-e2e.test.ts +++ b/packages/contrail-community/tests/community-e2e.test.ts @@ -1,11 +1,12 @@ 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 { 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"; @@ -90,8 +91,12 @@ function fakeAuth(): MiddlewareHandler { async function makeApp(): Promise { const db = createSqliteDatabase(":memory:"); const resolved = resolveConfig(CONFIG); - await initSchema(db, resolved); - return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); + const community = createCommunityIntegration({ db, config: resolved }); + await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); + return createApp(db, resolved, { + spaces: { authMiddleware: fakeAuth() }, + community, + }); } function call( diff --git a/packages/contrail/tests/community-mint.test.ts b/packages/contrail-community/tests/community-mint.test.ts similarity index 90% rename from packages/contrail/tests/community-mint.test.ts rename to packages/contrail-community/tests/community-mint.test.ts index c83491f..02ea73b 100644 --- a/packages/contrail/tests/community-mint.test.ts +++ b/packages/contrail-community/tests/community-mint.test.ts @@ -1,11 +1,12 @@ 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 { 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, @@ -13,7 +14,7 @@ import { generateKeyPair, jwkToDidKey, signGenesisOp, -} from "../src/core/community/plc"; +} from "../src/plc"; const ALICE = "did:plc:alice"; const BOB = "did:plc:bob"; @@ -63,8 +64,12 @@ function fakeAuth(): MiddlewareHandler { async function makeApp(): Promise { const db = createSqliteDatabase(":memory:"); const resolved = resolveConfig(CONFIG); - await initSchema(db, resolved); - return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); + const community = createCommunityIntegration({ db, config: resolved }); + await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); + return createApp(db, resolved, { + spaces: { authMiddleware: fakeAuth() }, + community, + }); } function call( diff --git a/packages/contrail/tests/community-publishing.test.ts b/packages/contrail-community/tests/community-publishing.test.ts similarity index 93% rename from packages/contrail/tests/community-publishing.test.ts rename to packages/contrail-community/tests/community-publishing.test.ts index 10a3d76..a05ee0f 100644 --- a/packages/contrail/tests/community-publishing.test.ts +++ b/packages/contrail-community/tests/community-publishing.test.ts @@ -1,11 +1,12 @@ 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 { 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"; @@ -93,8 +94,12 @@ function fakeAuth(): MiddlewareHandler { async function makeApp(): Promise { const db = createSqliteDatabase(":memory:"); const resolved = resolveConfig(CONFIG); - await initSchema(db, resolved); - return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); + const community = createCommunityIntegration({ db, config: resolved }); + await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); + return createApp(db, resolved, { + spaces: { authMiddleware: fakeAuth() }, + community, + }); } function call( diff --git a/packages/contrail/tests/invite-unified.test.ts b/packages/contrail-community/tests/invite-unified.test.ts similarity index 92% rename from packages/contrail/tests/invite-unified.test.ts rename to packages/contrail-community/tests/invite-unified.test.ts index b56d81d..32bd6c5 100644 --- a/packages/contrail/tests/invite-unified.test.ts +++ b/packages/contrail-community/tests/invite-unified.test.ts @@ -7,11 +7,12 @@ 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 { 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"; @@ -66,8 +67,12 @@ function fakeAuth(): MiddlewareHandler { async function makeApp(): Promise { const db = createSqliteDatabase(":memory:"); const resolved = resolveConfig(CONFIG); - await initSchema(db, resolved); - return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); + 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 { diff --git a/packages/contrail-community/tsconfig.build.json b/packages/contrail-community/tsconfig.build.json new file mode 100644 index 0000000..6092abf --- /dev/null +++ b/packages/contrail-community/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"] + }, + "include": ["src"] +} diff --git a/packages/contrail-community/tsconfig.json b/packages/contrail-community/tsconfig.json new file mode 100644 index 0000000..6092abf --- /dev/null +++ b/packages/contrail-community/tsconfig.json @@ -0,0 +1,7 @@ +{ + "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 new file mode 100644 index 0000000..b033415 --- /dev/null +++ b/packages/contrail-community/tsup.config.ts @@ -0,0 +1,11 @@ +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"], +}); diff --git a/packages/contrail-community/vitest.config.ts b/packages/contrail-community/vitest.config.ts new file mode 100644 index 0000000..56720e0 --- /dev/null +++ b/packages/contrail-community/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +const contrailSrc = path.resolve(__dirname, "../contrail/src"); + +// Alias `@atmo-dev/contrail` and its subpaths to the source so tests don't +// run through the built dist. Mirrors the in-tree-source-resolution pattern +// the contrail package's own tests use (they import via ../src/...). +export default defineConfig({ + resolve: { + alias: { + "@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/package.json b/packages/contrail/package.json index a64484d..846e6e8 100644 --- a/packages/contrail/package.json +++ b/packages/contrail/package.json @@ -78,6 +78,7 @@ "jiti": "^2.4.0" }, "devDependencies": { + "@atmo-dev/contrail-community": "workspace:*", "@cloudflare/workers-types": "^4.20250124.0", "@types/node": "^25.5.0", "@types/pg": "^8.20.0", diff --git a/packages/contrail/src/contrail.ts b/packages/contrail/src/contrail.ts index 7c52aa0..dcefac7 100644 --- a/packages/contrail/src/contrail.ts +++ b/packages/contrail/src/contrail.ts @@ -21,27 +21,43 @@ import { 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"; -export interface ContrailOptions extends ContrailConfig { +/** 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 { 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, ...configInput } = options; - this.config = resolveConfig(configInput); + const { db, spacesDb, communityIntegration, ...configInput } = options; + this.config = resolveConfig(configInput as ContrailConfig); 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) { @@ -72,7 +88,8 @@ export class Contrail { async init(db?: Database, spacesDb?: Database): Promise { const main = this.getDb(db); const spaces = spacesDb ?? this._spacesDb; - await initSchema(main, this.config, { spacesDb: spaces }); + const extraSchemas = this._community ? [this._community.applySchema] : []; + await initSchema(main, this.config, { spacesDb: spaces, extraSchemas }); } /** Query records from a collection. */ @@ -273,6 +290,8 @@ export class Contrail { 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 }, }); } diff --git a/packages/contrail/src/core/community-integration.ts b/packages/contrail/src/core/community-integration.ts new file mode 100644 index 0000000..d91b3a4 --- /dev/null +++ b/packages/contrail/src/core/community-integration.ts @@ -0,0 +1,54 @@ +/** 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"; +import type { WhoamiExtension } from "./spaces/router"; + +/** 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>; +} + +/** 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 `.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; +} diff --git a/packages/contrail/src/core/db/schema.ts b/packages/contrail/src/core/db/schema.ts index 5ef78e8..562a6ee 100644 --- a/packages/contrail/src/core/db/schema.ts +++ b/packages/contrail/src/core/db/schema.ts @@ -11,7 +11,6 @@ import { } from "../types"; import { getSearchableFields } from "../search"; import { buildSpacesBaseSchema } from "../spaces/schema"; -import { buildCommunitySchema } from "../community/schema"; import { buildLabelsSchema } from "../labels/schema"; function getResolved(config: ContrailConfig): ResolvedMaps { @@ -262,9 +261,17 @@ async function runMigrations(db: Database): Promise { } } +/** 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; + 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( @@ -311,11 +318,13 @@ export async function initSchema( await applySpacesSchema(spacesSharesMainDb ? db : spacesDb!, config, dialect); } - if (config.community) { - // Community tables live on the same DB as spaces (they reference space_uri). - const target = spacesSharesMainDb ? db : spacesDb!; - const communityStmts = buildCommunitySchema(dialect); - await target.batch(communityStmts.map((s) => target.prepare(s))); + // 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) { diff --git a/packages/contrail/src/core/realtime/resolve.ts b/packages/contrail/src/core/realtime/resolve.ts index f8a0ab4..c16abe5 100644 --- a/packages/contrail/src/core/realtime/resolve.ts +++ b/packages/contrail/src/core/realtime/resolve.ts @@ -12,8 +12,7 @@ * (not yet implemented). */ import type { StorageAdapter } from "../spaces/types"; -import type { CommunityAdapter } from "../community/adapter"; -import { resolveReachableSpaces } from "../community/acl"; +import type { CommunityProbe } from "../community-integration"; import { spaceTopic, parseCommunityTopic, parseSpaceTopic } from "./types"; export interface TopicResolutionContext { @@ -22,7 +21,7 @@ export interface TopicResolutionContext { * (`collection:`, `actor:`) still resolve. */ spaces: StorageAdapter | null; /** May be null if the community module is not enabled. */ - community: CommunityAdapter | null; + community: CommunityProbe | null; } export interface TopicResolution { @@ -63,7 +62,7 @@ export async function resolveTopicForCaller( } const row = await ctx.community.getCommunity(communityDid); if (!row) return { ok: false, error: "NotFound", reason: "community-not-found" }; - const reachable = await resolveReachableSpaces(ctx.community, callerDid); + 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 }); diff --git a/packages/contrail/src/core/realtime/router.ts b/packages/contrail/src/core/realtime/router.ts index 2726226..d10e45e 100644 --- a/packages/contrail/src/core/realtime/router.ts +++ b/packages/contrail/src/core/realtime/router.ts @@ -4,7 +4,7 @@ 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 { CommunityAdapter } from "../community/adapter"; +import type { CommunityProbe } from "../community-integration"; import { InMemoryPubSub } from "./in-memory"; import { TicketSigner } from "./ticket"; import { sseResponse } from "./sse"; @@ -40,7 +40,7 @@ export function registerRealtimeRoutes( app: Hono, config: ContrailConfig, spaces: StorageAdapter | null, - community: CommunityAdapter | null, + community: CommunityProbe | null, options: RealtimeRoutesOptions ): void { const cfg = config.realtime; diff --git a/packages/contrail/src/core/router/collection.ts b/packages/contrail/src/core/router/collection.ts index e440522..6d43d00 100644 --- a/packages/contrail/src/core/router/collection.ts +++ b/packages/contrail/src/core/router/collection.ts @@ -31,7 +31,7 @@ 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 { CommunityAdapter } from "../community/adapter"; +import type { CommunityProbe } from "../community-integration"; import { getRelationField, getNestedValue } from "../types"; /** Scope of a watch stream. @@ -497,7 +497,7 @@ export function registerCollectionRoutes( spacesCtx?: SpacesContext | null, options: { pubsub?: import("../realtime/types").PubSub | null; - community?: CommunityAdapter | null; + community?: CommunityProbe | null; } = {} ): void { const ns = config.namespace; diff --git a/packages/contrail/src/core/router/index.ts b/packages/contrail/src/core/router/index.ts index 535c15f..0c5e058 100644 --- a/packages/contrail/src/core/router/index.ts +++ b/packages/contrail/src/core/router/index.ts @@ -12,11 +12,7 @@ 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 { registerCommunityRoutes } from "../community/router"; -import type { CommunityRoutesOptions } from "../community/router"; -import { CommunityAdapter } from "../community/adapter"; -import { createCommunityInviteHandler } from "../community/invite-handler"; -import { createCommunityWhoamiExtension } from "../community/whoami"; +import type { CommunityIntegration } from "../community-integration"; import { registerRealtimeRoutes } from "../realtime/router"; import type { RealtimeRoutesOptions } from "../realtime/router"; import { registerInviteRoutes } from "../invite/router"; @@ -28,6 +24,7 @@ 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; @@ -36,7 +33,13 @@ export interface SpacesContext { export interface CreateAppOptions { spaces?: SpacesRoutesOptions; - community?: CommunityRoutesOptions; + /** 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; /** Separate DB for the spaces tables. Defaults to `db`. */ spacesDb?: Database; @@ -131,11 +134,11 @@ export function createApp( } : null; - // Community is wired up at this layer, not from inside spaces / invite — - // those modules consume injected hooks, not community internals. The - // adapter is shared across every call site that needs it (publishing - // wrapper, collection routes, whoami extension, invite handler, realtime). - const communityAdapter = config.community ? new CommunityAdapter(spacesDb) : 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 @@ -149,8 +152,8 @@ export function createApp( queueBound: config.realtime.queueBound, }); if (spacesCtx) { - const isCommunityDid = communityAdapter - ? cachedIsCommunityDid(communityAdapter) + const isCommunityDid = community + ? cachedIsCommunityDid(community.probe) : undefined; spacesCtx = { ...spacesCtx, @@ -163,52 +166,43 @@ export function createApp( registerCollectionRoutes(app, db, config, spacesCtx, { pubsub: realtimePubsub, - community: communityAdapter, + community: community?.probe ?? null, }); registerFeedRoutes(app, db, config); registerNotifyRoute(app, db, config); - // Spaces routes — get a whoami extension when community is configured so - // community-owned spaces get an `accessLevel` field. + // 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 ?? - (communityAdapter - ? createCommunityWhoamiExtension({ community: communityAdapter }) - : undefined), + options.spaces?.whoamiExtension ?? community?.whoamiExtension, }; registerSpacesRoutes(app, spacesDb, config, spacesOptions, spacesCtx); - if (config.community && spacesCtx) { + if (community && spacesCtx) { // Community routes reuse the spaces service-auth middleware (same JWT verifier). const authMiddleware = - options.community?.authMiddleware ?? + options.communityAuthMiddleware ?? options.spaces?.authMiddleware ?? createServiceAuthMiddleware(spacesCtx.verifier); - registerCommunityRoutes( - app, - spacesDb, - config, - { ...options.community, authMiddleware }, - { spacesAdapter: spacesCtx.adapter, verifier: spacesCtx.verifier } - ); + community.registerRoutes(app, { authMiddleware }); } if (config.spaces?.authority && spacesCtx) { // Unified invite surface: one `.invite.*` family that dispatches on // space ownership (user-owned → addMember; community-owned → grant via - // an injected community-invite handler). + // the integration's invite handler). const authMiddleware = options.spaces?.authMiddleware ?? createServiceAuthMiddleware(spacesCtx.verifier); - const inviteHandler = communityAdapter - ? createCommunityInviteHandler({ - community: communityAdapter, - authority: spacesCtx.adapter, - }) - : null; - registerInviteRoutes(app, config, spacesCtx.adapter, inviteHandler, { authMiddleware }); + registerInviteRoutes( + app, + config, + spacesCtx.adapter, + community?.inviteHandler ?? null, + { authMiddleware } + ); } if (config.realtime && realtimePubsub) { @@ -221,17 +215,23 @@ export function createApp( options.spaces?.authMiddleware ?? createServiceAuthMiddleware(spacesCtx.verifier) : null; - registerRealtimeRoutes(app, config, spacesCtx?.adapter ?? null, communityAdapter, { - authMiddleware, - pubsub: realtimePubsub, - }); + registerRealtimeRoutes( + app, + config, + spacesCtx?.adapter ?? null, + community?.probe ?? null, + { + authMiddleware, + pubsub: realtimePubsub, + } + ); } return app; } function cachedIsCommunityDid( - community: CommunityAdapter + probe: import("../community-integration").CommunityProbe ): (did: string) => Promise { const TTL = 60_000; const cache = new Map(); @@ -239,7 +239,7 @@ function cachedIsCommunityDid( const now = Date.now(); const hit = cache.get(did); if (hit && hit.expires > now) return hit.value; - const row = await community.getCommunity(did); + const row = await probe.getCommunity(did); const value = row != null; cache.set(did, { value, expires: now + TTL }); return value; diff --git a/packages/contrail/src/core/types.ts b/packages/contrail/src/core/types.ts index b895141..3b9dc48 100644 --- a/packages/contrail/src/core/types.ts +++ b/packages/contrail/src/core/types.ts @@ -155,9 +155,11 @@ export interface ContrailConfig { notify?: boolean | string; /** Permissioned spaces configuration. When set, the service exposes space XRPCs. */ spaces?: import("./spaces/types").SpacesConfig; - /** Community module configuration. When set, the service exposes community XRPCs - * for managing community-owned spaces and tiered access levels. Requires `spaces`. */ - community?: import("./community/types").CommunityConfig; + /** 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; diff --git a/packages/contrail/src/index.ts b/packages/contrail/src/index.ts index bbd8efe..63d07ec 100644 --- a/packages/contrail/src/index.ts +++ b/packages/contrail/src/index.ts @@ -110,6 +110,34 @@ export { 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 { @@ -160,28 +188,6 @@ export { export type { PersistentLabelsOptions } from "./core/labels/subscribe"; export { resolveLabelerEndpoint } from "./core/labels/resolve"; -// Community -export { - CommunityAdapter, - CredentialCipher, - registerCommunityRoutes, - ACCESS_LEVELS, - RESERVED_KEYS, - isAccessLevel, - isReservedKey, - rankOf, - resolveEffectiveLevel, - flattenEffectiveMembers, - wouldCycle, - reconcile, -} from "./core/community"; -export type { - CommunityConfig, - CommunityMode, - CommunityRow, - CommunityInviteRow, - CreateCommunityInviteInput, - AccessLevel, - AccessLevelRow, - ReservedKey, -} from "./core/community"; +// Community has moved to @atmo-dev/contrail-community. Import from there: +// import { createCommunityIntegration, CommunityAdapter, ... } from "@atmo-dev/contrail-community"; +// const app = createApp(db, config, { community: createCommunityIntegration(...) }); diff --git a/packages/contrail/tests/realtime-e2e.test.ts b/packages/contrail/tests/realtime-e2e.test.ts index 59e9338..0ef8aa2 100644 --- a/packages/contrail/tests/realtime-e2e.test.ts +++ b/packages/contrail/tests/realtime-e2e.test.ts @@ -7,6 +7,7 @@ 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"; +import { createCommunityIntegration } from "@atmo-dev/contrail-community"; const ALICE = "did:plc:alice"; const BOB = "did:plc:bob"; @@ -72,9 +73,11 @@ function fakeAuth(): MiddlewareHandler { async function makeApp(): Promise { const db = createSqliteDatabase(":memory:"); const resolved = resolveConfig(CONFIG); - await initSchema(db, resolved); + const community = createCommunityIntegration({ db, config: resolved }); + await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() }, + community, }); } diff --git a/packages/contrail/vitest.config.ts b/packages/contrail/vitest.config.ts index 04f1a15..70a22e6 100644 --- a/packages/contrail/vitest.config.ts +++ b/packages/contrail/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "vitest/config"; +import path from "node:path"; export default defineConfig({ test: { @@ -6,4 +7,15 @@ export default defineConfig({ // PostgreSQL tests share a single database and cannot run in parallel fileParallelism: false, }, + resolve: { + alias: { + // Point at contrail-community's source so tests don't require a built + // dist. Mirrors the contrail-community package's own vitest alias for + // `@atmo-dev/contrail` → contrail/src. + "@atmo-dev/contrail-community": path.resolve( + __dirname, + "../contrail-community/src/index.ts" + ), + }, + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e648bf..11a1eb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ importers: '@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 @@ -383,6 +386,9 @@ importers: specifier: ^2.4.0 version: 2.6.1 devDependencies: + '@atmo-dev/contrail-community': + specifier: workspace:* + version: link:../contrail-community '@cloudflare/workers-types': specifier: ^4.20250124.0 version: 4.20260424.1 @@ -408,6 +414,43 @@ importers: specifier: ^4.63.0 version: 4.84.1(@cloudflare/workers-types@4.20260424.1) + 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 + 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 + 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/lexicons: dependencies: '@atcute/lex-cli':