@micacms/runtime 0.2.0
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.
- package/README.md +16 -0
- package/dist/app.d.ts +62 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +107 -0
- package/dist/app.js.map +1 -0
- package/dist/config.d.ts +187 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +154 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +121 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +117 -0
- package/dist/context.js.map +1 -0
- package/dist/db/driver.d.ts +35 -0
- package/dist/db/driver.d.ts.map +1 -0
- package/dist/db/driver.js +95 -0
- package/dist/db/driver.js.map +1 -0
- package/dist/http.d.ts +40 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +38 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/errors.d.ts +39 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +52 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/file-type.d.ts +26 -0
- package/dist/lib/file-type.d.ts.map +1 -0
- package/dist/lib/file-type.js +89 -0
- package/dist/lib/file-type.js.map +1 -0
- package/dist/lib/git.d.ts +29 -0
- package/dist/lib/git.d.ts.map +1 -0
- package/dist/lib/git.js +62 -0
- package/dist/lib/git.js.map +1 -0
- package/dist/lib/id.d.ts +13 -0
- package/dist/lib/id.d.ts.map +1 -0
- package/dist/lib/id.js +56 -0
- package/dist/lib/id.js.map +1 -0
- package/dist/lib/logger.d.ts +20 -0
- package/dist/lib/logger.d.ts.map +1 -0
- package/dist/lib/logger.js +65 -0
- package/dist/lib/logger.js.map +1 -0
- package/dist/lib/ports.d.ts +17 -0
- package/dist/lib/ports.d.ts.map +1 -0
- package/dist/lib/ports.js +17 -0
- package/dist/lib/ports.js.map +1 -0
- package/dist/lib/rate-limit.d.ts +29 -0
- package/dist/lib/rate-limit.d.ts.map +1 -0
- package/dist/lib/rate-limit.js +47 -0
- package/dist/lib/rate-limit.js.map +1 -0
- package/dist/lib/schema-stats.d.ts +64 -0
- package/dist/lib/schema-stats.d.ts.map +1 -0
- package/dist/lib/schema-stats.js +103 -0
- package/dist/lib/schema-stats.js.map +1 -0
- package/dist/lib/version.d.ts +14 -0
- package/dist/lib/version.d.ts.map +1 -0
- package/dist/lib/version.js +25 -0
- package/dist/lib/version.js.map +1 -0
- package/dist/middleware/index.d.ts +41 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +142 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/routes/admin-content.d.ts +22 -0
- package/dist/routes/admin-content.d.ts.map +1 -0
- package/dist/routes/admin-content.js +194 -0
- package/dist/routes/admin-content.js.map +1 -0
- package/dist/routes/admin-media.d.ts +19 -0
- package/dist/routes/admin-media.d.ts.map +1 -0
- package/dist/routes/admin-media.js +81 -0
- package/dist/routes/admin-media.js.map +1 -0
- package/dist/routes/admin-spa.d.ts +13 -0
- package/dist/routes/admin-spa.d.ts.map +1 -0
- package/dist/routes/admin-spa.js +72 -0
- package/dist/routes/admin-spa.js.map +1 -0
- package/dist/routes/admin-staging.d.ts +15 -0
- package/dist/routes/admin-staging.d.ts.map +1 -0
- package/dist/routes/admin-staging.js +90 -0
- package/dist/routes/admin-staging.js.map +1 -0
- package/dist/routes/admin-system.d.ts +14 -0
- package/dist/routes/admin-system.d.ts.map +1 -0
- package/dist/routes/admin-system.js +153 -0
- package/dist/routes/admin-system.js.map +1 -0
- package/dist/routes/health.d.ts +12 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +44 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/schema.d.ts +56 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +173 -0
- package/dist/schema.js.map +1 -0
- package/dist/server.d.ts +28 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +47 -0
- package/dist/server.js.map +1 -0
- package/dist/services/media.d.ts +65 -0
- package/dist/services/media.d.ts.map +1 -0
- package/dist/services/media.js +180 -0
- package/dist/services/media.js.map +1 -0
- package/dist/services/staging-local.d.ts +58 -0
- package/dist/services/staging-local.d.ts.map +1 -0
- package/dist/services/staging-local.js +391 -0
- package/dist/services/staging-local.js.map +1 -0
- package/dist/services/staging.d.ts +167 -0
- package/dist/services/staging.d.ts.map +1 -0
- package/dist/services/staging.js +333 -0
- package/dist/services/staging.js.map +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
import { type FieldKind, type Site } from "@micacms/core";
|
|
14
|
+
export interface KindUsage {
|
|
15
|
+
kind: FieldKind;
|
|
16
|
+
count: number;
|
|
17
|
+
}
|
|
18
|
+
export interface RelationEdge {
|
|
19
|
+
/** Collection or singleton the field is declared in. */
|
|
20
|
+
from: string;
|
|
21
|
+
fromType: "collection" | "singleton";
|
|
22
|
+
/** Dotted path to the field, with `[]` marking a repeater. */
|
|
23
|
+
path: string;
|
|
24
|
+
/** Collection the relation points at. */
|
|
25
|
+
to: string;
|
|
26
|
+
multiple: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface FieldCounts {
|
|
29
|
+
/** Fields declared directly on the collection or singleton. */
|
|
30
|
+
top: number;
|
|
31
|
+
/** Every field, including those nested in objects and repeaters. */
|
|
32
|
+
total: number;
|
|
33
|
+
required: number;
|
|
34
|
+
}
|
|
35
|
+
export interface CollectionStats {
|
|
36
|
+
name: string;
|
|
37
|
+
label: string;
|
|
38
|
+
fields: FieldCounts;
|
|
39
|
+
titleField: string | null;
|
|
40
|
+
slugField: string | null;
|
|
41
|
+
listed: number;
|
|
42
|
+
sortable: boolean;
|
|
43
|
+
/** Deepest level of nesting; 1 means no objects or repeaters. */
|
|
44
|
+
depth: number;
|
|
45
|
+
}
|
|
46
|
+
export interface SingletonStats {
|
|
47
|
+
name: string;
|
|
48
|
+
label: string;
|
|
49
|
+
fields: FieldCounts;
|
|
50
|
+
depth: number;
|
|
51
|
+
}
|
|
52
|
+
export interface SchemaStats {
|
|
53
|
+
collections: CollectionStats[];
|
|
54
|
+
singletons: SingletonStats[];
|
|
55
|
+
totals: FieldCounts & {
|
|
56
|
+
collections: number;
|
|
57
|
+
singletons: number;
|
|
58
|
+
};
|
|
59
|
+
/** Field kinds in use, most used first. */
|
|
60
|
+
kinds: KindUsage[];
|
|
61
|
+
relations: RelationEdge[];
|
|
62
|
+
}
|
|
63
|
+
export declare function schemaStats(site: Site): SchemaStats;
|
|
64
|
+
//# sourceMappingURL=schema-stats.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema-stats.d.ts","sourceRoot":"","sources":["../../src/lib/schema-stats.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAKL,KAAK,SAAS,EAEd,KAAK,IAAI,EACV,MAAM,eAAe,CAAC;AAEvB,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,YAAY,GAAG,WAAW,CAAC;IACrC,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,yCAAyC;IACzC,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,WAAW;IAC1B,+DAA+D;IAC/D,GAAG,EAAE,MAAM,CAAC;IACZ,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,WAAW,CAAC;IACpB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,eAAe,EAAE,CAAC;IAC/B,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,MAAM,EAAE,WAAW,GAAG;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IAClE,2CAA2C;IAC3C,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,SAAS,EAAE,YAAY,EAAE,CAAC;CAC3B;AA2CD,wBAAgB,WAAW,CAAC,IAAI,EAAE,IAAI,GAAG,WAAW,CAkEnD"}
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
import { listedFields, resolveSlugField, resolveTitleField, } from "@micacms/core";
|
|
14
|
+
/** The two kinds that carry a nested `fields` map. */
|
|
15
|
+
function children(field) {
|
|
16
|
+
return field.kind === "object" || field.kind === "repeater" ? field.fields : null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Depth-first walk over a field map, visiting nested fields too.
|
|
20
|
+
*
|
|
21
|
+
* `path` is what an editor would say out loud — `blocks[].heading` rather than
|
|
22
|
+
* `blocks.fields.heading` — so it can be printed as-is.
|
|
23
|
+
*/
|
|
24
|
+
function walk(fields, visit, prefix = "", depth = 1) {
|
|
25
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
26
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
27
|
+
visit(key, field, path, depth);
|
|
28
|
+
const nested = children(field);
|
|
29
|
+
if (nested) {
|
|
30
|
+
walk(nested, visit, field.kind === "repeater" ? `${path}[]` : path, depth + 1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function countFields(fields) {
|
|
35
|
+
const counts = { top: Object.keys(fields).length, total: 0, required: 0 };
|
|
36
|
+
let deepest = 1;
|
|
37
|
+
walk(fields, (_key, field, _path, depth) => {
|
|
38
|
+
counts.total++;
|
|
39
|
+
if (field.required)
|
|
40
|
+
counts.required++;
|
|
41
|
+
if (depth > deepest)
|
|
42
|
+
deepest = depth;
|
|
43
|
+
});
|
|
44
|
+
return { counts, depth: deepest };
|
|
45
|
+
}
|
|
46
|
+
export function schemaStats(site) {
|
|
47
|
+
const kinds = new Map();
|
|
48
|
+
const relations = [];
|
|
49
|
+
const totals = {
|
|
50
|
+
collections: 0,
|
|
51
|
+
singletons: 0,
|
|
52
|
+
top: 0,
|
|
53
|
+
total: 0,
|
|
54
|
+
required: 0,
|
|
55
|
+
};
|
|
56
|
+
const tally = (fields, from, fromType) => {
|
|
57
|
+
walk(fields, (_key, field, path) => {
|
|
58
|
+
kinds.set(field.kind, (kinds.get(field.kind) ?? 0) + 1);
|
|
59
|
+
if (field.kind === "relation") {
|
|
60
|
+
relations.push({ from, fromType, path, to: field.collection, multiple: field.multiple ?? false });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
const collections = Object.values(site.collections).map((def) => {
|
|
65
|
+
const { counts, depth } = countFields(def.fields);
|
|
66
|
+
tally(def.fields, def.name, "collection");
|
|
67
|
+
totals.collections++;
|
|
68
|
+
totals.top += counts.top;
|
|
69
|
+
totals.total += counts.total;
|
|
70
|
+
totals.required += counts.required;
|
|
71
|
+
return {
|
|
72
|
+
name: def.name,
|
|
73
|
+
label: def.label,
|
|
74
|
+
fields: counts,
|
|
75
|
+
// The same resolution the editor sees, not the raw declaration: both keys
|
|
76
|
+
// are optional in the model and fall back to a convention.
|
|
77
|
+
titleField: resolveTitleField(def) ?? null,
|
|
78
|
+
slugField: resolveSlugField(def) ?? null,
|
|
79
|
+
listed: listedFields(def).length,
|
|
80
|
+
sortable: def.sortable ?? false,
|
|
81
|
+
depth,
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
const singletons = Object.values(site.singletons).map((def) => {
|
|
85
|
+
const { counts, depth } = countFields(def.fields);
|
|
86
|
+
tally(def.fields, def.name, "singleton");
|
|
87
|
+
totals.singletons++;
|
|
88
|
+
totals.top += counts.top;
|
|
89
|
+
totals.total += counts.total;
|
|
90
|
+
totals.required += counts.required;
|
|
91
|
+
return { name: def.name, label: def.label, fields: counts, depth };
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
collections,
|
|
95
|
+
singletons,
|
|
96
|
+
totals,
|
|
97
|
+
kinds: [...kinds]
|
|
98
|
+
.map(([kind, count]) => ({ kind, count }))
|
|
99
|
+
.sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind)),
|
|
100
|
+
relations,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=schema-stats.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema-stats.js","sourceRoot":"","sources":["../../src/lib/schema-stats.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
export declare const VERSION: string;
|
|
14
|
+
//# sourceMappingURL=version.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../../src/lib/version.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAiBH,eAAO,MAAM,OAAO,QAAgB,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
function readVersion() {
|
|
16
|
+
try {
|
|
17
|
+
const manifest = fs.readFileSync(path.resolve(import.meta.dirname, "../../package.json"), "utf8");
|
|
18
|
+
return JSON.parse(manifest).version ?? "unknown";
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return "unknown";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export const VERSION = readVersion();
|
|
25
|
+
//# sourceMappingURL=version.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.js","sourceRoot":"","sources":["../../src/lib/version.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,SAAS,WAAW;IAClB,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,EAAE,CAAC,YAAY,CAC9B,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,oBAAoB,CAAC,EACvD,MAAM,CACP,CAAC;QACF,OAAQ,IAAI,CAAC,KAAK,CAAC,QAAQ,CAA0B,CAAC,OAAO,IAAI,SAAS,CAAC;IAC7E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,MAAM,CAAC,MAAM,OAAO,GAAG,WAAW,EAAE,CAAC"}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
import type { ErrorHandler, MiddlewareHandler } from "hono";
|
|
13
|
+
import { type Env } from "../http.js";
|
|
14
|
+
/** Attaches the container, a request id and a scoped logger; logs the result. */
|
|
15
|
+
export declare function requestContext(ctx: Env["Variables"]["ctx"]): MiddlewareHandler<Env>;
|
|
16
|
+
export declare function securityHeaders(): MiddlewareHandler<Env>;
|
|
17
|
+
/**
|
|
18
|
+
* Converts thrown errors into the API error envelope. Deliberate failures
|
|
19
|
+
* (`ApiError`) are reported as-is; anything else is a bug and becomes a bare
|
|
20
|
+
* 500, with the stack kept in the log rather than sent to the client.
|
|
21
|
+
*
|
|
22
|
+
* Registered with `app.onError`, not as middleware: Hono's `compose` wraps each
|
|
23
|
+
* handler individually and routes a throw to `onError` at that level, so a
|
|
24
|
+
* try/catch in an outer middleware would never see it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function errorHandler(): ErrorHandler<Env>;
|
|
27
|
+
/** Coarse per-IP limit, applied to the whole API surface. */
|
|
28
|
+
export declare function rateLimit(): MiddlewareHandler<Env>;
|
|
29
|
+
export declare function allowedOrigins(config: {
|
|
30
|
+
MICA_PUBLIC_URL: string;
|
|
31
|
+
adminOrigins: string[];
|
|
32
|
+
}): string[];
|
|
33
|
+
/**
|
|
34
|
+
* First CSRF layer: the browser sets `Origin` on every state-changing request
|
|
35
|
+
* and a page on another site cannot forge it.
|
|
36
|
+
*
|
|
37
|
+
* Applied to the whole API — including sign-in, which has no session yet and
|
|
38
|
+
* so cannot use the token check below.
|
|
39
|
+
*/
|
|
40
|
+
export declare function originGuard(): MiddlewareHandler<Env>;
|
|
41
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAI5D,OAAO,EAAY,KAAK,GAAG,EAAE,MAAM,YAAY,CAAC;AAGhD,iFAAiF;AACjF,wBAAgB,cAAc,CAAC,GAAG,EAAE,GAAG,CAAC,WAAW,CAAC,CAAC,KAAK,CAAC,GAAG,iBAAiB,CAAC,GAAG,CAAC,CAmCnF;AAED,wBAAgB,eAAe,IAAI,iBAAiB,CAAC,GAAG,CAAC,CAUxD;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,IAAI,YAAY,CAAC,GAAG,CAAC,CAyBhD;AAED,6DAA6D;AAC7D,wBAAgB,SAAS,IAAI,iBAAiB,CAAC,GAAG,CAAC,CAalD;AAYD,wBAAgB,cAAc,CAAC,MAAM,EAAE;IACrC,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB,GAAG,MAAM,EAAE,CAEX;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,IAAI,iBAAiB,CAAC,GAAG,CAAC,CAsBpD"}
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
import { ApiError } from "../lib/errors.js";
|
|
13
|
+
import { clientIp } from "../http.js";
|
|
14
|
+
import { randomToken } from "../lib/id.js";
|
|
15
|
+
/** Attaches the container, a request id and a scoped logger; logs the result. */
|
|
16
|
+
export function requestContext(ctx) {
|
|
17
|
+
return async (c, next) => {
|
|
18
|
+
const requestId = c.req.header("x-request-id") ?? randomToken(8);
|
|
19
|
+
const logger = ctx.logger.child({ requestId });
|
|
20
|
+
c.set("ctx", ctx);
|
|
21
|
+
c.set("requestId", requestId);
|
|
22
|
+
c.set("logger", logger);
|
|
23
|
+
c.header("x-request-id", requestId);
|
|
24
|
+
const startedAt = performance.now();
|
|
25
|
+
await next();
|
|
26
|
+
const duration = Math.round(performance.now() - startedAt);
|
|
27
|
+
const line = {
|
|
28
|
+
method: c.req.method,
|
|
29
|
+
path: new URL(c.req.url).pathname,
|
|
30
|
+
status: c.res.status,
|
|
31
|
+
duration,
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* "Am I signed in?" — "No."
|
|
35
|
+
*
|
|
36
|
+
* `/auth/me` is the SPA's first request on every page load, and a 401 is
|
|
37
|
+
* one of its two correct answers, not a rejection worth a warning. Logging
|
|
38
|
+
* it as one puts a scary line in the terminal of everybody who has simply
|
|
39
|
+
* opened the editor while signed out.
|
|
40
|
+
*/
|
|
41
|
+
const expected401 = c.res.status === 401 && line.path === "/api/auth/me";
|
|
42
|
+
if (c.res.status >= 500)
|
|
43
|
+
logger.error("Request failed", line);
|
|
44
|
+
else if (c.res.status >= 400 && !expected401)
|
|
45
|
+
logger.warn("Request rejected", line);
|
|
46
|
+
else
|
|
47
|
+
logger.debug("Request", line);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function securityHeaders() {
|
|
51
|
+
return async (c, next) => {
|
|
52
|
+
await next();
|
|
53
|
+
c.header("x-content-type-options", "nosniff");
|
|
54
|
+
c.header("referrer-policy", "same-origin");
|
|
55
|
+
c.header("x-frame-options", "DENY");
|
|
56
|
+
// The admin is a first-party SPA; nothing here needs to be embedded.
|
|
57
|
+
c.header("permissions-policy", "geolocation=(), microphone=(), camera=()");
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Converts thrown errors into the API error envelope. Deliberate failures
|
|
62
|
+
* (`ApiError`) are reported as-is; anything else is a bug and becomes a bare
|
|
63
|
+
* 500, with the stack kept in the log rather than sent to the client.
|
|
64
|
+
*
|
|
65
|
+
* Registered with `app.onError`, not as middleware: Hono's `compose` wraps each
|
|
66
|
+
* handler individually and routes a throw to `onError` at that level, so a
|
|
67
|
+
* try/catch in an outer middleware would never see it.
|
|
68
|
+
*/
|
|
69
|
+
export function errorHandler() {
|
|
70
|
+
return (error, c) => {
|
|
71
|
+
const logger = c.get("logger") ?? c.get("ctx").logger;
|
|
72
|
+
if (error instanceof ApiError) {
|
|
73
|
+
logger.debug("Handled error", { code: error.code, message: error.message });
|
|
74
|
+
return c.json(error.toBody(), error.status, error.headers);
|
|
75
|
+
}
|
|
76
|
+
logger.error("Unhandled error", {
|
|
77
|
+
error: error instanceof Error ? (error.stack ?? error.message) : String(error),
|
|
78
|
+
path: new URL(c.req.url).pathname,
|
|
79
|
+
});
|
|
80
|
+
return c.json({
|
|
81
|
+
error: {
|
|
82
|
+
code: "internal_error",
|
|
83
|
+
message: "Something went wrong. Please try again.",
|
|
84
|
+
requestId: c.get("requestId"),
|
|
85
|
+
},
|
|
86
|
+
}, 500);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Coarse per-IP limit, applied to the whole API surface. */
|
|
90
|
+
export function rateLimit() {
|
|
91
|
+
return async (c, next) => {
|
|
92
|
+
const { limiters } = c.get("ctx");
|
|
93
|
+
const result = limiters.api.check(clientIp(c));
|
|
94
|
+
if (!result.allowed) {
|
|
95
|
+
throw new ApiError("rate_limited", "Too many requests. Please slow down.", {
|
|
96
|
+
headers: { "retry-after": String(result.retryAfter) },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
await next();
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
103
|
+
const normaliseOrigin = (value) => {
|
|
104
|
+
try {
|
|
105
|
+
return new URL(value).origin;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
export function allowedOrigins(config) {
|
|
112
|
+
return [...new Set([config.MICA_PUBLIC_URL, ...config.adminOrigins].map(normaliseOrigin))];
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* First CSRF layer: the browser sets `Origin` on every state-changing request
|
|
116
|
+
* and a page on another site cannot forge it.
|
|
117
|
+
*
|
|
118
|
+
* Applied to the whole API — including sign-in, which has no session yet and
|
|
119
|
+
* so cannot use the token check below.
|
|
120
|
+
*/
|
|
121
|
+
export function originGuard() {
|
|
122
|
+
return async (c, next) => {
|
|
123
|
+
if (SAFE_METHODS.has(c.req.method)) {
|
|
124
|
+
await next();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const { config } = c.get("ctx");
|
|
128
|
+
const origin = c.req.header("origin");
|
|
129
|
+
if (origin) {
|
|
130
|
+
if (!allowedOrigins(config).includes(normaliseOrigin(origin))) {
|
|
131
|
+
throw new ApiError("forbidden", "Request origin is not allowed.");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else if (config.isProduction) {
|
|
135
|
+
// A browser always sends Origin on writes; its absence means the request
|
|
136
|
+
// did not come from one. Tolerated in development for curl-based testing.
|
|
137
|
+
throw new ApiError("forbidden", "Missing Origin header.");
|
|
138
|
+
}
|
|
139
|
+
await next();
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin content routes — entries and singletons.
|
|
3
|
+
*
|
|
4
|
+
* Thin: parse, delegate to the content store, serialise. Every rule about
|
|
5
|
+
* validation, slugs and layout lives in the store, which is shared with the
|
|
6
|
+
* Astro build rather than reimplemented here.
|
|
7
|
+
*
|
|
8
|
+
* Three things shape this surface:
|
|
9
|
+
*
|
|
10
|
+
* - An entry is addressed by `collection/slug`. The path in the API is the
|
|
11
|
+
* path in the repository, so a URL here can be read off a diff.
|
|
12
|
+
* - There are no revision endpoints. Git is the history.
|
|
13
|
+
* - There is no status endpoint. Committed is published, and a draft is
|
|
14
|
+
* unsaved work in the editor that never reaches the server.
|
|
15
|
+
*
|
|
16
|
+
* `revision` is what makes concurrent editing safe: an opaque token naming the
|
|
17
|
+
* version an editor opened, sent back with the save.
|
|
18
|
+
*/
|
|
19
|
+
import { Hono } from "hono";
|
|
20
|
+
import { type Env } from "../http.js";
|
|
21
|
+
export declare function adminContentRoutes(): Hono<Env>;
|
|
22
|
+
//# sourceMappingURL=admin-content.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin-content.d.ts","sourceRoot":"","sources":["../../src/routes/admin-content.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAIH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAI5B,OAAO,EAAW,KAAK,GAAG,EAAE,MAAM,YAAY,CAAC;AAwC/C,wBAAgB,kBAAkB,IAAI,IAAI,CAAC,GAAG,CAAC,CA0K9C"}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin content routes — entries and singletons.
|
|
3
|
+
*
|
|
4
|
+
* Thin: parse, delegate to the content store, serialise. Every rule about
|
|
5
|
+
* validation, slugs and layout lives in the store, which is shared with the
|
|
6
|
+
* Astro build rather than reimplemented here.
|
|
7
|
+
*
|
|
8
|
+
* Three things shape this surface:
|
|
9
|
+
*
|
|
10
|
+
* - An entry is addressed by `collection/slug`. The path in the API is the
|
|
11
|
+
* path in the repository, so a URL here can be read off a diff.
|
|
12
|
+
* - There are no revision endpoints. Git is the history.
|
|
13
|
+
* - There is no status endpoint. Committed is published, and a draft is
|
|
14
|
+
* unsaved work in the editor that never reaches the server.
|
|
15
|
+
*
|
|
16
|
+
* `revision` is what makes concurrent editing safe: an opaque token naming the
|
|
17
|
+
* version an editor opened, sent back with the save.
|
|
18
|
+
*/
|
|
19
|
+
import { ConflictError, ValidationError } from "@micacms/connectors";
|
|
20
|
+
import { collectionEntryPath, compareEntries, entryTitle, singletonPath } from "@micacms/core";
|
|
21
|
+
import { Hono } from "hono";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { ApiError, badRequest, notFound } from "../lib/errors.js";
|
|
24
|
+
import { actorOf } from "../http.js";
|
|
25
|
+
const saveSchema = z.object({
|
|
26
|
+
data: z.record(z.string(), z.unknown()),
|
|
27
|
+
slug: z.string().max(120).optional(),
|
|
28
|
+
/**
|
|
29
|
+
* The revision the editor opened. `null` asserts the entry did not exist,
|
|
30
|
+
* which is what makes two simultaneous creates fail instead of one silently
|
|
31
|
+
* overwriting the other.
|
|
32
|
+
*/
|
|
33
|
+
revision: z.string().nullable().optional(),
|
|
34
|
+
});
|
|
35
|
+
const reorderSchema = z.object({ slugs: z.array(z.string().min(1)).max(2000) });
|
|
36
|
+
async function jsonBody(c) {
|
|
37
|
+
return c.req.json().catch(() => {
|
|
38
|
+
throw badRequest("The request body must be valid JSON.");
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Turns a store failure into the HTTP answer it deserves.
|
|
43
|
+
*
|
|
44
|
+
* A conflict is 409 and is recoverable in the editor — reload and reapply. A
|
|
45
|
+
* validation failure is 422 with the offending fields. Everything else is a
|
|
46
|
+
* genuine fault and reaches the error handler unchanged.
|
|
47
|
+
*/
|
|
48
|
+
async function handled(run) {
|
|
49
|
+
try {
|
|
50
|
+
return await run();
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof ConflictError)
|
|
54
|
+
throw new ApiError("conflict", error.message);
|
|
55
|
+
if (error instanceof ValidationError) {
|
|
56
|
+
throw new ApiError("validation_failed", error.message, { issues: error.issues });
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function adminContentRoutes() {
|
|
62
|
+
const app = new Hono();
|
|
63
|
+
/* ------------------------------------------------------------ entries */
|
|
64
|
+
app.get("/collections/:collection/entries", async (c) => {
|
|
65
|
+
const { content } = c.get("ctx").services;
|
|
66
|
+
const collection = c.req.param("collection");
|
|
67
|
+
const def = content.requireCollection(collection);
|
|
68
|
+
const entries = await handled(() => content.list(collection));
|
|
69
|
+
const search = c.req.query("search")?.trim().toLowerCase();
|
|
70
|
+
const items = entries
|
|
71
|
+
.map((entry) => ({
|
|
72
|
+
slug: entry.slug,
|
|
73
|
+
position: entry.position,
|
|
74
|
+
title: entryTitle(def, entry.data),
|
|
75
|
+
data: entry.data,
|
|
76
|
+
}))
|
|
77
|
+
// A substring match over the payload, which is what the SQL `LIKE` did.
|
|
78
|
+
// Content trees are small enough that filtering in memory is simpler than
|
|
79
|
+
// an index, and it can no longer disagree with what is on disk.
|
|
80
|
+
.filter((item) => !search || JSON.stringify(item.data).toLowerCase().includes(search))
|
|
81
|
+
// The order the model asks for — newest first for news, by hand for a
|
|
82
|
+
// menu. Ties fall back to the manual position, so the list is stable
|
|
83
|
+
// between requests whatever the sort field says.
|
|
84
|
+
.sort(compareEntries(def));
|
|
85
|
+
return c.json({ items, total: items.length });
|
|
86
|
+
});
|
|
87
|
+
app.post("/collections/:collection/entries", async (c) => {
|
|
88
|
+
const { content } = c.get("ctx").services;
|
|
89
|
+
const parsed = saveSchema.safeParse(await jsonBody(c));
|
|
90
|
+
if (!parsed.success)
|
|
91
|
+
throw badRequest("The entry payload is malformed.");
|
|
92
|
+
const saved = await handled(() => content.save(c.req.param("collection"), {
|
|
93
|
+
data: parsed.data.data,
|
|
94
|
+
// A create asserts absence unless the client says otherwise.
|
|
95
|
+
expectedRevision: parsed.data.revision ?? null,
|
|
96
|
+
author: actorOf(c) ? { name: actorOf(c).email, email: actorOf(c).email } : undefined,
|
|
97
|
+
}));
|
|
98
|
+
return c.json(saved, 201);
|
|
99
|
+
});
|
|
100
|
+
app.post("/collections/:collection/reorder", async (c) => {
|
|
101
|
+
const { content } = c.get("ctx").services;
|
|
102
|
+
const parsed = reorderSchema.safeParse(await jsonBody(c));
|
|
103
|
+
if (!parsed.success)
|
|
104
|
+
throw badRequest("Expected a list of entry slugs.");
|
|
105
|
+
await handled(() => content.reorder(c.req.param("collection"), parsed.data.slugs));
|
|
106
|
+
return c.json({ ok: true });
|
|
107
|
+
});
|
|
108
|
+
/** Server-side slug generation, so the URL preview matches what is saved. */
|
|
109
|
+
app.get("/collections/:collection/slug", async (c) => {
|
|
110
|
+
const { content } = c.get("ctx").services;
|
|
111
|
+
const source = c.req.query("source") ?? "";
|
|
112
|
+
if (!source.trim())
|
|
113
|
+
return c.json({ slug: "" });
|
|
114
|
+
return c.json({
|
|
115
|
+
slug: await handled(() => content.suggestSlug(c.req.param("collection"), source, c.req.query("except"))),
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
app.get("/collections/:collection/entries/:slug", async (c) => {
|
|
119
|
+
const { content } = c.get("ctx").services;
|
|
120
|
+
const collection = c.req.param("collection");
|
|
121
|
+
const slug = c.req.param("slug");
|
|
122
|
+
const entry = await handled(() => content.get(collection, slug));
|
|
123
|
+
if (!entry)
|
|
124
|
+
throw notFound("This entry no longer exists.");
|
|
125
|
+
const def = content.requireCollection(collection);
|
|
126
|
+
return c.json({
|
|
127
|
+
collection,
|
|
128
|
+
slug: entry.slug,
|
|
129
|
+
position: entry.position,
|
|
130
|
+
title: entryTitle(def, entry.data),
|
|
131
|
+
data: entry.data,
|
|
132
|
+
// Where this entry is on disk. Derived from the model, because whether an
|
|
133
|
+
// entry is one file or a directory depends on whether it has rich text or
|
|
134
|
+
// media — a rule the editor should not have to reimplement to show it.
|
|
135
|
+
path: collectionEntryPath(def.fields, collection, entry.slug),
|
|
136
|
+
// Sent back with the save. Without it the editor cannot tell the server
|
|
137
|
+
// which version of the entry it was looking at.
|
|
138
|
+
revision: await content.revisionOf(collection, slug),
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
app.put("/collections/:collection/entries/:slug", async (c) => {
|
|
142
|
+
const { content } = c.get("ctx").services;
|
|
143
|
+
const parsed = saveSchema.safeParse(await jsonBody(c));
|
|
144
|
+
if (!parsed.success)
|
|
145
|
+
throw badRequest("The entry payload is malformed.");
|
|
146
|
+
const actor = actorOf(c);
|
|
147
|
+
const saved = await handled(() => content.save(c.req.param("collection"), {
|
|
148
|
+
data: parsed.data.data,
|
|
149
|
+
slug: c.req.param("slug"),
|
|
150
|
+
expectedRevision: parsed.data.revision,
|
|
151
|
+
author: actor ? { name: actor.email, email: actor.email } : undefined,
|
|
152
|
+
}));
|
|
153
|
+
return c.json(saved);
|
|
154
|
+
});
|
|
155
|
+
app.delete("/collections/:collection/entries/:slug", async (c) => {
|
|
156
|
+
const { content } = c.get("ctx").services;
|
|
157
|
+
const actor = actorOf(c);
|
|
158
|
+
await handled(() => content.remove(c.req.param("collection"), c.req.param("slug"), {
|
|
159
|
+
author: actor ? { name: actor.email, email: actor.email } : undefined,
|
|
160
|
+
}));
|
|
161
|
+
return c.json({ ok: true });
|
|
162
|
+
});
|
|
163
|
+
/** Entries elsewhere that point at this one — shown before a delete. */
|
|
164
|
+
app.get("/collections/:collection/entries/:slug/references", async (c) => {
|
|
165
|
+
const { content } = c.get("ctx").services;
|
|
166
|
+
const references = await handled(() => content.findReferences(c.req.param("collection"), c.req.param("slug")));
|
|
167
|
+
return c.json({ references });
|
|
168
|
+
});
|
|
169
|
+
/* --------------------------------------------------------- singletons */
|
|
170
|
+
app.get("/singletons/:name", async (c) => {
|
|
171
|
+
const { content } = c.get("ctx").services;
|
|
172
|
+
const name = c.req.param("name");
|
|
173
|
+
const def = content.requireSingleton(name);
|
|
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
|
+
app.put("/singletons/:name", async (c) => {
|
|
183
|
+
const { content } = c.get("ctx").services;
|
|
184
|
+
const body = (await jsonBody(c));
|
|
185
|
+
const actor = actorOf(c);
|
|
186
|
+
const saved = await handled(() => content.saveSingleton(c.req.param("name"), body?.data ?? {}, {
|
|
187
|
+
expectedRevision: body?.revision,
|
|
188
|
+
author: actor ? { name: actor.email, email: actor.email } : undefined,
|
|
189
|
+
}));
|
|
190
|
+
return c.json(saved);
|
|
191
|
+
});
|
|
192
|
+
return app;
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=admin-content.js.map
|