@drakkar.software/octospaces-sdk 0.1.0

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.
Files changed (52) hide show
  1. package/dist/index.d.ts +972 -0
  2. package/dist/index.js +1656 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/platform/index.d.ts +9 -0
  5. package/dist/platform/index.js +111 -0
  6. package/dist/platform/index.js.map +1 -0
  7. package/dist/platform/index.native.d.ts +9 -0
  8. package/dist/platform/index.native.js +106 -0
  9. package/dist/platform/index.native.js.map +1 -0
  10. package/package.json +50 -0
  11. package/src/core/adapters.ts +34 -0
  12. package/src/core/config.ts +87 -0
  13. package/src/core/ids.test.ts +45 -0
  14. package/src/core/ids.ts +29 -0
  15. package/src/core/space-access-error.ts +13 -0
  16. package/src/core/storage-types.ts +71 -0
  17. package/src/core/types.ts +162 -0
  18. package/src/index.ts +221 -0
  19. package/src/objects/objects.test.ts +288 -0
  20. package/src/objects/objects.ts +296 -0
  21. package/src/platform/index.native.ts +3 -0
  22. package/src/platform/index.ts +3 -0
  23. package/src/platform/kv.native.ts +23 -0
  24. package/src/platform/kv.ts +29 -0
  25. package/src/platform/platform.native.ts +16 -0
  26. package/src/platform/platform.ts +10 -0
  27. package/src/spaces/members.test.ts +87 -0
  28. package/src/spaces/members.ts +271 -0
  29. package/src/spaces/object-index.test.ts +105 -0
  30. package/src/spaces/object-index.ts +160 -0
  31. package/src/spaces/registry.test.ts +111 -0
  32. package/src/spaces/registry.ts +466 -0
  33. package/src/sync/account-seal.test.ts +70 -0
  34. package/src/sync/account-seal.ts +80 -0
  35. package/src/sync/base64.ts +89 -0
  36. package/src/sync/base64url.ts +22 -0
  37. package/src/sync/client.ts +301 -0
  38. package/src/sync/fetch-timeout.test.ts +26 -0
  39. package/src/sync/fetch-timeout.ts +23 -0
  40. package/src/sync/identity.ts +158 -0
  41. package/src/sync/pairing.ts +103 -0
  42. package/src/sync/paths.test.ts +135 -0
  43. package/src/sync/paths.ts +177 -0
  44. package/src/sync/profile-cache.ts +34 -0
  45. package/src/sync/pull-cache.test.ts +55 -0
  46. package/src/sync/pull-cache.ts +33 -0
  47. package/src/sync/space-access-store.test.ts +129 -0
  48. package/src/sync/space-access-store.ts +117 -0
  49. package/src/sync/space-access.ts +136 -0
  50. package/tsconfig.json +17 -0
  51. package/tsup.config.ts +40 -0
  52. package/vitest.config.ts +12 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Device pairing (one-way, PIN-sealed). The existing device provisions a new
3
+ * device's keypair + cap bundle, seals it with the PIN (Argon2id → AES-GCM), and
4
+ * drops it on the public `_pairing/<nonce>` rendezvous. The QR carries only the
5
+ * nonce; the new device fetches the sealed blob, opens it with the PIN, and
6
+ * validates the cap bundle.
7
+ */
8
+ import { StarfishClient } from '@drakkar.software/starfish-client';
9
+ import {
10
+ installPairingBundle,
11
+ openWithPassphrase,
12
+ provisionDevice,
13
+ sealWithPassphrase,
14
+ } from '@drakkar.software/starfish-identities';
15
+ import type { CapCert } from '@drakkar.software/starfish-protocol';
16
+
17
+ import type { DeviceKeys } from './client.js';
18
+ import { getSyncBase, getSyncNamespace } from '../core/config.js';
19
+ import { fetchWithTimeout } from './fetch-timeout.js';
20
+ import type { Session } from './identity.js';
21
+ import { fingerprintFromUserId } from './identity.js';
22
+ import { addDeviceToSpaceKeyring } from '../spaces/members.js';
23
+ import { bytesToHex, linkedDeviceScope } from './paths.js';
24
+ import { readSpaces } from '../spaces/registry.js';
25
+
26
+ /** The QR-payload prefix this SDK uses. Kept distinct from `octochat-pair:` so apps
27
+ * can route QR payloads to the correct handler during their migration window. */
28
+ export const PAIR_PREFIX = 'octospaces-pair:';
29
+
30
+ // Linked-device cap-cert lifetime — one year keeps a linked device usable long-term.
31
+ const LINKED_DEVICE_TTL_SEC = 365 * 24 * 60 * 60;
32
+
33
+ function anonClient(): StarfishClient {
34
+ return new StarfishClient({ baseUrl: getSyncBase(), namespace: getSyncNamespace(), fetch: fetchWithTimeout() });
35
+ }
36
+
37
+ function randomNonce(): string {
38
+ const b = new Uint8Array(16);
39
+ globalThis.crypto.getRandomValues(b);
40
+ return bytesToHex(b);
41
+ }
42
+
43
+ /** Existing device: provision + PIN-seal a new device, publish to rendezvous, return the QR payload. */
44
+ export async function startDevicePairing(session: Session, pin: string): Promise<string> {
45
+ const { deviceKeys, bundle } = await provisionDevice(
46
+ { edPriv: session.keys.edPriv, edPub: session.keys.edPub },
47
+ { scope: linkedDeviceScope(session.userId), ttlSec: LINKED_DEVICE_TTL_SEC },
48
+ );
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
+ const blob = JSON.stringify({ v: 1, keys: deviceKeys, bundle });
59
+ const sealed = await sealWithPassphrase(pin, new TextEncoder().encode(blob));
60
+ const nonce = randomNonce();
61
+ await anonClient().push(`/push/_pairing/${nonce}`, sealed as unknown as Record<string, unknown>, null);
62
+ return `${PAIR_PREFIX}${nonce}.${session.keys.edPub}`;
63
+ }
64
+
65
+ export interface PairResult {
66
+ userId: string;
67
+ fingerprint: string;
68
+ deviceKeys: DeviceKeys;
69
+ capCert: CapCert;
70
+ }
71
+
72
+ /** New device: fetch the sealed blob by nonce, open with PIN, validate the bundle. */
73
+ export async function completeDevicePairing(payload: string, pin: string): Promise<PairResult> {
74
+ // Accept both `octospaces-pair:` and legacy `octochat-pair:` so apps still using the
75
+ // old QR format can complete a pairing against this SDK during the migration window.
76
+ const body = (payload.startsWith(PAIR_PREFIX) || payload.includes('-pair:')
77
+ ? payload.slice(payload.indexOf(':') + 1)
78
+ : payload).trim();
79
+ const [nonce, expectedRootEdPub] = body.split('.');
80
+ const res = await anonClient().pull(`/pull/_pairing/${nonce}`).catch(() => null);
81
+ const sealed = res?.data as Record<string, unknown> | undefined;
82
+ if (!sealed || !sealed.v) throw new Error('Pairing code not found or expired.');
83
+ let inner: Uint8Array;
84
+ try {
85
+ inner = await openWithPassphrase(pin, sealed as never);
86
+ } catch {
87
+ throw new Error('Wrong PIN or corrupted pairing code.');
88
+ }
89
+ const blob = JSON.parse(new TextDecoder().decode(inner)) as { keys: unknown; bundle: unknown };
90
+ const opts = (expectedRootEdPub ? { expectedRootEdPub } : {}) as Parameters<typeof installPairingBundle>[2];
91
+ const installed = await installPairingBundle(
92
+ blob.bundle as Parameters<typeof installPairingBundle>[0],
93
+ blob.keys as Parameters<typeof installPairingBundle>[1],
94
+ opts,
95
+ );
96
+ const userId = installed.credentials.userId;
97
+ return {
98
+ userId,
99
+ fingerprint: fingerprintFromUserId(userId),
100
+ deviceKeys: installed.credentials.device,
101
+ capCert: installed.credentials.capCert,
102
+ };
103
+ }
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ OBJECT_COLLECTIONS,
4
+ ownerScope,
5
+ spaceMemberScope,
6
+ accountScope,
7
+ linkedDeviceScope,
8
+ keyringPull,
9
+ keyringPush,
10
+ objIndexPull,
11
+ objIndexPush,
12
+ spacesPull,
13
+ spacesPush,
14
+ profilePull,
15
+ spaceIndexPull,
16
+ } from './paths.js';
17
+
18
+ describe('OBJECT_COLLECTIONS', () => {
19
+ it('contains the canonical generic object collections', () => {
20
+ expect(OBJECT_COLLECTIONS).toContain('keyring');
21
+ expect(OBJECT_COLLECTIONS).toContain('objindex');
22
+ expect(OBJECT_COLLECTIONS).toContain('objlog');
23
+ expect(OBJECT_COLLECTIONS).toContain('objdoc');
24
+ expect(OBJECT_COLLECTIONS).toContain('objblob');
25
+ expect(OBJECT_COLLECTIONS).toContain('typeindex');
26
+ });
27
+
28
+ it('does NOT contain chat-only collections', () => {
29
+ expect(OBJECT_COLLECTIONS).not.toContain('chat');
30
+ expect(OBJECT_COLLECTIONS).not.toContain('dminbox');
31
+ });
32
+ });
33
+
34
+ describe('ownerScope', () => {
35
+ it('uses OBJECT_COLLECTIONS', () => {
36
+ const scope = ownerScope();
37
+ expect(scope.collections).toEqual(OBJECT_COLLECTIONS);
38
+ });
39
+
40
+ it('includes wildcard ops', () => {
41
+ const scope = ownerScope();
42
+ expect(scope.ops).toContain('read');
43
+ expect(scope.ops).toContain('write');
44
+ });
45
+ });
46
+
47
+ describe('spaceMemberScope', () => {
48
+ it('scopes to the given spaceId path', () => {
49
+ const scope = spaceMemberScope('sp-abc', true);
50
+ expect(scope.paths).toEqual(expect.arrayContaining([expect.stringContaining('sp-abc')]));
51
+ });
52
+
53
+ it('write=true grants write ops', () => {
54
+ const scope = spaceMemberScope('sp-abc', true);
55
+ expect(scope.ops).toContain('write');
56
+ });
57
+
58
+ it('write=false omits write ops', () => {
59
+ const scope = spaceMemberScope('sp-abc', false);
60
+ expect(scope.ops).not.toContain('write');
61
+ });
62
+
63
+ it('collections equal OBJECT_COLLECTIONS', () => {
64
+ const scope = spaceMemberScope('sp-abc', true);
65
+ expect(scope.collections).toEqual(OBJECT_COLLECTIONS);
66
+ });
67
+ });
68
+
69
+ describe('accountScope', () => {
70
+ it('does NOT contain dminbox', () => {
71
+ const scope = accountScope('user-1');
72
+ expect(scope.collections).not.toContain('dminbox');
73
+ });
74
+
75
+ it('does NOT contain pubspace', () => {
76
+ const scope = accountScope('user-1');
77
+ expect(scope.collections).not.toContain('pubspace');
78
+ });
79
+
80
+ it('does NOT include pubspaces/ paths', () => {
81
+ const scope = accountScope('user-1');
82
+ const hasPubspaces = (scope.paths ?? []).some((p) => p.includes('pubspaces/'));
83
+ expect(hasPubspaces).toBe(false);
84
+ });
85
+
86
+ it('scopes to the given userId', () => {
87
+ const scope = accountScope('user-1');
88
+ expect(scope.paths).toEqual(expect.arrayContaining([expect.stringContaining('user-1')]));
89
+ });
90
+ });
91
+
92
+ describe('linkedDeviceScope', () => {
93
+ it('contains both object and account collections', () => {
94
+ const scope = linkedDeviceScope('user-1');
95
+ expect(scope.collections).toEqual(expect.arrayContaining(['keyring', 'profile', 'spaces']));
96
+ });
97
+
98
+ it('does NOT contain pubspace', () => {
99
+ const scope = linkedDeviceScope('user-1');
100
+ expect(scope.collections).not.toContain('pubspace');
101
+ });
102
+
103
+ it('does NOT include pubspaces/ paths', () => {
104
+ const scope = linkedDeviceScope('user-1');
105
+ const hasPubspaces = (scope.paths ?? []).some((p) => p.includes('pubspaces/'));
106
+ expect(hasPubspaces).toBe(false);
107
+ });
108
+ });
109
+
110
+ describe('spaceIndexPull', () => {
111
+ it('returns a pull path for the public shard', () => {
112
+ expect(spaceIndexPull('public')).toContain('public');
113
+ });
114
+ });
115
+
116
+ describe('path helpers', () => {
117
+ it('keyringPull / keyringPush are symmetric', () => {
118
+ expect(keyringPull('sp-1')).toContain('sp-1');
119
+ expect(keyringPush('sp-1')).toContain('sp-1');
120
+ });
121
+
122
+ it('objIndexPull / objIndexPush are symmetric', () => {
123
+ expect(objIndexPull('sp-1')).toContain('sp-1');
124
+ expect(objIndexPush('sp-1')).toContain('sp-1');
125
+ });
126
+
127
+ it('spacesPull / spacesPush use the userId', () => {
128
+ expect(spacesPull('alice')).toContain('alice');
129
+ expect(spacesPush('alice')).toContain('alice');
130
+ });
131
+
132
+ it('profilePull uses the userId', () => {
133
+ expect(profilePull('alice')).toContain('alice');
134
+ });
135
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Collection path + cap-scope helpers (merged from OctoChat + OctoVault).
3
+ *
4
+ * Paths are signed relative to SYNC_BASE; the server mounts the sync router at
5
+ * root, so they start with /pull or /push. Everything for a space is nested under
6
+ * `spaces/{spaceId}/…` so the `{spaceId}` segment gates it all uniformly through the
7
+ * space:owner/space:member enricher, and a single `spaces/{spaceId}/**` member cap
8
+ * covers a whole space.
9
+ *
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.
14
+ */
15
+ import type { ScopePreset } from '@drakkar.software/starfish-identities';
16
+
17
+ const pull = (rest: string) => `/pull/${rest}`;
18
+ const push = (rest: string) => `/push/${rest}`;
19
+
20
+ /** A room id is `sp-<rand>-<name>`; the space is its first two `-` segments. */
21
+ export const spaceIdFromRoomId = (roomId: string) => roomId.split('-').slice(0, 2).join('-');
22
+
23
+ // ── Space-wide keyring (one per space, shared by all its channels) ────────────
24
+ export const keyringName = (spaceId: string) => `spaces/${spaceId}`;
25
+ export const keyringPull = (spaceId: string) => pull(`${keyringName(spaceId)}/_keyring`);
26
+ export const keyringPush = (spaceId: string) => push(`${keyringName(spaceId)}/_keyring`);
27
+
28
+ // ── Attachments (sealed blobs, in a per-space subtree keyed by room) ──────────
29
+ /** Storage path of one attachment blob — also the AAD bound into its seal. */
30
+ export const attachmentName = (roomId: string, blobId: string) =>
31
+ `spaces/${spaceIdFromRoomId(roomId)}/attachments/${roomId}/${blobId}`;
32
+ export const attachmentPull = (roomId: string, blobId: string) => pull(attachmentName(roomId, blobId));
33
+ export const attachmentPush = (roomId: string, blobId: string) => push(attachmentName(roomId, blobId));
34
+
35
+ // ── Profile + registries ──────────────────────────────────────────────────────
36
+ export const profilePull = (userId: string) => pull(`user/${userId}/profile`);
37
+ export const profilePush = (userId: string) => push(`user/${userId}/profile`);
38
+
39
+ export const spacesPull = (userId: string) => pull(`user/${userId}/_spaces`);
40
+ export const spacesPush = (userId: string) => push(`user/${userId}/_spaces`);
41
+
42
+ export const roomsRegistryPull = (spaceId: string) => pull(`spaces/${spaceId}/_rooms`);
43
+ export const roomsRegistryPush = (spaceId: string) => push(`spaces/${spaceId}/_rooms`);
44
+
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.
56
+ export const objIndexName = (spaceId: string) => `spaces/${spaceId}/objects/_index`;
57
+ export const objIndexPull = (spaceId: string) => pull(objIndexName(spaceId));
58
+ export const objIndexPush = (spaceId: string) => push(objIndexName(spaceId));
59
+
60
+ export const objLogName = (spaceId: string, objectId: string) => `spaces/${spaceId}/objects/logs/${objectId}`;
61
+ export const objLogPull = (spaceId: string, objectId: string) => pull(objLogName(spaceId, objectId));
62
+ export const objLogPush = (spaceId: string, objectId: string) => push(objLogName(spaceId, objectId));
63
+
64
+ export const objDocName = (spaceId: string, objectId: string) => `spaces/${spaceId}/objects/docs/${objectId}`;
65
+ export const objDocPull = (spaceId: string, objectId: string) => pull(objDocName(spaceId, objectId));
66
+ export const objDocPush = (spaceId: string, objectId: string) => push(objDocName(spaceId, objectId));
67
+
68
+ /** Storage path of one sealed object blob — also the AAD bound into its seal. */
69
+ export const objectBlobName = (spaceId: string, blobId: string) => `spaces/${spaceId}/objects/blobs/${blobId}`;
70
+ export const objectBlobPull = (spaceId: string, blobId: string) => pull(objectBlobName(spaceId, blobId));
71
+ export const objectBlobPush = (spaceId: string, blobId: string) => push(objectBlobName(spaceId, blobId));
72
+
73
+ // ── Per-space custom type registry (private/E2EE) ─────────────────────────────
74
+ export const typesIndexName = (spaceId: string) => `spaces/${spaceId}/types/_index`;
75
+ export const typesIndexPull = (spaceId: string) => pull(typesIndexName(spaceId));
76
+ export const typesIndexPush = (spaceId: string) => push(typesIndexName(spaceId));
77
+
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));
81
+
82
+ // ── 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.
87
+ export const OBJECT_COLLECTIONS: string[] = [
88
+ 'keyring', 'objindex', 'objlog', 'objsnap', 'objdoc', 'objblob', 'typeindex',
89
+ ];
90
+
91
+ // ── Cap scopes ────────────────────────────────────────────────────────────────
92
+
93
+ /** Full owner/device access to every space the identity owns. */
94
+ export function ownerScope(): ScopePreset {
95
+ return {
96
+ ops: ['read', 'list', 'write'],
97
+ collections: OBJECT_COLLECTIONS,
98
+ paths: ['spaces/**'],
99
+ };
100
+ }
101
+
102
+ /**
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.
106
+ */
107
+ export function spaceMemberScope(spaceId: string, canWrite: boolean): ScopePreset {
108
+ const ops: ('read' | 'write' | 'list')[] = canWrite ? ['read', 'list', 'write'] : ['read', 'list'];
109
+ return {
110
+ ops,
111
+ collections: OBJECT_COLLECTIONS,
112
+ paths: [`spaces/${spaceId}/**`],
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Personal cap: profile + space registry + device directory + all spaces.
118
+ * Note: app-specific collections like `'dminbox'` (chat) are NOT included here —
119
+ * add them in the consumer's own `paths.ts` extension.
120
+ */
121
+ export function accountScope(userId: string): ScopePreset {
122
+ return {
123
+ ops: ['read', 'list', 'write'],
124
+ collections: ['profile', 'devices', 'spaces', 'rooms'],
125
+ paths: [
126
+ `user/${userId}/profile`,
127
+ `users/${userId}/_devices`,
128
+ `user/${userId}/_spaces`,
129
+ 'spaces/**',
130
+ ],
131
+ };
132
+ }
133
+
134
+ /**
135
+ * The single cap-cert scope granted to a PAIRED (linked) device. Covers both the
136
+ * object-store client (ownerScope) and the account client (accountScope), deduped,
137
+ * because a paired device cannot self-mint — it presents one root-signed cap-cert.
138
+ */
139
+ export function linkedDeviceScope(userId: string): ScopePreset {
140
+ return {
141
+ ops: ['read', 'list', 'write'],
142
+ collections: [...OBJECT_COLLECTIONS, 'profile', 'devices', 'spaces', 'rooms'],
143
+ paths: [
144
+ 'spaces/**',
145
+ `user/${userId}/profile`,
146
+ `users/${userId}/_devices`,
147
+ `user/${userId}/_spaces`,
148
+ ],
149
+ };
150
+ }
151
+
152
+ /** Extract the single space id a member cap is scoped to (from its `spaces/<id>/**`).
153
+ * Returns null if the cap names no space path OR more than one distinct space. */
154
+ export function spaceIdFromCap(cap: { scope?: { paths?: string[] } }): string | null {
155
+ let found: string | null = null;
156
+ for (const p of cap.scope?.paths ?? []) {
157
+ const m = /^spaces\/([^/]+)\//.exec(p);
158
+ if (!m) continue;
159
+ if (found !== null && found !== m[1]) return null;
160
+ found = m[1]!;
161
+ }
162
+ return found;
163
+ }
164
+
165
+ export function bytesToHex(b: Uint8Array): string {
166
+ let s = '';
167
+ for (const x of b) s += x.toString(16).padStart(2, '0');
168
+ return s;
169
+ }
170
+
171
+ /** The canonical identity derivation: `userId = sha256(edPub)[0:32]` (hex). */
172
+ export async function userIdFromEdPub(edPubHex: string): Promise<string> {
173
+ const bytes = new Uint8Array(edPubHex.length / 2);
174
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(edPubHex.slice(i * 2, i * 2 + 2), 16);
175
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
176
+ return bytesToHex(new Uint8Array(digest)).slice(0, 32);
177
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Offline-first cache for public profiles (pseudo + inline avatar).
3
+ *
4
+ * Profiles are PUBLIC plaintext so they don't flow through the SDK's read-through
5
+ * pull cache. This kv cache gives them the same offline-first behavior: a successful
6
+ * read is persisted per user, and a read that fails because the device is offline
7
+ * falls back to the last-known pseudo/avatar.
8
+ */
9
+ import { kvGet, kvSet } from '../core/adapters.js';
10
+ import type { PublicProfile } from './client.js';
11
+
12
+ const key = (userId: string) => `octospaces.profile.v1.${userId}`;
13
+
14
+ /** Persist a freshly-read profile (fire-and-forget). */
15
+ export function cacheProfile(userId: string, profile: PublicProfile): void {
16
+ void kvSet(key(userId), JSON.stringify(profile)).catch(() => {});
17
+ }
18
+
19
+ /** Last-known profile for a user, or null if never cached / unparseable. */
20
+ export async function loadCachedProfile(userId: string): Promise<PublicProfile | null> {
21
+ try {
22
+ const raw = await kvGet(key(userId));
23
+ if (!raw) return null;
24
+ const d = JSON.parse(raw) as Partial<PublicProfile>;
25
+ return {
26
+ pseudo: typeof d.pseudo === 'string' ? d.pseudo : null,
27
+ avatar: typeof d.avatar === 'string' ? d.avatar : null,
28
+ edPub: typeof d.edPub === 'string' ? d.edPub : null,
29
+ kemPub: typeof d.kemPub === 'string' ? d.kemPub : null,
30
+ };
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { configureKv } from '../core/adapters.js';
3
+ import { pullCache, PULL_CACHE_MAX_AGE_MS } from './pull-cache.js';
4
+
5
+ // Reset the singleton between tests by resetting the module-level `shared`
6
+ // — we work around it by reconfiguring KV with a fresh store each time.
7
+ let store: Map<string, string>;
8
+
9
+ function makeKv() {
10
+ store = new Map<string, string>();
11
+ configureKv({
12
+ get: (k) => Promise.resolve(store.get(k) ?? null),
13
+ set: (k, v) => { store.set(k, v); return Promise.resolve(); },
14
+ remove: (k) => { store.delete(k); return Promise.resolve(); },
15
+ });
16
+ }
17
+
18
+ describe('PULL_CACHE_MAX_AGE_MS', () => {
19
+ it('is a positive number', () => {
20
+ expect(PULL_CACHE_MAX_AGE_MS).toBeGreaterThan(0);
21
+ });
22
+ });
23
+
24
+ describe('pullCache', () => {
25
+ beforeEach(() => { makeKv(); });
26
+
27
+ it('returns an object with get and set', () => {
28
+ const cache = pullCache();
29
+ expect(typeof cache.get).toBe('function');
30
+ expect(typeof cache.set).toBe('function');
31
+ });
32
+
33
+ it('set + get round-trip stores and retrieves a string', async () => {
34
+ const cache = pullCache();
35
+ const key = 'test-key';
36
+ const value = JSON.stringify({ data: { hello: 'world' }, hash: 'abc123' });
37
+ await cache.set(key, value);
38
+ const hit = await cache.get(key);
39
+ expect(hit).toBe(value);
40
+ });
41
+
42
+ it('returns null for unknown key', async () => {
43
+ const cache = pullCache();
44
+ const hit = await cache.get('no-such-key');
45
+ expect(hit).toBeNull();
46
+ });
47
+
48
+ it('uses octospaces.pullcache. prefix internally', async () => {
49
+ const cache = pullCache();
50
+ await cache.set('mykey', 'myvalue');
51
+ // The key in the underlying store should have the prefix
52
+ const raw = store.get('octospaces.pullcache.mykey');
53
+ expect(raw).toBe('myvalue');
54
+ });
55
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The app's offline-first read cache for every {@link StarfishClient}.
3
+ *
4
+ * Backs the SDK's {@link PullCache} (read-through pull cache) with the kv layer
5
+ * (localStorage on web, AsyncStorage on native). When a client is built with this
6
+ * cache, every successful structured `pull()` is written through, and a pull that
7
+ * fails because the transport is unreachable falls back to the last-synced snapshot.
8
+ *
9
+ * SECURITY: the SDK caches the RAW server response only. For E2E collections that
10
+ * payload is the SEALED ciphertext the server holds — never the decrypted form —
11
+ * so this cache is ciphertext-at-rest by construction.
12
+ */
13
+ import type { PullCache } from '@drakkar.software/starfish-client';
14
+
15
+ import { kvGet, kvSet } from '../core/adapters.js';
16
+
17
+ const PREFIX = 'octospaces.pullcache.';
18
+
19
+ /**
20
+ * Max age for a cached snapshot before it's treated as a miss. Generous (30 days)
21
+ * because for an offline-first app any last-synced data beats none.
22
+ */
23
+ export const PULL_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
24
+
25
+ let shared: PullCache | undefined;
26
+
27
+ /** The shared app-wide pull cache (one instance, reused across every client). */
28
+ export function pullCache(): PullCache {
29
+ return (shared ??= {
30
+ get: (key) => kvGet(PREFIX + key),
31
+ set: (key, value) => kvSet(PREFIX + key, value),
32
+ });
33
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { configureKv } from '../core/adapters.js';
3
+ import {
4
+ clearSpaceAccessStore,
5
+ getSpaceAccessEntry,
6
+ hydrateSpaceAccessStore,
7
+ localSpaceAccessEntries,
8
+ memberCapsFromStore,
9
+ linkAccessFromStore,
10
+ removeSpaceAccessEntry,
11
+ saveSpaceAccessEntry,
12
+ } from './space-access-store.js';
13
+
14
+ let store: Map<string, string>;
15
+
16
+ function makeKvStore() {
17
+ store = new Map<string, string>();
18
+ configureKv({
19
+ get: (k) => Promise.resolve(store.get(k) ?? null),
20
+ set: (k, v) => { store.set(k, v); return Promise.resolve(); },
21
+ remove: (k) => { store.delete(k); return Promise.resolve(); },
22
+ });
23
+ return store;
24
+ }
25
+
26
+ describe('space-access-store', () => {
27
+ beforeEach(() => {
28
+ clearSpaceAccessStore();
29
+ makeKvStore();
30
+ });
31
+
32
+ it('returns null for unknown space', () => {
33
+ expect(getSpaceAccessEntry('sp-unknown')).toBeNull();
34
+ });
35
+
36
+ it('save + get member round-trip', () => {
37
+ saveSpaceAccessEntry('sp-test', { kind: 'member', cap: '{"kind":"member"}' });
38
+ expect(getSpaceAccessEntry('sp-test')).toEqual({ kind: 'member', cap: '{"kind":"member"}' });
39
+ });
40
+
41
+ it('save + get link round-trip', () => {
42
+ saveSpaceAccessEntry('sp-pub', { kind: 'link', cap: { kind: 'member' }, key: 'hexkey', write: true });
43
+ const entry = getSpaceAccessEntry('sp-pub');
44
+ expect(entry?.kind).toBe('link');
45
+ if (entry?.kind === 'link') {
46
+ expect(entry.key).toBe('hexkey');
47
+ expect(entry.write).toBe(true);
48
+ }
49
+ });
50
+
51
+ it('remove drops a stored entry', () => {
52
+ saveSpaceAccessEntry('sp-abc', { kind: 'member', cap: 'capjson' });
53
+ removeSpaceAccessEntry('sp-abc');
54
+ expect(getSpaceAccessEntry('sp-abc')).toBeNull();
55
+ });
56
+
57
+ it('clear wipes all entries', () => {
58
+ saveSpaceAccessEntry('sp-1', { kind: 'member', cap: 'a' });
59
+ saveSpaceAccessEntry('sp-2', { kind: 'member', cap: 'b' });
60
+ clearSpaceAccessStore();
61
+ expect(getSpaceAccessEntry('sp-1')).toBeNull();
62
+ expect(getSpaceAccessEntry('sp-2')).toBeNull();
63
+ });
64
+
65
+ it('hydrate loads caps from kv', async () => {
66
+ const caps = { 'sp-x': '{"kind":"member","sub":"abc"}' };
67
+ store.set('octospaces.spaceaccess.user123', JSON.stringify({
68
+ 'sp-x': { kind: 'member', cap: '{"kind":"member","sub":"abc"}' },
69
+ }));
70
+ clearSpaceAccessStore();
71
+ await hydrateSpaceAccessStore('user123', {}, {});
72
+ expect(getSpaceAccessEntry('sp-x')).toEqual({ kind: 'member', cap: '{"kind":"member","sub":"abc"}' });
73
+ void caps;
74
+ });
75
+
76
+ it('server caps override local on hydrate', async () => {
77
+ // Local has old value
78
+ store.set('octospaces.spaceaccess.user1', JSON.stringify({
79
+ 'sp-a': { kind: 'member', cap: 'old-cap' },
80
+ }));
81
+ clearSpaceAccessStore();
82
+ await hydrateSpaceAccessStore('user1', { 'sp-a': 'new-cap' }, {});
83
+ expect(getSpaceAccessEntry('sp-a')).toEqual({ kind: 'member', cap: 'new-cap' });
84
+ });
85
+
86
+ it('server link access populates link entries', async () => {
87
+ clearSpaceAccessStore();
88
+ await hydrateSpaceAccessStore('user2', {}, {
89
+ 'sp-link': { cap: { kind: 'member' }, key: 'privhex', write: false },
90
+ });
91
+ const entry = getSpaceAccessEntry('sp-link');
92
+ expect(entry?.kind).toBe('link');
93
+ if (entry?.kind === 'link') expect(entry.write).toBe(false);
94
+ });
95
+
96
+ it('uses octospaces. prefix for kv key (not octochat.)', async () => {
97
+ saveSpaceAccessEntry('sp-test', { kind: 'member', cap: '{"kind":"member"}' });
98
+ store.set('octospaces.spaceaccess.user-abc', JSON.stringify({
99
+ 'sp-test': { kind: 'member', cap: '{"kind":"member"}' },
100
+ }));
101
+ clearSpaceAccessStore();
102
+ await hydrateSpaceAccessStore('user-abc', {}, {});
103
+ const legacyKey = Array.from(store.keys()).find((k) => k.startsWith('octochat.'));
104
+ expect(legacyKey).toBeUndefined();
105
+ });
106
+
107
+ it('memberCapsFromStore returns only member entries', () => {
108
+ saveSpaceAccessEntry('sp-m', { kind: 'member', cap: 'cap1' });
109
+ saveSpaceAccessEntry('sp-l', { kind: 'link', cap: {}, key: 'k', write: false });
110
+ const caps = memberCapsFromStore();
111
+ expect(caps).toHaveProperty('sp-m', 'cap1');
112
+ expect(caps).not.toHaveProperty('sp-l');
113
+ });
114
+
115
+ it('linkAccessFromStore returns only link entries', () => {
116
+ saveSpaceAccessEntry('sp-m', { kind: 'member', cap: 'cap1' });
117
+ saveSpaceAccessEntry('sp-l', { kind: 'link', cap: { iss: 'abc' }, key: 'k', write: true });
118
+ const links = linkAccessFromStore();
119
+ expect(links).toHaveProperty('sp-l');
120
+ expect(links['sp-l']?.write).toBe(true);
121
+ expect(links).not.toHaveProperty('sp-m');
122
+ });
123
+
124
+ it('localSpaceAccessEntries returns a snapshot', () => {
125
+ saveSpaceAccessEntry('sp-snap', { kind: 'member', cap: 'c' });
126
+ const snap = localSpaceAccessEntries();
127
+ expect(snap).toHaveProperty('sp-snap');
128
+ });
129
+ });