@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.
@@ -1,136 +1,181 @@
1
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).
2
+ * Space and node access resolver.
4
3
  *
5
- * Replaces `space-encryptor.ts`. The key invariant: public spaces have
6
- * `encryptor: null`; private spaces always have a live `Encryptor`.
4
+ * Encryption is per-node (each node has an `enc` flag) but keyed under ONE
5
+ * space-wide keyring at `spaces/{spaceId}/_keyring`. All `enc` nodes in a space
6
+ * share the same CEK; `access` gates *fetching*, the keyring gates *decryption*.
7
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.
8
+ * Two entry points:
9
+ * - `getSpaceClient` — returns the right StarfishClient for member-gated
10
+ * space docs (index, _access). No encryptor.
11
+ * - `getNodeAccess` — resolves the (client, encryptor) for a specific node's
12
+ * CONTENT. Encryptor is null for plaintext nodes; for enc nodes the encryptor
13
+ * opens the SPACE keyring (not a per-node keyring).
14
+ *
15
+ * Resolution order for `getNodeAccess`:
16
+ * 1. Per-node link entry → sign as ephemeral identity; encryptor from space keyring.
17
+ * 2. Per-node member entry → open space keyring as recipient.
18
+ * 3. Space-level link entry → same client; open space keyring if enc.
19
+ * 4. Space-level member entry → open space keyring if enc.
20
+ * 5. No entry, owner → mint space keyring if enc; plain client otherwise.
21
+ * 6. No entry, non-owner → SpaceAccessError if enc; plain client otherwise.
15
22
  */
16
23
  import type { Encryptor, StarfishClient } from '@drakkar.software/starfish-client';
17
24
 
18
25
  import { buildEncryptor, makeClient, openEncryptor, ownerEnsureKeyring } from './client.js';
19
26
  import type { Session } from './identity.js';
20
27
  import { ownerTrustedAdders } from './identity.js';
21
- import { getSpaceAccessEntry } from './space-access-store.js';
28
+ import { getNodeAccessEntry, getSpaceAccessEntry } from './space-access-store.js';
22
29
  import { SpaceAccessError } from '../core/space-access-error.js';
23
- import type { SpaceVisibility } from '../core/types.js';
30
+ import { keyringPull, keyringPush } from './paths.js';
31
+ import type { NodeAccess } from '../core/types.js';
24
32
 
25
33
  // Re-export so existing importers keep reaching SpaceAccessError through this module.
26
34
  export { SpaceAccessError };
27
35
 
28
- export interface SpaceAccessHandle {
36
+ export interface NodeAccessHandle {
29
37
  encryptor: Encryptor | null;
30
38
  client: StarfishClient;
31
- /** True when opened as the space OWNER (so the caller must seed the room doc). */
39
+ /** True when opened as the space OWNER (may seed / mint the space keyring). */
32
40
  isOwnerOpen: boolean;
33
41
  }
34
42
 
35
- const cache = new Map<string, Promise<SpaceAccessHandle>>();
43
+ const cache = new Map<string, Promise<NodeAccessHandle>>();
36
44
 
37
45
  /** Drop every cached handle (on account switch — keys are per-identity). */
38
- export function clearSpaceAccessCache(): void {
46
+ export function clearNodeAccessCache(): void {
39
47
  cache.clear();
40
48
  }
41
49
 
42
50
  /**
43
- * Resolve the right (client, encryptor) for a space, opening and caching on first use.
51
+ * Return the right StarfishClient for reading/writing member-gated space docs
52
+ * (e.g. the `_index`, `_access`). Spaces are always plaintext — no encryptor.
53
+ */
54
+ export function getSpaceClient(spaceId: string, session: Session): StarfishClient {
55
+ const entry = getSpaceAccessEntry(spaceId);
56
+ if (entry?.kind === 'link') return makeClient(entry.cap, entry.key);
57
+ if (entry?.kind === 'member') {
58
+ const cap = JSON.parse(entry.cap) as { iss?: string };
59
+ return makeClient(cap, session.keys.edPriv);
60
+ }
61
+ return session.chatClient;
62
+ }
63
+
64
+ /**
65
+ * Resolve the right (client, encryptor) for a node's CONTENT, opening and
66
+ * caching on first use.
44
67
  *
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.
68
+ * `node` carries `{ access?, enc? }` the plaintext flags from the index.
69
+ * `reg` is the space's access record if already known; used to determine
70
+ * ownership. Pass null if unknown.
47
71
  */
48
- export function getSpaceAccess(
72
+ export function getNodeAccess(
49
73
  spaceId: string,
74
+ nodeId: string,
75
+ node: { access?: NodeAccess; enc?: boolean },
50
76
  session: Session,
51
- reg: { owner: string | null; members: string[]; visibility?: SpaceVisibility } | null,
52
- ): Promise<SpaceAccessHandle> {
53
- const hit = cache.get(spaceId);
77
+ reg?: { owner: string | null; members: string[] } | null,
78
+ ): Promise<NodeAccessHandle> {
79
+ const cacheKey = `${spaceId}:${nodeId}`;
80
+ const hit = cache.get(cacheKey);
54
81
  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 };
82
+
83
+ const p = (async (): Promise<NodeAccessHandle> => {
84
+ // Prefer a per-node entry, fall back to the space-level entry for the client.
85
+ const nodeEntry = getNodeAccessEntry(spaceId, nodeId);
86
+ const spaceEntry = getSpaceAccessEntry(spaceId);
87
+ const activeEntry = nodeEntry ?? spaceEntry;
88
+
89
+ // Build the client.
90
+ let client: StarfishClient;
91
+ let capIss: string | undefined;
92
+ if (activeEntry?.kind === 'link') {
93
+ client = makeClient(activeEntry.cap, activeEntry.key);
94
+ } else if (activeEntry?.kind === 'member') {
95
+ const cap = JSON.parse(activeEntry.cap) as { iss?: string };
96
+ capIss = cap.iss;
97
+ client = makeClient(cap, session.keys.edPriv);
98
+ } else {
99
+ client = session.chatClient;
63
100
  }
64
101
 
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 };
102
+ const isOwnerOpen =
103
+ reg != null ? reg.owner === session.userId : activeEntry == null;
104
+
105
+ // Plaintext node no keyring needed.
106
+ if (!node.enc) {
107
+ return { encryptor: null, client, isOwnerOpen };
71
108
  }
72
109
 
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 };
110
+ // E2EE noderesolve the SPACE-WIDE keyring.
111
+ const spacePullPath = keyringPull(spaceId);
112
+ const trustedAdders = capIss
113
+ ? [capIss]
114
+ : reg?.owner
115
+ ? [reg.owner]
116
+ : ownerTrustedAdders(session);
117
+
118
+ if (activeEntry?.kind === 'member' || activeEntry?.kind === 'link') {
119
+ const encryptor = await openEncryptor(client, session.keys, spacePullPath, trustedAdders);
120
+ return { encryptor, client, isOwnerOpen: false };
77
121
  }
78
122
 
79
- // 4. No entry, private — owner or error
123
+ // No access entry — owner mints/opens the keyring; non-owner errors.
80
124
  const owner = reg?.owner ?? null;
81
125
  const members = reg?.members ?? [];
82
126
  if (owner !== null && owner !== session.userId) {
83
127
  throw new SpaceAccessError(
84
128
  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.",
129
+ ? "You're a member of this space, but the space key isn't on this device yet — ask the owner to invite you."
130
+ : "You don't have access to this node.",
87
131
  );
88
132
  }
89
133
  const encryptor = await ownerEnsureKeyring(
90
134
  session.chatClient,
91
135
  session.keys,
92
- spaceId,
136
+ spacePullPath,
137
+ keyringPush(spaceId),
93
138
  ownerTrustedAdders(session),
94
139
  );
95
140
  return { encryptor, client: session.chatClient, isOwnerOpen: true };
96
141
  })();
97
- cache.set(spaceId, p);
98
- p.catch(() => cache.delete(spaceId));
142
+
143
+ cache.set(cacheKey, p);
144
+ p.catch(() => cache.delete(cacheKey));
99
145
  return p;
100
146
  }
101
147
 
102
148
  /**
103
149
  * 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.
150
+ * Returns null when the identity has no usable access for the node yet.
105
151
  */
106
- export async function buildSpaceAccess(
152
+ export async function buildNodeAccess(
107
153
  session: Session,
108
154
  spaceId: string,
109
- hint?: { visibility?: SpaceVisibility },
155
+ nodeId: string,
156
+ node: { enc?: boolean },
110
157
  ): Promise<{ client: StarfishClient; encryptor: Encryptor | null } | null> {
111
- const entry = getSpaceAccessEntry(spaceId);
158
+ const nodeEntry = getNodeAccessEntry(spaceId, nodeId);
159
+ const spaceEntry = getSpaceAccessEntry(spaceId);
160
+ const activeEntry = nodeEntry ?? spaceEntry;
112
161
 
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;
162
+ let client: StarfishClient;
119
163
  let trustedAdders = ownerTrustedAdders(session);
120
164
 
121
- if (entry?.kind === 'member') {
122
- const cap = JSON.parse(entry.cap) as { iss?: string };
165
+ if (activeEntry?.kind === 'link') {
166
+ client = makeClient(activeEntry.cap, activeEntry.key);
167
+ } else if (activeEntry?.kind === 'member') {
168
+ const cap = JSON.parse(activeEntry.cap) as { iss?: string };
123
169
  client = makeClient(cap, session.keys.edPriv);
124
170
  if (cap.iss) trustedAdders = [cap.iss];
125
- const encryptor = await buildEncryptor(client, session.keys, spaceId, trustedAdders);
126
- return encryptor ? { client, encryptor } : null;
171
+ } else {
172
+ client = session.chatClient;
127
173
  }
128
174
 
129
- if (hint?.visibility === 'public') {
130
- return { client, encryptor: null };
131
- }
175
+ if (!node.enc) return { client, encryptor: null };
132
176
 
133
- // No entry, no hint — try the keyring probe (owner path)
134
- const encryptor = await buildEncryptor(client, session.keys, spaceId, trustedAdders);
177
+ // Soft-open the SPACE-WIDE keyring.
178
+ const spacePullPath = keyringPull(spaceId);
179
+ const encryptor = await buildEncryptor(client, session.keys, spacePullPath, trustedAdders);
135
180
  return encryptor ? { client, encryptor } : null;
136
181
  }