Merge branch 'dev'
This commit is contained in:
commit
19a467bf2c
|
|
@ -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