@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.
- package/CLAUDE.md +1119 -0
- package/dist/api-errors.d.ts +5 -0
- package/dist/api-errors.js +33 -0
- package/dist/app-storage.d.ts +7 -0
- package/dist/app-storage.js +22 -0
- package/dist/assembly.d.ts +49 -0
- package/dist/assembly.js +235 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +118 -0
- package/dist/client-companies.d.ts +16 -0
- package/dist/client-companies.js +23 -0
- package/dist/client.d.ts +5 -2
- package/dist/client.js +14 -3
- package/dist/labels.d.ts +9 -0
- package/dist/labels.js +17 -0
- package/dist/mint-bearer.d.ts +18 -0
- package/dist/mint-bearer.js +152 -0
- package/dist/nango.d.ts +174 -0
- package/dist/nango.js +430 -0
- package/dist/network-capture.d.ts +36 -0
- package/dist/network-capture.js +473 -0
- package/dist/params.d.ts +3 -0
- package/dist/params.js +20 -0
- package/dist/resource-resolution.d.ts +89 -0
- package/dist/resource-resolution.js +142 -0
- package/dist/server.d.ts +9 -2
- package/dist/server.js +15 -5
- package/dist/session-exchange.d.ts +20 -0
- package/dist/session-exchange.js +101 -0
- package/dist/theme.d.ts +5 -0
- package/dist/theme.js +20 -0
- package/package.json +11 -4
- package/skills/assembly-ai/SKILL.md +133 -0
- package/skills/assembly-app-bridge-header/SKILL.md +88 -0
- package/skills/assembly-app-bridge-toast/SKILL.md +79 -0
- package/skills/assembly-avatar-sub/SKILL.md +103 -0
- package/skills/assembly-client-users/SKILL.md +45 -0
- package/skills/assembly-clients-companies/SKILL.md +143 -0
- package/skills/assembly-companies/SKILL.md +45 -0
- package/skills/assembly-custom-fields/SKILL.md +86 -0
- package/skills/assembly-db-transactions/SKILL.md +79 -0
- package/skills/assembly-dev-server-logs/SKILL.md +26 -0
- package/skills/assembly-forms/SKILL.md +103 -0
- package/skills/assembly-internal-users/SKILL.md +69 -0
- package/skills/assembly-mutations/SKILL.md +58 -0
- package/skills/assembly-native-resources/SKILL.md +117 -0
- package/skills/assembly-route-auth/SKILL.md +223 -0
- package/skills/assembly-route-errors/SKILL.md +81 -0
- package/skills/assembly-semantic-colors/SKILL.md +76 -0
- package/skills/assembly-session-token/SKILL.md +153 -0
- package/skills/assembly-storage/SKILL.md +157 -0
- package/skills/assembly-user-company-selector/SKILL.md +98 -0
- package/skills/assembly-workspace-labels/SKILL.md +50 -0
- package/skills/clearing-client-notifications/SKILL.md +100 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// Client-side network capture: monkey-patches window.fetch and XMLHttpRequest so every
|
|
2
|
+
// generated app records its own HTTP traffic and reports it to the parent frame for
|
|
3
|
+
// debugging. This module only emits postMessage records — the wave-2 webapp relay that
|
|
4
|
+
// listens for MESSAGE_TYPE, and the backend ingest that stores them, ship separately.
|
|
5
|
+
// Until that listener exists this is inert: postToParent() no-ops if there's no parent
|
|
6
|
+
// to receive it.
|
|
7
|
+
//
|
|
8
|
+
// Redaction here is best-effort and CLIENT-SIDE ONLY. The real security boundary is
|
|
9
|
+
// server-side, at ingest — never trust this module alone to keep secrets out of a
|
|
10
|
+
// downstream store.
|
|
11
|
+
export const NETWORK_CAPTURE_MESSAGE_TYPE = 'assembly.debug.network';
|
|
12
|
+
// Header names whose values are fingerprinted instead of forwarded. Matched
|
|
13
|
+
// case-insensitively against both request and response header names.
|
|
14
|
+
const SENSITIVE_HEADER_NAMES = new Set([
|
|
15
|
+
'authorization', 'cookie', 'set-cookie', 'x-api-key', 'x-assembly-token',
|
|
16
|
+
]);
|
|
17
|
+
// URL query parameter names redacted before a request/response URL is captured — these
|
|
18
|
+
// commonly carry bearer tokens, OAuth codes, or signed-URL credentials in the clear.
|
|
19
|
+
const SENSITIVE_QUERY_PARAM_NAMES = new Set([
|
|
20
|
+
'token', 'access_token', 'refresh_token', 'id_token', 'code', 'api_key', 'apikey',
|
|
21
|
+
'key', 'secret', 'client_secret', 'password', 'passwd', 'pwd', 'signature', 'sig',
|
|
22
|
+
'session', 'sessionid', 'auth',
|
|
23
|
+
]);
|
|
24
|
+
// Field/param name pattern redacted out of request bodies before capture. Matched
|
|
25
|
+
// case-insensitively against JSON keys and URL-encoded field names.
|
|
26
|
+
const SENSITIVE_BODY_FIELD_PATTERN = /pass(word)?|pwd|token|secret|api[-_]?key|access[-_]?key|auth|credential|ssn|card(number)?|cvv|cvc|pin/i;
|
|
27
|
+
const MAX_RING_SIZE = 50;
|
|
28
|
+
const MAX_HEADER_VALUE_CHARS = 200;
|
|
29
|
+
const MAX_BODY_SNIPPET_CHARS = 2_000;
|
|
30
|
+
const MAX_RECORD_JSON_CHARS = 8_000;
|
|
31
|
+
const HEADER_FINGERPRINT_PREFIX_CHARS = 8;
|
|
32
|
+
// Above this, an XHR response's Content-Length is large enough that materializing
|
|
33
|
+
// `responseText` just for a 2,000-char snippet isn't worth the memory it holds onto.
|
|
34
|
+
const MAX_XHR_SNIPPET_CONTENT_LENGTH_BYTES = 100_000;
|
|
35
|
+
let recordCounter = 0;
|
|
36
|
+
function nextRecordId() {
|
|
37
|
+
recordCounter += 1;
|
|
38
|
+
return `net_${Date.now().toString(36)}_${recordCounter}`;
|
|
39
|
+
}
|
|
40
|
+
function truncate(value, maxChars) {
|
|
41
|
+
return value.length > maxChars ? `${value.slice(0, maxChars)}…` : value;
|
|
42
|
+
}
|
|
43
|
+
// Reads only enough of a response body to fill a `maxChars` snippet, then cancels the
|
|
44
|
+
// rest of the stream — so a large file download or a long-lived/streaming response never
|
|
45
|
+
// gets fully buffered in memory just for telemetry. Falls back to a single `.text()` read
|
|
46
|
+
// when the body isn't a stream (e.g. in test environments that polyfill `Response`).
|
|
47
|
+
async function readBoundedResponseText(response, maxChars) {
|
|
48
|
+
const clone = response.clone();
|
|
49
|
+
const body = clone.body;
|
|
50
|
+
if (!body)
|
|
51
|
+
return truncate(await clone.text(), maxChars);
|
|
52
|
+
const reader = body.getReader();
|
|
53
|
+
const decoder = new TextDecoder();
|
|
54
|
+
let text = '';
|
|
55
|
+
try {
|
|
56
|
+
while (text.length <= maxChars) {
|
|
57
|
+
const { done, value } = await reader.read();
|
|
58
|
+
if (done)
|
|
59
|
+
break;
|
|
60
|
+
text += decoder.decode(value, { stream: true });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
void reader.cancel().catch(() => { });
|
|
65
|
+
}
|
|
66
|
+
return truncate(text, maxChars);
|
|
67
|
+
}
|
|
68
|
+
// Fingerprints a single sensitive header value. Deliberately uniform across
|
|
69
|
+
// authorization/cookie/x-api-key: scheme+prefix+len is enough to debug "is a token
|
|
70
|
+
// present / did it change" without ever forwarding the value that grants access.
|
|
71
|
+
function fingerprintHeaderValue(value) {
|
|
72
|
+
const trimmed = value.trim();
|
|
73
|
+
const schemeMatch = /^([A-Za-z][\w-]*)\s+([\s\S]*)$/.exec(trimmed);
|
|
74
|
+
const scheme = schemeMatch?.[1];
|
|
75
|
+
const rest = schemeMatch ? schemeMatch[2] : trimmed;
|
|
76
|
+
return {
|
|
77
|
+
present: true,
|
|
78
|
+
...(scheme ? { scheme } : {}),
|
|
79
|
+
prefix: rest.slice(0, HEADER_FINGERPRINT_PREFIX_CHARS),
|
|
80
|
+
len: value.length,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function redactHeaderValue(name, value) {
|
|
84
|
+
if (SENSITIVE_HEADER_NAMES.has(name.toLowerCase()))
|
|
85
|
+
return fingerprintHeaderValue(value);
|
|
86
|
+
return truncate(value, MAX_HEADER_VALUE_CHARS);
|
|
87
|
+
}
|
|
88
|
+
// Redacts sensitive query parameter values (tokens, OAuth codes, signed-URL secrets, ...)
|
|
89
|
+
// out of a captured URL. Falls back to the raw URL if it can't be parsed — a relative or
|
|
90
|
+
// malformed URL still gets recorded, since redaction here is best-effort, not the boundary.
|
|
91
|
+
// Presigned-URL credentials live under vendor prefixes (AWS SigV4 x-amz-*, GCP
|
|
92
|
+
// x-goog-*) or a bare AWSAccessKeyId, none of which match the exact-name set.
|
|
93
|
+
// Redacting the whole vendor-prefixed family is safe — over-redacting a benign
|
|
94
|
+
// x-amz-date in a debug capture is fine, leaking the signature is not.
|
|
95
|
+
function isSensitiveQueryParam(name) {
|
|
96
|
+
const lower = name.toLowerCase();
|
|
97
|
+
return (SENSITIVE_QUERY_PARAM_NAMES.has(lower) ||
|
|
98
|
+
lower.startsWith('x-amz-') ||
|
|
99
|
+
lower.startsWith('x-goog-') ||
|
|
100
|
+
lower === 'awsaccesskeyid');
|
|
101
|
+
}
|
|
102
|
+
export function redactUrl(rawUrl) {
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(rawUrl, typeof window !== 'undefined' ? window.location.href : undefined);
|
|
105
|
+
let redacted = false;
|
|
106
|
+
for (const name of Array.from(url.searchParams.keys())) {
|
|
107
|
+
if (!isSensitiveQueryParam(name))
|
|
108
|
+
continue;
|
|
109
|
+
const value = url.searchParams.get(name) ?? '';
|
|
110
|
+
url.searchParams.set(name, `[redacted:${value.length}]`);
|
|
111
|
+
redacted = true;
|
|
112
|
+
}
|
|
113
|
+
return redacted ? url.toString() : rawUrl;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return rawUrl;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Normalizes the many shapes fetch()/XHR headers can take into a redacted map.
|
|
120
|
+
export function collectHeaders(headers) {
|
|
121
|
+
const result = {};
|
|
122
|
+
if (!headers)
|
|
123
|
+
return result;
|
|
124
|
+
const entries = headers instanceof Headers
|
|
125
|
+
? Array.from(headers.entries())
|
|
126
|
+
: Array.isArray(headers)
|
|
127
|
+
? headers
|
|
128
|
+
: Object.entries(headers);
|
|
129
|
+
for (const [name, value] of entries) {
|
|
130
|
+
result[name] = redactHeaderValue(name, String(value));
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
// Parses a raw XHR getAllResponseHeaders() blob ("name: value\r\n...") into a redacted map.
|
|
135
|
+
export function collectXhrResponseHeaders(raw) {
|
|
136
|
+
const result = {};
|
|
137
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
138
|
+
const idx = line.indexOf(':');
|
|
139
|
+
if (idx === -1)
|
|
140
|
+
continue;
|
|
141
|
+
const name = line.slice(0, idx).trim();
|
|
142
|
+
const value = line.slice(idx + 1).trim();
|
|
143
|
+
if (!name)
|
|
144
|
+
continue;
|
|
145
|
+
result[name] = redactHeaderValue(name, value);
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
// Redacts sensitive keys (password, token, card number, ...) out of a parsed JSON body,
|
|
150
|
+
// recursing into nested objects/arrays. Non-sensitive values pass through unchanged.
|
|
151
|
+
function redactSensitiveFields(value) {
|
|
152
|
+
if (Array.isArray(value))
|
|
153
|
+
return value.map(redactSensitiveFields);
|
|
154
|
+
if (value !== null && typeof value === 'object') {
|
|
155
|
+
const result = {};
|
|
156
|
+
for (const [key, val] of Object.entries(value)) {
|
|
157
|
+
result[key] = SENSITIVE_BODY_FIELD_PATTERN.test(key) ? '[redacted]' : redactSensitiveFields(val);
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
// Redacts sensitive field values out of a `application/x-www-form-urlencoded` body.
|
|
164
|
+
function redactUrlEncodedBody(raw) {
|
|
165
|
+
const params = new URLSearchParams(raw);
|
|
166
|
+
let redacted = false;
|
|
167
|
+
for (const name of Array.from(params.keys())) {
|
|
168
|
+
if (!SENSITIVE_BODY_FIELD_PATTERN.test(name))
|
|
169
|
+
continue;
|
|
170
|
+
params.set(name, '[redacted]');
|
|
171
|
+
redacted = true;
|
|
172
|
+
}
|
|
173
|
+
return redacted ? params.toString() : raw;
|
|
174
|
+
}
|
|
175
|
+
// Best-effort field-level redaction for a string request body. Handles the two structured
|
|
176
|
+
// shapes a login/payment/API request body normally takes — JSON and url-encoded form data —
|
|
177
|
+
// so passwords, tokens, and card numbers don't ship verbatim. Anything else (plain text,
|
|
178
|
+
// unstructured strings) is left as-is: there's no reliable field boundary to redact within.
|
|
179
|
+
// Fallback scrub for a body that can't be parsed as structured data — most
|
|
180
|
+
// importantly a JSON response truncated mid-object before it reached this
|
|
181
|
+
// point. Redacts the values of sensitive JSON fields and query/form params by
|
|
182
|
+
// name (reusing SENSITIVE_BODY_FIELD_PATTERN) so a partial body still can't
|
|
183
|
+
// ship a credential to the parent frame.
|
|
184
|
+
function scrubSensitivePatterns(text) {
|
|
185
|
+
return text
|
|
186
|
+
.replace(/"([\w.-]+)"(\s*:\s*)"[^"]*"/g, (m, key, sep) => SENSITIVE_BODY_FIELD_PATTERN.test(key) ? `"${key}"${sep}"[redacted]"` : m)
|
|
187
|
+
.replace(/([\w.-]+)=([^&\s"']+)/g, (m, key) => SENSITIVE_BODY_FIELD_PATTERN.test(key) ? `${key}=[redacted]` : m);
|
|
188
|
+
}
|
|
189
|
+
function redactStringBody(raw) {
|
|
190
|
+
const trimmed = raw.trim();
|
|
191
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
192
|
+
try {
|
|
193
|
+
return JSON.stringify(redactSensitiveFields(JSON.parse(trimmed)));
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Truncated or otherwise invalid JSON — pattern-scrub the raw text so a
|
|
197
|
+
// sensitive field in the retained prefix is still redacted.
|
|
198
|
+
return scrubSensitivePatterns(raw);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// URLSearchParams parses any string without throwing and redactUrlEncodedBody
|
|
202
|
+
// returns the original when nothing sensitive is present, so plain text passes
|
|
203
|
+
// through untouched while a form body — even one whose value contains '=' —
|
|
204
|
+
// still gets its sensitive fields redacted.
|
|
205
|
+
return redactUrlEncodedBody(raw);
|
|
206
|
+
}
|
|
207
|
+
// Field-level redaction for a captured response body, mirroring the request-body
|
|
208
|
+
// path so a login/token-refresh response can't ship an access token or PII to the
|
|
209
|
+
// parent frame. Best-effort: a body larger than the snippet bound may not parse as
|
|
210
|
+
// JSON, in which case it falls through unredacted — the server ingest is the hard
|
|
211
|
+
// boundary — but the common small structured response is redacted here first.
|
|
212
|
+
function snippetFromResponseText(text) {
|
|
213
|
+
return truncate(redactStringBody(text), MAX_BODY_SNIPPET_CHARS);
|
|
214
|
+
}
|
|
215
|
+
// Best-effort, synchronous snapshot of an outgoing body. Anything that isn't cheap and
|
|
216
|
+
// safe to stringify (FormData, Blob, streams, ...) is summarized rather than read, so
|
|
217
|
+
// capture never buffers large/binary payloads or consumes a stream the app still needs.
|
|
218
|
+
export function snippetFromRequestBody(body) {
|
|
219
|
+
if (body == null)
|
|
220
|
+
return undefined;
|
|
221
|
+
if (typeof body === 'string')
|
|
222
|
+
return truncate(redactStringBody(body), MAX_BODY_SNIPPET_CHARS);
|
|
223
|
+
if (body instanceof URLSearchParams) {
|
|
224
|
+
return truncate(redactUrlEncodedBody(body.toString()), MAX_BODY_SNIPPET_CHARS);
|
|
225
|
+
}
|
|
226
|
+
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
227
|
+
return `[FormData fields: ${truncate(Array.from(body.keys()).join(', '), MAX_BODY_SNIPPET_CHARS)}]`;
|
|
228
|
+
}
|
|
229
|
+
if (typeof Blob !== 'undefined' && body instanceof Blob)
|
|
230
|
+
return `[Blob ${body.size} bytes]`;
|
|
231
|
+
if (body instanceof ArrayBuffer)
|
|
232
|
+
return `[ArrayBuffer ${body.byteLength} bytes]`;
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
// Keeps the final record within MAX_RECORD_JSON_CHARS by dropping the least useful
|
|
236
|
+
// (largest, most redundant) fields first — bodies before headers, since method/url/
|
|
237
|
+
// status/timing is the part worth keeping if something has to give.
|
|
238
|
+
function capRecordSize(record) {
|
|
239
|
+
let candidate = record;
|
|
240
|
+
const fits = (r) => JSON.stringify(r).length <= MAX_RECORD_JSON_CHARS;
|
|
241
|
+
if (fits(candidate))
|
|
242
|
+
return candidate;
|
|
243
|
+
candidate = { ...candidate, responseBodySnippet: undefined };
|
|
244
|
+
if (fits(candidate))
|
|
245
|
+
return candidate;
|
|
246
|
+
candidate = { ...candidate, requestBodySnippet: undefined };
|
|
247
|
+
if (fits(candidate))
|
|
248
|
+
return candidate;
|
|
249
|
+
candidate = { ...candidate, responseHeaders: {} };
|
|
250
|
+
if (fits(candidate))
|
|
251
|
+
return candidate;
|
|
252
|
+
return { ...candidate, requestHeaders: {} };
|
|
253
|
+
}
|
|
254
|
+
const ring = [];
|
|
255
|
+
export function getCapturedRecords() {
|
|
256
|
+
return ring;
|
|
257
|
+
}
|
|
258
|
+
let cachedParentOrigin;
|
|
259
|
+
// Mirrors sdk/app-bridge's src/core/origins.ts allowlist. Kept as a local copy (rather
|
|
260
|
+
// than importing the npm package) because only its public AssemblyBridge API — not this
|
|
261
|
+
// internal helper — is exported for consumers.
|
|
262
|
+
const ALLOWED_PARENT_ORIGIN_PATTERNS = [
|
|
263
|
+
/^https?:\/\/localhost(:\d+)?$/,
|
|
264
|
+
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
|
|
265
|
+
/^https:\/\/([a-z0-9-]+\.)?copilot\.app$/,
|
|
266
|
+
/^https:\/\/([a-z0-9-]+\.)?myassembly\.com$/,
|
|
267
|
+
/^https:\/\/dashboard\.assembly\.com$/,
|
|
268
|
+
/^https:\/\/([a-z0-9-]+\.)?copilot-staging\.app$/,
|
|
269
|
+
/^https:\/\/([a-z0-9-]+\.)?myassembly-staging\.com$/,
|
|
270
|
+
/^https:\/\/dashboard\.assembly-staging\.com$/,
|
|
271
|
+
];
|
|
272
|
+
function isAllowedParentOrigin(origin) {
|
|
273
|
+
return ALLOWED_PARENT_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin));
|
|
274
|
+
}
|
|
275
|
+
function getParentOrigin() {
|
|
276
|
+
if (cachedParentOrigin !== undefined)
|
|
277
|
+
return cachedParentOrigin;
|
|
278
|
+
if (typeof document === 'undefined' || !document.referrer) {
|
|
279
|
+
cachedParentOrigin = null;
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
cachedParentOrigin = new URL(document.referrer).origin;
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
cachedParentOrigin = null;
|
|
287
|
+
}
|
|
288
|
+
return cachedParentOrigin;
|
|
289
|
+
}
|
|
290
|
+
function postToParent(record) {
|
|
291
|
+
if (typeof window === 'undefined' || window.parent === window)
|
|
292
|
+
return;
|
|
293
|
+
const origin = getParentOrigin();
|
|
294
|
+
if (!origin || !isAllowedParentOrigin(origin))
|
|
295
|
+
return;
|
|
296
|
+
try {
|
|
297
|
+
window.parent.postMessage({ type: NETWORK_CAPTURE_MESSAGE_TYPE, record }, origin);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Best-effort telemetry — never let a postMessage failure surface to the app.
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
export function recordNetworkCapture(record) {
|
|
304
|
+
const capped = capRecordSize(record);
|
|
305
|
+
ring.push(capped);
|
|
306
|
+
if (ring.length > MAX_RING_SIZE)
|
|
307
|
+
ring.shift();
|
|
308
|
+
postToParent(capped);
|
|
309
|
+
}
|
|
310
|
+
function methodFromFetchArgs(input, init) {
|
|
311
|
+
if (init?.method)
|
|
312
|
+
return init.method.toUpperCase();
|
|
313
|
+
if (typeof Request !== 'undefined' && input instanceof Request)
|
|
314
|
+
return input.method.toUpperCase();
|
|
315
|
+
return 'GET';
|
|
316
|
+
}
|
|
317
|
+
function urlFromFetchArgs(input) {
|
|
318
|
+
const raw = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
319
|
+
return redactUrl(raw);
|
|
320
|
+
}
|
|
321
|
+
let fetchPatched = false;
|
|
322
|
+
export function installFetchCapture() {
|
|
323
|
+
if (fetchPatched || typeof window === 'undefined' || typeof window.fetch !== 'function')
|
|
324
|
+
return;
|
|
325
|
+
fetchPatched = true;
|
|
326
|
+
const originalFetch = window.fetch.bind(window);
|
|
327
|
+
window.fetch = async function capturedFetch(input, init) {
|
|
328
|
+
const startedAt = Date.now();
|
|
329
|
+
const method = methodFromFetchArgs(input, init);
|
|
330
|
+
const url = urlFromFetchArgs(input);
|
|
331
|
+
const requestHeaders = collectHeaders(init?.headers ?? (typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined));
|
|
332
|
+
let requestBodySnippet;
|
|
333
|
+
try {
|
|
334
|
+
requestBodySnippet = snippetFromRequestBody(init?.body);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// Reading the body description shouldn't ever throw, but never let capture break the app.
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const response = await originalFetch(input, init);
|
|
341
|
+
// Read the body off a clone, in the background, so capture never delays or
|
|
342
|
+
// consumes the stream the caller's own `await fetch()` is waiting on.
|
|
343
|
+
void (async () => {
|
|
344
|
+
let responseBodySnippet;
|
|
345
|
+
try {
|
|
346
|
+
const text = await readBoundedResponseText(response, MAX_BODY_SNIPPET_CHARS);
|
|
347
|
+
responseBodySnippet = text === undefined ? undefined : snippetFromResponseText(text);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// Non-text or already-consumed body — omit the snippet, metadata still ships.
|
|
351
|
+
}
|
|
352
|
+
recordNetworkCapture({
|
|
353
|
+
id: nextRecordId(),
|
|
354
|
+
source: 'fetch',
|
|
355
|
+
method,
|
|
356
|
+
url,
|
|
357
|
+
status: response.status,
|
|
358
|
+
ok: response.ok,
|
|
359
|
+
startedAt,
|
|
360
|
+
durationMs: Date.now() - startedAt,
|
|
361
|
+
requestHeaders,
|
|
362
|
+
responseHeaders: collectHeaders(response.headers),
|
|
363
|
+
requestBodySnippet,
|
|
364
|
+
responseBodySnippet,
|
|
365
|
+
});
|
|
366
|
+
})();
|
|
367
|
+
return response;
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
recordNetworkCapture({
|
|
371
|
+
id: nextRecordId(),
|
|
372
|
+
source: 'fetch',
|
|
373
|
+
method,
|
|
374
|
+
url,
|
|
375
|
+
status: null,
|
|
376
|
+
ok: null,
|
|
377
|
+
startedAt,
|
|
378
|
+
durationMs: Date.now() - startedAt,
|
|
379
|
+
requestHeaders,
|
|
380
|
+
responseHeaders: {},
|
|
381
|
+
requestBodySnippet,
|
|
382
|
+
error: err instanceof Error ? err.message : String(err),
|
|
383
|
+
});
|
|
384
|
+
throw err;
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
let xhrPatched = false;
|
|
389
|
+
export function installXhrCapture() {
|
|
390
|
+
if (xhrPatched || typeof window === 'undefined' || typeof window.XMLHttpRequest !== 'function')
|
|
391
|
+
return;
|
|
392
|
+
xhrPatched = true;
|
|
393
|
+
const OriginalXhr = window.XMLHttpRequest;
|
|
394
|
+
const originalOpen = OriginalXhr.prototype.open;
|
|
395
|
+
const originalSend = OriginalXhr.prototype.send;
|
|
396
|
+
const originalSetRequestHeader = OriginalXhr.prototype.setRequestHeader;
|
|
397
|
+
const state = new WeakMap();
|
|
398
|
+
OriginalXhr.prototype.open = function patchedOpen(method, url, ...rest) {
|
|
399
|
+
state.set(this, { method: method.toUpperCase(), url: redactUrl(String(url)), startedAt: 0, requestHeaders: {} });
|
|
400
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
401
|
+
return originalOpen.apply(this, [method, url, ...rest]);
|
|
402
|
+
};
|
|
403
|
+
OriginalXhr.prototype.setRequestHeader = function patchedSetRequestHeader(name, value) {
|
|
404
|
+
const entry = state.get(this);
|
|
405
|
+
if (entry)
|
|
406
|
+
entry.requestHeaders[name] = redactHeaderValue(name, value);
|
|
407
|
+
return originalSetRequestHeader.call(this, name, value);
|
|
408
|
+
};
|
|
409
|
+
OriginalXhr.prototype.send = function patchedSend(body) {
|
|
410
|
+
const entry = state.get(this);
|
|
411
|
+
if (entry) {
|
|
412
|
+
entry.startedAt = Date.now();
|
|
413
|
+
const requestBodySnippet = (() => {
|
|
414
|
+
try {
|
|
415
|
+
return snippetFromRequestBody(body);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
})();
|
|
421
|
+
this.addEventListener('loadend', () => {
|
|
422
|
+
let responseBodySnippet;
|
|
423
|
+
try {
|
|
424
|
+
if (this.responseType === '' || this.responseType === 'text') {
|
|
425
|
+
// Only materialize the body when a finite, acceptable Content-Length
|
|
426
|
+
// is present. A chunked response omits the header (Number(null) === 0),
|
|
427
|
+
// so this skips capture rather than buffering an unbounded stream.
|
|
428
|
+
const contentLengthHeader = this.getResponseHeader('content-length');
|
|
429
|
+
const contentLength = Number(contentLengthHeader);
|
|
430
|
+
responseBodySnippet =
|
|
431
|
+
contentLengthHeader !== null &&
|
|
432
|
+
Number.isFinite(contentLength) &&
|
|
433
|
+
contentLength <= MAX_XHR_SNIPPET_CONTENT_LENGTH_BYTES
|
|
434
|
+
? snippetFromResponseText(this.responseText)
|
|
435
|
+
: undefined;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// responseText throws for some responseTypes — omit rather than guess.
|
|
440
|
+
}
|
|
441
|
+
let responseHeaders = {};
|
|
442
|
+
try {
|
|
443
|
+
responseHeaders = collectXhrResponseHeaders(this.getAllResponseHeaders());
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// getAllResponseHeaders() can throw before headers arrive (e.g. network error).
|
|
447
|
+
}
|
|
448
|
+
recordNetworkCapture({
|
|
449
|
+
id: nextRecordId(),
|
|
450
|
+
source: 'xhr',
|
|
451
|
+
method: entry.method,
|
|
452
|
+
url: entry.url,
|
|
453
|
+
status: this.status || null,
|
|
454
|
+
ok: this.status >= 200 && this.status < 400,
|
|
455
|
+
startedAt: entry.startedAt,
|
|
456
|
+
durationMs: Date.now() - entry.startedAt,
|
|
457
|
+
requestHeaders: entry.requestHeaders,
|
|
458
|
+
responseHeaders,
|
|
459
|
+
requestBodySnippet,
|
|
460
|
+
responseBodySnippet,
|
|
461
|
+
error: this.status === 0 ? 'Network error or request aborted' : undefined,
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
return originalSend.call(this, body ?? null);
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
// Installs both patches. Safe to call more than once (each patch is idempotent) — the
|
|
469
|
+
// client component below calls it from an effect that can re-run in dev StrictMode.
|
|
470
|
+
export function installNetworkCapture() {
|
|
471
|
+
installFetchCapture();
|
|
472
|
+
installXhrCapture();
|
|
473
|
+
}
|
package/dist/params.d.ts
ADDED
package/dist/params.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { badRequest } from './api-errors.js';
|
|
2
|
+
// RFC 4122 UUID. Call before passing a value into a uuid-typed Drizzle column —
|
|
3
|
+
// Postgres throws on bad syntax and route handlers must not leak that string.
|
|
4
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
5
|
+
export function isUuid(value) {
|
|
6
|
+
return UUID_RE.test(value);
|
|
7
|
+
}
|
|
8
|
+
// Returns the value, or a 400 NextResponse. Use only when the next step is a
|
|
9
|
+
// query/mutation against a uuid column — not as a blanket route-param check
|
|
10
|
+
// (slugs, external ids, and other non-uuid route segments are fine).
|
|
11
|
+
// const id = parseUuid(rawId);
|
|
12
|
+
// if (id instanceof NextResponse) return id;
|
|
13
|
+
// await db.query.items.findFirst({ where: eq(items.id, id) });
|
|
14
|
+
// In server components/pages, use isUuid(id) and call notFound() instead.
|
|
15
|
+
export function parseUuid(value, label = 'id') {
|
|
16
|
+
if (!isUuid(value)) {
|
|
17
|
+
return badRequest(`Invalid ${label}`);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export interface ResourceOption {
|
|
2
|
+
/** Stable provider-side identifier — what the app stores and sends back. */
|
|
3
|
+
id: string;
|
|
4
|
+
/** Human-readable name — what the UI renders. Never display the id. */
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ResourceError {
|
|
8
|
+
/** ConnectorError code, e.g. 'needs_reauth', 'connection_required'. */
|
|
9
|
+
code: string;
|
|
10
|
+
provider: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* One page of resolved options, as data rather than exceptions: a thrown
|
|
14
|
+
* ConnectorError does not survive a Server Action boundary in production
|
|
15
|
+
* (Next.js redacts server errors), so broker failures travel inside the
|
|
16
|
+
* payload where the client can still branch on `error.code`.
|
|
17
|
+
*/
|
|
18
|
+
export type ResourcePage = {
|
|
19
|
+
ok: true;
|
|
20
|
+
options: ResourceOption[];
|
|
21
|
+
nextCursor?: string;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
error: ResourceError;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Where the id/label/items/cursor live on a resolver action's result. Resolver
|
|
28
|
+
* actions disagree on all four (Slack returns `conversations` + `next_cursor`,
|
|
29
|
+
* Drive `files` + `nextPageToken`, HubSpot `items`/`contacts` + `nextCursor`),
|
|
30
|
+
* so the caller supplies the mapping — take it from the action's catalog
|
|
31
|
+
* signature. All values are dot-paths (`response_metadata.next_cursor` works).
|
|
32
|
+
*/
|
|
33
|
+
export interface ResourceFieldMap {
|
|
34
|
+
/** Path to the identifier on each raw item. */
|
|
35
|
+
id: string;
|
|
36
|
+
/** Path to the display name on each raw item; falls back to the id. */
|
|
37
|
+
label: string;
|
|
38
|
+
/**
|
|
39
|
+
* Path to the item array on the result. When omitted, the result itself (if
|
|
40
|
+
* an array) or its first array-valued property is used.
|
|
41
|
+
*/
|
|
42
|
+
items?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Path to the next-page cursor on the result. When omitted, common cursor
|
|
45
|
+
* keys are tried in order.
|
|
46
|
+
*/
|
|
47
|
+
nextCursor?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Normalize one raw resolver-action result into `{ id, label }` options plus
|
|
51
|
+
* the next-page cursor. Items without a usable id are dropped; a missing label
|
|
52
|
+
* falls back to the id. When `query` is given, options are filtered by
|
|
53
|
+
* case-insensitive label substring — the client-side fallback for resolver
|
|
54
|
+
* actions with no server-side search (it narrows each fetched page, so paging
|
|
55
|
+
* through "Load more" still walks the full list).
|
|
56
|
+
*/
|
|
57
|
+
export declare function normalizeResourcePage(raw: unknown, fields: ResourceFieldMap, opts?: {
|
|
58
|
+
query?: string;
|
|
59
|
+
requestCursor?: string;
|
|
60
|
+
}): {
|
|
61
|
+
options: ResourceOption[];
|
|
62
|
+
nextCursor?: string;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Walk normalized pages until an option matches. The generic half of "resolve
|
|
66
|
+
* a name to an id": the caller's `fetchPage` owns transport and provider
|
|
67
|
+
* quirks (retries, scope fallbacks); this owns the cursor walk, the page
|
|
68
|
+
* bound, and the echoed-cursor stop.
|
|
69
|
+
*/
|
|
70
|
+
export declare function findResource(fetchPage: (cursor?: string) => Promise<{
|
|
71
|
+
options: ResourceOption[];
|
|
72
|
+
nextCursor?: string;
|
|
73
|
+
}>, match: (option: ResourceOption) => boolean, opts?: {
|
|
74
|
+
maxPages?: number;
|
|
75
|
+
}): Promise<ResourceOption | null>;
|
|
76
|
+
/**
|
|
77
|
+
* Small process-lifetime memo with a TTL and a size cap, for resolver results
|
|
78
|
+
* that are safe to share across requests. A deployed app serves exactly one
|
|
79
|
+
* workspace and connections are workspace-scoped, so entries never leak across
|
|
80
|
+
* tenants; the TTL just keeps freshly created resources from being invisible
|
|
81
|
+
* for long.
|
|
82
|
+
*/
|
|
83
|
+
export declare class TtlCache<V> {
|
|
84
|
+
private readonly maxEntries;
|
|
85
|
+
private readonly entries;
|
|
86
|
+
constructor(maxEntries: number);
|
|
87
|
+
get(key: string): V | undefined;
|
|
88
|
+
set(key: string, value: V, ttlMs: number): void;
|
|
89
|
+
}
|