Repository navigation
Expand file tree
/
Copy pathindex.js
More file actions
584 lines (523 loc) · 23.9 KB
/
Copy pathindex.js
File metadata and controls
584 lines (523 loc) · 23.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*
* CodeLedger Cloudflare Worker
* Handles: GitHub OAuth flow, static landing page, canonical map KV
*
* Required secrets (set via `wrangler secret put`):
* CODELEDGER_OAUTH_CLIENT_ID — OAuth App client ID
* CODELEDGER_OAUTH_CLIENT_SECRET — OAuth App client secret
* SESSION_SECRET — random secret, signs the OAuth state cookie
*
* Optional:
* CANONICAL_UPLOAD_TOKEN — random token for /api/admin/canonical.
* When unset, that endpoint is closed.
* CODELEDGER_GH_APP_WEBHOOK_SECRET — webhook HMAC secret. When unset, the
* webhook endpoint refuses every request.
*
* Bindings: CANONICAL_MAP (KV), ASSETS (worker/public — see wrangler.toml.example)
*
* NOTE ON AUTH MODEL: CodeLedger uses a classic OAuth App, not a GitHub App.
* A GitHub App's user-to-server token silently ignores the `scope` parameter and
* cannot create repositories on a personal account, which is what onboarding does
* first. The historical `CODELEDGER_GH_APP_*` names are still accepted so existing
* deployments keep working; see env() below.
*/
import { Hono } from "hono";
const app = new Hono();
/* ── Constants ────────────────────────────────────────────────────── */
// Kept in step with package.json by dev/sync-manifests.js — do not hand-edit.
const VERSION = "1.9.1";
/** Scopes the client may request. Anything else is rejected.
* public_repo is the default: it can create and push to public repos but
* cannot read private ones. Full `repo` is only for users who opt into a
* private ledger, and is requested at that moment rather than up front. */
const ALLOWED_SCOPES = new Set(["public_repo", "public_repo,workflow", "repo", "repo,workflow"]);
const DEFAULT_SCOPE = "public_repo,workflow";
// __Host- prefix: the browser refuses this cookie unless it is Secure, has
// Path=/ and no Domain attribute — so a sibling subdomain of the parent zone
// cannot plant its own state via cookie-tossing (Domain=.parent).
const STATE_COOKIE = "__Host-cl_oauth_state";
const STATE_TTL_MS = 10 * 60 * 1000;
/* ── CORS ─────────────────────────────────────────────────────────── */
/** Public, unauthenticated, read-only endpoints may be read cross-origin.
* Authenticated endpoints deliberately get NO CORS headers so a browser will
* not let another origin read their responses. */
const PUBLIC_CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
app.options("/api/health", () => new Response(null, { status: 204, headers: PUBLIC_CORS }));
app.options("/api/data/*", () => new Response(null, { status: 204, headers: PUBLIC_CORS }));
/* ── Env helper ───────────────────────────────────────────────────── */
const ENV_ALIASES = {
GH_CLIENT_ID: ["CODELEDGER_OAUTH_CLIENT_ID", "CODELEDGER_GH_APP_CLIENT_ID", "GITHUB_CLIENT_ID"],
GH_CLIENT_SECRET: [
"CODELEDGER_OAUTH_CLIENT_SECRET",
"CODELEDGER_GH_APP_CLIENT_SECRET",
"GITHUB_CLIENT_SECRET",
],
GH_WEBHOOK_SECRET: ["CODELEDGER_GH_APP_WEBHOOK_SECRET", "GITHUB_APP_WEBHOOK_SECRET"],
};
/**
* Which alias actually supplied a value, or undefined if none did.
*
* The ID and the secret resolve through separate chains, so setting only the
* new `CODELEDGER_OAUTH_CLIENT_ID` on a deployment that still holds an old
* `CODELEDGER_GH_APP_CLIENT_SECRET` pairs one app's ID with another app's
* secret. GitHub answers that with "client_id and/or client_secret passed are
* incorrect", which names neither, and `wrangler secret list` shows both
* present and looks right. Naming the winning alias is what makes the
* mismatch visible.
*
* @param {any} c
* @param {string} key
* @returns {string|undefined}
*/
function envSource(c, key) {
for (const name of ENV_ALIASES[key] || [key]) {
if (c.env?.[name]) return name;
}
return undefined;
}
function env(c, key) {
const name = envSource(c, key);
// Trailing whitespace survives `wrangler secret put` when the value is pasted
// with a newline. An untrimmed ID still passes the authorize redirect — it is
// percent-encoded and GitHub tolerates it — and then fails the token
// exchange, where the value is compared byte for byte.
return name === undefined ? undefined : String(c.env[name]).trim();
}
/**
* Describe a secret's shape without printing it.
*
* Used when a configured value fails its format check and the operator needs to
* know how. Reports length and character classes only — enough to tell a typo
* from a failed paste from a value set on the wrong worker, and not enough to
* reconstruct anything.
*
* @param {string} value
* @returns {string}
*/
export function describeSecretShape(value) {
const s = String(value ?? "");
if (s.length === 0) return "empty";
const printable = [...s].filter((ch) => {
const cp = ch.codePointAt(0);
return cp >= 0x20 && cp !== 0x7f;
}).length;
const unprintable = s.length - printable;
const plural = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
if (unprintable === s.length) {
return (
`${plural(s.length, "non-printable character")} — this is what a terminal that ` +
"cannot handle Ctrl+V records when you try to paste"
);
}
const parts = [plural(s.length, "character")];
if (unprintable > 0) parts.push(`${unprintable} of them non-printable`);
if (/\s/.test(s)) parts.push("with whitespace inside it");
if (/^[A-Za-z0-9._-]+$/.test(s)) parts.push("too short to be a client ID");
else parts.push("containing characters a client ID never has");
return parts.join(", ");
}
/* ── Encoding helpers ─────────────────────────────────────────────── */
/** Escapes text for interpolation into HTML text nodes and quoted attributes. */
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/** Serialises a value for embedding inside a <script> block.
* JSON.stringify alone is unsafe here: it does not escape `</script>`, so a
* string containing that sequence would terminate the block and let the rest
* be parsed as markup. U+2028/U+2029 are valid in JSON but are JavaScript
* line terminators, so they are escaped as well. */
function jsonForScript(value) {
return JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
function bytesToHex(buf) {
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/** Length-independent, constant-time string comparison.
* A plain `!==` leaks how many leading characters matched via timing. */
function timingSafeEqual(a, b) {
const enc = new TextEncoder();
const ab = enc.encode(String(a));
const bb = enc.encode(String(b));
// Compare fixed-width digests so differing lengths do not short-circuit.
let diff = ab.length ^ bb.length;
const len = Math.max(ab.length, bb.length);
for (let i = 0; i < len; i++) {
diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
}
return diff === 0;
}
async function hmacHex(secret, message) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
return bytesToHex(await crypto.subtle.sign("HMAC", key, enc.encode(message)));
}
/* ── OAuth state (CSRF protection) ────────────────────────────────── */
/** Builds an opaque, tamper-evident state token: <nonce>.<issuedAt>.<hmac>.
* Without this, an attacker can complete an OAuth flow with their own code and
* silently bind the victim's extension to the attacker's GitHub account. */
async function issueState(secret) {
const nonce = bytesToHex(crypto.getRandomValues(new Uint8Array(16)));
const issuedAt = Date.now();
const body = `${nonce}.${issuedAt}`;
return `${body}.${await hmacHex(secret, body)}`;
}
async function verifyState(secret, state) {
if (!state) return false;
const parts = String(state).split(".");
if (parts.length !== 3) return false;
const [nonce, issuedAt, mac] = parts;
const expected = await hmacHex(secret, `${nonce}.${issuedAt}`);
if (!timingSafeEqual(mac, expected)) return false;
const ts = Number(issuedAt);
if (!Number.isFinite(ts)) return false;
return Date.now() - ts < STATE_TTL_MS;
}
function readCookie(c, name) {
const raw = c.req.header("cookie") || "";
for (const part of raw.split(";")) {
const [k, ...v] = part.trim().split("=");
if (k === name) {
// A cookie an attacker (or a broken client) set to malformed
// percent-encoding must read as absent, not throw a bare 500.
try {
return decodeURIComponent(v.join("="));
} catch {
return "";
}
}
}
return "";
}
/* ── OAuth callback page ──────────────────────────────────────────── */
/**
* Returns an HTML page that hands CODELEDGER_AUTH to the extension, then closes.
* The token travels via a DOM attribute read by the content script at
* document_end; the same-page postMessage is a fallback for timing races.
*
* Every interpolation below is escaped: `error` originates from GitHub's
* redirect query string and is therefore attacker-controlled.
*/
function authCallbackHtml(provider, token, error = "", nonce = "") {
const payload = { type: "CODELEDGER_AUTH", provider, token, error };
const attr = escapeHtml(JSON.stringify(payload));
const status = token
? "Authentication successful. Closing…"
: `Authentication failed: ${escapeHtml(error || "unknown error")}`;
const n = escapeHtml(nonce);
return `<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><title>CodeLedger Auth</title>
<style nonce="${n}">body{font-family:system-ui,sans-serif;background:#050508;color:#e2e8f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}</style>
</head><body>
<div id="codeledger-auth-result" data-auth="${attr}" style="display:none"></div>
<p>${status}</p>
<script nonce="${n}">
(function(){
var msg = ${jsonForScript(payload)};
// Fallback only: delivered to this window alone, never broadcast with '*'.
try{window.postMessage(msg, window.location.origin);}catch(e){}
// Fires only if the content script fails to close the popup itself.
if(${jsonForScript(!!token)}) setTimeout(function(){try{window.close();}catch(e){}},5000);
})();
</script>
</body></html>`;
}
/* ── Routes ───────────────────────────────────────────────────────── */
app.get("/api/health", (c) =>
c.json({ ok: true, version: VERSION, ts: Date.now() }, 200, PUBLIC_CORS),
);
// OAuth: redirect to provider
app.get("/api/auth/:provider", async (c) => {
const provider = c.req.param("provider")?.toLowerCase();
if (provider !== "github") {
return c.json({ error: `Unsupported provider: ${provider}` }, 404);
}
const clientId = env(c, "GH_CLIENT_ID")?.trim();
if (!clientId) {
return c.text("GitHub OAuth not configured — set CODELEDGER_OAUTH_CLIENT_ID", 500);
}
// A present-but-malformed client ID used to sail through the emptiness check
// above and get percent-encoded into the authorize URL, where GitHub bounced
// it to a generic login page — a broken sign-in with nothing anywhere saying
// why. Production held a single 0x16 byte, which is what a terminal that does
// not handle Ctrl+V records when you try to paste into `wrangler secret put`.
// GitHub's IDs are 20 characters of `Ov23li…`/`Iv23li…`; anything that is not
// plausibly one of those is a misconfiguration, and should say so here.
if (!/^[A-Za-z0-9._-]{10,}$/.test(clientId)) {
// Say what arrived, not just that it was wrong. The first time this fired,
// the stored value was a single 0x16 byte and the message alone was not
// enough to work out that the re-set had failed the same way again. A
// client ID is public — it goes out in the authorize URL to every user —
// so describing its shape here leaks nothing, and the description is what
// tells you whether you typed a bad value, set the wrong secret, or set
// the right secret on the wrong worker.
return c.text(
"GitHub OAuth is misconfigured: CODELEDGER_OAUTH_CLIENT_ID is set but is not a " +
`client ID. It is ${describeSecretShape(clientId)}; a client ID is 20 characters ` +
"of letters and digits, beginning `Ov23li` for an OAuth App or `Iv23li` for a " +
"GitHub App. Re-set it with `wrangler secret put CODELEDGER_OAUTH_CLIENT_ID` and " +
"type or right-click-paste the value — Ctrl+V does not paste in that prompt.",
500,
);
}
// Trimmed like env(): a value pasted with a trailing newline must sign and
// verify with the same bytes, not silently mismatch itself.
const sessionSecret = String(c.env?.SESSION_SECRET || "").trim();
if (!sessionSecret) {
return c.text("GitHub OAuth not configured — set SESSION_SECRET", 500);
}
// Callers may request a wider scope (private ledger); anything unrecognised
// falls back to the least-privilege default rather than being passed through.
const requested = c.req.query("scope");
const scope = ALLOWED_SCOPES.has(requested) ? requested : DEFAULT_SCOPE;
const origin = new URL(c.req.url).origin;
const redirectUri = `${origin}/api/auth/github/callback`;
const state = await issueState(sessionSecret);
const url =
`https://github.com/login/oauth/authorize?client_id=${encodeURIComponent(clientId)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&scope=${encodeURIComponent(scope)}` +
`&state=${encodeURIComponent(state)}` +
`&allow_signup=true`;
// SameSite=Lax is correct here: the callback arrives as a top-level GET
// navigation from github.com, which Lax permits.
return new Response(null, {
status: 302,
headers: {
Location: url,
"Set-Cookie": `${STATE_COOKIE}=${encodeURIComponent(state)}; Path=/; Max-Age=600; HttpOnly; Secure; SameSite=Lax`,
},
});
});
// GitHub OAuth callback
app.get("/api/auth/github/callback", async (c) => {
const clearCookie = `${STATE_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
// This page carries a repo-scoped OAuth token in the DOM, so it is the one
// response in the worker where an escaping mistake costs the user their
// repository. The interpolations below it are escaped, and this is the
// second lock: a per-response nonce means injected markup cannot execute
// even if it reaches the page, and `default-src 'none'` means it cannot
// reach a network endpoint to send the token to. `frame-ancestors 'none'`
// because nothing should ever embed the sign-in result.
const reply = (token, error) => {
const nonce = crypto.randomUUID();
return new Response(authCallbackHtml("github", token, error, nonce), {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Set-Cookie": clearCookie,
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Content-Security-Policy": [
"default-src 'none'",
`script-src 'nonce-${nonce}'`,
`style-src 'nonce-${nonce}'`,
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'none'",
].join("; "),
},
});
};
const code = c.req.query("code");
const error = c.req.query("error");
const errorDesc = c.req.query("error_description");
// CSRF: the state echoed by GitHub must match the one we issued in this
// browser. This runs BEFORE the ?error= branch: GitHub echoes the state on
// error redirects too, so a genuine denial still passes, while a bare
// unauthenticated GET with ?error= can no longer clear a victim's pending
// state cookie or render attacker-chosen text in the trusted auth popup.
const sessionSecret = String(c.env?.SESSION_SECRET || "").trim();
const returned = c.req.query("state");
const stored = readCookie(c, STATE_COOKIE);
if (!sessionSecret) return reply("", "OAuth not configured on server");
if (!returned || !stored || !timingSafeEqual(returned, stored)) {
return reply("", "Authorization state mismatch — please start the sign-in again");
}
if (!(await verifyState(sessionSecret, returned))) {
return reply("", "Authorization request expired — please start the sign-in again");
}
if (error) return reply("", errorDesc || error);
if (!code) return reply("", "No code received from GitHub");
const clientId = env(c, "GH_CLIENT_ID");
const clientSecret = env(c, "GH_CLIENT_SECRET");
if (!clientId || !clientSecret) return reply("", "OAuth not configured on server");
try {
const res = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: `${new URL(c.req.url).origin}/api/auth/github/callback`,
}),
});
const data = await res.json();
// A GitHub App issues user-to-server tokens: it ignores the `scope` we
// requested and returns an expiring token instead. Such a token cannot call
// POST /user/repos, which surfaces much later as an unexplained 403 during
// repository creation. Refuse it here, where the cause is still visible.
if (data.access_token && (data.expires_in || data.refresh_token) && !data.scope) {
return reply(
"",
"This server is configured with a GitHub App client ID. CodeLedger needs a " +
"classic OAuth App — set CODELEDGER_OAUTH_CLIENT_ID and " +
"CODELEDGER_OAUTH_CLIENT_SECRET from an OAuth App registration.",
);
}
// "incorrect client_id and/or client_secret" names neither half, and both
// are present, so `wrangler secret list` looks correct. Say which alias
// supplied each — a pair drawn from two different chains is the usual
// cause and is otherwise invisible.
if (data.error === "incorrect_client_credentials") {
const idFrom = envSource(c, "GH_CLIENT_ID");
const secretFrom = envSource(c, "GH_CLIENT_SECRET");
return reply(
"",
`GitHub rejected the client credentials. The client ID came from ${idFrom} and ` +
`the client secret from ${secretFrom}; they must both belong to the same OAuth ` +
`App registration. Re-set both with \`wrangler secret put\`, and delete any ` +
`leftover CODELEDGER_GH_APP_CLIENT_ID / CODELEDGER_GH_APP_CLIENT_SECRET so they ` +
`cannot supply half the pair.`,
);
}
return reply(data.access_token || "", data.error_description || data.error || "");
} catch (e) {
return reply("", e.message);
}
});
// GitHub webhook receiver
app.post("/api/webhook/github", async (c) => {
const secret = env(c, "GH_WEBHOOK_SECRET");
// Fail closed. Previously an unset secret skipped verification entirely,
// leaving the endpoint open to forged deliveries.
if (!secret) return c.text("Webhook not configured", 503);
const sigHeader = c.req.header("x-hub-signature-256") || "";
const bodyText = await c.req.text();
const expected = `sha256=${await hmacHex(secret, bodyText)}`;
if (!timingSafeEqual(sigHeader, expected)) return c.text("Invalid signature", 401);
return c.json({ ok: true }, 200);
});
// Canonical map: read from KV or GitHub raw fallback
app.get("/api/data/canonical-map.json", async (c) => {
const headers = {
...PUBLIC_CORS,
"Content-Type": "application/json",
"Cache-Control": "public, max-age=3600",
};
try {
const kv = c.env?.CANONICAL_MAP;
if (kv) {
const v = await kv.get("canonical-map");
if (v) return new Response(v, { status: 200, headers });
}
} catch (_) {}
try {
const res = await fetch(
"https://raw.githubusercontent.com/Life-Experimentalist/Code-Ledger/refs/heads/main/src/data/canonical-map.json",
);
if (!res.ok) return c.json({ error: "Canonical map unavailable" }, 503, PUBLIC_CORS);
return new Response(await res.text(), { status: 200, headers });
} catch (e) {
return c.json({ error: "Canonical map unavailable" }, 503, PUBLIC_CORS);
}
});
/** The same shape dev/build-canonical-map.js enforces before an upload: a
* non-empty entries array (bare or under `entries`), every entry carrying the
* four required fields plus at least one platform alias. The KV value is
* served verbatim to every install, so a malformed upload here would break
* canonical resolution fleet-wide for the full cache TTL. */
function canonicalMapProblems(json) {
const entries = Array.isArray(json)
? json
: json && Array.isArray(json.entries)
? json.entries
: null;
if (!entries || !entries.length) return "must contain a non-empty entries array";
for (const e of entries) {
if (!e || typeof e !== "object") return "every entry must be an object";
if (!e.canonicalId || !e.canonicalTitle || !e.topic || !e.difficulty) {
return `entry ${JSON.stringify(e.canonicalId ?? null)} is missing canonicalId/canonicalTitle/topic/difficulty`;
}
const aliases = Array.isArray(e.aliases)
? e.aliases.filter((a) => a && a.platform && a.slug)
: e.platforms && typeof e.platforms === "object"
? Object.values(e.platforms).filter(Boolean)
: [];
if (!aliases.length) return `entry ${JSON.stringify(e.canonicalId)} has no platform aliases`;
}
return "";
}
// KV values cap at 25 MB; the real map is well under 5. Anything near the
// limit is a mistake, and letting kv.put throw would surface as a bare 500.
const CANONICAL_MAX_BYTES = 10 * 1024 * 1024;
// Canonical map: admin update (protected — deliberately no CORS headers)
app.post("/api/admin/canonical", async (c) => {
const expected = String(c.env?.CANONICAL_UPLOAD_TOKEN || "").trim();
if (!expected) return c.json({ error: "Upload not configured" }, 503);
const auth = c.req.header("Authorization") || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : auth;
if (!token || !timingSafeEqual(token, expected)) {
return c.json({ error: "Unauthorized" }, 401);
}
const kv = c.env?.CANONICAL_MAP;
if (!kv) return c.json({ error: "KV not bound" }, 500);
const body = await c.req.text();
if (new TextEncoder().encode(body).length > CANONICAL_MAX_BYTES) {
return c.json({ error: "Body exceeds the canonical map size limit" }, 413);
}
let json;
try {
json = JSON.parse(body);
} catch {
return c.json({ error: "Body must be valid JSON" }, 400);
}
const problem = canonicalMapProblems(json);
if (problem) return c.json({ error: `Not a canonical map: ${problem}` }, 400);
await kv.put("canonical-map", body);
return c.json({ ok: true }, 200);
});
// favicon fallback — redirect to the extension icon
app.get("/favicon.ico", (c) =>
c.redirect(
"https://raw.githubusercontent.com/Life-Experimentalist/Code-Ledger/main/src/assets/images/icon-transparent.png",
301,
),
);
// Static assets via ASSETS binding — must be last
app.get("/*", async (c) => {
if (c.env?.ASSETS) {
try {
const res = await c.env.ASSETS.fetch(c.req.raw);
if (res.status !== 404) return res;
} catch (_) {}
}
return c.notFound();
});
export default app;