@micacms/runtime 0.5.0 → 0.6.0-rc.2

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 (94) hide show
  1. package/README.md +3 -1
  2. package/dist/app.js +1 -1
  3. package/dist/app.js.map +1 -1
  4. package/dist/config.d.ts +13 -13
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +9 -9
  7. package/dist/config.js.map +1 -1
  8. package/dist/context.js +2 -2
  9. package/dist/context.js.map +1 -1
  10. package/dist/http.d.ts +1 -3
  11. package/dist/http.d.ts.map +1 -1
  12. package/dist/http.js +2 -9
  13. package/dist/http.js.map +1 -1
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/lib/errors.d.ts +1 -2
  19. package/dist/lib/errors.d.ts.map +1 -1
  20. package/dist/lib/errors.js +0 -2
  21. package/dist/lib/errors.js.map +1 -1
  22. package/dist/lib/file-type.d.ts.map +1 -1
  23. package/dist/lib/file-type.js +2 -8
  24. package/dist/lib/file-type.js.map +1 -1
  25. package/dist/lib/id.d.ts +0 -10
  26. package/dist/lib/id.d.ts.map +1 -1
  27. package/dist/lib/id.js +0 -50
  28. package/dist/lib/id.js.map +1 -1
  29. package/dist/lib/rate-limit.d.ts +0 -2
  30. package/dist/lib/rate-limit.d.ts.map +1 -1
  31. package/dist/lib/rate-limit.js +0 -3
  32. package/dist/lib/rate-limit.js.map +1 -1
  33. package/dist/middleware/index.d.ts +1 -1
  34. package/dist/middleware/index.js +2 -2
  35. package/dist/middleware/index.js.map +1 -1
  36. package/dist/routes/admin-content.d.ts.map +1 -1
  37. package/dist/routes/admin-content.js +3 -2
  38. package/dist/routes/admin-content.js.map +1 -1
  39. package/dist/routes/admin-system.js +4 -4
  40. package/dist/routes/admin-system.js.map +1 -1
  41. package/dist/schema.d.ts +6 -0
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +19 -1
  44. package/dist/schema.js.map +1 -1
  45. package/dist/services/changes.d.ts.map +1 -1
  46. package/dist/services/changes.js +89 -13
  47. package/dist/services/changes.js.map +1 -1
  48. package/dist/services/content-store.d.ts.map +1 -1
  49. package/dist/services/content-store.js +9 -10
  50. package/dist/services/content-store.js.map +1 -1
  51. package/dist/services/staging.d.ts.map +1 -1
  52. package/dist/services/staging.js +7 -4
  53. package/dist/services/staging.js.map +1 -1
  54. package/dist/workspace/developer.d.ts +2 -1
  55. package/dist/workspace/developer.d.ts.map +1 -1
  56. package/dist/workspace/developer.js +17 -3
  57. package/dist/workspace/developer.js.map +1 -1
  58. package/dist/workspace/tree.d.ts +6 -0
  59. package/dist/workspace/tree.d.ts.map +1 -1
  60. package/dist/workspace/tree.js +28 -0
  61. package/dist/workspace/tree.js.map +1 -1
  62. package/package.json +12 -4
  63. package/src/app.ts +127 -0
  64. package/src/config.ts +113 -0
  65. package/src/context.ts +167 -0
  66. package/src/http.ts +46 -0
  67. package/src/index.ts +141 -0
  68. package/src/lib/errors.ts +84 -0
  69. package/src/lib/file-type.ts +102 -0
  70. package/src/lib/id.ts +6 -0
  71. package/src/lib/logger.ts +87 -0
  72. package/src/lib/ports.ts +17 -0
  73. package/src/lib/rate-limit.ts +72 -0
  74. package/src/lib/schema-stats.ts +183 -0
  75. package/src/lib/version.ts +29 -0
  76. package/src/middleware/index.ts +234 -0
  77. package/src/routes/admin-content.ts +197 -0
  78. package/src/routes/admin-media.ts +96 -0
  79. package/src/routes/admin-spa.ts +82 -0
  80. package/src/routes/admin-staging.ts +126 -0
  81. package/src/routes/admin-system.ts +146 -0
  82. package/src/routes/health.ts +67 -0
  83. package/src/schema.ts +217 -0
  84. package/src/server.ts +68 -0
  85. package/src/services/changes.ts +605 -0
  86. package/src/services/content-store.ts +492 -0
  87. package/src/services/media.ts +229 -0
  88. package/src/services/staging.ts +330 -0
  89. package/src/workspace/developer.ts +58 -0
  90. package/src/workspace/files.ts +243 -0
  91. package/src/workspace/git.ts +108 -0
  92. package/src/workspace/locks.ts +103 -0
  93. package/src/workspace/policy.ts +77 -0
  94. package/src/workspace/tree.ts +314 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Structured logging.
3
+ *
4
+ * JSON lines in production so a log shipper can parse them; a compact
5
+ * human-readable form in development.
6
+ */
7
+
8
+ export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
9
+
10
+ const ORDER: Record<LogLevel, number> = {
11
+ debug: 10,
12
+ info: 20,
13
+ warn: 30,
14
+ error: 40,
15
+ silent: 100,
16
+ };
17
+
18
+ export interface Logger {
19
+ debug(message: string, fields?: Record<string, unknown>): void;
20
+ info(message: string, fields?: Record<string, unknown>): void;
21
+ warn(message: string, fields?: Record<string, unknown>): void;
22
+ error(message: string, fields?: Record<string, unknown>): void;
23
+ child(bindings: Record<string, unknown>): Logger;
24
+ }
25
+
26
+ const COLORS: Record<Exclude<LogLevel, "silent">, string> = {
27
+ debug: "",
28
+ info: "",
29
+ warn: "",
30
+ error: "",
31
+ };
32
+
33
+ export function createLogger(options: {
34
+ level: LogLevel;
35
+ pretty: boolean;
36
+ bindings?: Record<string, unknown>;
37
+ }): Logger {
38
+ const threshold = ORDER[options.level];
39
+ const bindings = options.bindings ?? {};
40
+
41
+ const write = (level: Exclude<LogLevel, "silent">, message: string, fields?: Record<string, unknown>) => {
42
+ if (ORDER[level] < threshold) return;
43
+
44
+ const payload = { ...bindings, ...fields };
45
+ const stream = level === "error" || level === "warn" ? process.stderr : process.stdout;
46
+
47
+ if (options.pretty) {
48
+ const extra = Object.keys(payload).length > 0 ? ` ${formatFields(payload)}` : "";
49
+ stream.write(`${COLORS[level]}${level.padEnd(5)} ${message}${extra}\n`);
50
+ return;
51
+ }
52
+
53
+ stream.write(
54
+ `${JSON.stringify({
55
+ time: new Date().toISOString(),
56
+ level,
57
+ message,
58
+ ...payload,
59
+ })}\n`,
60
+ );
61
+ };
62
+
63
+ return {
64
+ debug: (message, fields) => write("debug", message, fields),
65
+ info: (message, fields) => write("info", message, fields),
66
+ warn: (message, fields) => write("warn", message, fields),
67
+ error: (message, fields) => write("error", message, fields),
68
+ child: (extra) =>
69
+ createLogger({ ...options, bindings: { ...bindings, ...extra } }),
70
+ };
71
+ }
72
+
73
+ function formatFields(fields: Record<string, unknown>): string {
74
+ return Object.entries(fields)
75
+ .map(([key, value]) => `${key}=${format(value)}`)
76
+ .join(" ");
77
+ }
78
+
79
+ function format(value: unknown): string {
80
+ if (typeof value === "string") return value;
81
+ if (value instanceof Error) return value.stack ?? value.message;
82
+ try {
83
+ return JSON.stringify(value) ?? String(value);
84
+ } catch {
85
+ return String(value);
86
+ }
87
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The port Mica listens on when nobody says otherwise.
3
+ *
4
+ * 6422 is `MICA` on a telephone keypad, which is the only reason anybody will
5
+ * remember it. What matters more is what it is not: 3000, 4000, 4173, 4200,
6
+ * 4321, 5000, 5173, 8000, 8080 and 1313 all belong to something a developer is
7
+ * likely to be running beside Mica — Astro's dev server most of all — and IANA
8
+ * has 6420 and 6421 assigned but not 6422, so nothing on the machine is
9
+ * expected to want it.
10
+ *
11
+ * A port collision is a bad first minute: the command fails, and the reason is
12
+ * somebody else's software. Picking an unfashionable number is the whole fix.
13
+ */
14
+ export const DEFAULT_PORT = 6422;
15
+
16
+ /** The address the CLI prints and the browser opens. */
17
+ export const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Fixed-window rate limiting, in memory.
3
+ *
4
+ * The CMS is a single process by design, so an in-memory counter is
5
+ * sufficient and avoids a Redis dependency. The trade-off is deliberate:
6
+ * counters reset on restart. Mica checks no passwords; the identity provider
7
+ * does, with its own limits, so a restart-and-retry loop gains little.
8
+ */
9
+
10
+ export interface RateLimitResult {
11
+ allowed: boolean;
12
+ remaining: number;
13
+ /** Seconds until the window resets. */
14
+ retryAfter: number;
15
+ }
16
+
17
+ export interface RateLimiter {
18
+ check(key: string): RateLimitResult;
19
+ size(): number;
20
+ stop(): void;
21
+ }
22
+
23
+ export interface RateLimiterOptions {
24
+ limit: number;
25
+ windowMs: number;
26
+ /** How often expired buckets are swept. */
27
+ sweepMs?: number;
28
+ }
29
+
30
+ export function createRateLimiter(options: RateLimiterOptions): RateLimiter {
31
+ const buckets = new Map<string, { count: number; resetAt: number }>();
32
+
33
+ const sweep = setInterval(() => {
34
+ const now = Date.now();
35
+ for (const [key, bucket] of buckets) {
36
+ if (bucket.resetAt <= now) buckets.delete(key);
37
+ }
38
+ }, options.sweepMs ?? Math.max(options.windowMs, 60_000));
39
+
40
+ // A sweep timer must not keep the process alive on shutdown.
41
+ sweep.unref?.();
42
+
43
+ return {
44
+ check(key) {
45
+ const now = Date.now();
46
+ const bucket = buckets.get(key);
47
+
48
+ if (!bucket || bucket.resetAt <= now) {
49
+ buckets.set(key, { count: 1, resetAt: now + options.windowMs });
50
+ return { allowed: true, remaining: options.limit - 1, retryAfter: 0 };
51
+ }
52
+
53
+ bucket.count++;
54
+ const retryAfter = Math.ceil((bucket.resetAt - now) / 1000);
55
+
56
+ if (bucket.count > options.limit) {
57
+ return { allowed: false, remaining: 0, retryAfter };
58
+ }
59
+
60
+ return { allowed: true, remaining: options.limit - bucket.count, retryAfter };
61
+ },
62
+
63
+ size() {
64
+ return buckets.size;
65
+ },
66
+
67
+ stop() {
68
+ clearInterval(sweep);
69
+ buckets.clear();
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Summarises a content model.
3
+ *
4
+ * The admin already receives the whole model and could count fields itself, but
5
+ * the interesting numbers are the recursive ones — objects and repeaters nest —
6
+ * and the relation graph, which is only visible after walking every branch.
7
+ * Doing it here keeps that walk in one place, next to the schema checks that
8
+ * use the same shapes.
9
+ *
10
+ * Pure: it reads a `Site` and nothing else, so it is the same answer for the
11
+ * same model whatever the state of the database.
12
+ */
13
+
14
+ import {
15
+ listedFields,
16
+ resolveSlugField,
17
+ resolveTitleField,
18
+ type AnyField,
19
+ type FieldKind,
20
+ type FieldMap,
21
+ type Site,
22
+ } from "@micacms/core";
23
+
24
+ export interface KindUsage {
25
+ kind: FieldKind;
26
+ count: number;
27
+ }
28
+
29
+ export interface RelationEdge {
30
+ /** Collection or singleton the field is declared in. */
31
+ from: string;
32
+ fromType: "collection" | "singleton";
33
+ /** Dotted path to the field, with `[]` marking a repeater. */
34
+ path: string;
35
+ /** Collection the relation points at. */
36
+ to: string;
37
+ multiple: boolean;
38
+ }
39
+
40
+ export interface FieldCounts {
41
+ /** Fields declared directly on the collection or singleton. */
42
+ top: number;
43
+ /** Every field, including those nested in objects and repeaters. */
44
+ total: number;
45
+ required: number;
46
+ }
47
+
48
+ export interface CollectionStats {
49
+ name: string;
50
+ label: string;
51
+ fields: FieldCounts;
52
+ titleField: string | null;
53
+ slugField: string | null;
54
+ listed: number;
55
+ sortable: boolean;
56
+ /** Deepest level of nesting; 1 means no objects or repeaters. */
57
+ depth: number;
58
+ }
59
+
60
+ export interface SingletonStats {
61
+ name: string;
62
+ label: string;
63
+ fields: FieldCounts;
64
+ depth: number;
65
+ }
66
+
67
+ export interface SchemaStats {
68
+ collections: CollectionStats[];
69
+ singletons: SingletonStats[];
70
+ totals: FieldCounts & { collections: number; singletons: number };
71
+ /** Field kinds in use, most used first. */
72
+ kinds: KindUsage[];
73
+ relations: RelationEdge[];
74
+ }
75
+
76
+ /** The two kinds that carry a nested `fields` map. */
77
+ function children(field: AnyField): FieldMap | null {
78
+ return field.kind === "object" || field.kind === "repeater" ? field.fields : null;
79
+ }
80
+
81
+ /**
82
+ * Depth-first walk over a field map, visiting nested fields too.
83
+ *
84
+ * `path` is what an editor would say out loud — `blocks[].heading` rather than
85
+ * `blocks.fields.heading` — so it can be printed as-is.
86
+ */
87
+ function walk(
88
+ fields: FieldMap,
89
+ visit: (key: string, field: AnyField, path: string, depth: number) => void,
90
+ prefix = "",
91
+ depth = 1,
92
+ ): void {
93
+ for (const [key, field] of Object.entries(fields)) {
94
+ const path = prefix ? `${prefix}.${key}` : key;
95
+ visit(key, field, path, depth);
96
+
97
+ const nested = children(field);
98
+ if (nested) {
99
+ walk(nested, visit, field.kind === "repeater" ? `${path}[]` : path, depth + 1);
100
+ }
101
+ }
102
+ }
103
+
104
+ function countFields(fields: FieldMap): { counts: FieldCounts; depth: number } {
105
+ const counts: FieldCounts = { top: Object.keys(fields).length, total: 0, required: 0 };
106
+ let deepest = 1;
107
+
108
+ walk(fields, (_key, field, _path, depth) => {
109
+ counts.total++;
110
+ if (field.required) counts.required++;
111
+ if (depth > deepest) deepest = depth;
112
+ });
113
+
114
+ return { counts, depth: deepest };
115
+ }
116
+
117
+ export function schemaStats(site: Site): SchemaStats {
118
+ const kinds = new Map<FieldKind, number>();
119
+ const relations: RelationEdge[] = [];
120
+
121
+ const totals: FieldCounts & { collections: number; singletons: number } = {
122
+ collections: 0,
123
+ singletons: 0,
124
+ top: 0,
125
+ total: 0,
126
+ required: 0,
127
+ };
128
+
129
+ const tally = (fields: FieldMap, from: string, fromType: RelationEdge["fromType"]): void => {
130
+ walk(fields, (_key, field, path) => {
131
+ kinds.set(field.kind, (kinds.get(field.kind) ?? 0) + 1);
132
+
133
+ if (field.kind === "relation") {
134
+ relations.push({ from, fromType, path, to: field.collection, multiple: field.multiple ?? false });
135
+ }
136
+ });
137
+ };
138
+
139
+ const collections = Object.values(site.collections).map((def): CollectionStats => {
140
+ const { counts, depth } = countFields(def.fields);
141
+ tally(def.fields, def.name, "collection");
142
+
143
+ totals.collections++;
144
+ totals.top += counts.top;
145
+ totals.total += counts.total;
146
+ totals.required += counts.required;
147
+
148
+ return {
149
+ name: def.name,
150
+ label: def.label,
151
+ fields: counts,
152
+ // The same resolution the editor sees, not the raw declaration: both keys
153
+ // are optional in the model and fall back to a convention.
154
+ titleField: resolveTitleField(def) ?? null,
155
+ slugField: resolveSlugField(def) ?? null,
156
+ listed: listedFields(def).length,
157
+ sortable: def.sortable ?? false,
158
+ depth,
159
+ };
160
+ });
161
+
162
+ const singletons = Object.values(site.singletons).map((def): SingletonStats => {
163
+ const { counts, depth } = countFields(def.fields);
164
+ tally(def.fields, def.name, "singleton");
165
+
166
+ totals.singletons++;
167
+ totals.top += counts.top;
168
+ totals.total += counts.total;
169
+ totals.required += counts.required;
170
+
171
+ return { name: def.name, label: def.label, fields: counts, depth };
172
+ });
173
+
174
+ return {
175
+ collections,
176
+ singletons,
177
+ totals,
178
+ kinds: [...kinds]
179
+ .map(([kind, count]) => ({ kind, count }))
180
+ .sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind)),
181
+ relations,
182
+ };
183
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The version of this build, read from the package manifest.
3
+ *
4
+ * Read rather than imported: an import would need a JSON module attribute and
5
+ * would put a second copy of the manifest in `dist/`. The file is a fixed
6
+ * distance from this module in both layouts — `src/lib` and `dist/lib`
7
+ * are the same depth below the package root, and the Docker image copies
8
+ * `package.json` next to `dist/` — so one path serves both.
9
+ *
10
+ * A missing or unreadable manifest is not worth failing a request over; the
11
+ * system page shows "unknown" instead.
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ function readVersion(): string {
18
+ try {
19
+ const manifest = fs.readFileSync(
20
+ path.resolve(import.meta.dirname, "../../package.json"),
21
+ "utf8",
22
+ );
23
+ return (JSON.parse(manifest) as { version?: string }).version ?? "unknown";
24
+ } catch {
25
+ return "unknown";
26
+ }
27
+ }
28
+
29
+ export const VERSION = readVersion();
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Middleware.
3
+ *
4
+ * Ordering matters and is asserted in `app.ts`:
5
+ * requestContext → guards → securityHeaders → errorBoundary → rateLimit
6
+ * → originGuard → the auth surface's gate → route
7
+ *
8
+ * The gate itself is not here. Whether a request is anybody — a session
9
+ * middleware, `allowAll()` — belongs to the composing app, which is what
10
+ * keeps this package authentication-neutral.
11
+ */
12
+
13
+ import { ContentPathError } from "@micacms/core";
14
+ import type { ErrorHandler, MiddlewareHandler } from "hono";
15
+ import { bodyLimit } from "hono/body-limit";
16
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
17
+
18
+ import { ApiError, badRequest } from "../lib/errors.js";
19
+ import { clientIp, type Env } from "../http.js";
20
+ import { randomToken } from "../lib/id.js";
21
+
22
+ /** Attaches the container, a request id and a scoped logger; logs the result. */
23
+ export function requestContext(ctx: Env["Variables"]["ctx"]): MiddlewareHandler<Env> {
24
+ return async (c, next) => {
25
+ // A client's id is logged and echoed, so only a short, plain one is kept (#150).
26
+ const offered = c.req.header("x-request-id");
27
+ const requestId = offered && /^[A-Za-z0-9_-]{1,64}$/.test(offered) ? offered : randomToken(8);
28
+ const logger = ctx.logger.child({ requestId });
29
+
30
+ c.set("ctx", ctx);
31
+ c.set("requestId", requestId);
32
+ c.set("logger", logger);
33
+ c.header("x-request-id", requestId);
34
+
35
+ const startedAt = performance.now();
36
+ await next();
37
+ const duration = Math.round(performance.now() - startedAt);
38
+
39
+ const line = {
40
+ method: c.req.method,
41
+ path: new URL(c.req.url).pathname,
42
+ status: c.res.status,
43
+ duration,
44
+ };
45
+
46
+ /**
47
+ * "Am I signed in?" — "No."
48
+ *
49
+ * `/auth/me` is the SPA's first request on every page load, and a 401 is
50
+ * one of its two correct answers, not a rejection worth a warning. Logging
51
+ * it as one puts a scary line in the terminal of everybody who has simply
52
+ * opened the editor while signed out.
53
+ */
54
+ const expected401 = c.res.status === 401 && line.path === "/api/auth/me";
55
+
56
+ if (c.res.status >= 500) logger.error("Request failed", line);
57
+ else if (c.res.status >= 400 && !expected401) logger.warn("Request rejected", line);
58
+ else logger.debug("Request", line);
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Same-origin only. `blob:` and `data:` images cover previews; a route that
64
+ * needs a stricter policy, such as the sandbox on media files, sets its own.
65
+ */
66
+ export const CONTENT_SECURITY_POLICY = [
67
+ "default-src 'self'",
68
+ "img-src 'self' blob: data:",
69
+ "object-src 'none'",
70
+ "base-uri 'none'",
71
+ "frame-ancestors 'none'",
72
+ ].join("; ");
73
+
74
+ export function securityHeaders(): MiddlewareHandler<Env> {
75
+ return async (c, next) => {
76
+ await next();
77
+
78
+ if (!c.res.headers.has("content-security-policy")) {
79
+ c.header("content-security-policy", CONTENT_SECURITY_POLICY);
80
+ }
81
+ // Only for an https public URL: HSTS over plain HTTP is ignored, and on
82
+ // localhost it would pin a developer's other projects to https.
83
+ if (c.get("ctx").config.MICA_PUBLIC_URL.startsWith("https://")) {
84
+ c.header("strict-transport-security", "max-age=31536000");
85
+ }
86
+
87
+ c.header("x-content-type-options", "nosniff");
88
+ c.header("referrer-policy", "same-origin");
89
+ c.header("x-frame-options", "DENY");
90
+ // The admin is a first-party SPA; nothing here needs to be embedded.
91
+ c.header("permissions-policy", "geolocation=(), microphone=(), camera=()");
92
+ };
93
+ }
94
+
95
+ /** Files in one upload request; the Media view sends a selection in one request. */
96
+ export const MAX_FILES_PER_UPLOAD = 10;
97
+
98
+ /** Far more than any entry's JSON, and small enough to refuse a flood early. */
99
+ export const MAX_JSON_BYTES = 2 * 1024 * 1024;
100
+
101
+ /**
102
+ * Refuses an oversized body before a handler reads it into memory (#148). An
103
+ * upload may carry `MAX_FILES_PER_UPLOAD` files of `MICA_MAX_UPLOAD_BYTES`, plus
104
+ * room for the multipart framing; every other request gets `MAX_JSON_BYTES`.
105
+ */
106
+ export function requestSizeLimit(): MiddlewareHandler<Env> {
107
+ return async (c, next) => {
108
+ const { config } = c.get("ctx");
109
+ const upload = c.req.method === "POST" && c.req.path === "/api/admin/media";
110
+ const maxSize = upload
111
+ ? config.MICA_MAX_UPLOAD_BYTES * MAX_FILES_PER_UPLOAD + 1024 * 1024
112
+ : MAX_JSON_BYTES;
113
+
114
+ return bodyLimit({
115
+ maxSize,
116
+ onError: (ctx) => {
117
+ const refused = new ApiError(
118
+ "payload_too_large",
119
+ upload
120
+ ? "The upload is too large. Upload fewer or smaller files at a time."
121
+ : "The request is too large.",
122
+ );
123
+ return ctx.json(refused.toBody(), 413);
124
+ },
125
+ })(c, next);
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Converts thrown errors into the API error envelope. Deliberate failures
131
+ * (`ApiError`) are reported as-is; anything else is a bug and becomes a bare
132
+ * 500, with the stack kept in the log rather than sent to the client.
133
+ *
134
+ * Registered with `app.onError`, not as middleware: Hono's `compose` wraps each
135
+ * handler individually and routes a throw to `onError` at that level, so a
136
+ * try/catch in an outer middleware would never see it.
137
+ */
138
+ export function errorHandler(): ErrorHandler<Env> {
139
+ return (error, c) => {
140
+ const logger = c.get("logger") ?? c.get("ctx").logger;
141
+
142
+ if (error instanceof ApiError) {
143
+ logger.debug("Handled error", { code: error.code, message: error.message });
144
+ return c.json(error.toBody(), error.status as ContentfulStatusCode, error.headers);
145
+ }
146
+
147
+ // A path that could leave the content root is the client's mistake, not a bug.
148
+ if (error instanceof ContentPathError) {
149
+ logger.warn("Refused content path", { message: error.message });
150
+ const refused = badRequest(error.message);
151
+ return c.json(refused.toBody(), refused.status as ContentfulStatusCode);
152
+ }
153
+
154
+ logger.error("Unhandled error", {
155
+ error: error instanceof Error ? (error.stack ?? error.message) : String(error),
156
+ path: new URL(c.req.url).pathname,
157
+ });
158
+
159
+ return c.json(
160
+ {
161
+ error: {
162
+ code: "internal_error" as const,
163
+ message: "Something went wrong. Please try again.",
164
+ requestId: c.get("requestId"),
165
+ },
166
+ },
167
+ 500,
168
+ );
169
+ };
170
+ }
171
+
172
+ /** Coarse per-IP limit, applied to the whole API surface. */
173
+ export function rateLimit(): MiddlewareHandler<Env> {
174
+ return async (c, next) => {
175
+ const { limiters } = c.get("ctx");
176
+ const result = limiters.api.check(clientIp(c));
177
+
178
+ if (!result.allowed) {
179
+ throw new ApiError("rate_limited", "Too many requests. Please slow down.", {
180
+ headers: { "retry-after": String(result.retryAfter) },
181
+ });
182
+ }
183
+
184
+ await next();
185
+ };
186
+ }
187
+
188
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
189
+
190
+ const normaliseOrigin = (value: string): string => {
191
+ try {
192
+ return new URL(value).origin;
193
+ } catch {
194
+ return value;
195
+ }
196
+ };
197
+
198
+ export function allowedOrigins(config: {
199
+ MICA_PUBLIC_URL: string;
200
+ adminOrigins: string[];
201
+ }): string[] {
202
+ return [...new Set([config.MICA_PUBLIC_URL, ...config.adminOrigins].map(normaliseOrigin))];
203
+ }
204
+
205
+ /**
206
+ * First CSRF layer: the browser sets `Origin` on every state-changing request
207
+ * and a page on another site cannot forge it.
208
+ *
209
+ * Applied to the whole API — including sign-in, which has no session yet and
210
+ * so cannot use the token check below.
211
+ */
212
+ export function originGuard(): MiddlewareHandler<Env> {
213
+ return async (c, next) => {
214
+ if (SAFE_METHODS.has(c.req.method)) {
215
+ await next();
216
+ return;
217
+ }
218
+
219
+ const { config } = c.get("ctx");
220
+ const origin = c.req.header("origin");
221
+
222
+ if (origin) {
223
+ if (!allowedOrigins(config).includes(normaliseOrigin(origin))) {
224
+ throw new ApiError("forbidden", "Request origin is not allowed.");
225
+ }
226
+ } else if (config.isProduction) {
227
+ // A browser always sends Origin on writes; its absence means the request
228
+ // did not come from one. Tolerated in development for curl-based testing.
229
+ throw new ApiError("forbidden", "Missing Origin header.");
230
+ }
231
+
232
+ await next();
233
+ };
234
+ }