@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,117 @@
1
+ /**
2
+ * Unified local access store for spaces this identity has joined.
3
+ *
4
+ * Replaces the separate `member-caps.ts` (private spaces) and `pubspace-caps.ts`
5
+ * (public/link spaces). Two entry kinds:
6
+ * - `member`: a member cap-cert (plain JSON, no bearer secret — safe to store
7
+ * in the clear). Used for PRIVATE space keyring opens.
8
+ * - `link`: an ephemeral-subject cap + the link's Ed25519 private key. Embeds a
9
+ * bearer secret so it is SEALED in the synced `_spaces.pubAccess` field before
10
+ * leaving this device; the local kv stores it plaintext only on the owning device.
11
+ *
12
+ * Two tiers (same as old member-caps): device-local kv (fast, offline) and the
13
+ * user's synced `_spaces` doc (durable source of truth; merged over local on hydrate).
14
+ * Keyed PER-USER so multiple accounts on one device never see each other's entries.
15
+ */
16
+ import type { CapMap, PubAccessMap } from '../core/types.js';
17
+ import type { SealedBlob } from './account-seal.js';
18
+ import { kvGet, kvSet } from '../core/adapters.js';
19
+
20
+ export type SpaceAccessEntry =
21
+ | { kind: 'member'; cap: string }
22
+ | { kind: 'link'; cap: unknown; key: string; write: boolean };
23
+
24
+ export type SpaceAccessMap = Record<string, SpaceAccessEntry>;
25
+
26
+ const keyFor = (userId: string) => `octospaces.spaceaccess.${userId}`;
27
+
28
+ let cache: SpaceAccessMap = {};
29
+ let activeKey: string | null = null;
30
+
31
+ /**
32
+ * Load the active account's space-access entries into memory. Call (and await) on
33
+ * sign-in and on every account switch, before opening rooms.
34
+ *
35
+ * `serverCaps` (private member caps from `_spaces.caps`) and `serverPubAccess`
36
+ * (sealed link credentials from `_spaces.pubAccess`, already unsealed by the caller)
37
+ * are merged OVER the local kv cache (server wins).
38
+ */
39
+ export async function hydrateSpaceAccessStore(
40
+ userId: string,
41
+ serverCaps: CapMap,
42
+ serverLinkAccess: Record<string, { cap: unknown; key: string; write: boolean }>,
43
+ ): Promise<void> {
44
+ const key = keyFor(userId);
45
+ if (activeKey === key) return;
46
+ activeKey = key;
47
+ cache = {};
48
+ const raw = await kvGet(key);
49
+ if (raw) {
50
+ try {
51
+ cache = JSON.parse(raw) as SpaceAccessMap;
52
+ } catch (e) {
53
+ console.error('[octospaces] space-access-store: corrupt cache, resetting:', e);
54
+ cache = {};
55
+ }
56
+ }
57
+ let changed = false;
58
+ for (const [spaceId, capJson] of Object.entries(serverCaps)) {
59
+ cache[spaceId] = { kind: 'member', cap: capJson };
60
+ changed = true;
61
+ }
62
+ for (const [spaceId, access] of Object.entries(serverLinkAccess)) {
63
+ cache[spaceId] = { kind: 'link', cap: access.cap, key: access.key, write: access.write };
64
+ changed = true;
65
+ }
66
+ if (changed) await kvSet(key, JSON.stringify(cache));
67
+ }
68
+
69
+ function persist(): void {
70
+ if (activeKey) void kvSet(activeKey, JSON.stringify(cache));
71
+ }
72
+
73
+ export function getSpaceAccessEntry(spaceId: string): SpaceAccessEntry | null {
74
+ return cache[spaceId] ?? null;
75
+ }
76
+
77
+ export function saveSpaceAccessEntry(spaceId: string, entry: SpaceAccessEntry): void {
78
+ cache = { ...cache, [spaceId]: entry };
79
+ persist();
80
+ }
81
+
82
+ /** Forget one space's access (on leaving that space). */
83
+ export function removeSpaceAccessEntry(spaceId: string): void {
84
+ if (!(spaceId in cache)) return;
85
+ const next = { ...cache };
86
+ delete next[spaceId];
87
+ cache = next;
88
+ persist();
89
+ }
90
+
91
+ /** A snapshot of the in-memory cache — used by `recoverSpaceAccess` to find entries
92
+ * not yet on the server. */
93
+ export function localSpaceAccessEntries(): SpaceAccessMap {
94
+ return cache;
95
+ }
96
+
97
+ /** Build the `CapMap` slice (member entries only) for persisting into `_spaces.caps`. */
98
+ export function memberCapsFromStore(): CapMap {
99
+ const out: CapMap = {};
100
+ for (const [id, e] of Object.entries(cache)) if (e.kind === 'member') out[id] = e.cap;
101
+ return out;
102
+ }
103
+
104
+ /** Build the `PubAccessMap` slice (link entries already sealed by the caller). */
105
+ export function linkAccessFromStore(): Record<string, { cap: unknown; key: string; write: boolean }> {
106
+ const out: Record<string, { cap: unknown; key: string; write: boolean }> = {};
107
+ for (const [id, e] of Object.entries(cache)) {
108
+ if (e.kind === 'link') out[id] = { cap: e.cap, key: e.key, write: e.write };
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** Drop the in-memory cache (on account switch / sign-out). */
114
+ export function clearSpaceAccessStore(): void {
115
+ cache = {};
116
+ activeKey = null;
117
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Space access resolver — returns the right (client, encryptor) pair for any
3
+ * space regardless of whether it is private (E2EE) or public (plaintext).
4
+ *
5
+ * Replaces `space-encryptor.ts`. The key invariant: public spaces have
6
+ * `encryptor: null`; private spaces always have a live `Encryptor`.
7
+ *
8
+ * Resolution order (same semantics as the old `getSpaceEncryptor`):
9
+ * 1. Link entry in the access store → sign requests as the ephemeral identity;
10
+ * no keyring, encryptor null.
11
+ * 2. Member entry → open the keyring as a recipient with the stored cap.
12
+ * 3. No entry + visibility === 'public' (from `reg`) → owner mode, no keyring.
13
+ * 4. No entry, private → either owner (open/mint keyring) or SpaceAccessError
14
+ * if the space's roster shows we're a member but we're not holding a cap yet.
15
+ */
16
+ import type { Encryptor, StarfishClient } from '@drakkar.software/starfish-client';
17
+
18
+ import { buildEncryptor, makeClient, openEncryptor, ownerEnsureKeyring } from './client.js';
19
+ import type { Session } from './identity.js';
20
+ import { ownerTrustedAdders } from './identity.js';
21
+ import { getSpaceAccessEntry } from './space-access-store.js';
22
+ import { SpaceAccessError } from '../core/space-access-error.js';
23
+ import type { SpaceVisibility } from '../core/types.js';
24
+
25
+ // Re-export so existing importers keep reaching SpaceAccessError through this module.
26
+ export { SpaceAccessError };
27
+
28
+ export interface SpaceAccessHandle {
29
+ encryptor: Encryptor | null;
30
+ client: StarfishClient;
31
+ /** True when opened as the space OWNER (so the caller must seed the room doc). */
32
+ isOwnerOpen: boolean;
33
+ }
34
+
35
+ const cache = new Map<string, Promise<SpaceAccessHandle>>();
36
+
37
+ /** Drop every cached handle (on account switch — keys are per-identity). */
38
+ export function clearSpaceAccessCache(): void {
39
+ cache.clear();
40
+ }
41
+
42
+ /**
43
+ * Resolve the right (client, encryptor) for a space, opening and caching on first use.
44
+ *
45
+ * `reg` is the space's `_rooms` access record if already known. Pass null when the
46
+ * caller has not yet read the registry; the resolver will probe if needed.
47
+ */
48
+ export function getSpaceAccess(
49
+ spaceId: string,
50
+ session: Session,
51
+ reg: { owner: string | null; members: string[]; visibility?: SpaceVisibility } | null,
52
+ ): Promise<SpaceAccessHandle> {
53
+ const hit = cache.get(spaceId);
54
+ if (hit) return hit;
55
+ const p = (async (): Promise<SpaceAccessHandle> => {
56
+ const entry = getSpaceAccessEntry(spaceId);
57
+
58
+ // 1. Link entry — ephemeral identity; no keyring
59
+ if (entry?.kind === 'link') {
60
+ const cap = entry.cap;
61
+ const client = makeClient(cap, entry.key);
62
+ return { encryptor: null, client, isOwnerOpen: false };
63
+ }
64
+
65
+ // 2. Member entry — open as a keyring recipient
66
+ if (entry?.kind === 'member') {
67
+ const cap = JSON.parse(entry.cap) as { iss?: string };
68
+ const client = makeClient(cap, session.keys.edPriv);
69
+ const encryptor = await openEncryptor(client, session.keys, spaceId, cap.iss ? [cap.iss] : []);
70
+ return { encryptor, client, isOwnerOpen: false };
71
+ }
72
+
73
+ // 3. No entry — branch on visibility
74
+ const visibility = reg?.visibility;
75
+ if (visibility === 'public') {
76
+ return { encryptor: null, client: session.chatClient, isOwnerOpen: reg!.owner === session.userId };
77
+ }
78
+
79
+ // 4. No entry, private — owner or error
80
+ const owner = reg?.owner ?? null;
81
+ const members = reg?.members ?? [];
82
+ if (owner !== null && owner !== session.userId) {
83
+ throw new SpaceAccessError(
84
+ members.includes(session.userId)
85
+ ? "You're a member of this space, but its key isn't on this device yet — reconnect, or ask the owner to re-invite."
86
+ : "You don't have access to this space.",
87
+ );
88
+ }
89
+ const encryptor = await ownerEnsureKeyring(
90
+ session.chatClient,
91
+ session.keys,
92
+ spaceId,
93
+ ownerTrustedAdders(session),
94
+ );
95
+ return { encryptor, client: session.chatClient, isOwnerOpen: true };
96
+ })();
97
+ cache.set(spaceId, p);
98
+ p.catch(() => cache.delete(spaceId));
99
+ return p;
100
+ }
101
+
102
+ /**
103
+ * SOFT resolve — never mints a keyring, never throws on missing access.
104
+ * Returns null when the identity has no usable access for the space yet.
105
+ */
106
+ export async function buildSpaceAccess(
107
+ session: Session,
108
+ spaceId: string,
109
+ hint?: { visibility?: SpaceVisibility },
110
+ ): Promise<{ client: StarfishClient; encryptor: Encryptor | null } | null> {
111
+ const entry = getSpaceAccessEntry(spaceId);
112
+
113
+ if (entry?.kind === 'link') {
114
+ const client = makeClient(entry.cap, entry.key);
115
+ return { client, encryptor: null };
116
+ }
117
+
118
+ let client = session.chatClient;
119
+ let trustedAdders = ownerTrustedAdders(session);
120
+
121
+ if (entry?.kind === 'member') {
122
+ const cap = JSON.parse(entry.cap) as { iss?: string };
123
+ client = makeClient(cap, session.keys.edPriv);
124
+ if (cap.iss) trustedAdders = [cap.iss];
125
+ const encryptor = await buildEncryptor(client, session.keys, spaceId, trustedAdders);
126
+ return encryptor ? { client, encryptor } : null;
127
+ }
128
+
129
+ if (hint?.visibility === 'public') {
130
+ return { client, encryptor: null };
131
+ }
132
+
133
+ // No entry, no hint — try the keyring probe (owner path)
134
+ const encryptor = await buildEncryptor(client, session.keys, spaceId, trustedAdders);
135
+ return encryptor ? { client, encryptor } : null;
136
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "target": "ES2020",
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "lib": ["ES2020", "DOM"],
8
+ "outDir": "./dist",
9
+ "rootDir": "./src",
10
+ "declaration": true,
11
+ "declarationMap": true,
12
+ "sourceMap": true,
13
+ "types": ["node"]
14
+ },
15
+ "include": ["src"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ // Deps that will never be present at SDK build time (native peer deps)
4
+ const NATIVE_EXTERNALS = [
5
+ 'react-native-quick-crypto',
6
+ '@react-native-async-storage/async-storage',
7
+ ];
8
+
9
+ export default defineConfig([
10
+ {
11
+ entry: { index: 'src/index.ts' },
12
+ format: ['esm'],
13
+ dts: true,
14
+ clean: true,
15
+ splitting: false,
16
+ sourcemap: true,
17
+ target: 'es2020',
18
+ },
19
+ {
20
+ entry: { 'platform/index': 'src/platform/index.ts' },
21
+ format: ['esm'],
22
+ dts: true,
23
+ splitting: false,
24
+ sourcemap: true,
25
+ target: 'es2020',
26
+ outDir: 'dist',
27
+ },
28
+ {
29
+ entry: { 'platform/index.native': 'src/platform/index.native.ts' },
30
+ format: ['esm'],
31
+ dts: true,
32
+ splitting: false,
33
+ sourcemap: true,
34
+ target: 'es2020',
35
+ outDir: 'dist',
36
+ external: NATIVE_EXTERNALS,
37
+ // Do NOT resolve or bundle native platform deps — they are provided at runtime.
38
+ noExternal: [],
39
+ },
40
+ ]);
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ globals: false,
7
+ include: ['src/**/*.test.ts'],
8
+ // Run serially — some tests share module-level singletons (member-caps cache).
9
+ pool: 'forks',
10
+ poolOptions: { forks: { singleFork: true } },
11
+ },
12
+ });