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:
parent
c55e28a400
commit
15be495e91
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
553
server/spaces.ts
553
server/spaces.ts
|
|
@ -29,6 +29,11 @@ import {
|
|||
removeMember,
|
||||
DEFAULT_COMMUNITY_NEST_POLICY,
|
||||
addShapes,
|
||||
addConnection,
|
||||
updateConnection,
|
||||
removeConnection,
|
||||
getConnectionPolicy,
|
||||
updateConnectionPolicy,
|
||||
} from "./community-store";
|
||||
import type {
|
||||
SpaceVisibility,
|
||||
|
|
@ -38,6 +43,22 @@ import type {
|
|||
PendingNestRequest,
|
||||
NestRequestStatus,
|
||||
} from "./community-store";
|
||||
import type { FlowKind } from "../lib/layer-types";
|
||||
import type {
|
||||
ConnectionPolicy,
|
||||
ConnectionPermissions,
|
||||
ConnectionDirection,
|
||||
ConnectionState,
|
||||
SpaceConnection,
|
||||
PendingConnectionRequest,
|
||||
ConnectionRequestStatus,
|
||||
} from "../lib/connection-types";
|
||||
import {
|
||||
DEFAULT_PERSONAL_CONNECTION_POLICY,
|
||||
DEFAULT_COMMUNITY_CONNECTION_POLICY,
|
||||
invertDirection,
|
||||
computeMembranePermeability,
|
||||
} from "../lib/connection-types";
|
||||
import {
|
||||
verifyEncryptIDToken,
|
||||
extractToken,
|
||||
|
|
@ -103,13 +124,20 @@ export type CreateSpaceResult =
|
|||
* All creation endpoints should call this instead of duplicating logic.
|
||||
*/
|
||||
export async function createSpace(opts: CreateSpaceOpts): Promise<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
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Reference in New Issue