@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.
- package/CHANGELOG.md +200 -0
- package/dist/index.d.ts +481 -274
- package/dist/index.js +1000 -493
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/types.ts +50 -83
- package/src/index.ts +62 -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/utils/invite-preview.test.ts +169 -0
- package/src/utils/invite-preview.ts +101 -0
- package/src/utils/live-sync-bus.test.ts +116 -0
- package/src/utils/live-sync-bus.ts +71 -0
- package/src/utils/search-match.test.ts +149 -0
- package/src/utils/search-match.ts +145 -0
package/src/sync/space-access.ts
CHANGED
|
@@ -1,136 +1,181 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Space access resolver
|
|
3
|
-
* space regardless of whether it is private (E2EE) or public (plaintext).
|
|
2
|
+
* Space and node access resolver.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
|
36
|
+
export interface NodeAccessHandle {
|
|
29
37
|
encryptor: Encryptor | null;
|
|
30
38
|
client: StarfishClient;
|
|
31
|
-
/** True when opened as the space OWNER (
|
|
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<
|
|
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
|
|
46
|
+
export function clearNodeAccessCache(): void {
|
|
39
47
|
cache.clear();
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
/**
|
|
43
|
-
*
|
|
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
|
-
* `
|
|
46
|
-
*
|
|
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
|
|
72
|
+
export function getNodeAccess(
|
|
49
73
|
spaceId: string,
|
|
74
|
+
nodeId: string,
|
|
75
|
+
node: { access?: NodeAccess; enc?: boolean },
|
|
50
76
|
session: Session,
|
|
51
|
-
reg
|
|
52
|
-
): Promise<
|
|
53
|
-
const
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return { encryptor, client, isOwnerOpen
|
|
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
|
-
//
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
110
|
+
// E2EE node — resolve 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
|
-
//
|
|
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
|
|
86
|
-
: "You don't have access to this
|
|
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
|
-
|
|
136
|
+
spacePullPath,
|
|
137
|
+
keyringPush(spaceId),
|
|
93
138
|
ownerTrustedAdders(session),
|
|
94
139
|
);
|
|
95
140
|
return { encryptor, client: session.chatClient, isOwnerOpen: true };
|
|
96
141
|
})();
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
150
|
+
* Returns null when the identity has no usable access for the node yet.
|
|
105
151
|
*/
|
|
106
|
-
export async function
|
|
152
|
+
export async function buildNodeAccess(
|
|
107
153
|
session: Session,
|
|
108
154
|
spaceId: string,
|
|
109
|
-
|
|
155
|
+
nodeId: string,
|
|
156
|
+
node: { enc?: boolean },
|
|
110
157
|
): Promise<{ client: StarfishClient; encryptor: Encryptor | null } | null> {
|
|
111
|
-
const
|
|
158
|
+
const nodeEntry = getNodeAccessEntry(spaceId, nodeId);
|
|
159
|
+
const spaceEntry = getSpaceAccessEntry(spaceId);
|
|
160
|
+
const activeEntry = nodeEntry ?? spaceEntry;
|
|
112
161
|
|
|
113
|
-
|
|
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 (
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
171
|
+
} else {
|
|
172
|
+
client = session.chatClient;
|
|
127
173
|
}
|
|
128
174
|
|
|
129
|
-
if (
|
|
130
|
-
return { client, encryptor: null };
|
|
131
|
-
}
|
|
175
|
+
if (!node.enc) return { client, encryptor: null };
|
|
132
176
|
|
|
133
|
-
//
|
|
134
|
-
const
|
|
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
|
+
});
|