feat: add SpaceConnection primitive + default visibility to private

Introduces bilateral typed inter-space connections (economic, trust,
data, governance, resource) stored in both spaces' Automerge docs.
Includes connection policy, approval flow, membrane permeability
endpoint, and full CRUD API. Also changes default space visibility
from public to private for all user-facing creation paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-03 15:05:23 -08:00
parent c55e28a400
commit 15be495e91
5 changed files with 987 additions and 6 deletions

View File

@ -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<SpaceConnection>): 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<string, unknown>)[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
*/

239
lib/connection-types.ts Normal file
View File

@ -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<ConnectionState, string> = {
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<string, unknown>;
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<string, unknown>;
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 };
}

View File

@ -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<CommunityDoc>,
slug: string,
): Automerge.Doc<CommunityDoc> {
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<Automerge.Doc<Communi
doc = migrateParticipantToMember(doc, slug);
// Runtime migration: normalize legacy visibility values
doc = migrateVisibility(doc, slug);
// Runtime migration: add connections map
doc = migrateAddConnections(doc, slug);
communities.set(slug, doc);
return doc;
} catch (e) {
@ -287,6 +312,8 @@ export async function loadCommunity(slug: string): Promise<Automerge.Doc<Communi
doc = migrateParticipantToMember(doc, slug);
// Runtime migration: normalize legacy visibility values
doc = migrateVisibility(doc, slug);
// Runtime migration: add connections map
doc = migrateAddConnections(doc, slug);
communities.set(slug, doc);
// Save as Automerge binary
await saveCommunity(slug);
@ -319,6 +346,10 @@ function jsonToAutomerge(data: CommunityDoc): Automerge.Doc<CommunityDoc> {
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<Automerge.Doc<CommunityDoc>> {
@ -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<Array<{ slug: st
return results;
}
// ── Space Connections CRUD ──
/**
* Add a SpaceConnection to a space's doc
*/
export function addConnection(slug: string, conn: SpaceConnection): void {
const doc = communities.get(slug);
if (!doc) return;
const newDoc = Automerge.change(doc, `Add connection ${conn.id}${conn.remoteSlug}`, (d) => {
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<SpaceConnection>,
): 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<string, unknown>)[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<ConnectionPolicy>): 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;
}

View File

@ -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);

View File

@ -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<CreateSpaceResult> {
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<typeof getDocumentD
const nestRequests = new Map<string, PendingNestRequest>();
let nestRequestCounter = 0;
// ── In-memory pending connection requests (move to DB later) ──
const connectionRequests = new Map<string, PendingConnectionRequest>();
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<Partial<ConnectionPolicy>>();
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<ConnectionPermissions>;
strength?: number;
label?: string;
economicFlows?: SpaceConnection['economicFlows'];
delegations?: SpaceConnection['delegations'];
informationFlow?: SpaceConnection['informationFlow'];
meta?: Record<string, unknown>;
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<ConnectionPermissions>;
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<SpaceConnection> = {
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<SpaceConnection> = {
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
// ══════════════════════════════════════════════════════════════════════════════