@drakkar.software/octospaces-sdk 0.1.0 → 0.4.3

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
  }
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { previewInvite } from './invite-preview.js';
3
+ import { encodeSpaceInviteLink } from '../spaces/members.js';
4
+ import { encodeNodeInviteLink } from '../spaces/nodes.js';
5
+ import type { SpaceInviteLinkToken } from '../spaces/members.js';
6
+ import type { NodeInviteLinkToken } from '../spaces/nodes.js';
7
+
8
+ const spaceToken: SpaceInviteLinkToken = {
9
+ v: 1,
10
+ spaceId: 'sp-abc123',
11
+ spaceName: 'My Space',
12
+ cap: { kind: 'member', iss: 'deadbeef', sub: 'cafecafe', scope: {} },
13
+ key: 'a1b2c3d4',
14
+ write: true,
15
+ };
16
+
17
+ const nodeToken: NodeInviteLinkToken = {
18
+ v: 1,
19
+ spaceId: 'sp-abc123',
20
+ nodeId: 'nd-xyz789',
21
+ nodeName: 'Secret Doc',
22
+ cap: { kind: 'member', iss: 'deadbeef', sub: 'cafecafe', scope: {} },
23
+ key: 'b2c3d4e5',
24
+ write: false,
25
+ };
26
+
27
+ const spaceLink = encodeSpaceInviteLink('https://app.example.com', spaceToken);
28
+ const nodeLink = encodeNodeInviteLink('https://app.example.com', nodeToken);
29
+
30
+ // ── space-link ────────────────────────────────────────────────────────────────
31
+
32
+ describe('previewInvite — space-link', () => {
33
+ it('classifies a space invite link', () => {
34
+ const p = previewInvite(spaceLink);
35
+ expect(p.kind).toBe('space-link');
36
+ });
37
+
38
+ it('extracts spaceName and write flag', () => {
39
+ const p = previewInvite(spaceLink);
40
+ if (p.kind !== 'space-link') throw new Error('wrong kind');
41
+ expect(p.spaceName).toBe('My Space');
42
+ expect(p.write).toBe(true);
43
+ });
44
+
45
+ it('exposes the decoded token', () => {
46
+ const p = previewInvite(spaceLink);
47
+ if (p.kind !== 'space-link') throw new Error('wrong kind');
48
+ expect(p.token.spaceId).toBe('sp-abc123');
49
+ });
50
+
51
+ it('accepts a raw fragment (no origin prefix)', () => {
52
+ const fragment = spaceLink.slice(spaceLink.indexOf('#'));
53
+ const p = previewInvite(fragment);
54
+ expect(p.kind).toBe('space-link');
55
+ });
56
+
57
+ it('accepts a read-only (write:false) link', () => {
58
+ const readOnlyToken: SpaceInviteLinkToken = { ...spaceToken, write: false };
59
+ const link = encodeSpaceInviteLink('https://app.example.com', readOnlyToken);
60
+ const p = previewInvite(link);
61
+ if (p.kind !== 'space-link') throw new Error('wrong kind');
62
+ expect(p.write).toBe(false);
63
+ });
64
+ });
65
+
66
+ // ── node-link ─────────────────────────────────────────────────────────────────
67
+
68
+ describe('previewInvite — node-link', () => {
69
+ it('classifies a node invite link', () => {
70
+ const p = previewInvite(nodeLink);
71
+ expect(p.kind).toBe('node-link');
72
+ });
73
+
74
+ it('extracts nodeTitle from nodeName', () => {
75
+ const p = previewInvite(nodeLink);
76
+ if (p.kind !== 'node-link') throw new Error('wrong kind');
77
+ expect(p.nodeTitle).toBe('Secret Doc');
78
+ });
79
+
80
+ it('exposes spaceId via spaceName fallback', () => {
81
+ const p = previewInvite(nodeLink);
82
+ if (p.kind !== 'node-link') throw new Error('wrong kind');
83
+ expect(p.spaceName).toContain('abc123'.slice(-6));
84
+ });
85
+
86
+ it('exposes the decoded token', () => {
87
+ const p = previewInvite(nodeLink);
88
+ if (p.kind !== 'node-link') throw new Error('wrong kind');
89
+ expect(p.token.nodeId).toBe('nd-xyz789');
90
+ });
91
+ });
92
+
93
+ // ── member-bundle ─────────────────────────────────────────────────────────────
94
+
95
+ const bundle = JSON.stringify({
96
+ spaceId: 'sp-abc123',
97
+ spaceName: 'Team Space',
98
+ cap: { kind: 'member', iss: 'aabbccddee112233' },
99
+ });
100
+
101
+ describe('previewInvite — member-bundle', () => {
102
+ it('classifies a private member-bundle JSON', () => {
103
+ const p = previewInvite(bundle);
104
+ expect(p.kind).toBe('member-bundle');
105
+ });
106
+
107
+ it('extracts spaceName and spaceId', () => {
108
+ const p = previewInvite(bundle);
109
+ if (p.kind !== 'member-bundle') throw new Error('wrong kind');
110
+ expect(p.spaceName).toBe('Team Space');
111
+ expect(p.spaceId).toBe('sp-abc123');
112
+ });
113
+
114
+ it('builds issuerKey fingerprint from iss', () => {
115
+ const p = previewInvite(bundle);
116
+ if (p.kind !== 'member-bundle') throw new Error('wrong kind');
117
+ expect(p.issuerKey).toContain('aabbccdd');
118
+ expect(p.issuerKey).toContain('…');
119
+ });
120
+
121
+ it('issuerKey is null when iss is absent', () => {
122
+ const noIss = JSON.stringify({ spaceId: 'sp-abc123', cap: { kind: 'member' } });
123
+ const p = previewInvite(noIss);
124
+ if (p.kind !== 'member-bundle') throw new Error('wrong kind');
125
+ expect(p.issuerKey).toBeNull();
126
+ });
127
+
128
+ it('falls back to id-derived spaceName when spaceName is absent', () => {
129
+ const noName = JSON.stringify({ spaceId: 'sp-abc123', cap: { kind: 'member' } });
130
+ const p = previewInvite(noName);
131
+ if (p.kind !== 'member-bundle') throw new Error('wrong kind');
132
+ expect(p.spaceName).toBe('space-abc123');
133
+ });
134
+
135
+ it('preserves raw inviteJson verbatim', () => {
136
+ const p = previewInvite(bundle);
137
+ if (p.kind !== 'member-bundle') throw new Error('wrong kind');
138
+ expect(p.inviteJson).toBe(bundle);
139
+ });
140
+ });
141
+
142
+ // ── error cases ───────────────────────────────────────────────────────────────
143
+
144
+ describe('previewInvite — errors', () => {
145
+ it('throws on empty input', () => {
146
+ expect(() => previewInvite('')).toThrow('Paste an invite');
147
+ });
148
+
149
+ it('throws on a whitespace-only input', () => {
150
+ expect(() => previewInvite(' ')).toThrow('Paste an invite');
151
+ });
152
+
153
+ it('throws on a malformed URL fragment', () => {
154
+ expect(() => previewInvite('#not-valid-base64!!!!')).toThrow();
155
+ });
156
+
157
+ it('throws on plain text (not JSON, not a link)', () => {
158
+ expect(() => previewInvite('hello world')).toThrow("doesn't look like an invite");
159
+ });
160
+
161
+ it('throws on JSON that is not a member bundle', () => {
162
+ expect(() => previewInvite(JSON.stringify({ foo: 'bar' }))).toThrow('not a valid space invite');
163
+ });
164
+
165
+ it('throws on a bundle whose cap.kind is not member', () => {
166
+ const bad = JSON.stringify({ spaceId: 'sp-1', cap: { kind: 'owner' } });
167
+ expect(() => previewInvite(bad)).toThrow('not a valid space invite');
168
+ });
169
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Parse an invite (pasted text or a `#fragment` deep link) into a preview the
3
+ * join screen can show on a consent card — name, type, identifying fingerprint
4
+ * — WITHOUT joining. Actual join calls (`joinSpaceByLink`, `joinNodeByLink`,
5
+ * `acceptSpaceInvite`, `acceptNodeInvite`) only run after the user confirms.
6
+ *
7
+ * Accepts three input forms:
8
+ * - A space invite link (URL fragment encoded by `createSpaceInviteLink`)
9
+ * - A node invite link (URL fragment encoded by `createNodeInviteLink`)
10
+ * - A private member-bundle JSON minted by `inviteToSpace`
11
+ */
12
+ import { decodeSpaceInviteLink } from '../spaces/members.js';
13
+ import { decodeNodeInviteLink } from '../spaces/nodes.js';
14
+ import type { SpaceInviteLinkToken } from '../spaces/members.js';
15
+ import type { NodeInviteLinkToken } from '../spaces/nodes.js';
16
+
17
+ export type { SpaceInviteLinkToken, NodeInviteLinkToken };
18
+
19
+ export type InvitePreview =
20
+ | {
21
+ kind: 'space-link';
22
+ spaceName: string;
23
+ /** True if the link grants write access, false for read-only. */
24
+ write: boolean;
25
+ token: SpaceInviteLinkToken;
26
+ }
27
+ | {
28
+ kind: 'node-link';
29
+ spaceName: string;
30
+ /** The node's display name, absent for legacy tokens that omit it. */
31
+ nodeTitle?: string;
32
+ token: NodeInviteLinkToken;
33
+ }
34
+ | {
35
+ kind: 'member-bundle';
36
+ spaceName: string;
37
+ spaceId: string;
38
+ /** Short hex fingerprint of the issuing owner's signing key, or null if absent. */
39
+ issuerKey: string | null;
40
+ /** The raw cap-bundle JSON — pass verbatim to `acceptSpaceInvite` on consent. */
41
+ inviteJson: string;
42
+ };
43
+
44
+ /** Shape of the private invite bundle minted by `inviteToSpace`. */
45
+ interface PrivateInviteShape {
46
+ spaceId?: string;
47
+ spaceName?: string;
48
+ cap?: { kind?: string; iss?: string };
49
+ }
50
+
51
+ /**
52
+ * Classify and decode an invite string into a typed {@link InvitePreview}.
53
+ * Throws a human-readable `Error` on invalid input (safe to surface verbatim
54
+ * in a toast or inline error message).
55
+ */
56
+ export function previewInvite(raw: string): InvitePreview {
57
+ const text = raw.trim();
58
+ if (!text) throw new Error('Paste an invite link or code first.');
59
+
60
+ // Invite links carry their credential in a URL fragment.
61
+ if (text.includes('#')) {
62
+ const fragment = text.slice(text.indexOf('#'));
63
+ // Try node-invite link first — it has a `nodeId` field the space link lacks.
64
+ try {
65
+ const token = decodeNodeInviteLink(fragment);
66
+ return {
67
+ kind: 'node-link',
68
+ spaceName: `space-${token.spaceId.slice(-6)}`,
69
+ nodeTitle: token.nodeName,
70
+ token,
71
+ };
72
+ } catch {
73
+ // fall through to space link
74
+ }
75
+ try {
76
+ const token = decodeSpaceInviteLink(fragment);
77
+ return { kind: 'space-link', spaceName: token.spaceName, write: token.write, token };
78
+ } catch {
79
+ throw new Error('That invite link appears to be invalid or expired.');
80
+ }
81
+ }
82
+
83
+ // Private member-bundle JSON minted by `inviteToSpace`.
84
+ let parsed: PrivateInviteShape;
85
+ try {
86
+ parsed = JSON.parse(text) as PrivateInviteShape;
87
+ } catch {
88
+ throw new Error("That doesn't look like an invite. Paste the full invite code or link.");
89
+ }
90
+ if (!parsed?.spaceId || parsed.cap?.kind !== 'member') {
91
+ throw new Error('That is not a valid space invite.');
92
+ }
93
+ const iss = parsed.cap?.iss;
94
+ return {
95
+ kind: 'member-bundle',
96
+ spaceName: parsed.spaceName?.trim() || `space-${parsed.spaceId.slice(-6)}`,
97
+ spaceId: parsed.spaceId,
98
+ issuerKey: typeof iss === 'string' && iss.length >= 8 ? `${iss.slice(0, 8)}…${iss.slice(-8)}` : null,
99
+ inviteJson: text,
100
+ };
101
+ }
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ registerPull,
4
+ dispatchDocChange,
5
+ emitSseStatus,
6
+ onSseStatus,
7
+ clearLiveSyncBus,
8
+ } from './live-sync-bus.js';
9
+
10
+ beforeEach(() => clearLiveSyncBus());
11
+
12
+ // ── registerPull / dispatchDocChange ──────────────────────────────────────────
13
+
14
+ describe('registerPull / dispatchDocChange', () => {
15
+ it('returns false when no pull is registered for the path', () => {
16
+ expect(dispatchDocChange('spaces/sp-1/objects/_index')).toBe(false);
17
+ });
18
+
19
+ it('calls the registered pull and returns true', () => {
20
+ const pull = vi.fn();
21
+ registerPull('spaces/sp-1/objects/_index', pull);
22
+ const dispatched = dispatchDocChange('spaces/sp-1/objects/_index');
23
+ expect(dispatched).toBe(true);
24
+ expect(pull).toHaveBeenCalledOnce();
25
+ });
26
+
27
+ it('does not cross-fire — different paths are independent', () => {
28
+ const pull1 = vi.fn();
29
+ const pull2 = vi.fn();
30
+ registerPull('spaces/sp-1/objects/_index', pull1);
31
+ registerPull('spaces/sp-2/objects/_index', pull2);
32
+ dispatchDocChange('spaces/sp-1/objects/_index');
33
+ expect(pull1).toHaveBeenCalledOnce();
34
+ expect(pull2).not.toHaveBeenCalled();
35
+ });
36
+
37
+ it('unsubscribe removes the registration', () => {
38
+ const pull = vi.fn();
39
+ const unsub = registerPull('spaces/sp-1/objects/_index', pull);
40
+ unsub();
41
+ expect(dispatchDocChange('spaces/sp-1/objects/_index')).toBe(false);
42
+ expect(pull).not.toHaveBeenCalled();
43
+ });
44
+
45
+ it('stale unsubscribe is a no-op after re-registration', () => {
46
+ const pull1 = vi.fn();
47
+ const pull2 = vi.fn();
48
+ const unsub1 = registerPull('spaces/sp-1/objects/_index', pull1);
49
+ registerPull('spaces/sp-1/objects/_index', pull2); // overwrites
50
+ unsub1(); // should NOT remove pull2
51
+ dispatchDocChange('spaces/sp-1/objects/_index');
52
+ expect(pull2).toHaveBeenCalledOnce();
53
+ });
54
+ });
55
+
56
+ // ── emitSseStatus / onSseStatus ───────────────────────────────────────────────
57
+
58
+ describe('emitSseStatus / onSseStatus', () => {
59
+ it('fires immediately with the current state (false by default)', () => {
60
+ const cb = vi.fn();
61
+ onSseStatus(cb);
62
+ expect(cb).toHaveBeenCalledWith(false);
63
+ });
64
+
65
+ it('fires on each status change', () => {
66
+ const cb = vi.fn();
67
+ onSseStatus(cb);
68
+ emitSseStatus(true);
69
+ emitSseStatus(false);
70
+ expect(cb).toHaveBeenNthCalledWith(1, false); // initial
71
+ expect(cb).toHaveBeenNthCalledWith(2, true);
72
+ expect(cb).toHaveBeenNthCalledWith(3, false);
73
+ });
74
+
75
+ it('unsubscribe stops receiving updates', () => {
76
+ const cb = vi.fn();
77
+ const unsub = onSseStatus(cb);
78
+ unsub();
79
+ emitSseStatus(true);
80
+ expect(cb).toHaveBeenCalledOnce(); // only the initial fire
81
+ });
82
+
83
+ it('new subscriber gets current state after emitSseStatus(true)', () => {
84
+ emitSseStatus(true);
85
+ const cb = vi.fn();
86
+ onSseStatus(cb);
87
+ expect(cb).toHaveBeenCalledWith(true);
88
+ });
89
+ });
90
+
91
+ // ── clearLiveSyncBus ──────────────────────────────────────────────────────────
92
+
93
+ describe('clearLiveSyncBus', () => {
94
+ it('clears all registered pulls', () => {
95
+ const pull = vi.fn();
96
+ registerPull('spaces/sp-1/objects/_index', pull);
97
+ clearLiveSyncBus();
98
+ expect(dispatchDocChange('spaces/sp-1/objects/_index')).toBe(false);
99
+ });
100
+
101
+ it('resets SSE health to false', () => {
102
+ emitSseStatus(true);
103
+ clearLiveSyncBus();
104
+ const cb = vi.fn();
105
+ onSseStatus(cb);
106
+ expect(cb).toHaveBeenCalledWith(false);
107
+ });
108
+
109
+ it('leaves status listeners intact (they self-unsub on unmount)', () => {
110
+ const cb = vi.fn();
111
+ onSseStatus(cb);
112
+ clearLiveSyncBus();
113
+ emitSseStatus(true);
114
+ expect(cb).toHaveBeenCalledWith(true); // still fires
115
+ });
116
+ });