diff --git a/packages/contrail/src/core/community/index.ts b/packages/contrail/src/core/community/index.ts index 3fbe1ad..fd0cc53 100644 --- a/packages/contrail/src/core/community/index.ts +++ b/packages/contrail/src/core/community/index.ts @@ -21,6 +21,8 @@ export { 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 } from "./pds"; export { diff --git a/packages/contrail/src/core/community/invite-handler.ts b/packages/contrail/src/core/community/invite-handler.ts new file mode 100644 index 0000000..0b41f8a --- /dev/null +++ b/packages/contrail/src/core/community/invite-handler.ts @@ -0,0 +1,155 @@ +/** 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, +} from "../invite/community-handler"; +import { mintInviteToken } from "../invite/token"; +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"; +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): HandlerResponse => ({ status: 200, body }); +const err = (status: number, body: Record): 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/src/core/community/reconcile.ts b/packages/contrail/src/core/community/reconcile.ts index b9d7e35..5fde1d3 100644 --- a/packages/contrail/src/core/community/reconcile.ts +++ b/packages/contrail/src/core/community/reconcile.ts @@ -1,13 +1,18 @@ -import type { StorageAdapter as SpacesAdapter } from "../spaces/types"; +import type { SpaceAuthority } from "../spaces/types"; 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). */ + * 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: SpacesAdapter, + spaces: SpaceAuthority, spaceUri: string, byDid: string, opts: { depth?: number; maxReverseDepth?: number } = {} diff --git a/packages/contrail/src/core/community/whoami.ts b/packages/contrail/src/core/community/whoami.ts new file mode 100644 index 0000000..4a75a34 --- /dev/null +++ b/packages/contrail/src/core/community/whoami.ts @@ -0,0 +1,26 @@ +/** 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 "../spaces/router"; +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/src/core/invite/community-handler.ts b/packages/contrail/src/core/invite/community-handler.ts new file mode 100644 index 0000000..1043525 --- /dev/null +++ b/packages/contrail/src/core/invite/community-handler.ts @@ -0,0 +1,67 @@ +/** 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; +}; + +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; + + /** 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; + + /** List invites for a community-owned space. */ + list(input: { + spaceUri: string; + callerDid: string; + includeRevoked: boolean; + }): Promise; + + /** 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; + + /** 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; + + /** 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; +} diff --git a/packages/contrail/src/core/invite/router.ts b/packages/contrail/src/core/invite/router.ts index 440bc16..941af9b 100644 --- a/packages/contrail/src/core/invite/router.ts +++ b/packages/contrail/src/core/invite/router.ts @@ -2,36 +2,31 @@ * user-owned and community-owned spaces. Dispatches on space ownership. * * - User-owned space → `kind` in create, `addMember` on redeem, owner-only. - * - Community-owned → `accessLevel` in create, `grant` on redeem, - * manager+ with "cannot grant higher than self". + * - Community-owned → routed to a {@link CommunityInviteHandler} provided + * by the community module (or null when community is + * not configured). * * 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. */ + * primitive and HTTP dance are shared. invite/router has zero imports from + * community/ — coupling is via the {@link CommunityInviteHandler} interface. */ 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 { SpaceAuthority } from "../spaces/types"; import type { InviteKind, InviteRow } from "../spaces/types"; -import type { CommunityAdapter } from "../community/adapter"; -import type { CommunityInviteRow, AccessLevel } from "../community/types"; -import { isAccessLevel, rankOf } from "../community/types"; import { hashInviteToken, mintInviteToken } from "./token"; -import { resolveEffectiveLevel } from "../community/acl"; -import { reconcile } from "../community/reconcile"; +import type { CommunityInviteHandler, HandlerResponse } from "./community-handler"; export interface InviteRoutesOptions { authMiddleware: MiddlewareHandler; } -/** Shape returned to clients — `kind` (user-owned space) or `accessLevel` - * (community-owned space) is set, never both. */ interface PublicInviteView { tokenHash: string; spaceUri: string; kind?: InviteKind; - accessLevel?: AccessLevel; createdBy: string; createdAt: number; expiresAt: number | null; @@ -56,26 +51,11 @@ function toSpacesView(row: InviteRow): PublicInviteView { }; } -function toCommunityView(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, - }; -} - export function registerInviteRoutes( app: Hono, config: ContrailConfig, - spaces: StorageAdapter, - community: CommunityAdapter | null, + authority: SpaceAuthority, + community: CommunityInviteHandler | null, options: InviteRoutesOptions ): void { if (!config.spaces?.authority) return; @@ -86,9 +66,9 @@ export function registerInviteRoutes( /** Resolve whether a space is community-owned. Returns null if the space * doesn't exist. */ const classifySpace = async (spaceUri: string) => { - const space = await spaces.getSpace(spaceUri); + const space = await authority.getSpace(spaceUri); if (!space) return null; - const isCommunity = community ? !!(await community.getCommunity(space.ownerDid)) : false; + const isCommunity = community ? await community.isCommunityOwned(spaceUri) : false; return { space, isCommunity }; }; @@ -120,35 +100,15 @@ export function registerInviteRoutes( if (isCommunity) { if (!community) return c.json({ error: "InvalidState" }, 500); - if (body.kind) { - return c.json( - { error: "InvalidRequest", reason: "kind-on-community-space", message: "community spaces take accessLevel, not kind" }, - 400 - ); - } - if (!body.accessLevel || !isAccessLevel(body.accessLevel)) { - return c.json({ error: "InvalidRequest", reason: "accessLevel-required" }, 400); - } - // Caller must have manager+ on the target space and cannot create an - // invite that confers a higher level than their own. - 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(body.accessLevel) > rankOf(callerLevel)) { - return c.json({ error: "Forbidden", reason: "cannot-grant-higher-than-self" }, 403); - } - const { token, tokenHash } = await mintInviteToken(); - const row = await community.createInvite({ + return relay(c, await community.create({ spaceUri: body.spaceUri, - tokenHash, + callerDid: sa.issuer, accessLevel: body.accessLevel, - createdBy: sa.issuer, + kind: body.kind, expiresAt: body.expiresAt ?? null, maxUses: body.maxUses ?? null, note: body.note ?? null, - }); - return c.json({ token, invite: toCommunityView(row) }); + })); } // User-owned space. @@ -166,7 +126,7 @@ export function registerInviteRoutes( return c.json({ error: "InvalidRequest", message: "kind must be 'join', 'read', or 'read-join'" }, 400); } const { token, tokenHash } = await mintInviteToken(); - const invite = await spaces.createInvite({ + const invite = await authority.createInvite({ spaceUri: body.spaceUri, tokenHash, kind, @@ -189,18 +149,17 @@ export function registerInviteRoutes( const { space, isCommunity } = classified; if (isCommunity) { - const callerLevel = await resolveEffectiveLevel(community!, spaceUri, sa.issuer); - if (!callerLevel || rankOf(callerLevel) < rankOf("manager")) { - return c.json({ error: "Forbidden", reason: "manager-required" }, 403); - } - const rows = await community!.listInvites(spaceUri, { includeRevoked }); - return c.json({ invites: rows.map(toCommunityView) }); + 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 spaces.listInvites(spaceUri, { includeRevoked }); + const rows = await authority.listInvites(spaceUri, { includeRevoked }); return c.json({ invites: rows.map(toSpacesView) }); }); @@ -213,54 +172,39 @@ export function registerInviteRoutes( return c.json({ error: "InvalidRequest", message: "tokenHash required" }, 400); } - // When the caller passes spaceUri we do an auth check up front so the - // response doesn't leak token existence. Community revokers may also be - // the invite creator (even without manager+), which is resolved after. if (body.spaceUri) { const classified = await classifySpace(body.spaceUri); if (!classified) return c.json({ error: "NotFound" }, 404); if (classified.isCommunity) { - const level = await resolveEffectiveLevel(community!, body.spaceUri, sa.issuer); - const managerOrHigher = !!level && rankOf(level) >= rankOf("manager"); - if (!managerOrHigher) { - const crow = await community!.getInvite(body.tokenHash); - if (!crow || crow.createdBy !== sa.issuer) { - return c.json({ error: "Forbidden", reason: "creator-or-manager-required" }, 403); - } - } - const ok = await community!.revokeInvite(body.tokenHash); - return c.json({ ok }); + 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 spaces.revokeInvite(body.tokenHash); + const ok = await authority.revokeInvite(body.tokenHash); return c.json({ ok }); } - // No spaceUri provided — infer from the invite row. + // No spaceUri — try the community handler first (it returns null if the + // token isn't a community invite), then fall back to the user-owned path. if (community) { - const crow = await community.getInvite(body.tokenHash); - if (crow) { - let allowed = crow.createdBy === sa.issuer; - if (!allowed) { - const level = await resolveEffectiveLevel(community, crow.spaceUri, sa.issuer); - allowed = !!level && rankOf(level) >= rankOf("manager"); - } - if (!allowed) { - return c.json({ error: "Forbidden", reason: "creator-or-manager-required" }, 403); - } - const ok = await community.revokeInvite(body.tokenHash); - return c.json({ ok }); - } + const r = await community.tryRevokeByToken({ + tokenHash: body.tokenHash, + callerDid: sa.issuer, + }); + if (r) return relay(c, r); } - const srow = await spaces.getInvite(body.tokenHash); + const srow = await authority.getInvite(body.tokenHash); if (!srow) return c.json({ error: "NotFound" }, 404); - const space = await spaces.getSpace(srow.spaceUri); + 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 spaces.revokeInvite(body.tokenHash); + const ok = await authority.revokeInvite(body.tokenHash); return c.json({ ok }); }); @@ -273,43 +217,32 @@ export function registerInviteRoutes( const tokenHash = await hashInviteToken(body.token); const now = Date.now(); - // Try community first (it's atomic — returns null if not consumable). + // Try community first (atomic — null if not a community invite). if (community) { - const cinvite = await community.redeemInvite(tokenHash, now); - if (cinvite) { - const space = await spaces.getSpace(cinvite.spaceUri); - if (!space) { - return c.json({ error: "NotFound", reason: "space-not-found" }, 404); - } - // 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: sa.issuer, - accessLevel: cinvite.accessLevel, - grantedBy: cinvite.createdBy, - }); - await reconcile(community, spaces, cinvite.spaceUri, cinvite.createdBy); - return c.json({ - spaceUri: cinvite.spaceUri, - accessLevel: cinvite.accessLevel, - communityDid: space.ownerDid, - }); - } + const r = await community.tryRedeem({ + tokenHash, + callerDid: sa.issuer, + now, + }); + if (r) return relay(c, r); } - // Fall back to the spaces (user-owned) path. The spaces redeem filter - // already restricts to `kind IN ('join','read-join')` at the SQL level. - const sinvite = await spaces.redeemInvite(tokenHash, now); + // Fall back to the user-owned spaces path. The redeem filter at the SQL + // level already restricts to `kind IN ('join','read-join')`. + const sinvite = await authority.redeemInvite(tokenHash, now); if (!sinvite) { return c.json({ error: "InvalidInvite", reason: "expired-revoked-or-exhausted" }, 400); } - await spaces.addMember(sinvite.spaceUri, sa.issuer, sinvite.createdBy); + await authority.addMember(sinvite.spaceUri, sa.issuer, sinvite.createdBy); return c.json({ spaceUri: sinvite.spaceUri, kind: sinvite.kind }); }); } +/** Forward a community-handler response to the wire. */ +function relay(c: Context, r: HandlerResponse) { + return c.json(r.body, r.status as Parameters[1]); +} + function getAuth(c: Context): ServiceAuth { const a = c.get("serviceAuth") as ServiceAuth | undefined; if (!a) throw new Error("service auth not set"); diff --git a/packages/contrail/src/core/router/index.ts b/packages/contrail/src/core/router/index.ts index 8c10854..535c15f 100644 --- a/packages/contrail/src/core/router/index.ts +++ b/packages/contrail/src/core/router/index.ts @@ -15,6 +15,8 @@ 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 { registerRealtimeRoutes } from "../realtime/router"; import type { RealtimeRoutesOptions } from "../realtime/router"; import { registerInviteRoutes } from "../invite/router"; @@ -129,6 +131,12 @@ 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; + // 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 @@ -141,7 +149,6 @@ export function createApp( queueBound: config.realtime.queueBound, }); if (spacesCtx) { - const communityAdapter = config.community ? new CommunityAdapter(spacesDb) : null; const isCommunityDid = communityAdapter ? cachedIsCommunityDid(communityAdapter) : undefined; @@ -153,19 +160,25 @@ export function createApp( } registerAdminRoutes(app, db, config); - const communityAdapterForCollection = config.community - ? new CommunityAdapter(spacesDb) - : null; + registerCollectionRoutes(app, db, config, spacesCtx, { pubsub: realtimePubsub, - community: communityAdapterForCollection, + community: communityAdapter, }); registerFeedRoutes(app, db, config); registerNotifyRoute(app, db, config); - const communityAdapterForSpaces = config.community && spacesCtx - ? new CommunityAdapter(spacesDb) - : null; - registerSpacesRoutes(app, spacesDb, config, options.spaces, spacesCtx, communityAdapterForSpaces); + + // Spaces routes — get a whoami extension when community is configured so + // community-owned spaces get an `accessLevel` field. + const spacesOptions = { + ...options.spaces, + whoamiExtension: + options.spaces?.whoamiExtension ?? + (communityAdapter + ? createCommunityWhoamiExtension({ community: communityAdapter }) + : undefined), + }; + registerSpacesRoutes(app, spacesDb, config, spacesOptions, spacesCtx); if (config.community && spacesCtx) { // Community routes reuse the spaces service-auth middleware (same JWT verifier). @@ -184,12 +197,18 @@ export function createApp( if (config.spaces?.authority && spacesCtx) { // Unified invite surface: one `.invite.*` family that dispatches on - // space ownership (user-owned → addMember; community-owned → grant). + // space ownership (user-owned → addMember; community-owned → grant via + // an injected community-invite handler). const authMiddleware = options.spaces?.authMiddleware ?? createServiceAuthMiddleware(spacesCtx.verifier); - const communityAdapter = config.community ? new CommunityAdapter(spacesDb) : null; - registerInviteRoutes(app, config, spacesCtx.adapter, communityAdapter, { authMiddleware }); + const inviteHandler = communityAdapter + ? createCommunityInviteHandler({ + community: communityAdapter, + authority: spacesCtx.adapter, + }) + : null; + registerInviteRoutes(app, config, spacesCtx.adapter, inviteHandler, { authMiddleware }); } if (config.realtime && realtimePubsub) { @@ -202,7 +221,6 @@ export function createApp( options.spaces?.authMiddleware ?? createServiceAuthMiddleware(spacesCtx.verifier) : null; - const communityAdapter = config.community ? new CommunityAdapter(spacesDb) : null; registerRealtimeRoutes(app, config, spacesCtx?.adapter ?? null, communityAdapter, { authMiddleware, pubsub: realtimePubsub, diff --git a/packages/contrail/src/core/spaces/router.ts b/packages/contrail/src/core/spaces/router.ts index f3e3fd5..4e71c05 100644 --- a/packages/contrail/src/core/spaces/router.ts +++ b/packages/contrail/src/core/spaces/router.ts @@ -11,7 +11,6 @@ import { } from "./auth"; import { nextTid } from "./tid"; import { hashInviteToken } from "../invite/token"; -import { resolveEffectiveLevel } from "../community/acl"; import { buildSpaceUri } from "./uri"; import { DEFAULT_BLOB_MAX_SIZE, @@ -26,11 +25,27 @@ import { blobKey } from "./blob-adapter"; import { collectBlobCids } from "./blob-refs"; import { create as createCid, toString as cidToString } from "@atcute/cid"; +/** Optional hook to extend `.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 | null>; + 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; } /** Umbrella registration: wires both the authority and the record-host @@ -43,8 +58,7 @@ export function registerSpacesRoutes( db: Database, config: ContrailConfig, options: SpacesRoutesOptions = {}, - ctx?: { adapter: StorageAdapter; verifier: import("@atcute/xrpc-server/auth").ServiceJwtVerifier } | null, - community?: import("../community/adapter").CommunityAdapter | null + ctx?: { adapter: StorageAdapter; verifier: import("@atcute/xrpc-server/auth").ServiceJwtVerifier } | null ): void { const spacesConfig = config.spaces; if (!spacesConfig) return; @@ -55,7 +69,7 @@ export function registerSpacesRoutes( const verifier = ctx?.verifier ?? buildVerifier(authorityConfig); const auth = options.authMiddleware ?? createServiceAuthMiddleware(verifier); - registerAuthorityRoutes(app, adapter, authorityConfig, config, auth, community ?? null); + registerAuthorityRoutes(app, adapter, authorityConfig, config, auth, options.whoamiExtension); if (spacesConfig.recordHost) { registerRecordHostRoutes(app, adapter, adapter, spacesConfig.recordHost, config, auth); @@ -70,7 +84,7 @@ export function registerAuthorityRoutes( authorityConfig: AuthorityConfig, config: ContrailConfig, auth: MiddlewareHandler, - community: import("../community/adapter").CommunityAdapter | null + whoamiExtension?: WhoamiExtension ): void { /** Space endpoints are emitted per-deployment under the configured namespace; * the deployment owns and publishes its own lexicons. The library ships @@ -243,8 +257,8 @@ export function registerAuthorityRoutes( }); // Unified whoami — `.spaceExt.whoami?spaceUri=X` → { isOwner, isMember, - // accessLevel? }. `accessLevel` is present only when the target space is - // community-owned; for user-owned spaces membership is binary. + // ... }. Extra fields (e.g. accessLevel for community-owned spaces) come + // from the optional whoamiExtension hook; without one, response is binary. app.get(`/xrpc/${SPACE_EXT}.whoami`, auth, async (c) => { const sa = getAuth(c); const spaceUri = c.req.query("spaceUri"); @@ -254,20 +268,17 @@ export function registerAuthorityRoutes( const isOwner = space.ownerDid === sa.issuer; - // Community-owned space: resolve through the access-level ladder. The - // reconciler keeps spaces_members in sync, so isMember derives from the - // effective level directly. - const isCommunity = community ? !!(await community.getCommunity(space.ownerDid)) : false; - if (isCommunity) { - const level = await resolveEffectiveLevel(community!, spaceUri, sa.issuer); - return c.json({ + if (whoamiExtension) { + const ext = await whoamiExtension({ + spaceUri, + callerDid: sa.issuer, isOwner, - isMember: isOwner || !!level, - accessLevel: level, + ownerDid: space.ownerDid, }); + if (ext) return c.json(ext); } - // User-owned space: binary membership. + // Default: binary membership. if (isOwner) return c.json({ isOwner: true, isMember: true }); const member = await authority.getMember(spaceUri, sa.issuer); return c.json({ isOwner: false, isMember: !!member });