diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 6fc8e2a..bc756a2 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -1,7 +1,13 @@ import * as Automerge from "@automerge/automerge"; import type { FolkShape } from "./folk-shape"; import type { OfflineStore } from "./offline-store"; -import type { Layer, LayerFlow } from "./layer-types"; +import type { Layer, LayerFlow, FlowKind } from "./layer-types"; +import type { + SpaceConnection, + ConnectionState, + MembranePermeability, +} from "./connection-types"; +import { computeMembranePermeability } from "./connection-types"; // Shape data stored in Automerge document export interface ShapeData { @@ -90,6 +96,10 @@ export interface CommunityDoc { flows?: { [id: string]: LayerFlow; }; + /** Bilateral typed connections to other spaces */ + connections?: { + [connId: string]: SpaceConnection; + }; /** Currently active layer ID */ activeLayerId?: string; /** Layer view mode: flat (tabs) or stack (side view) */ @@ -1186,6 +1196,67 @@ export class CommunitySync extends EventTarget { ); } + // ── Connection API ── + + /** Add a connection to the document */ + addConnection(conn: SpaceConnection): void { + this.#doc = Automerge.change(this.#doc, `Add connection ${conn.id}`, (doc) => { + if (!doc.connections) doc.connections = {}; + doc.connections[conn.id] = conn; + }); + this.#scheduleSave(); + this.#syncToServer(); + this.dispatchEvent(new CustomEvent("connection-added", { detail: conn })); + } + + /** Remove a connection */ + removeConnection(connId: string): void { + this.#doc = Automerge.change(this.#doc, `Remove connection ${connId}`, (doc) => { + if (doc.connections && doc.connections[connId]) { + delete doc.connections[connId]; + } + }); + this.#scheduleSave(); + this.#syncToServer(); + this.dispatchEvent(new CustomEvent("connection-removed", { detail: { connId } })); + } + + /** Update connection properties */ + updateConnection(connId: string, updates: Partial): void { + this.#doc = Automerge.change(this.#doc, `Update connection ${connId}`, (doc) => { + if (doc.connections && doc.connections[connId]) { + for (const [key, value] of Object.entries(updates)) { + if (key === 'id') continue; + (doc.connections[connId] as unknown as Record)[key] = value; + } + } + }); + this.#scheduleSave(); + this.#syncToServer(); + this.dispatchEvent(new CustomEvent("connection-updated", { detail: { connId, updates } })); + } + + /** Get all connections */ + getConnections(): SpaceConnection[] { + const connections = this.#doc.connections || {}; + return Object.values(connections); + } + + /** Get connections filtered by state */ + getConnectionsByState(state: ConnectionState): SpaceConnection[] { + return this.getConnections().filter(c => c.state === state); + } + + /** Get connections filtered by flow kind */ + getConnectionsByFlowKind(kind: FlowKind): SpaceConnection[] { + return this.getConnections().filter(c => c.flowKinds.includes(kind)); + } + + /** Compute membrane permeability from all connections */ + getMembranePermeability(): MembranePermeability { + return computeMembranePermeability(this.getConnections(), this.#communitySlug); + } + /** * Disconnect from server */ diff --git a/lib/connection-types.ts b/lib/connection-types.ts new file mode 100644 index 0000000..79b57b5 --- /dev/null +++ b/lib/connection-types.ts @@ -0,0 +1,239 @@ +/** + * SpaceConnection — bilateral typed relationships between spaces. + * + * Distinct from nesting (SpaceRef: unilateral visual embed) and LayerFlow + * (intra-space module flows). SpaceConnections are stored in BOTH spaces' + * Automerge docs with the same ID, enabling economic streams, delegative + * authority, and information sharing across space boundaries. + */ + +import type { FlowKind } from "./layer-types"; + +// ── Connection state machine ── + +export type ConnectionState = "proposed" | "active" | "paused" | "revoked"; + +export const CONNECTION_STATE_COLORS: Record = { + proposed: "#f59e0b", // amber + active: "#22c55e", // green + paused: "#64748b", // slate + revoked: "#ef4444", // red +}; + +// ── Directionality ── + +export type ConnectionDirection = "outbound" | "inbound" | "bidirectional"; + +/** Invert direction from the other space's perspective */ +export function invertDirection(dir: ConnectionDirection): ConnectionDirection { + if (dir === "outbound") return "inbound"; + if (dir === "inbound") return "outbound"; + return "bidirectional"; +} + +// ── Economic stream spec ── + +export interface EconomicFlowSpec { + tokenId: string; // mint shape ID, ERC-20 address, or slug:mintId + tokenName?: string; + rateLimit: number; // max tokens per window (0 = unlimited) + rateWindowMs: number; // window duration in ms + requireApproval: boolean; // per-transfer approval gate + minTransfer?: number; + maxTransfer?: number; +} + +// ── Delegation spec (liquid democracy) ── + +export type DelegableAuthority = + | "voting" + | "moderation" + | "curation" + | "treasury" + | "membership" + | "custom"; + +export interface DelegationSpec { + authority: DelegableAuthority; + scope?: string; // human-readable scope description + maxDepth: number; // re-delegation depth (0 = no chain) + weight: number; // 0-1 fractional delegation + retainAuthority: boolean; // delegator keeps authority alongside delegate + expiry?: number; // unix timestamp +} + +// ── Information stream spec ── + +export interface InformationFlowSpec { + allowedShapeTypes?: string[]; // shape types that can flow (empty = all) + allowedTags?: string[]; + allowedModules?: string[]; + feedIds?: string[]; // subscribe to specific feeds + maxItemsPerSync?: number; + historicalSync: boolean; // include history or only new items +} + +// ── Per-connection permissions ── + +export interface ConnectionPermissions { + read: boolean; + write: boolean; + configure: boolean; // can modify connection params + pause: boolean; + revoke: boolean; + expiry?: number; // auto-expire unix timestamp +} + +// ── Per-space connection policy ── + +export interface ConnectionPolicy { + inboundConsent: "open" | "members" | "approval" | "closed"; + outboundConsent: "open" | "members" | "approval" | "closed"; + defaultPermissions: ConnectionPermissions; + acceptedFlowKinds: FlowKind[]; + allowlist?: string[]; // slugs bypassing consent + blocklist?: string[]; // slugs always denied +} + +// ── The SpaceConnection record (stored in BOTH spaces' docs) ── + +export interface SpaceConnection { + id: string; // shared ID (same in both docs) + localSlug: string; // this space + remoteSlug: string; // other space + remoteDID?: string; // remote owner DID at creation time + direction: ConnectionDirection; // from this space's perspective + flowKinds: FlowKind[]; + state: ConnectionState; + economicFlows?: EconomicFlowSpec[]; + delegations?: DelegationSpec[]; + informationFlow?: InformationFlowSpec; + permissions: ConnectionPermissions; + strength: number; // 0-1, visual membrane permeability + label?: string; + meta?: Record; + proposedBy: string; // DID + proposedAt: number; + acceptedBy?: string; + acceptedAt?: number; + lastModifiedBy?: string; + lastModifiedAt?: number; + stateReason?: string; // reason for pause/revoke +} + +// ── Pending connection request (in-memory, like PendingNestRequest) ── + +export type ConnectionRequestStatus = "pending" | "accepted" | "rejected"; + +export interface PendingConnectionRequest { + id: string; + fromSlug: string; + toSlug: string; + proposedBy: string; // DID + direction: ConnectionDirection; + flowKinds: FlowKind[]; + permissions: ConnectionPermissions; + strength: number; + label?: string; + economicFlows?: EconomicFlowSpec[]; + delegations?: DelegationSpec[]; + informationFlow?: InformationFlowSpec; + meta?: Record; + message?: string; + status: ConnectionRequestStatus; + createdAt: number; + resolvedAt?: number; + resolvedBy?: string; +} + +// ── Derived membrane permeability (for visualization) ── + +export interface MembranePermeability { + inbound: { kind: FlowKind; strength: number; connectionId: string }[]; + outbound: { kind: FlowKind; strength: number; connectionId: string }[]; + overallPermeability: number; // 0 = sealed, 1 = fully open +} + +// ── Default policies ── + +export const DEFAULT_PERSONAL_CONNECTION_POLICY: ConnectionPolicy = { + inboundConsent: "approval", + outboundConsent: "approval", + defaultPermissions: { + read: true, + write: false, + configure: false, + pause: true, + revoke: true, + }, + acceptedFlowKinds: ["data", "trust"], +}; + +export const DEFAULT_COMMUNITY_CONNECTION_POLICY: ConnectionPolicy = { + inboundConsent: "members", + outboundConsent: "members", + defaultPermissions: { + read: true, + write: true, + configure: false, + pause: true, + revoke: true, + }, + acceptedFlowKinds: ["economic", "trust", "data", "governance", "resource"], +}; + +// ── Helpers ── + +/** + * Cascade connection permissions across a nesting chain (intersection). + * When space A nests B which has connections, the effective permissions + * are the intersection of the nest permissions and connection permissions. + */ +export function cascadeConnectionPermissions( + chain: ConnectionPermissions[], +): ConnectionPermissions { + return { + read: chain.every(p => p.read), + write: chain.every(p => p.write), + configure: chain.every(p => p.configure), + pause: chain.every(p => p.pause), + revoke: chain.every(p => p.revoke), + expiry: chain.some(p => p.expiry) + ? Math.min(...chain.filter(p => p.expiry).map(p => p.expiry!)) + : undefined, + }; +} + +/** + * Compute membrane permeability from a set of active connections. + */ +export function computeMembranePermeability( + connections: SpaceConnection[], + localSlug: string, +): MembranePermeability { + const inbound: MembranePermeability["inbound"] = []; + const outbound: MembranePermeability["outbound"] = []; + + for (const conn of connections) { + if (conn.state !== "active") continue; + + for (const kind of conn.flowKinds) { + const entry = { kind, strength: conn.strength, connectionId: conn.id }; + + if (conn.direction === "inbound" || conn.direction === "bidirectional") { + inbound.push(entry); + } + if (conn.direction === "outbound" || conn.direction === "bidirectional") { + outbound.push(entry); + } + } + } + + // Overall permeability: average of all active connection strengths, or 0 if none + const activeConns = connections.filter(c => c.state === "active"); + const overallPermeability = activeConns.length > 0 + ? activeConns.reduce((sum, c) => sum + c.strength, 0) / activeConns.length + : 0; + + return { inbound, outbound, overallPermeability }; +} diff --git a/server/community-store.ts b/server/community-store.ts index 4d59d4f..313bfb9 100644 --- a/server/community-store.ts +++ b/server/community-store.ts @@ -8,6 +8,11 @@ import { packEncrypted, unpackEncrypted, } from "./local-first/encryption-utils"; +import type { ConnectionPolicy, SpaceConnection } from "../lib/connection-types"; +import { + DEFAULT_PERSONAL_CONNECTION_POLICY, + DEFAULT_COMMUNITY_CONNECTION_POLICY, +} from "../lib/connection-types"; const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities"; @@ -143,6 +148,7 @@ export interface CommunityMeta { description?: string; avatar?: string; nestPolicy?: NestPolicy; + connectionPolicy?: ConnectionPolicy; encrypted?: boolean; encryptionKeyId?: string; } @@ -179,6 +185,9 @@ export interface CommunityDoc { nestedSpaces: { [refId: string]: SpaceRef; }; + connections: { + [connId: string]: SpaceConnection; + }; } // Per-peer sync state for Automerge @@ -236,6 +245,20 @@ function migrateVisibility( }); } +/** + * Runtime migration: ensure `connections` map exists on docs created before SpaceConnections. + */ +function migrateAddConnections( + doc: Automerge.Doc, + slug: string, +): Automerge.Doc { + if (doc.connections) return doc; + console.log(`[Store] Migrating: adding connections map to ${slug}`); + return Automerge.change(doc, 'Add connections map', (d) => { + d.connections = {}; + }); +} + /** * Load community document from disk */ @@ -267,6 +290,8 @@ export async function loadCommunity(slug: string): Promise { for (const [refId, ref] of Object.entries(data.nestedSpaces || {})) { d.nestedSpaces[refId] = { ...ref }; } + d.connections = {}; + for (const [connId, conn] of Object.entries((data as any).connections || {})) { + d.connections[connId] = { ...(conn as SpaceConnection) }; + } }); return doc; } @@ -374,10 +405,11 @@ export async function createCommunity( name: string, slug: string, ownerDID: string | null = null, - visibility: SpaceVisibility = 'public', + visibility: SpaceVisibility = 'private', options?: { enabledModules?: string[]; nestPolicy?: NestPolicy; + connectionPolicy?: ConnectionPolicy; description?: string; }, ): Promise> { @@ -396,12 +428,16 @@ export async function createCommunity( if (options?.nestPolicy) { d.meta.nestPolicy = options.nestPolicy; } + if (options?.connectionPolicy) { + d.meta.connectionPolicy = options.connectionPolicy; + } if (options?.description) { d.meta.description = options.description; } d.shapes = {}; d.members = {}; d.nestedSpaces = {}; + d.connections = {}; // If owner is known, add them as admin member if (ownerDID) { d.members[ownerDID] = { @@ -1032,3 +1068,91 @@ export async function findNestedIn(sourceSlug: string): Promise { + if (!d.connections) d.connections = {}; + d.connections[conn.id] = { ...conn }; + }); + communities.set(slug, newDoc); + saveCommunity(slug); +} + +/** + * Update fields on an existing SpaceConnection + */ +export function updateConnection( + slug: string, + connId: string, + fields: Partial, +): boolean { + const doc = communities.get(slug); + if (!doc || !doc.connections?.[connId]) return false; + + const newDoc = Automerge.change(doc, `Update connection ${connId}`, (d) => { + if (d.connections[connId]) { + for (const [key, value] of Object.entries(fields)) { + if (key === 'id') continue; // never change ID + (d.connections[connId] as unknown as Record)[key] = value; + } + } + }); + communities.set(slug, newDoc); + saveCommunity(slug); + return true; +} + +/** + * Remove a SpaceConnection from a space's doc + */ +export function removeConnection(slug: string, connId: string): boolean { + const doc = communities.get(slug); + if (!doc || !doc.connections?.[connId]) return false; + + const newDoc = Automerge.change(doc, `Remove connection ${connId}`, (d) => { + if (d.connections && d.connections[connId]) { + delete d.connections[connId]; + } + }); + communities.set(slug, newDoc); + saveCommunity(slug); + return true; +} + +/** + * Get the ConnectionPolicy for a space, with fallback to default + */ +export function getConnectionPolicy(slug: string): ConnectionPolicy | null { + const doc = communities.get(slug); + if (!doc) return null; + return doc.meta.connectionPolicy || DEFAULT_COMMUNITY_CONNECTION_POLICY; +} + +/** + * Update the ConnectionPolicy for a space + */ +export function updateConnectionPolicy(slug: string, policy: Partial): boolean { + const doc = communities.get(slug); + if (!doc) return false; + + const newDoc = Automerge.change(doc, `Update connection policy`, (d) => { + const current = d.meta.connectionPolicy || { ...DEFAULT_COMMUNITY_CONNECTION_POLICY }; + d.meta.connectionPolicy = { + ...current, + ...policy, + defaultPermissions: { ...current.defaultPermissions, ...(policy.defaultPermissions || {}) }, + acceptedFlowKinds: policy.acceptedFlowKinds || current.acceptedFlowKinds, + }; + }); + communities.set(slug, newDoc); + saveCommunity(slug); + return true; +} diff --git a/server/index.ts b/server/index.ts index ea9b502..f45690e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -354,7 +354,7 @@ app.post("/api/communities", async (c) => { } const body = await c.req.json<{ name?: string; slug?: string; visibility?: SpaceVisibility }>(); - const { name, slug, visibility = "public" } = body; + const { name, slug, visibility = "private" } = body; const validVisibilities: SpaceVisibility[] = ["public", "permissioned", "private"]; if (visibility && !validVisibilities.includes(visibility)) return c.json({ error: "Invalid visibility" }, 400); diff --git a/server/spaces.ts b/server/spaces.ts index 7c45c61..2ecc5ec 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -29,6 +29,11 @@ import { removeMember, DEFAULT_COMMUNITY_NEST_POLICY, addShapes, + addConnection, + updateConnection, + removeConnection, + getConnectionPolicy, + updateConnectionPolicy, } from "./community-store"; import type { SpaceVisibility, @@ -38,6 +43,22 @@ import type { PendingNestRequest, NestRequestStatus, } from "./community-store"; +import type { FlowKind } from "../lib/layer-types"; +import type { + ConnectionPolicy, + ConnectionPermissions, + ConnectionDirection, + ConnectionState, + SpaceConnection, + PendingConnectionRequest, + ConnectionRequestStatus, +} from "../lib/connection-types"; +import { + DEFAULT_PERSONAL_CONNECTION_POLICY, + DEFAULT_COMMUNITY_CONNECTION_POLICY, + invertDirection, + computeMembranePermeability, +} from "../lib/connection-types"; import { verifyEncryptIDToken, extractToken, @@ -103,13 +124,20 @@ export type CreateSpaceResult = * All creation endpoints should call this instead of duplicating logic. */ export async function createSpace(opts: CreateSpaceOpts): Promise { - const { name, slug, ownerDID, visibility = 'public', enabledModules, source = 'api' } = opts; + const { name, slug, ownerDID, visibility = 'private', enabledModules, source = 'api' } = opts; if (!name || !slug) return { ok: false, error: "Name and slug are required", status: 400 }; if (!/^[a-z0-9-]+$/.test(slug)) return { ok: false, error: "Slug must contain only lowercase letters, numbers, and hyphens", status: 400 }; if (await communityExists(slug)) return { ok: false, error: "Space already exists", status: 409 }; - await createCommunity(name, slug, ownerDID, visibility); + // Personal spaces (auto-provisioned) get conservative connection policy; + // community spaces get the broader default. + const isPersonal = source === 'auto-provision'; + const connectionPolicy = isPersonal + ? DEFAULT_PERSONAL_CONNECTION_POLICY + : DEFAULT_COMMUNITY_CONNECTION_POLICY; + + await createCommunity(name, slug, ownerDID, visibility, { connectionPolicy }); // If enabledModules specified, update the community doc if (enabledModules) { @@ -159,6 +187,10 @@ function buildLifecycleContext(slug: string, doc: ReturnType(); let nestRequestCounter = 0; +// ── In-memory pending connection requests (move to DB later) ── +const connectionRequests = new Map(); +let connectionRequestCounter = 0; + // ── In-memory access requests (move to DB later) ── interface AccessRequest { id: string; @@ -271,7 +303,7 @@ spaces.post("/", async (c) => { enabledModules?: string[]; }>(); - const { name, slug, visibility = "public", enabledModules } = body; + const { name, slug, visibility = "private", enabledModules } = body; const validVisibilities: SpaceVisibility[] = ["public", "permissioned", "private"]; if (visibility && !validVisibilities.includes(visibility)) { @@ -490,6 +522,9 @@ spaces.delete("/admin/:slug", async (c) => { for (const [id, req] of nestRequests) { if (req.sourceSlug === slug || req.targetSlug === slug) nestRequests.delete(id); } + for (const [id, req] of connectionRequests) { + if (req.fromSlug === slug || req.toSlug === slug) connectionRequests.delete(id); + } // Clean up EncryptID space_members try { @@ -593,6 +628,9 @@ spaces.delete("/:slug", async (c) => { for (const [id, req] of nestRequests) { if (req.sourceSlug === slug || req.targetSlug === slug) nestRequests.delete(id); } + for (const [id, req] of connectionRequests) { + if (req.fromSlug === slug || req.toSlug === slug) connectionRequests.delete(id); + } await deleteCommunity(slug); @@ -1153,6 +1191,515 @@ spaces.patch("/:slug/nest-requests/:reqId", async (c) => { return c.json({ error: "action must be 'approve' or 'deny'" }, 400); }); +// ══════════════════════════════════════════════════════════════════════════════ +// CONNECTION API — bilateral typed relationships between spaces +// ══════════════════════════════════════════════════════════════════════════════ + +// ── Get connection policy for a space ── + +spaces.get("/:slug/connection-policy", async (c) => { + const slug = c.req.param("slug"); + await loadCommunity(slug); + const policy = getConnectionPolicy(slug); + if (!policy) return c.json({ error: "Space not found" }, 404); + return c.json({ connectionPolicy: policy }); +}); + +// ── Update connection policy (admin only) ── + +spaces.patch("/:slug/connection-policy", async (c) => { + const slug = c.req.param("slug"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + const member = data.members?.[claims.sub]; + const isOwner = data.meta.ownerDID === claims.sub; + if (!isOwner && member?.role !== 'admin') { + return c.json({ error: "Admin access required" }, 403); + } + + const body = await c.req.json>(); + updateConnectionPolicy(slug, body); + + return c.json({ ok: true, connectionPolicy: getConnectionPolicy(slug) }); +}); + +// ── List connections for a space (auth required) ── + +spaces.get("/:slug/connections", async (c) => { + const slug = c.req.param("slug"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + // Must be at least a member to see connections + const member = data.members?.[claims.sub]; + const isOwner = data.meta.ownerDID === claims.sub; + if (!isOwner && !member) { + return c.json({ error: "Must be a member to view connections" }, 403); + } + + const connections = Object.values(data.connections || {}); + return c.json({ connections }); +}); + +// ── Propose a connection (auth required, admin/moderator in FROM space) ── + +spaces.post("/:slug/connections", async (c) => { + const fromSlug = c.req.param("slug"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const body = await c.req.json<{ + toSlug: string; + direction?: ConnectionDirection; + flowKinds: FlowKind[]; + permissions?: Partial; + strength?: number; + label?: string; + economicFlows?: SpaceConnection['economicFlows']; + delegations?: SpaceConnection['delegations']; + informationFlow?: SpaceConnection['informationFlow']; + meta?: Record; + message?: string; + }>(); + + const { toSlug } = body; + if (!toSlug) return c.json({ error: "toSlug is required" }, 400); + if (!body.flowKinds?.length) return c.json({ error: "flowKinds must be a non-empty array" }, 400); + if (fromSlug === toSlug) return c.json({ error: "Cannot connect a space to itself" }, 400); + + // Load both spaces + await loadCommunity(fromSlug); + await loadCommunity(toSlug); + const fromData = getDocumentData(fromSlug); + const toData = getDocumentData(toSlug); + if (!fromData) return c.json({ error: "Source space not found" }, 404); + if (!toData) return c.json({ error: "Target space not found" }, 404); + + // Proposer must be admin or moderator in FROM space + const fromMember = fromData.members?.[claims.sub]; + const isFromOwner = fromData.meta.ownerDID === claims.sub; + if (!isFromOwner && fromMember?.role !== 'admin' && fromMember?.role !== 'moderator') { + return c.json({ error: "Admin or moderator role required in the source space" }, 403); + } + + // Check FROM space's outbound consent policy + const fromPolicy = fromData.meta.connectionPolicy || DEFAULT_COMMUNITY_CONNECTION_POLICY; + if (fromPolicy.outboundConsent === 'closed' && !fromPolicy.allowlist?.includes(toSlug)) { + return c.json({ error: "Source space does not allow outbound connections" }, 403); + } + + // Check TO space's inbound consent policy + const toPolicy = toData.meta.connectionPolicy || DEFAULT_COMMUNITY_CONNECTION_POLICY; + if (toPolicy.blocklist?.includes(fromSlug)) { + return c.json({ error: "Target space has blocked connections from this space" }, 403); + } + if (toPolicy.inboundConsent === 'closed' && !toPolicy.allowlist?.includes(fromSlug)) { + return c.json({ error: "Target space does not accept inbound connections" }, 403); + } + + // Check flow kinds are accepted by target + const rejectedKinds = body.flowKinds.filter(k => !toPolicy.acceptedFlowKinds.includes(k)); + if (rejectedKinds.length > 0) { + return c.json({ error: `Target space does not accept flow kinds: ${rejectedKinds.join(', ')}` }, 403); + } + + const direction: ConnectionDirection = body.direction || "bidirectional"; + const permissions: ConnectionPermissions = { + read: body.permissions?.read ?? toPolicy.defaultPermissions.read, + write: body.permissions?.write ?? toPolicy.defaultPermissions.write, + configure: body.permissions?.configure ?? toPolicy.defaultPermissions.configure, + pause: body.permissions?.pause ?? toPolicy.defaultPermissions.pause, + revoke: body.permissions?.revoke ?? toPolicy.defaultPermissions.revoke, + expiry: body.permissions?.expiry, + }; + const strength = Math.max(0, Math.min(1, body.strength ?? 0.5)); + + const isOnAllowlist = toPolicy.allowlist?.includes(fromSlug); + + // If inbound consent is 'approval' and not on allowlist, create a pending request + if (toPolicy.inboundConsent === 'approval' && !isOnAllowlist) { + const reqId = `conn-req-${++connectionRequestCounter}`; + const request: PendingConnectionRequest = { + id: reqId, + fromSlug, + toSlug, + proposedBy: claims.sub, + direction, + flowKinds: body.flowKinds, + permissions, + strength, + label: body.label, + economicFlows: body.economicFlows, + delegations: body.delegations, + informationFlow: body.informationFlow, + meta: body.meta, + message: body.message, + status: "pending", + createdAt: Date.now(), + }; + connectionRequests.set(reqId, request); + + return c.json({ + status: 'pending', + requestId: reqId, + message: 'Connection request created. Awaiting target space admin approval.', + }, 202); + } + + // If 'members' consent, check proposer is also member in target + if (toPolicy.inboundConsent === 'members' && !isOnAllowlist) { + const toMember = toData.members?.[claims.sub]; + const isToOwner = toData.meta.ownerDID === claims.sub; + if (!isToOwner && !toMember) { + return c.json({ error: "Must be a member of the target space for direct connection" }, 403); + } + } + + // Consent is 'open', 'members' (passed), or allowlisted — create immediately + const connId = `conn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const now = Date.now(); + const conn: SpaceConnection = { + id: connId, + localSlug: fromSlug, + remoteSlug: toSlug, + remoteDID: toData.meta.ownerDID || undefined, + direction, + flowKinds: body.flowKinds, + state: "active", + economicFlows: body.economicFlows, + delegations: body.delegations, + informationFlow: body.informationFlow, + permissions, + strength, + label: body.label, + meta: body.meta, + proposedBy: claims.sub, + proposedAt: now, + acceptedBy: claims.sub, + acceptedAt: now, + }; + + // Bilateral write: store in BOTH spaces' docs + addConnection(fromSlug, conn); + addConnection(toSlug, { + ...conn, + localSlug: toSlug, + remoteSlug: fromSlug, + remoteDID: fromData.meta.ownerDID || undefined, + direction: invertDirection(direction), + }); + + return c.json({ ok: true, connection: conn }, 201); +}); + +// ── Get a specific connection ── + +spaces.get("/:slug/connections/:connId", async (c) => { + const slug = c.req.param("slug"); + const connId = c.req.param("connId"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + const member = data.members?.[claims.sub]; + const isOwner = data.meta.ownerDID === claims.sub; + if (!isOwner && !member) { + return c.json({ error: "Must be a member to view connections" }, 403); + } + + const conn = data.connections?.[connId]; + if (!conn) return c.json({ error: "Connection not found" }, 404); + + return c.json({ connection: conn }); +}); + +// ── Update a connection (state, permissions, strength) ── + +spaces.patch("/:slug/connections/:connId", async (c) => { + const slug = c.req.param("slug"); + const connId = c.req.param("connId"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + const conn = data.connections?.[connId]; + if (!conn) return c.json({ error: "Connection not found" }, 404); + + // Must be admin/moderator in this space + const member = data.members?.[claims.sub]; + const isOwner = data.meta.ownerDID === claims.sub; + if (!isOwner && member?.role !== 'admin' && member?.role !== 'moderator') { + return c.json({ error: "Admin or moderator role required" }, 403); + } + + // Revoked is terminal + if (conn.state === 'revoked') { + return c.json({ error: "Revoked connections cannot be modified — propose a new connection" }, 400); + } + + const body = await c.req.json<{ + state?: ConnectionState; + permissions?: Partial; + strength?: number; + label?: string; + stateReason?: string; + }>(); + + // Validate state transition: cannot go back from revoked (already checked), and can't set to 'proposed' + if (body.state === 'proposed') { + return c.json({ error: "Cannot set state back to 'proposed'" }, 400); + } + + const updates: Partial = { + lastModifiedBy: claims.sub, + lastModifiedAt: Date.now(), + }; + if (body.state) updates.state = body.state; + if (body.permissions) { + updates.permissions = { ...conn.permissions, ...body.permissions }; + } + if (body.strength !== undefined) updates.strength = Math.max(0, Math.min(1, body.strength)); + if (body.label !== undefined) updates.label = body.label; + if (body.stateReason !== undefined) updates.stateReason = body.stateReason; + + // Update in this space's doc + updateConnection(slug, connId, updates); + + // Bilateral update: also update remote space's copy + const remoteSlug = conn.remoteSlug; + await loadCommunity(remoteSlug); + const remoteData = getDocumentData(remoteSlug); + if (remoteData?.connections?.[connId]) { + updateConnection(remoteSlug, connId, updates); + } + + return c.json({ ok: true }); +}); + +// ── Revoke (delete) a connection — either side's admin can revoke ── + +spaces.delete("/:slug/connections/:connId", async (c) => { + const slug = c.req.param("slug"); + const connId = c.req.param("connId"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + const conn = data.connections?.[connId]; + if (!conn) return c.json({ error: "Connection not found" }, 404); + + // Must be admin in THIS space (sovereignty guarantee: either side can revoke) + const member = data.members?.[claims.sub]; + const isOwner = data.meta.ownerDID === claims.sub; + if (!isOwner && member?.role !== 'admin') { + return c.json({ error: "Admin access required to revoke connections" }, 403); + } + + // Already revoked? No-op + if (conn.state === 'revoked') { + return c.json({ ok: true, message: "Connection already revoked" }); + } + + const revokeFields: Partial = { + state: 'revoked', + lastModifiedBy: claims.sub, + lastModifiedAt: Date.now(), + stateReason: 'Revoked by admin', + }; + + // Bilateral revoke: update both docs to 'revoked' state + updateConnection(slug, connId, revokeFields); + + const remoteSlug = conn.remoteSlug; + await loadCommunity(remoteSlug); + const remoteData = getDocumentData(remoteSlug); + if (remoteData?.connections?.[connId]) { + updateConnection(remoteSlug, connId, revokeFields); + } + + return c.json({ ok: true, state: 'revoked' }); +}); + +// ── List pending connection requests for a space (admin only) ── + +spaces.get("/:slug/connection-requests", async (c) => { + const slug = c.req.param("slug"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + const member = data.members?.[claims.sub]; + const isOwner = data.meta.ownerDID === claims.sub; + if (!isOwner && member?.role !== 'admin') { + return c.json({ error: "Admin access required" }, 403); + } + + // Find requests where this space is the TARGET (someone wants to connect to us) + const requests = Array.from(connectionRequests.values()) + .filter(r => r.toSlug === slug); + + return c.json({ requests }); +}); + +// ── Accept or reject a connection request ── + +spaces.patch("/:slug/connection-requests/:reqId", async (c) => { + const slug = c.req.param("slug"); + const reqId = c.req.param("reqId"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + const member = data.members?.[claims.sub]; + const isOwner = data.meta.ownerDID === claims.sub; + if (!isOwner && member?.role !== 'admin') { + return c.json({ error: "Admin access required" }, 403); + } + + const request = connectionRequests.get(reqId); + if (!request || request.toSlug !== slug) { + return c.json({ error: "Connection request not found" }, 404); + } + if (request.status !== 'pending') { + return c.json({ error: `Request already ${request.status}` }, 400); + } + + const body = await c.req.json<{ + action: 'accept' | 'reject'; + modifiedPermissions?: ConnectionPermissions; + }>(); + + if (body.action === 'reject') { + request.status = 'rejected'; + request.resolvedAt = Date.now(); + request.resolvedBy = claims.sub; + return c.json({ ok: true, status: 'rejected' }); + } + + if (body.action === 'accept') { + request.status = 'accepted'; + request.resolvedAt = Date.now(); + request.resolvedBy = claims.sub; + + const finalPerms = body.modifiedPermissions || request.permissions; + const connId = `conn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const now = Date.now(); + + // Load FROM space for DID + await loadCommunity(request.fromSlug); + const fromData = getDocumentData(request.fromSlug); + + const conn: SpaceConnection = { + id: connId, + localSlug: request.fromSlug, + remoteSlug: request.toSlug, + remoteDID: data.meta.ownerDID || undefined, + direction: request.direction, + flowKinds: request.flowKinds, + state: "active", + economicFlows: request.economicFlows, + delegations: request.delegations, + informationFlow: request.informationFlow, + permissions: finalPerms, + strength: request.strength, + label: request.label, + meta: request.meta, + proposedBy: request.proposedBy, + proposedAt: request.createdAt, + acceptedBy: claims.sub, + acceptedAt: now, + }; + + // Bilateral write: store in BOTH spaces' docs + addConnection(request.fromSlug, conn); + addConnection(request.toSlug, { + ...conn, + localSlug: request.toSlug, + remoteSlug: request.fromSlug, + remoteDID: fromData?.meta.ownerDID || undefined, + direction: invertDirection(request.direction), + }); + + return c.json({ ok: true, status: 'accepted', connection: conn }); + } + + return c.json({ error: "action must be 'accept' or 'reject'" }, 400); +}); + +// ── Derived membrane permeability data ── + +spaces.get("/:slug/membrane", async (c) => { + const slug = c.req.param("slug"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + const member = data.members?.[claims.sub]; + const isOwner = data.meta.ownerDID === claims.sub; + if (!isOwner && !member) { + return c.json({ error: "Must be a member to view membrane data" }, 403); + } + + const connections = Object.values(data.connections || {}); + const permeability = computeMembranePermeability(connections, slug); + + return c.json({ membrane: permeability }); +}); + // ══════════════════════════════════════════════════════════════════════════════ // ENCRYPTION API // ══════════════════════════════════════════════════════════════════════════════