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:
parent
86fc403138
commit
91cb68a09f
|
|
@ -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.*
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
415
server/spaces.ts
415
server/spaces.ts
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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, '-'),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue