feat: implement nested spaces architecture with permission cascade

Spaces are now nestable — any space can embed references to other spaces
via SpaceRef, with a permission cascade model (most-restrictive-wins at
each nesting boundary). Every EncryptID registration auto-provisions a
sovereign space at <username>.rspace.online with consent-based nesting
controls.

Key additions:
- NestPolicy per space (open/members/approval/closed consent levels)
- SpaceRef CRUD with allowlist/blocklist, permission ceiling enforcement
- Approval flow for nest requests with admin review
- Reverse lookup (nested-in) so owners see where their space appears
- Source space admins can always revoke (sovereignty guarantee)
- cascadePermissions() for multi-depth permission intersection
- Client-side types for nested space rendering
- Full spec at docs/SPACE-ARCHITECTURE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-24 18:26:50 -08:00
parent 86fc403138
commit 91cb68a09f
5 changed files with 1604 additions and 2 deletions

819
docs/SPACE-ARCHITECTURE.md Normal file
View File

@ -0,0 +1,819 @@
# Space Architecture: Nested Spaces & Permission Cascade
*Version 0.1 — February 2026*
---
## Core Principle: A Space Is a Space Is a Space
There is no `type` field. There is no distinction between "personal" and "community" at the schema level. A space is the singular primitive. What varies between spaces is:
- **Who owns it** — the `ownerDID` and admin set
- **Who can see into it** — visibility + membership
- **What it nests** — references to other spaces
- **What apps are deployed on it** — enabled modules
A space where one person is the sole admin with `members_only` visibility *behaves* like a personal space. A space with many members and `public_read` visibility *behaves* like a community. But they are the same data structure, the same Automerge document, the same API surface. This uniformity is what makes arbitrary nesting depth work without special cases.
---
## `<username>.rspace.online` — Identity-Provisioned Spaces
Every EncryptID registration auto-provisions a space:
```
Registration → EncryptID created → Space provisioned at <username>.rspace.online
```
This space is not special. It's a space where:
- The user is the sole `admin`
- Visibility defaults to `members_only` (sovereign by default)
- The user's chosen apps are enabled
- It exists at a predictable, memorable URL
### What this gives the user
1. **A sovereign home on the network**`alice.rspace.online` is Alice's address. Her canvas, her apps, her data.
2. **An app deployment surface** — Alice enables rWallet, rVote, rNotes on her space. They're live immediately.
3. **The root of her sharing tree** — When Alice shares content into a DAO, it originates from her space. When she revokes, it disappears from the DAO. Her space is always the canonical source.
4. **A URL she can give someone** — "Find me at `alice.rspace.online`." The equivalent of a homepage, but living.
### What this does NOT mean
- It is NOT a different kind of space
- It does NOT have special server-side logic beyond auto-provisioning
- It CAN be reconfigured — Alice could make it `public`, add members, change its name
- Other spaces CAN also be provisioned at `<anything>.rspace.online` — the subdomain is just a slug
---
## Nested Spaces
Nesting is the mechanism for both sharing and composition. A space can reference other spaces, which appear as embedded regions on its canvas.
### SpaceRef: The Nesting Primitive
```typescript
interface SpaceRef {
id: string; // unique ref ID within this space
sourceSlug: string; // the nested space's slug
sourceDID?: string; // who created this nesting (provenance)
// What to show from the nested space
filter?: SpaceRefFilter;
// Where it appears on the parent canvas
x: number;
y: number;
width: number;
height: number;
rotation: number;
// What the parent space's members can do with nested content
permissions: NestPermissions;
// Display
collapsed?: boolean; // render as a card summary vs. full canvas
label?: string; // override display name
}
interface SpaceRefFilter {
// Show all shapes, or a subset?
shapeTypes?: string[]; // only these shape types (e.g., ['folk-note', 'folk-budget'])
shapeIds?: string[]; // only these specific shapes
tags?: string[]; // only shapes with these tags (future)
moduleIds?: string[]; // only content from these modules
}
interface NestPermissions {
read: boolean; // can members of the parent space see this nested content?
write: boolean; // can they edit shapes in the nested space?
addShapes: boolean; // can they add new shapes to the nested space?
deleteShapes: boolean; // can they delete shapes from the nested space?
reshare: boolean; // can they nest this space further into other spaces?
expiry?: number; // unix timestamp — auto-revoke after this time
}
```
### How Nesting Works at the CRDT Level
Each space's `CommunityDoc` gains a `nestedSpaces` map alongside its existing `shapes` map:
```typescript
interface CommunityDoc {
meta: CommunityMeta;
shapes: { [id: string]: ShapeData };
members: { [did: string]: SpaceMember };
nestedSpaces: { [refId: string]: SpaceRef }; // NEW
}
```
When a client renders a space, it:
1. Renders all local `shapes` as before
2. For each entry in `nestedSpaces`:
a. Opens a secondary WebSocket to the nested space's sync endpoint
b. Receives that space's shapes (filtered by `SpaceRefFilter` if set)
c. Renders them inside the `SpaceRef`'s bounding box on the canvas
d. Applies `NestPermissions` — e.g., if `write: false`, nested shapes are non-editable
This is the same mechanism planned for `folk-canvas` (Task-45), but elevated from a shape type to a first-class document field. The `folk-canvas` shape becomes the *renderer* for `SpaceRef` entries.
### Nesting Depth
There is no hard limit on nesting depth. A space can nest a space that nests a space. Each boundary applies its own `NestPermissions`, and the **permission cascade** (see below) ensures the most restrictive permission always wins.
```
alice.rspace.online
└── nestedSpaces:
├── ref-1 → dao.rspace.online (read + write)
│ └── nestedSpaces:
│ ├── ref-a → working-group (read + write)
│ │ └── nestedSpaces:
│ │ └── ref-x → alice.rspace.online/project-x (read-only)
│ │ ↑ circular reference is fine — alice
│ │ shared a filtered view of her own
│ │ space back into the working group
│ └── ref-b → treasury (read-only, rWallet shapes only)
└── ref-2 → art-collab.rspace.online (read-only)
```
### Circular References
A space nesting itself (directly or through a chain) is permitted. The renderer detects cycles and displays the nested instance as a static snapshot rather than a live recursive embed, preventing infinite recursion. The canonical data still syncs — only the rendering is bounded.
---
## Permission Cascade Model
### The Rule: Most Restrictive Wins (Intersection)
When a space is nested, the effective permissions at any depth are the **intersection** of all permissions along the nesting chain. No downstream nest can grant more access than its parent granted.
### Formal Definition
```
EffectivePermissions(depth N) = NestPermissions[1] ∩ NestPermissions[2] ∩ ... ∩ NestPermissions[N]
```
Where `∩` for each boolean field is logical AND:
```typescript
function cascadePermissions(chain: NestPermissions[]): NestPermissions {
return {
read: chain.every(p => p.read),
write: chain.every(p => p.write),
addShapes: chain.every(p => p.addShapes),
deleteShapes: chain.every(p => p.deleteShapes),
reshare: chain.every(p => p.reshare),
expiry: Math.min(...chain.filter(p => p.expiry).map(p => p.expiry!)) || undefined,
};
}
```
### Examples
**Example 1: Permission narrowing across depth**
```
Alice's space
└── nests DAO space (read: ✓, write: ✓, reshare: ✓)
└── nests Working Group (read: ✓, write: ✓, reshare: ✗)
└── nests Bob's space (read: ✓, write: ✗)
Effective permissions at each level:
Level 1 (DAO in Alice's view): read: ✓ write: ✓ reshare: ✓
Level 2 (WG in Alice's → DAO view): read: ✓ write: ✓ reshare: ✗ ← narrowed
Level 3 (Bob in Alice → DAO → WG): read: ✓ write: ✗ reshare: ✗ ← narrowed further
```
**Example 2: Revocation propagates**
```
Alice revokes the DAO nest from her space
→ DAO content disappears from Alice's canvas
→ Working Group content (nested inside DAO) also disappears
→ Bob's content (nested inside WG) also disappears
```
One action, clean propagation. Alice doesn't need to know the nesting depth.
**Example 3: reshare: false stops propagation**
```
Alice nests her project into DAO space with reshare: false
→ DAO members can see and interact with Alice's project
→ DAO members CANNOT nest Alice's project further into other spaces
→ If they try, the server rejects the SpaceRef creation
```
This is Alice's control over how far her content travels.
### Permission Enforcement Points
| Point | What's checked | Who checks |
|-------|---------------|------------|
| **SpaceRef creation** | 1. Source space's `nestPolicy.consent` — is this requester allowed? 2. Source space's `nestPolicy.blocklist/allowlist` 3. Requested permissions capped by `nestPolicy.defaultPermissions` 4. Does the creator have `reshare` permission if nesting an already-nested space? 5. Does the creator have `admin` or `moderator` role in the target? | Server, at `POST /api/spaces/:slug/nest` or approval flow |
| **WebSocket upgrade** | Does the connecting user have access to this space AND to all nested spaces in the chain? | Server, at WS handshake via `authenticateWSUpgrade()` |
| **Shape write** | Is the effective `write` permission `true` across the full nesting chain? Does the user's role in the *source* space permit this action? | Server, in WS message handler |
| **Shape read** | Is the effective `read` permission `true`? Does the user meet the source space's visibility requirements? | Server, when syncing nested space data |
| **Revocation** | Only the `SpaceRef` creator, an admin of the nesting space, or an admin of the *source* space can remove a `SpaceRef`. Source space admins can always revoke — this is the "pull the plug" guarantee. | Server, at `DELETE /api/spaces/:slug/nest/:refId` |
### Dual Authority: Nesting Permission + Space Role
A user's effective capability in a nested space is determined by TWO factors:
1. **The NestPermissions chain** — what the nesting allows at a structural level
2. **The user's role in the source space** — what the user is allowed to do in that space independently
Both must permit the action. If the nesting grants `write: true` but the user is only a `viewer` in the source space, they still can't write. If the user is an `admin` in the source space but the nesting says `write: false`, they still can't write *through the nest*.
```typescript
function canPerformAction(
action: 'read' | 'write' | 'addShapes' | 'deleteShapes',
nestChain: NestPermissions[],
userRoleInSource: SpaceRole,
): boolean {
const cascaded = cascadePermissions(nestChain);
// Nesting must allow it
if (!cascaded[action]) return false;
// User's role in the source space must also allow it
const requiredRole = actionToMinimumRole(action);
return roleRank(userRoleInSource) >= roleRank(requiredRole);
}
function actionToMinimumRole(action: string): SpaceRole {
switch (action) {
case 'read': return 'viewer';
case 'write': return 'participant';
case 'addShapes': return 'participant';
case 'deleteShapes': return 'moderator';
default: return 'admin';
}
}
```
---
## Identity-Space Binding
### Auto-Provisioning at Registration
When `POST /api/register/complete` succeeds in EncryptID, the server also:
1. Calls `createCommunity(username, username, did, 'members_only')`
2. Fires `onSpaceCreate(username)` for all registered modules
3. Enables the user's selected default modules (or a sensible default set)
4. The space is immediately available at `<username>.rspace.online`
```typescript
// In src/encryptid/server.ts — after successful registration
// Auto-provision user's space
const userSpace = await createCommunity(
username, // name
username, // slug (becomes <username>.rspace.online)
did, // ownerDID
'members_only', // sovereign by default
);
// Enable default modules
await setEnabledModules(username, DEFAULT_USER_MODULES);
// Fire module hooks
for (const mod of getAllModules()) {
if (mod.onSpaceCreate) {
await mod.onSpaceCreate(username);
}
}
```
### Default Module Set
New users get a sensible starting set. They can add or remove modules at any time.
```typescript
const DEFAULT_USER_MODULES = [
'canvas', // the canvas itself
'notes', // note-taking
'files', // file storage
'wallet', // identity-linked wallet
];
```
### Space Settings Stored in Meta
```typescript
interface CommunityMeta {
name: string;
slug: string;
createdAt: string;
visibility: SpaceVisibility;
ownerDID: string | null;
enabledModules: string[]; // NEW — which modules are active in this space
description?: string; // NEW — optional space description
avatar?: string; // NEW — optional space avatar/icon URL
}
```
---
## Nest Consent & Notification
### The Problem
Without consent controls, anyone who can access a space could nest it into another space without the owner knowing. The `reshare` flag on `NestPermissions` controls whether *already-nested* content can be nested further — but it doesn't govern the *initial* nesting. The space owner needs to control whether their space can be nested at all, by whom, and whether they're notified.
### NestPolicy: Per-Space Inbound Nesting Rules
Every space has a `nestPolicy` in its metadata that governs how *other* spaces can nest it:
```typescript
interface NestPolicy {
// Who can nest this space into their space?
consent: 'open' | 'members' | 'approval' | 'closed';
// Should the space owner/admins be notified of nesting events?
notifications: NestNotifications;
// Default permissions granted when this space is nested
// (the nester can request less, but not more)
defaultPermissions: NestPermissions;
// Spaces that are always allowed to nest this one (bypass consent)
allowlist?: string[]; // slugs
// Spaces that are never allowed to nest this one
blocklist?: string[]; // slugs
}
interface NestNotifications {
onNestRequest: boolean; // notify when someone requests to nest this space
onNestCreated: boolean; // notify when nesting is established
onNestRevoked: boolean; // notify when a nesting is removed
onReshare: boolean; // notify when nested content is re-nested further
channel: 'inbox' | 'email' | 'both'; // where to send notifications
}
```
### Consent Levels
| Level | Behavior | Default for |
|-------|----------|-------------|
| `open` | Anyone with access to the space can nest it. No approval needed. | Public community spaces |
| `members` | Only members of this space can nest it into other spaces. | Collaborative workgroups |
| `approval` | Nesting creates a pending request. Space admin must approve before the nest becomes active. | Auto-provisioned user spaces |
| `closed` | No one can nest this space. It exists only at its own URL. | Sensitive/private spaces |
### How Consent Flows
**`open` — no friction:**
```
Bob nests alice.rspace.online into dao.rspace.online
→ SpaceRef created immediately
→ Alice notified (if notifications.onNestCreated: true)
```
**`members` — membership gate:**
```
Bob attempts to nest alice.rspace.online into dao.rspace.online
→ Server checks: is Bob a member of alice.rspace.online?
→ YES → SpaceRef created, Alice notified
→ NO → 403 Forbidden
```
**`approval` — request/approve flow:**
```
Bob requests to nest alice.rspace.online into dao.rspace.online
→ Server creates a PendingNestRequest (not a live SpaceRef yet)
→ Alice notified via her preferred channel
→ Alice reviews: sees who's requesting, which space, what permissions
→ Alice approves → SpaceRef created, Bob notified
→ Alice denies → request deleted, Bob notified
→ Alice can also modify the permissions before approving
(e.g., downgrade write: true to write: false)
```
**`closed` — hard block:**
```
Bob attempts to nest alice.rspace.online
→ 403 Forbidden, no request created
```
### PendingNestRequest
```typescript
interface PendingNestRequest {
id: string;
sourceSlug: string; // the space being nested (e.g., alice)
targetSlug: string; // where it's being nested into (e.g., dao)
requestedBy: string; // DID of requester
requestedPermissions: NestPermissions;
message?: string; // optional note from requester ("sharing my project notes")
status: 'pending' | 'approved' | 'denied';
createdAt: number;
resolvedAt?: number;
resolvedBy?: string; // DID of admin who approved/denied
modifiedPermissions?: NestPermissions; // if admin adjusted before approving
}
```
### Default Permissions Cap
The `defaultPermissions` in `NestPolicy` acts as a ceiling. When someone nests the space, they can request permissions *up to* this level, but not beyond:
```
Alice's nestPolicy.defaultPermissions:
read: true, write: false, addShapes: false, deleteShapes: false, reshare: false
Bob requests to nest Alice's space with write: true
→ Server caps it: write: false (Alice's default ceiling)
→ Bob's SpaceRef gets read: true, write: false
```
This means Alice can set her policy once — "my space can be read but not written to when nested" — and every future nesting respects it without her reviewing each request (unless she wants to, via `approval` consent).
### Allowlist & Blocklist
For spaces where `consent: 'approval'` is too much friction for trusted relationships but `open` is too permissive:
- **Allowlist**: These slugs bypass consent entirely. If `climate-dao` is on Alice's allowlist, the DAO can nest her space without approval.
- **Blocklist**: These slugs are always denied, even if consent is `open`. Overrides everything.
Blocklist takes priority over allowlist. If a slug appears in both, it's blocked.
### Notifications in Practice
Notifications are delivered through rSpace's existing channels:
- **`inbox`**: Appears in the user's rSpace inbox (the rInbox module). Non-intrusive, checked at the user's pace.
- **`email`**: Sent to the user's EncryptID-linked email via Mailcow. For important events.
- **`both`**: Both channels.
Example notification:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔗 Nest Request
@bob wants to nest your space into climate-dao.rspace.online
Requested permissions:
Read: ✓ Write: ✗ Reshare: ✗
Message: "Sharing your research notes with the funding working group"
[Approve] [Approve with changes] [Deny]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
### Updated CommunityMeta
```typescript
interface CommunityMeta {
name: string;
slug: string;
createdAt: string;
visibility: SpaceVisibility;
ownerDID: string | null;
enabledModules: string[];
description?: string;
avatar?: string;
encrypted?: boolean;
encryptionKeyId?: string;
nestPolicy: NestPolicy; // NEW — governs inbound nesting
}
```
### Sensible Defaults
```typescript
// Auto-provisioned user space: sovereign by default
const DEFAULT_USER_NEST_POLICY: NestPolicy = {
consent: 'approval',
notifications: {
onNestRequest: true,
onNestCreated: true,
onNestRevoked: false,
onReshare: true,
channel: 'inbox',
},
defaultPermissions: {
read: true,
write: false,
addShapes: false,
deleteShapes: false,
reshare: false,
},
};
// Newly created community space: open collaboration
const DEFAULT_COMMUNITY_NEST_POLICY: NestPolicy = {
consent: 'members',
notifications: {
onNestRequest: false,
onNestCreated: true,
onNestRevoked: true,
onReshare: false,
channel: 'inbox',
},
defaultPermissions: {
read: true,
write: true,
addShapes: true,
deleteShapes: false,
reshare: true,
},
};
```
Note: these are not enforced by a `type` field. The auto-provisioned space gets `DEFAULT_USER_NEST_POLICY` at creation time, and a manually created space gets `DEFAULT_COMMUNITY_NEST_POLICY` — but the user can change either to anything. A "personal" space could be set to `open` and a "community" space could be set to `closed`. The defaults just encode sensible starting points.
### API Surface for Consent
```
GET /api/spaces/:slug/nest-policy Get space's nest policy
PATCH /api/spaces/:slug/nest-policy Update nest policy (admin only)
POST /api/spaces/:slug/nest-requests Create a pending nest request
GET /api/spaces/:slug/nest-requests List pending requests (admin only)
GET /api/spaces/:slug/nest-requests/:id Get a specific request
PATCH /api/spaces/:slug/nest-requests/:id Approve/deny (admin only)
```
When a request is approved, the server automatically creates the `SpaceRef` in the target space's `nestedSpaces` map with the (potentially modified) permissions.
---
## How Sharing Actually Works
### Scenario: Alice shares her project notes into a DAO
1. Alice has shapes on her canvas at `alice.rspace.online` — notes, a budget, a timeline.
2. Alice is a member of `climate-dao.rspace.online`.
3. Alice opens her space settings or right-clicks a group of shapes → "Nest into space..."
4. She selects `climate-dao.rspace.online` and sets permissions:
- `read: true` — DAO members can see the shapes
- `write: false` — only Alice can edit them (they're her canonical data)
- `reshare: false` — the DAO can't nest these further
5. Server creates a `SpaceRef` in `climate-dao`'s `nestedSpaces` map, pointing to `alice` with the chosen filter and permissions.
6. DAO members visiting `climate-dao.rspace.online/canvas` now see Alice's shapes rendered inside a bordered region on the canvas.
7. The shapes are live — if Alice updates them on her space, the DAO sees the update in real time.
### Scenario: Alice revokes
1. Alice opens her space settings → sees "Shared into: climate-dao"
2. Clicks revoke.
3. Server removes the `SpaceRef` from `climate-dao`'s `nestedSpaces` map.
4. The shapes vanish from the DAO canvas. The DAO's Automerge doc is unchanged — it never contained Alice's shapes directly, only a reference.
5. Alice's shapes remain on her own canvas, untouched.
### Scenario: DAO nests a working group
1. `climate-dao.rspace.online` creates a new space: `climate-dao-wg-funding` (or it could be at `funding.climate-dao.rspace.online` if wildcard subdomains are supported)
2. The DAO admin nests the working group into the DAO space with `read: true, write: true, reshare: true`
3. Working group members can also be members of the DAO — their role in each space is independent
4. Content created in the working group space appears on both the working group canvas AND the DAO canvas (via the nest)
5. Alice's content that was nested into the DAO with `reshare: false` does NOT appear in the working group — `reshare: false` blocked further nesting
### Scenario: Browsing nested spaces
When viewing a canvas with nested spaces, the user sees:
```
┌─────────────────────────────────────────────────────────────┐
│ climate-dao.rspace.online/canvas │
│ │
│ [folk-note] [folk-budget] [folk-poll] │
│ │
│ ┌──────────────────────────────┐ │
│ │ ≡ alice.rspace.online │ ← nested space border │
│ │ (shared by @alice, read-only) │
│ │ │ │
│ │ [folk-note] [folk-timeline] │ │
│ │ │ │
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ ≡ funding working group │ │
│ │ (read + write) │ │
│ │ │ │
│ │ [folk-budget] [folk-note] │ │
│ │ │ │
│ └──────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
Each nested region is:
- Visually bounded (border, header with source space name)
- Scrollable/zoomable independently (it's a canvas within a canvas)
- Badged with the source and permission level
- Clickable to "enter" — navigating to the full nested space
---
## Encryption & Nested Spaces
### At-Rest Encryption
Each space's Automerge document can optionally be encrypted at rest using the owner's EncryptID Layer 2 AES-256 key:
```typescript
interface CommunityMeta {
// ... existing fields ...
encrypted: boolean; // is this space's document encrypted at rest?
encryptionKeyId?: string; // which key was used (for key rotation)
}
```
For the user's own space, this is straightforward — encrypt with their derived key, decrypt on their device.
### Sharing Encrypted Content via Nesting
When an encrypted space is nested into another space, the nested content must be readable by the parent space's members. Two approaches:
**Approach A: Re-encryption at the boundary**
The space owner re-encrypts the shared subset of shapes with a shared key that the parent space's members can access. This key is distributed via the parent space's membership (each member's public key encrypts a copy of the shared key).
**Approach B: Plaintext projection**
The server (which must hold decryption capability for sync purposes, or the owner's client does) creates a plaintext projection of the filtered shapes and serves that to the parent space's WebSocket. The canonical encrypted version stays in the source space.
**Approach C: Client-side decryption with delegated keys**
The source space owner wraps their content key with each authorized viewer's public key. Authorized clients decrypt on their device. This is true E2E but adds complexity to the nesting chain.
**Recommendation:** Start with Approach B (plaintext projection, server-mediated) for simplicity. Graduate to Approach C (delegated keys) for spaces that require E2E encryption. The `encrypted` flag on `CommunityMeta` determines which path is used.
---
## Schema Changes Summary
### CommunityDoc (Automerge)
```typescript
interface CommunityDoc {
meta: CommunityMeta;
shapes: { [id: string]: ShapeData };
members: { [did: string]: SpaceMember };
nestedSpaces: { [refId: string]: SpaceRef }; // NEW
}
```
### CommunityMeta
```typescript
interface CommunityMeta {
name: string;
slug: string;
createdAt: string;
visibility: SpaceVisibility;
ownerDID: string | null;
enabledModules: string[]; // NEW
description?: string; // NEW
avatar?: string; // NEW
encrypted?: boolean; // NEW
encryptionKeyId?: string; // NEW
}
```
### New Types
```typescript
interface SpaceRef {
id: string;
sourceSlug: string;
sourceDID?: string;
filter?: SpaceRefFilter;
x: number;
y: number;
width: number;
height: number;
rotation: number;
permissions: NestPermissions;
collapsed?: boolean;
label?: string;
createdAt: number;
createdBy: string; // DID of who created this nesting
}
interface SpaceRefFilter {
shapeTypes?: string[];
shapeIds?: string[];
tags?: string[];
moduleIds?: string[];
}
interface NestPermissions {
read: boolean;
write: boolean;
addShapes: boolean;
deleteShapes: boolean;
reshare: boolean;
expiry?: number;
}
```
### No Changes To
- `ShapeData` — shapes remain the same. They don't know or care whether they're being viewed directly or through a nest.
- `SpaceMember` — membership is per-space, unchanged.
- `SpaceVisibility` — the four levels remain. They govern direct access to the space; nesting adds a second access path that still must satisfy visibility.
---
## API Surface
### New Endpoints
```
POST /api/spaces/:slug/nest Create a SpaceRef (nest a space)
GET /api/spaces/:slug/nest List all nested space refs
GET /api/spaces/:slug/nest/:refId Get a specific SpaceRef
PATCH /api/spaces/:slug/nest/:refId Update SpaceRef (permissions, filter, position)
DELETE /api/spaces/:slug/nest/:refId Remove a SpaceRef (un-nest)
GET /api/spaces/:slug/nested-in List all spaces this space is nested into
(so the owner can see where their content appears)
```
### WebSocket Protocol Additions
```
Message Type Direction Purpose
─────────────────────────────────────────────────────────────
nest-sync Server→Client Sync data from a nested space
nest-subscribe Client→Server Subscribe to a specific nested space's updates
nest-unsubscribe Client→Server Unsubscribe from a nested space
nest-permission-check Client→Server Check effective permissions for an action in a nest
```
### Modified Endpoints
```
POST /api/register/complete Now also provisions <username>.rspace.online
GET /api/spaces Returns user's own space first, pinned
```
---
## Implementation Phases
### Phase 1: Schema & Auto-Provisioning
- Add `enabledModules`, `description`, `avatar`, `nestPolicy` to `CommunityMeta`
- Add `nestedSpaces` map to `CommunityDoc`
- Add auto-provisioning in EncryptID registration flow (with `DEFAULT_USER_NEST_POLICY`)
- Add subdomain routing for `<username>.rspace.online`
### Phase 2: Nesting CRUD & Consent
- Implement `SpaceRef` creation, update, deletion endpoints
- Implement `nested-in` reverse lookup
- Implement `nestPolicy` CRUD (get/update)
- Implement consent flow: `open` (immediate), `members` (membership gate), `approval` (request/approve/deny), `closed` (block)
- Implement `PendingNestRequest` lifecycle (create, list, approve, deny)
- Implement `defaultPermissions` ceiling on requested permissions
- Implement allowlist/blocklist evaluation
- Permission validation on nest creation (consent + reshare check + role check)
- Notification dispatch (inbox module integration)
### Phase 3: Nested Space Rendering
- Build `folk-canvas` shape (Task-45) as the renderer for `SpaceRef` entries
- Implement secondary WebSocket connections for nested space data
- Render nested spaces as bordered, labeled regions on the canvas
- Handle collapsed vs. expanded views
### Phase 4: Permission Cascade
- Implement `cascadePermissions()` across nesting chains
- Enforce dual authority (nest permissions + space role) on all write operations
- Implement revocation propagation
- Add `reshare: false` enforcement
### Phase 5: Encryption Integration
- Add `encrypted` flag to `CommunityMeta`
- Implement plaintext projection for nested encrypted spaces
- Implement module-level encryption opt-in (e.g., rWallet always encrypted)
---
## Open Questions
1. **Subdomain wildcards** — Should `working-group.climate-dao.rspace.online` be supported for hierarchical subdomain nesting? Or keep it flat with `climate-dao-wg-funding.rspace.online`?
2. **Offline nesting** — When offline, should the client cache nested space content from the last sync? How stale can nested content be before we show a warning?
3. ~~**Notification of nesting**~~**RESOLVED**: See *Nest Consent & Notification* section. Space owners configure consent level (`open`/`members`/`approval`/`closed`) and notification preferences in their space's `nestPolicy`. Auto-provisioned user spaces default to `approval` consent with inbox notifications.
4. **Billing/quotas** — Does each `<username>.rspace.online` space count against storage? Are there limits on how many spaces a user can create or how deeply they can nest?
5. **Presence across nests** — When Alice is editing shapes in her space that are visible via nesting in the DAO space, do DAO members see Alice's presence cursor? Or only direct space visitors?
6. **Nest request expiry** — Should pending nest requests expire after a period of inactivity? (e.g., 30 days with no response → auto-denied and cleaned up)
7. **Bulk consent** — Should there be a way to approve/deny nest requests in bulk? DAOs with many members nesting content could generate a lot of requests if set to `approval`.
---
*This document describes the target architecture. Implementation should proceed incrementally through the phases above, with each phase usable independently.*

View File

@ -28,16 +28,57 @@ export interface ShapeData {
[key: string]: unknown;
}
// ── Nested space types (client-side) ──
export interface NestPermissions {
read: boolean;
write: boolean;
addShapes: boolean;
deleteShapes: boolean;
reshare: boolean;
expiry?: number;
}
export interface SpaceRefFilter {
shapeTypes?: string[];
shapeIds?: string[];
tags?: string[];
moduleIds?: string[];
}
export interface SpaceRef {
id: string;
sourceSlug: string;
sourceDID?: string;
filter?: SpaceRefFilter;
x: number;
y: number;
width: number;
height: number;
rotation: number;
permissions: NestPermissions;
collapsed?: boolean;
label?: string;
createdAt: number;
createdBy: string;
}
// Automerge document structure
export interface CommunityDoc {
meta: {
name: string;
slug: string;
createdAt: string;
enabledModules?: string[];
description?: string;
avatar?: string;
};
shapes: {
[id: string]: ShapeData;
};
nestedSpaces?: {
[refId: string]: SpaceRef;
};
}
type SyncState = Automerge.SyncState;

View File

@ -5,12 +5,129 @@ const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
export type SpaceVisibility = 'public' | 'public_read' | 'authenticated' | 'members_only';
// ── Nest Permissions & Policy ──
export interface NestPermissions {
read: boolean;
write: boolean;
addShapes: boolean;
deleteShapes: boolean;
reshare: boolean;
expiry?: number; // unix timestamp — auto-revoke after this time
}
export interface NestNotifications {
onNestRequest: boolean;
onNestCreated: boolean;
onNestRevoked: boolean;
onReshare: boolean;
channel: 'inbox' | 'email' | 'both';
}
export interface NestPolicy {
consent: 'open' | 'members' | 'approval' | 'closed';
notifications: NestNotifications;
defaultPermissions: NestPermissions;
allowlist?: string[]; // slugs that bypass consent
blocklist?: string[]; // slugs that are always denied
}
// ── SpaceRef: Nesting Primitive ──
export interface SpaceRefFilter {
shapeTypes?: string[];
shapeIds?: string[];
tags?: string[];
moduleIds?: string[];
}
export interface SpaceRef {
id: string;
sourceSlug: string;
sourceDID?: string;
filter?: SpaceRefFilter;
x: number;
y: number;
width: number;
height: number;
rotation: number;
permissions: NestPermissions;
collapsed?: boolean;
label?: string;
createdAt: number;
createdBy: string; // DID of who created this nesting
}
export type NestRequestStatus = 'pending' | 'approved' | 'denied';
export interface PendingNestRequest {
id: string;
sourceSlug: string;
targetSlug: string;
requestedBy: string; // DID
requestedPermissions: NestPermissions;
message?: string;
status: NestRequestStatus;
createdAt: number;
resolvedAt?: number;
resolvedBy?: string;
modifiedPermissions?: NestPermissions;
}
// ── Default Nest Policies ──
export const DEFAULT_USER_NEST_POLICY: NestPolicy = {
consent: 'approval',
notifications: {
onNestRequest: true,
onNestCreated: true,
onNestRevoked: false,
onReshare: true,
channel: 'inbox',
},
defaultPermissions: {
read: true,
write: false,
addShapes: false,
deleteShapes: false,
reshare: false,
},
};
export const DEFAULT_COMMUNITY_NEST_POLICY: NestPolicy = {
consent: 'members',
notifications: {
onNestRequest: false,
onNestCreated: true,
onNestRevoked: true,
onReshare: false,
channel: 'inbox',
},
defaultPermissions: {
read: true,
write: true,
addShapes: true,
deleteShapes: false,
reshare: true,
},
};
export const DEFAULT_USER_MODULES = ['canvas', 'notes', 'files', 'wallet'];
// ── Core Types ──
export interface CommunityMeta {
name: string;
slug: string;
createdAt: string;
visibility: SpaceVisibility;
ownerDID: string | null;
enabledModules?: string[];
description?: string;
avatar?: string;
nestPolicy?: NestPolicy;
encrypted?: boolean;
encryptionKeyId?: string;
}
export interface ShapeData {
@ -42,6 +159,9 @@ export interface CommunityDoc {
members: {
[did: string]: SpaceMember;
};
nestedSpaces: {
[refId: string]: SpaceRef;
};
}
// Per-peer sync state for Automerge
@ -123,6 +243,10 @@ function jsonToAutomerge(data: CommunityDoc): Automerge.Doc<CommunityDoc> {
for (const [did, member] of Object.entries(data.members || {})) {
d.members[did] = { ...member };
}
d.nestedSpaces = {};
for (const [refId, ref] of Object.entries(data.nestedSpaces || {})) {
d.nestedSpaces[refId] = { ...ref };
}
});
return doc;
}
@ -155,13 +279,18 @@ export async function saveCommunity(slug: string): Promise<void> {
}
/**
* Create a new community
* Create a new community/space
*/
export async function createCommunity(
name: string,
slug: string,
ownerDID: string | null = null,
visibility: SpaceVisibility = 'public_read',
options?: {
enabledModules?: string[];
nestPolicy?: NestPolicy;
description?: string;
},
): Promise<Automerge.Doc<CommunityDoc>> {
let doc = Automerge.init<CommunityDoc>();
doc = Automerge.change(doc, "Create community", (d) => {
@ -172,8 +301,18 @@ export async function createCommunity(
visibility,
ownerDID,
};
if (options?.enabledModules) {
d.meta.enabledModules = options.enabledModules;
}
if (options?.nestPolicy) {
d.meta.nestPolicy = options.nestPolicy;
}
if (options?.description) {
d.meta.description = options.description;
}
d.shapes = {};
d.members = {};
d.nestedSpaces = {};
// If owner is known, add them as admin member
if (ownerDID) {
d.members[ownerDID] = {
@ -552,3 +691,163 @@ export function clearShapes(slug: string): void {
saveCommunity(slug);
}
}
// ── Nested Spaces CRUD ──
/**
* Add a SpaceRef (nest a space into another)
*/
export function addNestedSpace(slug: string, ref: SpaceRef): void {
const doc = communities.get(slug);
if (!doc) return;
const newDoc = Automerge.change(doc, `Nest space ${ref.sourceSlug}`, (d) => {
if (!d.nestedSpaces) d.nestedSpaces = {};
d.nestedSpaces[ref.id] = { ...ref };
});
communities.set(slug, newDoc);
saveCommunity(slug);
}
/**
* Update a SpaceRef (permissions, filter, position)
*/
export function updateNestedSpace(
slug: string,
refId: string,
fields: Partial<SpaceRef>,
): boolean {
const doc = communities.get(slug);
if (!doc || !doc.nestedSpaces?.[refId]) return false;
const newDoc = Automerge.change(doc, `Update nest ${refId}`, (d) => {
if (d.nestedSpaces[refId]) {
for (const [key, value] of Object.entries(fields)) {
if (key === 'id') continue; // never change ID
(d.nestedSpaces[refId] as unknown as Record<string, unknown>)[key] = value;
}
}
});
communities.set(slug, newDoc);
saveCommunity(slug);
return true;
}
/**
* Remove a SpaceRef (un-nest)
*/
export function removeNestedSpace(slug: string, refId: string): boolean {
const doc = communities.get(slug);
if (!doc || !doc.nestedSpaces?.[refId]) return false;
const newDoc = Automerge.change(doc, `Remove nest ${refId}`, (d) => {
if (d.nestedSpaces && d.nestedSpaces[refId]) {
delete d.nestedSpaces[refId];
}
});
communities.set(slug, newDoc);
saveCommunity(slug);
return true;
}
/**
* Get the NestPolicy for a space, with fallback to default
*/
export function getNestPolicy(slug: string): NestPolicy | null {
const doc = communities.get(slug);
if (!doc) return null;
return doc.meta.nestPolicy || DEFAULT_COMMUNITY_NEST_POLICY;
}
/**
* Update the NestPolicy for a space
*/
export function updateNestPolicy(slug: string, policy: Partial<NestPolicy>): boolean {
const doc = communities.get(slug);
if (!doc) return false;
const newDoc = Automerge.change(doc, `Update nest policy`, (d) => {
const current = d.meta.nestPolicy || { ...DEFAULT_COMMUNITY_NEST_POLICY };
d.meta.nestPolicy = {
...current,
...policy,
notifications: { ...current.notifications, ...(policy.notifications || {}) },
defaultPermissions: { ...current.defaultPermissions, ...(policy.defaultPermissions || {}) },
};
});
communities.set(slug, newDoc);
saveCommunity(slug);
return true;
}
/**
* Update enabled modules for a space
*/
export function setEnabledModules(slug: string, modules: string[]): boolean {
const doc = communities.get(slug);
if (!doc) return false;
const newDoc = Automerge.change(doc, `Set enabled modules`, (d) => {
d.meta.enabledModules = modules;
});
communities.set(slug, newDoc);
saveCommunity(slug);
return true;
}
/**
* Cap requested permissions against a policy's defaultPermissions ceiling
*/
export function capPermissions(
requested: NestPermissions,
ceiling: NestPermissions,
): NestPermissions {
return {
read: requested.read && ceiling.read,
write: requested.write && ceiling.write,
addShapes: requested.addShapes && ceiling.addShapes,
deleteShapes: requested.deleteShapes && ceiling.deleteShapes,
reshare: requested.reshare && ceiling.reshare,
expiry: requested.expiry
? (ceiling.expiry ? Math.min(requested.expiry, ceiling.expiry) : requested.expiry)
: ceiling.expiry,
};
}
/**
* Cascade permissions across a nesting chain (intersection most restrictive wins)
*/
export function cascadePermissions(chain: NestPermissions[]): NestPermissions {
return {
read: chain.every(p => p.read),
write: chain.every(p => p.write),
addShapes: chain.every(p => p.addShapes),
deleteShapes: chain.every(p => p.deleteShapes),
reshare: chain.every(p => p.reshare),
expiry: chain.some(p => p.expiry)
? Math.min(...chain.filter(p => p.expiry).map(p => p.expiry!))
: undefined,
};
}
/**
* Find all spaces that a given space is nested into (reverse lookup)
*/
export async function findNestedIn(sourceSlug: string): Promise<Array<{ slug: string; refId: string; ref: SpaceRef }>> {
const results: Array<{ slug: string; refId: string; ref: SpaceRef }> = [];
const allSlugs = await listCommunities();
for (const slug of allSlugs) {
await loadCommunity(slug);
const doc = communities.get(slug);
if (!doc?.nestedSpaces) continue;
for (const [refId, ref] of Object.entries(doc.nestedSpaces)) {
if (ref.sourceSlug === sourceSlug) {
results.push({ slug, refId, ref: JSON.parse(JSON.stringify(ref)) });
}
}
}
return results;
}

View File

@ -14,8 +14,23 @@ import {
loadCommunity,
getDocumentData,
listCommunities,
addNestedSpace,
updateNestedSpace,
removeNestedSpace,
getNestPolicy,
updateNestPolicy,
capPermissions,
findNestedIn,
DEFAULT_COMMUNITY_NEST_POLICY,
} from "./community-store";
import type {
SpaceVisibility,
NestPermissions,
NestPolicy,
SpaceRef,
PendingNestRequest,
NestRequestStatus,
} from "./community-store";
import type { SpaceVisibility } from "./community-store";
import {
verifyEncryptIDToken,
extractToken,
@ -23,6 +38,10 @@ import {
import type { EncryptIDClaims } from "@encryptid/sdk/server";
import { getAllModules } from "../shared/module";
// ── In-memory pending nest requests (move to DB later) ──
const nestRequests = new Map<string, PendingNestRequest>();
let nestRequestCounter = 0;
const spaces = new Hono();
// ── List spaces (public + user's own/member spaces) ──
@ -209,4 +228,398 @@ spaces.get("/admin", async (c) => {
return c.json({ spaces: spacesList, total: spacesList.length });
});
// ══════════════════════════════════════════════════════════════════════════════
// NESTING API
// ══════════════════════════════════════════════════════════════════════════════
// ── Get nest policy for a space ──
spaces.get("/:slug/nest-policy", async (c) => {
const slug = c.req.param("slug");
await loadCommunity(slug);
const policy = getNestPolicy(slug);
if (!policy) return c.json({ error: "Space not found" }, 404);
return c.json({ nestPolicy: policy });
});
// ── Update nest policy (admin only) ──
spaces.patch("/:slug/nest-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);
// Must be admin or owner
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<NestPolicy>>();
updateNestPolicy(slug, body);
return c.json({ ok: true, nestPolicy: getNestPolicy(slug) });
});
// ── List nested spaces in a space ──
spaces.get("/:slug/nest", async (c) => {
const slug = c.req.param("slug");
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Space not found" }, 404);
const refs = Object.values(data.nestedSpaces || {});
return c.json({ nestedSpaces: refs });
});
// ── Nest a space (create SpaceRef) — respects source space's NestPolicy ──
spaces.post("/:slug/nest", async (c) => {
const targetSlug = 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<{
sourceSlug: string;
permissions?: Partial<NestPermissions>;
filter?: SpaceRef['filter'];
x?: number; y?: number;
width?: number; height?: number;
rotation?: number;
label?: string;
collapsed?: boolean;
message?: string;
}>();
const { sourceSlug } = body;
if (!sourceSlug) return c.json({ error: "sourceSlug is required" }, 400);
// Load both spaces
await loadCommunity(targetSlug);
await loadCommunity(sourceSlug);
const targetData = getDocumentData(targetSlug);
const sourceData = getDocumentData(sourceSlug);
if (!targetData) return c.json({ error: "Target space not found" }, 404);
if (!sourceData) return c.json({ error: "Source space not found" }, 404);
// Check: requester must be admin or moderator in the TARGET space
const targetMember = targetData.members?.[claims.sub];
const isTargetOwner = targetData.meta.ownerDID === claims.sub;
if (!isTargetOwner && targetMember?.role !== 'admin' && targetMember?.role !== 'moderator') {
return c.json({ error: "Admin or moderator role required in the target space" }, 403);
}
// Get source space's nest policy
const policy = sourceData.meta.nestPolicy || DEFAULT_COMMUNITY_NEST_POLICY;
// Check blocklist
if (policy.blocklist?.includes(targetSlug)) {
return c.json({ error: "Source space has blocked nesting into this space" }, 403);
}
// Check consent level
const isOnAllowlist = policy.allowlist?.includes(targetSlug);
if (policy.consent === 'closed' && !isOnAllowlist) {
return c.json({ error: "Source space does not allow nesting" }, 403);
}
if (policy.consent === 'members' && !isOnAllowlist) {
const sourceMember = sourceData.members?.[claims.sub];
const isSourceOwner = sourceData.meta.ownerDID === claims.sub;
if (!isSourceOwner && !sourceMember) {
return c.json({ error: "Must be a member of the source space to nest it" }, 403);
}
}
// Build requested permissions, capped by source policy's ceiling
const requestedPerms: NestPermissions = {
read: body.permissions?.read ?? true,
write: body.permissions?.write ?? false,
addShapes: body.permissions?.addShapes ?? false,
deleteShapes: body.permissions?.deleteShapes ?? false,
reshare: body.permissions?.reshare ?? false,
expiry: body.permissions?.expiry,
};
const cappedPerms = capPermissions(requestedPerms, policy.defaultPermissions);
// If consent is 'approval' and not on allowlist, create a pending request
if (policy.consent === 'approval' && !isOnAllowlist) {
const reqId = `nest-req-${++nestRequestCounter}`;
const request: PendingNestRequest = {
id: reqId,
sourceSlug,
targetSlug,
requestedBy: claims.sub,
requestedPermissions: cappedPerms,
message: body.message,
status: 'pending',
createdAt: Date.now(),
};
nestRequests.set(reqId, request);
return c.json({
status: 'pending',
requestId: reqId,
message: 'Nest request created. Awaiting source space admin approval.',
cappedPermissions: cappedPerms,
}, 202);
}
// Consent is 'open', 'members' (passed), or allowlisted — create immediately
const refId = `ref-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const ref: SpaceRef = {
id: refId,
sourceSlug,
sourceDID: claims.sub,
filter: body.filter,
x: body.x ?? 100,
y: body.y ?? 100,
width: body.width ?? 600,
height: body.height ?? 400,
rotation: body.rotation ?? 0,
permissions: cappedPerms,
collapsed: body.collapsed ?? false,
label: body.label,
createdAt: Date.now(),
createdBy: claims.sub,
};
addNestedSpace(targetSlug, ref);
return c.json({ ok: true, ref }, 201);
});
// ── Get a specific nested space ref ──
spaces.get("/:slug/nest/:refId", async (c) => {
const slug = c.req.param("slug");
const refId = c.req.param("refId");
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Space not found" }, 404);
const ref = data.nestedSpaces?.[refId];
if (!ref) return c.json({ error: "Nested space ref not found" }, 404);
return c.json({ ref });
});
// ── Update a nested space ref (permissions, filter, position) ──
spaces.patch("/:slug/nest/:refId", async (c) => {
const slug = c.req.param("slug");
const refId = c.req.param("refId");
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 ref = data.nestedSpaces?.[refId];
if (!ref) return c.json({ error: "Nested space ref not found" }, 404);
// Must be admin/moderator in the nesting space OR the creator of this ref
const member = data.members?.[claims.sub];
const isOwner = data.meta.ownerDID === claims.sub;
const isRefCreator = ref.createdBy === claims.sub;
if (!isOwner && member?.role !== 'admin' && member?.role !== 'moderator' && !isRefCreator) {
return c.json({ error: "Insufficient permissions" }, 403);
}
const body = await c.req.json<Partial<SpaceRef>>();
updateNestedSpace(slug, refId, body);
return c.json({ ok: true });
});
// ── Remove a nested space ref (un-nest) ──
spaces.delete("/:slug/nest/:refId", async (c) => {
const slug = c.req.param("slug");
const refId = c.req.param("refId");
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 ref = data.nestedSpaces?.[refId];
if (!ref) return c.json({ error: "Nested space ref not found" }, 404);
// Can be removed by:
// 1. Admin/owner of the nesting (target) space
// 2. Creator of this ref
// 3. Admin/owner of the SOURCE space (sovereignty guarantee)
const member = data.members?.[claims.sub];
const isOwner = data.meta.ownerDID === claims.sub;
const isRefCreator = ref.createdBy === claims.sub;
let isSourceAdmin = false;
await loadCommunity(ref.sourceSlug);
const sourceData = getDocumentData(ref.sourceSlug);
if (sourceData) {
const sourceMember = sourceData.members?.[claims.sub];
isSourceAdmin = sourceData.meta.ownerDID === claims.sub || sourceMember?.role === 'admin';
}
if (!isOwner && member?.role !== 'admin' && !isRefCreator && !isSourceAdmin) {
return c.json({ error: "Insufficient permissions" }, 403);
}
removeNestedSpace(slug, refId);
return c.json({ ok: true });
});
// ── Reverse lookup: where is this space nested? ──
spaces.get("/:slug/nested-in", 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); }
// Must be admin/owner of the space to see where it's nested
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 nestedIn = await findNestedIn(slug);
return c.json({ nestedIn });
});
// ══════════════════════════════════════════════════════════════════════════════
// NEST REQUEST API (approval flow)
// ══════════════════════════════════════════════════════════════════════════════
// ── List pending nest requests for a space (admin only) ──
spaces.get("/:slug/nest-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 SOURCE (someone wants to nest us)
const requests = Array.from(nestRequests.values())
.filter(r => r.sourceSlug === slug);
return c.json({ requests });
});
// ── Approve or deny a nest request ──
spaces.patch("/:slug/nest-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 = nestRequests.get(reqId);
if (!request || request.sourceSlug !== slug) {
return c.json({ error: "Nest request not found" }, 404);
}
if (request.status !== 'pending') {
return c.json({ error: `Request already ${request.status}` }, 400);
}
const body = await c.req.json<{
action: 'approve' | 'deny';
modifiedPermissions?: NestPermissions;
}>();
if (body.action === 'deny') {
request.status = 'denied';
request.resolvedAt = Date.now();
request.resolvedBy = claims.sub;
return c.json({ ok: true, status: 'denied' });
}
if (body.action === 'approve') {
const finalPerms = body.modifiedPermissions || request.requestedPermissions;
request.status = 'approved';
request.resolvedAt = Date.now();
request.resolvedBy = claims.sub;
request.modifiedPermissions = body.modifiedPermissions;
// Create the actual SpaceRef in the TARGET space
await loadCommunity(request.targetSlug);
const refId = `ref-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const ref: SpaceRef = {
id: refId,
sourceSlug: request.sourceSlug,
sourceDID: request.requestedBy,
x: 100,
y: 100,
width: 600,
height: 400,
rotation: 0,
permissions: finalPerms,
collapsed: false,
createdAt: Date.now(),
createdBy: request.requestedBy,
};
addNestedSpace(request.targetSlug, ref);
return c.json({ ok: true, status: 'approved', ref });
}
return c.json({ error: "action must be 'approve' or 'deny'" }, 400);
});
export { spaces };

View File

@ -377,6 +377,35 @@ app.post('/api/register/complete', async (c) => {
userId: userId.slice(0, 20) + '...',
});
// Auto-provision user space at <username>.rspace.online
try {
const { communityExists, createCommunity } = await import('../../server/community-store');
const { DEFAULT_USER_NEST_POLICY, DEFAULT_USER_MODULES } = await import('../../server/community-store');
const { getAllModules } = await import('../../shared/module');
const spaceSlug = username.toLowerCase().replace(/[^a-z0-9-]/g, '-');
if (!await communityExists(spaceSlug)) {
await createCommunity(username, spaceSlug, did, 'members_only', {
enabledModules: DEFAULT_USER_MODULES,
nestPolicy: DEFAULT_USER_NEST_POLICY,
});
// Fire module hooks
for (const mod of getAllModules()) {
if (mod.onSpaceCreate) {
try { await mod.onSpaceCreate(spaceSlug); } catch (e) {
console.error(`[EncryptID] Module ${mod.id} onSpaceCreate for user space:`, e);
}
}
}
console.log(`EncryptID: Auto-provisioned space ${spaceSlug}.rspace.online for ${username}`);
}
} catch (e) {
// Non-fatal: user is created even if space provisioning fails
console.error('EncryptID: Failed to auto-provision user space:', e);
}
// Generate initial session token
const token = await generateSessionToken(userId, username);
@ -385,6 +414,7 @@ app.post('/api/register/complete', async (c) => {
userId,
token,
did,
spaceSlug: username.toLowerCase().replace(/[^a-z0-9-]/g, '-'),
});
});