@somewhere-tech/cli 0.11.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.
- package/dist/commands/deploy.js +25 -5
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.js +70 -4
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/exec.js +109 -0
- package/dist/commands/exec.js.map +1 -0
- package/dist/commands/pull.js +8 -6
- package/dist/commands/pull.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/build-errors.js +147 -0
- package/dist/lib/build-errors.js.map +1 -0
- package/dist/lib/client.js +48 -4
- package/dist/lib/client.js.map +1 -1
- package/dist/local/envfile.js +40 -0
- package/dist/local/envfile.js.map +1 -0
- package/dist/local/loader.js +103 -0
- package/dist/local/loader.js.map +1 -0
- package/dist/local/router.js +114 -0
- package/dist/local/router.js.map +1 -0
- package/dist/local/runtime.js +268 -0
- package/dist/local/runtime.js.map +1 -0
- package/dist/local/server.js +169 -0
- package/dist/local/server.js.map +1 -0
- package/package.json +8 -4
- package/runtime/platform-context.mjs +3678 -0
- package/runtime/sw-init.mjs +237 -0
|
@@ -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 };
|