@recursiv/sdk 0.7.121 → 0.7.122

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.
@@ -0,0 +1,379 @@
1
+ import { isSealedFrameToken, parseSealedFrameMessage, SEALED_FRAME_ALLOW, SEALED_FRAME_HOST_KINDS, SEALED_FRAME_LAYOUTS, SEALED_FRAME_PROTOCOL, SEALED_FRAME_REFERRER_POLICY, SEALED_FRAME_SANDBOX, SEALED_FRAME_SCHEMES, sealedFrameHello, sealedFrameOrigin, sealedHostMessage, } from './protocol.js';
2
+ import { SEALED_WIPE_TIMEOUT_MS, wipeSealedFrame } from './wipe.js';
3
+ /**
4
+ * A cached token is handed out until this long before it expires (later only to a frame load that does not hold it,
5
+ * while the next mint is held back); the frame asks again then.
6
+ */
7
+ export const SEALED_FRAME_TOKEN_REFRESH_MS = 60_000;
8
+ /**
9
+ * The least time between two mints that worked, for one account: whatever the browser's clock says (a fast clock makes
10
+ * every token look near its expiry) and however often the frame asks, the host mints at most twice a minute.
11
+ */
12
+ const MIN_MINT_GAP_MS = 30_000;
13
+ /** The longest a token may say it lives (`expiresIn`): a day, far above anything the API grants. */
14
+ const MAX_LIFE_SECONDS = 86_400;
15
+ const FIRST_BACKOFF_MS = 2_000;
16
+ const MAX_BACKOFF_MS = 60_000;
17
+ const READY_TIMEOUT_MS = 15_000;
18
+ function expiryMs(value) {
19
+ if (value instanceof Date)
20
+ return value.getTime();
21
+ if (typeof value === 'number')
22
+ return value;
23
+ if (typeof value === 'string')
24
+ return Date.parse(value);
25
+ return Number.NaN;
26
+ }
27
+ /**
28
+ * When a minted token expires, in this browser's clock: `startedAt` (when the host asked for it) plus `expiresIn` when
29
+ * the API said how long it lives, else `expiresAt` as given. NaN when neither is usable.
30
+ */
31
+ function localExpiryMs(minted, startedAt) {
32
+ if (minted?.expiresIn !== undefined) {
33
+ const life = minted.expiresIn;
34
+ if (typeof life !== 'number' || !Number.isFinite(life) || life <= 0 || life > MAX_LIFE_SECONDS)
35
+ return Number.NaN;
36
+ return startedAt + life * 1000;
37
+ }
38
+ return expiryMs(minted?.expiresAt);
39
+ }
40
+ /**
41
+ * The host's frame tokens: one cached token, handed out until 60 s before it expires; one shared mint in flight; at
42
+ * least 30 s between two mints that worked; and after a failed mint, nothing new for 2 s doubling to 60 s.
43
+ */
44
+ class FrameTokens {
45
+ mint;
46
+ frameOrigin;
47
+ onError;
48
+ cached = null;
49
+ inflight = null;
50
+ generation = 0;
51
+ failures = 0;
52
+ notBefore = 0;
53
+ /** When the last mint that worked was asked for, in this generation. */
54
+ lastMinted = Number.NEGATIVE_INFINITY;
55
+ constructor(mint, frameOrigin, onError) {
56
+ this.mint = mint;
57
+ this.frameOrigin = frameOrigin;
58
+ this.onError = onError;
59
+ }
60
+ /** Whether `token` is the cached token for `account`. */
61
+ holds(account, token) {
62
+ return this.cached?.account === account && this.cached.token === token;
63
+ }
64
+ /**
65
+ * A token for `account`. `refused`: the frame already holds the cached one and asks again (the API refused it, or it
66
+ * is near its expiry). Null when a mint failed.
67
+ */
68
+ async get(account, refused) {
69
+ const now = Date.now();
70
+ const cached = this.cached !== null && this.cached.account === account ? this.cached : null;
71
+ const usable = cached !== null && cached.expiresAtMs - SEALED_FRAME_TOKEN_REFRESH_MS > now;
72
+ if (cached && usable && !refused)
73
+ return { token: cached };
74
+ if (this.inflight && this.inflight.account === account) {
75
+ const token = await this.inflight.promise;
76
+ return token ? { token } : null;
77
+ }
78
+ const waitMs = Math.max(this.notBefore - now, this.lastMinted + MIN_MINT_GAP_MS - now);
79
+ if (waitMs > 0) {
80
+ // While the next mint is held back, a frame load that does not hold the cached token yet gets it if it is alive.
81
+ if (cached && !refused && cached.expiresAtMs > now)
82
+ return { token: cached };
83
+ return { waitMs };
84
+ }
85
+ const token = await this.start(account);
86
+ return token ? { token } : null;
87
+ }
88
+ /** Drops the cached token and any mint in flight (an account switch or sign-out): neither is handed out again. */
89
+ forget() {
90
+ this.generation += 1;
91
+ this.cached = null;
92
+ this.inflight = null;
93
+ this.lastMinted = Number.NEGATIVE_INFINITY;
94
+ }
95
+ start(account) {
96
+ const generation = this.generation;
97
+ const startedAt = Date.now();
98
+ const promise = Promise.resolve()
99
+ .then(() => this.mint())
100
+ .then((minted) => {
101
+ const expiresAtMs = localExpiryMs(minted, startedAt);
102
+ if (!isSealedFrameToken(minted?.token))
103
+ throw new TypeError('mintToken did not give a Sealed frame token');
104
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
105
+ throw new TypeError('mintToken gave a frame token with no expiry, or one already expired');
106
+ }
107
+ if (minted.origin !== undefined && minted.origin !== this.frameOrigin) {
108
+ throw new TypeError(`mintToken gave a frame token for ${String(minted.origin)}, not ${this.frameOrigin}`);
109
+ }
110
+ const token = { account, token: minted.token, expiresAtMs: Math.floor(expiresAtMs) };
111
+ if (generation !== this.generation)
112
+ return null;
113
+ this.failures = 0;
114
+ this.notBefore = 0;
115
+ this.lastMinted = startedAt;
116
+ this.cached = token;
117
+ return token;
118
+ })
119
+ .catch((error) => {
120
+ if (generation === this.generation) {
121
+ this.failures += 1;
122
+ this.notBefore = Date.now() + Math.min(MAX_BACKOFF_MS, FIRST_BACKOFF_MS * 2 ** (this.failures - 1));
123
+ }
124
+ this.onError?.(error);
125
+ return null;
126
+ });
127
+ const entry = { account, promise };
128
+ this.inflight = entry;
129
+ void promise.then(() => {
130
+ if (this.inflight === entry)
131
+ this.inflight = null;
132
+ });
133
+ return promise;
134
+ }
135
+ }
136
+ function checkEnum(values, value, name) {
137
+ if (typeof value !== 'string' || !values.includes(value)) {
138
+ throw new TypeError(`${name} must be one of ${values.join(', ')}`);
139
+ }
140
+ return value;
141
+ }
142
+ function checkAccount(value, allowNull) {
143
+ if (value === null && allowNull)
144
+ return null;
145
+ // The hello builder holds the full rule; this gives a clearer error first.
146
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(value)) {
147
+ throw new TypeError(`account must be the member's user id${allowNull ? ', or null' : ''}`);
148
+ }
149
+ return value;
150
+ }
151
+ /**
152
+ * Embeds the Sealed frame in `container` and runs the host's side of protocol v1 (protocol.ts). The iframe is
153
+ * sandboxed (SEALED_FRAME_SANDBOX), may only write to the clipboard, sends no referrer, and is never `credentialless`
154
+ * (that would give the frame a throwaway storage partition and lose the member's Sealed chats).
155
+ *
156
+ * On every load of the iframe the host posts `sealed/hello` to the frame origin only, with a new MessagePort, and from
157
+ * then on reads that port only: a window message is never read, whatever its origin or source, and a port from an
158
+ * earlier load is closed. Nothing counts until the frame answers `ready`.
159
+ *
160
+ * `need-token` is answered through `mintToken`, minting at most once per token life and never twice in 30 s (see
161
+ * MountSealedFrameOptions), so reloading the frame, a frame that asks too often or a wrong browser clock never storms
162
+ * the API. The token cache belongs to one mount: keep one mount for as long as the page shows Sealed, since each new
163
+ * mount mints afresh on its frame's first ask.
164
+ *
165
+ * Browser only: call it in an effect, and `destroy()` on unmount.
166
+ */
167
+ export function mountSealedFrame(options) {
168
+ const frameOrigin = sealedFrameOrigin(options.frameOrigin);
169
+ const hostKind = checkEnum(SEALED_FRAME_HOST_KINDS, options.hostKind, 'hostKind');
170
+ let scheme = checkEnum(SEALED_FRAME_SCHEMES, options.scheme, 'scheme');
171
+ let layout = checkEnum(SEALED_FRAME_LAYOUTS, options.layout, 'layout');
172
+ let account = checkAccount(options.account, true);
173
+ if (typeof options.mintToken !== 'function')
174
+ throw new TypeError('mintToken must be a function');
175
+ const readyTimeoutMs = options.readyTimeoutMs ?? READY_TIMEOUT_MS;
176
+ const doc = options.container.ownerDocument;
177
+ const tokens = new FrameTokens(options.mintToken, frameOrigin, options.onTokenError);
178
+ const waiters = new Set();
179
+ let session = null;
180
+ let destroyed = false;
181
+ let readyTimer;
182
+ const iframe = doc.createElement('iframe');
183
+ /** From the mount and from each load: a frame that has not said `ready` in time is reported as `no_answer`. */
184
+ function armReadyTimer() {
185
+ clearTimeout(readyTimer);
186
+ readyTimer = setTimeout(() => {
187
+ if (!destroyed && !session?.ready)
188
+ options.onState?.({ state: 'unavailable', reason: 'no_answer' });
189
+ }, readyTimeoutMs);
190
+ }
191
+ function endSession() {
192
+ if (!session)
193
+ return;
194
+ clearTimeout(session.tokenTimer);
195
+ session.port.onmessage = null;
196
+ session.port.close();
197
+ session = null;
198
+ }
199
+ function settleWaiters(result) {
200
+ const pending = [...waiters];
201
+ waiters.clear();
202
+ for (const settle of pending)
203
+ settle(result);
204
+ }
205
+ function post(message) {
206
+ session?.port.postMessage(message);
207
+ }
208
+ async function answerNeedToken(current) {
209
+ const forAccount = account;
210
+ if (forAccount === null || current.tokenTimer !== undefined)
211
+ return;
212
+ const refused = current.delivered !== null && tokens.holds(forAccount, current.delivered);
213
+ const answer = await tokens.get(forAccount, refused);
214
+ if (destroyed || session !== current || account !== forAccount || !answer)
215
+ return;
216
+ if ('waitMs' in answer) {
217
+ if (current.tokenTimer === undefined) {
218
+ current.tokenTimer = setTimeout(() => {
219
+ current.tokenTimer = undefined;
220
+ void answerNeedToken(current);
221
+ }, answer.waitMs);
222
+ }
223
+ return;
224
+ }
225
+ current.port.postMessage(sealedHostMessage.token(answer.token.token, answer.token.expiresAtMs, forAccount));
226
+ current.delivered = answer.token.token;
227
+ }
228
+ function onFrameMessage(current, data) {
229
+ if (destroyed || session !== current)
230
+ return;
231
+ const message = parseSealedFrameMessage(data);
232
+ if (!message)
233
+ return;
234
+ if (!current.ready) {
235
+ if (message.t !== 'ready')
236
+ return;
237
+ clearTimeout(readyTimer);
238
+ if (message.protocol !== SEALED_FRAME_PROTOCOL) {
239
+ endSession();
240
+ options.onState?.({ state: 'unavailable', reason: 'update' });
241
+ return;
242
+ }
243
+ current.ready = true;
244
+ return;
245
+ }
246
+ switch (message.t) {
247
+ case 'need-token':
248
+ void answerNeedToken(current);
249
+ return;
250
+ case 'state':
251
+ options.onState?.(message.state === 'ready' ? { state: 'ready' } : { state: 'unavailable', reason: message.reason });
252
+ return;
253
+ case 'navigate':
254
+ options.onNavigate?.(message.to);
255
+ return;
256
+ case 'wiped':
257
+ settleWaiters('wiped');
258
+ return;
259
+ default:
260
+ // A second `ready`: ignored.
261
+ return;
262
+ }
263
+ }
264
+ function onLoad() {
265
+ if (destroyed)
266
+ return;
267
+ endSession();
268
+ const target = iframe.contentWindow;
269
+ if (!target)
270
+ return;
271
+ const channel = new MessageChannel();
272
+ const current = { port: channel.port1, ready: false, delivered: null, tokenTimer: undefined };
273
+ session = current;
274
+ channel.port1.onmessage = (event) => onFrameMessage(current, event.data);
275
+ armReadyTimer();
276
+ target.postMessage(sealedFrameHello({ host: hostKind, scheme, layout, account }), frameOrigin, [channel.port2]);
277
+ }
278
+ // Every attribute before the src: the sandbox applies from the first navigation.
279
+ iframe.setAttribute('sandbox', SEALED_FRAME_SANDBOX);
280
+ iframe.setAttribute('allow', SEALED_FRAME_ALLOW);
281
+ iframe.setAttribute('referrerpolicy', SEALED_FRAME_REFERRER_POLICY);
282
+ iframe.title = options.title ?? 'Sealed chat';
283
+ iframe.style.border = '0';
284
+ iframe.style.display = 'block';
285
+ iframe.style.width = '100%';
286
+ iframe.style.height = '100%';
287
+ iframe.addEventListener('load', onLoad);
288
+ iframe.src = `${frameOrigin}/`;
289
+ options.container.appendChild(iframe);
290
+ armReadyTimer();
291
+ return {
292
+ iframe,
293
+ setTheme(next) {
294
+ if (destroyed)
295
+ return;
296
+ scheme = checkEnum(SEALED_FRAME_SCHEMES, next, 'scheme');
297
+ post(sealedHostMessage.theme(scheme));
298
+ },
299
+ setLayout(next) {
300
+ if (destroyed)
301
+ return;
302
+ layout = checkEnum(SEALED_FRAME_LAYOUTS, next, 'layout');
303
+ post(sealedHostMessage.layout(layout));
304
+ },
305
+ setAccount(next) {
306
+ if (destroyed)
307
+ return;
308
+ const checked = checkAccount(next, false);
309
+ if (checked === account)
310
+ return;
311
+ account = checked;
312
+ tokens.forget();
313
+ if (session) {
314
+ session.delivered = null;
315
+ clearTimeout(session.tokenTimer);
316
+ session.tokenTimer = undefined;
317
+ }
318
+ post(sealedHostMessage.account(checked));
319
+ },
320
+ signedOut(signOutOptions) {
321
+ if (destroyed)
322
+ return Promise.resolve('timeout');
323
+ const timeoutMs = signOutOptions?.timeoutMs ?? SEALED_WIPE_TIMEOUT_MS;
324
+ const previous = account;
325
+ account = null;
326
+ tokens.forget();
327
+ const current = session;
328
+ // Always, beside the frame's own wipe: the page records the wipe before doing it, so a frame whose port is dead
329
+ // (mid-reload) or a page that reloads now still loses the account's data on the frame's next load.
330
+ const viaPage = wipeSealedFrame({ frameOrigin, account: previous, timeoutMs, document: doc });
331
+ if (!current?.ready) {
332
+ // A frame that has had its hello but not said ready accepted the account it named. Reload it: its next hello
333
+ // is signed out, and the reload closes any of the account's storage it holds open (which would block the wipe).
334
+ if (current) {
335
+ endSession();
336
+ iframe.src = `${frameOrigin}/`;
337
+ }
338
+ return viaPage;
339
+ }
340
+ current.delivered = null;
341
+ clearTimeout(current.tokenTimer);
342
+ current.tokenTimer = undefined;
343
+ return new Promise((resolve) => {
344
+ let done = false;
345
+ const settle = (result) => {
346
+ if (done)
347
+ return;
348
+ done = true;
349
+ clearTimeout(timer);
350
+ waiters.delete(settle);
351
+ resolve(result);
352
+ };
353
+ const timer = setTimeout(() => settle('timeout'), timeoutMs);
354
+ waiters.add(settle);
355
+ post(sealedHostMessage.signedOut());
356
+ void viaPage.then((result) => {
357
+ if (result === 'wiped')
358
+ settle('wiped');
359
+ });
360
+ });
361
+ },
362
+ newChat() {
363
+ if (destroyed)
364
+ return;
365
+ post(sealedHostMessage.newChat());
366
+ },
367
+ destroy() {
368
+ if (destroyed)
369
+ return;
370
+ destroyed = true;
371
+ clearTimeout(readyTimer);
372
+ endSession();
373
+ tokens.forget();
374
+ settleWaiters('timeout');
375
+ iframe.removeEventListener('load', onLoad);
376
+ iframe.remove();
377
+ },
378
+ };
379
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `@recursiv/sdk/sealed-frame`: web Sealed's frame protocol (v1) for both sides, and the host's helpers. Browser only.
3
+ *
4
+ * - Host pages (minds.com/ai, build.minds.com): `mountSealedFrame()` embeds https://sealed.minds.com and runs the
5
+ * handshake, the token answers and the sign-out; `wipeSealedFrame()` clears an account's Sealed chats at any sign-out.
6
+ * - The Sealed frame: the same message types, builders and validators (`parseSealedFrameHello`,
7
+ * `parseSealedHostMessage`, `sealedFrameMessage`, ...), so both sides agree on what may cross.
8
+ *
9
+ * Importing it touches no DOM: everything runs when called.
10
+ */
11
+ export * from './protocol.js';
12
+ export { mountSealedFrame, SEALED_FRAME_TOKEN_REFRESH_MS } from './host.js';
13
+ export type { MountSealedFrameOptions, SealedFrameHandle, SealedFrameHostState, SealedFrameMintedToken } from './host.js';
14
+ export { SEALED_WIPE_TIMEOUT_MS, wipeSealedFrame } from './wipe.js';
15
+ export type { WipeSealedFrameOptions } from './wipe.js';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@recursiv/sdk/sealed-frame`: web Sealed's frame protocol (v1) for both sides, and the host's helpers. Browser only.
3
+ *
4
+ * - Host pages (minds.com/ai, build.minds.com): `mountSealedFrame()` embeds https://sealed.minds.com and runs the
5
+ * handshake, the token answers and the sign-out; `wipeSealedFrame()` clears an account's Sealed chats at any sign-out.
6
+ * - The Sealed frame: the same message types, builders and validators (`parseSealedFrameHello`,
7
+ * `parseSealedHostMessage`, `sealedFrameMessage`, ...), so both sides agree on what may cross.
8
+ *
9
+ * Importing it touches no DOM: everything runs when called.
10
+ */
11
+ export * from './protocol.js';
12
+ export { mountSealedFrame, SEALED_FRAME_TOKEN_REFRESH_MS } from './host.js';
13
+ export { SEALED_WIPE_TIMEOUT_MS, wipeSealedFrame } from './wipe.js';