@recursiv/sdk 0.7.121 → 0.7.123

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,276 @@
1
+ /**
2
+ * The Sealed frame's protocol, version 1: the messages between a host page (minds.com/ai, build.minds.com) and the
3
+ * Sealed frame it embeds (https://sealed.minds.com), and between a host and the frame's sign-out page (/wipe.html).
4
+ * Both sides use these types, builders and validators, so there is one definition of what may cross.
5
+ *
6
+ * How the two talk (the Sealed frame design of 2026-10-13, section 5):
7
+ * 1. On every load event of the frame's iframe, the host posts `sealed/hello` with a transferred MessagePort to the
8
+ * frame origin only (`targetOrigin`). The frame must add its window message listener as soon as its module runs, and
9
+ * accepts the first hello whose `event.origin` is one of its compiled hosts and whose `event.source` is
10
+ * `window.parent`. After that both sides use the port only.
11
+ * 2. The frame answers `ready` on the port, naming the protocol it speaks; a host that speaks another shows "Reload
12
+ * the page". Nothing else counts before `ready`.
13
+ * 3. Host to frame, on the port: `token`, `theme`, `layout`, `account`, `signed-out`, `new-chat`.
14
+ * Frame to host, on the port: `need-token`, `state`, `navigate`, `wiped`.
15
+ *
16
+ * Every field is an enum, a boolean, an id, a time or the opaque token. Never sent, either way: typed words, answers,
17
+ * chat ids, titles or counts, failure details, or when sends happen. There are no size messages: the host sizes the
18
+ * frame with CSS, since a height would say how long the transcript is.
19
+ *
20
+ * Every message is checked before it is read. A validator returns a fresh copy holding only the message's own fields,
21
+ * or null for anything else: a type it does not know, a missing, extra or wrongly typed field, a string outside its
22
+ * enum or pattern, a getter or symbol key, or a message over 4 KB of JSON. The receiver ignores null. Builders check
23
+ * their own output the same way and throw a TypeError rather than send a message the other side would drop.
24
+ */
25
+ /** The protocol this SDK speaks, which `ready` names. */
26
+ export const SEALED_FRAME_PROTOCOL = 1;
27
+ /** Every message's format version (`v`). */
28
+ export const SEALED_FRAME_MESSAGE_VERSION = 1;
29
+ /** The most a message may be, as UTF-8 JSON. Anything larger is dropped unread. */
30
+ export const SEALED_FRAME_MESSAGE_MAX_BYTES = 4096;
31
+ /**
32
+ * The frame's iframe sandbox. It protects the host, not the frame: the frame's protection is the same-origin policy and
33
+ * its own CSP. `allow-same-origin` keeps the frame's own storage; popups let crisis and help links open in a new tab.
34
+ * There is no `allow-forms`, in line with the frame CSP's `form-action 'none'`: embedded, the browser stops a form
35
+ * submission before its `submit` event fires, so the frame must not use `<form>` submission (handle Enter and the send
36
+ * button with key and click handlers). Standalone at its own origin a form would work, which is why tests miss it.
37
+ */
38
+ export const SEALED_FRAME_SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox';
39
+ /** The sign-out page's sandbox: it runs a script against the frame origin's storage and opens nothing. */
40
+ export const SEALED_WIPE_SANDBOX = 'allow-scripts allow-same-origin';
41
+ /** The frame's permissions policy: only writing to the clipboard (copy an answer). */
42
+ export const SEALED_FRAME_ALLOW = 'clipboard-write';
43
+ /** No referrer leaves the host for the frame. */
44
+ export const SEALED_FRAME_REFERRER_POLICY = 'no-referrer';
45
+ /** The frame's sign-out page, on the frame origin. */
46
+ export const SEALED_WIPE_PATH = '/wipe.html';
47
+ /** Which host embeds the frame. */
48
+ export const SEALED_FRAME_HOST_KINDS = ['minds', 'build'];
49
+ export const SEALED_FRAME_SCHEMES = ['light', 'dark'];
50
+ /** `column`: a phone-width column. `workspace`: the desktop thread area, where the frame lists its own Sealed chats. */
51
+ export const SEALED_FRAME_LAYOUTS = ['column', 'workspace'];
52
+ /** Why the frame cannot run Sealed now (`state`, `unavailable`). */
53
+ export const SEALED_FRAME_UNAVAILABLE_REASONS = [
54
+ 'unsupported',
55
+ 'tab_busy',
56
+ 'off',
57
+ 'words',
58
+ 'signed_out',
59
+ 'update',
60
+ 'no_account',
61
+ ];
62
+ /** Where the frame asks the host to take the member (`navigate`). */
63
+ export const SEALED_FRAME_NAVIGATE_TARGETS = ['plans', 'settings_ai', 'sign_in', 'get_app'];
64
+ /** A member's user id (a UUID today): letters, digits, `-` and `_`, 1 to 64 of them. */
65
+ const ACCOUNT = /^[A-Za-z0-9_-]{1,64}$/;
66
+ /** A frame token: `sft_` + base64url payload + `.` + base64url MAC, the API's format. */
67
+ const FRAME_TOKEN = /^sft_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
68
+ const FRAME_TOKEN_MAX_LENGTH = 2048;
69
+ /** A frame release id: the release's name or source commit. */
70
+ const RELEASE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
71
+ function oneOf(values) {
72
+ return (value) => typeof value === 'string' && values.includes(value);
73
+ }
74
+ const isVersion = (value) => value === SEALED_FRAME_MESSAGE_VERSION;
75
+ const isBoolean = (value) => typeof value === 'boolean';
76
+ const isAccount = (value) => typeof value === 'string' && ACCOUNT.test(value);
77
+ const isAccountOrNull = (value) => value === null || isAccount(value);
78
+ const isRelease = (value) => typeof value === 'string' && RELEASE.test(value);
79
+ const isProtocol = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 1 && value <= 65535;
80
+ const isTime = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
81
+ /** Whether a value is a frame token in the API's format. */
82
+ export function isSealedFrameToken(value) {
83
+ return typeof value === 'string' && value.length <= FRAME_TOKEN_MAX_LENGTH && FRAME_TOKEN.test(value);
84
+ }
85
+ const field = (rule) => ({ rule });
86
+ const optional = (rule) => ({ rule, optional: true });
87
+ const HELLO_SHAPES = {
88
+ 'sealed/hello': {
89
+ v: field(isVersion),
90
+ host: field(oneOf(SEALED_FRAME_HOST_KINDS)),
91
+ scheme: field(oneOf(SEALED_FRAME_SCHEMES)),
92
+ layout: field(oneOf(SEALED_FRAME_LAYOUTS)),
93
+ signedIn: field(isBoolean),
94
+ account: field(isAccountOrNull),
95
+ },
96
+ };
97
+ const HOST_SHAPES = {
98
+ token: { v: field(isVersion), token: field(isSealedFrameToken), expiresAt: field(isTime), account: field(isAccount) },
99
+ theme: { v: field(isVersion), scheme: field(oneOf(SEALED_FRAME_SCHEMES)) },
100
+ layout: { v: field(isVersion), layout: field(oneOf(SEALED_FRAME_LAYOUTS)) },
101
+ account: { v: field(isVersion), account: field(isAccount) },
102
+ 'signed-out': { v: field(isVersion) },
103
+ 'new-chat': { v: field(isVersion) },
104
+ };
105
+ const FRAME_SHAPES = {
106
+ ready: { v: field(isVersion), protocol: field(isProtocol), release: field(isRelease) },
107
+ 'need-token': { v: field(isVersion) },
108
+ state: {
109
+ v: field(isVersion),
110
+ state: field(oneOf(['ready', 'unavailable'])),
111
+ reason: optional(oneOf(SEALED_FRAME_UNAVAILABLE_REASONS)),
112
+ },
113
+ navigate: { v: field(isVersion), to: field(oneOf(SEALED_FRAME_NAVIGATE_TARGETS)) },
114
+ wiped: { v: field(isVersion) },
115
+ };
116
+ const WIPE_SHAPES = {
117
+ 'sealed/wipe': { v: field(isVersion), account: field(isAccountOrNull) },
118
+ };
119
+ /** More keys than any message has. */
120
+ const MAX_KEYS = 8;
121
+ const hasOwn = (object, key) => Object.hasOwn(object, key);
122
+ function utf8Length(text) {
123
+ return new TextEncoder().encode(text).length;
124
+ }
125
+ /**
126
+ * The message `data` is, checked against `shapes`, as a fresh object with only its own fields; null for anything else.
127
+ * Never throws.
128
+ */
129
+ function readShape(data, shapes) {
130
+ try {
131
+ // A plain object (from any realm: structured clone makes one in the receiver's), not an array or a class instance.
132
+ if (typeof data !== 'object' || data === null || Array.isArray(data))
133
+ return null;
134
+ if (Object.prototype.toString.call(data) !== '[object Object]')
135
+ return null;
136
+ const proto = Object.getPrototypeOf(data);
137
+ if (proto !== null && Object.getPrototypeOf(proto) !== null)
138
+ return null;
139
+ const keys = Reflect.ownKeys(data);
140
+ if (keys.length > MAX_KEYS)
141
+ return null;
142
+ // No prototype: an own `__proto__` key (JSON.parse and structured clone keep one) is then copied as a plain key and
143
+ // refused below as an extra field, instead of reaching Object.prototype's `__proto__` setter and vanishing.
144
+ const values = Object.create(null);
145
+ for (const key of keys) {
146
+ if (typeof key !== 'string')
147
+ return null;
148
+ const descriptor = Object.getOwnPropertyDescriptor(data, key);
149
+ // Data properties only: a getter could answer differently on each read.
150
+ if (!descriptor || !('value' in descriptor) || !descriptor.enumerable)
151
+ return null;
152
+ const value = descriptor.value;
153
+ if (value !== null && typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean')
154
+ return null;
155
+ if (typeof value === 'string' && value.length > SEALED_FRAME_MESSAGE_MAX_BYTES)
156
+ return null;
157
+ values[key] = value;
158
+ }
159
+ if (utf8Length(JSON.stringify(values)) > SEALED_FRAME_MESSAGE_MAX_BYTES)
160
+ return null;
161
+ const type = values.t;
162
+ if (typeof type !== 'string' || !hasOwn(shapes, type))
163
+ return null;
164
+ const shape = shapes[type];
165
+ for (const key of Object.keys(values)) {
166
+ if (key !== 't' && !hasOwn(shape, key))
167
+ return null;
168
+ }
169
+ const message = { t: type };
170
+ for (const [key, { rule, optional: isOptional }] of Object.entries(shape)) {
171
+ if (!hasOwn(values, key)) {
172
+ if (isOptional)
173
+ continue;
174
+ return null;
175
+ }
176
+ if (!rule(values[key]))
177
+ return null;
178
+ message[key] = values[key];
179
+ }
180
+ return message;
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ }
186
+ /** A `sealed/hello`, as the frame reads it; null for anything else. */
187
+ export function parseSealedFrameHello(data) {
188
+ const message = readShape(data, HELLO_SHAPES);
189
+ if (!message)
190
+ return null;
191
+ // Signed in exactly when an account is named.
192
+ if ((message.signedIn === true) !== (message.account !== null))
193
+ return null;
194
+ return message;
195
+ }
196
+ /** A host-to-frame port message, as the frame reads it; null for anything else. */
197
+ export function parseSealedHostMessage(data) {
198
+ return readShape(data, HOST_SHAPES);
199
+ }
200
+ /** A frame-to-host port message, as the host reads it; null for anything else. */
201
+ export function parseSealedFrameMessage(data) {
202
+ const message = readShape(data, FRAME_SHAPES);
203
+ if (!message)
204
+ return null;
205
+ // A reason exactly when unavailable.
206
+ if (message.t === 'state' && (message.state === 'unavailable') !== hasOwn(message, 'reason'))
207
+ return null;
208
+ return message;
209
+ }
210
+ /** A `sealed/wipe`, as the sign-out page reads it; null for anything else. */
211
+ export function parseSealedWipeRequest(data) {
212
+ return readShape(data, WIPE_SHAPES);
213
+ }
214
+ // --- builders ------------------------------------------------------------------------------------------------------
215
+ function checked(message, what) {
216
+ if (message === null)
217
+ throw new TypeError(`Not a valid Sealed frame ${what} message. Nothing was sent.`);
218
+ return message;
219
+ }
220
+ /** The host's `sealed/hello`. `account` is the signed-in member's user id, or null when no one is signed in. */
221
+ export function sealedFrameHello(input) {
222
+ const hello = {
223
+ t: 'sealed/hello',
224
+ v: 1,
225
+ host: input.host,
226
+ scheme: input.scheme,
227
+ layout: input.layout,
228
+ signedIn: input.account !== null,
229
+ account: input.account,
230
+ };
231
+ return checked(parseSealedFrameHello(hello), 'hello');
232
+ }
233
+ /** The host's port messages. */
234
+ export const sealedHostMessage = {
235
+ token: (token, expiresAt, account) => checked(parseSealedHostMessage({ t: 'token', v: 1, token, expiresAt, account }), 'token'),
236
+ theme: (scheme) => checked(parseSealedHostMessage({ t: 'theme', v: 1, scheme }), 'theme'),
237
+ layout: (layout) => checked(parseSealedHostMessage({ t: 'layout', v: 1, layout }), 'layout'),
238
+ account: (account) => checked(parseSealedHostMessage({ t: 'account', v: 1, account }), 'account'),
239
+ signedOut: () => checked(parseSealedHostMessage({ t: 'signed-out', v: 1 }), 'signed-out'),
240
+ newChat: () => checked(parseSealedHostMessage({ t: 'new-chat', v: 1 }), 'new-chat'),
241
+ };
242
+ /**
243
+ * The frame's port messages. Each takes only enums, numbers and the frame's compiled release id, so nothing the member
244
+ * typed or read can be put in one.
245
+ */
246
+ export const sealedFrameMessage = {
247
+ ready: (release) => checked(parseSealedFrameMessage({ t: 'ready', v: 1, protocol: SEALED_FRAME_PROTOCOL, release }), 'ready'),
248
+ needToken: () => checked(parseSealedFrameMessage({ t: 'need-token', v: 1 }), 'need-token'),
249
+ stateReady: () => checked(parseSealedFrameMessage({ t: 'state', v: 1, state: 'ready' }), 'state'),
250
+ unavailable: (reason) => checked(parseSealedFrameMessage({ t: 'state', v: 1, state: 'unavailable', reason }), 'state'),
251
+ navigate: (to) => checked(parseSealedFrameMessage({ t: 'navigate', v: 1, to }), 'navigate'),
252
+ wiped: () => checked(parseSealedFrameMessage({ t: 'wiped', v: 1 }), 'wiped'),
253
+ };
254
+ /** The host's `sealed/wipe` for the sign-out page. `account` null wipes every account's Sealed data in the browser. */
255
+ export function sealedWipeRequest(account) {
256
+ return checked(parseSealedWipeRequest({ t: 'sealed/wipe', v: 1, account }), 'wipe');
257
+ }
258
+ /**
259
+ * The frame origin as an exact origin (`https://sealed.minds.com`), or a TypeError. Only https, or http on localhost
260
+ * for development; no path, query, fragment or credentials.
261
+ */
262
+ export function sealedFrameOrigin(value) {
263
+ let url;
264
+ try {
265
+ url = new URL(value);
266
+ }
267
+ catch {
268
+ throw new TypeError('frameOrigin must be an origin such as https://sealed.minds.com');
269
+ }
270
+ const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]';
271
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/'
272
+ || !(url.protocol === 'https:' || (url.protocol === 'http:' && local))) {
273
+ throw new TypeError('frameOrigin must be an https origin with no path, such as https://sealed.minds.com');
274
+ }
275
+ return url.origin;
276
+ }
@@ -0,0 +1,25 @@
1
+ export interface WipeSealedFrameOptions {
2
+ /** The frame origin, e.g. `https://sealed.minds.com`. */
3
+ frameOrigin: string;
4
+ /** The account signing out (its user id); null wipes every account's Sealed data in this browser. */
5
+ account: string | null;
6
+ /** How long to wait in all, page load included, before giving up. 1500 ms by default. */
7
+ timeoutMs?: number;
8
+ /** The document to load the page in; the global one by default. */
9
+ document?: Document;
10
+ }
11
+ /** How long a sign-out waits for the Sealed data to go before it carries on (the frame design, section 7). */
12
+ export declare const SEALED_WIPE_TIMEOUT_MS = 1500;
13
+ /**
14
+ * Clears an account's Sealed chats from this browser at sign-out, whether or not a Sealed frame is open: it loads the
15
+ * frame origin's sign-out page (/wipe.html) in a hidden iframe, posts `sealed/wipe` to that origin only with a
16
+ * transferred MessagePort, and waits for `wiped` on the port. Resolves `wiped`, or `timeout` once `timeoutMs` has passed
17
+ * in all (page load included) so sign-out never hangs on it. Either way the iframe is removed. Call it on every
18
+ * sign-out path before the page reloads.
19
+ *
20
+ * The page writes a pending-wipe record first, so a wipe cut short by the reload finishes on the frame's next load.
21
+ * Without a DOM (React Native, a server render) it resolves `timeout` at once and does nothing.
22
+ *
23
+ * Only the port carries the answer: a window message, from any origin or window, is never read.
24
+ */
25
+ export declare function wipeSealedFrame(options: WipeSealedFrameOptions): Promise<'wiped' | 'timeout'>;
@@ -0,0 +1,67 @@
1
+ import { parseSealedFrameMessage, SEALED_FRAME_REFERRER_POLICY, SEALED_WIPE_PATH, SEALED_WIPE_SANDBOX, sealedFrameOrigin, sealedWipeRequest, } from './protocol.js';
2
+ /** How long a sign-out waits for the Sealed data to go before it carries on (the frame design, section 7). */
3
+ export const SEALED_WIPE_TIMEOUT_MS = 1500;
4
+ /**
5
+ * Clears an account's Sealed chats from this browser at sign-out, whether or not a Sealed frame is open: it loads the
6
+ * frame origin's sign-out page (/wipe.html) in a hidden iframe, posts `sealed/wipe` to that origin only with a
7
+ * transferred MessagePort, and waits for `wiped` on the port. Resolves `wiped`, or `timeout` once `timeoutMs` has passed
8
+ * in all (page load included) so sign-out never hangs on it. Either way the iframe is removed. Call it on every
9
+ * sign-out path before the page reloads.
10
+ *
11
+ * The page writes a pending-wipe record first, so a wipe cut short by the reload finishes on the frame's next load.
12
+ * Without a DOM (React Native, a server render) it resolves `timeout` at once and does nothing.
13
+ *
14
+ * Only the port carries the answer: a window message, from any origin or window, is never read.
15
+ */
16
+ export function wipeSealedFrame(options) {
17
+ const frameOrigin = sealedFrameOrigin(options.frameOrigin);
18
+ const request = sealedWipeRequest(options.account);
19
+ const timeoutMs = options.timeoutMs ?? SEALED_WIPE_TIMEOUT_MS;
20
+ const doc = options.document ?? (typeof document === 'undefined' ? undefined : document);
21
+ if (!doc?.body || typeof MessageChannel === 'undefined')
22
+ return Promise.resolve('timeout');
23
+ return new Promise((resolve) => {
24
+ const iframe = doc.createElement('iframe');
25
+ let port = null;
26
+ let done = false;
27
+ const finish = (result) => {
28
+ if (done)
29
+ return;
30
+ done = true;
31
+ clearTimeout(timer);
32
+ iframe.removeEventListener('load', onLoad);
33
+ if (port) {
34
+ port.onmessage = null;
35
+ port.close();
36
+ }
37
+ iframe.remove();
38
+ resolve(result);
39
+ };
40
+ const onLoad = () => {
41
+ const target = iframe.contentWindow;
42
+ if (done || !target)
43
+ return;
44
+ // A second load (the page reloaded itself) gets a fresh port; the old one is closed and never read again.
45
+ if (port) {
46
+ port.onmessage = null;
47
+ port.close();
48
+ }
49
+ const channel = new MessageChannel();
50
+ port = channel.port1;
51
+ channel.port1.onmessage = (event) => {
52
+ if (parseSealedFrameMessage(event.data)?.t === 'wiped')
53
+ finish('wiped');
54
+ };
55
+ target.postMessage(request, frameOrigin, [channel.port2]);
56
+ };
57
+ const timer = setTimeout(() => finish('timeout'), timeoutMs);
58
+ iframe.setAttribute('sandbox', SEALED_WIPE_SANDBOX);
59
+ iframe.setAttribute('referrerpolicy', SEALED_FRAME_REFERRER_POLICY);
60
+ iframe.setAttribute('aria-hidden', 'true');
61
+ iframe.tabIndex = -1;
62
+ iframe.style.display = 'none';
63
+ iframe.addEventListener('load', onLoad);
64
+ iframe.src = `${frameOrigin}${SEALED_WIPE_PATH}`;
65
+ doc.body.appendChild(iframe);
66
+ });
67
+ }
package/llm.md CHANGED
@@ -739,9 +739,13 @@ Each answers 404 while the app's operator has it off; `feeds.top` is private to
739
739
  // One query, answered as sections. Each has its own time budget; a slow one is listed in `omitted`.
740
740
  const { data: answer } = await r.search.query({ q: 'apple', limit: 6 })
741
741
  for (const section of answer.order) render(section, answer.sections[section])
742
- // top_posts, latest_posts, open_network, people, groups, topics, stories, related, trending
742
+ // top_posts, latest_posts, open_network, people, groups, topics, stories, related, trending, media
743
+ // What the web has for the words, alone, so it paints beside the rest (0.7.123):
744
+ const { data: web } = await r.search.query({ q: 'hockey', sections: ['media'], own_images: shownPictures })
743
745
  ```
744
746
 
747
+ `media` (0.7.123): videos (YouTube ids as `embed`, PeerTube embed pages), news articles, pictures, sound and one `about` card (Wikipedia), from Brave Search, Wikipedia, PeerTube and Openverse with strict safe search. It answers only when `sections` names it (the default sections leave it out: each answer may spend a paid web search). Every picture passes the same vision check a post's picture does: a visitor sees only pictures judged safe, and a member who turned Show NSFW on gets a suggestive one marked `sensitive` (blur it). Every `thumbnail` is on the API's signed media proxy; play a YouTube embed through youtube-nocookie.com by its player's rules and show each `attribution`. `own_images` (how many pictures the page already shows) lets the server ask for the web's pictures only when fewer than six. Only the networks the server offers it to get it (others: `omitted` `unavailable`); `providers` says what each source did (`timeout`: ask again shortly). Visitors read it as `publicDiscovery.search` `tab: 'media'` (`PublicSearchPage.media`).
748
+
745
749
  `trending` joins when the answer is thin or old, so a search never dead-ends. A thin answer steps down the search ladder (0.7.79): the word a typo meant, the known words with the rest dropped ("wiggly memes" searches "memes"), what is popular in the category the words are about, then on the app. Every item says how in `via` (`SearchVia`) and `answer.ladder` (`SearchLadder`) says what happened: label it honestly ("No exact matches for X", "Did you mean Y?", "Popular in Comedy right now"); `ladder.unavailable` names sections the key cannot read. Visitors read the same ladder keyless through tRPC `publicDiscovery.search` (`PublicSearchPage`). `top_posts` is floored as `r.posts.search`'s relevance page is; `people` rows carry `followers_count`, `posts_count`, `empty` and `verified` (see `r.profiles.search`); `groups` rows carry `description` (one line) and `last_active_at`, to tell groups of one name apart. Page further with `r.posts.search`. Needs `posts:read`; `people` needs `users:read`, `groups` `communities:read`; `open_network` and `stories` need `users:read` plus the caller's own app key on its network, `trending` that app key. What the key cannot have is in `omitted` (`scope` / `unavailable`).
746
750
 
747
751
  ### r.curator — World Today and its story pages
@@ -856,6 +860,13 @@ const { data: capabilities } = await r.privacy.capabilities()
856
860
  const showNoPushLine = isFactLive('sealed.no_push', capabilities)
857
861
  const showCheckedLine = isFactLive('sealed.attest_before_send', capabilities, { deviceFacts: ['sealed.attest_before_send'] })
858
862
  ```
863
+ - `r.sealed` web frame routes (unreleased; server switch `config().web`, SEALED_GATEWAY_WEB) — `frameToken()` → `{ token, expiresAt, expiresIn?, origin }` (`expiresIn` only once the gateway sends `expires_in`): a short-lived `sft_` credential for the Sealed frame, minted from the member's own app key (never an API key; accepted only from `origin`, only on the frame's routes, never to mint another). `me()` → `{ user_id, privacy, content, data }` (GET /sealed/me; `data` repeats the fields so a `settings.getPreferences()` reader takes it unchanged; through a frame token `content.sealed_adult` is always false). `report({ reason, details? })` → `{ id, existing, details_kept }` (filed on the member's own account; `details` at most 2000 characters). All three are sent once, never retried, and throw AuthorizationError `sealed_web_off` while web Sealed is off (`isSealedWebOff(error)`). `@recursiv/sdk/sealed` carries only `HttpClient`, `SealedResource` and the errors, for the frame's bundle. `@recursiv/sdk/sealed-frame` (browser only) is protocol v1 for both sides: the message types, builders and validators (`parseSealedFrameHello`, `parseSealedHostMessage`, `parseSealedFrameMessage`, `parseSealedWipeRequest`; null for anything unknown, mistyped, outside its enum or over 4 KB), `mountSealedFrame({ container, frameOrigin, hostKind, scheme, layout, account, mintToken, onState, onNavigate, onTokenError? })` → `{ iframe, setTheme, setLayout, setAccount, signedOut, newChat, destroy }` (sandboxed iframe, hello + MessagePort on every load, window messages never read, one shared mint cached until 60 s before expiry, at least 30 s between mints whatever the browser clock says; `signedOut()` also runs the sign-out page and reloads a frame that has not said ready), and `wipeSealedFrame({ frameOrigin, account, timeoutMs? })` → `'wiped' | 'timeout'` for every sign-out path.
864
+
865
+ ```typescript
866
+ import { mountSealedFrame, wipeSealedFrame } from '@recursiv/sdk/sealed-frame'
867
+ const frame = mountSealedFrame({ container, frameOrigin: 'https://sealed.minds.com', hostKind: 'minds', scheme: 'dark', layout: 'column', account: me.id, mintToken: () => r.sealed.frameToken(), onState: setSealedState, onNavigate: go })
868
+ // sign-out: await frame.signedOut() (no frame mounted: await wipeSealedFrame({ frameOrigin, account: me.id }))
869
+ ```
859
870
  - `r.billing` — Usage tracking, checkout sessions, portal sessions, metered billing
860
871
  - `r.appSubscriptions` — Project-scoped consumer plans: `checkout` (409 `subscription_exists` when the member already pays), `status`, `createPortalSession({ return_url, flow?: 'cancel' })` (`flow: 'cancel'` opens Stripe's cancel confirmation; 409 `no_subscription` when there is no plan to cancel), `previewTierChange({ tier })`, `changeTier({ tier, proration_date? })` (in-place, prorated, never a second subscription)
861
872
  - `r.boosts` — Promoted posts (micro-USD): `create`, `mine`, `balance`, `pause`, `resume`, `cancel`, `recordEvent`; buy credit with `topup({ amount_cents, return_url })` then `confirmTopup(sessionId)` (credits once); admin `listPendingReview`, `review`, `grant`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recursiv/sdk",
3
- "version": "0.7.121",
3
+ "version": "0.7.123",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
@@ -32,6 +32,16 @@
32
32
  "types": "./dist/privacy/index.d.ts",
33
33
  "import": "./dist/privacy/index.js",
34
34
  "default": "./dist/privacy/index.js"
35
+ },
36
+ "./sealed": {
37
+ "types": "./dist/sealed/index.d.ts",
38
+ "import": "./dist/sealed/index.js",
39
+ "default": "./dist/sealed/index.js"
40
+ },
41
+ "./sealed-frame": {
42
+ "types": "./dist/sealed-frame/index.d.ts",
43
+ "import": "./dist/sealed-frame/index.js",
44
+ "default": "./dist/sealed-frame/index.js"
35
45
  }
36
46
  },
37
47
  "files": [