Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-03 15:05:50 -08:00
commit 19a467bf2c
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
// ══════════════════════════════════════════════════════════════════════════════