Merge branch 'dev'
This commit is contained in:
commit
19a467bf2c
|
|
@ -1,7 +1,13 @@
|
||||||
import * as Automerge from "@automerge/automerge";
|
import * as Automerge from "@automerge/automerge";
|
||||||
import type { FolkShape } from "./folk-shape";
|
import type { FolkShape } from "./folk-shape";
|
||||||
import type { OfflineStore } from "./offline-store";
|
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
|
// Shape data stored in Automerge document
|
||||||
export interface ShapeData {
|
export interface ShapeData {
|
||||||
|
|
@ -90,6 +96,10 @@ export interface CommunityDoc {
|
||||||
flows?: {
|
flows?: {
|
||||||
[id: string]: LayerFlow;
|
[id: string]: LayerFlow;
|
||||||
};
|
};
|
||||||
|
/** Bilateral typed connections to other spaces */
|
||||||
|
connections?: {
|
||||||
|
[connId: string]: SpaceConnection;
|
||||||
|
};
|
||||||
/** Currently active layer ID */
|
/** Currently active layer ID */
|
||||||
activeLayerId?: string;
|
activeLayerId?: string;
|
||||||
/** Layer view mode: flat (tabs) or stack (side view) */
|
/** 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
|
* Disconnect from server
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,11 @@ import {
|
||||||
packEncrypted,
|
packEncrypted,
|
||||||
unpackEncrypted,
|
unpackEncrypted,
|
||||||
} from "./local-first/encryption-utils";
|
} 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";
|
const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
|
||||||
|
|
||||||
|
|
@ -143,6 +148,7 @@ export interface CommunityMeta {
|
||||||
description?: string;
|
description?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
nestPolicy?: NestPolicy;
|
nestPolicy?: NestPolicy;
|
||||||
|
connectionPolicy?: ConnectionPolicy;
|
||||||
encrypted?: boolean;
|
encrypted?: boolean;
|
||||||
encryptionKeyId?: string;
|
encryptionKeyId?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -179,6 +185,9 @@ export interface CommunityDoc {
|
||||||
nestedSpaces: {
|
nestedSpaces: {
|
||||||
[refId: string]: SpaceRef;
|
[refId: string]: SpaceRef;
|
||||||
};
|
};
|
||||||
|
connections: {
|
||||||
|
[connId: string]: SpaceConnection;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-peer sync state for Automerge
|
// 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
|
* Load community document from disk
|
||||||
*/
|
*/
|
||||||
|
|
@ -267,6 +290,8 @@ export async function loadCommunity(slug: string): Promise<Automerge.Doc<Communi
|
||||||
doc = migrateParticipantToMember(doc, slug);
|
doc = migrateParticipantToMember(doc, slug);
|
||||||
// Runtime migration: normalize legacy visibility values
|
// Runtime migration: normalize legacy visibility values
|
||||||
doc = migrateVisibility(doc, slug);
|
doc = migrateVisibility(doc, slug);
|
||||||
|
// Runtime migration: add connections map
|
||||||
|
doc = migrateAddConnections(doc, slug);
|
||||||
communities.set(slug, doc);
|
communities.set(slug, doc);
|
||||||
return doc;
|
return doc;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -287,6 +312,8 @@ export async function loadCommunity(slug: string): Promise<Automerge.Doc<Communi
|
||||||
doc = migrateParticipantToMember(doc, slug);
|
doc = migrateParticipantToMember(doc, slug);
|
||||||
// Runtime migration: normalize legacy visibility values
|
// Runtime migration: normalize legacy visibility values
|
||||||
doc = migrateVisibility(doc, slug);
|
doc = migrateVisibility(doc, slug);
|
||||||
|
// Runtime migration: add connections map
|
||||||
|
doc = migrateAddConnections(doc, slug);
|
||||||
communities.set(slug, doc);
|
communities.set(slug, doc);
|
||||||
// Save as Automerge binary
|
// Save as Automerge binary
|
||||||
await saveCommunity(slug);
|
await saveCommunity(slug);
|
||||||
|
|
@ -319,6 +346,10 @@ function jsonToAutomerge(data: CommunityDoc): Automerge.Doc<CommunityDoc> {
|
||||||
for (const [refId, ref] of Object.entries(data.nestedSpaces || {})) {
|
for (const [refId, ref] of Object.entries(data.nestedSpaces || {})) {
|
||||||
d.nestedSpaces[refId] = { ...ref };
|
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;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
@ -374,10 +405,11 @@ export async function createCommunity(
|
||||||
name: string,
|
name: string,
|
||||||
slug: string,
|
slug: string,
|
||||||
ownerDID: string | null = null,
|
ownerDID: string | null = null,
|
||||||
visibility: SpaceVisibility = 'public',
|
visibility: SpaceVisibility = 'private',
|
||||||
options?: {
|
options?: {
|
||||||
enabledModules?: string[];
|
enabledModules?: string[];
|
||||||
nestPolicy?: NestPolicy;
|
nestPolicy?: NestPolicy;
|
||||||
|
connectionPolicy?: ConnectionPolicy;
|
||||||
description?: string;
|
description?: string;
|
||||||
},
|
},
|
||||||
): Promise<Automerge.Doc<CommunityDoc>> {
|
): Promise<Automerge.Doc<CommunityDoc>> {
|
||||||
|
|
@ -396,12 +428,16 @@ export async function createCommunity(
|
||||||
if (options?.nestPolicy) {
|
if (options?.nestPolicy) {
|
||||||
d.meta.nestPolicy = options.nestPolicy;
|
d.meta.nestPolicy = options.nestPolicy;
|
||||||
}
|
}
|
||||||
|
if (options?.connectionPolicy) {
|
||||||
|
d.meta.connectionPolicy = options.connectionPolicy;
|
||||||
|
}
|
||||||
if (options?.description) {
|
if (options?.description) {
|
||||||
d.meta.description = options.description;
|
d.meta.description = options.description;
|
||||||
}
|
}
|
||||||
d.shapes = {};
|
d.shapes = {};
|
||||||
d.members = {};
|
d.members = {};
|
||||||
d.nestedSpaces = {};
|
d.nestedSpaces = {};
|
||||||
|
d.connections = {};
|
||||||
// If owner is known, add them as admin member
|
// If owner is known, add them as admin member
|
||||||
if (ownerDID) {
|
if (ownerDID) {
|
||||||
d.members[ownerDID] = {
|
d.members[ownerDID] = {
|
||||||
|
|
@ -1032,3 +1068,91 @@ export async function findNestedIn(sourceSlug: string): Promise<Array<{ slug: st
|
||||||
|
|
||||||
return results;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -354,7 +354,7 @@ app.post("/api/communities", async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await c.req.json<{ name?: string; slug?: string; visibility?: SpaceVisibility }>();
|
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"];
|
const validVisibilities: SpaceVisibility[] = ["public", "permissioned", "private"];
|
||||||
if (visibility && !validVisibilities.includes(visibility)) return c.json({ error: "Invalid visibility" }, 400);
|
if (visibility && !validVisibilities.includes(visibility)) return c.json({ error: "Invalid visibility" }, 400);
|
||||||
|
|
|
||||||
553
server/spaces.ts
553
server/spaces.ts
|
|
@ -29,6 +29,11 @@ import {
|
||||||
removeMember,
|
removeMember,
|
||||||
DEFAULT_COMMUNITY_NEST_POLICY,
|
DEFAULT_COMMUNITY_NEST_POLICY,
|
||||||
addShapes,
|
addShapes,
|
||||||
|
addConnection,
|
||||||
|
updateConnection,
|
||||||
|
removeConnection,
|
||||||
|
getConnectionPolicy,
|
||||||
|
updateConnectionPolicy,
|
||||||
} from "./community-store";
|
} from "./community-store";
|
||||||
import type {
|
import type {
|
||||||
SpaceVisibility,
|
SpaceVisibility,
|
||||||
|
|
@ -38,6 +43,22 @@ import type {
|
||||||
PendingNestRequest,
|
PendingNestRequest,
|
||||||
NestRequestStatus,
|
NestRequestStatus,
|
||||||
} from "./community-store";
|
} 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 {
|
import {
|
||||||
verifyEncryptIDToken,
|
verifyEncryptIDToken,
|
||||||
extractToken,
|
extractToken,
|
||||||
|
|
@ -103,13 +124,20 @@ export type CreateSpaceResult =
|
||||||
* All creation endpoints should call this instead of duplicating logic.
|
* All creation endpoints should call this instead of duplicating logic.
|
||||||
*/
|
*/
|
||||||
export async function createSpace(opts: CreateSpaceOpts): Promise<CreateSpaceResult> {
|
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 (!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 (!/^[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 };
|
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 specified, update the community doc
|
||||||
if (enabledModules) {
|
if (enabledModules) {
|
||||||
|
|
@ -159,6 +187,10 @@ function buildLifecycleContext(slug: string, doc: ReturnType<typeof getDocumentD
|
||||||
const nestRequests = new Map<string, PendingNestRequest>();
|
const nestRequests = new Map<string, PendingNestRequest>();
|
||||||
let nestRequestCounter = 0;
|
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) ──
|
// ── In-memory access requests (move to DB later) ──
|
||||||
interface AccessRequest {
|
interface AccessRequest {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -271,7 +303,7 @@ spaces.post("/", async (c) => {
|
||||||
enabledModules?: string[];
|
enabledModules?: string[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { name, slug, visibility = "public", enabledModules } = body;
|
const { name, slug, visibility = "private", enabledModules } = body;
|
||||||
|
|
||||||
const validVisibilities: SpaceVisibility[] = ["public", "permissioned", "private"];
|
const validVisibilities: SpaceVisibility[] = ["public", "permissioned", "private"];
|
||||||
if (visibility && !validVisibilities.includes(visibility)) {
|
if (visibility && !validVisibilities.includes(visibility)) {
|
||||||
|
|
@ -490,6 +522,9 @@ spaces.delete("/admin/:slug", async (c) => {
|
||||||
for (const [id, req] of nestRequests) {
|
for (const [id, req] of nestRequests) {
|
||||||
if (req.sourceSlug === slug || req.targetSlug === slug) nestRequests.delete(id);
|
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
|
// Clean up EncryptID space_members
|
||||||
try {
|
try {
|
||||||
|
|
@ -593,6 +628,9 @@ spaces.delete("/:slug", async (c) => {
|
||||||
for (const [id, req] of nestRequests) {
|
for (const [id, req] of nestRequests) {
|
||||||
if (req.sourceSlug === slug || req.targetSlug === slug) nestRequests.delete(id);
|
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);
|
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);
|
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
|
// ENCRYPTION API
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue