UNPKG

ethercalc

Version:

Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)

713 lines (677 loc) 29.1 kB
/** * Room-level HTTP routes that dispatch through the `ROOM` Durable Object * namespace. Covers the Phase 5 surface from §8 (AGENTS.md): * * POST /_ create room * PUT /_/:room replace snapshot * GET /_/:room fetch SocialCalc save * DELETE /_/:room delete room * GET /_exists/:room bare JSON boolean * GET /_rooms JSON array (D1 mirror — Phase 5.1) * GET /_roomlinks HTML <a> list (diverges from legacy bug, see * tests/oracle/FINDINGS.md and §6.1 §13 Q1) * GET /_roomtimes JSON hash (D1 mirror — Phase 5.1) * GET /_from/:template 302 → new room id (copies template snapshot) * * Excluded from coverage for the same reason as `src/index.ts` — workerd * istanbul can't reach hits here. Pure logic lives in * `src/handlers/rooms.ts` (request body classification), * `src/lib/{csv,do-dispatch,room-name,rooms-index}.ts`. */ /* istanbul ignore file */ import type { Hono } from 'hono'; import type { Env } from '../env.ts'; import { upsertCronTriggers } from '../handlers/cron.ts'; import { classifyCommandBody, joinCommands } from '../handlers/post-command.ts'; import { classifyRequestBody } from '../handlers/rooms.ts'; import { verifyAuth } from '../lib/auth.ts'; import { parseSettimetrigger } from '../lib/cron.ts'; import { doFetch } from '../lib/do-dispatch.ts'; import { enrichLoadClipboard, isBannedWikiFormat, isLoadClipboard, isMultiCascade, } from '../lib/loadclipboard.ts'; import { verifyMigrateToken } from '../lib/migrate-auth.ts'; import { parsePitrRequest } from '../lib/pitr.ts'; import { shouldDisableRoomIndex } from '../lib/room-index-access.ts'; import { generateRoomId } from '../lib/room-name.ts'; import { listRooms, listRoomTimes, renderRoomLinks, } from '../lib/rooms-index.ts'; import { ImportArchiveTooLargeError, ImportColumnOutOfRangeError, ImportTooLargeError, workbookToLoadClipboardCommand, xlsxToLoadClipboardCommands, xlsxToSave, } from '../lib/xlsx-import.ts'; const TEXT_CT = 'text/plain; charset=utf-8'; const HTML_CT = 'text/html; charset=utf-8'; const JSON_CT = 'application/json; charset=utf-8'; const PITR_POLL_ATTEMPTS = 20; const PITR_POLL_INTERVAL_MS = 100; /** * Response with `Content-Length` set from the body. Workers auto-emits it * for buffered bodies, but the oracle recordings pin the value so we set * it explicitly to avoid mismatches if a future workerd update flips the * default. */ function sizedResponse( body: string, status: number, contentType: string, extraHeaders: Record<string, string> = {}, ): Response { const bytes = new TextEncoder().encode(body); return new Response(body, { status, headers: { 'Content-Type': contentType, 'Content-Length': String(bytes.byteLength), ...extraHeaders, }, }); } async function readBodyBytes(request: Request): Promise<Uint8Array> { return new Uint8Array(await request.arrayBuffer()); } /** * Register all Phase 5 room routes on the provided Hono app. Ordering * matters — Hono dispatches literal prefixes before params, so `/_rooms`, * `/_roomlinks`, `/_roomtimes`, `/_from/:template`, `/_exists/:room`, * `/_new`, `/_start`, `/_health` all have to come before `/_/:room` (the * `_` vs `_…` split in Hono's trie is exact-match-first). */ export function registerRoomRoutes(app: Hono<{ Bindings: Env }>): void { // ─── Cross-room index endpoints (Phase 5.1 — backed by D1) ───────── // // The D1 `rooms` table (migrations/0001_rooms.sql) is maintained by // the DO — every snapshot write upserts a row, every DELETE /_do/all // removes one. These handlers read from that table. When `env.DB` is // unbound (Node unit tests without Miniflare, or a deployment that // opts out of the index) we short-circuit to the empty-state body // shapes the oracle recorded. app.get('/_rooms', async (c) => { if (shouldDisableRoomIndex(c.env)) { return sizedResponse('_rooms not available with CORS', 403, TEXT_CT); } if (!c.env.DB) return sizedResponse('[]', 200, JSON_CT); const rooms = await listRooms(c.env.DB); return sizedResponse(JSON.stringify(rooms), 200, JSON_CT); }); app.get('/_roomlinks', async (c) => { if (shouldDisableRoomIndex(c.env)) { return sizedResponse('_roomlinks not available with CORS', 403, TEXT_CT); } // Sensible-fix (§13 Q1): oracle emitted JSON in a text/html // response — we render an actual <a> list. Empty-state body is // still `[]` to keep the oracle's byte recording passing. if (!c.env.DB) return sizedResponse('[]', 200, HTML_CT); const rooms = await listRooms(c.env.DB); const body = renderRoomLinks(rooms, c.env.BASEPATH ?? ''); return sizedResponse(body, 200, HTML_CT); }); app.get('/_roomtimes', async (c) => { if (shouldDisableRoomIndex(c.env)) { return sizedResponse('_roomtimes not available with CORS', 403, TEXT_CT); } if (!c.env.DB) return sizedResponse('{}', 200, JSON_CT); const times = await listRoomTimes(c.env.DB); return sizedResponse(JSON.stringify(times), 200, JSON_CT); }); // ─── Template copy ───────────────────────────────────────────────── app.get('/_from/:template', async (c) => { const template = c.req.param('template') ?? ''; const snapshotRes = await doFetch(c.env, template, '/_do/snapshot'); if (snapshotRes.status === 404) { // Template doesn't exist — legacy still redirects into a blank room. // Preserve that but note it in FINDINGS for a potential fix later. const newRoom = generateRoomId(); return c.redirect( `${c.env.BASEPATH ?? ''}/${newRoom}${c.env.ETHERCALC_KEY ? '/edit' : ''}`, 302, ); } const snapshot = await snapshotRes.text(); const newRoom = generateRoomId(); await doFetch(c.env, newRoom, '/_do/snapshot', { method: 'PUT', body: snapshot, }); return c.redirect( `${c.env.BASEPATH ?? ''}/${newRoom}${c.env.ETHERCALC_KEY ? '/edit' : ''}`, 302, ); }); // ─── _exists ──────────────────────────────────────────────────────── app.get('/_exists/:room', async (c) => { // Gate the per-name existence oracle behind the same switch as the // batch-enumeration endpoints above (M-1). Without this, even a // deploy that disables `/_rooms` still leaks "does room X exist" to // anyone, one cheap probe at a time. Hosted still uses the legacy // `ETHERCALC_CORS=1` fallback; self-hosts should prefer the explicit // `ETHERCALC_DISABLE_ROOM_INDEX=1`. if (shouldDisableRoomIndex(c.env)) { return sizedResponse('_exists not available with CORS', 403, TEXT_CT); } const room = c.req.param('room') ?? ''; const res = await doFetch(c.env, room, '/_do/exists'); const { exists } = (await res.json()) as { exists: 0 | 1 }; // Oracle emits a bare JSON boolean with `application/json; charset=utf-8` // (see tests/oracle/FINDINGS F-05 + the recorded fixture). Matches the // legacy `@response.json (exists === 1)` in src/main.ls:275. return sizedResponse(exists === 1 ? 'true' : 'false', 200, JSON_CT); }); // ─── POST /_ (create) ────────────────────────────────────────────── app.post('/_', async (c) => { const bytes = await readBodyBytes(c.req.raw); const ct = c.req.header('content-type') ?? ''; // For POST `/_`, a JSON body can carry `{room?, snapshot}` — pull the // room out before the generic body classifier consumes it. The // classifier also handles the snapshot part. let userRoom: string | undefined; if (ct.split(';')[0]!.trim().toLowerCase() === 'application/json') { try { const parsed = JSON.parse(new TextDecoder('utf-8').decode(bytes)) as { room?: unknown; }; if (typeof parsed.room === 'string' && parsed.room.length > 0) { userRoom = parsed.room; } } catch { /* fall through; classifyRequestBody handles bad JSON */ } } const decoded = classifyRequestBody(ct, bytes); let snapshot: string; if (decoded.kind === 'xlsx-deferred') { try { snapshot = xlsxToSave(bytes); } catch (err) { if (err instanceof ImportTooLargeError || err instanceof ImportArchiveTooLargeError) { return sizedResponse(err.message, 413, TEXT_CT); } if (err instanceof ImportColumnOutOfRangeError) { return sizedResponse(err.message, 400, TEXT_CT); } snapshot = ''; } } else { snapshot = decoded.kind === 'save' ? decoded.snapshot : ''; } const room = userRoom ?? generateRoomId(); await doFetch(c.env, room, '/_do/snapshot', { method: 'PUT', body: snapshot, }); return sizedResponse(`/${room}`, 201, TEXT_CT, { Location: `/_/${room}` }); }); // ─── PUT /_/:room (overwrite snapshot) ───────────────────────────── app.put('/_/:room', async (c) => { const room = c.req.param('room') ?? ''; const bytes = await readBodyBytes(c.req.raw); const ct = c.req.header('content-type') ?? ''; const decoded = classifyRequestBody(ct, bytes); let snapshot: string; if (decoded.kind === 'xlsx-deferred') { try { snapshot = xlsxToSave(bytes); } catch (err) { if (err instanceof ImportTooLargeError || err instanceof ImportArchiveTooLargeError) { return sizedResponse(err.message, 413, TEXT_CT); } if (err instanceof ImportColumnOutOfRangeError) { return sizedResponse(err.message, 400, TEXT_CT); } snapshot = ''; } } else { snapshot = decoded.kind === 'save' ? decoded.snapshot : ''; } await doFetch(c.env, room, '/_do/snapshot', { method: 'PUT', body: snapshot, }); // Legacy responds with exactly `201 OK` text/plain (src/main.ls:404). return sizedResponse('OK', 201, TEXT_CT); }); // ─── GET /_/:room (raw save) ─────────────────────────────────────── app.get('/_/:room', async (c) => { const room = c.req.param('room') ?? ''; const res = await doFetch(c.env, room, '/_do/snapshot'); if (res.status === 404) { return sizedResponse('', 404, TEXT_CT); } // Pass the body through as a stream. Materializing via `res.text()` // would load the entire save into worker memory and re-set a // Content-Length header — fine for the common small-snapshot case, // but breaks for chunked snapshots over workerd's ~96 MB // DO-response ceiling (the DO delivers those as a streamed body; // collapsing back to text would defeat the point). Browsers and // curl handle chunked transfer encoding fine. return new Response(res.body, { status: 200, headers: { 'Content-Type': TEXT_CT }, }); }); // ─── GET /_/:room/cells (full cell map JSON) ─────────────────────── // // Legacy `src/main.ls` mapped this to `JSON.stringify(ss.sheet.cells)`. // The DO exposes the same via `/_do/cells`. Must register BEFORE the // `/_/:room/cells/:cell` route so Hono picks the trailing-slashless // literal before the param form. app.get('/_/:room/cells', async (c) => { const room = c.req.param('room') ?? ''; const res = await doFetch(c.env, room, '/_do/cells'); const body = await res.text(); return new Response(body, { status: res.status, headers: { 'Content-Type': 'application/json; charset=utf-8' }, }); }); // ─── GET /_/:room/cells/:cell (single cell JSON) ─────────────────── app.get('/_/:room/cells/:cell', async (c) => { const room = c.req.param('room') ?? ''; const cell = c.req.param('cell') ?? ''; const res = await doFetch(c.env, room, `/_do/cells/${encodeURIComponent(cell)}`); const body = await res.text(); return new Response(body, { status: res.status, headers: { 'Content-Type': 'application/json; charset=utf-8' }, }); }); // ─── POST /_/:room/pitr-restore (hosted SQLite DO recovery) ──────── app.post('/_/:room/pitr-restore', async (c) => { const room = c.req.param('room') ?? ''; // PITR can resurrect deleted data, so it uses the deployment's operator // bearer token rather than the legacy room gate (which is permissive // when ETHERCALC_KEY is unset). Authenticate before parsing. const auth = verifyMigrateToken( c.env.ETHERCALC_MIGRATE_TOKEN, c.req.header('Authorization') ?? null, ); if (auth.kind === 'disabled') { return sizedResponse('Not Found', 404, TEXT_CT); } if (auth.kind !== 'ok') { return sizedResponse('Unauthorized', 401, TEXT_CT); } let requestBody: unknown; try { requestBody = await c.req.raw.json(); } catch { return sizedResponse('body must be valid JSON', 400, TEXT_CT); } const parsed = parsePitrRequest(requestBody); if (!parsed.ok) return sizedResponse(parsed.error, 400, TEXT_CT); let restoreResponse: Response; try { restoreResponse = await doFetch(c.env, room, '/_do/pitr-restore', { method: 'POST', headers: { 'Content-Type': JSON_CT }, body: JSON.stringify(parsed.value), }); } catch { return sizedResponse('PITR restore dispatch failed', 502, TEXT_CT); } if (!restoreResponse.ok) { return new Response(restoreResponse.body, { status: restoreResponse.status, headers: { 'Content-Type': restoreResponse.headers.get('content-type') ?? TEXT_CT, }, }); } let restoreBody: unknown; try { restoreBody = await restoreResponse.json(); } catch { return sizedResponse('Invalid PITR response', 502, TEXT_CT); } if (restoreBody === null || typeof restoreBody !== 'object') { return sizedResponse('Invalid PITR response', 502, TEXT_CT); } const restoreRecord = restoreBody as Record<string, unknown>; if (parsed.value.dryRun) { if ( restoreRecord.dryRun !== true || typeof restoreRecord.bookmark !== 'string' ) { return sizedResponse('Invalid PITR response', 502, TEXT_CT); } return sizedResponse( JSON.stringify({ dryRun: true, bookmark: restoreRecord.bookmark, }), 200, JSON_CT, ); } if ( typeof restoreRecord.bookmark !== 'string' || typeof restoreRecord.undoBookmark !== 'string' || typeof restoreRecord.nonce !== 'string' ) { return sizedResponse('Invalid PITR response', 502, TEXT_CT); } const acceptedBookmark = restoreRecord.bookmark; const undoBookmark = restoreRecord.undoBookmark; const acceptingNonce = restoreRecord.nonce; // From here the DO has already scheduled the rewind and armed its // restart, so a failure response must never strand the operator // without the reverse handle: every post-acceptance error carries // `accepted: true` plus both bookmarks as JSON. const acceptedFailure = (error: string, status: 500 | 502): Response => sizedResponse( JSON.stringify({ accepted: true, bookmark: acceptedBookmark, undoBookmark, error, }), status, JSON_CT, ); let restarted = false; for (let attempt = 0; attempt < PITR_POLL_ATTEMPTS; attempt += 1) { if (attempt > 0) { const { promise, resolve } = Promise.withResolvers<void>(); setTimeout(resolve, PITR_POLL_INTERVAL_MS); await promise; } try { const pingResponse = await doFetch(c.env, room, '/_do/ping'); if (!pingResponse.ok) continue; const pingBody = (await pingResponse.json()) as { nonce?: unknown }; if ( typeof pingBody.nonce === 'string' && pingBody.nonce !== acceptingNonce ) { restarted = true; break; } } catch { // Expected while state.abort() replaces the accepting instance. } } if (!restarted) { return acceptedFailure('PITR restore did not restart the room', 500); } let touchResponse: Response; try { touchResponse = await doFetch(c.env, room, '/_do/pitr-touch', { method: 'POST', }); } catch { return acceptedFailure('PITR restore finalization failed', 502); } if (!touchResponse.ok) { return acceptedFailure('PITR restore finalization failed', 502); } let touchBody: unknown; try { touchBody = await touchResponse.json(); } catch { return acceptedFailure('PITR restore finalization failed', 502); } if (touchBody === null || typeof touchBody !== 'object') { return acceptedFailure('PITR restore finalization failed', 502); } const touchRecord = touchBody as Record<string, unknown>; if (touchRecord.exists === false) { return sizedResponse( JSON.stringify({ restored: true, bookmark: acceptedBookmark, undoBookmark, exists: false, }), 200, JSON_CT, ); } if ( touchRecord.exists !== true || typeof touchRecord.updatedAt !== 'number' || !Number.isFinite(touchRecord.updatedAt) ) { return acceptedFailure('PITR restore finalization failed', 502); } return sizedResponse( JSON.stringify({ restored: true, bookmark: acceptedBookmark, undoBookmark, exists: true, updatedAt: touchRecord.updatedAt, }), 200, JSON_CT, ); }); // ─── DELETE /_/:room ─────────────────────────────────────────────── app.delete('/_/:room', async (c) => { const room = c.req.param('room') ?? ''; // H-2: permanently wiping a room (incl. the never-truncated audit) is // a destructive verb, so gate it on the per-room capability HMAC the // same way the WS `stopHuddle` path does. When no `ETHERCALC_KEY` is // set (anonymous mode), `verifyAuth` returns true for any non-'0' // auth, so this is a no-op and DELETE stays open — matching the // documented anonymous contract (§6.4). When a KEY *is* configured it // closes the HTTP-vs-WS asymmetry where DELETE ignored the key. if (!(await verifyAuth(c.env.ETHERCALC_KEY, room, c.req.query('auth') ?? ''))) { return sizedResponse('Forbidden', 403, TEXT_CT); } await doFetch(c.env, room, '/_do/all', { method: 'DELETE' }); return sizedResponse('OK', 201, TEXT_CT); }); // ─── POST /_/:room (execute commands) ─────────────────────────────── // // Phase 6 implementation. Direct port of src/main.ls:406-446: // // 1. classify body (JSON {command} / text / xlsx) // 2. empty body -> 400 'Please send command' // 3. xlsx/ods -> decode the workbook into a `loadclipboard <save>` + // `paste A1 all` command pair and run it through this same POST // path (matches the legacy J-library decode that replied 202 // {command}). The cells paste INTO the room, preserving any // existing content -- unlike PUT, which replaces the snapshot. // 4. apply text-wiki filter -- the banned command is silently // dropped at the top (sec 7 item 12); we return 202 with the // original command echoed so clients don't re-issue it. // 5. if the command is loadclipboard AND it arrived as plain text, // auto-enrich per legacy: // row = ?row query if present and truthy, else derive from // the current snapshot's sheet dimension line // -> [cmd, insertrow A<row>, paste A<row> all] if ?row given // -> [cmd, paste A<row> all] otherwise // 6. if the command matches `^set A\d+:B\d+ empty multi-cascade`, // read the referenced cell's t:-prefixed content out of the // current snapshot and rename that foreign room <ref> -> <ref>.bak // via POST /_do/rename on the source DO. // 7. join the (possibly-array) command with newlines and // dispatch to POST /_do/commands on the room DO. // 8. reply 202 application/json with body {command} -- command // may be a string or an array of strings. // // The WS broadcast (`{type: execute, cmdstr, room}`) is Phase 7's // responsibility; this handler trusts the DO has already surfaced // the change via its own WS fan-out when that layer lands. app.post('/_/:room', async (c) => { const room = c.req.param('room') ?? ''; const bytes = await readBodyBytes(c.req.raw); const ct = c.req.header('content-type') ?? ''; const classified = classifyCommandBody(ct, bytes); // xlsx/ods body: decode the workbook into a `loadclipboard <save>` + // `paste A1 all` command pair and dispatch it through the shared tail // below (same as a JSON-array command). The paste lands the imported // cells INTO the room without clobbering existing content. An empty // body classifies as `empty` (-> 400); an empty/cell-less workbook // yields no commands, which we treat as a no-op 202 echo. if (classified.kind === 'xlsx-deferred') { let commands: string[]; try { commands = xlsxToLoadClipboardCommands(bytes); } catch (err) { if (err instanceof ImportTooLargeError || err instanceof ImportArchiveTooLargeError) { return sizedResponse(err.message, 413, TEXT_CT); } if (err instanceof ImportColumnOutOfRangeError) { return sizedResponse(err.message, 400, TEXT_CT); } /* istanbul ignore next -- xlsxToLoadClipboardCommands only throws ImportTooLargeError / ImportColumnOutOfRangeError in practice; any other failure (malformed workbook) is swallowed by SheetJS into an empty sheet. */ return sizedResponse('Could not import workbook', 400, TEXT_CT); } if (commands.length > 0) { await doFetch(c.env, room, '/_do/commands', { method: 'POST', body: commands.join('\n'), }); } return new Response(JSON.stringify({ command: commands }), { status: 202, headers: { 'Content-Type': JSON_CT }, }); } if (classified.kind === 'empty') { return sizedResponse('Please send command', 400, TEXT_CT); } // CSV body: convert to a loadclipboard command via SheetJS (which // auto-detects CSV format), then feed it through the existing // text-command enrichment path — the loadclipboard enricher adds // `paste A<N> all` (A2 for cold rooms, A<lastrow+1> for existing // sheets). This mirrors the legacy J-library decode path. let commandKind: 'json-command' | 'text-command'; let command: string | readonly string[]; if (classified.kind === 'csv-deferred') { let csvCommand: string | null; try { csvCommand = workbookToLoadClipboardCommand(bytes); } catch (err) { if ( err instanceof ImportTooLargeError || err instanceof ImportArchiveTooLargeError ) { return sizedResponse(err.message, 413, TEXT_CT); } if (err instanceof ImportColumnOutOfRangeError) { return sizedResponse(err.message, 400, TEXT_CT); } return sizedResponse('Could not import CSV', 400, TEXT_CT); } if (csvCommand === null) { return new Response(JSON.stringify({ command: [] }), { status: 202, headers: { 'Content-Type': JSON_CT }, }); } commandKind = 'text-command'; command = csvCommand; } else { commandKind = classified.kind; command = classified.command; } // Apply text-wiki filter first (at the top per legacy sec 7 item 12). // Single-string only -- the banned command is never an array member // in practice; if a client ever nests it, the DO's own executor // would still process it (we match legacy's surface-only filter). if ( typeof command === 'string' && isBannedWikiFormat(command) ) { return new Response( JSON.stringify({ command }), { status: 202, headers: { 'Content-Type': JSON_CT } }, ); } // For plain-text bodies the legacy auto-enriches loadclipboard and // handles multi-cascade renames. Those branches don't apply when // the command arrived as JSON -- the client has already composed // the array shape it wants. let commandOut: string | readonly string[] = command; if (commandKind === 'text-command') { const textCmd = command as string; // Multi-cascade rename (src/main.ls:425-436). Reads the current // snapshot out of this room's DO, matches the // `cell:<ref>:t:/<foreignRoom>` line, and issues /_do/rename on // the foreign room's DO. Errors (missing snapshot, missing cell // line, rename 5xx) are swallowed: legacy proceeds to execute // the command regardless. const cascadeRef = isMultiCascade(textCmd); if (cascadeRef !== null) { const snapshotRes = await doFetch(c.env, room, '/_do/snapshot'); if (snapshotRes.ok) { const snapshot = await snapshotRes.text(); const cellLine = new RegExp(`cell:${cascadeRef}:t:/(.+)`, 'i').exec(snapshot); if (cellLine) { const targetRoom = cellLine[1]!.replace(/\r?$/, ''); // SECURITY (H-4): `targetRoom` is read from cell text that any // anonymous writer fully controls. The legitimate use is the // multi-sheet editor cascade-deleting one of its OWN sub-sheets, // which are always named `<room>.<n>` (see client-multi // Foldr.ts — the TOC seeds `/${id}.1`). Restrict the rename to // that namespace so a POST can never move/destroy an unrelated // tenant's room. A foreign target is silently ignored, matching // legacy's "proceed with the command regardless" on a no-match. if (targetRoom.startsWith(`${room}.`)) { await doFetch(c.env, targetRoom, '/_do/rename', { method: 'POST', body: JSON.stringify({ to: `${targetRoom}.bak` }), headers: { 'Content-Type': 'application/json' }, }); } } } } // Loadclipboard auto-enrichment (src/main.ls:414-424). if (isLoadClipboard(textCmd)) { const rowQ = Number(c.req.query('row')); const rowQueryParam = Number.isNaN(rowQ) ? null : rowQ; // Snapshot is needed for the lastrow derivation. Treat 404 as empty. const snapshotRes = await doFetch(c.env, room, '/_do/snapshot'); const snapshot = snapshotRes.ok ? await snapshotRes.text() : ''; commandOut = enrichLoadClipboard(textCmd, { rowQueryParam, snapshot }); } } // Dispatch the joined commands to the DO. Array commands become a // single newline-separated batch (matches legacy // `cmdstr = command * '\n'`). const cmdstr = joinCommands(commandOut); // Phase 9 — settimetrigger side-effect. The legacy flow posted the // command to a worker-thread which then emitted a `setcrontrigger` // message (src/sc.ls:220); we short-circuit by detecting the verb // here and writing to D1 before the DO runs the command. The DO // still executes the command (which records a log entry), but the // actual scheduling lives in `cron_triggers`. // // Multi-line dispatches (array command or newline-joined text // block) can carry several settimetrigger lines; we parse each. if (c.env.DB) { for (const line of cmdstr.split('\n')) { const parsed = parseSettimetrigger(line); if (parsed) { await upsertCronTriggers(c.env.DB, room, parsed.cell, parsed.times); } } } await doFetch(c.env, room, '/_do/commands', { method: 'POST', body: cmdstr, }); // Legacy replies `@response.json 202 {command}` -- `command` is the // post-enrichment value (string or array), not the raw body text. return new Response(JSON.stringify({ command: commandOut }), { status: 202, headers: { 'Content-Type': JSON_CT }, }); }); }