@drakkar.software/octospaces-sdk 0.1.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,9 +19,7 @@ import { getSyncBase, getSyncNamespace } from '../core/config.js';
19
19
  import { fetchWithTimeout } from './fetch-timeout.js';
20
20
  import type { Session } from './identity.js';
21
21
  import { fingerprintFromUserId } from './identity.js';
22
- import { addDeviceToSpaceKeyring } from '../spaces/members.js';
23
22
  import { bytesToHex, linkedDeviceScope } from './paths.js';
24
- import { readSpaces } from '../spaces/registry.js';
25
23
 
26
24
  /** The QR-payload prefix this SDK uses. Kept distinct from `octochat-pair:` so apps
27
25
  * can route QR payloads to the correct handler during their migration window. */
@@ -40,21 +38,21 @@ function randomNonce(): string {
40
38
  return bytesToHex(b);
41
39
  }
42
40
 
43
- /** Existing device: provision + PIN-seal a new device, publish to rendezvous, return the QR payload. */
41
+ /**
42
+ * Existing device: provision + PIN-seal a new device, publish to rendezvous, return
43
+ * the QR payload.
44
+ *
45
+ * After pairing, call `addDeviceToSpaceKeyring(session, spaceId, newDeviceKeys)` for
46
+ * each space whose E2EE content the new device should decrypt. ONE space keyring
47
+ * encrypts ALL `enc` nodes in a space — one call per space unlocks everything.
48
+ * Plaintext (`space` / `public`) nodes are immediately accessible via the linked-device
49
+ * cap-cert (no extra keyring step).
50
+ */
44
51
  export async function startDevicePairing(session: Session, pin: string): Promise<string> {
45
52
  const { deviceKeys, bundle } = await provisionDevice(
46
53
  { edPriv: session.keys.edPriv, edPub: session.keys.edPub },
47
54
  { scope: linkedDeviceScope(session.userId), ttlSec: LINKED_DEVICE_TTL_SEC },
48
55
  );
49
- const { spaces, caps } = await readSpaces(session.accountClient, session.userId);
50
- for (const space of spaces) {
51
- if (caps[space.id]) continue;
52
- try {
53
- await addDeviceToSpaceKeyring(session, space.id, { kemPub: deviceKeys.kemPub, userId: session.userId });
54
- } catch (err) {
55
- console.log('[pairing] keyring grant failed', { spaceId: space.id, error: String((err as Error)?.message ?? err) });
56
- }
57
- }
58
56
  const blob = JSON.stringify({ v: 1, keys: deviceKeys, bundle });
59
57
  const sealed = await sealWithPassphrase(pin, new TextEncoder().encode(blob));
60
58
  const nonce = randomNonce();
@@ -3,26 +3,43 @@ import {
3
3
  OBJECT_COLLECTIONS,
4
4
  ownerScope,
5
5
  spaceMemberScope,
6
+ nodeMemberScope,
6
7
  accountScope,
7
8
  linkedDeviceScope,
8
9
  keyringPull,
9
10
  keyringPush,
10
11
  objIndexPull,
11
12
  objIndexPush,
13
+ objPubPull,
14
+ objPubPush,
15
+ objInvPull,
16
+ objInvPush,
17
+ objectDirPull,
12
18
  spacesPull,
13
19
  spacesPush,
14
20
  profilePull,
15
- spaceIndexPull,
16
21
  } from './paths.js';
17
22
 
18
23
  describe('OBJECT_COLLECTIONS', () => {
19
- it('contains the canonical generic object collections', () => {
20
- expect(OBJECT_COLLECTIONS).toContain('keyring');
24
+ it('contains spacekeyring (space-wide keyring)', () => {
25
+ expect(OBJECT_COLLECTIONS).toContain('spacekeyring');
26
+ });
27
+
28
+ it('contains the generic object storage collections', () => {
21
29
  expect(OBJECT_COLLECTIONS).toContain('objindex');
22
30
  expect(OBJECT_COLLECTIONS).toContain('objlog');
23
31
  expect(OBJECT_COLLECTIONS).toContain('objdoc');
24
32
  expect(OBJECT_COLLECTIONS).toContain('objblob');
25
33
  expect(OBJECT_COLLECTIONS).toContain('typeindex');
34
+ expect(OBJECT_COLLECTIONS).toContain('objpub');
35
+ });
36
+
37
+ it('does NOT contain nodekeyring (removed — keyring is now space-wide)', () => {
38
+ expect(OBJECT_COLLECTIONS).not.toContain('nodekeyring');
39
+ });
40
+
41
+ it('does NOT contain objinv (invite-only content excluded from broad scope)', () => {
42
+ expect(OBJECT_COLLECTIONS).not.toContain('objinv');
26
43
  });
27
44
 
28
45
  it('does NOT contain chat-only collections', () => {
@@ -37,7 +54,7 @@ describe('ownerScope', () => {
37
54
  expect(scope.collections).toEqual(OBJECT_COLLECTIONS);
38
55
  });
39
56
 
40
- it('includes wildcard ops', () => {
57
+ it('includes read, list, write ops', () => {
41
58
  const scope = ownerScope();
42
59
  expect(scope.ops).toContain('read');
43
60
  expect(scope.ops).toContain('write');
@@ -64,6 +81,54 @@ describe('spaceMemberScope', () => {
64
81
  const scope = spaceMemberScope('sp-abc', true);
65
82
  expect(scope.collections).toEqual(OBJECT_COLLECTIONS);
66
83
  });
84
+
85
+ it('includes spacekeyring (members need to reach the space keyring)', () => {
86
+ const scope = spaceMemberScope('sp-abc', true);
87
+ expect(scope.collections).toContain('spacekeyring');
88
+ });
89
+
90
+ it('does NOT include objinv in collections', () => {
91
+ const scope = spaceMemberScope('sp-abc', true);
92
+ expect(scope.collections).not.toContain('objinv');
93
+ });
94
+ });
95
+
96
+ describe('nodeMemberScope', () => {
97
+ it('scopes to the specific nodeId path', () => {
98
+ const scope = nodeMemberScope('sp-1', 'n-42', true);
99
+ expect(scope.paths).toEqual(
100
+ expect.arrayContaining([expect.stringContaining('n-42')]),
101
+ );
102
+ expect(scope.paths).toEqual(
103
+ expect.arrayContaining([expect.stringContaining('sp-1')]),
104
+ );
105
+ });
106
+
107
+ it('includes objinv (invite-plaintext content gate)', () => {
108
+ const scope = nodeMemberScope('sp-1', 'n-42', true);
109
+ expect(scope.collections).toContain('objinv');
110
+ });
111
+
112
+ it('does NOT include nodekeyring (keyring is space-wide, not per-node)', () => {
113
+ const scope = nodeMemberScope('sp-1', 'n-42', true);
114
+ expect(scope.collections).not.toContain('nodekeyring');
115
+ });
116
+
117
+ it('does NOT include spacekeyring (use spaceMemberScope for enc node access)', () => {
118
+ const scope = nodeMemberScope('sp-1', 'n-42', true);
119
+ expect(scope.collections).not.toContain('spacekeyring');
120
+ });
121
+
122
+ it('does NOT include broad space collections', () => {
123
+ const scope = nodeMemberScope('sp-1', 'n-42', true);
124
+ expect(scope.collections).not.toContain('objdoc');
125
+ expect(scope.collections).not.toContain('objpub');
126
+ });
127
+
128
+ it('write=false omits write ops', () => {
129
+ const scope = nodeMemberScope('sp-1', 'n-42', false);
130
+ expect(scope.ops).not.toContain('write');
131
+ });
67
132
  });
68
133
 
69
134
  describe('accountScope', () => {
@@ -90,40 +155,83 @@ describe('accountScope', () => {
90
155
  });
91
156
 
92
157
  describe('linkedDeviceScope', () => {
93
- it('contains both object and account collections', () => {
158
+ it('contains spacekeyring (space-wide keyring)', () => {
94
159
  const scope = linkedDeviceScope('user-1');
95
- expect(scope.collections).toEqual(expect.arrayContaining(['keyring', 'profile', 'spaces']));
160
+ expect(scope.collections).toContain('spacekeyring');
96
161
  });
97
162
 
98
- it('does NOT contain pubspace', () => {
163
+ it('does NOT contain nodekeyring (removed — keyring is space-wide)', () => {
99
164
  const scope = linkedDeviceScope('user-1');
100
- expect(scope.collections).not.toContain('pubspace');
165
+ expect(scope.collections).not.toContain('nodekeyring');
101
166
  });
102
167
 
103
- it('does NOT include pubspaces/ paths', () => {
168
+ it('contains standard account collections', () => {
104
169
  const scope = linkedDeviceScope('user-1');
105
- const hasPubspaces = (scope.paths ?? []).some((p) => p.includes('pubspaces/'));
106
- expect(hasPubspaces).toBe(false);
170
+ expect(scope.collections).toEqual(expect.arrayContaining(['profile', 'spaces']));
171
+ });
172
+
173
+ it('does NOT contain pubspace', () => {
174
+ const scope = linkedDeviceScope('user-1');
175
+ expect(scope.collections).not.toContain('pubspace');
107
176
  });
108
177
  });
109
178
 
110
- describe('spaceIndexPull', () => {
179
+ describe('objectDirPull', () => {
111
180
  it('returns a pull path for the public shard', () => {
112
- expect(spaceIndexPull('public')).toContain('public');
181
+ expect(objectDirPull('public')).toContain('public');
182
+ expect(objectDirPull('public')).toContain('_index/objects');
183
+ });
184
+
185
+ it('defaults to the public shard', () => {
186
+ expect(objectDirPull()).toContain('public');
113
187
  });
114
188
  });
115
189
 
116
- describe('path helpers', () => {
117
- it('keyringPull / keyringPush are symmetric', () => {
190
+ describe('space keyring path helpers', () => {
191
+ it('keyringPull / keyringPush embed spaceId', () => {
118
192
  expect(keyringPull('sp-1')).toContain('sp-1');
193
+ expect(keyringPull('sp-1')).toContain('_keyring');
119
194
  expect(keyringPush('sp-1')).toContain('sp-1');
195
+ expect(keyringPush('sp-1')).toContain('_keyring');
120
196
  });
121
197
 
122
- it('objIndexPull / objIndexPush are symmetric', () => {
198
+ it('keyringPull and keyringPush are symmetric (same subpath)', () => {
199
+ const pull = keyringPull('sp-1').replace('/pull/', '/');
200
+ const push = keyringPush('sp-1').replace('/push/', '/');
201
+ expect(pull).toBe(push);
202
+ });
203
+
204
+ it('keyringPull path does NOT contain a nodeId segment', () => {
205
+ const path = keyringPull('sp-42');
206
+ // Space-wide path: spaces/sp-42/_keyring — no /objects/n/ segment.
207
+ expect(path).not.toContain('/objects/n/');
208
+ });
209
+ });
210
+
211
+ describe('public node content path helpers', () => {
212
+ it('objPubPull / objPubPush embed spaceId and nodeId', () => {
213
+ expect(objPubPull('sp-1', 'n-5')).toContain('sp-1');
214
+ expect(objPubPull('sp-1', 'n-5')).toContain('n-5');
215
+ expect(objPubPush('sp-1', 'n-5')).toContain('pub');
216
+ });
217
+ });
218
+
219
+ describe('invite-only content path helpers', () => {
220
+ it('objInvPull / objInvPush embed spaceId and nodeId', () => {
221
+ expect(objInvPull('sp-1', 'n-5')).toContain('sp-1');
222
+ expect(objInvPull('sp-1', 'n-5')).toContain('n-5');
223
+ expect(objInvPush('sp-1', 'n-5')).toContain('content');
224
+ });
225
+ });
226
+
227
+ describe('object index path helpers', () => {
228
+ it('objIndexPull / objIndexPush embed spaceId', () => {
123
229
  expect(objIndexPull('sp-1')).toContain('sp-1');
124
230
  expect(objIndexPush('sp-1')).toContain('sp-1');
125
231
  });
232
+ });
126
233
 
234
+ describe('other path helpers', () => {
127
235
  it('spacesPull / spacesPush use the userId', () => {
128
236
  expect(spacesPull('alice')).toContain('alice');
129
237
  expect(spacesPush('alice')).toContain('alice');
package/src/sync/paths.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Collection path + cap-scope helpers (merged from OctoChat + OctoVault).
2
+ * Collection path + cap-scope helpers for OctoSpaces.
3
3
  *
4
4
  * Paths are signed relative to SYNC_BASE; the server mounts the sync router at
5
5
  * root, so they start with /pull or /push. Everything for a space is nested under
@@ -8,9 +8,13 @@
8
8
  * covers a whole space.
9
9
  *
10
10
  * **Generic object collections** — scopes use the `obj*` collection names (the
11
- * domain-neutral storage layer both apps migrate onto). App-specific collection
12
- * names like `'chat'` are left for the consumer's own `paths.ts` extension until
13
- * that app finishes migrating.
11
+ * domain-neutral storage layer). The access record lives at
12
+ * `spaces/{spaceId}/_access` (collection `spaceregistry`); the space-wide keyring at
13
+ * `spaces/{spaceId}/_keyring` (collection `spacekeyring`). ONE keyring per space
14
+ * encrypts ALL the space's `enc` nodes.
15
+ *
16
+ * Note: `objinv` (invite-plaintext content) is intentionally EXCLUDED from
17
+ * OBJECT_COLLECTIONS / spaceMemberScope — only a per-node cap can reach it.
14
18
  */
15
19
  import type { ScopePreset } from '@drakkar.software/starfish-identities';
16
20
 
@@ -20,7 +24,9 @@ const push = (rest: string) => `/push/${rest}`;
20
24
  /** A room id is `sp-<rand>-<name>`; the space is its first two `-` segments. */
21
25
  export const spaceIdFromRoomId = (roomId: string) => roomId.split('-').slice(0, 2).join('-');
22
26
 
23
- // ── Space-wide keyring (one per space, shared by all its channels) ────────────
27
+ // ── Space-wide keyring (one keyring per space, encrypts all enc nodes) ────────
28
+ /** Base name used as the `collectionName` arg to `addCollectionRecipient`.
29
+ * Appending `/_keyring` gives the full storage path. */
24
30
  export const keyringName = (spaceId: string) => `spaces/${spaceId}`;
25
31
  export const keyringPull = (spaceId: string) => pull(`${keyringName(spaceId)}/_keyring`);
26
32
  export const keyringPush = (spaceId: string) => push(`${keyringName(spaceId)}/_keyring`);
@@ -39,24 +45,27 @@ export const profilePush = (userId: string) => push(`user/${userId}/profile`);
39
45
  export const spacesPull = (userId: string) => pull(`user/${userId}/_spaces`);
40
46
  export const spacesPush = (userId: string) => push(`user/${userId}/_spaces`);
41
47
 
42
- export const roomsRegistryPull = (spaceId: string) => pull(`spaces/${spaceId}/_rooms`);
43
- export const roomsRegistryPush = (spaceId: string) => push(`spaces/${spaceId}/_rooms`);
48
+ export const spaceAccessPull = (spaceId: string) => pull(`spaces/${spaceId}/_access`);
49
+ export const spaceAccessPush = (spaceId: string) => push(`spaces/${spaceId}/_access`);
44
50
 
45
- // ── Unified Object index + content (private/E2EE) ─────────────────────────────
46
- // ALL Object content lives in one generic path family no type-specific prefixes:
47
- //
48
- // objects/_index — union-merged ObjectNode tree (every Object in the space)
49
- // objects/logs/{id} — WAL/CRDT append-only op-log (contentKind "append")
50
- // objects/logs/{id}__snapshot — sibling LWW snapshot for fast cold-start
51
- // objects/docs/{id} — LWW merge-doc (contentKind "merge": records, captions)
52
- // objects/blobs/{id} — sealed raw binary blob (file/image objects)
53
- //
54
- // Keep in sync with the objindex/objlog/objsnap/objdoc/objblob collections in
55
- // apps/server AND Infra collections.py.
51
+ // ── Object index (member-gated, always plaintext) ────────────────────────────
52
+ // The index lists every node with structural fields plaintext. For `invite`
53
+ // nodes the title/emoji are stripped before storage; only invited members read
54
+ // the real title from the node's content doc. Keep in sync with the objindex
55
+ // collection in apps/server AND Infra collections.py.
56
56
  export const objIndexName = (spaceId: string) => `spaces/${spaceId}/objects/_index`;
57
57
  export const objIndexPull = (spaceId: string) => pull(objIndexName(spaceId));
58
58
  export const objIndexPush = (spaceId: string) => push(objIndexName(spaceId));
59
59
 
60
+ // ── Space-tier & general object content (space:member gated) ─────────────────
61
+ //
62
+ // objects/logs/{id} — WAL/CRDT append-only op-log (contentKind "append")
63
+ // objects/logs/{id}__snapshot — sibling LWW snapshot for fast cold-start
64
+ // objects/docs/{id} — LWW merge-doc (contentKind "merge")
65
+ // objects/blobs/{id} — sealed raw binary blob
66
+ //
67
+ // Keep in sync with the objlog/objsnap/objdoc/objblob collections in
68
+ // apps/server AND Infra collections.py.
60
69
  export const objLogName = (spaceId: string, objectId: string) => `spaces/${spaceId}/objects/logs/${objectId}`;
61
70
  export const objLogPull = (spaceId: string, objectId: string) => pull(objLogName(spaceId, objectId));
62
71
  export const objLogPush = (spaceId: string, objectId: string) => push(objLogName(spaceId, objectId));
@@ -70,22 +79,39 @@ export const objectBlobName = (spaceId: string, blobId: string) => `spaces/${spa
70
79
  export const objectBlobPull = (spaceId: string, blobId: string) => pull(objectBlobName(spaceId, blobId));
71
80
  export const objectBlobPush = (spaceId: string, blobId: string) => push(objectBlobName(spaceId, blobId));
72
81
 
73
- // ── Per-space custom type registry (private/E2EE) ─────────────────────────────
82
+ // ── Public node content (world-readable) ─────────────────────────────────────
83
+ // For `access:'public'` nodes, content is stored here so anonymous readers can
84
+ // fetch it without being a space member. Keep in sync with objpub in server config.
85
+ export const objPubName = (spaceId: string, nodeId: string) => `spaces/${spaceId}/objects/pub/${nodeId}`;
86
+ export const objPubPull = (spaceId: string, nodeId: string) => pull(objPubName(spaceId, nodeId));
87
+ export const objPubPush = (spaceId: string, nodeId: string) => push(objPubName(spaceId, nodeId));
88
+
89
+ // ── Invite-only plaintext content (cap-gated) ────────────────────────────────
90
+ // For `access:'invite' + enc:false` nodes, content is stored here. The `objinv`
91
+ // collection is intentionally excluded from spaceMemberScope — only a per-node cap
92
+ // (nodeMemberScope) can reach it. Keep in sync with objinv in server config.
93
+ export const objInvName = (spaceId: string, nodeId: string) => `spaces/${spaceId}/objects/n/${nodeId}/content`;
94
+ export const objInvPull = (spaceId: string, nodeId: string) => pull(objInvName(spaceId, nodeId));
95
+ export const objInvPush = (spaceId: string, nodeId: string) => push(objInvName(spaceId, nodeId));
96
+
97
+ // ── Per-space custom type registry ────────────────────────────────────────────
74
98
  export const typesIndexName = (spaceId: string) => `spaces/${spaceId}/types/_index`;
75
99
  export const typesIndexPull = (spaceId: string) => pull(typesIndexName(spaceId));
76
100
  export const typesIndexPush = (spaceId: string) => push(typesIndexName(spaceId));
77
101
 
78
- // ── Public-space directory index (server-maintained projection) ───────────────
79
- export const spaceIndexName = (shard: 'public') => `_index/spaces/${shard}`;
80
- export const spaceIndexPull = (shard: 'public') => pull(spaceIndexName(shard));
102
+ // ── Global object directory (server-maintained projection) ───────────────────
103
+ // Pull `_index/objects/{shard}` to discover world-readable public nodes.
104
+ // Default shard 'public'; future: sharded by app-supplied type string.
105
+ export const objectDirName = (shard: string = 'public') => `_index/objects/${shard}`;
106
+ export const objectDirPull = (shard: string = 'public') => pull(objectDirName(shard));
81
107
 
82
108
  // ── Generic object collections — used in cap scopes ──────────────────────────
83
- // These are the domain-neutral storage collections both apps migrate onto. The
84
- // server ignores unrecognized collection names, so a cap minted with this set still
85
- // authorizes an app whose data currently lives under a legacy collection name during
86
- // the migration transition.
109
+ // These are the domain-neutral storage collections both apps migrate onto.
110
+ // IMPORTANT: `objinv` is NOT included here it is excluded from the broad space
111
+ // member scope so that only per-node caps (nodeMemberScope) can reach it.
112
+ // `spacekeyring` IS included — space members need to reach the keyring to decrypt enc content.
87
113
  export const OBJECT_COLLECTIONS: string[] = [
88
- 'keyring', 'objindex', 'objlog', 'objsnap', 'objdoc', 'objblob', 'typeindex',
114
+ 'spacekeyring', 'objindex', 'objlog', 'objsnap', 'objdoc', 'objblob', 'typeindex', 'objpub',
89
115
  ];
90
116
 
91
117
  // ── Cap scopes ────────────────────────────────────────────────────────────────
@@ -100,9 +126,10 @@ export function ownerScope(): ScopePreset {
100
126
  }
101
127
 
102
128
  /**
103
- * Member access to one SPACE — its keyring + every channel's messages and
104
- * attachments + the room registry, all under `spaces/{spaceId}/**`. One cap
105
- * covers current AND future channels.
129
+ * Member access to one SPACE — the space keyring, every node's content docs and
130
+ * attachments, all under `spaces/{spaceId}/**`. Does NOT cover `objinv` (invite-
131
+ * plaintext content) — use `nodeMemberScope` for that. One cap covers current AND
132
+ * future nodes.
106
133
  */
107
134
  export function spaceMemberScope(spaceId: string, canWrite: boolean): ScopePreset {
108
135
  const ops: ('read' | 'write' | 'list')[] = canWrite ? ['read', 'list', 'write'] : ['read', 'list'];
@@ -113,6 +140,20 @@ export function spaceMemberScope(spaceId: string, canWrite: boolean): ScopePrese
113
140
  };
114
141
  }
115
142
 
143
+ /**
144
+ * Narrow per-node cap for `invite+plaintext` nodes. Covers ONLY the node's `objinv`
145
+ * content path — the space keyring is space-wide and is covered by the broader space
146
+ * member scope. Use `spaceMemberScope` when the bearer also needs to decrypt enc content.
147
+ */
148
+ export function nodeMemberScope(spaceId: string, nodeId: string, canWrite: boolean): ScopePreset {
149
+ const ops: ('read' | 'write' | 'list')[] = canWrite ? ['read', 'list', 'write'] : ['read', 'list'];
150
+ return {
151
+ ops,
152
+ collections: ['objinv'],
153
+ paths: [`spaces/${spaceId}/objects/n/${nodeId}/**`],
154
+ };
155
+ }
156
+
116
157
  /**
117
158
  * Personal cap: profile + space registry + device directory + all spaces.
118
159
  * Note: app-specific collections like `'dminbox'` (chat) are NOT included here —
@@ -121,7 +162,7 @@ export function spaceMemberScope(spaceId: string, canWrite: boolean): ScopePrese
121
162
  export function accountScope(userId: string): ScopePreset {
122
163
  return {
123
164
  ops: ['read', 'list', 'write'],
124
- collections: ['profile', 'devices', 'spaces', 'rooms'],
165
+ collections: ['profile', 'devices', 'spaces', 'spaceregistry'],
125
166
  paths: [
126
167
  `user/${userId}/profile`,
127
168
  `users/${userId}/_devices`,
@@ -139,7 +180,7 @@ export function accountScope(userId: string): ScopePreset {
139
180
  export function linkedDeviceScope(userId: string): ScopePreset {
140
181
  return {
141
182
  ops: ['read', 'list', 'write'],
142
- collections: [...OBJECT_COLLECTIONS, 'profile', 'devices', 'spaces', 'rooms'],
183
+ collections: [...OBJECT_COLLECTIONS, 'profile', 'devices', 'spaces', 'spaceregistry'],
143
184
  paths: [
144
185
  'spaces/**',
145
186
  `user/${userId}/profile`,
@@ -88,6 +88,23 @@ export function removeSpaceAccessEntry(spaceId: string): void {
88
88
  persist();
89
89
  }
90
90
 
91
+ // ── Per-node access entries (keyed by `${spaceId}:${nodeId}`) ────────────────
92
+
93
+ /** Look up a per-node invite access entry. Returns null if not invited or unknown. */
94
+ export function getNodeAccessEntry(spaceId: string, nodeId: string): SpaceAccessEntry | null {
95
+ return cache[`${spaceId}:${nodeId}`] ?? null;
96
+ }
97
+
98
+ /** Persist an invite access entry for one node. */
99
+ export function saveNodeAccessEntry(spaceId: string, nodeId: string, entry: SpaceAccessEntry): void {
100
+ saveSpaceAccessEntry(`${spaceId}:${nodeId}`, entry);
101
+ }
102
+
103
+ /** Forget a node's invite access entry (e.g. on leaving the node). */
104
+ export function removeNodeAccessEntry(spaceId: string, nodeId: string): void {
105
+ removeSpaceAccessEntry(`${spaceId}:${nodeId}`);
106
+ }
107
+
91
108
  /** A snapshot of the in-memory cache — used by `recoverSpaceAccess` to find entries
92
109
  * not yet on the server. */
93
110
  export function localSpaceAccessEntries(): SpaceAccessMap {