@markusylisiurunen/tau 0.3.9 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +37 -1
  2. package/dist/core/cli.js +3 -0
  3. package/dist/core/cli.js.map +1 -1
  4. package/dist/core/config/index.js +1 -1
  5. package/dist/core/config/index.js.map +1 -1
  6. package/dist/core/config/schema.js +68 -0
  7. package/dist/core/config/schema.js.map +1 -1
  8. package/dist/core/index.js +2 -0
  9. package/dist/core/index.js.map +1 -1
  10. package/dist/core/nook/cli.js +196 -0
  11. package/dist/core/nook/cli.js.map +1 -0
  12. package/dist/core/nook/client.js +164 -0
  13. package/dist/core/nook/client.js.map +1 -0
  14. package/dist/core/nook/deploy.js +116 -0
  15. package/dist/core/nook/deploy.js.map +1 -0
  16. package/dist/core/nook/index.js +5 -0
  17. package/dist/core/nook/index.js.map +1 -0
  18. package/dist/core/nook/setup.js +270 -0
  19. package/dist/core/nook/setup.js.map +1 -0
  20. package/dist/core/nook/validation.js +149 -0
  21. package/dist/core/nook/validation.js.map +1 -0
  22. package/dist/core/session/session_engine.js +20 -4
  23. package/dist/core/session/session_engine.js.map +1 -1
  24. package/dist/core/telegram/adapter.js +88 -5
  25. package/dist/core/telegram/adapter.js.map +1 -1
  26. package/dist/core/telegram/session_manager.js +11 -0
  27. package/dist/core/telegram/session_manager.js.map +1 -1
  28. package/dist/core/tools/catalog.js +2 -0
  29. package/dist/core/tools/catalog.js.map +1 -1
  30. package/dist/core/tools/nook.js +175 -0
  31. package/dist/core/tools/nook.js.map +1 -0
  32. package/dist/core/tools/tool_names.js +2 -0
  33. package/dist/core/tools/tool_names.js.map +1 -1
  34. package/dist/core/version.js +1 -1
  35. package/dist/core/version.js.map +1 -1
  36. package/dist/main.js +23 -1
  37. package/dist/main.js.map +1 -1
  38. package/dist/nook/README.md +96 -0
  39. package/dist/nook/worker/index.js +767 -0
  40. package/dist/nook/worker/index.js.map +1 -0
  41. package/package.json +3 -2
@@ -0,0 +1,767 @@
1
+ import { createRemoteJWKSet, jwtVerify } from "jose";
2
+ const RESERVED_PREFIX = "/__nook";
3
+ const DEPLOY_SESSION_MS = 15 * 60 * 1000;
4
+ const MAX_PENDING_DEPLOYMENTS = 3;
5
+ const MAX_FILES = 1_000;
6
+ const MAX_FILE_BYTES = 10 * 1024 * 1024;
7
+ const MAX_TOTAL_BYTES = 100 * 1024 * 1024;
8
+ const MAX_PATH_LENGTH = 512;
9
+ const MAX_KV_KEY_LENGTH = 256;
10
+ const MAX_KV_VALUE_BYTES = 64 * 1024;
11
+ const MAX_KV_KEYS = 1_000;
12
+ const MAX_KV_TOTAL_BYTES = 5 * 1024 * 1024;
13
+ const NOOK_CLIENT_SCRIPT = '<script src="/__nook/client.js"></script>';
14
+ const DEPLOYED_ASSET_CACHE_CONTROL = "public, no-cache, must-revalidate";
15
+ const accessJwksCache = new Map();
16
+ const SKILL_MARKDOWN = `# Nook
17
+
18
+ Nook deploys static mini-apps to wildcard subdomains and gives each site same-origin JSON KV through window.nook.
19
+
20
+ Deploy a finished static directory only. The directory must contain /index.html, must not contain hidden files or symlinks, and must not contain files under /__nook/.
21
+
22
+ Sites are served at https://<site>.<nook-domain>/. App URLs are subdomains only.
23
+
24
+ The Worker injects /__nook/client.js into HTML. Browser code can use:
25
+
26
+ \`\`\`js
27
+ await window.nook.kv.put("settings", { theme: "dark" });
28
+ const settings = await window.nook.kv.get("settings");
29
+ await window.nook.kv.delete("settings");
30
+ const keys = await window.nook.kv.list({ prefix: "todos/" });
31
+ \`\`\`
32
+
33
+ KV is per-site, JSON-only, and survives redeploys. Public deployments expose public writable per-site KV. Private deployments require Cloudflare Access identity.
34
+ `;
35
+ const CLIENT_JS = `(() => {
36
+ async function request(path, options = {}) {
37
+ const response = await fetch(path, {
38
+ ...options,
39
+ headers: {
40
+ ...(options.body !== undefined ? { "content-type": "application/json" } : {}),
41
+ ...(options.headers || {})
42
+ }
43
+ });
44
+ if (!response.ok) {
45
+ let message = response.statusText;
46
+ try {
47
+ const payload = await response.json();
48
+ message = payload && payload.error && payload.error.message ? payload.error.message : message;
49
+ } catch {}
50
+ throw new Error(message);
51
+ }
52
+ if (response.status === 204) return null;
53
+ return await response.json();
54
+ }
55
+ window.nook = {
56
+ kv: {
57
+ async get(key) {
58
+ const payload = await request("/__nook/kv/" + encodeURIComponent(key));
59
+ return payload.value;
60
+ },
61
+ async put(key, value) {
62
+ await request("/__nook/kv/" + encodeURIComponent(key), {
63
+ method: "PUT",
64
+ body: JSON.stringify(value)
65
+ });
66
+ },
67
+ async delete(key) {
68
+ await request("/__nook/kv/" + encodeURIComponent(key), { method: "DELETE" });
69
+ },
70
+ async list(options = {}) {
71
+ const url = new URL("/__nook/kv", window.location.origin);
72
+ if (options.prefix) url.searchParams.set("prefix", options.prefix);
73
+ const payload = await request(url.pathname + url.search);
74
+ return payload.keys;
75
+ }
76
+ }
77
+ };
78
+ })();`;
79
+ function json(data, status = 200) {
80
+ return new Response(JSON.stringify(data), {
81
+ status,
82
+ headers: { "content-type": "application/json; charset=utf-8" },
83
+ });
84
+ }
85
+ function error(code, message, status) {
86
+ return json({ error: { code, message } }, status);
87
+ }
88
+ function now() {
89
+ return new Date().toISOString();
90
+ }
91
+ function randomId(prefix) {
92
+ const bytes = crypto.getRandomValues(new Uint8Array(12));
93
+ return `${prefix}_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
94
+ }
95
+ function accessTokenFromRequest(request) {
96
+ const headerToken = request.headers.get("Cf-Access-Jwt-Assertion");
97
+ if (headerToken?.trim())
98
+ return headerToken.trim();
99
+ const cookie = request.headers.get("Cookie") ?? "";
100
+ for (const segment of cookie.split(";")) {
101
+ const [name, ...rest] = segment.trim().split("=");
102
+ if (name === "CF_Authorization")
103
+ return rest.join("=");
104
+ }
105
+ return undefined;
106
+ }
107
+ function accessJwks(teamDomain) {
108
+ let jwks = accessJwksCache.get(teamDomain);
109
+ if (!jwks) {
110
+ jwks = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`));
111
+ accessJwksCache.set(teamDomain, jwks);
112
+ }
113
+ return jwks;
114
+ }
115
+ function stringClaim(payload, claim) {
116
+ const value = payload[claim];
117
+ return typeof value === "string" && value ? value : undefined;
118
+ }
119
+ function accessActor(payload) {
120
+ return (stringClaim(payload, "email") ??
121
+ stringClaim(payload, "common_name") ??
122
+ payload.sub ??
123
+ "access-user");
124
+ }
125
+ async function verifyAccessJwt(token, env) {
126
+ const teamDomain = env.NOOK_ACCESS_TEAM_DOMAIN?.replace(/\/+$/, "");
127
+ const audience = env.NOOK_ACCESS_AUD?.trim();
128
+ if (!teamDomain || !audience) {
129
+ throw new ErrorResponse("access_not_configured", "Cloudflare Access is not configured", 500);
130
+ }
131
+ try {
132
+ const { payload } = await jwtVerify(token, accessJwks(teamDomain), {
133
+ issuer: teamDomain,
134
+ audience,
135
+ algorithms: ["RS256"],
136
+ });
137
+ if (!payload.exp) {
138
+ throw new ErrorResponse("unauthorized", "Cloudflare Access JWT has invalid claims", 401);
139
+ }
140
+ return { actor: accessActor(payload) };
141
+ }
142
+ catch (err) {
143
+ if (err instanceof ErrorResponse)
144
+ throw err;
145
+ throw new ErrorResponse("unauthorized", "Invalid Cloudflare Access JWT", 401);
146
+ }
147
+ }
148
+ async function requireAccessIdentity(request, env) {
149
+ const token = accessTokenFromRequest(request);
150
+ if (!token)
151
+ throw new ErrorResponse("unauthorized", "Authentication required", 401);
152
+ return await verifyAccessJwt(token, env);
153
+ }
154
+ async function optionalAccessIdentity(request, env) {
155
+ const token = accessTokenFromRequest(request);
156
+ if (!token)
157
+ return { actor: "anonymous" };
158
+ try {
159
+ return await verifyAccessJwt(token, env);
160
+ }
161
+ catch {
162
+ return { actor: "anonymous" };
163
+ }
164
+ }
165
+ function validateSlug(slug) {
166
+ if (!/^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/.test(slug))
167
+ return "invalid slug";
168
+ if (new Set(["admin", "api", "assets", "login", "logout", "nook", "quick", "static", "www"]).has(slug)) {
169
+ return "reserved slug";
170
+ }
171
+ return undefined;
172
+ }
173
+ function normalizeAssetPath(pathname) {
174
+ if (!pathname ||
175
+ pathname.includes("\0") ||
176
+ !pathname.startsWith("/") ||
177
+ pathname.startsWith("//")) {
178
+ return undefined;
179
+ }
180
+ const parts = pathname.split("/");
181
+ const out = [];
182
+ for (const part of parts) {
183
+ if (!part || part === ".")
184
+ continue;
185
+ if (part === "..")
186
+ return undefined;
187
+ out.push(part);
188
+ }
189
+ const normalized = `/${out.join("/")}`;
190
+ if (normalized === RESERVED_PREFIX || normalized.startsWith(`${RESERVED_PREFIX}/`))
191
+ return undefined;
192
+ if (normalized.length > MAX_PATH_LENGTH)
193
+ return undefined;
194
+ return normalized;
195
+ }
196
+ function validateManifest(files) {
197
+ if (!Array.isArray(files) || files.length === 0)
198
+ return "manifest must include files";
199
+ if (files.length > MAX_FILES)
200
+ return `deployment exceeds ${MAX_FILES} files`;
201
+ const paths = new Set();
202
+ let total = 0;
203
+ let hasIndex = false;
204
+ for (const file of files) {
205
+ const normalized = normalizeAssetPath(file.path);
206
+ if (!normalized || normalized !== file.path)
207
+ return `invalid asset path ${file.path}`;
208
+ if (paths.has(file.path))
209
+ return `duplicate asset path ${file.path}`;
210
+ if (file.path.split("/").some((segment) => segment.startsWith("."))) {
211
+ return `hidden deploy path ${file.path} is not allowed`;
212
+ }
213
+ if (typeof file.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(file.sha256)) {
214
+ return `invalid sha256 for ${file.path}`;
215
+ }
216
+ paths.add(file.path);
217
+ if (file.path === "/index.html")
218
+ hasIndex = true;
219
+ if (!Number.isInteger(file.sizeBytes) || file.sizeBytes < 0)
220
+ return `invalid size for ${file.path}`;
221
+ if (file.sizeBytes > MAX_FILE_BYTES)
222
+ return `${file.path} exceeds max file size`;
223
+ total += file.sizeBytes;
224
+ if (typeof file.contentType !== "string" || !file.contentType)
225
+ return `invalid content type for ${file.path}`;
226
+ }
227
+ if (!hasIndex)
228
+ return "root index.html is required";
229
+ if (total > MAX_TOTAL_BYTES)
230
+ return `deployment exceeds max total size`;
231
+ return undefined;
232
+ }
233
+ function parseHost(url, env) {
234
+ const domain = env.NOOK_DOMAIN.toLowerCase();
235
+ const host = url.hostname.toLowerCase();
236
+ if (host === domain)
237
+ return { kind: "base" };
238
+ if (host.endsWith(`.${domain}`)) {
239
+ const slug = host.slice(0, -(domain.length + 1));
240
+ if (!slug.includes(".") && !validateSlug(slug))
241
+ return { kind: "site", slug };
242
+ }
243
+ return undefined;
244
+ }
245
+ async function readJson(request) {
246
+ try {
247
+ return await request.json();
248
+ }
249
+ catch {
250
+ throw new ErrorResponse("invalid_json", "Invalid JSON body", 400);
251
+ }
252
+ }
253
+ async function registryFetch(env, path, init) {
254
+ return await env.REGISTRY_DO.get(env.REGISTRY_DO.idFromName("__nook_registry")).fetch(new Request(`https://registry.local${path}`, init));
255
+ }
256
+ async function siteFetch(env, site, path, init) {
257
+ return await env.SITE_DO.get(env.SITE_DO.idFromName(site)).fetch(new Request(`https://site.local${path}`, init));
258
+ }
259
+ function assetKey(site, deploymentId, path) {
260
+ return `sites/${site}/deployments/${deploymentId}${path}`;
261
+ }
262
+ async function sha256Hex(bytes) {
263
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
264
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
265
+ }
266
+ async function deleteR2Prefix(bucket, prefix) {
267
+ let cursor;
268
+ let deleted = 0;
269
+ do {
270
+ const listed = await bucket.list({ prefix, cursor, limit: 1000 });
271
+ if (listed.objects.length > 0) {
272
+ await bucket.delete(listed.objects.map((object) => object.key));
273
+ deleted += listed.objects.length;
274
+ }
275
+ cursor = listed.cursor;
276
+ if (!listed.truncated)
277
+ break;
278
+ } while (cursor);
279
+ return deleted;
280
+ }
281
+ async function handleBaseApi(request, env, url, identity) {
282
+ if (request.method === "POST" && url.pathname === "/__nook/api/destroy") {
283
+ const sitesResponse = await registryFetch(env, `/sites?domain=${encodeURIComponent(env.NOOK_DOMAIN)}`);
284
+ if (!sitesResponse.ok)
285
+ return sitesResponse;
286
+ const payload = (await sitesResponse.json());
287
+ for (const site of payload.sites) {
288
+ await deleteR2Prefix(env.ASSETS, `sites/${site.slug}/`);
289
+ await siteFetch(env, site.slug, "/delete", { method: "POST" });
290
+ }
291
+ await registryFetch(env, "/delete-all", { method: "POST" });
292
+ const objectsDeleted = await deleteR2Prefix(env.ASSETS, "");
293
+ return json({
294
+ deleted: true,
295
+ sitesDeleted: payload.sites.length,
296
+ objectsDeleted,
297
+ actor: identity.actor,
298
+ });
299
+ }
300
+ const match = url.pathname.match(/^\/__nook\/api\/sites(?:\/([^/]+))?(?:\/deploy\/([^/]+)\/(file|finish)|\/deploy\/start)?$/);
301
+ if (!match)
302
+ return error("not_found", "Unknown Nook API route", 404);
303
+ const site = match[1] ? decodeURIComponent(match[1]) : undefined;
304
+ const deploymentId = match[2] ? decodeURIComponent(match[2]) : undefined;
305
+ const deployAction = match[3];
306
+ if (url.pathname === "/__nook/api/sites" && request.method === "GET") {
307
+ return await registryFetch(env, `/sites?domain=${encodeURIComponent(env.NOOK_DOMAIN)}`);
308
+ }
309
+ if (!site)
310
+ return error("site_not_found", "Site is required", 404);
311
+ const slugError = validateSlug(site);
312
+ if (slugError)
313
+ return error("invalid_slug", slugError, 400);
314
+ if (request.method === "DELETE" && url.pathname === `/__nook/api/sites/${site}`) {
315
+ await deleteR2Prefix(env.ASSETS, `sites/${site}/`);
316
+ await siteFetch(env, site, "/delete", { method: "POST" });
317
+ await registryFetch(env, `/sites/${encodeURIComponent(site)}`, { method: "DELETE" });
318
+ return json({ site, deleted: true });
319
+ }
320
+ if (request.method === "POST" && url.pathname.endsWith("/deploy/start")) {
321
+ const body = (await readJson(request));
322
+ const files = body.files ?? [];
323
+ const validationError = validateManifest(files);
324
+ if (validationError)
325
+ return error("invalid_manifest", validationError, 400);
326
+ await registryFetch(env, "/sites", {
327
+ method: "POST",
328
+ body: JSON.stringify({ slug: site, domain: env.NOOK_DOMAIN }),
329
+ });
330
+ return await siteFetch(env, site, "/deploy/start", {
331
+ method: "POST",
332
+ body: JSON.stringify({ files, public: body.public === true, actor: identity.actor }),
333
+ });
334
+ }
335
+ if (!deploymentId)
336
+ return error("deployment_not_found", "Deployment is required", 404);
337
+ const token = request.headers.get("x-nook-deploy-token");
338
+ if (!token)
339
+ return error("unauthorized", "Missing deploy token", 401);
340
+ if (request.method === "PUT" && deployAction === "file") {
341
+ const path = url.searchParams.get("path");
342
+ if (!path)
343
+ return error("invalid_path", "Missing upload path", 400);
344
+ const verify = await siteFetch(env, site, "/deploy/verify-upload", {
345
+ method: "POST",
346
+ body: JSON.stringify({ deploymentId, token, path }),
347
+ });
348
+ if (!verify.ok)
349
+ return verify;
350
+ const payload = (await verify.json());
351
+ const bytes = await request.arrayBuffer();
352
+ if (bytes.byteLength !== payload.file.sizeBytes) {
353
+ return error("invalid_upload", "Uploaded file size does not match manifest", 400);
354
+ }
355
+ if ((await sha256Hex(bytes)) !== payload.file.sha256) {
356
+ return error("invalid_upload", "Uploaded file hash does not match manifest", 400);
357
+ }
358
+ await env.ASSETS.put(assetKey(site, deploymentId, payload.file.path), bytes, {
359
+ httpMetadata: { contentType: payload.file.contentType },
360
+ });
361
+ return await siteFetch(env, site, "/deploy/mark-uploaded", {
362
+ method: "POST",
363
+ body: JSON.stringify({ deploymentId, token, path }),
364
+ });
365
+ }
366
+ if (request.method === "POST" && deployAction === "finish") {
367
+ const finish = await siteFetch(env, site, "/deploy/finish", {
368
+ method: "POST",
369
+ body: JSON.stringify({ deploymentId, token }),
370
+ });
371
+ if (!finish.ok)
372
+ return finish;
373
+ const rawResult = (await finish.json());
374
+ const result = {
375
+ ...rawResult,
376
+ site,
377
+ url: `https://${site}.${env.NOOK_DOMAIN}`,
378
+ };
379
+ await registryFetch(env, `/sites/${encodeURIComponent(site)}/deploy`, {
380
+ method: "POST",
381
+ body: JSON.stringify({
382
+ latestDeploymentId: result.deploymentId,
383
+ visibility: result.visibility,
384
+ }),
385
+ });
386
+ if (result.previousDeploymentId) {
387
+ await deleteR2Prefix(env.ASSETS, `sites/${site}/deployments/${result.previousDeploymentId}/`);
388
+ }
389
+ return json(result);
390
+ }
391
+ return error("not_found", "Unknown Nook API route", 404);
392
+ }
393
+ async function handleKv(request, env, site, identity) {
394
+ return await siteFetch(env, site, `/kv${new URL(request.url).pathname.slice("/__nook/kv".length)}${new URL(request.url).search}`, {
395
+ method: request.method,
396
+ headers: { "x-nook-actor": identity.actor },
397
+ body: request.body,
398
+ });
399
+ }
400
+ function shouldSpaFallback(pathname) {
401
+ const segment = pathname.split("/").pop() ?? "";
402
+ return !segment.includes(".");
403
+ }
404
+ export function cacheControlForDeployedAsset() {
405
+ return DEPLOYED_ASSET_CACHE_CONTROL;
406
+ }
407
+ function injectNookClientScript(response) {
408
+ const injector = {
409
+ element(element) {
410
+ element.onEndTag((endTag) => endTag.before(NOOK_CLIENT_SCRIPT, { html: true }));
411
+ delete injector.end;
412
+ },
413
+ end(end) {
414
+ end.append(NOOK_CLIENT_SCRIPT, { html: true });
415
+ },
416
+ };
417
+ return new HTMLRewriter().on("body", injector).onDocument(injector).transform(response);
418
+ }
419
+ async function serveAsset(request, env, site, url) {
420
+ const activeResponse = await siteFetch(env, site, "/active");
421
+ if (!activeResponse.ok)
422
+ return error("site_not_found", "Site has no active deployment", 404);
423
+ const active = (await activeResponse.json());
424
+ const identity = active.public
425
+ ? await optionalAccessIdentity(request, env)
426
+ : await requireAccessIdentity(request, env);
427
+ if (url.pathname === "/__nook/client.js") {
428
+ return new Response(CLIENT_JS, {
429
+ headers: { "content-type": "application/javascript; charset=utf-8" },
430
+ });
431
+ }
432
+ if (url.pathname === "/__nook/kv" || url.pathname.startsWith("/__nook/kv/")) {
433
+ return await handleKv(request, env, site, identity);
434
+ }
435
+ if (url.pathname === RESERVED_PREFIX || url.pathname.startsWith(`${RESERVED_PREFIX}/`)) {
436
+ return error("not_found", "Unknown Nook route", 404);
437
+ }
438
+ const normalized = normalizeAssetPath(url.pathname === "/" ? "/index.html" : url.pathname);
439
+ if (!normalized)
440
+ return error("invalid_path", "Invalid path", 400);
441
+ let object = await env.ASSETS.get(assetKey(site, active.deploymentId, normalized));
442
+ let servedPath = normalized;
443
+ if (!object && shouldSpaFallback(normalized)) {
444
+ servedPath = "/index.html";
445
+ object = await env.ASSETS.get(assetKey(site, active.deploymentId, servedPath));
446
+ }
447
+ if (!object)
448
+ return new Response("Not found", { status: 404 });
449
+ const file = active.files.find((entry) => entry.path === servedPath);
450
+ const contentType = object.httpMetadata?.contentType ?? file?.contentType ?? "application/octet-stream";
451
+ const headers = new Headers({
452
+ "content-type": contentType,
453
+ "cache-control": cacheControlForDeployedAsset(),
454
+ });
455
+ if (contentType.startsWith("text/html")) {
456
+ return injectNookClientScript(new Response(object.body ?? "", { headers }));
457
+ }
458
+ return new Response(object.body, { headers });
459
+ }
460
+ export default {
461
+ async fetch(request, env) {
462
+ try {
463
+ const url = new URL(request.url);
464
+ const parsedHost = parseHost(url, env);
465
+ if (!parsedHost)
466
+ return error("not_found", "Unknown nook host", 404);
467
+ if (url.pathname === "/__nook/skill") {
468
+ return new Response(SKILL_MARKDOWN, {
469
+ headers: { "content-type": "text/markdown; charset=utf-8" },
470
+ });
471
+ }
472
+ if (parsedHost.kind === "base") {
473
+ if (url.pathname.startsWith("/__nook/api/")) {
474
+ const identity = await requireAccessIdentity(request, env);
475
+ return await handleBaseApi(request, env, url, identity);
476
+ }
477
+ return error("not_found", "Nook base domain only serves platform APIs", 404);
478
+ }
479
+ return await serveAsset(request, env, parsedHost.slug, url);
480
+ }
481
+ catch (err) {
482
+ if (err instanceof ErrorResponse)
483
+ return err.toResponse();
484
+ return error("internal_error", err instanceof Error ? err.message : String(err), 500);
485
+ }
486
+ },
487
+ };
488
+ export class RegistryDO {
489
+ state;
490
+ constructor(state) {
491
+ this.state = state;
492
+ }
493
+ async fetch(request) {
494
+ const url = new URL(request.url);
495
+ if (request.method === "POST" && url.pathname === "/delete-all") {
496
+ await this.state.storage.deleteAll();
497
+ return json({ deleted: true });
498
+ }
499
+ if (request.method === "GET" && url.pathname === "/sites") {
500
+ const domain = url.searchParams.get("domain") ?? "";
501
+ const records = await this.state.storage.list({ prefix: "site:" });
502
+ return json({
503
+ sites: [...records.values()].map((site) => ({
504
+ ...site,
505
+ url: `https://${site.slug}.${domain}`,
506
+ })),
507
+ });
508
+ }
509
+ if (request.method === "POST" && url.pathname === "/sites") {
510
+ const body = (await readJson(request));
511
+ const key = `site:${body.slug}`;
512
+ const existing = await this.state.storage.get(key);
513
+ if (existing)
514
+ return json(existing);
515
+ const timestamp = now();
516
+ const site = {
517
+ slug: body.slug,
518
+ url: `https://${body.slug}.${body.domain}`,
519
+ createdAt: timestamp,
520
+ updatedAt: timestamp,
521
+ };
522
+ await this.state.storage.put(key, site);
523
+ return json(site, 201);
524
+ }
525
+ const deployMatch = url.pathname.match(/^\/sites\/([^/]+)\/deploy$/);
526
+ if (request.method === "POST" && deployMatch) {
527
+ const slug = decodeURIComponent(deployMatch[1]);
528
+ const body = (await readJson(request));
529
+ const key = `site:${slug}`;
530
+ const existing = await this.state.storage.get(key);
531
+ if (!existing)
532
+ return error("site_not_found", "Site not found", 404);
533
+ const updated = {
534
+ ...existing,
535
+ updatedAt: now(),
536
+ latestDeploymentId: body.latestDeploymentId,
537
+ visibility: body.visibility,
538
+ };
539
+ await this.state.storage.put(key, updated);
540
+ return json(updated);
541
+ }
542
+ const deleteMatch = url.pathname.match(/^\/sites\/([^/]+)$/);
543
+ if (request.method === "DELETE" && deleteMatch) {
544
+ await this.state.storage.delete(`site:${decodeURIComponent(deleteMatch[1])}`);
545
+ return json({ deleted: true });
546
+ }
547
+ return error("not_found", "Unknown registry route", 404);
548
+ }
549
+ }
550
+ export class SiteDO {
551
+ state;
552
+ constructor(state) {
553
+ this.state = state;
554
+ }
555
+ async fetch(request) {
556
+ const url = new URL(request.url);
557
+ if (request.method === "POST" && url.pathname === "/delete") {
558
+ await this.state.storage.deleteAll();
559
+ return json({ deleted: true });
560
+ }
561
+ if (request.method === "GET" && url.pathname === "/active") {
562
+ const site = await this.state.storage.get("site");
563
+ if (!site?.activeDeploymentId)
564
+ return error("deployment_not_found", "No active deployment", 404);
565
+ const deployment = await this.state.storage.get(`deployment:${site.activeDeploymentId}`);
566
+ if (!deployment)
567
+ return error("deployment_not_found", "Active deployment missing", 404);
568
+ return json({
569
+ deploymentId: deployment.deploymentId,
570
+ public: deployment.public,
571
+ files: deployment.files,
572
+ });
573
+ }
574
+ if (request.method === "POST" && url.pathname === "/deploy/start") {
575
+ const body = (await readJson(request));
576
+ const timestamp = now();
577
+ const nowMs = Date.now();
578
+ const deployments = await this.state.storage.list({
579
+ prefix: "deployment:",
580
+ });
581
+ let pendingDeployments = 0;
582
+ const expiredPendingKeys = [];
583
+ for (const [key, deployment] of deployments.entries()) {
584
+ if (deployment.status !== "pending")
585
+ continue;
586
+ if (Date.parse(deployment.expiresAt) < nowMs) {
587
+ expiredPendingKeys.push(key);
588
+ }
589
+ else {
590
+ pendingDeployments += 1;
591
+ }
592
+ }
593
+ if (expiredPendingKeys.length > 0)
594
+ await this.state.storage.delete(expiredPendingKeys);
595
+ if (pendingDeployments >= MAX_PENDING_DEPLOYMENTS) {
596
+ return error("quota_exceeded", "Too many pending deployments for this site", 429);
597
+ }
598
+ const deployment = {
599
+ deploymentId: randomId("dep"),
600
+ status: "pending",
601
+ public: body.public,
602
+ createdAt: timestamp,
603
+ token: randomId("tok"),
604
+ expiresAt: new Date(Date.now() + DEPLOY_SESSION_MS).toISOString(),
605
+ files: body.files,
606
+ uploaded: [],
607
+ };
608
+ const existingSite = await this.state.storage.get("site");
609
+ await this.state.storage.put("site", {
610
+ createdAt: existingSite?.createdAt ?? timestamp,
611
+ updatedAt: timestamp,
612
+ activeDeploymentId: existingSite?.activeDeploymentId,
613
+ activePublic: existingSite?.activePublic,
614
+ });
615
+ await this.state.storage.put(`deployment:${deployment.deploymentId}`, deployment);
616
+ return json({
617
+ deploymentId: deployment.deploymentId,
618
+ upload: deployment.files.map((file) => file.path),
619
+ token: deployment.token,
620
+ });
621
+ }
622
+ if (request.method === "POST" && url.pathname === "/deploy/verify-upload") {
623
+ const body = (await readJson(request));
624
+ const deploymentResult = await this.requirePendingDeployment(body.deploymentId, body.token);
625
+ if (deploymentResult instanceof Response)
626
+ return deploymentResult;
627
+ const deployment = deploymentResult;
628
+ const file = deployment.files.find((entry) => entry.path === body.path);
629
+ if (!file)
630
+ return error("invalid_path", "Path is not in deployment manifest", 400);
631
+ return json({ file });
632
+ }
633
+ if (request.method === "POST" && url.pathname === "/deploy/mark-uploaded") {
634
+ const body = (await readJson(request));
635
+ const deploymentResult = await this.requirePendingDeployment(body.deploymentId, body.token);
636
+ if (deploymentResult instanceof Response)
637
+ return deploymentResult;
638
+ const deployment = deploymentResult;
639
+ if (!deployment.uploaded.includes(body.path))
640
+ deployment.uploaded.push(body.path);
641
+ await this.state.storage.put(`deployment:${deployment.deploymentId}`, deployment);
642
+ return json({ uploaded: true });
643
+ }
644
+ if (request.method === "POST" && url.pathname === "/deploy/finish") {
645
+ const body = (await readJson(request));
646
+ const deploymentResult = await this.requirePendingDeployment(body.deploymentId, body.token);
647
+ if (deploymentResult instanceof Response)
648
+ return deploymentResult;
649
+ const deployment = deploymentResult;
650
+ const uploaded = new Set(deployment.uploaded);
651
+ const missing = deployment.files.filter((file) => !uploaded.has(file.path));
652
+ if (missing.length > 0)
653
+ return error("deployment_incomplete", "Deployment has missing files", 409);
654
+ const previous = await this.state.storage.get("site");
655
+ const timestamp = now();
656
+ deployment.status = "active";
657
+ deployment.finishedAt = timestamp;
658
+ await this.state.storage.put(`deployment:${deployment.deploymentId}`, deployment);
659
+ await this.state.storage.put("site", {
660
+ createdAt: previous?.createdAt ?? deployment.createdAt,
661
+ updatedAt: timestamp,
662
+ activeDeploymentId: deployment.deploymentId,
663
+ activePublic: deployment.public,
664
+ });
665
+ return json({
666
+ visibility: deployment.public ? "public" : "private",
667
+ deploymentId: deployment.deploymentId,
668
+ fileCount: deployment.files.length,
669
+ byteCount: deployment.files.reduce((total, file) => total + file.sizeBytes, 0),
670
+ previousDeploymentId: previous?.activeDeploymentId,
671
+ });
672
+ }
673
+ if (url.pathname === "/kv" || url.pathname.startsWith("/kv/")) {
674
+ return await this.handleKv(request, url);
675
+ }
676
+ return error("not_found", "Unknown site route", 404);
677
+ }
678
+ async requirePendingDeployment(deploymentId, token) {
679
+ const deployment = await this.state.storage.get(`deployment:${deploymentId}`);
680
+ if (!deployment)
681
+ return error("deployment_not_found", "Deployment not found", 404);
682
+ if (deployment.status !== "pending") {
683
+ return error("deployment_not_found", "Deployment is not pending", 409);
684
+ }
685
+ if (deployment.token !== token)
686
+ return error("unauthorized", "Invalid deploy token", 401);
687
+ if (Date.parse(deployment.expiresAt) < Date.now()) {
688
+ return error("deployment_expired", "Deployment session expired", 410);
689
+ }
690
+ return deployment;
691
+ }
692
+ async handleKv(request, url) {
693
+ try {
694
+ const key = decodeURIComponent(url.pathname.slice("/kv/".length));
695
+ if (request.method === "GET" && url.pathname === "/kv") {
696
+ const prefix = url.searchParams.get("prefix") ?? "";
697
+ const records = await this.state.storage.list({ prefix: "kv:" });
698
+ const keys = [...records.entries()]
699
+ .map(([storageKey, record]) => ({
700
+ key: storageKey.slice("kv:".length),
701
+ sizeBytes: record.sizeBytes,
702
+ updatedAt: record.updatedAt,
703
+ }))
704
+ .filter((entry) => entry.key.startsWith(prefix));
705
+ return json({ keys });
706
+ }
707
+ if (!key || key.length > MAX_KV_KEY_LENGTH)
708
+ return error("invalid_key", "Invalid KV key", 400);
709
+ const storageKey = `kv:${key}`;
710
+ if (request.method === "GET") {
711
+ const record = await this.state.storage.get(storageKey);
712
+ return json({ value: record?.value ?? null });
713
+ }
714
+ if (request.method === "DELETE") {
715
+ const deleted = await this.state.storage.delete(storageKey);
716
+ return json({ key, deleted });
717
+ }
718
+ if (request.method === "PUT") {
719
+ const value = await readJson(request);
720
+ const serialized = JSON.stringify(value);
721
+ const sizeBytes = new TextEncoder().encode(serialized).length;
722
+ if (sizeBytes > MAX_KV_VALUE_BYTES)
723
+ return error("quota_exceeded", "KV value is too large", 413);
724
+ const existing = await this.state.storage.get(storageKey);
725
+ const usage = await this.kvUsage();
726
+ const nextKeys = existing ? usage.keyCount : usage.keyCount + 1;
727
+ const nextBytes = usage.bytesUsed - (existing?.sizeBytes ?? 0) + sizeBytes;
728
+ if (nextKeys > MAX_KV_KEYS || nextBytes > MAX_KV_TOTAL_BYTES) {
729
+ return error("quota_exceeded", "KV quota exceeded", 413);
730
+ }
731
+ await this.state.storage.put(storageKey, {
732
+ value,
733
+ sizeBytes,
734
+ updatedAt: now(),
735
+ updatedBy: request.headers.get("x-nook-actor") ?? "unknown",
736
+ });
737
+ return json({ key });
738
+ }
739
+ return error("method_not_allowed", "Method not allowed", 405);
740
+ }
741
+ catch (err) {
742
+ if (err instanceof ErrorResponse)
743
+ return err.toResponse();
744
+ return error("internal_error", err instanceof Error ? err.message : String(err), 500);
745
+ }
746
+ }
747
+ async kvUsage() {
748
+ const records = await this.state.storage.list({ prefix: "kv:" });
749
+ let bytesUsed = 0;
750
+ for (const record of records.values())
751
+ bytesUsed += record.sizeBytes;
752
+ return { keyCount: records.size, bytesUsed };
753
+ }
754
+ }
755
+ class ErrorResponse extends Error {
756
+ code;
757
+ status;
758
+ constructor(code, message, status) {
759
+ super(message);
760
+ this.code = code;
761
+ this.status = status;
762
+ }
763
+ toResponse() {
764
+ return error(this.code, this.message, this.status);
765
+ }
766
+ }
767
+ //# sourceMappingURL=index.js.map