@somewhere-tech/cli 0.10.0 → 0.12.0

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,3678 @@
1
+ // VENDORED from worker/src/utils/function-bundle.ts (PLATFORM_CONTEXT_JS) @ 58927b3
2
+ // — the exact runtime deployed functions run against. Do not edit by hand;
3
+ // re-sync with: node scripts/extract-runtime.mjs <monorepo>
4
+ // Process-wide cache for sw.auth.me — keyed by the JWT signature
5
+ // segment so the full token never lives outside the user's call frame
6
+ // any longer than the request itself. CF Workers reuse isolates across
7
+ // requests, so a hit here saves the /v1/auth/me round-trip on every
8
+ // subsequent call within the cache window. TTL is the smaller of 60s
9
+ // or (jwt.exp - now - 5s) so a near-expiry token is never returned
10
+ // past its real expiry. Soft cap of 256 entries; oldest evicted on
11
+ // overflow. Trade-off: a token revoked server-side stays "valid"
12
+ // inside this isolate for up to 60s. If you need stricter freshness,
13
+ // fetch /v1/auth/me directly via fetch() instead of sw.auth.me.
14
+ const __sw_authMeCache = new Map();
15
+ const __sw_AUTH_ME_TTL_MS = 60_000;
16
+ const __sw_AUTH_ME_MAX = 256;
17
+
18
+ function __sw_b64urlDecode(s) {
19
+ s = s.replace(/-/g, '+').replace(/_/g, '/');
20
+ while (s.length % 4) s += '=';
21
+ return atob(s);
22
+ }
23
+
24
+ // Pre-flight: parse the JWT shape and check exp. Throws an AUTH_ERROR
25
+ // for malformed / expired tokens so we never even attempt the network
26
+ // call for obviously-bad input.
27
+ // Pull a named cookie out of a Cookie header. Returns the raw value
28
+ // (URL-decoded) or null. Used by sw.auth.fromRequest + anonSession so
29
+ // every demo doesn't reinvent cookie parsing.
30
+ function __sw_readCookie(header, name) {
31
+ if (!header || typeof header !== 'string') return null;
32
+ // Match name= surrounded by start, ;, or whitespace. Encoded chars
33
+ // (e.g. %3D) are tolerated in the value — we strip surrounding
34
+ // whitespace and decodeURIComponent at the end.
35
+ const re = new RegExp('(?:^|;\s*)' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)');
36
+ const m = header.match(re);
37
+ if (!m) return null;
38
+ const v = m[1].trim();
39
+ if (!v) return null;
40
+ try { return decodeURIComponent(v); } catch (_) { return v; }
41
+ }
42
+
43
+ // Pull a Bearer token out of an Authorization header. Returns the raw
44
+ // JWT or null. Used by fromRequest as the fall-back to cookie auth.
45
+ function __sw_readBearer(header) {
46
+ if (!header || typeof header !== 'string') return null;
47
+ const m = header.match(/^bearer\s+(.+)$/i);
48
+ if (!m) return null;
49
+ const v = m[1].trim();
50
+ return v || null;
51
+ }
52
+
53
+ function __sw_preflightJwt(token) {
54
+ const pre = __sw_preflightJwtAllowExpired(token);
55
+ if (pre.expired) {
56
+ const err = new Error('sw.auth.me: token expired');
57
+ err.code = 'AUTH_ERROR';
58
+ err.status = 401;
59
+ throw err;
60
+ }
61
+ return pre;
62
+ }
63
+
64
+ // Same shape parser as __sw_preflightJwt but does NOT throw on expiry —
65
+ // it returns { expired: true } instead so the header-based auto-refresh
66
+ // flow can decide whether to send the request to the platform with
67
+ // X-Refresh-Token attached.
68
+ function __sw_preflightJwtAllowExpired(token) {
69
+ if (typeof token !== 'string' || !token) {
70
+ const err = new Error('sw.auth.me: token is required');
71
+ err.code = 'AUTH_ERROR';
72
+ err.status = 401;
73
+ throw err;
74
+ }
75
+ const parts = token.split('.');
76
+ if (parts.length !== 3) {
77
+ const err = new Error('sw.auth.me: token is not a JWT');
78
+ err.code = 'AUTH_ERROR';
79
+ err.status = 401;
80
+ throw err;
81
+ }
82
+ let payload;
83
+ try {
84
+ payload = JSON.parse(__sw_b64urlDecode(parts[1]));
85
+ } catch (_) {
86
+ const err = new Error('sw.auth.me: token payload is not JSON');
87
+ err.code = 'AUTH_ERROR';
88
+ err.status = 401;
89
+ throw err;
90
+ }
91
+ const now = Math.floor(Date.now() / 1000);
92
+ const expSec = typeof payload.exp === 'number' ? payload.exp : 0;
93
+ return { sig: parts[2], expSec, expired: expSec > 0 && expSec <= now };
94
+ }
95
+
96
+ function buildPlatformContext(env, request) {
97
+ const projectId = env.PROJECT_ID;
98
+ const projectEnv = env.PROJECT_ENV || 'dev';
99
+ const platformDomain = env.PLATFORM_DOMAIN || 'somewhere.tech';
100
+ const platformBase = env.PLATFORM_API_BASE || ('https://api.' + platformDomain);
101
+ const apiKey = env.PROJECT_API_KEY;
102
+
103
+ async function platformFetch(path, opts) {
104
+ opts = opts || {};
105
+ const headers = {
106
+ 'Authorization': 'Bearer ' + apiKey,
107
+ ...(opts.headers || {}),
108
+ };
109
+ if (opts.body && !headers['Content-Type'] && !headers['content-type']) {
110
+ headers['Content-Type'] = 'application/json';
111
+ }
112
+ return fetch(platformBase + path, {
113
+ method: opts.method || 'GET',
114
+ headers,
115
+ body: opts.body,
116
+ });
117
+ }
118
+
119
+ async function platformJSON(path, opts) {
120
+ const r = await platformFetch(path, opts);
121
+ let data;
122
+ try { data = await r.json(); } catch { data = null; }
123
+ if (!r.ok || !data || data.ok === false) {
124
+ const msg = (data && data.message) || ('Platform call failed: ' + r.status);
125
+ const err = new Error(msg);
126
+ err.code = (data && data.error) || 'PLATFORM_ERROR';
127
+ err.status = r.status;
128
+ throw err;
129
+ }
130
+ return data.data;
131
+ }
132
+
133
+ // fs paths accept both '/avatars/x.png' and 'avatars/x.png' — code ported
134
+ // from Supabase storage never uses the leading slash. A missing slash used
135
+ // to glue the path onto the projectId in the URL ('/v1/fs/<id>avatars/…')
136
+ // → malformed route → opaque "fs.write failed: 403" (pfb_06445ed51a8b).
137
+ function __sw_fsPath(path) {
138
+ if (typeof path !== 'string' || path.length === 0) {
139
+ throw new Error('sw.fs: path must be a non-empty string (got ' + (path === '' ? 'an empty string' : typeof path) + ')');
140
+ }
141
+ return path.startsWith('/') ? path : '/' + path;
142
+ }
143
+
144
+ // Header-based auto-refresh stash. When sw.auth.fromRequest or
145
+ // sw.auth.me sees a paired X-Refresh-Token on the inbound request
146
+ // and the platform mints a new pair (returned via X-New-Access-Token
147
+ // / X-New-Refresh-Token on the /v1/auth/me response), we park the
148
+ // new pair here. The function shim (generateIndexModule) reads this
149
+ // after the user handler returns and attaches the same two headers
150
+ // to the user's outbound Response — so the dev's function does zero
151
+ // refresh work.
152
+ const __sw_pendingRefresh = { access: null, refresh: null };
153
+
154
+ // httpOnly cookie sessions (sw.auth.*WithCookie, tsk_1288e1c6). Auth cookies
155
+ // produced during the handler are parked here; the shim attaches them as
156
+ // Set-Cookie on the outbound Response post-handler (Option B — invisible, the
157
+ // dev never touches headers). The cookie persists 30d; the access JWT inside
158
+ // expires ~15min and fromRequest auto-refreshes it from the refresh cookie,
159
+ // re-issuing fresh cookies — so the browser stays logged in across restarts.
160
+ const __sw_pendingCookies = [];
161
+ const __SW_COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
162
+ function __sw_authCookie(name, value, maxAge) {
163
+ return name + '=' + encodeURIComponent(value) +
164
+ '; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=' + maxAge;
165
+ }
166
+ function __sw_setAuthCookies(access, refresh) {
167
+ // Names match what fromRequest reads back: access='token', refresh='sw_refresh_token'.
168
+ __sw_pendingCookies.push(__sw_authCookie('token', access, __SW_COOKIE_MAX_AGE));
169
+ __sw_pendingCookies.push(__sw_authCookie('sw_refresh_token', refresh, __SW_COOKIE_MAX_AGE));
170
+ }
171
+ function __sw_clearAuthCookies() {
172
+ __sw_pendingCookies.push(__sw_authCookie('token', '', 0));
173
+ __sw_pendingCookies.push(__sw_authCookie('sw_refresh_token', '', 0));
174
+ }
175
+
176
+ const sw = {
177
+ project_id: projectId,
178
+ subdomain: env.SUBDOMAIN || '',
179
+ tier: env.TIER || 'free',
180
+ env: (env.USER_ENV ? JSON.parse(env.USER_ENV) : {}),
181
+ request_id: request.headers.get('cf-ray') || crypto.randomUUID(),
182
+ // Exposed under a name the user code is told never to touch. The
183
+ // shim reads it post-handler. Lives on the same object so user
184
+ // handlers can't accidentally return a fresh ctx without it.
185
+ __sw_pendingRefresh,
186
+ __sw_pendingCookies,
187
+
188
+ // sw.crypto — small crypto helpers for deployed functions.
189
+ //
190
+ // hmacSha256Hex(message, secret) → lowercase hex HMAC-SHA256. Verify
191
+ // inbound webhook signatures: auth/db/inbox webhooks sign the string
192
+ // "<t>.<body>" with your webhook secret and send it as the header
193
+ // X-Somewhere-Signature: t=<t>,v1=<hex>.
194
+ // timingSafeEqual(a, b) → constant-time string compare; use it to
195
+ // compare your computed signature against the request's so a timing
196
+ // side-channel can't leak how many bytes matched.
197
+ // bcrypt.verify(password, hash) → VERIFY-ONLY check against a bcrypt
198
+ // hash imported from another provider (Supabase / Clerk / Firebase /
199
+ // a self-hosted app) so users sign in with their existing password,
200
+ // no reset. There is deliberately NO bcrypt *hashing* surface — the
201
+ // platform only ever mints PBKDF2. If these are your sw.auth
202
+ // app_users you don't even need this: POST /v1/auth/import + the
203
+ // login path migrate them to native PBKDF2 automatically. One verify
204
+ // is ~10-50ms CPU; don't loop it.
205
+ crypto: {
206
+ hmacSha256Hex: async function (message, secret) {
207
+ const enc = new TextEncoder();
208
+ const key = await crypto.subtle.importKey(
209
+ 'raw',
210
+ enc.encode(String(secret)),
211
+ { name: 'HMAC', hash: 'SHA-256' },
212
+ false,
213
+ ['sign'],
214
+ );
215
+ const sig = await crypto.subtle.sign('HMAC', key, enc.encode(String(message)));
216
+ return Array.from(new Uint8Array(sig))
217
+ .map(function (b) { return b.toString(16).padStart(2, '0'); })
218
+ .join('');
219
+ },
220
+ timingSafeEqual: function (a, b) {
221
+ a = String(a);
222
+ b = String(b);
223
+ if (a.length !== b.length) return false;
224
+ let mismatch = 0;
225
+ for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
226
+ return mismatch === 0;
227
+ },
228
+ bcrypt: {
229
+ verify: async function (password, hash) {
230
+ const d = await platformJSON('/v1/auth/bcrypt-verify', {
231
+ method: 'POST',
232
+ body: JSON.stringify({ password: password, hash: hash }),
233
+ });
234
+ return !!(d && d.valid === true);
235
+ },
236
+ },
237
+ },
238
+
239
+ db: (function () {
240
+ // Two execution paths for sw.db, picked once at function start.
241
+ // 1. Native D1 binding when the deploy wired it up (env.PROJECT_DB).
242
+ // Zero HTTP, no auth middleware — fastest path.
243
+ // 2. REST fallback through /v1/db/query (+ /v1/db/batch) when
244
+ // no binding is attached. Used on accounts where the WfP
245
+ // dispatch-namespace + D1 combo is not yet enabled (CF
246
+ // returns 10015 at script-upload). Re-enable the native
247
+ // path by setting WFP_D1_BINDING_ENABLED=1 on the worker.
248
+ //
249
+ // The fallback synthesizes an object that mimics D1's binding
250
+ // surface — prepare(sql).bind(...).all() / .batch([...]) — so
251
+ // the rest of this module doesn't branch on which path served
252
+ // the query.
253
+ function __sw_makeRestDB() {
254
+ async function runOne(sql, params) {
255
+ const r = await platformFetch('/v1/db/query', {
256
+ method: 'POST',
257
+ headers: { 'Content-Type': 'application/json' },
258
+ body: JSON.stringify({ project_id: projectId, sql: sql, params: params || [] }),
259
+ });
260
+ if (!r.ok) {
261
+ const txt = await r.text().catch(function () { return ''; });
262
+ const err = new Error('sw.db query failed: HTTP ' + r.status + ' ' + txt.slice(0, 200));
263
+ err.code = 'DB_QUERY_FAILED';
264
+ throw err;
265
+ }
266
+ const j = await r.json();
267
+ const d = (j && j.data) || j || {};
268
+ // d.rows are row objects (the field name is a CF carry-over;
269
+ // see worker/src/utils/d1.ts:queryD1 — results[0] is already
270
+ // a record). Normalize to D1-binding shape.
271
+ return {
272
+ results: Array.isArray(d.rows) ? d.rows : (Array.isArray(d.results) ? d.results : []),
273
+ meta: {
274
+ last_row_id: (d.meta && d.meta.last_row_id) || null,
275
+ changes: (d.meta && d.meta.changes) || 0,
276
+ rows_read: (d.meta && d.meta.rows_read) || 0,
277
+ rows_written: (d.meta && d.meta.rows_written) || 0,
278
+ },
279
+ };
280
+ }
281
+ function prepare(sql) {
282
+ // __sw_sql / __sw_params are read back by batch() so it can
283
+ // reassemble { sql, params } and send the whole batch to the
284
+ // atomic /v1/db/batch endpoint. The query path (all/first/run)
285
+ // is unchanged.
286
+ const stmt = {
287
+ __sw_sql: sql,
288
+ __sw_params: [],
289
+ bind: function () { stmt.__sw_params = Array.prototype.slice.call(arguments); return stmt; },
290
+ all: function () { return runOne(sql, stmt.__sw_params); },
291
+ first: async function () {
292
+ const r = await runOne(sql, stmt.__sw_params);
293
+ return r.results[0] || null;
294
+ },
295
+ run: function () { return runOne(sql, stmt.__sw_params); },
296
+ };
297
+ return stmt;
298
+ }
299
+ async function batch(prepareds) {
300
+ // The native D1 binding runs .batch([...]) as a single
301
+ // all-or-nothing transaction. On the REST fallback we MUST
302
+ // give the same guarantee, so route the whole batch through
303
+ // the atomic /v1/db/batch endpoint (the per-project write
304
+ // serializer commits every statement or none). NEVER replay
305
+ // statements one-by-one over /v1/db/query: that silently
306
+ // drops atomicity and can leave a half-applied batch with no
307
+ // signal (parity audit: REST-fallback db.batch atomicity).
308
+ const list = Array.isArray(prepareds) ? prepareds : [];
309
+ const statements = list.map(function (p) {
310
+ return { sql: p && p.__sw_sql, params: (p && p.__sw_params) || [] };
311
+ });
312
+ // If we cannot assemble an atomic request, fail loudly rather
313
+ // than degrade to non-atomic sequential execution.
314
+ if (statements.length === 0 || statements.some(function (s) { return typeof s.sql !== 'string'; })) {
315
+ const err = new Error('sw.db.batch could not be assembled for atomic execution (each statement needs a SQL string). Refusing to run a non-atomic batch.');
316
+ err.code = 'DB_BATCH_NOT_ATOMIC';
317
+ throw err;
318
+ }
319
+ const r = await platformFetch('/v1/db/batch', {
320
+ method: 'POST',
321
+ headers: { 'Content-Type': 'application/json' },
322
+ body: JSON.stringify({ project_id: projectId, statements: statements }),
323
+ });
324
+ const txt = await r.text().catch(function () { return ''; });
325
+ let j = null;
326
+ try { j = txt ? JSON.parse(txt) : null; } catch (_) { j = null; }
327
+ if (!r.ok || !j || j.ok === false) {
328
+ const msg = (j && j.message) || ('sw.db.batch failed: HTTP ' + r.status + ' ' + txt.slice(0, 200));
329
+ const err = new Error(msg + ' The batch was rolled back; no statements were applied.');
330
+ err.code = (j && j.error) || 'DB_BATCH_FAILED';
331
+ throw err;
332
+ }
333
+ const d = (j && j.data) || {};
334
+ const results = Array.isArray(d.results) ? d.results : [];
335
+ // Normalize each per-statement result to the D1-binding shape
336
+ // the callers expect: { results, meta: { changes, last_row_id } }.
337
+ return results.map(function (row) {
338
+ return {
339
+ results: Array.isArray(row && row.rows) ? row.rows : [],
340
+ meta: {
341
+ last_row_id: (row && row.last_row_id != null) ? row.last_row_id : null,
342
+ changes: (row && row.changes) || 0,
343
+ rows_read: 0,
344
+ rows_written: (row && row.changes) || 0,
345
+ },
346
+ };
347
+ });
348
+ }
349
+ return { prepare: prepare, batch: batch };
350
+ }
351
+
352
+ // Direct D1 binding when the deploy wired it up. No HTTP, no API
353
+ // key, no auth middleware — the function's Worker talks directly
354
+ // to the project's database. Falls back to the REST shim above
355
+ // when the binding is absent (CF 10015 carve-out).
356
+ const DB = env.PROJECT_DB || __sw_makeRestDB();
357
+
358
+ // Scopes baked in at deploy time. Map of lowercased table name →
359
+ // owner column. Mirrors worker/src/utils/scope-enforcement.ts —
360
+ // see that file for the full threat model.
361
+ const SCOPES = (function () {
362
+ try { return env.PROJECT_SCOPES ? JSON.parse(env.PROJECT_SCOPES) : {}; }
363
+ catch (_) { return {}; }
364
+ })();
365
+
366
+ function ensureBinding() {
367
+ // Always satisfied now — DB is either the native binding or
368
+ // the REST facade. Kept as a no-op so the call sites below
369
+ // don't have to change. Remove with the next refactor.
370
+ }
371
+
372
+ function __sw_stripStringsAndComments(sql) {
373
+ let out = '', i = 0;
374
+ const n = sql.length;
375
+ while (i < n) {
376
+ const ch = sql[i];
377
+ if (ch === "'") {
378
+ out += ' '; i++;
379
+ while (i < n) {
380
+ if (sql[i] === "'" && sql[i + 1] === "'") { i += 2; continue; }
381
+ if (sql[i] === "'") { i++; break; }
382
+ i++;
383
+ }
384
+ continue;
385
+ }
386
+ if (ch === '-' && sql[i + 1] === '-') {
387
+ while (i < n && sql[i] !== '\n') i++;
388
+ continue;
389
+ }
390
+ if (ch === '/' && sql[i + 1] === '*') {
391
+ i += 2;
392
+ while (i < n && !(sql[i] === '*' && sql[i + 1] === '/')) i++;
393
+ if (i < n) i += 2;
394
+ continue;
395
+ }
396
+ out += ch; i++;
397
+ }
398
+ return out;
399
+ }
400
+
401
+ function __sw_extractTables(sql) {
402
+ const stripped = __sw_stripStringsAndComments(sql);
403
+ const tables = new Set();
404
+ const patterns = [
405
+ /\bFROM\s+("?)([a-zA-Z_][a-zA-Z0-9_]*)\1/gi,
406
+ /\bJOIN\s+("?)([a-zA-Z_][a-zA-Z0-9_]*)\1/gi,
407
+ /\bINTO\s+("?)([a-zA-Z_][a-zA-Z0-9_]*)\1/gi,
408
+ /\bUPDATE\s+("?)([a-zA-Z_][a-zA-Z0-9_]*)\1/gi,
409
+ ];
410
+ for (const rx of patterns) {
411
+ let m;
412
+ while ((m = rx.exec(stripped)) !== null) {
413
+ tables.add(m[2].toLowerCase());
414
+ }
415
+ }
416
+ return tables;
417
+ }
418
+
419
+ function __sw_detectStmt(stripped) {
420
+ const t = stripped.trimStart().toLowerCase();
421
+ if (t.startsWith('select') || t.startsWith('with')) return 'select';
422
+ if (t.startsWith('insert')) return 'insert';
423
+ if (t.startsWith('update')) return 'update';
424
+ if (t.startsWith('delete')) return 'delete';
425
+ return 'other';
426
+ }
427
+
428
+ function __sw_escapeRegex(s) {
429
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
430
+ }
431
+
432
+ function __sw_checkScope(sql) {
433
+ const tableNames = Object.keys(SCOPES);
434
+ if (tableNames.length === 0) return null;
435
+ const touched = __sw_extractTables(sql);
436
+ const stripped = __sw_stripStringsAndComments(sql);
437
+ const kind = __sw_detectStmt(stripped);
438
+ for (const table of touched) {
439
+ const ownerCol = SCOPES[table];
440
+ if (!ownerCol) continue;
441
+ if (kind === 'insert') {
442
+ const m = new RegExp('INTO\\s+"?' + table + '"?\\s*\\(([^)]*)\\)', 'i').exec(stripped);
443
+ if (!m) return { table: table, ownerCol: ownerCol, reason: 'INSERT into scoped table `' + table + '` must use the explicit-column form: INSERT INTO ' + table + ' (' + ownerCol + ', …) VALUES ($current_user, …).' };
444
+ const cols = m[1].split(',').map(function (s) { return s.trim().replace(/^"|"$/g, '').toLowerCase(); });
445
+ if (cols.indexOf(ownerCol.toLowerCase()) === -1) return { table: table, ownerCol: ownerCol, reason: 'INSERT into scoped table `' + table + '` must include column `' + ownerCol + '` set to $current_user.' };
446
+ if (!/\$current_user\b/.test(sql)) return { table: table, ownerCol: ownerCol, reason: 'INSERT into scoped table `' + table + '` must bind `' + ownerCol + '` to $current_user.' };
447
+ continue;
448
+ }
449
+ if (kind === 'select' || kind === 'update' || kind === 'delete') {
450
+ if (!/\bWHERE\b/i.test(stripped)) return { table: table, ownerCol: ownerCol, reason: kind.toUpperCase() + ' on scoped table `' + table + '` must have a WHERE clause that constrains `' + ownerCol + '` to $current_user.' };
451
+ const bindRx = new RegExp('(?:\\b|\\.|")' + __sw_escapeRegex(ownerCol) + '"?\\s*=\\s*\\$current_user\\b', 'i');
452
+ if (!bindRx.test(stripped)) return { table: table, ownerCol: ownerCol, reason: kind.toUpperCase() + ' on scoped table `' + table + '` must constrain `' + ownerCol + '` with: ' + ownerCol + ' = $current_user.' };
453
+ continue;
454
+ }
455
+ return { table: table, ownerCol: ownerCol, reason: 'Statement type not recognized for scope enforcement on `' + table + '`. Use SELECT, INSERT, UPDATE, or DELETE.' };
456
+ }
457
+ return null;
458
+ }
459
+
460
+ // Auto-scope rewriter. Given a raw query on a scoped table,
461
+ // produce the equivalent query with the owner predicate
462
+ // injected (via $current_user — the existing substituter then
463
+ // turns it into the actual user id). Returns null for shapes
464
+ // we can't safely rewrite — JOIN/UNION/subquery; caller should
465
+ // throw with a "use scoped(userId).query() explicitly" message.
466
+ //
467
+ // The rule of thumb: if the stripped SQL has any of these
468
+ // tokens beyond the one-table simple shape, bail. Better to
469
+ // force the caller's hand than to silently scope a query that
470
+ // means something different from what the caller wrote.
471
+ function __sw_autoScopeRewrite(sql, table, ownerCol) {
472
+ const stripped = __sw_stripStringsAndComments(sql);
473
+ // Bail on complex shapes — auto-rewriting these is a footgun.
474
+ if (/\bJOIN\b/i.test(stripped)) return null;
475
+ if (/\bUNION\b/i.test(stripped) || /\bEXCEPT\b/i.test(stripped) || /\bINTERSECT\b/i.test(stripped)) return null;
476
+ // Subquery detector: a "(" followed (after whitespace) by SELECT.
477
+ if (/\(\s*SELECT\b/i.test(stripped)) return null;
478
+ const tableNamesTouched = __sw_extractTables(sql);
479
+ // Set has .size, not .length — earlier code used .length, which
480
+ // is undefined, so this branch ALWAYS returned null and the
481
+ // auto-scope rewrite never fired (deploy-scope-mcp-audit.md
482
+ // §"Auto-Scope Database Implementation"). Every {user}-scoped
483
+ // query was throwing SCOPE_VIOLATION instead of injecting the
484
+ // owner predicate.
485
+ if (tableNamesTouched.size !== 1) return null;
486
+
487
+ const kind = __sw_detectStmt(stripped);
488
+ const ownerQ = '"' + ownerCol + '"';
489
+ const predicate = ownerQ + ' = $current_user';
490
+
491
+ if (kind === 'select' || kind === 'update' || kind === 'delete') {
492
+ // If a WHERE already exists, AND the predicate onto it.
493
+ // Otherwise, append a fresh WHERE clause. Use the ORIGINAL
494
+ // sql (with strings) and place the predicate before any
495
+ // trailing GROUP BY / ORDER BY / LIMIT.
496
+ const whereRx = /\bWHERE\b/i;
497
+ if (whereRx.test(stripped)) {
498
+ // Find the trailing-clause anchor in the stripped sql.
499
+ // String-stripping is a same-length replacement so offsets
500
+ // remain valid against the original SQL.
501
+ const tailMatch = /(\bGROUP\s+BY\b|\bORDER\s+BY\b|\bLIMIT\b|;|$)/i.exec(stripped);
502
+ if (!tailMatch) return null;
503
+ const insertAt = tailMatch.index;
504
+ // Inject the AND-predicate before the tail clauses.
505
+ return sql.slice(0, insertAt).trimEnd() + ' AND ' + predicate + ' ' + sql.slice(insertAt);
506
+ }
507
+ // No WHERE — append one before the tail clauses.
508
+ const tailMatch = /(\bGROUP\s+BY\b|\bORDER\s+BY\b|\bLIMIT\b|;|$)/i.exec(stripped);
509
+ const insertAt = tailMatch ? tailMatch.index : sql.length;
510
+ return sql.slice(0, insertAt).trimEnd() + ' WHERE ' + predicate + ' ' + sql.slice(insertAt);
511
+ }
512
+
513
+ if (kind === 'insert') {
514
+ // Add the owner column to the column list and bind it to
515
+ // $current_user in the VALUES tuple. Single-row INSERT only.
516
+ // Bail if the INSERT has no explicit cols + VALUES shape.
517
+ const m = /INTO\s+("?[A-Za-z_][A-Za-z0-9_]*"?)\s*\(([^)]*)\)\s*VALUES\s*\(([^)]*)\)/i.exec(sql);
518
+ if (!m) return null;
519
+ const colList = m[2];
520
+ const valList = m[3];
521
+ // Already has owner column? Bail — caller did it manually.
522
+ const colsLower = colList.split(',').map(function (c) { return c.trim().replace(/^"|"$/g, '').toLowerCase(); });
523
+ if (colsLower.indexOf(ownerCol.toLowerCase()) !== -1) return null;
524
+ const newCols = colList + ', ' + ownerQ;
525
+ const newVals = valList + ', $current_user';
526
+ const before = sql.slice(0, m.index);
527
+ const after = sql.slice(m.index + m[0].length);
528
+ const newInsert = 'INTO ' + m[1] + ' (' + newCols + ') VALUES (' + newVals + ')';
529
+ return before + newInsert + after;
530
+ }
531
+ return null;
532
+ }
533
+
534
+ function __sw_substCurrentUser(sql, params, userId) {
535
+ let out = '', i = 0;
536
+ const n = sql.length;
537
+ const newParams = [];
538
+ while (i < n) {
539
+ const ch = sql[i];
540
+ if (ch === "'") {
541
+ out += ch; i++;
542
+ while (i < n) {
543
+ if (sql[i] === "'" && sql[i + 1] === "'") { out += "''"; i += 2; continue; }
544
+ out += sql[i];
545
+ if (sql[i] === "'") { i++; break; }
546
+ i++;
547
+ }
548
+ continue;
549
+ }
550
+ if (ch === '"') {
551
+ out += ch; i++;
552
+ while (i < n) {
553
+ if (sql[i] === '"' && sql[i + 1] === '"') { out += '""'; i += 2; continue; }
554
+ out += sql[i];
555
+ if (sql[i] === '"') { i++; break; }
556
+ i++;
557
+ }
558
+ continue;
559
+ }
560
+ if (ch === '-' && sql[i + 1] === '-') {
561
+ while (i < n && sql[i] !== '\n') { out += sql[i]; i++; }
562
+ continue;
563
+ }
564
+ if (ch === '/' && sql[i + 1] === '*') {
565
+ out += '/*'; i += 2;
566
+ while (i < n && !(sql[i] === '*' && sql[i + 1] === '/')) { out += sql[i]; i++; }
567
+ if (i < n) { out += '*/'; i += 2; }
568
+ continue;
569
+ }
570
+ if (ch === '$' && sql.substr(i, 13) === '$current_user' && !/[a-zA-Z0-9_]/.test(sql[i + 13] || '')) {
571
+ out += '?';
572
+ newParams.push(userId);
573
+ i += 13;
574
+ continue;
575
+ }
576
+ out += ch; i++;
577
+ }
578
+ return { sql: out, params: (params || []).concat(newParams) };
579
+ }
580
+
581
+ function __sw_throwViolation(v, hint) {
582
+ const err = new Error(v.reason + (hint ? ' ' + hint : ''));
583
+ err.code = 'SCOPE_VIOLATION';
584
+ err.table = v.table;
585
+ err.owner_column = v.ownerCol;
586
+ throw err;
587
+ }
588
+
589
+ // Normalize Postgres-style $N placeholders to D1's positional ?
590
+ // markers. D1's .bind() walks ? left-to-right; left-untouched $N
591
+ // tokens silently bind in array order, so 'WHERE id = $1 AND a = $2'
592
+ // with ['x', 'A'] would still bind x→? then A→? — fine here, but
593
+ // 'a = $2 WHERE id = $1' would bind 'x' to a and 'A' to id (zero
594
+ // changes). Reject mixed ?/$N as VALIDATION_ERROR.
595
+ function __sw_normalizePlaceholders(sql, params) {
596
+ const inputParams = params || [];
597
+ let out = '';
598
+ let hasQ = false;
599
+ const usedNs = [];
600
+ let i = 0;
601
+ const n = sql.length;
602
+ while (i < n) {
603
+ const ch = sql[i];
604
+ if (ch === "'") {
605
+ out += ch; i++;
606
+ while (i < n) {
607
+ if (sql[i] === "'" && sql[i + 1] === "'") { out += "''"; i += 2; continue; }
608
+ out += sql[i];
609
+ if (sql[i] === "'") { i++; break; }
610
+ i++;
611
+ }
612
+ continue;
613
+ }
614
+ if (ch === '"') {
615
+ out += ch; i++;
616
+ while (i < n) {
617
+ if (sql[i] === '"' && sql[i + 1] === '"') { out += '""'; i += 2; continue; }
618
+ out += sql[i];
619
+ if (sql[i] === '"') { i++; break; }
620
+ i++;
621
+ }
622
+ continue;
623
+ }
624
+ if (ch === '-' && sql[i + 1] === '-') {
625
+ while (i < n && sql[i] !== '\n') { out += sql[i]; i++; }
626
+ continue;
627
+ }
628
+ if (ch === '/' && sql[i + 1] === '*') {
629
+ out += '/*'; i += 2;
630
+ while (i < n && !(sql[i] === '*' && sql[i + 1] === '/')) { out += sql[i]; i++; }
631
+ if (i < n) { out += '*/'; i += 2; }
632
+ continue;
633
+ }
634
+ if (ch === '?') { hasQ = true; out += '?'; i++; continue; }
635
+ if (ch === '$' && sql[i + 1] >= '0' && sql[i + 1] <= '9') {
636
+ let j = i + 1;
637
+ let digits = '';
638
+ while (j < n && sql[j] >= '0' && sql[j] <= '9') { digits += sql[j]; j++; }
639
+ const num = parseInt(digits, 10);
640
+ if (num < 1) {
641
+ const err = new Error('Invalid placeholder $' + digits + ' — placeholder numbers are 1-indexed.');
642
+ err.code = 'VALIDATION_ERROR';
643
+ throw err;
644
+ }
645
+ usedNs.push(num);
646
+ out += '?';
647
+ i = j;
648
+ continue;
649
+ }
650
+ out += ch; i++;
651
+ }
652
+ if (hasQ && usedNs.length > 0) {
653
+ const err = new Error('Mixed placeholder styles in one statement. Use either ? throughout or $N throughout, not both.');
654
+ err.code = 'VALIDATION_ERROR';
655
+ throw err;
656
+ }
657
+ if (usedNs.length === 0) return { sql: out, params: inputParams };
658
+ const maxN = Math.max.apply(null, usedNs);
659
+ if (maxN > inputParams.length) {
660
+ const err = new Error('Placeholder $' + maxN + ' has no matching param — only ' + inputParams.length + ' provided.');
661
+ err.code = 'VALIDATION_ERROR';
662
+ throw err;
663
+ }
664
+ const newParams = usedNs.map((num) => inputParams[num - 1]);
665
+ return { sql: out, params: newParams };
666
+ }
667
+
668
+ // Postgres-flavored SQL → SQLite. Mirrors translateSqlForDialect
669
+ // in worker/src/utils/sql-translate.ts; kept inline because this
670
+ // runs inside the customer's deployed function bundle (no imports
671
+ // available at runtime). Literal- and comment-aware: text inside
672
+ // '...', "...", -- ..., or /* ... */ is preserved verbatim.
673
+ // Dialect is hard-coded to 'sqlite' today (every project is on
674
+ // D1); when a project lands on a Postgres backend the dialect
675
+ // flips at deploy time and the function becomes a pass-through.
676
+ const __sw_DIALECT = 'sqlite';
677
+ function __sw_translateForDialect(sqlIn) {
678
+ if (__sw_DIALECT !== 'sqlite') return sqlIn;
679
+ // Pre-pass: json arrows. The quoted key is syntactically a
680
+ // SQL literal but semantically part of the operator, so the
681
+ // literal-aware walker below would skip it. See sql-translate.ts.
682
+ let sql = sqlIn
683
+ .replace(/(\w+)->>\s*'([^']+)'/g, "json_extract($1, '$.$2')")
684
+ .replace(/(\w+)->\s*'([^']+)'/g, "json_extract($1, '$.$2')");
685
+ const rewrites = [
686
+ [/\bNOW\s*\(\s*\)/gi, "datetime('now')"],
687
+ [/\bTRUE\b/g, '1'],
688
+ [/\bFALSE\b/g, '0'],
689
+ [/\bILIKE\b/gi, 'LIKE'],
690
+ [/\bSERIAL\b/gi, 'INTEGER'],
691
+ [/\bBOOLEAN\b/gi, 'INTEGER'],
692
+ // NOTE: RETURNING * is intentionally NOT rewritten — SQLite/D1
693
+ // support it natively. Rewriting it to RETURNING id silently
694
+ // dropped every other column and broke id-less tables (tsk_7afb8f97).
695
+ ];
696
+ let out = '';
697
+ let code = '';
698
+ const flushCode = () => {
699
+ if (!code) return;
700
+ let s = code;
701
+ for (const [re, rep] of rewrites) s = s.replace(re, rep);
702
+ out += s;
703
+ code = '';
704
+ };
705
+ let i = 0;
706
+ const n = sql.length;
707
+ while (i < n) {
708
+ const ch = sql[i];
709
+ if (ch === "'") {
710
+ flushCode();
711
+ out += ch; i++;
712
+ while (i < n) {
713
+ if (sql[i] === "'" && sql[i + 1] === "'") { out += "''"; i += 2; continue; }
714
+ out += sql[i];
715
+ if (sql[i] === "'") { i++; break; }
716
+ i++;
717
+ }
718
+ continue;
719
+ }
720
+ if (ch === '"') {
721
+ flushCode();
722
+ out += ch; i++;
723
+ while (i < n) {
724
+ if (sql[i] === '"' && sql[i + 1] === '"') { out += '""'; i += 2; continue; }
725
+ out += sql[i];
726
+ if (sql[i] === '"') { i++; break; }
727
+ i++;
728
+ }
729
+ continue;
730
+ }
731
+ if (ch === '-' && sql[i + 1] === '-') {
732
+ flushCode();
733
+ while (i < n && sql[i] !== '\n') { out += sql[i]; i++; }
734
+ continue;
735
+ }
736
+ if (ch === '/' && sql[i + 1] === '*') {
737
+ flushCode();
738
+ out += '/*'; i += 2;
739
+ while (i < n && !(sql[i] === '*' && sql[i + 1] === '/')) { out += sql[i]; i++; }
740
+ if (i < n) { out += '*/'; i += 2; }
741
+ continue;
742
+ }
743
+ code += ch; i++;
744
+ }
745
+ flushCode();
746
+ return out;
747
+ }
748
+
749
+ function prep(sql, params) {
750
+ const normalized = __sw_normalizePlaceholders(sql, params);
751
+ const translated = __sw_translateForDialect(normalized.sql);
752
+ let stmt = DB.prepare(translated);
753
+ if (normalized.params && normalized.params.length) stmt = stmt.bind(...normalized.params);
754
+ return stmt;
755
+ }
756
+
757
+ // Slow-query alerting (tsk_4d3e9e item 2) + schema-drift hint
758
+ // (tsk_4d3e9e item 7). Wraps any sw.db execution: console.warn
759
+ // on slow queries (>SW_SLOW_QUERY_MS), and on errors matching
760
+ // "no such column" / "no such table" rewrites the message to
761
+ // tell the developer their schema is out of date — a real
762
+ // missing-column error otherwise blames the deployed code when
763
+ // the actual fix is db_migrate. Logs and dashboard Logs tab
764
+ // surface both tags ([SW_SLOW_QUERY] / [SW_SCHEMA_DRIFT]).
765
+ const SW_SLOW_QUERY_MS = 100;
766
+ function __sw_decorateError(label, sqlPreview, err) {
767
+ const msg = (err && err.message) || String(err);
768
+ const m = msg.match(/no such (?:column|table)[^A-Za-z0-9_]*([A-Za-z0-9_.]*)/i);
769
+ if (m) {
770
+ const ident = m[1] || 'unknown';
771
+ const snippet = String(sqlPreview).replace(/\s+/g, ' ').slice(0, 200);
772
+ console.warn('[SW_SCHEMA_DRIFT] ' + label + ' — ' + ident + ' missing — ' + snippet);
773
+ const better = new Error('Schema drift: ' + msg + '. Your code references "' + ident + '" but the database schema does not. Did you forget to run db_migrate before deploying?');
774
+ better.code = 'SCHEMA_DRIFT';
775
+ better.original = err;
776
+ return better;
777
+ }
778
+ return err;
779
+ }
780
+ // Retry-on-backpressure (founder 2026-06-08): a write that gets
781
+ // "D1 overloaded / queued for too long" or SQLITE_BUSY did NOT commit
782
+ // (rejected before it ran), so retrying is safe — no double-write.
783
+ // Only CLEAR backpressure signals are retried (never a generic error,
784
+ // so a post-commit network blip can't trigger a double-write). Reads
785
+ // are idempotent, also safe. Bounded attempts + backoff: absorbs
786
+ // transient spikes; sustained overload still surfaces after ~1.5s
787
+ // (correct backpressure, not an infinite hang).
788
+ function __sw_isRetryable(err) {
789
+ const m = String((err && err.message) || err || '').toLowerCase();
790
+ return m.indexOf('overload') !== -1 ||
791
+ m.indexOf('queued for too long') !== -1 ||
792
+ m.indexOf('sqlite_busy') !== -1 ||
793
+ m.indexOf('database is locked') !== -1 ||
794
+ (m.indexOf('busy') !== -1 && m.indexOf('database') !== -1);
795
+ }
796
+ // Patience budget ~10s total: gentle early (transient spikes clear in
797
+ // ms) and progressively patient (absorb sustained overload by WAITING,
798
+ // not erroring — Postgres-like graceful degradation, bounded by the
799
+ // request window so we never hold a connection indefinitely).
800
+ const __SW_RETRY_DELAYS = [50, 150, 400, 900, 2000, 3000, 3500];
801
+ async function __sw_timedExec(label, sqlPreview, runner) {
802
+ const start = Date.now();
803
+ let attempt = 0;
804
+ for (;;) {
805
+ try {
806
+ const out = await runner();
807
+ const ms = Date.now() - start;
808
+ if (ms >= SW_SLOW_QUERY_MS) {
809
+ const snippet = String(sqlPreview).replace(/\s+/g, ' ').slice(0, 200);
810
+ console.warn('[SW_SLOW_QUERY] ' + label + ' took ' + ms + 'ms — ' + snippet);
811
+ }
812
+ return out;
813
+ } catch (err) {
814
+ if (attempt < __SW_RETRY_DELAYS.length && __sw_isRetryable(err)) {
815
+ console.warn('[SW_DB_RETRY] ' + label + ' backpressure — retry ' + (attempt + 1) + ' after ' + __SW_RETRY_DELAYS[attempt] + 'ms');
816
+ await new Promise(function (r) { setTimeout(r, __SW_RETRY_DELAYS[attempt]); });
817
+ attempt++;
818
+ continue;
819
+ }
820
+ throw __sw_decorateError(label, sqlPreview, err);
821
+ }
822
+ }
823
+ }
824
+
825
+ // tsk_5523b9 / tsk_2bf7d327: auto-publish a realtime event after a
826
+ // successful sw.db mutation so clients can subscribe to db:<table>
827
+ // for live updates without polling. Fire-and-forget — wrapped in
828
+ // catch so a realtime hiccup never kills the user's write.
829
+ // Channel name format: 'db:' + lowercased table name. Event
830
+ // payload: { event, table, timestamp, row, rows, row_count,
831
+ // truncated }. `rows` carries the changed row(s) the statement
832
+ // returned (new row on insert/update, old row on delete) so a
833
+ // subscriber doesn't have to refetch on every change; `row` is a
834
+ // convenience alias for rows[0]. It's empty when the statement
835
+ // returned nothing — e.g. a raw INSERT/UPDATE/DELETE without
836
+ // RETURNING — in which case the event still fires (op + table)
837
+ // exactly as before. Payload is bounded by __sw_boundRows so a
838
+ // bulk write or a fat TEXT column can't blow up the message.
839
+ function __sw_boundRows(rows) {
840
+ const MAX_ROWS = 25;
841
+ const MAX_FIELD_CHARS = 1024;
842
+ const MAX_TOTAL_CHARS = 32 * 1024;
843
+ if (!Array.isArray(rows) || rows.length === 0) return { rows: [], truncated: false };
844
+ let truncated = rows.length > MAX_ROWS;
845
+ const out = [];
846
+ for (const row of rows.slice(0, MAX_ROWS)) {
847
+ if (row === null || typeof row !== 'object') { out.push(row); continue; }
848
+ const clean = {};
849
+ for (const k of Object.keys(row)) {
850
+ const v = row[k];
851
+ if (typeof v === 'string' && v.length > MAX_FIELD_CHARS) {
852
+ clean[k] = v.slice(0, MAX_FIELD_CHARS) + '...[+' + (v.length - MAX_FIELD_CHARS) + ' chars]';
853
+ truncated = true;
854
+ } else {
855
+ clean[k] = v;
856
+ }
857
+ }
858
+ out.push(clean);
859
+ }
860
+ // Backstop: if the bounded rows still serialize too large (many
861
+ // columns, big numeric arrays), drop trailing rows until under cap.
862
+ while (out.length > 0) {
863
+ let size;
864
+ try { size = JSON.stringify(out).length; } catch (_) { size = MAX_TOTAL_CHARS + 1; }
865
+ if (size <= MAX_TOTAL_CHARS) break;
866
+ out.pop();
867
+ truncated = true;
868
+ }
869
+ return { rows: out, truncated: truncated };
870
+ }
871
+ function __sw_publishDbMutation(table, op, rows) {
872
+ if (!table || (op !== 'insert' && op !== 'update' && op !== 'delete')) return;
873
+ const channel = 'db:' + String(table).toLowerCase();
874
+ const bounded = __sw_boundRows(rows);
875
+ // No await — publish in the background so the response
876
+ // returns immediately.
877
+ platformFetch('/v1/realtime/publish', {
878
+ method: 'POST',
879
+ headers: { 'Content-Type': 'application/json' },
880
+ body: JSON.stringify({
881
+ project_id: projectId,
882
+ channel: channel,
883
+ event: op,
884
+ data: {
885
+ event: op,
886
+ table: String(table).toLowerCase(),
887
+ timestamp: new Date().toISOString(),
888
+ row: bounded.rows.length ? bounded.rows[0] : null,
889
+ rows: bounded.rows,
890
+ row_count: bounded.rows.length,
891
+ truncated: bounded.truncated,
892
+ },
893
+ from: 'sw.db',
894
+ }),
895
+ }).catch(function () { /* silent — never block the write */ });
896
+ }
897
+
898
+ // Detect the (op, table) from a SQL statement. Returns null
899
+ // for SELECT or anything we can't safely parse. Reuses the
900
+ // existing strip / detect / extract helpers. For multi-table
901
+ // mutations (rare in app code, e.g. UPDATE ... FROM joins)
902
+ // returns the FIRST touched table — good enough for the v1
903
+ // event hook; subscribers can dedupe.
904
+ function __sw_mutationOf(sql) {
905
+ try {
906
+ var stripped = __sw_stripStringsAndComments(sql);
907
+ var kind = __sw_detectStmt(stripped);
908
+ if (kind !== 'insert' && kind !== 'update' && kind !== 'delete') return null;
909
+ var tables = __sw_extractTables(sql);
910
+ for (var t of tables) return { op: kind, table: t };
911
+ return null;
912
+ } catch (_) { return null; }
913
+ }
914
+
915
+ function scopedClient(userId) {
916
+ if (typeof userId !== 'string' || !userId) {
917
+ const err = new Error('sw.db.scoped(userId) requires a non-empty userId string (the JWT subject from sw.auth.fromRequest).');
918
+ err.code = 'VALIDATION_ERROR';
919
+ throw err;
920
+ }
921
+
922
+ function fromTable(table) {
923
+ const tableLc = String(table).toLowerCase();
924
+ const ownerCol = SCOPES[tableLc];
925
+ if (!ownerCol) {
926
+ const err = new Error('sw.db.scoped(...).from("' + table + '") — table is not declared as user-scoped. Declare it via POST /v1/db/scopes with { project_id, table, owner_column }, then redeploy.');
927
+ err.code = 'SCOPE_NOT_DECLARED';
928
+ throw err;
929
+ }
930
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) {
931
+ const err = new Error('Invalid table name: ' + table);
932
+ err.code = 'VALIDATION_ERROR';
933
+ throw err;
934
+ }
935
+ const tableQ = '"' + table + '"';
936
+ const ownerQ = '"' + ownerCol + '"';
937
+
938
+ function whereFromFilter(filter) {
939
+ const keys = filter ? Object.keys(filter) : [];
940
+ const conds = [];
941
+ const params = [];
942
+ for (const k of keys) {
943
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(k)) {
944
+ const err = new Error('Invalid filter column: ' + k);
945
+ err.code = 'VALIDATION_ERROR';
946
+ throw err;
947
+ }
948
+ conds.push('"' + k + '" = ?');
949
+ params.push(filter[k]);
950
+ }
951
+ conds.push(ownerQ + ' = ?');
952
+ params.push(userId);
953
+ return { where: ' WHERE ' + conds.join(' AND '), params: params };
954
+ }
955
+
956
+ return {
957
+ async list(opts) {
958
+ ensureBinding();
959
+ opts = opts || {};
960
+ let sql = 'SELECT * FROM ' + tableQ + ' WHERE ' + ownerQ + ' = ?';
961
+ const params = [userId];
962
+ if (opts.orderBy) {
963
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(opts.orderBy)) {
964
+ const err = new Error('Invalid orderBy column: ' + opts.orderBy);
965
+ err.code = 'VALIDATION_ERROR';
966
+ throw err;
967
+ }
968
+ sql += ' ORDER BY "' + opts.orderBy + '"' + (opts.descending ? ' DESC' : ' ASC');
969
+ }
970
+ if (typeof opts.limit === 'number') sql += ' LIMIT ' + Math.max(1, Math.floor(opts.limit));
971
+ if (typeof opts.offset === 'number') sql += ' OFFSET ' + Math.max(0, Math.floor(opts.offset));
972
+ const r = await DB.prepare(sql).bind(...params).all();
973
+ return r.results || [];
974
+ },
975
+ async get(id) {
976
+ ensureBinding();
977
+ const r = await DB.prepare('SELECT * FROM ' + tableQ + ' WHERE id = ? AND ' + ownerQ + ' = ?')
978
+ .bind(id, userId).all();
979
+ return (r.results || [])[0] || null;
980
+ },
981
+ async insert(rows) {
982
+ ensureBinding();
983
+ const list = Array.isArray(rows) ? rows : [rows];
984
+ if (list.length === 0) return [];
985
+ const enriched = list.map(function (r) { const copy = Object.assign({}, r); copy[ownerCol] = userId; return copy; });
986
+ const cols = Object.keys(enriched[0]);
987
+ for (const c of cols) {
988
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(c)) {
989
+ const err = new Error('Invalid column name: ' + c);
990
+ err.code = 'VALIDATION_ERROR';
991
+ throw err;
992
+ }
993
+ }
994
+ const colSql = cols.map(function (c) { return '"' + c + '"'; }).join(', ');
995
+ const placeholders = '(' + cols.map(function () { return '?'; }).join(', ') + ')';
996
+ const valuesClause = enriched.map(function () { return placeholders; }).join(', ');
997
+ const sql = 'INSERT INTO ' + tableQ + ' (' + colSql + ') VALUES ' + valuesClause + ' RETURNING *';
998
+ const params = [];
999
+ for (const r of enriched) for (const c of cols) params.push(r[c]);
1000
+ const res = await DB.prepare(sql).bind(...params).all();
1001
+ const inserted = res.results || [];
1002
+ // tsk_5523b9: known table name — no parse needed.
1003
+ // tsk_2bf7d327: include the inserted row(s) (RETURNING *).
1004
+ __sw_publishDbMutation(table, 'insert', inserted);
1005
+ return inserted;
1006
+ },
1007
+ async update(filter, patch) {
1008
+ ensureBinding();
1009
+ const cols = Object.keys(patch || {});
1010
+ if (cols.length === 0) {
1011
+ const err = new Error('update(filter, patch) requires a non-empty patch object.');
1012
+ err.code = 'VALIDATION_ERROR';
1013
+ throw err;
1014
+ }
1015
+ for (const c of cols) {
1016
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(c)) {
1017
+ const err = new Error('Invalid column name in patch: ' + c);
1018
+ err.code = 'VALIDATION_ERROR';
1019
+ throw err;
1020
+ }
1021
+ if (c.toLowerCase() === ownerCol.toLowerCase()) {
1022
+ const err = new Error('Cannot reassign the owner column `' + ownerCol + '` via .update().');
1023
+ err.code = 'SCOPE_VIOLATION';
1024
+ throw err;
1025
+ }
1026
+ }
1027
+ const setClause = cols.map(function (c) { return '"' + c + '" = ?'; }).join(', ');
1028
+ const setParams = cols.map(function (c) { return patch[c]; });
1029
+ const w = whereFromFilter(filter);
1030
+ // tsk_2bf7d327: RETURNING * so the change event can carry the
1031
+ // new row(s). Return shape stays { changes } — derive it from
1032
+ // meta.changes, falling back to the returned-row count
1033
+ // (RETURNING yields one row per updated row).
1034
+ const sql = 'UPDATE ' + tableQ + ' SET ' + setClause + w.where + ' RETURNING *';
1035
+ const r = await DB.prepare(sql).bind(...setParams.concat(w.params)).all();
1036
+ const updated = r.results || [];
1037
+ __sw_publishDbMutation(table, 'update', updated);
1038
+ return { changes: (r.meta && typeof r.meta.changes === 'number') ? r.meta.changes : updated.length };
1039
+ },
1040
+ async delete(filter) {
1041
+ ensureBinding();
1042
+ const w = whereFromFilter(filter);
1043
+ // tsk_2bf7d327: RETURNING * so the change event can carry the
1044
+ // deleted (old) row(s). Return shape stays { changes }.
1045
+ const sql = 'DELETE FROM ' + tableQ + w.where + ' RETURNING *';
1046
+ const r = await DB.prepare(sql).bind(...w.params).all();
1047
+ const deleted = r.results || [];
1048
+ __sw_publishDbMutation(table, 'delete', deleted);
1049
+ return { changes: (r.meta && typeof r.meta.changes === 'number') ? r.meta.changes : deleted.length };
1050
+ },
1051
+ };
1052
+ }
1053
+
1054
+ return {
1055
+ async query(sql, params) {
1056
+ ensureBinding();
1057
+ __sw_assertNoDdl(sql);
1058
+ const subst = __sw_substCurrentUser(sql, params, userId);
1059
+ const r = await prep(subst.sql, subst.params).all();
1060
+ const rows = r.results || [];
1061
+ // tsk_5523b9: auto-publish a realtime event for mutations.
1062
+ // tsk_2bf7d327: include any returned row(s) (when RETURNING).
1063
+ const mut = __sw_mutationOf(sql);
1064
+ if (mut) __sw_publishDbMutation(mut.table, mut.op, rows);
1065
+ return {
1066
+ data: rows,
1067
+ error: null,
1068
+ count: rows.length,
1069
+ last_row_id: (r.meta && r.meta.last_row_id) || null,
1070
+ changes: (r.meta && r.meta.changes) || 0,
1071
+ };
1072
+ },
1073
+ async batch(statements) {
1074
+ ensureBinding();
1075
+ if (!Array.isArray(statements) || statements.length === 0) {
1076
+ const err = new Error('sw.db.scoped(...).batch requires a non-empty array of { sql, params }.');
1077
+ err.code = 'VALIDATION_ERROR';
1078
+ throw err;
1079
+ }
1080
+ for (const s of statements) __sw_assertNoDdl(s.sql);
1081
+ const prepared = statements.map(function (s) {
1082
+ const subst = __sw_substCurrentUser(s.sql, s.params, userId);
1083
+ return prep(subst.sql, subst.params);
1084
+ });
1085
+ const results = await __sw_timedExec('sw.db.batch', statements.map(function(s){return s.sql;}).join(' ; ').slice(0, 200), function () { return DB.batch(prepared); });
1086
+ // tsk_5523b9: one realtime publish per mutating statement.
1087
+ // tsk_2bf7d327: include that statement's returned row(s),
1088
+ // index-aligned with the batch result set.
1089
+ for (let i = 0; i < statements.length; i++) {
1090
+ const mut = __sw_mutationOf(statements[i].sql);
1091
+ if (mut) __sw_publishDbMutation(mut.table, mut.op, (results[i] && results[i].results) || []);
1092
+ }
1093
+ return results.map(function (r) {
1094
+ return {
1095
+ data: r.results || [],
1096
+ changes: (r.meta && r.meta.changes) || 0,
1097
+ last_row_id: (r.meta && r.meta.last_row_id) || null,
1098
+ };
1099
+ });
1100
+ },
1101
+ from: fromTable,
1102
+ user_id: userId,
1103
+ };
1104
+ }
1105
+
1106
+ // Resolve the user id from a query option in any of the shapes
1107
+ // developers actually pass — a string user id, a user object
1108
+ // with .id (from sw.auth.fromRequest), or null/missing.
1109
+ // Centralized so { user } means the same thing across query()
1110
+ // and batch().
1111
+ function __sw_resolveUserId(opt) {
1112
+ if (opt == null) return null;
1113
+ if (typeof opt === 'string') return opt;
1114
+ if (typeof opt === 'object' && typeof opt.id === 'string') return opt.id;
1115
+ return null;
1116
+ }
1117
+
1118
+ // Reject DDL statements from inside the function runtime
1119
+ // (tsk_a9f1fee70, 2026-05-21). A request handler should never
1120
+ // ALTER / DROP / CREATE the schema — those operations belong to
1121
+ // operator tooling (CLI, MCP db_migrate, dashboard). Without
1122
+ // this guard, a buggy handler that takes SQL from the request
1123
+ // body (or just hard-codes a destructive query) could destroy
1124
+ // the database, and removing sw.db.migrate alone wouldn't help
1125
+ // because sw.db.query / batch also speak raw SQL to the same
1126
+ // D1 binding. Match the FIRST keyword after stripping leading
1127
+ // whitespace + line comments — captures the common shapes
1128
+ // without false-positive-ing on data queries that happen to
1129
+ // contain "DROP" or "CREATE" in a string literal further in.
1130
+ function __sw_assertNoDdl(sql) {
1131
+ // NOTE: this source lives inside the PLATFORM_CONTEXT_JS
1132
+ // template literal. Every backslash in a regex literal must
1133
+ // be doubled here, otherwise the emitted bundle collapses
1134
+ // each escape (e.g. backslash-s becomes plain s) and the
1135
+ // resulting regex stops matching whitespace + word boundary.
1136
+ // Avoid backticks in this comment block — they would close
1137
+ // the outer template literal.
1138
+ const cleaned = String(sql).replace(/^(?:\s+|--[^\n]*\n?)+/g, '').toUpperCase();
1139
+ if (/^(?:ALTER|DROP|CREATE|RENAME|TRUNCATE|PRAGMA|VACUUM|REINDEX|ATTACH|DETACH)\b/.test(cleaned)) {
1140
+ const op = (cleaned.match(/^[A-Z]+/) || ['DDL'])[0];
1141
+ const err = new Error(
1142
+ 'sw.db: ' + op + ' statements are not allowed from the function runtime ' +
1143
+ '(removed 2026-05-21). Schema changes must run with developer credentials. ' +
1144
+ 'Use the CLI somewhere fetch /v1/db/migrate, the db_migrate MCP tool, or the ' +
1145
+ 'dashboard Database tab. Your app code should treat the schema as fixed at ' +
1146
+ 'runtime; data writes (INSERT, UPDATE, DELETE) are fine.'
1147
+ );
1148
+ err.code = 'DDL_NOT_ALLOWED_IN_FUNCTION';
1149
+ throw err;
1150
+ }
1151
+ }
1152
+
1153
+ return {
1154
+ async query(sql, params, options) {
1155
+ ensureBinding();
1156
+ __sw_assertNoDdl(sql);
1157
+ options = options || {};
1158
+ // Opt-out: { unscoped: true } bypasses the safety net.
1159
+ // Document the reason — analytics, admin endpoints, public
1160
+ // reads. The security advisor flips from "use scoped" to
1161
+ // "you're using unscoped, are you sure?" on this branch.
1162
+ const unscoped = options.unscoped === true;
1163
+ // Opt-in: { user } bound at the call site (e.g. from
1164
+ // sw.auth.fromRequest(req).user). Auto-rewrites a raw
1165
+ // query into the equivalent scoped query.
1166
+ const userId = __sw_resolveUserId(options.user);
1167
+ if (!unscoped) {
1168
+ const v = __sw_checkScope(sql);
1169
+ if (v) {
1170
+ if (userId) {
1171
+ // Auto-rewrite: substitute $current_user → user id and
1172
+ // route through the scoped-client query path so the
1173
+ // existing checker re-validates the rewritten SQL.
1174
+ const rewritten = __sw_autoScopeRewrite(sql, v.table, v.ownerCol);
1175
+ if (rewritten) {
1176
+ const subst = __sw_substCurrentUser(rewritten, params, userId);
1177
+ const rr = await prep(subst.sql, subst.params).all();
1178
+ const rrRows = rr.results || [];
1179
+ // Realtime publish — same hook as the unscoped path
1180
+ // below. The auto-rewritten SQL targets the same
1181
+ // table, so the channel is identical.
1182
+ // tsk_2bf7d327: include any returned row(s).
1183
+ const mut = __sw_mutationOf(rewritten);
1184
+ if (mut) __sw_publishDbMutation(mut.table, mut.op, rrRows);
1185
+ return {
1186
+ data: rrRows,
1187
+ error: null,
1188
+ count: rrRows.length,
1189
+ last_row_id: (rr.meta && rr.meta.last_row_id) || null,
1190
+ changes: (rr.meta && rr.meta.changes) || 0,
1191
+ };
1192
+ }
1193
+ __sw_throwViolation(v,
1194
+ 'Pass { user } and we will scope your query automatically, but only for single-table SELECT/UPDATE/DELETE/INSERT. This query has a JOIN, UNION, or subquery — use sw.db.scoped(user.id) to write the scoped form explicitly, or { unscoped: true } if cross-user is intentional.');
1195
+ }
1196
+ __sw_throwViolation(v,
1197
+ 'Pass { user } as the third arg — sw.db.query(sql, params, { user: sw.auth.fromRequest(req).user }) — or { unscoped: true } if cross-user is intentional. The verbose form sw.db.scoped(user.id).query(...) still works.');
1198
+ }
1199
+ }
1200
+ const r = await __sw_timedExec('sw.db.query', sql, function () { return prep(sql, params).all(); });
1201
+ const rows = r.results || [];
1202
+ // tsk_5523b9: auto-publish a realtime event for mutations.
1203
+ // tsk_2bf7d327: include any returned row(s) (when RETURNING).
1204
+ const mut = __sw_mutationOf(sql);
1205
+ if (mut) __sw_publishDbMutation(mut.table, mut.op, rows);
1206
+ // Writes return meta.last_row_id (INSERT rowid) and meta.changes
1207
+ // (rows touched by INSERT/UPDATE/DELETE). Surface both so callers
1208
+ // can grab the auto-increment id without a follow-up SELECT.
1209
+ return {
1210
+ data: rows,
1211
+ error: null,
1212
+ count: rows.length,
1213
+ last_row_id: (r.meta && r.meta.last_row_id) || null,
1214
+ changes: (r.meta && r.meta.changes) || 0,
1215
+ };
1216
+ },
1217
+ async batch(statements, options) {
1218
+ ensureBinding();
1219
+ if (!Array.isArray(statements) || statements.length === 0) {
1220
+ const err = new Error('sw.db.batch requires a non-empty array of { sql, params }.');
1221
+ err.code = 'VALIDATION_ERROR';
1222
+ throw err;
1223
+ }
1224
+ for (const s of statements) __sw_assertNoDdl(s.sql);
1225
+ options = options || {};
1226
+ const unscoped = options.unscoped === true;
1227
+ const userId = __sw_resolveUserId(options.user);
1228
+ if (!unscoped) {
1229
+ for (const s of statements) {
1230
+ const v = __sw_checkScope(s.sql);
1231
+ if (v && !userId) {
1232
+ __sw_throwViolation(v,
1233
+ 'Pass { user } as the second arg to sw.db.batch, or { unscoped: true } if cross-user is intentional. The verbose form sw.db.scoped(user.id).batch(...) still works.');
1234
+ }
1235
+ }
1236
+ }
1237
+ const prepared = statements.map((s) => {
1238
+ if (userId && !unscoped) {
1239
+ const v = __sw_checkScope(s.sql);
1240
+ if (v) {
1241
+ const rewritten = __sw_autoScopeRewrite(s.sql, v.table, v.ownerCol);
1242
+ if (rewritten) {
1243
+ const subst = __sw_substCurrentUser(rewritten, s.params, userId);
1244
+ return prep(subst.sql, subst.params);
1245
+ }
1246
+ __sw_throwViolation(v,
1247
+ 'Auto-scope only handles single-table SELECT/UPDATE/DELETE/INSERT. Use sw.db.scoped(user.id).batch(...) for JOIN/UNION/subquery shapes.');
1248
+ }
1249
+ }
1250
+ return prep(s.sql, s.params);
1251
+ });
1252
+ const results = await __sw_timedExec('sw.db.batch', statements.map(function(s){return s.sql;}).join(' ; ').slice(0, 200), function () { return DB.batch(prepared); });
1253
+ // tsk_5523b9: one realtime publish per mutating statement.
1254
+ // Batches are atomic at the DB layer so it's safe to fire
1255
+ // the events after the batch resolves — by definition every
1256
+ // statement either committed or none did.
1257
+ // tsk_2bf7d327: include that statement's returned row(s),
1258
+ // index-aligned with the batch result set.
1259
+ for (let i = 0; i < statements.length; i++) {
1260
+ const mut = __sw_mutationOf(statements[i].sql);
1261
+ if (mut) __sw_publishDbMutation(mut.table, mut.op, (results[i] && results[i].results) || []);
1262
+ }
1263
+ return results.map((r) => ({
1264
+ data: r.results || [],
1265
+ changes: (r.meta && r.meta.changes) || 0,
1266
+ last_row_id: (r.meta && r.meta.last_row_id) || null,
1267
+ }));
1268
+ },
1269
+ async migrate(_sql) {
1270
+ // sw.db.migrate was removed from the function runtime
1271
+ // 2026-05-21 (tsk_a9f1fee70). Schema changes must run with
1272
+ // developer credentials so a public handler can't ALTER /
1273
+ // DROP at request time. Same shape of error as the DDL
1274
+ // guard so call sites get a consistent recovery message.
1275
+ const err = new Error(
1276
+ 'sw.db.migrate was removed from the function runtime (2026-05-21). ' +
1277
+ 'Run schema changes with developer credentials: use the CLI ' +
1278
+ 'somewhere fetch /v1/db/migrate, the db_migrate MCP tool, or the ' +
1279
+ 'dashboard Database tab.'
1280
+ );
1281
+ err.code = 'DDL_NOT_ALLOWED_IN_FUNCTION';
1282
+ throw err;
1283
+ },
1284
+ async tables() {
1285
+ ensureBinding();
1286
+ // Hide platform-reserved tables: leading-underscore (fts index,
1287
+ // cf internal) and sqlite_ system tables.
1288
+ const r = await DB.prepare(
1289
+ "SELECT name FROM sqlite_master WHERE type='table' AND substr(name, 1, 1) != '_' AND name NOT LIKE 'sqlite_%'"
1290
+ ).all();
1291
+ return (r.results || []).map((row) => row.name);
1292
+ },
1293
+ scoped: scopedClient,
1294
+ async dump() {
1295
+ // sw.db.dump is developer-only and is NOT callable from a
1296
+ // deployed function. A full-database export must run with
1297
+ // developer credentials — a public request handler must not be
1298
+ // able to siphon the entire database at request time. Same
1299
+ // immediate-throw shape as sw.db.migrate (tsk_a9f1fee70): the
1300
+ // dev-only db-dump route only ever answered the runtime key
1301
+ // with an opaque 403, so short-circuit with a clear, actionable
1302
+ // error instead of a confusing round-trip (tsk_ccaadf9dd832).
1303
+ const err = new Error(
1304
+ 'sw.db.dump is not available inside a deployed function — ' +
1305
+ 'exporting the whole database is a developer-only operation. ' +
1306
+ 'Run it with developer credentials instead: the db_dump tool ' +
1307
+ 'from your coding agent, or the Database tab in the dashboard.'
1308
+ );
1309
+ err.code = 'DEV_ONLY_IN_FUNCTION';
1310
+ throw err;
1311
+ },
1312
+ onchange: (function () {
1313
+ // sw.db.onchange manages a database-change webhook — developer-
1314
+ // only, NOT callable from a deployed function. A public request
1315
+ // handler must not be able to point change notifications at an
1316
+ // arbitrary URL at request time. Like sw.db.dump / sw.db.migrate,
1317
+ // the dev-only db-webhook route only ever returned the runtime
1318
+ // key an opaque 403, so each method throws a clear, actionable
1319
+ // error instead (tsk_ccaadf9dd832).
1320
+ function deny() {
1321
+ const err = new Error(
1322
+ 'sw.db.onchange is not available inside a deployed function — ' +
1323
+ 'managing a database-change webhook is a developer-only ' +
1324
+ 'operation. Set it up with developer credentials instead: the ' +
1325
+ 'db_webhook_set / db_webhook_get / db_webhook_delete tools ' +
1326
+ 'from your coding agent, or the Database tab in the dashboard.'
1327
+ );
1328
+ err.code = 'DEV_ONLY_IN_FUNCTION';
1329
+ return err;
1330
+ }
1331
+ return {
1332
+ async set(_url, _opts) { throw deny(); },
1333
+ async get() { throw deny(); },
1334
+ async delete() { throw deny(); },
1335
+ };
1336
+ })(),
1337
+ };
1338
+ })(),
1339
+
1340
+ fs: {
1341
+ async read(path, opts) {
1342
+ path = __sw_fsPath(path);
1343
+ opts = opts || {};
1344
+ // { user } scopes the read to one end-user: the platform enforces the
1345
+ // file's per-end-user ACL (owner_subject_*). Pass the app_user id so a
1346
+ // file owned by another user is refused.
1347
+ const asUser = opts.user || opts.as_user;
1348
+ const userQ = asUser ? 'as_user=' + encodeURIComponent(asUser) : '';
1349
+ // Line-range mode: return the parsed JSON envelope so user code
1350
+ // gets { content, lines, total_lines } directly. No lines option
1351
+ // preserves the legacy behavior (raw Response).
1352
+ if (opts.lines) {
1353
+ const range = Array.isArray(opts.lines)
1354
+ ? opts.lines[0] + '-' + opts.lines[1]
1355
+ : String(opts.lines);
1356
+ const q = 'lines=' + encodeURIComponent(range) + (userQ ? '&' + userQ : '');
1357
+ return platformJSON('/v1/fs/' + projectId + path + '?' + q);
1358
+ }
1359
+ const r = await platformFetch('/v1/fs/' + projectId + path + (userQ ? '?' + userQ : ''));
1360
+ return r;
1361
+ },
1362
+ async write(path, body, opts) {
1363
+ path = __sw_fsPath(path);
1364
+ opts = opts || {};
1365
+ // Accept both camelCase and snake_case so docs that say
1366
+ // content_type and SDKs that pass contentType both work. Without
1367
+ // this, snake_case was silently dropped — the file would land
1368
+ // with application/octet-stream and browsers refused to play it.
1369
+ const ct = opts.contentType || opts.content_type || 'application/octet-stream';
1370
+ // Honor visibility:'public' (or public:true). The PUT route decides
1371
+ // visibility solely from the X-Visibility header; without it every
1372
+ // function write landed PRIVATE regardless of the option (the MCP
1373
+ // fs_write path worked, the in-function shim silently dropped it).
1374
+ const headers = { 'Content-Type': ct };
1375
+ if (opts.visibility === 'public' || opts.public === true) headers['X-Visibility'] = 'public';
1376
+ const r = await platformFetch('/v1/fs/' + projectId + path, {
1377
+ method: 'PUT',
1378
+ headers,
1379
+ body,
1380
+ });
1381
+ if (!r.ok) throw new Error('fs.write failed: ' + r.status);
1382
+ return r.json();
1383
+ },
1384
+ delete(path) {
1385
+ return platformFetch('/v1/fs/' + projectId + __sw_fsPath(path), { method: 'DELETE' }).then(r => r.json());
1386
+ },
1387
+ move(from, to, opts) {
1388
+ const body = { from: __sw_fsPath(from), to: __sw_fsPath(to) };
1389
+ if (opts && opts.overwrite === true) body.overwrite = true;
1390
+ return platformJSON('/v1/fs/' + projectId + '/move', {
1391
+ method: 'POST',
1392
+ body: JSON.stringify(body),
1393
+ });
1394
+ },
1395
+ copy(from, to) {
1396
+ return platformJSON('/v1/fs/' + projectId + '/copy', {
1397
+ method: 'POST',
1398
+ body: JSON.stringify({ from: __sw_fsPath(from), to: __sw_fsPath(to) }),
1399
+ });
1400
+ },
1401
+ restore(path, version) {
1402
+ return platformJSON('/v1/fs/' + projectId + '/restore', {
1403
+ method: 'POST',
1404
+ body: JSON.stringify({ path: __sw_fsPath(path), version }),
1405
+ });
1406
+ },
1407
+ stat(path) {
1408
+ return platformJSON('/v1/fs/' + projectId + '/stat' + __sw_fsPath(path));
1409
+ },
1410
+ versions(path) {
1411
+ return platformJSON('/v1/fs/' + projectId + '/versions' + __sw_fsPath(path));
1412
+ },
1413
+ list(path, opts) {
1414
+ opts = opts || {};
1415
+ const query = [];
1416
+ if (opts.recursive) query.push('recursive=1');
1417
+ if (opts.depth) query.push('depth=' + Number(opts.depth));
1418
+ const qs = query.length ? '?' + query.join('&') : '';
1419
+ if (path) path = __sw_fsPath(path);
1420
+ const dirPath = path && path !== '/' && !path.endsWith('/') ? path + '/' : (path || '/');
1421
+ return platformJSON('/v1/fs/' + projectId + dirPath + qs);
1422
+ },
1423
+ diff(path, opts) {
1424
+ opts = opts || {};
1425
+ const body = { path: __sw_fsPath(path) };
1426
+ if (typeof opts.version === 'number') body.version = opts.version;
1427
+ return platformJSON('/v1/fs/' + projectId + '/diff', {
1428
+ method: 'POST',
1429
+ body: JSON.stringify(body),
1430
+ });
1431
+ },
1432
+ glob(pattern, opts) {
1433
+ opts = opts || {};
1434
+ return platformJSON('/v1/fs/' + projectId + '/glob', {
1435
+ method: 'POST',
1436
+ body: JSON.stringify({ pattern, limit: opts.limit }),
1437
+ });
1438
+ },
1439
+ search(opts) {
1440
+ opts = opts || {};
1441
+ return platformJSON('/v1/fs/' + projectId + '/search', {
1442
+ method: 'POST',
1443
+ body: JSON.stringify({
1444
+ path: opts.path ? __sw_fsPath(opts.path) : '/',
1445
+ query: opts.query,
1446
+ limit: opts.limit,
1447
+ max_files: opts.max_files,
1448
+ }),
1449
+ });
1450
+ },
1451
+ replace(opts) {
1452
+ return platformJSON('/v1/fs/' + projectId + '/replace', {
1453
+ method: 'POST',
1454
+ body: JSON.stringify({
1455
+ path: __sw_fsPath(opts.path),
1456
+ find: opts.find,
1457
+ replace: opts.replace,
1458
+ }),
1459
+ });
1460
+ },
1461
+ // sw.fs.uploadUrl({ path, maxSize?, contentType?, expiresIn? })
1462
+ // → { url, path, expires_at, max_size, content_type }
1463
+ // Mints a short-lived signed URL the browser can PUT bytes to
1464
+ // directly. The browser does NOT need a platform key.
1465
+ uploadUrl(opts) {
1466
+ opts = opts || {};
1467
+ return platformJSON('/v1/fs/upload-url', {
1468
+ method: 'POST',
1469
+ body: JSON.stringify({
1470
+ project_id: projectId,
1471
+ path: __sw_fsPath(opts.path),
1472
+ max_size: opts.maxSize ?? opts.max_size,
1473
+ content_type: opts.contentType ?? opts.content_type,
1474
+ expires_in: opts.expiresIn ?? opts.expires_in,
1475
+ }),
1476
+ });
1477
+ },
1478
+ // sw.fs.signedUrl(path, { expiresIn? })
1479
+ // → { url, token, path, expires_at, expires_in }
1480
+ // Mints a short-lived signed URL anyone can GET to download the
1481
+ // file. No platform key required for the recipient. Default 1h,
1482
+ // max 7d. Common for email attachments, image previews, share
1483
+ // links.
1484
+ signedUrl(path, opts) {
1485
+ opts = opts || {};
1486
+ return platformJSON('/v1/fs/' + projectId + '/sign', {
1487
+ method: 'POST',
1488
+ body: JSON.stringify({
1489
+ path: __sw_fsPath(path),
1490
+ expires_in: opts.expiresIn ?? opts.expires_in,
1491
+ // { user } mints a link only if that end-user owns the file
1492
+ // (per-end-user ACL, owner_subject_*). Omit for full backend access.
1493
+ as_user: opts.user ?? opts.as_user,
1494
+ }),
1495
+ });
1496
+ },
1497
+ // sw.fs.public_url(path) → { path, public_url, content_type, size_bytes, visibility }
1498
+ // Flips the file to public and returns its permanent, unauthenticated
1499
+ // URL. Advertised in AGENT.md but was missing from the shim (doc/runtime
1500
+ // drift — a function calling it got "undefined is not a function").
1501
+ public_url(path) {
1502
+ return platformJSON('/v1/fs/' + projectId + '/public-url?path=' + encodeURIComponent(__sw_fsPath(path)));
1503
+ },
1504
+ publicUrl(path) {
1505
+ return platformJSON('/v1/fs/' + projectId + '/public-url?path=' + encodeURIComponent(__sw_fsPath(path)));
1506
+ },
1507
+ // sw.fs.setOwner(path, user)
1508
+ // → { path, owner_subject_type, owner_subject_id }
1509
+ // Assigns (or transfers) a PRIVATE file to a single end-user so only that
1510
+ // app_user can read it — via sw.fs.read(path, { user }) /
1511
+ // sw.fs.signedUrl(path, { user }). Pass null to reset ownership back to
1512
+ // the project. The ACL applies to private files only.
1513
+ setOwner(path, user) {
1514
+ return platformJSON('/v1/fs/' + projectId + '/owner', {
1515
+ method: 'POST',
1516
+ body: JSON.stringify({ path: __sw_fsPath(path), owner: user ?? null }),
1517
+ });
1518
+ },
1519
+ // sw.fs.uploadFromRequest(req, { path, maxBytes?, allowedTypes?, fieldName? })
1520
+ // → { url, path, size, contentType }
1521
+ //
1522
+ // One-call multipart upload handler. Parses multipart/form-data
1523
+ // from the request, validates against the optional limits, writes
1524
+ // to the given path, returns the public URL the browser can fetch.
1525
+ // Throws on every failure mode with a stable error code in the
1526
+ // message so the caller can map → HTTP status.
1527
+ async uploadFromRequest(req, opts) {
1528
+ opts = opts || {};
1529
+ const path = opts.path;
1530
+ if (!path || typeof path !== 'string') {
1531
+ throw new Error('UPLOAD_PATH_REQUIRED: opts.path is required');
1532
+ }
1533
+ const fieldName = opts.fieldName || opts.field_name || 'file';
1534
+ const reqCt = (req.headers.get('content-type') || '').toLowerCase();
1535
+ if (!reqCt.includes('multipart/form-data')) {
1536
+ throw new Error('UPLOAD_NOT_MULTIPART: request Content-Type must be multipart/form-data');
1537
+ }
1538
+ let form;
1539
+ try {
1540
+ form = await req.formData();
1541
+ } catch (err) {
1542
+ throw new Error('UPLOAD_PARSE_FAILED: ' + (err && err.message ? err.message : String(err)));
1543
+ }
1544
+ const file = form.get(fieldName);
1545
+ if (!file || typeof file === 'string') {
1546
+ throw new Error('UPLOAD_FIELD_MISSING: no file in form field "' + fieldName + '"');
1547
+ }
1548
+ const fileType = (file.type || 'application/octet-stream').toLowerCase();
1549
+ if (Array.isArray(opts.allowedTypes) && opts.allowedTypes.length > 0) {
1550
+ const allowed = opts.allowedTypes.map((t) => String(t).toLowerCase());
1551
+ if (!allowed.includes(fileType)) {
1552
+ throw new Error('UPLOAD_TYPE_NOT_ALLOWED: ' + fileType + ' is not in allowedTypes (' + allowed.join(', ') + ')');
1553
+ }
1554
+ }
1555
+ const bytes = await file.arrayBuffer();
1556
+ const size = bytes.byteLength;
1557
+ if (size === 0) {
1558
+ throw new Error('UPLOAD_FILE_EMPTY: uploaded file has zero bytes');
1559
+ }
1560
+ const max = Number(opts.maxBytes ?? opts.max_bytes ?? 0);
1561
+ if (max > 0 && size > max) {
1562
+ throw new Error('UPLOAD_TOO_LARGE: file is ' + size + ' bytes, limit is ' + max);
1563
+ }
1564
+ await this.write(path, bytes, { contentType: fileType });
1565
+ const normalizedPath = path.startsWith('/') ? path : '/' + path;
1566
+ return {
1567
+ url: '/storage' + normalizedPath,
1568
+ path: normalizedPath,
1569
+ size,
1570
+ contentType: fileType,
1571
+ };
1572
+ },
1573
+ },
1574
+
1575
+ storage: (function () {
1576
+ // sw.storage.* was removed when sw.fs replaced the legacy storage
1577
+ // API — the old storage route now returns 410 GONE (see
1578
+ // worker/src/index.ts). The old shim forwarded to that dead route,
1579
+ // so get()/delete() failed SILENTLY (the 410 response was handed
1580
+ // back as if it were file data) and put() threw an opaque
1581
+ // "storage.put failed: 410". sw.fs is a different, versioned file
1582
+ // store under a different key space, so a silent redirect would
1583
+ // read the WRONG location and miss data written via the old API.
1584
+ // Each method throws one clear, actionable error instead — the
1585
+ // same immediate-throw shape as the sw.db dev-only shim.
1586
+ // (tsk_8192b820)
1587
+ function deny() {
1588
+ const err = new Error(
1589
+ 'sw.storage is removed — use sw.fs instead. ' +
1590
+ 'Upload: sw.fs.write(path, body, { contentType }). ' +
1591
+ 'Download: sw.fs.read(path). ' +
1592
+ 'Remove: sw.fs.delete(path). ' +
1593
+ 'Note: sw.fs paths start with "/" (e.g. sw.fs.write("/avatars/me.png", bytes)).'
1594
+ );
1595
+ err.code = 'STORAGE_REMOVED';
1596
+ return err;
1597
+ }
1598
+ return {
1599
+ async get(_key) { throw deny(); },
1600
+ async put(_key, _body, _opts) { throw deny(); },
1601
+ async delete(_key) { throw deny(); },
1602
+ };
1603
+ })(),
1604
+
1605
+ email: {
1606
+ send(opts) {
1607
+ return platformJSON('/v1/email/send', {
1608
+ method: 'POST',
1609
+ body: JSON.stringify({ ...opts, project_id: projectId }),
1610
+ });
1611
+ },
1612
+ // Scrub before you send: has this ONE address bounced or complained?
1613
+ // → { address, suppressed, status, last_at, occurrences }. (tsk_f108058c)
1614
+ checkSuppression(address) {
1615
+ const q = new URLSearchParams({ project_id: projectId, address: String(address || '') });
1616
+ return platformJSON('/v1/email/suppression?' + q.toString());
1617
+ },
1618
+ // The dead-address list (bounced/complained) to scrub a mailing list in
1619
+ // one call → { bounces: [{ address, last_status, last_at, occurrences }] }.
1620
+ bounces(opts) {
1621
+ opts = opts || {};
1622
+ const q = new URLSearchParams({ project_id: projectId });
1623
+ if (opts.days) q.set('days', String(opts.days));
1624
+ if (opts.limit) q.set('limit', String(opts.limit));
1625
+ return platformJSON('/v1/email/bounces?' + q.toString());
1626
+ },
1627
+ },
1628
+
1629
+ ai: (function () {
1630
+ // Direct DB binding for sw.ai surfaces that read/write the
1631
+ // project DB (catalogTool, userMemory, notifications bell).
1632
+ // Mirrors the sw.db IIFE's "const DB = env.PROJECT_DB;" — kept
1633
+ // separate per-closure so each surface fails loud with a
1634
+ // helpful error rather than throwing on undefined.
1635
+ const DB = env.PROJECT_DB;
1636
+
1637
+ function withSubject(opts, subjectType, subjectId) {
1638
+ opts = opts || {};
1639
+ if (!subjectId) {
1640
+ const err = new Error('sw.ai.scoped: subjectId is required');
1641
+ err.code = 'VALIDATION_ERROR';
1642
+ throw err;
1643
+ }
1644
+ return {
1645
+ ...opts,
1646
+ subject_type: subjectType || 'app_user',
1647
+ subject_id: String(subjectId),
1648
+ };
1649
+ }
1650
+ function chat(opts) {
1651
+ return platformJSON('/v1/ai/complete', {
1652
+ method: 'POST',
1653
+ body: JSON.stringify({ ...opts, project_id: projectId }),
1654
+ });
1655
+ }
1656
+ function transcribe(opts) {
1657
+ return platformJSON('/v1/ai/transcribe', {
1658
+ method: 'POST',
1659
+ body: JSON.stringify({ ...opts, project_id: projectId }),
1660
+ });
1661
+ }
1662
+ // tts returns binary audio (Response) when no storage opt is set,
1663
+ // and a JSON envelope { storage_path, ... } when storage is set.
1664
+ // Mirrors sw.render.* — keep raw Response so callers can stream.
1665
+ async function tts(opts) {
1666
+ opts = opts || {};
1667
+ const body = JSON.stringify({ ...opts, project_id: projectId });
1668
+ if (opts.storage) {
1669
+ return platformJSON('/v1/ai/tts', { method: 'POST', body });
1670
+ }
1671
+ const r = await platformFetch('/v1/ai/tts', { method: 'POST', body });
1672
+ if (!r.ok) {
1673
+ let msg = 'tts failed: ' + r.status;
1674
+ try { const j = await r.json(); if (j && j.message) msg = j.message; } catch { /* noop */ }
1675
+ const err = new Error(msg); err.status = r.status; throw err;
1676
+ }
1677
+ return r;
1678
+ }
1679
+ async function generateImage(opts) {
1680
+ opts = opts || {};
1681
+ const body = JSON.stringify({ ...opts, project_id: projectId });
1682
+ if (opts.storage) {
1683
+ return platformJSON('/v1/ai/generate-image', { method: 'POST', body });
1684
+ }
1685
+ const r = await platformFetch('/v1/ai/generate-image', { method: 'POST', body });
1686
+ if (!r.ok) {
1687
+ let msg = 'generate-image failed: ' + r.status;
1688
+ try { const j = await r.json(); if (j && j.message) msg = j.message; } catch { /* noop */ }
1689
+ const err = new Error(msg); err.status = r.status; throw err;
1690
+ }
1691
+ return r;
1692
+ }
1693
+ async function removeBackground(opts) {
1694
+ opts = opts || {};
1695
+ const body = JSON.stringify({ ...opts, project_id: projectId });
1696
+ if (opts.storage) {
1697
+ return platformJSON('/v1/ai/remove-background', { method: 'POST', body });
1698
+ }
1699
+ const r = await platformFetch('/v1/ai/remove-background', { method: 'POST', body });
1700
+ if (!r.ok) {
1701
+ let msg = 'remove-background failed: ' + r.status;
1702
+ try { const j = await r.json(); if (j && j.message) msg = j.message; } catch { /* noop */ }
1703
+ const err = new Error(msg); err.status = r.status; throw err;
1704
+ }
1705
+ return r;
1706
+ }
1707
+ function embeddings(opts) {
1708
+ return platformJSON('/v1/ai/embeddings', {
1709
+ method: 'POST',
1710
+ body: JSON.stringify({ ...opts, project_id: projectId }),
1711
+ });
1712
+ }
1713
+ function moderate(text) {
1714
+ return platformJSON('/v1/ai/moderate', {
1715
+ method: 'POST',
1716
+ body: JSON.stringify({ project_id: projectId, text }),
1717
+ });
1718
+ }
1719
+ function catalog() {
1720
+ return platformJSON('/v1/ai/catalog');
1721
+ }
1722
+ // Conversation listing / fetching / deletion — needed by chat
1723
+ // apps that want a Claude.ai-style sidebar of prior conversations
1724
+ // (tsk_ba27a4b6). Same endpoints the MCP ai_conversation_* tools
1725
+ // hit; sw.ai.conversations.* mirrors that surface inside a
1726
+ // deployed function so the UI can render previews + handle
1727
+ // "new chat" / "delete" without round-tripping to MCP.
1728
+ function conversationsList(opts) {
1729
+ opts = opts || {};
1730
+ const q = new URLSearchParams({ project_id: projectId });
1731
+ if (opts.subject_id) q.set('subject_id', String(opts.subject_id));
1732
+ if (opts.subject_type) q.set('subject_type', String(opts.subject_type));
1733
+ if (typeof opts.limit === 'number') q.set('limit', String(opts.limit));
1734
+ return platformJSON('/v1/ai/conversations?' + q.toString());
1735
+ }
1736
+ function conversationGet(id, opts) {
1737
+ opts = opts || {};
1738
+ const q = new URLSearchParams({ project_id: projectId });
1739
+ if (opts.include) q.set('include', String(opts.include));
1740
+ return platformJSON('/v1/ai/conversations/' + encodeURIComponent(id) + '?' + q.toString());
1741
+ }
1742
+ function conversationDelete(id) {
1743
+ return platformJSON('/v1/ai/conversations/' + encodeURIComponent(id) + '?project_id=' + encodeURIComponent(projectId), {
1744
+ method: 'DELETE',
1745
+ });
1746
+ }
1747
+ // fork(sourceId, newId, opts?) — branch a conversation under a
1748
+ // fresh id without losing the original. Use for "regenerate
1749
+ // from this point" / "what if I asked X differently" UIs.
1750
+ // opts.upToMessageId truncates the copy at that message id
1751
+ // (inclusive); omit to copy the full history.
1752
+ function conversationFork(sourceId, newId, opts) {
1753
+ opts = opts || {};
1754
+ const body = { project_id: projectId, new_conversation_id: newId };
1755
+ if (typeof opts.upToMessageId === 'number') body.up_to_message_id = opts.upToMessageId;
1756
+ return platformJSON('/v1/ai/conversations/' + encodeURIComponent(sourceId) + '/fork', {
1757
+ method: 'POST',
1758
+ body: JSON.stringify(body),
1759
+ });
1760
+ }
1761
+ const conversations = {
1762
+ list: conversationsList,
1763
+ get: conversationGet,
1764
+ delete: conversationDelete,
1765
+ fork: conversationFork,
1766
+ forUser(userId) {
1767
+ return {
1768
+ list(opts) { return conversationsList({ ...(opts || {}), subject_type: 'app_user', subject_id: String(userId) }); },
1769
+ get: conversationGet,
1770
+ delete: conversationDelete,
1771
+ fork: conversationFork,
1772
+ };
1773
+ },
1774
+ };
1775
+
1776
+ // Tool-use loop helper (tsk_293ae3fd) — every chatbot was
1777
+ // re-implementing the same while-stop_reason==='tool_use' loop
1778
+ // with iteration caps and error handling. chatWithTools runs the
1779
+ // loop internally; the dev provides one tool-dispatch function.
1780
+ //
1781
+ // const r = await sw.ai.chatWithTools({
1782
+ // provider: 'anthropic',
1783
+ // model: 'claude-haiku-4-5',
1784
+ // conversation_id: 'nibble:' + user.id,
1785
+ // messages: [{ role: 'user', content: text }],
1786
+ // tools: [{ name: 'lookup', description: '...', input_schema: {...} }],
1787
+ // async executeTools(toolCalls) {
1788
+ // // toolCalls = [{ id, name, input }, ...]
1789
+ // return Promise.all(toolCalls.map(async (tc) => {
1790
+ // try {
1791
+ // const out = await runTool(tc.name, tc.input);
1792
+ // return { tool_use_id: tc.id, content: JSON.stringify(out) };
1793
+ // } catch (err) {
1794
+ // return { tool_use_id: tc.id, content: String(err), is_error: true };
1795
+ // }
1796
+ // }));
1797
+ // },
1798
+ // maxIterations: 5, // optional, default 5
1799
+ // maxSpendCents: 50, // optional, abort if running cost passes cap
1800
+ // });
1801
+ // // r.text, r.iterations, r.tool_calls_made, r.total_cost_cents
1802
+ async function chatWithTools(opts) {
1803
+ opts = opts || {};
1804
+ const execute = opts.executeTools;
1805
+ if (typeof execute !== 'function') {
1806
+ const err = new Error('sw.ai.chatWithTools: executeTools function is required');
1807
+ err.code = 'VALIDATION_ERROR';
1808
+ throw err;
1809
+ }
1810
+ const maxIter = Math.max(1, Math.min(20, Number(opts.maxIterations) || 5));
1811
+ const maxSpend = typeof opts.maxSpendCents === 'number' && opts.maxSpendCents > 0
1812
+ ? opts.maxSpendCents
1813
+ : Infinity;
1814
+
1815
+ // Pull out loop-specific opts so they don't bleed into the
1816
+ // underlying chat() call.
1817
+ const passOpts = { ...opts };
1818
+ delete passOpts.executeTools;
1819
+ delete passOpts.maxIterations;
1820
+ delete passOpts.maxSpendCents;
1821
+
1822
+ let messages = Array.isArray(opts.messages) ? [...opts.messages] : [];
1823
+ let totalCostCents = 0;
1824
+ let totalInputTokens = 0;
1825
+ let totalOutputTokens = 0;
1826
+ let toolCallsMade = 0;
1827
+ let lastResponse = null;
1828
+
1829
+ for (let iter = 0; iter < maxIter; iter++) {
1830
+ const r = await chat({ ...passOpts, messages });
1831
+ lastResponse = r;
1832
+ if (r && r.usage) {
1833
+ totalInputTokens += Number(r.usage.input_tokens) || 0;
1834
+ totalOutputTokens += Number(r.usage.output_tokens) || 0;
1835
+ }
1836
+ if (r && r.cost && typeof r.cost.total_cents === 'number') {
1837
+ totalCostCents += r.cost.total_cents;
1838
+ }
1839
+ if (totalCostCents > maxSpend) {
1840
+ const err = new Error('sw.ai.chatWithTools: maxSpendCents (' + maxSpend + '¢) exceeded at iter ' + iter + ' (' + totalCostCents + '¢).');
1841
+ err.code = 'AI_SPEND_CAP_EXCEEDED';
1842
+ err.status = 402;
1843
+ throw err;
1844
+ }
1845
+
1846
+ const blocks = Array.isArray(r && r.content) ? r.content : [];
1847
+ const toolUseBlocks = blocks.filter((b) => b && b.type === 'tool_use');
1848
+
1849
+ if (r.stop_reason !== 'tool_use' || toolUseBlocks.length === 0) {
1850
+ // Final answer — return with accumulated metrics.
1851
+ return {
1852
+ ...r,
1853
+ iterations: iter + 1,
1854
+ tool_calls_made: toolCallsMade,
1855
+ total_input_tokens: totalInputTokens,
1856
+ total_output_tokens: totalOutputTokens,
1857
+ total_cost_cents: totalCostCents,
1858
+ };
1859
+ }
1860
+
1861
+ // Run dev's tool dispatcher.
1862
+ toolCallsMade += toolUseBlocks.length;
1863
+ let toolResults;
1864
+ try {
1865
+ toolResults = await execute(toolUseBlocks.map((b) => ({
1866
+ id: b.id, name: b.name, input: b.input,
1867
+ })));
1868
+ } catch (err) {
1869
+ // Tool runner threw — feed the error back to the model as
1870
+ // a tool_result with is_error so it can recover, rather
1871
+ // than aborting the whole loop.
1872
+ toolResults = toolUseBlocks.map((b) => ({
1873
+ tool_use_id: b.id,
1874
+ content: 'Tool runner threw: ' + (err && err.message ? err.message : String(err)),
1875
+ is_error: true,
1876
+ }));
1877
+ }
1878
+ if (!Array.isArray(toolResults)) {
1879
+ const err = new Error('sw.ai.chatWithTools: executeTools must return an array of tool_result objects.');
1880
+ err.code = 'VALIDATION_ERROR';
1881
+ throw err;
1882
+ }
1883
+ // Build the next-iter user message: an array of tool_result
1884
+ // blocks, one per tool_use the model emitted. Missing ids get
1885
+ // synthesized so the model never sees an unanswered tool_use.
1886
+ const resultsById = new Map();
1887
+ for (const tr of toolResults) {
1888
+ if (tr && typeof tr.tool_use_id === 'string') resultsById.set(tr.tool_use_id, tr);
1889
+ }
1890
+ const orderedResults = toolUseBlocks.map((b) => {
1891
+ const tr = resultsById.get(b.id);
1892
+ if (tr) {
1893
+ return {
1894
+ type: 'tool_result',
1895
+ tool_use_id: b.id,
1896
+ content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
1897
+ ...(tr.is_error ? { is_error: true } : {}),
1898
+ };
1899
+ }
1900
+ return {
1901
+ type: 'tool_result',
1902
+ tool_use_id: b.id,
1903
+ content: 'No result returned for this tool call.',
1904
+ is_error: true,
1905
+ };
1906
+ });
1907
+
1908
+ // On the next iter, messages is just the new user tool_result
1909
+ // turn — the platform's conversation_id replay handles
1910
+ // history, OR if no conversation_id we keep building a local
1911
+ // history.
1912
+ if (opts.conversation_id) {
1913
+ messages = [{ role: 'user', content: orderedResults }];
1914
+ } else {
1915
+ messages.push({ role: 'assistant', content: blocks });
1916
+ messages.push({ role: 'user', content: orderedResults });
1917
+ }
1918
+ }
1919
+
1920
+ // Hit maxIterations without a terminal answer. Return the last
1921
+ // response so the dev can inspect what the model wanted to do.
1922
+ const err = new Error('sw.ai.chatWithTools: maxIterations (' + maxIter + ') reached without a final answer.');
1923
+ err.code = 'AI_MAX_ITERATIONS';
1924
+ err.status = 422;
1925
+ // Stuff metrics onto the error so a logging handler can see
1926
+ // them without re-querying.
1927
+ err.metrics = {
1928
+ iterations: maxIter,
1929
+ tool_calls_made: toolCallsMade,
1930
+ total_input_tokens: totalInputTokens,
1931
+ total_output_tokens: totalOutputTokens,
1932
+ total_cost_cents: totalCostCents,
1933
+ last_response: lastResponse,
1934
+ };
1935
+ throw err;
1936
+ }
1937
+
1938
+ // sw.ai.userMemory — per-user structured memory blob with
1939
+ // auto-compaction (tsk_293ae3fd item 2). Every chat app
1940
+ // reinvents this — Nibble has nibble_memory, RailTime would
1941
+ // have railtime_memory, etc. One table, three calls.
1942
+ //
1943
+ // const m = await sw.ai.userMemory.get(user.id);
1944
+ // await sw.ai.userMemory.update(user.id, { preferred_line: 'Northern' });
1945
+ // // After N turns, fold history into the structured blob:
1946
+ // await sw.ai.userMemory.compact(user.id, {
1947
+ // type: 'object',
1948
+ // properties: {
1949
+ // preferred_line: { type: 'string' },
1950
+ // commute_time: { type: 'string' },
1951
+ // last_seen_disruptions: { type: 'array', items: { type: 'string' } },
1952
+ // },
1953
+ // }, { conversation_id: 'railtime:' + user.id });
1954
+ //
1955
+ // Storage is in the project's own database under
1956
+ // _ai_user_memory — _-prefixed so it's hidden from db_browse /
1957
+ // db_describe like the other platform tables.
1958
+ const memoryTableSql = "CREATE TABLE IF NOT EXISTS _ai_user_memory (user_id TEXT PRIMARY KEY, blob_json TEXT NOT NULL DEFAULT '{}', updated_at INTEGER NOT NULL)";
1959
+ let memoryEnsured = false;
1960
+ async function ensureMemoryTable() {
1961
+ if (memoryEnsured) return;
1962
+ if (!DB || typeof DB.prepare !== 'function') return;
1963
+ try {
1964
+ await DB.prepare(memoryTableSql).run();
1965
+ memoryEnsured = true;
1966
+ } catch (err) {
1967
+ console.error('sw.ai.userMemory: ensureTable failed:', err && err.message ? err.message : err);
1968
+ }
1969
+ }
1970
+ const userMemory = {
1971
+ async get(userId) {
1972
+ if (!userId) {
1973
+ const err = new Error('sw.ai.userMemory.get: userId is required'); err.code = 'VALIDATION_ERROR'; throw err;
1974
+ }
1975
+ await ensureMemoryTable();
1976
+ const r = await DB.prepare(
1977
+ 'SELECT blob_json, updated_at FROM _ai_user_memory WHERE user_id = ?'
1978
+ ).bind(String(userId)).first();
1979
+ if (!r) return {};
1980
+ try { return JSON.parse(r.blob_json); } catch { return {}; }
1981
+ },
1982
+ async update(userId, patch) {
1983
+ if (!userId) {
1984
+ const err = new Error('sw.ai.userMemory.update: userId is required'); err.code = 'VALIDATION_ERROR'; throw err;
1985
+ }
1986
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
1987
+ const err = new Error('sw.ai.userMemory.update: patch must be a plain object'); err.code = 'VALIDATION_ERROR'; throw err;
1988
+ }
1989
+ await ensureMemoryTable();
1990
+ const current = await this.get(userId);
1991
+ const merged = { ...current, ...patch };
1992
+ const now = Date.now();
1993
+ await DB.prepare(
1994
+ 'INSERT INTO _ai_user_memory (user_id, blob_json, updated_at) VALUES (?, ?, ?) ' +
1995
+ 'ON CONFLICT(user_id) DO UPDATE SET blob_json = excluded.blob_json, updated_at = excluded.updated_at'
1996
+ ).bind(String(userId), JSON.stringify(merged), now).run();
1997
+ return merged;
1998
+ },
1999
+ async clear(userId) {
2000
+ if (!userId) {
2001
+ const err = new Error('sw.ai.userMemory.clear: userId is required'); err.code = 'VALIDATION_ERROR'; throw err;
2002
+ }
2003
+ await ensureMemoryTable();
2004
+ await DB.prepare('DELETE FROM _ai_user_memory WHERE user_id = ?').bind(String(userId)).run();
2005
+ return { cleared: true };
2006
+ },
2007
+ // compact(userId, schema, opts?) — feeds recent conversation
2008
+ // turns to a cheap model with the schema as a response_schema,
2009
+ // merges the structured output into the existing blob. Returns
2010
+ // the new blob. Use after N conversation turns or as a periodic
2011
+ // cron task. Cost: one ai.chat call against the cheapest model.
2012
+ async compact(userId, schema, opts) {
2013
+ opts = opts || {};
2014
+ if (!userId) {
2015
+ const err = new Error('sw.ai.userMemory.compact: userId is required'); err.code = 'VALIDATION_ERROR'; throw err;
2016
+ }
2017
+ if (!schema || typeof schema !== 'object') {
2018
+ const err = new Error('sw.ai.userMemory.compact: schema (JSON Schema object) is required'); err.code = 'VALIDATION_ERROR'; throw err;
2019
+ }
2020
+ // Pull recent turns from the user's conversation, if one was
2021
+ // named. Without a conversation_id we still extract from any
2022
+ // history the dev pre-loads as opts.history.
2023
+ let recentText = '';
2024
+ if (opts.conversation_id) {
2025
+ try {
2026
+ const full = await platformJSON('/v1/ai/conversations/' + encodeURIComponent(opts.conversation_id) + '?project_id=' + encodeURIComponent(projectId));
2027
+ const msgs = Array.isArray(full && full.messages) ? full.messages : [];
2028
+ const lastN = msgs.slice(-Number(opts.windowMessages) || -20);
2029
+ recentText = lastN.map((m) => {
2030
+ const c = typeof m.content === 'string'
2031
+ ? m.content
2032
+ : Array.isArray(m.content)
2033
+ ? m.content.map((b) => (b && b.type === 'text' ? b.text : '')).filter(Boolean).join(' ')
2034
+ : '';
2035
+ return m.role + ': ' + c;
2036
+ }).join('\n');
2037
+ } catch (err) {
2038
+ console.error('sw.ai.userMemory.compact: history load failed:', err && err.message ? err.message : err);
2039
+ }
2040
+ } else if (typeof opts.history === 'string') {
2041
+ recentText = opts.history;
2042
+ }
2043
+ if (!recentText) {
2044
+ return this.get(userId);
2045
+ }
2046
+
2047
+ const current = await this.get(userId);
2048
+ const sys = "You are a memory-compaction agent. Read the conversation transcript below and produce the user's structured memory blob according to the supplied schema. Carry forward any fields from the existing memory that the transcript does NOT contradict. Be terse — record durable facts, preferences, and goals; skip transient state.";
2049
+ const userMsg = 'Existing memory:\n' + JSON.stringify(current, null, 2) +
2050
+ '\n\nRecent transcript:\n' + recentText;
2051
+ const r = await platformJSON('/v1/ai/complete', {
2052
+ method: 'POST',
2053
+ body: JSON.stringify({
2054
+ project_id: projectId,
2055
+ provider: opts.provider || 'anthropic',
2056
+ model: opts.model || 'claude-haiku-4-5',
2057
+ system: sys,
2058
+ messages: [{ role: 'user', content: userMsg }],
2059
+ response_schema: schema,
2060
+ max_tokens: Number(opts.maxTokens) || 1024,
2061
+ }),
2062
+ });
2063
+ if (r && r.parsed && typeof r.parsed === 'object') {
2064
+ await DB.prepare(
2065
+ 'INSERT INTO _ai_user_memory (user_id, blob_json, updated_at) VALUES (?, ?, ?) ' +
2066
+ 'ON CONFLICT(user_id) DO UPDATE SET blob_json = excluded.blob_json, updated_at = excluded.updated_at'
2067
+ ).bind(String(userId), JSON.stringify(r.parsed), Date.now()).run();
2068
+ return r.parsed;
2069
+ }
2070
+ return current;
2071
+ },
2072
+ };
2073
+
2074
+ // sw.ai.catalogTool (tsk_261b) — three-line wiring for the
2075
+ // "search my catalog" tool every chat app builds. Returns
2076
+ // { tool, execute } so the dev can drop them into
2077
+ // sw.ai.chatWithTools without re-implementing the
2078
+ // SELECT/LIKE/format pattern.
2079
+ //
2080
+ // const restaurants = sw.ai.catalogTool({
2081
+ // table: 'restaurants',
2082
+ // searchColumns: ['name', 'cuisine', 'neighborhood'],
2083
+ // resultColumns: ['id', 'name', 'cuisine', 'rating', 'image_url'],
2084
+ // urlTemplate: '/restaurant/{id}',
2085
+ // limit: 10,
2086
+ // // optional Anthropic-tool overrides:
2087
+ // name: 'search_restaurants',
2088
+ // description: 'Search the restaurant catalog by name, cuisine, or area.',
2089
+ // // optional WHERE constraint (parameterized):
2090
+ // where: { sql: 'is_published = ?', params: [1] },
2091
+ // });
2092
+ //
2093
+ // const r = await sw.ai.chatWithTools({
2094
+ // model: 'claude-haiku-4-5',
2095
+ // messages: [{ role: 'user', content: q }],
2096
+ // tools: [restaurants.tool],
2097
+ // async executeTools(toolCalls) {
2098
+ // return Promise.all(toolCalls.map(async (tc) => {
2099
+ // if (tc.name === restaurants.tool.name) {
2100
+ // const out = await restaurants.execute(tc.input);
2101
+ // return { tool_use_id: tc.id, content: JSON.stringify(out) };
2102
+ // }
2103
+ // return { tool_use_id: tc.id, content: 'Unknown tool', is_error: true };
2104
+ // }));
2105
+ // },
2106
+ // });
2107
+ //
2108
+ // Identifier safety: table + column names are validated against
2109
+ // [a-zA-Z0-9_]+ so the assembled SQL is injection-safe. The
2110
+ // user query is bound as a parameter — never interpolated.
2111
+ function catalogTool(cfg) {
2112
+ cfg = cfg || {};
2113
+ const table = String(cfg.table || '');
2114
+ if (!/^[a-zA-Z0-9_]+$/.test(table)) {
2115
+ const err = new Error('sw.ai.catalogTool: table must be [a-zA-Z0-9_]+'); err.code = 'VALIDATION_ERROR'; throw err;
2116
+ }
2117
+ const searchColumns = Array.isArray(cfg.searchColumns) ? cfg.searchColumns.filter((c) => typeof c === 'string' && /^[a-zA-Z0-9_]+$/.test(c)) : [];
2118
+ if (searchColumns.length === 0) {
2119
+ const err = new Error('sw.ai.catalogTool: searchColumns required'); err.code = 'VALIDATION_ERROR'; throw err;
2120
+ }
2121
+ const resultColumns = Array.isArray(cfg.resultColumns) && cfg.resultColumns.length > 0
2122
+ ? cfg.resultColumns.filter((c) => typeof c === 'string' && /^[a-zA-Z0-9_]+$/.test(c))
2123
+ : null;
2124
+ const selectExpr = resultColumns && resultColumns.length > 0
2125
+ ? resultColumns.map((c) => '"' + c + '"').join(', ')
2126
+ : '*';
2127
+ const defaultLimit = Math.max(1, Math.min(50, Number(cfg.limit) || 10));
2128
+ const urlTemplate = typeof cfg.urlTemplate === 'string' ? cfg.urlTemplate : null;
2129
+ const extraWhere = cfg.where && typeof cfg.where.sql === 'string' ? cfg.where : null;
2130
+
2131
+ const toolName = typeof cfg.name === 'string' && /^[a-zA-Z0-9_]+$/.test(cfg.name)
2132
+ ? cfg.name
2133
+ : 'search_' + table;
2134
+ const description = typeof cfg.description === 'string'
2135
+ ? cfg.description
2136
+ : 'Search the ' + table + ' catalog. Matches user query against ' + searchColumns.join(', ') + ' with a SQL LIKE.';
2137
+
2138
+ const tool = {
2139
+ name: toolName,
2140
+ description,
2141
+ input_schema: {
2142
+ type: 'object',
2143
+ properties: {
2144
+ query: { type: 'string', description: 'Free-text search query (matched against ' + searchColumns.join(', ') + ').' },
2145
+ limit: { type: 'integer', description: 'Max results to return. Defaults to ' + defaultLimit + ', capped at 50.' },
2146
+ },
2147
+ required: ['query'],
2148
+ },
2149
+ };
2150
+
2151
+ async function execute(input) {
2152
+ input = input || {};
2153
+ const query = String(input.query || '').trim();
2154
+ const limit = Math.max(1, Math.min(50, Number(input.limit) || defaultLimit));
2155
+ if (!query) return { count: 0, results: [] };
2156
+
2157
+ if (!DB || typeof DB.prepare !== 'function') {
2158
+ const err = new Error('sw.ai.catalogTool: sw.db is not attached to this deploy. Call any sw.db.* method once first to provision the database, then redeploy.');
2159
+ err.code = 'DB_NOT_PROVISIONED';
2160
+ throw err;
2161
+ }
2162
+
2163
+ const likeClause = searchColumns.map((c) => '"' + c + '" LIKE ?').join(' OR ');
2164
+ const params = searchColumns.map(() => '%' + query + '%');
2165
+ let sql = 'SELECT ' + selectExpr + ' FROM "' + table + '" WHERE (' + likeClause + ')';
2166
+ if (extraWhere) {
2167
+ sql += ' AND (' + extraWhere.sql + ')';
2168
+ if (Array.isArray(extraWhere.params)) {
2169
+ for (const p of extraWhere.params) params.push(p);
2170
+ }
2171
+ }
2172
+ sql += ' LIMIT ?';
2173
+ params.push(limit);
2174
+
2175
+ const stmt = params.length > 0
2176
+ ? DB.prepare(sql).bind(...params)
2177
+ : DB.prepare(sql);
2178
+ const r = await stmt.all();
2179
+ const rows = (r.results || []);
2180
+ const results = urlTemplate
2181
+ ? rows.map((row) => {
2182
+ const url = urlTemplate.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, key) => {
2183
+ const v = row[key];
2184
+ return v == null ? '' : encodeURIComponent(String(v));
2185
+ });
2186
+ return Object.assign({}, row, { url });
2187
+ })
2188
+ : rows;
2189
+ return { count: results.length, query, limit, results };
2190
+ }
2191
+
2192
+ return { tool, execute };
2193
+ }
2194
+
2195
+ return {
2196
+ chat,
2197
+ complete: chat,
2198
+ chatWithTools,
2199
+ catalogTool,
2200
+ userMemory,
2201
+ conversations,
2202
+ scoped(subjectId, subjectType) {
2203
+ const sType = subjectType || 'app_user';
2204
+ return {
2205
+ chat(opts) { return chat(withSubject(opts, sType, subjectId)); },
2206
+ complete(opts) { return chat(withSubject(opts, sType, subjectId)); },
2207
+ chatWithTools(opts) { return chatWithTools(withSubject(opts, sType, subjectId)); },
2208
+ conversations: {
2209
+ list(opts) { return conversationsList({ ...(opts || {}), subject_type: sType, subject_id: String(subjectId) }); },
2210
+ get: conversationGet,
2211
+ delete: conversationDelete,
2212
+ fork: conversationFork,
2213
+ },
2214
+ };
2215
+ },
2216
+ forUser(userId) {
2217
+ return this.scoped(userId, 'app_user');
2218
+ },
2219
+ transcribe,
2220
+ tts,
2221
+ generateImage,
2222
+ removeBackground,
2223
+ embeddings,
2224
+ moderate,
2225
+ catalog,
2226
+ };
2227
+ })(),
2228
+
2229
+ image: (function () {
2230
+ // Image transformations — URL builder. Returns a string URL that
2231
+ // points at Cloudflare's image resizer on the project's domain.
2232
+ // Stick the URL in <img src="..."> or fetch it server-side. No
2233
+ // API call is made by this helper; transformation happens at the
2234
+ // edge when the URL is fetched.
2235
+ const PROJECT_HOST = env.SUBDOMAIN ? env.SUBDOMAIN + '.somewhere.tech' : null;
2236
+
2237
+ function buildOpts(opts) {
2238
+ opts = opts || {};
2239
+ const parts = [];
2240
+ const allowed = [
2241
+ 'width', 'height', 'fit', 'format', 'quality', 'dpr', 'gravity',
2242
+ 'background', 'blur', 'sharpen', 'rotate', 'trim', 'metadata',
2243
+ 'anim', 'brightness', 'contrast', 'gamma', 'border',
2244
+ ];
2245
+ for (const k of allowed) {
2246
+ const v = opts[k];
2247
+ if (v === undefined || v === null) continue;
2248
+ parts.push(k + '=' + String(v));
2249
+ }
2250
+ return parts.length > 0 ? parts.join(',') : 'format=auto';
2251
+ }
2252
+
2253
+ function resize(source, opts) {
2254
+ if (typeof source !== 'string' || source.length === 0) {
2255
+ throw new Error('sw.image.resize: source must be a non-empty URL or path string');
2256
+ }
2257
+ const optStr = buildOpts(opts);
2258
+ // Absolute URL: use as-is for the source. The cdn-cgi prefix
2259
+ // still needs a host with Image Transformations enabled — we
2260
+ // use the project's own subdomain.
2261
+ // NOTE: this regex lives inside a template literal; \/ here
2262
+ // emits / in the bundle, which is what V8 needs to keep the
2263
+ // regex literal valid. A bare / collapses to / and breaks
2264
+ // every customer's bundle (incident: 2026-05-09).
2265
+ if (/^https?:\/\//i.test(source)) {
2266
+ if (!PROJECT_HOST) {
2267
+ throw new Error('sw.image.resize: project subdomain not available; cannot build transform URL');
2268
+ }
2269
+ return 'https://' + PROJECT_HOST + '/cdn-cgi/image/' + optStr + '/' + source;
2270
+ }
2271
+ // Relative path: assume project subdomain.
2272
+ if (!PROJECT_HOST) {
2273
+ throw new Error('sw.image.resize: project subdomain not available; pass an absolute URL instead');
2274
+ }
2275
+ const path = source.startsWith('/') ? source : '/' + source;
2276
+ return 'https://' + PROJECT_HOST + '/cdn-cgi/image/' + optStr + path;
2277
+ }
2278
+
2279
+ return { resize };
2280
+ })(),
2281
+
2282
+ auth: (function () {
2283
+ // Direct D1 binding — same one sw.db uses. Needed by migrateAnon
2284
+ // so a single call can rewrite anon→user rows in one place
2285
+ // instead of fanning out HTTP requests.
2286
+ const DB = env.PROJECT_DB;
2287
+
2288
+ // Forward an end-user JWT (app_user) directly as the Authorization
2289
+ // header — used for routes that require an app_user JWT (verify
2290
+ // email, update password, profile, /me). The smt_ developer key is
2291
+ // skipped for these calls because the route handlers explicitly
2292
+ // reject developer mode.
2293
+ async function userTokenJSON(path, token, opts) {
2294
+ opts = opts || {};
2295
+ const headers = {
2296
+ 'Authorization': 'Bearer ' + token,
2297
+ ...(opts.headers || {}),
2298
+ };
2299
+ if (opts.body && !headers['Content-Type'] && !headers['content-type']) {
2300
+ headers['Content-Type'] = 'application/json';
2301
+ }
2302
+ const r = await fetch(platformBase + path, {
2303
+ method: opts.method || 'GET',
2304
+ headers,
2305
+ body: opts.body,
2306
+ });
2307
+ let data;
2308
+ try { data = await r.json(); } catch { data = null; }
2309
+ if (!r.ok || !data || data.ok === false) {
2310
+ const msg = (data && data.message) || ('Auth call failed: ' + r.status);
2311
+ const err = new Error(msg);
2312
+ err.code = (data && data.error) || 'AUTH_ERROR';
2313
+ err.status = r.status;
2314
+ throw err;
2315
+ }
2316
+ return data.data;
2317
+ }
2318
+
2319
+ return {
2320
+ signup(opts) {
2321
+ return platformJSON('/v1/auth/signup', {
2322
+ method: 'POST',
2323
+ body: JSON.stringify({ ...opts, project_id: projectId }),
2324
+ });
2325
+ },
2326
+ login(opts) {
2327
+ return platformJSON('/v1/auth/login', {
2328
+ method: 'POST',
2329
+ body: JSON.stringify({ ...opts, project_id: projectId }),
2330
+ });
2331
+ },
2332
+ logout(opts) {
2333
+ return platformJSON('/v1/auth/logout', {
2334
+ method: 'POST',
2335
+ body: JSON.stringify({ ...opts, project_id: projectId }),
2336
+ });
2337
+ },
2338
+ async me(token, opts) {
2339
+ opts = opts || {};
2340
+ const refreshToken = opts.refreshToken || null;
2341
+
2342
+ // Pre-flight parses the JWT and detects malformed input
2343
+ // synchronously. Expired tokens are tolerated here ONLY when
2344
+ // a paired refreshToken is supplied — the platform-side
2345
+ // /v1/auth/me handler does the auto-refresh dance and
2346
+ // returns the new pair via X-New-* response headers, which
2347
+ // we capture into the function context's __sw_pendingRefresh
2348
+ // slot so the shim can attach them to the user's response.
2349
+ const pre = refreshToken
2350
+ ? __sw_preflightJwtAllowExpired(token)
2351
+ : __sw_preflightJwt(token);
2352
+ const now = Date.now();
2353
+
2354
+ // Cache hit short-circuits the network call. Skip the cache
2355
+ // when a refreshToken is supplied AND the access token is
2356
+ // expired — otherwise a previously-cached fresh response
2357
+ // would suppress the refresh that the caller is asking for.
2358
+ if (!(refreshToken && pre.expired)) {
2359
+ const hit = __sw_authMeCache.get(pre.sig);
2360
+ if (hit && hit.expiresAt > now) {
2361
+ return hit.payload;
2362
+ }
2363
+ }
2364
+
2365
+ // Build the auth call by hand because we need the raw
2366
+ // Response back to read X-New-* headers — userTokenJSON
2367
+ // throws away the headers and returns data only.
2368
+ const headers = { 'Authorization': 'Bearer ' + token };
2369
+ if (refreshToken) headers['X-Refresh-Token'] = refreshToken;
2370
+ const r = await fetch(platformBase + '/v1/auth/me', { headers });
2371
+ let data;
2372
+ try { data = await r.json(); } catch { data = null; }
2373
+ if (!r.ok || !data || data.ok === false) {
2374
+ const msg = (data && data.message) || ('Auth call failed: ' + r.status);
2375
+ const err = new Error(msg);
2376
+ err.code = (data && data.error) || 'AUTH_ERROR';
2377
+ err.status = r.status;
2378
+ throw err;
2379
+ }
2380
+
2381
+ const newAccess = r.headers.get('X-New-Access-Token') || r.headers.get('x-new-access-token');
2382
+ const newRefresh = r.headers.get('X-New-Refresh-Token') || r.headers.get('x-new-refresh-token');
2383
+ if (newAccess && newRefresh) {
2384
+ __sw_pendingRefresh.access = newAccess;
2385
+ __sw_pendingRefresh.refresh = newRefresh;
2386
+ }
2387
+
2388
+ const payload = data.data;
2389
+
2390
+ // Only cache when refresh did NOT fire. If refresh fired the
2391
+ // cache key (sig of the old access token) is stale anyway.
2392
+ if (!(newAccess && newRefresh)) {
2393
+ let ttl = __sw_AUTH_ME_TTL_MS;
2394
+ if (pre.expSec) {
2395
+ const untilExp = pre.expSec * 1000 - now - 5_000;
2396
+ if (untilExp > 0 && untilExp < ttl) ttl = untilExp;
2397
+ }
2398
+ if (ttl > 0) {
2399
+ if (__sw_authMeCache.size >= __sw_AUTH_ME_MAX) {
2400
+ const firstKey = __sw_authMeCache.keys().next().value;
2401
+ if (firstKey) __sw_authMeCache.delete(firstKey);
2402
+ }
2403
+ __sw_authMeCache.set(pre.sig, { payload, expiresAt: now + ttl });
2404
+ }
2405
+ }
2406
+ return payload;
2407
+ },
2408
+ refresh(opts) {
2409
+ return platformJSON('/v1/auth/refresh', {
2410
+ method: 'POST',
2411
+ body: JSON.stringify({ ...opts, project_id: projectId }),
2412
+ });
2413
+ },
2414
+ forgot(opts) {
2415
+ return platformJSON('/v1/auth/forgot', {
2416
+ method: 'POST',
2417
+ body: JSON.stringify({ ...opts, project_id: projectId }),
2418
+ });
2419
+ },
2420
+ reset(opts) {
2421
+ return platformJSON('/v1/auth/reset', {
2422
+ method: 'POST',
2423
+ body: JSON.stringify({ ...opts, project_id: projectId }),
2424
+ });
2425
+ },
2426
+ requestEmailVerification(token) {
2427
+ return userTokenJSON('/v1/auth/request-email-verification', token, { method: 'POST' });
2428
+ },
2429
+ verifyEmail(token, opts) {
2430
+ return userTokenJSON('/v1/auth/verify-email', token, {
2431
+ method: 'POST',
2432
+ body: JSON.stringify(opts || {}),
2433
+ });
2434
+ },
2435
+ resendVerification(token) {
2436
+ return userTokenJSON('/v1/auth/resend-verification', token, { method: 'POST' });
2437
+ },
2438
+ updatePassword(token, opts) {
2439
+ return userTokenJSON('/v1/auth/update-password', token, {
2440
+ method: 'POST',
2441
+ body: JSON.stringify(opts || {}),
2442
+ });
2443
+ },
2444
+ updateProfile(token, opts) {
2445
+ return userTokenJSON('/v1/auth/users/me', token, {
2446
+ method: 'PATCH',
2447
+ body: JSON.stringify(opts || {}),
2448
+ });
2449
+ },
2450
+ deleteUser(token) {
2451
+ return userTokenJSON('/v1/auth/users/me', token, { method: 'DELETE' });
2452
+ },
2453
+ // Build the Google OAuth URL for the user's browser. After consent,
2454
+ // the platform redirects to redirect_uri with ?code=AUTH_CODE.
2455
+ // Pass that code to sw.auth.googleExchange to get the JWT.
2456
+ googleUrl(opts) {
2457
+ opts = opts || {};
2458
+ if (!opts.redirect_uri) {
2459
+ const err = new Error('sw.auth.googleUrl: redirect_uri is required');
2460
+ err.code = 'VALIDATION_ERROR';
2461
+ throw err;
2462
+ }
2463
+ const params = new URLSearchParams({
2464
+ project_id: projectId,
2465
+ redirect_uri: opts.redirect_uri,
2466
+ });
2467
+ return platformBase + '/v1/auth/google?' + params.toString();
2468
+ },
2469
+ googleExchange(opts) {
2470
+ return platformJSON('/v1/auth/google/exchange', {
2471
+ method: 'POST',
2472
+ body: JSON.stringify(opts || {}),
2473
+ });
2474
+ },
2475
+
2476
+ // ── httpOnly cookie sessions (tsk_1288e1c6) ──────────────────────
2477
+ // Set the session as HttpOnly + Secure cookies on the response and
2478
+ // return the user. The client just does fetch(url, { credentials:
2479
+ // 'include' }) — no tokens in localStorage, nothing for XSS to steal,
2480
+ // and fromRequest auto-refreshes the cookie so sessions are long-lived.
2481
+ // The cookies ride out on the Response via the shim, so the handler
2482
+ // returns a plain user/Response and does zero header work.
2483
+ async loginWithCookie(req, email, password) {
2484
+ const d = await this.login({ email: email, password: password });
2485
+ const access = d && (d.token || d.access_token);
2486
+ const refresh = d && d.refresh_token;
2487
+ if (access && refresh) __sw_setAuthCookies(access, refresh);
2488
+ return (d && d.user) || null;
2489
+ },
2490
+ async signupWithCookie(req, email, password) {
2491
+ const d = await this.signup({ email: email, password: password });
2492
+ const access = d && (d.token || d.access_token);
2493
+ const refresh = d && d.refresh_token;
2494
+ if (access && refresh) __sw_setAuthCookies(access, refresh);
2495
+ return (d && d.user) || null;
2496
+ },
2497
+ // Completes the Google OAuth round-trip: reads ?code from the callback
2498
+ // request, exchanges it, sets the cookies, returns a 302 redirect.
2499
+ async googleCallbackWithCookie(req, redirectTo) {
2500
+ const u = new URL(req.url);
2501
+ const code = u.searchParams.get('code');
2502
+ if (!code) { const e = new Error('Missing ?code on the OAuth callback.'); e.code = 'VALIDATION_ERROR'; throw e; }
2503
+ const d = await this.googleExchange({ code: code, redirect_uri: u.origin + u.pathname });
2504
+ const access = d && (d.token || d.access_token);
2505
+ const refresh = d && d.refresh_token;
2506
+ if (access && refresh) __sw_setAuthCookies(access, refresh);
2507
+ return new Response(null, { status: 302, headers: { Location: redirectTo || '/' } });
2508
+ },
2509
+ // Revokes the session server-side and clears the cookies.
2510
+ async logoutWithCookie(req) {
2511
+ const ch = (req && req.headers && (req.headers.get('cookie') || req.headers.get('Cookie'))) || '';
2512
+ const refresh = __sw_readCookie(ch, 'sw_refresh_token') || __sw_readCookie(ch, 'refresh_token');
2513
+ try { await this.logout(refresh ? { refresh_token: refresh } : {}); } catch (_) {}
2514
+ __sw_clearAuthCookies();
2515
+ return { ok: true };
2516
+ },
2517
+
2518
+ // Magic-link / OTP sign-in (Supabase-shaped). Email-based,
2519
+ // single-use, 15-minute TTL. Auto-creates the user on first
2520
+ // sign-in. verifyOtp consumes the token and returns the same
2521
+ // shape login/signup produce.
2522
+ signInWithOtp(opts) {
2523
+ opts = opts || {};
2524
+ if (!opts.email) {
2525
+ const err = new Error('sw.auth.signInWithOtp: email is required');
2526
+ err.code = 'VALIDATION_ERROR';
2527
+ throw err;
2528
+ }
2529
+ return platformJSON('/v1/auth/magic-link', {
2530
+ method: 'POST',
2531
+ body: JSON.stringify({ ...opts, project_id: projectId }),
2532
+ });
2533
+ },
2534
+ verifyOtp(opts) {
2535
+ opts = opts || {};
2536
+ if (!opts.token) {
2537
+ const err = new Error('sw.auth.verifyOtp: token is required');
2538
+ err.code = 'VALIDATION_ERROR';
2539
+ throw err;
2540
+ }
2541
+ return platformJSON('/v1/auth/magic-link/verify', {
2542
+ method: 'POST',
2543
+ body: JSON.stringify({ ...opts, project_id: projectId }),
2544
+ });
2545
+ },
2546
+
2547
+ // MFA / TOTP (Supabase-shaped). Two-step enrollment so a closed
2548
+ // tab mid-enrol doesn't lock the user out: enroll() stores the
2549
+ // secret, verify(code) flips mfa_enabled=1 and returns 8 single-
2550
+ // use backup codes (once). challenge() is the second-factor
2551
+ // call after password /login returned mfa_required:true.
2552
+ mfa: {
2553
+ // Begin enrolment. Caller must pass the user's JWT — MFA is
2554
+ // always tied to a logged-in account. Returns { secret,
2555
+ // otpauth_uri, issuer, account } — render otpauth_uri as a
2556
+ // QR code in the UI.
2557
+ enroll(opts) {
2558
+ opts = opts || {};
2559
+ if (!opts.token) {
2560
+ const err = new Error('sw.auth.mfa.enroll: token is required');
2561
+ err.code = 'VALIDATION_ERROR';
2562
+ throw err;
2563
+ }
2564
+ return platformJSON('/v1/auth/mfa/enroll', {
2565
+ method: 'POST',
2566
+ body: JSON.stringify({}),
2567
+ headers: { Authorization: 'Bearer ' + opts.token },
2568
+ });
2569
+ },
2570
+ // Confirm enrolment by submitting the first 6-digit code the
2571
+ // authenticator generates. Returns { enabled:true,
2572
+ // backup_codes:[...] } on first success; backup_codes is null
2573
+ // on a re-verify (existing codes are preserved).
2574
+ verify(opts) {
2575
+ opts = opts || {};
2576
+ if (!opts.token || !opts.code) {
2577
+ const err = new Error('sw.auth.mfa.verify: token and code are required');
2578
+ err.code = 'VALIDATION_ERROR';
2579
+ throw err;
2580
+ }
2581
+ return platformJSON('/v1/auth/mfa/verify', {
2582
+ method: 'POST',
2583
+ body: JSON.stringify({ code: opts.code }),
2584
+ headers: { Authorization: 'Bearer ' + opts.token },
2585
+ });
2586
+ },
2587
+ // Second-factor exchange after /login returned
2588
+ // { mfa_required:true, mfa_token }. Code is either the 6-digit
2589
+ // TOTP or a 10-char backup code. Returns the same shape as
2590
+ // /login on success.
2591
+ challenge(opts) {
2592
+ opts = opts || {};
2593
+ if (!opts.mfa_token || !opts.code) {
2594
+ const err = new Error('sw.auth.mfa.challenge: mfa_token and code are required');
2595
+ err.code = 'VALIDATION_ERROR';
2596
+ throw err;
2597
+ }
2598
+ return platformJSON('/v1/auth/mfa/challenge', {
2599
+ method: 'POST',
2600
+ body: JSON.stringify({
2601
+ project_id: projectId,
2602
+ mfa_token: opts.mfa_token,
2603
+ code: opts.code,
2604
+ }),
2605
+ });
2606
+ },
2607
+ // Disable MFA. Requires a fresh code (TOTP or backup) so a
2608
+ // stolen JWT alone can't turn it off.
2609
+ unenroll(opts) {
2610
+ opts = opts || {};
2611
+ if (!opts.token || !opts.code) {
2612
+ const err = new Error('sw.auth.mfa.unenroll: token and code are required');
2613
+ err.code = 'VALIDATION_ERROR';
2614
+ throw err;
2615
+ }
2616
+ return platformJSON('/v1/auth/mfa/unenroll', {
2617
+ method: 'POST',
2618
+ body: JSON.stringify({ code: opts.code }),
2619
+ headers: { Authorization: 'Bearer ' + opts.token },
2620
+ });
2621
+ },
2622
+ },
2623
+
2624
+ // sw.auth.admin used to live here — removed 2026-05-21 per the
2625
+ // function-runtime security audit (tsk_a9f1fee70). Admin
2626
+ // app-user actions (banUser, deleteUser, impersonate, session
2627
+ // revocation, etc.) silently used the deploy-time smt_ key, so
2628
+ // any public handler that accepted a userId from the request
2629
+ // body became a takeover primitive. Existing call sites get a
2630
+ // typed error pointing at the supported paths — the same
2631
+ // REST/MCP endpoints continue to work for legitimate operator
2632
+ // tooling, just not from inside a request handler.
2633
+ admin: new Proxy({}, {
2634
+ get(_t, prop) {
2635
+ return () => {
2636
+ const err = new Error(
2637
+ 'sw.auth.admin.' + String(prop) + ' was removed from the function runtime (2026-05-21). ' +
2638
+ 'Admin actions on app_users must run with a developer smt_ key, not inside a request handler — ' +
2639
+ 'call PATCH/DELETE /v1/auth/users/:id directly (CLI: somewhere fetch ..., MCP: auth_user_delete / auth_user_update), ' +
2640
+ 'or admin from your operator dashboard. Reason: any public handler taking a userId from the body could escalate.'
2641
+ );
2642
+ err.code = 'AUTH_ADMIN_REMOVED_FROM_RUNTIME';
2643
+ throw err;
2644
+ };
2645
+ },
2646
+ }),
2647
+
2648
+ // Pull the end user out of the request in one call. Looks at
2649
+ // common cookie names (token, auth_token, session) first, then
2650
+ // the Authorization: Bearer header. Validates via sw.auth.me.
2651
+ // Returns the user object on success or null when no token is
2652
+ // present / the token is invalid / expired. Never throws — the
2653
+ // caller handles "no auth" with a single null check.
2654
+ //
2655
+ // Auto-refresh: when the request also carries an X-Refresh-Token
2656
+ // header (or refresh_token / sw_refresh_token cookie) and the
2657
+ // access token is expired, the platform mints a fresh pair and
2658
+ // we stash it into __sw_pendingRefresh; the function shim
2659
+ // attaches X-New-Access-Token / X-New-Refresh-Token to the
2660
+ // caller's response. Opt out with X-No-Auto-Refresh: 1.
2661
+ // Throwing variant of fromRequest — pulls the JWT from the
2662
+ // request, returns the user, or throws an Error with status=401
2663
+ // if the request is unauthenticated. The handler shim turns
2664
+ // thrown errors with a status field into the matching HTTP
2665
+ // response, so a typical guarded handler is one line:
2666
+ //
2667
+ // export default async (req, sw) => {
2668
+ // const user = await sw.auth.requireUser(req);
2669
+ // return Response.json({ id: user.id, email: user.email });
2670
+ // };
2671
+ //
2672
+ // Optional second arg matches sw.auth.fromRequest's enrichment
2673
+ // shape so requireUser(req, { enrichFrom: 'members', fields: [...] })
2674
+ // returns the joined fields too.
2675
+ async requireUser(req, enrich) {
2676
+ const user = await this.fromRequest(req, enrich);
2677
+ if (!user) {
2678
+ const err = new Error('Sign in required.');
2679
+ err.code = 'AUTH_REQUIRED';
2680
+ err.status = 401;
2681
+ throw err;
2682
+ }
2683
+ return user;
2684
+ },
2685
+ // fromRequest(req, enrich?) — resolves the signed-in user from
2686
+ // the request, optionally joining one row from a user-table the
2687
+ // dev maintains so every handler doesn't have to do a follow-up
2688
+ // SELECT. The enrich shape mirrors the Adapted Co feedback in
2689
+ // tsk_5c98f4f6:
2690
+ //
2691
+ // const me = await sw.auth.fromRequest(req, {
2692
+ // enrichFrom: 'members',
2693
+ // fields: ['role', 'metadata'], // optional, default '*'
2694
+ // on: 'id', // optional, default 'id'
2695
+ // });
2696
+ // // me.id, me.email, me.role, me.metadata
2697
+ //
2698
+ // The join column on the user-table defaults to id and
2699
+ // matches the platform user's id. Set "on" to a different
2700
+ // column (e.g. user_id) when the dev's table doesn't use the
2701
+ // platform id as its primary key. Enrichment errors (missing
2702
+ // table, missing column) are swallowed — the base user is
2703
+ // returned without the extra fields and an error is logged.
2704
+ async fromRequest(req, enrich) {
2705
+ if (!req || !req.headers || typeof req.headers.get !== 'function') return null;
2706
+ // Parse the Cookie header once into an exact-key map. A per-name
2707
+ // regex had an order edge case — reading 'token' failed when
2708
+ // 'sw_refresh_token' (which contains "token") came first in the
2709
+ // header, which browsers do freely (tsk_1288e1c6). Exact-key match
2710
+ // is order-independent and collision-free.
2711
+ const cookieHeader = req.headers.get('cookie') || req.headers.get('Cookie') || '';
2712
+ const jar = {};
2713
+ for (const part of cookieHeader.split(';')) {
2714
+ const eq = part.indexOf('=');
2715
+ if (eq < 0) continue;
2716
+ const k = part.slice(0, eq).trim();
2717
+ if (k && !(k in jar)) {
2718
+ const raw = part.slice(eq + 1).trim();
2719
+ try { jar[k] = decodeURIComponent(raw); } catch (_) { jar[k] = raw; }
2720
+ }
2721
+ }
2722
+ let token = null;
2723
+ let fromCookie = false;
2724
+ for (const name of ['token', 'auth_token', 'session']) {
2725
+ if (jar[name]) { token = jar[name]; fromCookie = true; break; }
2726
+ }
2727
+ if (!token) {
2728
+ token = __sw_readBearer(req.headers.get('Authorization') || req.headers.get('authorization'));
2729
+ }
2730
+ if (!token) return null;
2731
+
2732
+ let refreshToken = null;
2733
+ const optOut = req.headers.get('X-No-Auto-Refresh') || req.headers.get('x-no-auto-refresh');
2734
+ if (optOut !== '1') {
2735
+ refreshToken = req.headers.get('X-Refresh-Token') || req.headers.get('x-refresh-token');
2736
+ if (!refreshToken) {
2737
+ for (const name of ['refresh_token', 'sw_refresh_token']) {
2738
+ if (jar[name]) { refreshToken = jar[name]; break; }
2739
+ }
2740
+ }
2741
+ }
2742
+
2743
+ let user;
2744
+ try {
2745
+ const result = await this.me(token, refreshToken ? { refreshToken } : undefined);
2746
+ user = result && result.user ? result.user : (result || null);
2747
+ } catch (_) {
2748
+ return null;
2749
+ }
2750
+ if (!user) return null;
2751
+
2752
+ // Cookie session that auto-refreshed → re-issue the httpOnly cookies so
2753
+ // the browser keeps a long-lived session, invisibly (tsk_1288e1c6). The
2754
+ // header-based X-New-* path (non-cookie clients) is unaffected.
2755
+ if (fromCookie && __sw_pendingRefresh.access && __sw_pendingRefresh.refresh) {
2756
+ __sw_setAuthCookies(__sw_pendingRefresh.access, __sw_pendingRefresh.refresh);
2757
+ }
2758
+
2759
+ // Optional enrichment — JOIN the dev's user-table row onto
2760
+ // the platform user. Skipped when not requested or when sw.db
2761
+ // isn't bound (shouldn't happen in practice). Errors are
2762
+ // swallowed so a missing table doesn't crash the request —
2763
+ // returns the unenriched user.
2764
+ if (enrich && enrich.enrichFrom && DB && typeof DB.prepare === 'function') {
2765
+ const table = String(enrich.enrichFrom);
2766
+ // Allow only [a-zA-Z0-9_] table names — same constraint
2767
+ // sw.db.scoped uses, blocks SQL injection on this surface.
2768
+ if (!/^[a-zA-Z0-9_]+$/.test(table)) {
2769
+ console.error('sw.auth.fromRequest: invalid enrichFrom table name:', table);
2770
+ return user;
2771
+ }
2772
+ const joinCol = enrich.on && /^[a-zA-Z0-9_]+$/.test(String(enrich.on))
2773
+ ? String(enrich.on)
2774
+ : 'id';
2775
+ let selectExpr = '*';
2776
+ if (Array.isArray(enrich.fields) && enrich.fields.length > 0) {
2777
+ const cleaned = enrich.fields
2778
+ .filter((f) => typeof f === 'string' && /^[a-zA-Z0-9_]+$/.test(f))
2779
+ .map((f) => '"' + f + '"');
2780
+ if (cleaned.length > 0) selectExpr = cleaned.join(', ');
2781
+ }
2782
+ try {
2783
+ const row = await DB.prepare(
2784
+ 'SELECT ' + selectExpr + ' FROM "' + table + '" WHERE "' + joinCol + '" = ?'
2785
+ ).bind(user.id).first();
2786
+ if (row) {
2787
+ // Platform fields win on collision — never let the
2788
+ // dev table overwrite id/email/etc.
2789
+ user = Object.assign({}, row, user);
2790
+ }
2791
+ } catch (err) {
2792
+ console.error('sw.auth.fromRequest enrichment failed:', err && err.message ? err.message : err);
2793
+ }
2794
+ }
2795
+ return user;
2796
+ },
2797
+
2798
+ // Cookie-backed anonymous identity for pre-signup users.
2799
+ // Reads sw_anon_id from the request cookie if present; otherwise
2800
+ // mints a fresh uuid and returns a setCookie string the caller
2801
+ // attaches to its response. Use applyTo() to wrap a Response in
2802
+ // one line.
2803
+ anonSession(req) {
2804
+ let id = null;
2805
+ let isNew = false;
2806
+ if (req && req.headers && typeof req.headers.get === 'function') {
2807
+ const existing = __sw_readCookie(
2808
+ req.headers.get('cookie') || req.headers.get('Cookie'),
2809
+ 'sw_anon_id'
2810
+ );
2811
+ if (existing && /^anon_[a-zA-Z0-9_-]{8,}$/.test(existing)) {
2812
+ id = existing;
2813
+ }
2814
+ }
2815
+ if (!id) {
2816
+ id = 'anon_' + crypto.randomUUID().replace(/-/g, '');
2817
+ isNew = true;
2818
+ }
2819
+ // 1y, HttpOnly so JS can't read it, SameSite=Lax for normal
2820
+ // navigation, Secure so it only flies over HTTPS.
2821
+ const setCookie = isNew
2822
+ ? `sw_anon_id=${id}; Path=/; Max-Age=31536000; HttpOnly; Secure; SameSite=Lax`
2823
+ : null;
2824
+ return {
2825
+ id,
2826
+ isAnon: true,
2827
+ setCookie,
2828
+ applyTo(response) {
2829
+ if (!setCookie) return response;
2830
+ const headers = new Headers(response.headers);
2831
+ headers.append('Set-Cookie', setCookie);
2832
+ return new Response(response.body, {
2833
+ status: response.status,
2834
+ statusText: response.statusText,
2835
+ headers,
2836
+ });
2837
+ },
2838
+ };
2839
+ },
2840
+
2841
+ // Move every row tagged with anonId over to userId on signup /
2842
+ // login. Auto-detects user-tables that have a user_id column
2843
+ // (skipping platform-managed _-prefixed tables and sqlite_*).
2844
+ // Pass tables: ['x', 'y'] to override the auto-detect.
2845
+ // Returns { migrated, tables }.
2846
+ async migrateAnon(opts) {
2847
+ opts = opts || {};
2848
+ if (!opts.anonId || !opts.userId) {
2849
+ const err = new Error('sw.auth.migrateAnon requires { anonId, userId }');
2850
+ err.code = 'VALIDATION_ERROR';
2851
+ throw err;
2852
+ }
2853
+ if (!DB) {
2854
+ const err = new Error('sw.auth.migrateAnon requires the project database. Call any sw.db.* method once first to provision it.');
2855
+ err.code = 'DB_NOT_PROVISIONED';
2856
+ throw err;
2857
+ }
2858
+ let tableList = Array.isArray(opts.tables) ? opts.tables.slice() : null;
2859
+ if (!tableList) {
2860
+ const tablesQ = await DB.prepare(
2861
+ "SELECT name FROM sqlite_master WHERE type='table' AND substr(name, 1, 1) != '_' AND name NOT LIKE 'sqlite_%'"
2862
+ ).all();
2863
+ tableList = [];
2864
+ for (const row of (tablesQ.results || [])) {
2865
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(row.name)) continue;
2866
+ const cols = await DB.prepare('PRAGMA table_info("' + row.name.replace(/"/g, '""') + '")').all();
2867
+ if ((cols.results || []).some((c) => c.name === 'user_id')) {
2868
+ tableList.push(row.name);
2869
+ }
2870
+ }
2871
+ }
2872
+ let total = 0;
2873
+ for (const t of tableList) {
2874
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(t)) continue;
2875
+ const r = await DB.prepare(
2876
+ 'UPDATE "' + t.replace(/"/g, '""') + '" SET user_id = ? WHERE user_id = ?'
2877
+ ).bind(opts.userId, opts.anonId).run();
2878
+ total += (r.meta && r.meta.changes) || 0;
2879
+ }
2880
+ return { migrated: total, tables: tableList };
2881
+ },
2882
+ };
2883
+ })(),
2884
+
2885
+ jobs: {
2886
+ create(opts) {
2887
+ return platformJSON('/v1/jobs', {
2888
+ method: 'POST',
2889
+ body: JSON.stringify({ ...opts, project_id: projectId }),
2890
+ });
2891
+ },
2892
+ status(jobId) {
2893
+ return platformJSON('/v1/jobs/' + jobId);
2894
+ },
2895
+ async verifyInvocation(req) {
2896
+ const body = await req.clone().text();
2897
+ const source = req.headers.get('X-Somewhere-Invocation-Source') || req.headers.get('X-Somewhere-Source') || 'job';
2898
+ const invocationId = req.headers.get('X-Somewhere-Job-Id') || req.headers.get('X-Somewhere-Message-Id') || '';
2899
+ const timestamp = req.headers.get('X-Somewhere-Invocation-Timestamp') || '';
2900
+ const bodySha256 = req.headers.get('X-Somewhere-Body-SHA256') || '';
2901
+ const signature = req.headers.get('X-Somewhere-Signature') || '';
2902
+ try {
2903
+ const verified = await platformJSON('/v1/jobs/verify-invocation', {
2904
+ method: 'POST',
2905
+ body: JSON.stringify({
2906
+ project_id: projectId,
2907
+ source,
2908
+ invocation_id: invocationId,
2909
+ timestamp,
2910
+ body,
2911
+ body_sha256: bodySha256,
2912
+ signature,
2913
+ }),
2914
+ });
2915
+ return !!(verified && verified.valid);
2916
+ } catch (_) {
2917
+ return false;
2918
+ }
2919
+ },
2920
+ },
2921
+
2922
+ queue: {
2923
+ push(opts) {
2924
+ return platformJSON('/v1/queue', {
2925
+ method: 'POST',
2926
+ body: JSON.stringify({ ...opts, project_id: projectId }),
2927
+ });
2928
+ },
2929
+ async verifyInvocation(req) {
2930
+ return sw.jobs.verifyInvocation(req);
2931
+ },
2932
+ },
2933
+
2934
+ logs: {
2935
+ _send(level, message, data) {
2936
+ return platformFetch('/v1/logs', {
2937
+ method: 'POST',
2938
+ body: JSON.stringify({ project_id: projectId, level, message, data, source: 'function' }),
2939
+ }).catch(() => {});
2940
+ },
2941
+ debug(msg, data) { return this._send('debug', msg, data); },
2942
+ info(msg, data) { return this._send('info', msg, data); },
2943
+ warn(msg, data) { return this._send('warn', msg, data); },
2944
+ error(msg, data) { return this._send('error', msg, data); },
2945
+ },
2946
+
2947
+ realtime: {
2948
+ // Publish an event to every subscriber on a channel. Returns
2949
+ // { channel, event, delivered }. event defaults to 'message'.
2950
+ publish(channel, data, opts) {
2951
+ opts = opts || {};
2952
+ return platformJSON('/v1/realtime/publish', {
2953
+ method: 'POST',
2954
+ body: JSON.stringify({
2955
+ project_id: projectId,
2956
+ channel,
2957
+ event: opts.event,
2958
+ data,
2959
+ from: opts.from,
2960
+ }),
2961
+ });
2962
+ },
2963
+ // Wait for the next event on a channel (server-side listener).
2964
+ // Used for webhook-style flows between functions. Returns the
2965
+ // envelope or null after timeout. Default timeout is 25 s.
2966
+ async subscribe(channel, opts) {
2967
+ opts = opts || {};
2968
+ const out = await platformJSON('/v1/realtime/wait', {
2969
+ method: 'POST',
2970
+ body: JSON.stringify({
2971
+ project_id: projectId,
2972
+ channel,
2973
+ timeout_ms: opts.timeout_ms,
2974
+ event: opts.event,
2975
+ }),
2976
+ });
2977
+ return out && out.data ? out.data.event : null;
2978
+ },
2979
+ // List active channels on this project (rows the platform has seen
2980
+ // in the last 10 minutes). Returns { channels: [...] }.
2981
+ channels() {
2982
+ return platformJSON('/v1/realtime/channels?project_id=' + encodeURIComponent(projectId));
2983
+ },
2984
+ // Legacy alias — same as publish but with the older { message }
2985
+ // envelope shape. Prefer publish() for new code.
2986
+ broadcast(channel, message, opts) {
2987
+ opts = opts || {};
2988
+ return platformJSON('/v1/realtime/channels/' + encodeURIComponent(channel) + '/broadcast', {
2989
+ method: 'POST',
2990
+ body: JSON.stringify({ project_id: projectId, message, from: opts.from }),
2991
+ });
2992
+ },
2993
+ meta(channel) {
2994
+ return platformJSON('/v1/realtime/channels/' + encodeURIComponent(channel) + '/meta?project_id=' + encodeURIComponent(projectId));
2995
+ },
2996
+ },
2997
+
2998
+ analytics: {
2999
+ track(event, opts) {
3000
+ opts = opts || {};
3001
+ return platformJSON('/v1/analytics/track', {
3002
+ method: 'POST',
3003
+ body: JSON.stringify({
3004
+ project_id: projectId,
3005
+ event,
3006
+ user_id: opts.user_id,
3007
+ properties: opts.properties,
3008
+ page: opts.page,
3009
+ referrer: opts.referrer,
3010
+ user_agent: opts.user_agent,
3011
+ }),
3012
+ });
3013
+ },
3014
+ query(opts) {
3015
+ opts = opts || {};
3016
+ return platformJSON('/v1/analytics/query', {
3017
+ method: 'POST',
3018
+ body: JSON.stringify({ ...opts, project_id: projectId }),
3019
+ });
3020
+ },
3021
+ },
3022
+
3023
+ // sw.render.{screenshot,pdf} — when storage opt is set, returns a
3024
+ // JSON envelope { storage_path, size_bytes, content_type }; without
3025
+ // storage, returns the raw fetch Response so callers can stream
3026
+ // the binary out (e.g. to the browser).
3027
+ render: {
3028
+ async screenshot(opts) {
3029
+ opts = opts || {};
3030
+ const body = JSON.stringify({ ...opts, project_id: projectId });
3031
+ if (opts.storage) {
3032
+ return platformJSON('/v1/render/screenshot', { method: 'POST', body });
3033
+ }
3034
+ const r = await platformFetch('/v1/render/screenshot', { method: 'POST', body });
3035
+ if (!r.ok) {
3036
+ let msg = 'screenshot failed: ' + r.status;
3037
+ try { const j = await r.json(); if (j && j.message) msg = j.message; } catch { /* noop */ }
3038
+ const err = new Error(msg); err.status = r.status; throw err;
3039
+ }
3040
+ return r;
3041
+ },
3042
+ async pdf(opts) {
3043
+ opts = opts || {};
3044
+ const body = JSON.stringify({ ...opts, project_id: projectId });
3045
+ if (opts.storage) {
3046
+ return platformJSON('/v1/render/pdf', { method: 'POST', body });
3047
+ }
3048
+ const r = await platformFetch('/v1/render/pdf', { method: 'POST', body });
3049
+ if (!r.ok) {
3050
+ let msg = 'pdf failed: ' + r.status;
3051
+ try { const j = await r.json(); if (j && j.message) msg = j.message; } catch { /* noop */ }
3052
+ const err = new Error(msg); err.status = r.status; throw err;
3053
+ }
3054
+ return r;
3055
+ },
3056
+ },
3057
+
3058
+ search: {
3059
+ createIndex(name) {
3060
+ return platformJSON('/v1/search/index', {
3061
+ method: 'POST',
3062
+ body: JSON.stringify({ project_id: projectId, name }),
3063
+ });
3064
+ },
3065
+ listIndexes() {
3066
+ return platformJSON('/v1/search/index?project_id=' + encodeURIComponent(projectId));
3067
+ },
3068
+ deleteIndex(name) {
3069
+ return platformJSON('/v1/search/index/' + encodeURIComponent(name) + '?project_id=' + encodeURIComponent(projectId), { method: 'DELETE' });
3070
+ },
3071
+ upsert(opts) {
3072
+ return platformJSON('/v1/search/upsert', {
3073
+ method: 'POST',
3074
+ body: JSON.stringify({ ...opts, project_id: projectId }),
3075
+ });
3076
+ },
3077
+ query(opts) {
3078
+ return platformJSON('/v1/search/query', {
3079
+ method: 'POST',
3080
+ body: JSON.stringify({ ...opts, project_id: projectId }),
3081
+ });
3082
+ },
3083
+ remove(opts) {
3084
+ return platformJSON('/v1/search/remove', {
3085
+ method: 'POST',
3086
+ body: JSON.stringify({ ...opts, project_id: projectId }),
3087
+ });
3088
+ },
3089
+ },
3090
+
3091
+ video: {
3092
+ uploadUrl(opts) {
3093
+ opts = opts || {};
3094
+ return platformJSON('/v1/video/upload-url', {
3095
+ method: 'POST',
3096
+ body: JSON.stringify({ ...opts, project_id: projectId }),
3097
+ });
3098
+ },
3099
+ list(opts) {
3100
+ opts = opts || {};
3101
+ const qs = '?project_id=' + encodeURIComponent(projectId) + (opts.limit ? '&limit=' + Number(opts.limit) : '');
3102
+ return platformJSON('/v1/video' + qs);
3103
+ },
3104
+ get(id) {
3105
+ return platformJSON('/v1/video/' + encodeURIComponent(id));
3106
+ },
3107
+ delete(id) {
3108
+ return platformJSON('/v1/video/' + encodeURIComponent(id), { method: 'DELETE' });
3109
+ },
3110
+ },
3111
+
3112
+ inbox: {
3113
+ listAddresses() {
3114
+ return platformJSON('/v1/inbox/addresses?project_id=' + encodeURIComponent(projectId));
3115
+ },
3116
+ createAddress(opts) {
3117
+ opts = opts || {};
3118
+ // App-created mailboxes default to kind='app' so per-user inboxes
3119
+ // minted at runtime stay out of the dashboard Email tab. Pass
3120
+ // kind:'admin' explicitly to surface one there.
3121
+ const body = { kind: 'app', ...opts, project_id: projectId };
3122
+ return platformJSON('/v1/inbox/addresses', {
3123
+ method: 'POST',
3124
+ body: JSON.stringify(body),
3125
+ });
3126
+ },
3127
+ listAppAddresses() {
3128
+ return platformJSON('/v1/inbox/addresses?kind=app&project_id=' + encodeURIComponent(projectId));
3129
+ },
3130
+ deleteAddress(id) {
3131
+ return platformJSON('/v1/inbox/addresses/' + encodeURIComponent(id), { method: 'DELETE' });
3132
+ },
3133
+ list(opts) {
3134
+ opts = opts || {};
3135
+ const params = ['project_id=' + encodeURIComponent(projectId)];
3136
+ if (opts.address_id) params.push('address_id=' + encodeURIComponent(opts.address_id));
3137
+ if (opts.limit) params.push('limit=' + Number(opts.limit));
3138
+ if (opts.unread) params.push('unread=1');
3139
+ if (opts.q) params.push('q=' + encodeURIComponent(String(opts.q)));
3140
+ if (opts.include_spam) params.push('include_spam=1');
3141
+ return platformJSON('/v1/inbox?' + params.join('&'));
3142
+ },
3143
+ get(id, opts) {
3144
+ opts = opts || {};
3145
+ const qs = opts.include_html ? '?include=html' : '';
3146
+ return platformJSON('/v1/inbox/' + encodeURIComponent(id) + qs);
3147
+ },
3148
+ // Returns a Response — caller can stream/redirect/serve.
3149
+ // Use .arrayBuffer() / .blob() / .body to consume.
3150
+ raw(id) {
3151
+ return platformFetch('/v1/inbox/' + encodeURIComponent(id) + '/raw');
3152
+ },
3153
+ attachment(id, idx) {
3154
+ return platformFetch('/v1/inbox/' + encodeURIComponent(id) + '/attachments/' + Number(idx));
3155
+ },
3156
+ markRead(id, read) {
3157
+ const body = read === false ? { read: false } : { read: true };
3158
+ return platformJSON('/v1/inbox/' + encodeURIComponent(id) + '/read', {
3159
+ method: 'POST',
3160
+ body: JSON.stringify(body),
3161
+ });
3162
+ },
3163
+ delete(id) {
3164
+ return platformJSON('/v1/inbox/' + encodeURIComponent(id), { method: 'DELETE' });
3165
+ },
3166
+ reply(id, opts) {
3167
+ opts = opts || {};
3168
+ return platformJSON('/v1/inbox/' + encodeURIComponent(id) + '/reply', {
3169
+ method: 'POST',
3170
+ body: JSON.stringify(opts),
3171
+ });
3172
+ },
3173
+ send(addressId, opts) {
3174
+ // Compose new — addressId is one of sw.inbox.listAddresses().
3175
+ // opts: { to, subject, body | text | html }
3176
+ opts = opts || {};
3177
+ return platformJSON('/v1/inbox/addresses/' + encodeURIComponent(addressId) + '/send', {
3178
+ method: 'POST',
3179
+ body: JSON.stringify(opts),
3180
+ });
3181
+ },
3182
+ threads(opts) {
3183
+ opts = opts || {};
3184
+ const params = ['project_id=' + encodeURIComponent(projectId)];
3185
+ if (opts.address_id) params.push('address_id=' + encodeURIComponent(opts.address_id));
3186
+ if (opts.limit) params.push('limit=' + Number(opts.limit));
3187
+ if (opts.include_spam) params.push('include_spam=1');
3188
+ return platformJSON('/v1/inbox/threads?' + params.join('&'));
3189
+ },
3190
+ thread(root) {
3191
+ return platformJSON('/v1/inbox/threads/by-root?project_id=' +
3192
+ encodeURIComponent(projectId) + '&root=' + encodeURIComponent(root));
3193
+ },
3194
+ rules: {
3195
+ list(opts) {
3196
+ opts = opts || {};
3197
+ const params = ['project_id=' + encodeURIComponent(projectId)];
3198
+ if (opts.address_id) params.push('address_id=' + encodeURIComponent(opts.address_id));
3199
+ return platformJSON('/v1/inbox/rules?' + params.join('&'));
3200
+ },
3201
+ create(opts) {
3202
+ opts = opts || {};
3203
+ return platformJSON('/v1/inbox/rules', {
3204
+ method: 'POST',
3205
+ body: JSON.stringify({ ...opts, project_id: projectId }),
3206
+ });
3207
+ },
3208
+ delete(id) {
3209
+ return platformJSON('/v1/inbox/rules/' + encodeURIComponent(id), { method: 'DELETE' });
3210
+ },
3211
+ },
3212
+ },
3213
+
3214
+ // sw.connect.* — read-side third-party connections (tsk_1b20).
3215
+ // Opposite of sw.payments: read another account's data, don't accept money.
3216
+ connect: {
3217
+ stripe: {
3218
+ // Returns { url } — send the creator's browser there to authorize
3219
+ // read-only access to their existing Stripe. opts.return_url is where
3220
+ // Stripe bounces them back (gets ?connect=success|error appended).
3221
+ connect(opts) {
3222
+ opts = opts || {};
3223
+ return platformJSON('/v1/connect/stripe/connect', {
3224
+ method: 'POST',
3225
+ body: JSON.stringify({ project_id: projectId, return_url: opts.return_url || opts.returnUrl }),
3226
+ });
3227
+ },
3228
+ // { connected, account_id, scope, status, connected_at }
3229
+ status() {
3230
+ return platformJSON('/v1/connect/stripe/status?project_id=' + encodeURIComponent(projectId));
3231
+ },
3232
+ // { data: [{ email, tier, status, current_period_end, ... }], next_cursor }
3233
+ // Reads the platform-cached list (kept fresh by the Connect webhook).
3234
+ subscribers(opts) {
3235
+ opts = opts || {};
3236
+ const params = new URLSearchParams({ project_id: projectId });
3237
+ if (opts.status) params.set('status', opts.status);
3238
+ if (opts.limit) params.set('limit', String(opts.limit));
3239
+ if (opts.cursor) params.set('cursor', opts.cursor);
3240
+ return platformJSON('/v1/connect/stripe/subscribers?' + params.toString());
3241
+ },
3242
+ disconnect() {
3243
+ return platformJSON('/v1/connect/stripe/disconnect', {
3244
+ method: 'POST',
3245
+ body: JSON.stringify({ project_id: projectId }),
3246
+ });
3247
+ },
3248
+ },
3249
+ },
3250
+
3251
+ payments: {
3252
+ onboard(opts) {
3253
+ opts = opts || {};
3254
+ return platformJSON('/v1/payments/onboard', {
3255
+ method: 'POST',
3256
+ body: JSON.stringify(opts),
3257
+ });
3258
+ },
3259
+ status(opts) {
3260
+ opts = opts || {};
3261
+ return platformJSON('/v1/payments/status' + (opts.refresh ? '?refresh=1' : ''));
3262
+ },
3263
+ checkout(opts) {
3264
+ opts = opts || {};
3265
+ const env = opts.env || projectEnv;
3266
+ return platformJSON('/v1/payments/checkout', {
3267
+ method: 'POST',
3268
+ body: JSON.stringify({ ...opts, project_id: projectId, env }),
3269
+ });
3270
+ },
3271
+ checkoutForUser(userId, opts) {
3272
+ opts = opts || {};
3273
+ if (!userId) {
3274
+ const err = new Error('sw.payments.checkoutForUser: userId is required');
3275
+ err.code = 'VALIDATION_ERROR';
3276
+ throw err;
3277
+ }
3278
+ if (!opts.plan) {
3279
+ const err = new Error('sw.payments.checkoutForUser: opts.plan is required');
3280
+ err.code = 'VALIDATION_ERROR';
3281
+ throw err;
3282
+ }
3283
+ const metadata = {
3284
+ ...(opts.metadata || {}),
3285
+ app_user_id: String(userId),
3286
+ plan: String(opts.plan),
3287
+ };
3288
+ const body = { ...opts, metadata };
3289
+ delete body.plan;
3290
+ return this.checkout(body);
3291
+ },
3292
+ dashboardLink() {
3293
+ return platformJSON('/v1/payments/dashboard-link');
3294
+ },
3295
+ refund(opts) {
3296
+ opts = opts || {};
3297
+ const env = opts.env || projectEnv;
3298
+ return platformJSON('/v1/payments/refund', {
3299
+ method: 'POST',
3300
+ body: JSON.stringify({ ...opts, project_id: projectId, env }),
3301
+ });
3302
+ },
3303
+ cancelSubscription(opts) {
3304
+ opts = opts || {};
3305
+ const env = opts.env || projectEnv;
3306
+ return platformJSON('/v1/payments/cancel-subscription', {
3307
+ method: 'POST',
3308
+ body: JSON.stringify({ ...opts, project_id: projectId, env }),
3309
+ });
3310
+ },
3311
+ transactions(opts) {
3312
+ opts = opts || {};
3313
+ const env = opts.env || projectEnv;
3314
+ const params = new URLSearchParams({ project_id: projectId, env });
3315
+ if (opts.limit) params.set('limit', String(opts.limit));
3316
+ if (opts.starting_after || opts.startingAfter) {
3317
+ params.set('starting_after', opts.starting_after || opts.startingAfter);
3318
+ }
3319
+ return platformJSON('/v1/payments/transactions?' + params.toString());
3320
+ },
3321
+ portal(opts) {
3322
+ opts = opts || {};
3323
+ const env = opts.env || projectEnv;
3324
+ return platformJSON('/v1/payments/portal', {
3325
+ method: 'POST',
3326
+ body: JSON.stringify({
3327
+ project_id: projectId,
3328
+ env,
3329
+ customer_id: opts.customer_id || opts.customerId,
3330
+ return_url: opts.return_url || opts.returnUrl,
3331
+ }),
3332
+ });
3333
+ },
3334
+ events(opts) {
3335
+ opts = opts || {};
3336
+ const params = new URLSearchParams({ project_id: projectId });
3337
+ if (opts.limit) params.set('limit', String(opts.limit));
3338
+ if (opts.before) params.set('before', String(opts.before));
3339
+ if (opts.type) params.set('type', opts.type);
3340
+ return platformJSON('/v1/payments/events?' + params.toString());
3341
+ },
3342
+ },
3343
+
3344
+ calls: {
3345
+ newSession(opts) {
3346
+ opts = opts || {};
3347
+ return platformJSON('/v1/calls/sessions', {
3348
+ method: 'POST',
3349
+ body: JSON.stringify({ ...opts, project_id: projectId }),
3350
+ });
3351
+ },
3352
+ },
3353
+
3354
+ tasks: {
3355
+ // sw.tasks.create({ title, description?, status?, priority?, assignee?, labels?, due_at?, area?, parent_id? })
3356
+ create(opts) {
3357
+ opts = opts || {};
3358
+ return platformJSON('/v1/tasks', {
3359
+ method: 'POST',
3360
+ body: JSON.stringify({ ...opts, project_id: projectId }),
3361
+ });
3362
+ },
3363
+ // sw.tasks.list({ status?, assignee?, area?, parent_id?, limit? })
3364
+ // Pass parent_id: 'null' to fetch only top-level tasks (no parent).
3365
+ list(opts) {
3366
+ opts = opts || {};
3367
+ const q = new URLSearchParams({ project_id: projectId });
3368
+ if (opts.status) q.set('status', opts.status);
3369
+ if (opts.assignee) q.set('assignee', opts.assignee);
3370
+ if (opts.area) q.set('area', opts.area);
3371
+ if (opts.parent_id !== undefined && opts.parent_id !== null) q.set('parent_id', String(opts.parent_id));
3372
+ if (opts.limit) q.set('limit', String(opts.limit));
3373
+ return platformJSON('/v1/tasks?' + q.toString());
3374
+ },
3375
+ // sw.tasks.get(id) → { ...task, comments: [...] }
3376
+ get(id) {
3377
+ return platformJSON('/v1/tasks/' + encodeURIComponent(id) + '?project_id=' + encodeURIComponent(projectId));
3378
+ },
3379
+ // sw.tasks.update(id, { title?, description?, status?, priority?, assignee?, labels?, due_at?, area?, parent_id? })
3380
+ update(id, opts) {
3381
+ return platformJSON('/v1/tasks/' + encodeURIComponent(id), {
3382
+ method: 'PATCH',
3383
+ body: JSON.stringify({ ...(opts || {}), project_id: projectId }),
3384
+ });
3385
+ },
3386
+ // sw.tasks.delete(id)
3387
+ delete(id) {
3388
+ return platformJSON('/v1/tasks/' + encodeURIComponent(id) + '?project_id=' + encodeURIComponent(projectId), { method: 'DELETE' });
3389
+ },
3390
+ // sw.tasks.comment(id, body, author?)
3391
+ comment(id, body, author) {
3392
+ return platformJSON('/v1/tasks/' + encodeURIComponent(id) + '/comments', {
3393
+ method: 'POST',
3394
+ body: JSON.stringify({ project_id: projectId, body: body, ...(author ? { author: author } : {}) }),
3395
+ });
3396
+ },
3397
+ // sw.tasks.settings.get() / sw.tasks.settings.update({ webhook_url?, notify_email? })
3398
+ // — webhook_secret is returned exactly once when first set; store it
3399
+ // server-side to verify the X-Somewhere-Signature header on incoming
3400
+ // task webhook POSTs.
3401
+ settings: {
3402
+ get() {
3403
+ return platformJSON('/v1/tasks/settings?project_id=' + encodeURIComponent(projectId));
3404
+ },
3405
+ update(opts) {
3406
+ return platformJSON('/v1/tasks/settings', {
3407
+ method: 'PATCH',
3408
+ body: JSON.stringify({ project_id: projectId, ...opts }),
3409
+ });
3410
+ },
3411
+ },
3412
+ },
3413
+
3414
+ cron: {
3415
+ create(opts) {
3416
+ return platformJSON('/v1/cron', {
3417
+ method: 'POST',
3418
+ body: JSON.stringify({ ...opts, project_id: projectId }),
3419
+ });
3420
+ },
3421
+ list() {
3422
+ return platformJSON('/v1/cron?project_id=' + encodeURIComponent(projectId));
3423
+ },
3424
+ update(id, opts) {
3425
+ return platformJSON('/v1/cron/' + encodeURIComponent(id), {
3426
+ method: 'PATCH',
3427
+ body: JSON.stringify(opts || {}),
3428
+ });
3429
+ },
3430
+ delete(id) {
3431
+ return platformJSON('/v1/cron/' + encodeURIComponent(id), { method: 'DELETE' });
3432
+ },
3433
+ },
3434
+
3435
+ rateLimit: {
3436
+ // sw.rateLimit.check(key, max, windowSeconds) → { allowed, remaining, reset, ... }
3437
+ // Fixed-window counter, scoped per (project, key). On allowed=false the
3438
+ // caller should return 429.
3439
+ check(key, max, windowSeconds) {
3440
+ return platformJSON('/v1/rate-limit/check', {
3441
+ method: 'POST',
3442
+ body: JSON.stringify({
3443
+ project_id: projectId,
3444
+ key,
3445
+ max,
3446
+ window_seconds: windowSeconds,
3447
+ }),
3448
+ });
3449
+ },
3450
+ },
3451
+
3452
+ web: {
3453
+ // sw.web.scrape(url, opts?) — fetch a URL, return its content as
3454
+ // markdown by default. opts: { formats?, only_main?, wait_for? }
3455
+ scrape(url, opts) {
3456
+ opts = opts || {};
3457
+ return platformJSON('/v1/web/scrape', {
3458
+ method: 'POST',
3459
+ body: JSON.stringify({
3460
+ project_id: projectId,
3461
+ url,
3462
+ ...(opts.formats !== undefined ? { formats: opts.formats } : {}),
3463
+ ...(opts.only_main !== undefined ? { only_main: opts.only_main } : {}),
3464
+ ...(opts.wait_for !== undefined ? { wait_for: opts.wait_for } : {}),
3465
+ }),
3466
+ });
3467
+ },
3468
+ // sw.web.search(query, opts?) — search the public web by keyword.
3469
+ // Returns { query, results: [{ url, title, description, age, source }], count }
3470
+ // opts: { count?, country?, freshness?, safesearch? }
3471
+ search(query, opts) {
3472
+ opts = opts || {};
3473
+ return platformJSON('/v1/web/search', {
3474
+ method: 'POST',
3475
+ body: JSON.stringify({
3476
+ project_id: projectId,
3477
+ query,
3478
+ ...(opts.count !== undefined ? { count: opts.count } : {}),
3479
+ ...(opts.country !== undefined ? { country: opts.country } : {}),
3480
+ ...(opts.freshness !== undefined ? { freshness: opts.freshness } : {}),
3481
+ ...(opts.safesearch !== undefined ? { safesearch: opts.safesearch } : {}),
3482
+ }),
3483
+ });
3484
+ },
3485
+ },
3486
+
3487
+ // sw.notifications — unified notify primitive (tsk_594eddbf).
3488
+ // Fans out across the channels the app uses: push (sw.push), the
3489
+ // app's own in-app bell (a _notifications table in the project DB,
3490
+ // auto-created on first use), and optionally email when the dev
3491
+ // passes an explicit email address. Safe to call from an LLM tool
3492
+ // — write your own dedup if you need it; this layer is the
3493
+ // primitive, not the policy.
3494
+ notifications: (function () {
3495
+ const TABLE_SQL = "CREATE TABLE IF NOT EXISTS _notifications (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT, body TEXT, url TEXT, read INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS idx_notifications_user ON _notifications(user_id, created_at DESC);";
3496
+ let ensured = false;
3497
+ async function ensureTable() {
3498
+ if (ensured) return;
3499
+ if (!DB || typeof DB.exec !== 'function') return;
3500
+ try { await DB.exec(TABLE_SQL); ensured = true; } catch (err) {
3501
+ // exec doesn't support multi-statement on some D1 versions;
3502
+ // try one at a time.
3503
+ try {
3504
+ await DB.prepare("CREATE TABLE IF NOT EXISTS _notifications (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT, body TEXT, url TEXT, read INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL)").run();
3505
+ await DB.prepare("CREATE INDEX IF NOT EXISTS idx_notifications_user ON _notifications(user_id, created_at DESC)").run();
3506
+ ensured = true;
3507
+ } catch (e2) {
3508
+ console.error('sw.notifications: ensureTable failed:', e2 && e2.message ? e2.message : e2);
3509
+ }
3510
+ }
3511
+ }
3512
+
3513
+ return {
3514
+ // send(userId, { title, body?, url?, email?, channels? }) →
3515
+ // { bell?, push?, email? } with per-channel result or error.
3516
+ // channels defaults to ['bell','push']. Pass channels:['email']
3517
+ // (with email:'addr') to send only email, etc.
3518
+ async send(userId, opts) {
3519
+ opts = opts || {};
3520
+ if (!userId) {
3521
+ const err = new Error('sw.notifications.send: userId is required'); err.code = 'VALIDATION_ERROR'; throw err;
3522
+ }
3523
+ const title = opts.title || '';
3524
+ const body = opts.body || '';
3525
+ const url = opts.url || null;
3526
+ const channels = Array.isArray(opts.channels) && opts.channels.length > 0
3527
+ ? opts.channels
3528
+ : ['bell', 'push'];
3529
+ const results = {};
3530
+
3531
+ if (channels.includes('bell')) {
3532
+ try {
3533
+ await ensureTable();
3534
+ const id = 'ntf_' + crypto.randomUUID().replace(/-/g, '').slice(0, 16);
3535
+ await DB.prepare(
3536
+ 'INSERT INTO _notifications (id, user_id, title, body, url, created_at) VALUES (?, ?, ?, ?, ?, ?)'
3537
+ ).bind(id, String(userId), title, body, url, Date.now()).run();
3538
+ results.bell = { id, ok: true };
3539
+ } catch (err) {
3540
+ results.bell = { ok: false, error: err && err.message ? err.message : 'bell write failed' };
3541
+ }
3542
+ }
3543
+
3544
+ if (channels.includes('push')) {
3545
+ try {
3546
+ const r = await platformJSON('/v1/push/send', {
3547
+ method: 'POST',
3548
+ body: JSON.stringify({
3549
+ project_id: projectId,
3550
+ payload: { title, body, url },
3551
+ user_id: String(userId),
3552
+ }),
3553
+ });
3554
+ results.push = { ok: true, ...r };
3555
+ } catch (err) {
3556
+ results.push = { ok: false, error: err && err.message ? err.message : 'push send failed', code: err && err.code };
3557
+ }
3558
+ }
3559
+
3560
+ if (channels.includes('email')) {
3561
+ if (!opts.email || typeof opts.email !== 'string') {
3562
+ results.email = { ok: false, error: "email channel requires opts.email (address). The user-table lookup is the dev's call — see sw.auth.fromRequest({ enrichFrom })." };
3563
+ } else {
3564
+ try {
3565
+ const r = await platformJSON('/v1/email/send', {
3566
+ method: 'POST',
3567
+ body: JSON.stringify({
3568
+ project_id: projectId,
3569
+ to: opts.email,
3570
+ subject: title,
3571
+ html: body || '',
3572
+ ...(opts.from ? { from: opts.from } : {}),
3573
+ }),
3574
+ });
3575
+ results.email = { ok: true, ...r };
3576
+ } catch (err) {
3577
+ results.email = { ok: false, error: err && err.message ? err.message : 'email send failed', code: err && err.code };
3578
+ }
3579
+ }
3580
+ }
3581
+
3582
+ return results;
3583
+ },
3584
+
3585
+ // Bell-only read helpers — list / mark read / unread count.
3586
+ async list(userId, opts) {
3587
+ opts = opts || {};
3588
+ if (!userId) return { notifications: [], count: 0 };
3589
+ await ensureTable();
3590
+ const limit = Math.max(1, Math.min(200, Number(opts.limit) || 50));
3591
+ const where = opts.unread_only ? 'WHERE user_id = ? AND read = 0' : 'WHERE user_id = ?';
3592
+ const r = await DB.prepare(
3593
+ 'SELECT id, title, body, url, read, created_at FROM _notifications ' + where + ' ORDER BY created_at DESC LIMIT ?'
3594
+ ).bind(String(userId), limit).all();
3595
+ return { notifications: r.results || [], count: (r.results || []).length };
3596
+ },
3597
+ async unreadCount(userId) {
3598
+ if (!userId) return 0;
3599
+ await ensureTable();
3600
+ const r = await DB.prepare(
3601
+ 'SELECT COUNT(*) AS n FROM _notifications WHERE user_id = ? AND read = 0'
3602
+ ).bind(String(userId)).first();
3603
+ return r && typeof r.n === 'number' ? r.n : 0;
3604
+ },
3605
+ async markRead(notificationId) {
3606
+ if (!notificationId) return { ok: false, error: 'notificationId required' };
3607
+ await ensureTable();
3608
+ const res = await DB.prepare(
3609
+ 'UPDATE _notifications SET read = 1 WHERE id = ?'
3610
+ ).bind(String(notificationId)).run();
3611
+ return { ok: true, changes: (res.meta && res.meta.changes) || 0 };
3612
+ },
3613
+ async markAllRead(userId) {
3614
+ if (!userId) return { ok: false, error: 'userId required' };
3615
+ await ensureTable();
3616
+ const res = await DB.prepare(
3617
+ 'UPDATE _notifications SET read = 1 WHERE user_id = ? AND read = 0'
3618
+ ).bind(String(userId)).run();
3619
+ return { ok: true, changes: (res.meta && res.meta.changes) || 0 };
3620
+ },
3621
+ };
3622
+ })(),
3623
+
3624
+ push: {
3625
+ // sw.push.vapidPublicKey() → { vapid_public_key }
3626
+ // The browser passes vapid_public_key to PushManager.subscribe().
3627
+ vapidPublicKey() {
3628
+ return platformJSON('/v1/push/vapid-public-key?project_id=' + encodeURIComponent(projectId));
3629
+ },
3630
+ // sw.push.subscribe({ subscription, user_id? })
3631
+ // Persists the subscription object the browser handed back from
3632
+ // PushManager.subscribe().
3633
+ subscribe(opts) {
3634
+ opts = opts || {};
3635
+ return platformJSON('/v1/push/subscribe', {
3636
+ method: 'POST',
3637
+ body: JSON.stringify({
3638
+ project_id: projectId,
3639
+ subscription: opts.subscription,
3640
+ user_id: opts.user_id ?? opts.userId,
3641
+ }),
3642
+ });
3643
+ },
3644
+ // sw.push.unsubscribe({ endpoint })
3645
+ unsubscribe(opts) {
3646
+ opts = opts || {};
3647
+ return platformJSON('/v1/push/unsubscribe', {
3648
+ method: 'POST',
3649
+ body: JSON.stringify({
3650
+ project_id: projectId,
3651
+ endpoint: opts.endpoint,
3652
+ }),
3653
+ });
3654
+ },
3655
+ // sw.push.send({ payload, user_id? | endpoint?, ttl? })
3656
+ // payload may be a string or any JSON-serializable value.
3657
+ send(opts) {
3658
+ opts = opts || {};
3659
+ return platformJSON('/v1/push/send', {
3660
+ method: 'POST',
3661
+ body: JSON.stringify({
3662
+ project_id: projectId,
3663
+ payload: opts.payload,
3664
+ user_id: opts.user_id ?? opts.userId,
3665
+ endpoint: opts.endpoint,
3666
+ ttl: opts.ttl,
3667
+ }),
3668
+ });
3669
+ },
3670
+ },
3671
+ };
3672
+ // sw and ctx are aliases — same object, both names work forever.
3673
+ sw.sw = sw;
3674
+ sw.ctx = sw;
3675
+ return sw;
3676
+ }
3677
+
3678
+ export { buildPlatformContext };