@assembly-js/app-foundation 0.0.0 → 0.0.1-rc.2

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.
Files changed (54) hide show
  1. package/CLAUDE.md +1119 -0
  2. package/dist/api-errors.d.ts +5 -0
  3. package/dist/api-errors.js +33 -0
  4. package/dist/app-storage.d.ts +7 -0
  5. package/dist/app-storage.js +22 -0
  6. package/dist/assembly.d.ts +49 -0
  7. package/dist/assembly.js +235 -0
  8. package/dist/auth.d.ts +17 -0
  9. package/dist/auth.js +118 -0
  10. package/dist/client-companies.d.ts +16 -0
  11. package/dist/client-companies.js +23 -0
  12. package/dist/client.d.ts +5 -2
  13. package/dist/client.js +14 -3
  14. package/dist/labels.d.ts +9 -0
  15. package/dist/labels.js +17 -0
  16. package/dist/mint-bearer.d.ts +18 -0
  17. package/dist/mint-bearer.js +152 -0
  18. package/dist/nango.d.ts +174 -0
  19. package/dist/nango.js +430 -0
  20. package/dist/network-capture.d.ts +36 -0
  21. package/dist/network-capture.js +473 -0
  22. package/dist/params.d.ts +3 -0
  23. package/dist/params.js +20 -0
  24. package/dist/resource-resolution.d.ts +89 -0
  25. package/dist/resource-resolution.js +142 -0
  26. package/dist/server.d.ts +9 -2
  27. package/dist/server.js +15 -5
  28. package/dist/session-exchange.d.ts +20 -0
  29. package/dist/session-exchange.js +101 -0
  30. package/dist/theme.d.ts +5 -0
  31. package/dist/theme.js +20 -0
  32. package/package.json +11 -4
  33. package/skills/assembly-ai/SKILL.md +133 -0
  34. package/skills/assembly-app-bridge-header/SKILL.md +88 -0
  35. package/skills/assembly-app-bridge-toast/SKILL.md +79 -0
  36. package/skills/assembly-avatar-sub/SKILL.md +103 -0
  37. package/skills/assembly-client-users/SKILL.md +45 -0
  38. package/skills/assembly-clients-companies/SKILL.md +143 -0
  39. package/skills/assembly-companies/SKILL.md +45 -0
  40. package/skills/assembly-custom-fields/SKILL.md +86 -0
  41. package/skills/assembly-db-transactions/SKILL.md +79 -0
  42. package/skills/assembly-dev-server-logs/SKILL.md +26 -0
  43. package/skills/assembly-forms/SKILL.md +103 -0
  44. package/skills/assembly-internal-users/SKILL.md +69 -0
  45. package/skills/assembly-mutations/SKILL.md +58 -0
  46. package/skills/assembly-native-resources/SKILL.md +117 -0
  47. package/skills/assembly-route-auth/SKILL.md +223 -0
  48. package/skills/assembly-route-errors/SKILL.md +81 -0
  49. package/skills/assembly-semantic-colors/SKILL.md +76 -0
  50. package/skills/assembly-session-token/SKILL.md +153 -0
  51. package/skills/assembly-storage/SKILL.md +157 -0
  52. package/skills/assembly-user-company-selector/SKILL.md +98 -0
  53. package/skills/assembly-workspace-labels/SKILL.md +50 -0
  54. package/skills/clearing-client-notifications/SKILL.md +100 -0
package/dist/nango.js ADDED
@@ -0,0 +1,430 @@
1
+ import { getAssembly } from './assembly.js';
2
+ import { findResource, normalizeResourcePage, TtlCache, } from './resource-resolution.js';
3
+ // Thrown when the broker returns a structured `{ error, provider }` fail-closed
4
+ // response. Catch it server-side and branch on `code` to render the right empty
5
+ // state (e.g. "reconnect required" for needs_reauth, "not configured" for
6
+ // connection_required). `status` is the broker's HTTP status for the response.
7
+ export class ConnectorError extends Error {
8
+ code;
9
+ provider;
10
+ status;
11
+ /**
12
+ * For `action_failed`: what the action script threw — usually the
13
+ * provider-level failure, e.g. `{ type: 'slack_error', message, response }`
14
+ * where `response.error` is Slack's error code.
15
+ */
16
+ detail;
17
+ constructor(args) {
18
+ super(`Connector ${args.provider} error: ${args.code}`);
19
+ this.name = 'ConnectorError';
20
+ this.code = args.code;
21
+ this.provider = args.provider;
22
+ this.status = args.status;
23
+ this.detail = args.detail;
24
+ }
25
+ }
26
+ // Build the broker proxy path: /v1/connectors/:provider/proxy/<endpoint>?<params>.
27
+ // The provider path is the URL suffix after /proxy/; query params are appended.
28
+ function buildProxyPath(provider, endpoint, params) {
29
+ const cleanEndpoint = endpoint.replace(/^\//, '');
30
+ let path = `/v1/connectors/${provider}/proxy/${cleanEndpoint}`;
31
+ if (params) {
32
+ const search = new URLSearchParams();
33
+ for (const [key, value] of Object.entries(params)) {
34
+ if (value !== undefined)
35
+ search.set(key, String(value));
36
+ }
37
+ const qs = search.toString();
38
+ if (qs)
39
+ path += `?${qs}`;
40
+ }
41
+ return path;
42
+ }
43
+ // Reads a structured `{ error, provider }` fail-closed response off a non-2xx
44
+ // broker response and throws ConnectorError. On a 2xx this is a no-op.
45
+ async function throwIfBrokerError(res, provider) {
46
+ if (res.ok)
47
+ return;
48
+ let code = `http_${res.status}`;
49
+ let detail;
50
+ try {
51
+ const cloned = res.clone();
52
+ const body = (await cloned.json());
53
+ // A relayed Nango error nests the code (`{ error: { code } }`); the
54
+ // broker's own fail-closed responses use a flat string.
55
+ if (typeof body?.error === 'string')
56
+ code = body.error;
57
+ else if (typeof body?.error?.code === 'string')
58
+ code = body.error.code;
59
+ detail = body?.detail;
60
+ }
61
+ catch {
62
+ // Non-JSON error body (rare) — fall back to the http_<status> code.
63
+ }
64
+ throw new ConnectorError({ code, provider, status: res.status, detail });
65
+ }
66
+ /**
67
+ * Returns a thin, server-side-only client for a connected provider. Every method
68
+ * POSTs to the Assembly connector broker (`/v1/connectors/:provider/...`) via
69
+ * getAssembly().fetch — there is no Nango client and no provider credentials in
70
+ * the app. The broker resolves the workspace's connection server-side and
71
+ * relays the provider response transparently.
72
+ *
73
+ * MUST be called from server-side code only (API routes, server components,
74
+ * server actions) within an authenticated request scope. `getAssembly()` reads
75
+ * the viewer bearer from the request cookie (same as every other data call);
76
+ * there is no `x-assembly-token` header and no token argument. Background/cron
77
+ * calls with no viewer are out of scope for v1 — see connector Phase-2.
78
+ */
79
+ export async function getIntegration(provider) {
80
+ const assembly = await getAssembly();
81
+ // Relays a provider HTTP call through the broker's universal proxy. The broker
82
+ // endpoint is always POST-ed (so a body carries through); the real provider
83
+ // verb travels in X-Connector-Method (default GET). The provider response is
84
+ // relayed transparently, so we return the raw Response for the caller to read
85
+ // (.json(), .text(), status, headers).
86
+ const proxy = async (opts) => {
87
+ const method = (opts.method ?? 'GET').toUpperCase();
88
+ const path = buildProxyPath(provider, opts.endpoint, opts.params);
89
+ // Named proxyHeaders (not `headers`) so callers' opts.headers stay distinct
90
+ // from this local map.
91
+ const proxyHeaders = {
92
+ ...(opts.headers ?? {}),
93
+ 'X-Connector-Method': method,
94
+ };
95
+ const init = { method: 'POST', headers: proxyHeaders };
96
+ if (opts.data !== undefined) {
97
+ if (typeof opts.data === 'string') {
98
+ init.body = opts.data;
99
+ }
100
+ else {
101
+ init.body = JSON.stringify(opts.data);
102
+ // fetch defaults string bodies to text/plain; the broker forwards the
103
+ // content-type to the provider, so declare JSON unless the caller set
104
+ // their own content type.
105
+ const hasContentType = Object.keys(proxyHeaders).some((key) => key.toLowerCase() === 'content-type');
106
+ if (!hasContentType)
107
+ proxyHeaders['Content-Type'] = 'application/json';
108
+ }
109
+ }
110
+ const res = await assembly.fetch(path, init);
111
+ await throwIfBrokerError(res, provider);
112
+ return res;
113
+ };
114
+ // Verb sugar: same as proxy() but pins the provider HTTP method.
115
+ const withMethod = (method) => (opts) => proxy({ ...opts, method });
116
+ return {
117
+ proxy,
118
+ get: withMethod('GET'),
119
+ post: withMethod('POST'),
120
+ put: withMethod('PUT'),
121
+ patch: withMethod('PATCH'),
122
+ delete: withMethod('DELETE'),
123
+ // Synchronously triggers a pre-built Nango Action and returns its normalized
124
+ // result inline as JSON. The whole `input` object is the action input.
125
+ // The available actions are listed in the assembly-integrations skill —
126
+ // never guess a name not on that list (an unknown name fails at runtime).
127
+ // A script-level failure throws ConnectorError with code 'action_failed'
128
+ // and the provider error in `detail`.
129
+ action: async (name, input) => {
130
+ const path = `/v1/connectors/${provider}/action/${encodeURIComponent(name)}`;
131
+ const res = await assembly.fetch(path, {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify(input ?? {}),
135
+ });
136
+ await throwIfBrokerError(res, provider);
137
+ return (await res.json());
138
+ },
139
+ // Reads the workspace's stored connection state from the broker — no
140
+ // provider round-trip, no side effects. Gate errors (provider_not_declared,
141
+ // app_key_required) still throw ConnectorError; a missing or reauth-needing
142
+ // connection is a normal `{ connected: false, status }` result. Prefer the
143
+ // isConnected() helper for a plain boolean.
144
+ status: async () => {
145
+ const res = await assembly.fetch(`/v1/connectors/${provider}/status`, {
146
+ method: 'GET',
147
+ });
148
+ await throwIfBrokerError(res, provider);
149
+ return (await res.json());
150
+ },
151
+ // Reserved seam for the future two-way sync surface (read synced records via
152
+ // the broker). Not implemented in v1 — declare it so call sites and the
153
+ // backend sync endpoint can land additively without changing this signature.
154
+ records: (_model, _query) => {
155
+ throw new Error('getIntegration().records is not supported in v1');
156
+ },
157
+ };
158
+ }
159
+ // The ONLY ConnectorError codes that mean a provider is genuinely not usable
160
+ // by this app. Every other outcome — connector_unavailable, a transport blip —
161
+ // leaves the stored connection intact; a connectivity check must never treat
162
+ // those as "not connected".
163
+ const NOT_CONNECTED_CODES = new Set([
164
+ 'connection_required',
165
+ 'needs_reauth',
166
+ 'needs_reauth_scope',
167
+ 'provider_not_declared',
168
+ 'app_key_required',
169
+ ]);
170
+ /**
171
+ * Report whether a provider is connected for the current workspace via the
172
+ * broker's read-only status endpoint — no provider call is made, so an
173
+ * unrelated call failure can never masquerade as "not connected".
174
+ *
175
+ * - status says connected → true
176
+ * - status says connection_required /
177
+ * needs_reauth / needs_reauth_scope → false (user must (re)connect)
178
+ * - provider_not_declared / app_key_required → false (a wiring bug — the
179
+ * provider is unusable until the manifest / call site is fixed)
180
+ * - the status call itself fails
181
+ * (connector_unavailable, transport) → true (unknown ≠ disconnected; a
182
+ * healthy connection must never render a Connect prompt over a blip)
183
+ *
184
+ * Do NOT hand-roll connectivity checks by making a provider call and branching
185
+ * on whether it succeeded — that reports a healthy connection as "not
186
+ * connected" the moment any unrelated call fails (the classic bug: probing a
187
+ * non-existent endpoint 404s, and the app shows "not connected" while real
188
+ * reads/writes succeed).
189
+ */
190
+ export async function isConnected(provider) {
191
+ try {
192
+ const integration = await getIntegration(provider);
193
+ const { connected } = await integration.status();
194
+ return connected;
195
+ }
196
+ catch (err) {
197
+ if (err instanceof ConnectorError && NOT_CONNECTED_CODES.has(err.code)) {
198
+ return false;
199
+ }
200
+ return true;
201
+ }
202
+ }
203
+ // Short-lived memo of resolver pages so a user typing in a picker doesn't
204
+ // re-hit the (often rate-limited) provider for every keystroke revisit. Safe
205
+ // to share across requests: a deployment serves one workspace and connections
206
+ // are workspace-scoped. Errors are never cached.
207
+ const resolveResourceCache = new TtlCache(200);
208
+ const RESOLVE_RESOURCE_CACHE_TTL_MS = 30_000;
209
+ /**
210
+ * Fetch one page of provider resources as normalized `{ id, label }` options —
211
+ * the server half of a resource picker (channel/folder/spreadsheet/base
212
+ * selection). Runs the resolver action through the broker and maps the result
213
+ * via `fields`, so apps never re-derive pagination, id/label mapping, or
214
+ * connector error handling per picker.
215
+ *
216
+ * Returns a serializable ResourcePage rather than throwing ConnectorError:
217
+ * call it from a server action and hand the result (or the action itself) to
218
+ * `<ResourcePicker>`, which renders the matching option list, "Load more",
219
+ * and reconnect/error states. Non-connector errors still propagate.
220
+ *
221
+ * @example
222
+ * // app/actions/channels.ts
223
+ * 'use server';
224
+ * import { getIntegration, resolveResource } from '@assembly-js/app-foundation/server';
225
+ *
226
+ * export async function searchChannels(args: { query: string; cursor?: string }) {
227
+ * // list-channels has no query input, so pages are label-filtered locally.
228
+ * return resolveResource('slack', 'list-channels', {
229
+ * ...args,
230
+ * fields: { id: 'id', label: 'name', items: 'conversations', nextCursor: 'next_cursor' },
231
+ * });
232
+ * }
233
+ */
234
+ export async function resolveResource(provider, resolverAction, opts) {
235
+ const query = opts.query?.trim() || undefined;
236
+ const actionInput = { ...(opts.input ?? {}) };
237
+ if (query && opts.queryInput)
238
+ actionInput[opts.queryInput] = query;
239
+ if (opts.cursor)
240
+ actionInput[opts.cursorInput ?? 'cursor'] = opts.cursor;
241
+ const cacheKey = JSON.stringify([
242
+ provider,
243
+ resolverAction,
244
+ actionInput,
245
+ opts.fields,
246
+ query,
247
+ ]);
248
+ const cached = resolveResourceCache.get(cacheKey);
249
+ if (cached)
250
+ return cached;
251
+ let raw;
252
+ try {
253
+ const integration = await getIntegration(provider);
254
+ raw = await integration.action(resolverAction, actionInput);
255
+ }
256
+ catch (err) {
257
+ if (err instanceof ConnectorError) {
258
+ return { ok: false, error: { code: err.code, provider: err.provider } };
259
+ }
260
+ throw err;
261
+ }
262
+ const page = normalizeResourcePage(raw, opts.fields, {
263
+ // When the action searched server-side, don't second-guess its matching
264
+ // with a literal substring filter.
265
+ query: opts.queryInput ? undefined : query,
266
+ requestCursor: opts.cursor,
267
+ });
268
+ const result = { ok: true, ...page };
269
+ resolveResourceCache.set(cacheKey, result, RESOLVE_RESOURCE_CACHE_TTL_MS);
270
+ return result;
271
+ }
272
+ // Process-lifetime memo of resolved channel IDs so repeated sends don't
273
+ // re-walk the (rate-limited) channel list. Names are stored normalized.
274
+ // Misses are not cached — the channel may be created later.
275
+ const slackChannelIdCache = new Map();
276
+ const SLACK_LIST_PAGE_LIMIT = '999'; // Slack caps below 1000; fewer, bigger pages
277
+ const SLACK_LIST_MAX_PAGES = 100; // defensive bound; trust but cap the cursor
278
+ const SLACK_RATE_LIMIT_MAX_WAITS = 3;
279
+ function sleep(ms) {
280
+ return new Promise((resolve) => setTimeout(resolve, ms));
281
+ }
282
+ /**
283
+ * Resolve a Slack channel name to its ID, walking the cursor-paginated
284
+ * conversations.list until found (Slack treats `limit` as a per-page cap and
285
+ * may return fewer; a single un-paginated call misses channels in large
286
+ * workspaces). Input is normalized (leading '#' stripped, lowercased — Slack
287
+ * channel names are always lowercase). Resolved IDs are memoized for the
288
+ * process lifetime; Slack rate limits (conversations.list is a low-tier
289
+ * method) are absorbed by waiting and resuming from the same cursor.
290
+ *
291
+ * Use the returned ID for every follow-up call — conversations.join /
292
+ * conversations.history / chat.postMessage should all be given IDs; most
293
+ * methods reject a `#name` with `invalid_arguments`.
294
+ *
295
+ * Membership: public channels are listed whether or not the bot joined, but
296
+ * posting still needs membership — on a `not_in_channel` error from
297
+ * chat.postMessage, call conversations.join with the ID and retry once.
298
+ * Private channels are listed only when the bot is already a member (and only
299
+ * when the connection has the groups:read scope — without it this helper
300
+ * automatically falls back to public channels only). A private channel cannot
301
+ * be self-joined: surface "invite the bot to #channel in Slack" to the user.
302
+ */
303
+ export async function slackFindChannelId(slack, name) {
304
+ const target = name.replace(/^#/, '').trim().toLowerCase();
305
+ if (!target)
306
+ return null;
307
+ const cached = slackChannelIdCache.get(target);
308
+ if (cached)
309
+ return cached;
310
+ const fields = {
311
+ id: 'id',
312
+ label: 'name',
313
+ items: 'channels',
314
+ nextCursor: 'response_metadata.next_cursor',
315
+ };
316
+ let types = 'public_channel,private_channel';
317
+ let rateLimitWaits = 0;
318
+ // findResource drives the cursor walk; this fetcher owns the Slack quirks —
319
+ // rate-limit waits resume from the SAME cursor (throwing would discard the
320
+ // walk and a retry would restart from page 1), and a missing_scope for
321
+ // private_channel (no groups:read) fails the WHOLE first call, so retrying
322
+ // public-only from the same cursor restarts the walk correctly.
323
+ const fetchPage = async (cursor) => {
324
+ for (;;) {
325
+ let res;
326
+ try {
327
+ res = await slack.get({
328
+ endpoint: '/conversations.list',
329
+ params: {
330
+ types,
331
+ limit: SLACK_LIST_PAGE_LIMIT,
332
+ exclude_archived: 'true',
333
+ ...(cursor ? { cursor } : {}),
334
+ },
335
+ });
336
+ }
337
+ catch (err) {
338
+ // A provider 429 relays through the broker as a ConnectorError with
339
+ // the provider's status.
340
+ if (err instanceof ConnectorError &&
341
+ err.status === 429 &&
342
+ rateLimitWaits < SLACK_RATE_LIMIT_MAX_WAITS) {
343
+ rateLimitWaits++;
344
+ await sleep(Math.min(10_000 * rateLimitWaits, 30_000));
345
+ continue;
346
+ }
347
+ throw err;
348
+ }
349
+ const data = (await res.json());
350
+ if (!data.ok) {
351
+ if (data.error === 'missing_scope' && types !== 'public_channel') {
352
+ types = 'public_channel';
353
+ continue;
354
+ }
355
+ if (data.error === 'ratelimited' &&
356
+ rateLimitWaits < SLACK_RATE_LIMIT_MAX_WAITS) {
357
+ rateLimitWaits++;
358
+ await sleep(Math.min(10_000 * rateLimitWaits, 30_000));
359
+ continue;
360
+ }
361
+ throw new Error(data.error ?? 'Slack conversations.list failed');
362
+ }
363
+ return normalizeResourcePage(data, fields, { requestCursor: cursor });
364
+ }
365
+ };
366
+ // Slack channel names are always lowercase, so an exact label match is the
367
+ // same comparison the raw walk made against channel.name.
368
+ const hit = await findResource(fetchPage, (option) => option.label === target, {
369
+ maxPages: SLACK_LIST_MAX_PAGES,
370
+ });
371
+ if (hit)
372
+ slackChannelIdCache.set(target, hit.id);
373
+ return hit?.id ?? null;
374
+ }
375
+ // Slack channel/group IDs (C…/G…) pass through slackPostMessage untouched;
376
+ // anything else is treated as a name and resolved first.
377
+ const SLACK_CHANNEL_ID_RE = /^[CG][A-Z0-9]{6,}$/;
378
+ /**
379
+ * Post a message to a Slack channel by name or ID. Use this instead of calling
380
+ * the send-message action or chat.postMessage yourself: it resolves `#name` →
381
+ * ID via slackFindChannelId (Slack rejects names with `invalid_arguments`),
382
+ * sends via the pre-built `send-message` action, and on `not_in_channel` it
383
+ * joins the channel and retries once (posting can require membership; joining
384
+ * needs the `channels:join` scope).
385
+ *
386
+ * Slack-level failures come back as `{ ok: false, error }` rather than throwing,
387
+ * so render the right state per code:
388
+ * - `channel_not_found` — no such channel; check the name.
389
+ * - `not_in_channel` — the bot couldn't self-join (private channel, or
390
+ * `channels:join` not declared in the manifest scopes): tell the user to
391
+ * invite the bot to the channel in Slack.
392
+ * Broker-level failures (provider not connected, throttled) still throw
393
+ * ConnectorError like every other call.
394
+ */
395
+ export async function slackPostMessage(slack, args) {
396
+ const channelId = SLACK_CHANNEL_ID_RE.test(args.channel)
397
+ ? args.channel
398
+ : await slackFindChannelId(slack, args.channel);
399
+ if (!channelId)
400
+ return { ok: false, error: 'channel_not_found' };
401
+ // The send-message action throws on Slack-level failure (relayed as
402
+ // ConnectorError 'action_failed' with the thrown payload in detail); map
403
+ // that back to `{ ok: false, error }` so callers branch on one shape.
404
+ const post = async () => {
405
+ try {
406
+ const result = await slack.action('send-message', { channel_id: channelId, text: args.text });
407
+ return { ok: result.ok, ts: result.ts };
408
+ }
409
+ catch (err) {
410
+ if (err instanceof ConnectorError && err.code === 'action_failed') {
411
+ const detail = err.detail;
412
+ const slackError = detail?.response?.error ?? detail?.message;
413
+ if (typeof slackError === 'string')
414
+ return { ok: false, error: slackError };
415
+ }
416
+ throw err;
417
+ }
418
+ };
419
+ let result = await post();
420
+ if (!result.ok && result.error === 'not_in_channel') {
421
+ const joinRes = await slack.post({
422
+ endpoint: '/conversations.join',
423
+ data: { channel: channelId },
424
+ });
425
+ const join = (await joinRes.json());
426
+ if (join.ok)
427
+ result = await post();
428
+ }
429
+ return { ok: result.ok, ts: result.ts, channelId, error: result.error };
430
+ }
@@ -0,0 +1,36 @@
1
+ export declare const NETWORK_CAPTURE_MESSAGE_TYPE: "assembly.debug.network";
2
+ export interface NetworkCaptureHeaderFingerprint {
3
+ present: true;
4
+ /** Auth scheme word (e.g. "Bearer", "Basic") when the value looks like "<scheme> <token>". */
5
+ scheme?: string;
6
+ /** Bounded prefix of the value (after the scheme, if any) — never the full secret. */
7
+ prefix: string;
8
+ /** Length of the original, un-redacted value. */
9
+ len: number;
10
+ }
11
+ type RedactedHeaderValue = string | NetworkCaptureHeaderFingerprint;
12
+ export interface NetworkCaptureRecord {
13
+ id: string;
14
+ source: 'fetch' | 'xhr';
15
+ method: string;
16
+ url: string;
17
+ status: number | null;
18
+ ok: boolean | null;
19
+ startedAt: number;
20
+ durationMs: number;
21
+ requestHeaders: Record<string, RedactedHeaderValue>;
22
+ responseHeaders: Record<string, RedactedHeaderValue>;
23
+ requestBodySnippet?: string;
24
+ responseBodySnippet?: string;
25
+ error?: string;
26
+ }
27
+ export declare function redactUrl(rawUrl: string): string;
28
+ export declare function collectHeaders(headers: HeadersInit | Headers | null | undefined): Record<string, RedactedHeaderValue>;
29
+ export declare function collectXhrResponseHeaders(raw: string): Record<string, RedactedHeaderValue>;
30
+ export declare function snippetFromRequestBody(body: unknown): string | undefined;
31
+ export declare function getCapturedRecords(): readonly NetworkCaptureRecord[];
32
+ export declare function recordNetworkCapture(record: NetworkCaptureRecord): void;
33
+ export declare function installFetchCapture(): void;
34
+ export declare function installXhrCapture(): void;
35
+ export declare function installNetworkCapture(): void;
36
+ export {};