@micacms/runtime 0.5.0 → 0.6.0-rc.1

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 (87) 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/id.d.ts +0 -10
  23. package/dist/lib/id.d.ts.map +1 -1
  24. package/dist/lib/id.js +0 -50
  25. package/dist/lib/id.js.map +1 -1
  26. package/dist/lib/rate-limit.d.ts +0 -2
  27. package/dist/lib/rate-limit.d.ts.map +1 -1
  28. package/dist/lib/rate-limit.js +0 -3
  29. package/dist/lib/rate-limit.js.map +1 -1
  30. package/dist/middleware/index.d.ts +1 -1
  31. package/dist/middleware/index.js +2 -2
  32. package/dist/middleware/index.js.map +1 -1
  33. package/dist/routes/admin-content.d.ts.map +1 -1
  34. package/dist/routes/admin-content.js +3 -2
  35. package/dist/routes/admin-content.js.map +1 -1
  36. package/dist/routes/admin-system.js +4 -4
  37. package/dist/routes/admin-system.js.map +1 -1
  38. package/dist/schema.d.ts +6 -0
  39. package/dist/schema.d.ts.map +1 -1
  40. package/dist/schema.js +19 -1
  41. package/dist/schema.js.map +1 -1
  42. package/dist/services/changes.d.ts.map +1 -1
  43. package/dist/services/changes.js +89 -13
  44. package/dist/services/changes.js.map +1 -1
  45. package/dist/services/content-store.d.ts.map +1 -1
  46. package/dist/services/content-store.js +9 -10
  47. package/dist/services/content-store.js.map +1 -1
  48. package/dist/services/staging.d.ts.map +1 -1
  49. package/dist/services/staging.js +7 -4
  50. package/dist/services/staging.js.map +1 -1
  51. package/dist/workspace/tree.d.ts +6 -0
  52. package/dist/workspace/tree.d.ts.map +1 -1
  53. package/dist/workspace/tree.js +28 -0
  54. package/dist/workspace/tree.js.map +1 -1
  55. package/package.json +12 -4
  56. package/src/app.ts +127 -0
  57. package/src/config.ts +113 -0
  58. package/src/context.ts +167 -0
  59. package/src/http.ts +46 -0
  60. package/src/index.ts +141 -0
  61. package/src/lib/errors.ts +84 -0
  62. package/src/lib/file-type.ts +108 -0
  63. package/src/lib/id.ts +6 -0
  64. package/src/lib/logger.ts +87 -0
  65. package/src/lib/ports.ts +17 -0
  66. package/src/lib/rate-limit.ts +72 -0
  67. package/src/lib/schema-stats.ts +183 -0
  68. package/src/lib/version.ts +29 -0
  69. package/src/middleware/index.ts +234 -0
  70. package/src/routes/admin-content.ts +197 -0
  71. package/src/routes/admin-media.ts +96 -0
  72. package/src/routes/admin-spa.ts +82 -0
  73. package/src/routes/admin-staging.ts +126 -0
  74. package/src/routes/admin-system.ts +146 -0
  75. package/src/routes/health.ts +67 -0
  76. package/src/schema.ts +217 -0
  77. package/src/server.ts +68 -0
  78. package/src/services/changes.ts +605 -0
  79. package/src/services/content-store.ts +492 -0
  80. package/src/services/media.ts +229 -0
  81. package/src/services/staging.ts +330 -0
  82. package/src/workspace/developer.ts +46 -0
  83. package/src/workspace/files.ts +243 -0
  84. package/src/workspace/git.ts +108 -0
  85. package/src/workspace/locks.ts +103 -0
  86. package/src/workspace/policy.ts +77 -0
  87. package/src/workspace/tree.ts +314 -0
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Admin routes for entries and singletons. The content store holds every rule;
3
+ * these routes only parse and serialize. The API path is the repository path,
4
+ * Git is the history, and `revision` makes concurrent edits safe.
5
+ */
6
+
7
+ import { ReadOnlyError, ValidationError } from "../services/content-store.js";
8
+ import { ConflictError } from "../workspace/files.js";
9
+ import { collectionEntryPath, compareEntries, entryTitle, LayoutError, singletonPath } from "@micacms/core";
10
+ import { Hono } from "hono";
11
+ import { z } from "zod";
12
+
13
+ import { ApiError, badRequest, notFound } from "../lib/errors.js";
14
+ import type { Env } from "../http.js";
15
+
16
+ const saveSchema = z.object({
17
+ data: z.record(z.string(), z.unknown()),
18
+ slug: z.string().max(120).optional(),
19
+ /**
20
+ * The revision the editor opened. `null` asserts that the entry does not
21
+ * exist, so two parallel creates cannot overwrite.
22
+ */
23
+ revision: z.string().nullable().optional(),
24
+ });
25
+
26
+ const reorderSchema = z.object({ slugs: z.array(z.string().min(1)).max(2000) });
27
+
28
+ async function jsonBody(c: { req: { json(): Promise<unknown> } }): Promise<unknown> {
29
+ return c.req.json().catch(() => {
30
+ throw badRequest("The request body must be valid JSON.");
31
+ });
32
+ }
33
+
34
+ /** Maps a conflict to 409 and a validation failure to 422; other errors go to the error handler. */
35
+ async function handled<T>(run: () => Promise<T>): Promise<T> {
36
+ try {
37
+ return await run();
38
+ } catch (error) {
39
+ if (error instanceof ConflictError || error instanceof ReadOnlyError || error instanceof LayoutError) {
40
+ throw new ApiError("conflict", error.message);
41
+ }
42
+ if (error instanceof ValidationError) {
43
+ throw new ApiError("validation_failed", error.message, { issues: error.issues });
44
+ }
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ export function adminContentRoutes(): Hono<Env> {
50
+ const app = new Hono<Env>();
51
+
52
+ app.get("/collections/:collection/entries", async (c) => {
53
+ const { content } = c.get("ctx").services;
54
+ const collection = c.req.param("collection");
55
+ const def = content.requireCollection(collection);
56
+
57
+ const entries = await handled(() => content.list(collection));
58
+ const search = c.req.query("search")?.trim().toLowerCase();
59
+
60
+ const items = entries
61
+ .map((entry) => ({
62
+ slug: entry.slug,
63
+ position: entry.position,
64
+ title: entryTitle(def, entry.data),
65
+ data: entry.data,
66
+ }))
67
+ // Content trees are small, so an in-memory substring match is sufficient.
68
+ .filter((item) => !search || JSON.stringify(item.data).toLowerCase().includes(search))
69
+ .sort(compareEntries(def));
70
+
71
+ return c.json({ items, total: items.length });
72
+ });
73
+
74
+ app.post("/collections/:collection/entries", async (c) => {
75
+ const { content } = c.get("ctx").services;
76
+ const parsed = saveSchema.safeParse(await jsonBody(c));
77
+ if (!parsed.success) throw badRequest("The entry payload is malformed.");
78
+
79
+ const saved = await handled(() =>
80
+ content.save(c.req.param("collection"), {
81
+ data: parsed.data.data,
82
+ expectedRevision: parsed.data.revision ?? null,
83
+ }),
84
+ );
85
+
86
+ return c.json(saved, 201);
87
+ });
88
+
89
+ app.post("/collections/:collection/reorder", async (c) => {
90
+ const { content } = c.get("ctx").services;
91
+ const parsed = reorderSchema.safeParse(await jsonBody(c));
92
+ if (!parsed.success) throw badRequest("Expected a list of entry slugs.");
93
+
94
+ await handled(() => content.reorder(c.req.param("collection"), parsed.data.slugs));
95
+ return c.json({ ok: true });
96
+ });
97
+
98
+ /** Server-side slug generation, so the URL preview matches what is saved. */
99
+ app.get("/collections/:collection/slug", async (c) => {
100
+ const { content } = c.get("ctx").services;
101
+ const source = c.req.query("source") ?? "";
102
+ if (!source.trim()) return c.json({ slug: "" });
103
+
104
+ return c.json({
105
+ slug: await handled(() =>
106
+ content.suggestSlug(c.req.param("collection"), source, c.req.query("except")),
107
+ ),
108
+ });
109
+ });
110
+
111
+ app.get("/collections/:collection/entries/:slug", async (c) => {
112
+ const { content } = c.get("ctx").services;
113
+ const collection = c.req.param("collection");
114
+ const slug = c.req.param("slug");
115
+
116
+ const entry = await handled(() => content.get(collection, slug));
117
+ if (!entry) throw notFound("This entry no longer exists.");
118
+
119
+ const def = content.requireCollection(collection);
120
+
121
+ return c.json({
122
+ collection,
123
+ slug: entry.slug,
124
+ position: entry.position,
125
+ title: entryTitle(def, entry.data),
126
+ data: entry.data,
127
+ // From the model, so the editor need not know when an entry is a directory.
128
+ path: collectionEntryPath(def.fields, collection, entry.slug),
129
+ revision: await content.revisionOf(collection, slug),
130
+ });
131
+ });
132
+
133
+ app.put("/collections/:collection/entries/:slug", async (c) => {
134
+ const { content } = c.get("ctx").services;
135
+ const parsed = saveSchema.safeParse(await jsonBody(c));
136
+ if (!parsed.success) throw badRequest("The entry payload is malformed.");
137
+
138
+ const saved = await handled(() =>
139
+ content.save(c.req.param("collection"), {
140
+ data: parsed.data.data,
141
+ slug: c.req.param("slug"),
142
+ expectedRevision: parsed.data.revision,
143
+ }),
144
+ );
145
+
146
+ return c.json(saved);
147
+ });
148
+
149
+ app.delete("/collections/:collection/entries/:slug", async (c) => {
150
+ const { content } = c.get("ctx").services;
151
+
152
+ await handled(() =>
153
+ content.remove(c.req.param("collection"), c.req.param("slug")),
154
+ );
155
+
156
+ return c.json({ ok: true });
157
+ });
158
+
159
+ /** Entries elsewhere that point at this one — shown before a delete. */
160
+ app.get("/collections/:collection/entries/:slug/references", async (c) => {
161
+ const { content } = c.get("ctx").services;
162
+ const references = await handled(() =>
163
+ content.findReferences(c.req.param("collection"), c.req.param("slug")),
164
+ );
165
+
166
+ return c.json({ references });
167
+ });
168
+
169
+ app.get("/singletons/:name", async (c) => {
170
+ const { content } = c.get("ctx").services;
171
+ const name = c.req.param("name");
172
+ const def = content.requireSingleton(name);
173
+
174
+ return c.json({
175
+ name,
176
+ label: def.label,
177
+ path: singletonPath(def.fields, name),
178
+ data: await handled(() => content.getSingleton(name)),
179
+ revision: await content.revisionOfSingleton(name),
180
+ });
181
+ });
182
+
183
+ app.put("/singletons/:name", async (c) => {
184
+ const { content } = c.get("ctx").services;
185
+ const body = (await jsonBody(c)) as { data?: unknown; revision?: string | null };
186
+
187
+ const saved = await handled(() =>
188
+ content.saveSingleton(c.req.param("name"), body?.data ?? {}, {
189
+ expectedRevision: body?.revision,
190
+ }),
191
+ );
192
+
193
+ return c.json(saved);
194
+ });
195
+
196
+ return app;
197
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Admin media routes. Media is files in the content repository, and there is no delete endpoint.
3
+ * An upload is written on its own, before the entry save, because the editor needs its path first.
4
+ */
5
+
6
+ import { Hono } from "hono";
7
+
8
+ import { ApiError, badRequest, notFound } from "../lib/errors.js";
9
+ import { isImageType } from "../lib/file-type.js";
10
+ import { MAX_FILES_PER_UPLOAD } from "../middleware/index.js";
11
+ import type { Env } from "../http.js";
12
+
13
+ export function adminMediaRoutes(): Hono<Env> {
14
+ const app = new Hono<Env>();
15
+
16
+ app.get("/", async (c) => {
17
+ const { media } = c.get("ctx").services;
18
+ return c.json({ items: await media.list() });
19
+ });
20
+
21
+ app.post("/", async (c) => {
22
+ const ctx = c.get("ctx");
23
+ const form = await c.req.formData().catch(() => {
24
+ throw badRequest("Expected a multipart upload.");
25
+ });
26
+
27
+ // The cast picks one of the two structurally different `File` types (Node's and undici's).
28
+ const files = form
29
+ .getAll("file")
30
+ .filter((value) => typeof value !== "string") as unknown as File[];
31
+
32
+ if (files.length === 0) throw badRequest("No file was uploaded.");
33
+ if (files.length > MAX_FILES_PER_UPLOAD) {
34
+ throw badRequest(`Upload at most ${MAX_FILES_PER_UPLOAD} files at a time.`);
35
+ }
36
+
37
+ const accept = form.get("accept");
38
+ // An entry's own `media/` directory or the shared one; the media service refuses anything else.
39
+ const directory = typeof form.get("directory") === "string" ? String(form.get("directory")) : undefined;
40
+ // The media service accepts `/gallery/x/media` too; take the owner from the path it will write to.
41
+ const owner = (directory ?? "").replace(/^[/\\]+/, "").split(/[/\\]/)[0] ?? "";
42
+ const readOnly = ctx.site.readOnly.get(`collections.${owner}`) ?? ctx.site.readOnly.get(`singletons.${owner}`);
43
+ if (readOnly) throw new ApiError("conflict", readOnly);
44
+
45
+ const prepared = await Promise.all(
46
+ files.map((file) =>
47
+ ctx.services.media.prepare({
48
+ file,
49
+ directory,
50
+ accept: accept === "image" || accept === "document" ? accept : "any",
51
+ }),
52
+ ),
53
+ );
54
+
55
+ await ctx.site.contentRepository.apply({
56
+ changes: prepared.map((item) => ({
57
+ type: "write" as const,
58
+ path: item.file.path,
59
+ contents: item.contents,
60
+ })),
61
+ });
62
+
63
+ return c.json({ items: prepared.map((item) => item.file) }, 201);
64
+ });
65
+
66
+ /**
67
+ * Serves a file to the editor, behind the admin gate. The site build reads
68
+ * the repository, not this route.
69
+ */
70
+ app.get("/file/*", async (c) => {
71
+ const { media } = c.get("ctx").services;
72
+ const mediaPath = c.req.path.replace(/^.*\/file\//, "");
73
+
74
+ let decoded: string;
75
+ try {
76
+ decoded = decodeURIComponent(mediaPath);
77
+ } catch {
78
+ throw badRequest("The file path is not valid.");
79
+ }
80
+
81
+ const found = await media.read(decoded);
82
+ if (!found) throw notFound("No such file.");
83
+
84
+ return c.body(found.bytes as unknown as ArrayBuffer, 200, {
85
+ // Text without a charset is decoded by guessing; the repository's text is UTF-8.
86
+ "content-type": found.mimeType.startsWith("text/") ? `${found.mimeType}; charset=utf-8` : found.mimeType,
87
+ "cache-control": "private, max-age=60",
88
+ // A file opened directly runs nothing and reaches nothing on this origin.
89
+ "content-security-policy": "sandbox; default-src 'none'",
90
+ // An image is shown as a preview; anything else is downloaded, never rendered here.
91
+ ...(isImageType(found.mimeType) ? {} : { "content-disposition": "attachment" }),
92
+ });
93
+ });
94
+
95
+ return app;
96
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Serves the built administration SPA.
3
+ *
4
+ * Small enough to do directly rather than pull in a static-file middleware,
5
+ * and doing it here means the caching rules are explicit: hashed asset files
6
+ * are immutable, `index.html` never is — otherwise a deploy leaves editors on
7
+ * a stale bundle pointing at assets that no longer exist.
8
+ */
9
+
10
+ import fs from "node:fs";
11
+ import fsp from "node:fs/promises";
12
+ import path from "node:path";
13
+ import { Readable } from "node:stream";
14
+
15
+ import { Hono, type Context } from "hono";
16
+
17
+ import type { Env } from "../http.js";
18
+
19
+ const CONTENT_TYPES: Record<string, string> = {
20
+ ".html": "text/html; charset=utf-8",
21
+ ".js": "text/javascript; charset=utf-8",
22
+ ".css": "text/css; charset=utf-8",
23
+ ".json": "application/json; charset=utf-8",
24
+ ".svg": "image/svg+xml",
25
+ ".png": "image/png",
26
+ ".jpg": "image/jpeg",
27
+ ".webp": "image/webp",
28
+ ".ico": "image/x-icon",
29
+ ".woff2": "font/woff2",
30
+ ".woff": "font/woff",
31
+ ".map": "application/json; charset=utf-8",
32
+ };
33
+
34
+ export function adminAssetsExist(directory: string): boolean {
35
+ return fs.existsSync(path.join(directory, "index.html"));
36
+ }
37
+
38
+ export function adminSpaRoutes(directory: string): Hono<Env> {
39
+ const app = new Hono<Env>();
40
+ const root = path.resolve(directory);
41
+
42
+ const send = async (c: Context<Env>, filePath: string, immutable: boolean) => {
43
+ const stat = await fsp.stat(filePath);
44
+ const extension = path.extname(filePath).toLowerCase();
45
+
46
+ return c.body(Readable.toWeb(fs.createReadStream(filePath)) as ReadableStream<Uint8Array>, 200, {
47
+ "content-type": CONTENT_TYPES[extension] ?? "application/octet-stream",
48
+ "content-length": String(stat.size),
49
+ "cache-control": immutable
50
+ ? "public, max-age=31536000, immutable"
51
+ : "no-cache, must-revalidate",
52
+ });
53
+ };
54
+
55
+ app.get("/*", async (c) => {
56
+ const url = new URL(c.req.url);
57
+ const relative = decodeURIComponent(url.pathname.replace(/^\/admin\/?/, ""));
58
+ const target = path.join(root, relative);
59
+
60
+ // Never serve outside the build directory, whatever the path contains.
61
+ if (relative && !path.relative(root, target).startsWith("..")) {
62
+ try {
63
+ const stat = await fsp.stat(target);
64
+ if (stat.isFile()) {
65
+ // Vite emits hashed filenames under /assets, so those are immutable.
66
+ return await send(c, target, relative.startsWith("assets/"));
67
+ }
68
+ } catch {
69
+ // Fall through to the SPA entry point.
70
+ }
71
+ }
72
+
73
+ // Any other path belongs to the client-side router.
74
+ try {
75
+ return await send(c, path.join(root, "index.html"), false);
76
+ } catch {
77
+ return c.text("The admin interface has not been built. Run `rush build`.", 503);
78
+ }
79
+ });
80
+
81
+ return app;
82
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Admin routes for the pending changes and Publish. Sync and conflict
3
+ * resolution exist only for a clone that Mica keeps. `committed-pending-push`
4
+ * is a 200: the commit is real, and an error status would tell the editor that
5
+ * nothing happened.
6
+ */
7
+
8
+ import { Hono } from "hono";
9
+ import { z } from "zod";
10
+
11
+ import { actorOf, type Env } from "../http.js";
12
+ import { ApiError, badRequest } from "../lib/errors.js";
13
+ import { ConflictError } from "../workspace/files.js";
14
+
15
+ const publishSchema = z.object({
16
+ message: z.string().max(500),
17
+ /** The reviewed paths, with the revision each had in the review. */
18
+ paths: z.record(z.string().min(1), z.string().nullable()),
19
+ });
20
+
21
+ const resolveSchema = z.object({
22
+ group: z.string().min(1),
23
+ choice: z.enum(["mine", "website"]),
24
+ });
25
+
26
+ const discardSchema = z.object({ paths: z.array(z.string().min(1)).min(1).max(2000) });
27
+
28
+ const compareSchema = z.discriminatedUnion("kind", [
29
+ z.object({
30
+ kind: z.literal("entry"),
31
+ collection: z.string().min(1),
32
+ slug: z.string().min(1),
33
+ from: z.string().min(1).optional(),
34
+ }),
35
+ z.object({ kind: z.literal("singleton"), name: z.string().min(1) }),
36
+ z.object({ kind: z.literal("order"), collection: z.string().min(1) }),
37
+ ]);
38
+
39
+ async function jsonBody(c: { req: { json(): Promise<unknown> } }): Promise<unknown> {
40
+ return c.req.json().catch(() => {
41
+ throw badRequest("The request body must be valid JSON.");
42
+ });
43
+ }
44
+
45
+ /** A repository conflict is 409 and recoverable — refresh, review, retry. */
46
+ async function handled<T>(run: () => Promise<T>): Promise<T> {
47
+ try {
48
+ return await run();
49
+ } catch (error) {
50
+ if (error instanceof ConflictError) throw new ApiError("conflict", error.message);
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ export function adminStagingRoutes(): Hono<Env> {
56
+ const app = new Hono<Env>();
57
+
58
+ app.get("/changes", async (c) => {
59
+ const { staging } = c.get("ctx").services;
60
+ return c.json(await staging.status());
61
+ });
62
+
63
+ // Raw data before and after one pending change; the editor renders the comparison.
64
+ app.get("/changes/compare", async (c) => {
65
+ const { staging } = c.get("ctx").services;
66
+ const parsed = compareSchema.safeParse({
67
+ kind: c.req.query("kind"),
68
+ collection: c.req.query("collection"),
69
+ slug: c.req.query("slug"),
70
+ from: c.req.query("from"),
71
+ name: c.req.query("name"),
72
+ });
73
+ if (!parsed.success) throw badRequest("The compare target is malformed.");
74
+ const against = c.req.query("against") === "origin" ? "origin" : "head";
75
+
76
+ return c.json(await staging.compare(parsed.data, against));
77
+ });
78
+
79
+ app.post("/publish", async (c) => {
80
+ const { staging } = c.get("ctx").services;
81
+ const parsed = publishSchema.safeParse(await jsonBody(c));
82
+ if (!parsed.success) throw badRequest("The publish payload is malformed.");
83
+
84
+ const actor = actorOf(c);
85
+ const result = await handled(() =>
86
+ staging.publish({
87
+ message: parsed.data.message,
88
+ paths: parsed.data.paths,
89
+ author: actor ? { name: actor.name || actor.email, email: actor.email } : undefined,
90
+ }),
91
+ );
92
+
93
+ return c.json(result);
94
+ });
95
+
96
+ app.post("/publish/push", async (c) => {
97
+ const { staging } = c.get("ctx").services;
98
+ return c.json(await handled(() => staging.push()));
99
+ });
100
+
101
+ // "Try again" after a sync error.
102
+ app.post("/sync", async (c) => {
103
+ const { staging } = c.get("ctx").services;
104
+ await staging.sync();
105
+ return c.json(await staging.status());
106
+ });
107
+
108
+ app.post("/changes/resolve", async (c) => {
109
+ const { staging } = c.get("ctx").services;
110
+ const parsed = resolveSchema.safeParse(await jsonBody(c));
111
+ if (!parsed.success) throw badRequest("Expected a group and a choice: mine or website.");
112
+
113
+ await handled(() => staging.resolve(parsed.data.group, parsed.data.choice));
114
+ return c.json(await staging.status());
115
+ });
116
+
117
+ app.post("/changes/discard", async (c) => {
118
+ const { staging } = c.get("ctx").services;
119
+ const parsed = discardSchema.safeParse(await jsonBody(c));
120
+ if (!parsed.success) throw badRequest("Expected a list of change paths.");
121
+
122
+ return c.json(await handled(() => staging.discard(parsed.data.paths)));
123
+ });
124
+
125
+ return app;
126
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Schema, dashboard and system information. The SPA knows no collection; it
3
+ * renders what `/schema` returns.
4
+ */
5
+
6
+ import { Hono } from "hono";
7
+
8
+ import { listedFields, resolveSlugField, resolveTitleField } from "@micacms/core";
9
+
10
+ import type { Env } from "../http.js";
11
+ import { schemaStats } from "../lib/schema-stats.js";
12
+ import { schemaOrigin } from "../schema.js";
13
+ import { VERSION } from "../lib/version.js";
14
+ import { formatBytes } from "../services/media.js";
15
+
16
+ export function adminSystemRoutes(): Hono<Env> {
17
+ const app = new Hono<Env>();
18
+
19
+ /** The content model, plus the derived values that the UI would otherwise recompute. */
20
+ app.get("/schema", (c) => {
21
+ const { site } = c.get("ctx");
22
+
23
+ return c.json({
24
+ collections: Object.values(site.model.collections).map((def) => ({
25
+ name: def.name,
26
+ label: def.label,
27
+ labelSingular: def.labelSingular ?? def.label,
28
+ description: def.description ?? null,
29
+ sortable: def.sortable ?? false,
30
+ defaultSort: def.defaultSort ?? null,
31
+ titleField: resolveTitleField(def),
32
+ slugField: resolveSlugField(def),
33
+ listedFields: listedFields(def),
34
+ fields: def.fields,
35
+ readOnly: site.readOnly.get(`collections.${def.name}`) ?? null,
36
+ })),
37
+ singletons: Object.values(site.model.singletons).map((def) => ({
38
+ name: def.name,
39
+ label: def.label,
40
+ description: def.description ?? null,
41
+ fields: def.fields,
42
+ readOnly: site.readOnly.get(`singletons.${def.name}`) ?? null,
43
+ })),
44
+ });
45
+ });
46
+
47
+ app.get("/overview", async (c) => {
48
+ const ctx = c.get("ctx");
49
+ const { content, media } = ctx.services;
50
+
51
+ const collections = await Promise.all(
52
+ Object.values(ctx.site.model.collections).map(async (def) => ({
53
+ name: def.name,
54
+ label: def.label,
55
+ total: (await content.list(def.name)).length,
56
+ })),
57
+ );
58
+
59
+ const files = await media.list();
60
+
61
+ return c.json({
62
+ collections,
63
+ media: {
64
+ files: files.length,
65
+ bytes: files.reduce((sum, file) => sum + file.size, 0),
66
+ readable: formatBytes(files.reduce((sum, file) => sum + file.size, 0)),
67
+ },
68
+ repository: ctx.site.contentRepository.origin,
69
+ });
70
+ });
71
+
72
+ /**
73
+ * What this deployment is and serves. Every admitted editor sees it, because
74
+ * there is no administrator role. It never returns a secret value.
75
+ */
76
+ app.get("/system", async (c) => {
77
+ const ctx = c.get("ctx");
78
+ const { config } = ctx;
79
+
80
+ const stats = schemaStats(ctx.site.model);
81
+
82
+ const counts = Object.fromEntries(
83
+ await Promise.all(
84
+ Object.values(ctx.site.model.collections).map(
85
+ async (def) => [def.name, (await ctx.services.content.list(def.name)).length] as const,
86
+ ),
87
+ ),
88
+ );
89
+
90
+ const files = await ctx.services.media.list();
91
+ const mediaBytes = files.reduce((sum, file) => sum + file.size, 0);
92
+
93
+ const uptimeSeconds = Math.floor(process.uptime());
94
+
95
+ const operational = ctx.operational();
96
+ const repository = await ctx.services.staging.repository();
97
+
98
+ return c.json({
99
+ cms: {
100
+ version: VERSION,
101
+ environment: config.NODE_ENV,
102
+ node: process.version,
103
+ platform: `${process.platform}/${process.arch}`,
104
+ startedAt: new Date(Date.now() - uptimeSeconds * 1000).toISOString(),
105
+ uptimeSeconds,
106
+ },
107
+ schema: {
108
+ totals: stats.totals,
109
+ kinds: stats.kinds,
110
+ relations: stats.relations,
111
+ collections: stats.collections.map((item) => ({
112
+ ...item,
113
+ entries: counts[item.name] ?? 0,
114
+ })),
115
+ singletons: stats.singletons,
116
+ },
117
+ repository: {
118
+ origin: ctx.site.contentRepository.origin,
119
+ entries: Object.values(counts).reduce((sum, count) => sum + count, 0),
120
+ media: { files: files.length, bytes: mediaBytes, readable: formatBytes(mediaBytes) },
121
+ ...repository,
122
+ },
123
+ deployment: {
124
+ site: ctx.site.id,
125
+ baseUrl: config.MICA_PUBLIC_URL,
126
+ schemaOrigin: schemaOrigin(ctx.site.contentRepository),
127
+ // A clone that Mica keeps names its remote in `repository.url` instead.
128
+ contentRoot: repository.owner === "developer" ? config.contentRoot : null,
129
+ dataDir: operational.dataDir ?? null,
130
+ logLevel: config.MICA_LOG_LEVEL,
131
+ maxUploadBytes: config.MICA_MAX_UPLOAD_BYTES,
132
+ maxUploadReadable: formatBytes(config.MICA_MAX_UPLOAD_BYTES),
133
+ sessionTtlHours: operational.sessionTtlHours ?? null,
134
+ sessionIdleHours: operational.sessionIdleHours ?? null,
135
+ auth: operational.auth,
136
+ admission: operational.admission ?? null,
137
+ trustProxy: config.MICA_TRUST_PROXY,
138
+ adminOrigins: config.adminOrigins,
139
+ generatedSecrets: operational.generatedSecrets ?? [],
140
+ memoryBytes: process.memoryUsage().rss,
141
+ },
142
+ });
143
+ });
144
+
145
+ return app;
146
+ }