@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.
- package/CHANGELOG.md +200 -0
- package/dist/index.d.ts +337 -273
- package/dist/index.js +812 -469
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/types.ts +47 -81
- package/src/index.ts +42 -28
- package/src/objects/objects.test.ts +55 -95
- package/src/objects/objects.ts +23 -136
- package/src/spaces/members.test.ts +10 -3
- package/src/spaces/members.ts +86 -49
- package/src/spaces/nodes.test.ts +225 -0
- package/src/spaces/nodes.ts +427 -0
- package/src/spaces/object-index.test.ts +127 -71
- package/src/spaces/object-index.ts +61 -107
- package/src/spaces/registry.test.ts +59 -46
- package/src/spaces/registry.ts +28 -47
- package/src/sync/client.ts +20 -15
- package/src/sync/pairing.ts +10 -12
- package/src/sync/paths.test.ts +124 -16
- package/src/sync/paths.ts +73 -32
- package/src/sync/space-access-store.ts +17 -0
- package/src/sync/space-access.ts +112 -67
package/src/sync/pairing.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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();
|
package/src/sync/paths.test.ts
CHANGED
|
@@ -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
|
|
20
|
-
expect(OBJECT_COLLECTIONS).toContain('
|
|
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
|
|
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
|
|
158
|
+
it('contains spacekeyring (space-wide keyring)', () => {
|
|
94
159
|
const scope = linkedDeviceScope('user-1');
|
|
95
|
-
expect(scope.collections).
|
|
160
|
+
expect(scope.collections).toContain('spacekeyring');
|
|
96
161
|
});
|
|
97
162
|
|
|
98
|
-
it('does NOT contain
|
|
163
|
+
it('does NOT contain nodekeyring (removed — keyring is space-wide)', () => {
|
|
99
164
|
const scope = linkedDeviceScope('user-1');
|
|
100
|
-
expect(scope.collections).not.toContain('
|
|
165
|
+
expect(scope.collections).not.toContain('nodekeyring');
|
|
101
166
|
});
|
|
102
167
|
|
|
103
|
-
it('
|
|
168
|
+
it('contains standard account collections', () => {
|
|
104
169
|
const scope = linkedDeviceScope('user-1');
|
|
105
|
-
|
|
106
|
-
|
|
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('
|
|
179
|
+
describe('objectDirPull', () => {
|
|
111
180
|
it('returns a pull path for the public shard', () => {
|
|
112
|
-
expect(
|
|
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
|
|
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('
|
|
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
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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,
|
|
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
|
|
43
|
-
export const
|
|
48
|
+
export const spaceAccessPull = (spaceId: string) => pull(`spaces/${spaceId}/_access`);
|
|
49
|
+
export const spaceAccessPush = (spaceId: string) => push(`spaces/${spaceId}/_access`);
|
|
44
50
|
|
|
45
|
-
// ──
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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
|
-
// ──
|
|
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
|
-
// ──
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// the
|
|
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
|
-
'
|
|
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 —
|
|
104
|
-
* attachments
|
|
105
|
-
* covers current AND
|
|
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', '
|
|
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', '
|
|
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 {
|