@o-a/cms-agent 0.1.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/LICENSE +105 -0
- package/README.md +132 -0
- package/dist/boot.d.ts +15 -0
- package/dist/boot.js +33 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +45 -0
- package/dist/create-site/cli.d.ts +2 -0
- package/dist/create-site/cli.js +31 -0
- package/dist/create-site/generate-site.d.ts +9 -0
- package/dist/create-site/generate-site.js +109 -0
- package/dist/create-site/mint-token-cli.d.ts +2 -0
- package/dist/create-site/mint-token-cli.js +26 -0
- package/dist/create-site/mint-token.d.ts +7 -0
- package/dist/create-site/mint-token.js +49 -0
- package/dist/create-site/template/content/menus/footerCompany.json +9 -0
- package/dist/create-site/template/content/menus/footerProduct.json +9 -0
- package/dist/create-site/template/content/menus/footerResources.json +9 -0
- package/dist/create-site/template/content/menus/main.json +9 -0
- package/dist/create-site/template/content/pages/404.json +22 -0
- package/dist/create-site/template/content/pages/about/careers.json +21 -0
- package/dist/create-site/template/content/pages/about/team.json +21 -0
- package/dist/create-site/template/content/pages/about.json +21 -0
- package/dist/create-site/template/content/pages/docs/deployment.json +21 -0
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +21 -0
- package/dist/create-site/template/content/pages/docs/getting-started.json +23 -0
- package/dist/create-site/template/content/pages/docs.json +21 -0
- package/dist/create-site/template/content/pages/index.json +181 -0
- package/dist/create-site/template/content/redirects.json +4 -0
- package/dist/create-site/template/dockerignore +6 -0
- package/dist/create-site/template/gitignore +11 -0
- package/dist/create-site/template/theme/assets/site.js +49 -0
- package/dist/create-site/template/theme/assets/style.css +1153 -0
- package/dist/create-site/template/theme/blocks/button.liquid +21 -0
- package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
- package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
- package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
- package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
- package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
- package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
- package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
- package/dist/create-site/template/theme/layouts/theme.liquid +99 -0
- package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +27 -0
- package/dist/create-site/template/theme/sections/faq.liquid +27 -0
- package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
- package/dist/create-site/template/theme/sections/hero.liquid +36 -0
- package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
- package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
- package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
- package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
- package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
- package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
- package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
- package/dist/create-site/template/vhost/Dockerfile +33 -0
- package/dist/create-site/template/vhost/docker-entrypoint.sh +30 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/media/drivers/driver.d.ts +11 -0
- package/dist/media/drivers/driver.js +1 -0
- package/dist/media/drivers/local-fs-driver.d.ts +3 -0
- package/dist/media/drivers/local-fs-driver.js +81 -0
- package/dist/media/filename.d.ts +1 -0
- package/dist/media/filename.js +31 -0
- package/dist/media/manage-media.d.ts +19 -0
- package/dist/media/manage-media.js +45 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +49 -0
- package/dist/renderer/engine.d.ts +2 -0
- package/dist/renderer/engine.js +48 -0
- package/dist/renderer/render-page.d.ts +28 -0
- package/dist/renderer/render-page.js +143 -0
- package/dist/renderer/theme-templates.d.ts +7 -0
- package/dist/renderer/theme-templates.js +82 -0
- package/dist/routes/assets.d.ts +6 -0
- package/dist/routes/assets.js +30 -0
- package/dist/routes/batch.d.ts +10 -0
- package/dist/routes/batch.js +148 -0
- package/dist/routes/capabilities.d.ts +5 -0
- package/dist/routes/capabilities.js +27 -0
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.js +166 -0
- package/dist/routes/drafts.d.ts +10 -0
- package/dist/routes/drafts.js +73 -0
- package/dist/routes/git.d.ts +8 -0
- package/dist/routes/git.js +158 -0
- package/dist/routes/index.d.ts +19 -0
- package/dist/routes/index.js +84 -0
- package/dist/routes/media-public.d.ts +5 -0
- package/dist/routes/media-public.js +48 -0
- package/dist/routes/media.d.ts +9 -0
- package/dist/routes/media.js +84 -0
- package/dist/routes/menus.d.ts +8 -0
- package/dist/routes/menus.js +77 -0
- package/dist/routes/preview-revision.d.ts +13 -0
- package/dist/routes/preview-revision.js +109 -0
- package/dist/routes/preview.d.ts +13 -0
- package/dist/routes/preview.js +52 -0
- package/dist/routes/public.d.ts +11 -0
- package/dist/routes/public.js +117 -0
- package/dist/routes/publish.d.ts +10 -0
- package/dist/routes/publish.js +105 -0
- package/dist/routes/redirects.d.ts +8 -0
- package/dist/routes/redirects.js +132 -0
- package/dist/routes/search.d.ts +8 -0
- package/dist/routes/search.js +11 -0
- package/dist/routes/sitemap.d.ts +6 -0
- package/dist/routes/sitemap.js +79 -0
- package/dist/routes/theme.d.ts +10 -0
- package/dist/routes/theme.js +17 -0
- package/dist/schemas/instance.schema.json +17 -0
- package/dist/schemas/menu.schema.json +23 -0
- package/dist/schemas/page.schema.json +20 -0
- package/dist/schemas/post.schema.json +25 -0
- package/dist/schemas/redirects.schema.json +24 -0
- package/dist/search/drivers/driver.d.ts +9 -0
- package/dist/search/drivers/driver.js +1 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +3 -0
- package/dist/search/drivers/node-sqlite-driver.js +19 -0
- package/dist/search/query-index.d.ts +5 -0
- package/dist/search/query-index.js +21 -0
- package/dist/search/rebuild-index.d.ts +2 -0
- package/dist/search/rebuild-index.js +111 -0
- package/dist/server-config.d.ts +22 -0
- package/dist/server-config.js +196 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +163 -0
- package/dist/services/batch.d.ts +35 -0
- package/dist/services/batch.js +136 -0
- package/dist/services/checkpoint.d.ts +4 -0
- package/dist/services/checkpoint.js +45 -0
- package/dist/services/content-read.d.ts +27 -0
- package/dist/services/content-read.js +156 -0
- package/dist/services/delete-content.d.ts +12 -0
- package/dist/services/delete-content.js +174 -0
- package/dist/services/dev-tunnel.d.ts +5 -0
- package/dist/services/dev-tunnel.js +5 -0
- package/dist/services/drafts.d.ts +15 -0
- package/dist/services/drafts.js +155 -0
- package/dist/services/etag.d.ts +2 -0
- package/dist/services/etag.js +30 -0
- package/dist/services/fs-walk.d.ts +1 -0
- package/dist/services/fs-walk.js +26 -0
- package/dist/services/git-commit.d.ts +3 -0
- package/dist/services/git-commit.js +12 -0
- package/dist/services/git-history.d.ts +28 -0
- package/dist/services/git-history.js +131 -0
- package/dist/services/git-revert.d.ts +12 -0
- package/dist/services/git-revert.js +140 -0
- package/dist/services/git.d.ts +16 -0
- package/dist/services/git.js +127 -0
- package/dist/services/interval-job.d.ts +4 -0
- package/dist/services/interval-job.js +9 -0
- package/dist/services/ip-allowlist.d.ts +7 -0
- package/dist/services/ip-allowlist.js +31 -0
- package/dist/services/manage-menus.d.ts +10 -0
- package/dist/services/manage-menus.js +84 -0
- package/dist/services/manage-redirects.d.ts +25 -0
- package/dist/services/manage-redirects.js +166 -0
- package/dist/services/menus.d.ts +8 -0
- package/dist/services/menus.js +42 -0
- package/dist/services/migration-runner.d.ts +13 -0
- package/dist/services/migration-runner.js +123 -0
- package/dist/services/mime-types.d.ts +3 -0
- package/dist/services/mime-types.js +36 -0
- package/dist/services/move.d.ts +16 -0
- package/dist/services/move.js +175 -0
- package/dist/services/path-safety.d.ts +6 -0
- package/dist/services/path-safety.js +112 -0
- package/dist/services/post-urls.d.ts +3 -0
- package/dist/services/post-urls.js +27 -0
- package/dist/services/prepared-operation.d.ts +4 -0
- package/dist/services/prepared-operation.js +1 -0
- package/dist/services/publish.d.ts +14 -0
- package/dist/services/publish.js +254 -0
- package/dist/services/rate-limit-config.d.ts +9 -0
- package/dist/services/rate-limit-config.js +20 -0
- package/dist/services/redirects.d.ts +27 -0
- package/dist/services/redirects.js +175 -0
- package/dist/services/resolve-blog-url.d.ts +11 -0
- package/dist/services/resolve-blog-url.js +31 -0
- package/dist/services/resolve-url.d.ts +11 -0
- package/dist/services/resolve-url.js +20 -0
- package/dist/services/startup-checks.d.ts +10 -0
- package/dist/services/startup-checks.js +81 -0
- package/dist/services/static-file.d.ts +7 -0
- package/dist/services/static-file.js +37 -0
- package/dist/services/theme-component-file.d.ts +5 -0
- package/dist/services/theme-component-file.js +32 -0
- package/dist/services/theme-page-templates.d.ts +7 -0
- package/dist/services/theme-page-templates.js +53 -0
- package/dist/services/theme-schemas.d.ts +2 -0
- package/dist/services/theme-schemas.js +63 -0
- package/dist/services/token-auth.d.ts +10 -0
- package/dist/services/token-auth.js +64 -0
- package/dist/services/urls.d.ts +2 -0
- package/dist/services/urls.js +20 -0
- package/dist/services/validation.d.ts +24 -0
- package/dist/services/validation.js +158 -0
- package/dist/services/write-queue.d.ts +1 -0
- package/dist/services/write-queue.js +14 -0
- package/package.json +53 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Shared rate-limit config markers for route options objects
|
|
2
|
+
// (Fastify's config: { rateLimit: {...} } opt-in shape, consumed by
|
|
3
|
+
// @fastify/rate-limit registered with global: false in server.ts).
|
|
4
|
+
// One constant per tier, not ad-hoc literals at each of the ~10 call
|
|
5
|
+
// sites, both for DRYness and so the structural test
|
|
6
|
+
// (test/static/static-analysis.test.ts) has something unambiguous to
|
|
7
|
+
// grep for.
|
|
8
|
+
// An empty per-route override: @fastify/rate-limit (registered with
|
|
9
|
+
// global: false in server.ts) still requires each route to opt in via
|
|
10
|
+
// this marker, but an empty {} inherits the plugin's own registration-
|
|
11
|
+
// level max/timeWindow - which server.ts sets from serverConfig.rateLimit
|
|
12
|
+
// (site.config.json-configurable, defaulting to 60/min - see
|
|
13
|
+
// server-config.ts). Verified empirically before relying on it: a
|
|
14
|
+
// route's own config.rateLimit only needs *something* present to opt
|
|
15
|
+
// in, not a duplicated copy of the numbers.
|
|
16
|
+
export const WRITE_ROUTE_RATE_LIMIT = { rateLimit: {} };
|
|
17
|
+
// Generous defense-in-depth against basic scanning of the one endpoint
|
|
18
|
+
// reachable with zero credentials, not a meaningful throttle on
|
|
19
|
+
// legitimate use.
|
|
20
|
+
export const CAPABILITIES_RATE_LIMIT = { rateLimit: { max: 300, timeWindow: 60000 } };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
export type RedirectReason = 'redirect-cycle' | 'malformed-file' | 'invalid-target';
|
|
3
|
+
export declare class RedirectError extends Error {
|
|
4
|
+
readonly reason: RedirectReason;
|
|
5
|
+
constructor(reason: RedirectReason, message: string);
|
|
6
|
+
}
|
|
7
|
+
export interface RedirectEntry {
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
note?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface Redirects {
|
|
13
|
+
schemaVersion: number;
|
|
14
|
+
entries: RedirectEntry[];
|
|
15
|
+
}
|
|
16
|
+
export declare const CURRENT_REDIRECTS_SCHEMA_VERSION = 1;
|
|
17
|
+
export declare function loadRedirects(config: SiteConfig): Redirects;
|
|
18
|
+
export declare function loadRedirectsStrict(config: SiteConfig): Redirects;
|
|
19
|
+
export declare function serialiseRedirects(entries: RedirectEntry[]): string;
|
|
20
|
+
export declare function buildRedirectLookup(entries: RedirectEntry[]): Map<string, string>;
|
|
21
|
+
export interface AddRedirectResult {
|
|
22
|
+
entries: RedirectEntry[];
|
|
23
|
+
retargeted: RedirectEntry[];
|
|
24
|
+
}
|
|
25
|
+
export declare function addRedirect(entries: RedirectEntry[], from: string, to: string, note?: string): AddRedirectResult;
|
|
26
|
+
export declare function removeRedirectForPath(entries: RedirectEntry[], url: string): RedirectEntry[];
|
|
27
|
+
export declare function isValidRedirectTarget(value: string): boolean;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { validateRedirects } from "./validation.js";
|
|
3
|
+
export class RedirectError extends Error {
|
|
4
|
+
reason;
|
|
5
|
+
constructor(reason, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'RedirectError';
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export const CURRENT_REDIRECTS_SCHEMA_VERSION = 1;
|
|
12
|
+
const EMPTY_REDIRECTS = { schemaVersion: CURRENT_REDIRECTS_SCHEMA_VERSION, entries: [] };
|
|
13
|
+
function isPlainObject(value) {
|
|
14
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
// The old shape (pre-Group M) was a flat { from: to } map, with no
|
|
17
|
+
// schemaVersion or entries key at all. Identified purely by the
|
|
18
|
+
// ABSENCE of both new-shape keys - a file that has an `entries` key is
|
|
19
|
+
// always treated as new-shape from here on, even if malformed, so a
|
|
20
|
+
// corrupt new-shape file is never silently reinterpreted as an empty
|
|
21
|
+
// old-shape map (which would look like "no redirects" and risk a
|
|
22
|
+
// write-time caller clobbering real data - see loadRedirectsStrict).
|
|
23
|
+
function isOldFlatShape(value) {
|
|
24
|
+
return !('schemaVersion' in value) && !('entries' in value);
|
|
25
|
+
}
|
|
26
|
+
function upgradeOldFlatShape(value) {
|
|
27
|
+
const entries = Object.entries(value)
|
|
28
|
+
.filter((pair) => typeof pair[1] === 'string')
|
|
29
|
+
.map(([from, to]) => ({ from, to }));
|
|
30
|
+
return { schemaVersion: CURRENT_REDIRECTS_SCHEMA_VERSION, entries };
|
|
31
|
+
}
|
|
32
|
+
// Read-time loader, used by public request handling (resolve-url.ts,
|
|
33
|
+
// resolve-blog-url.ts) as well as by write paths before they call
|
|
34
|
+
// loadRedirectsStrict for the extra guarantee. Graceful-empty on any
|
|
35
|
+
// failure - including a malformed new-shape file - matching
|
|
36
|
+
// theme-schemas.ts's established pattern: a corrupt redirects.json
|
|
37
|
+
// must never crash public page serving, it should just mean redirects
|
|
38
|
+
// don't resolve until the file is fixed.
|
|
39
|
+
export function loadRedirects(config) {
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = JSON.parse(readFileSync(config.redirectsPath, 'utf-8'));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return EMPTY_REDIRECTS;
|
|
46
|
+
}
|
|
47
|
+
if (!isPlainObject(raw)) {
|
|
48
|
+
return EMPTY_REDIRECTS;
|
|
49
|
+
}
|
|
50
|
+
if (isOldFlatShape(raw)) {
|
|
51
|
+
return upgradeOldFlatShape(raw);
|
|
52
|
+
}
|
|
53
|
+
const result = validateRedirects(raw);
|
|
54
|
+
if (!result.valid) {
|
|
55
|
+
return EMPTY_REDIRECTS;
|
|
56
|
+
}
|
|
57
|
+
return raw;
|
|
58
|
+
}
|
|
59
|
+
// Stricter sibling of loadRedirects, for write paths only (manage-
|
|
60
|
+
// redirects.ts, and any other future direct redirect writer). Unlike
|
|
61
|
+
// the graceful read-time loader, this throws on a malformed new-shape
|
|
62
|
+
// file instead of silently treating it as empty - a write that follows
|
|
63
|
+
// an incorrectly-"empty" load would overwrite and destroy whatever
|
|
64
|
+
// real entries the file actually held, which is a much worse outcome
|
|
65
|
+
// for a write than for a page render.
|
|
66
|
+
export function loadRedirectsStrict(config) {
|
|
67
|
+
if (!existsSync(config.redirectsPath)) {
|
|
68
|
+
return EMPTY_REDIRECTS;
|
|
69
|
+
}
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = JSON.parse(readFileSync(config.redirectsPath, 'utf-8'));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new RedirectError('malformed-file', 'redirects.json exists but is not valid JSON; refusing to write until it is fixed');
|
|
76
|
+
}
|
|
77
|
+
if (!isPlainObject(raw)) {
|
|
78
|
+
throw new RedirectError('malformed-file', 'redirects.json exists but is not a JSON object');
|
|
79
|
+
}
|
|
80
|
+
if (isOldFlatShape(raw)) {
|
|
81
|
+
return upgradeOldFlatShape(raw);
|
|
82
|
+
}
|
|
83
|
+
const result = validateRedirects(raw);
|
|
84
|
+
if (!result.valid) {
|
|
85
|
+
throw new RedirectError('malformed-file', `redirects.json exists but does not match the expected shape: ${result.errors.map((e) => e.message).join(', ')}`);
|
|
86
|
+
}
|
|
87
|
+
return raw;
|
|
88
|
+
}
|
|
89
|
+
export function serialiseRedirects(entries) {
|
|
90
|
+
const redirects = { schemaVersion: CURRENT_REDIRECTS_SCHEMA_VERSION, entries };
|
|
91
|
+
return JSON.stringify(redirects, null, 2);
|
|
92
|
+
}
|
|
93
|
+
export function buildRedirectLookup(entries) {
|
|
94
|
+
return new Map(entries.map((entry) => [entry.from, entry.to]));
|
|
95
|
+
}
|
|
96
|
+
// Collapses chains at write time. Walks `to` forward through any
|
|
97
|
+
// existing chain to its final destination (not just one hop), then
|
|
98
|
+
// rewrites every existing entry that pointed at the just-added `from`
|
|
99
|
+
// to point at that final destination too, so old entries stay
|
|
100
|
+
// collapsed alongside the new one. Also handles updating an existing
|
|
101
|
+
// entry in place: if `from` already has an entry, it is replaced.
|
|
102
|
+
export function addRedirect(entries, from, to, note) {
|
|
103
|
+
const byFrom = new Map(entries.map((entry) => [entry.from, entry]));
|
|
104
|
+
let target = to;
|
|
105
|
+
const visited = new Set();
|
|
106
|
+
for (;;) {
|
|
107
|
+
const next = byFrom.get(target)?.to;
|
|
108
|
+
if (next === undefined) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (visited.has(target)) {
|
|
112
|
+
throw new RedirectError('redirect-cycle', `redirects.json already contains a cycle reachable from "${to}"`);
|
|
113
|
+
}
|
|
114
|
+
visited.add(target);
|
|
115
|
+
target = next;
|
|
116
|
+
}
|
|
117
|
+
// The walk above only detects a cycle among *pre-existing* entries.
|
|
118
|
+
// A walk that terminates normally can still resolve back to `from`
|
|
119
|
+
// itself (e.g. adding "/b" -> "/a" when "/a" -> "/b" already exists:
|
|
120
|
+
// the walk hits "/b", which has no further mapping, and stops without
|
|
121
|
+
// revisiting anything) - that's a self-referential redirect, checked
|
|
122
|
+
// separately here.
|
|
123
|
+
if (target === from) {
|
|
124
|
+
throw new RedirectError('redirect-cycle', `Adding redirect "${from}" -> "${to}" would create a cycle (resolves back to "${from}")`);
|
|
125
|
+
}
|
|
126
|
+
const retargeted = [];
|
|
127
|
+
const updated = [];
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (entry.from === from) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (entry.to === from) {
|
|
133
|
+
const rewritten = { ...entry, to: target };
|
|
134
|
+
retargeted.push(rewritten);
|
|
135
|
+
updated.push(rewritten);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
updated.push(entry);
|
|
139
|
+
}
|
|
140
|
+
const newEntry = note !== undefined ? { from, to: target, note } : { from, to: target };
|
|
141
|
+
updated.push(newEntry);
|
|
142
|
+
return { entries: updated, retargeted };
|
|
143
|
+
}
|
|
144
|
+
export function removeRedirectForPath(entries, url) {
|
|
145
|
+
if (!entries.some((entry) => entry.from === url)) {
|
|
146
|
+
return entries;
|
|
147
|
+
}
|
|
148
|
+
return entries.filter((entry) => entry.from !== url);
|
|
149
|
+
}
|
|
150
|
+
// Checked by char code rather than a regex control-character class, to
|
|
151
|
+
// avoid embedding literal control characters in source.
|
|
152
|
+
function hasControlCharacter(value) {
|
|
153
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
154
|
+
const code = value.charCodeAt(i);
|
|
155
|
+
if (code <= 0x1f || code === 0x7f) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
// Shared by manage-redirects.ts (POST/PUT /v1/redirects) and the
|
|
162
|
+
// retroactive fix to delete-content.ts's client-supplied redirectTo -
|
|
163
|
+
// both must enforce the same rule on a redirect target, or the two
|
|
164
|
+
// write paths would silently disagree on what's allowed into the same
|
|
165
|
+
// file. Internal-only: must start with a single "/", never a scheme,
|
|
166
|
+
// host, or protocol-relative "//" - closes an open-redirect vector.
|
|
167
|
+
// Control characters are rejected regardless of the internal/external
|
|
168
|
+
// question, since public.ts writes this value directly into the
|
|
169
|
+
// Location response header.
|
|
170
|
+
export function isValidRedirectTarget(value) {
|
|
171
|
+
if (value.length === 0 || hasControlCharacter(value)) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return value.startsWith('/') && !value.startsWith('//');
|
|
175
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
export type ResolvedBlogUrl = {
|
|
3
|
+
kind: 'post';
|
|
4
|
+
relativePath: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'redirect';
|
|
7
|
+
to: string;
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'not-found';
|
|
10
|
+
};
|
|
11
|
+
export declare function resolveBlogUrl(config: SiteConfig, url: string): ResolvedBlogUrl;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { sanitisePath } from "./path-safety.js";
|
|
3
|
+
import { buildRedirectLookup, loadRedirects } from "./redirects.js";
|
|
4
|
+
import { urlToPostPath } from "./post-urls.js";
|
|
5
|
+
// Mirrors resolve-url.ts's shape exactly (a live post always wins over
|
|
6
|
+
// a redirect at the same URL), kept as its own distinct result type
|
|
7
|
+
// rather than reusing ResolvedUrl - clearer branching at the call site
|
|
8
|
+
// and avoids touching Group C's already-tested resolve-url.ts.
|
|
9
|
+
//
|
|
10
|
+
// Unlike pagesRoot (always expected to exist on a real site),
|
|
11
|
+
// postsRoot is optional - a site that has never used blog posts has
|
|
12
|
+
// no content/posts/ directory at all. sanitisePath calls realpathSync
|
|
13
|
+
// directly on its root argument, which throws a raw, uncaught ENOENT
|
|
14
|
+
// if that root itself is missing - so postsRoot's existence is checked
|
|
15
|
+
// first, before ever calling sanitisePath, rather than letting a
|
|
16
|
+
// perfectly ordinary "no posts yet" site crash on its first /blog/ hit.
|
|
17
|
+
export function resolveBlogUrl(config, url) {
|
|
18
|
+
const relativePath = urlToPostPath(url);
|
|
19
|
+
if (relativePath !== null && existsSync(config.postsRoot)) {
|
|
20
|
+
const postFile = sanitisePath(config.postsRoot, relativePath);
|
|
21
|
+
if (existsSync(postFile)) {
|
|
22
|
+
return { kind: 'post', relativePath };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const lookup = buildRedirectLookup(loadRedirects(config).entries);
|
|
26
|
+
const to = lookup.get(url);
|
|
27
|
+
if (to !== undefined) {
|
|
28
|
+
return { kind: 'redirect', to };
|
|
29
|
+
}
|
|
30
|
+
return { kind: 'not-found' };
|
|
31
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
export type ResolvedUrl = {
|
|
3
|
+
kind: 'page';
|
|
4
|
+
relativePath: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'redirect';
|
|
7
|
+
to: string;
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'not-found';
|
|
10
|
+
};
|
|
11
|
+
export declare function resolveUrl(config: SiteConfig, url: string): ResolvedUrl;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { sanitisePath } from "./path-safety.js";
|
|
3
|
+
import { buildRedirectLookup, loadRedirects } from "./redirects.js";
|
|
4
|
+
import { urlToPagePath } from "./urls.js";
|
|
5
|
+
// A redirect whose "from" matches a live page is never served: the
|
|
6
|
+
// page always wins (E6). Only consults redirects.json when no live
|
|
7
|
+
// page exists at the requested URL.
|
|
8
|
+
export function resolveUrl(config, url) {
|
|
9
|
+
const relativePath = urlToPagePath(url);
|
|
10
|
+
const pageFile = sanitisePath(config.pagesRoot, relativePath);
|
|
11
|
+
if (existsSync(pageFile)) {
|
|
12
|
+
return { kind: 'page', relativePath };
|
|
13
|
+
}
|
|
14
|
+
const lookup = buildRedirectLookup(loadRedirects(config).entries);
|
|
15
|
+
const to = lookup.get(url);
|
|
16
|
+
if (to !== undefined) {
|
|
17
|
+
return { kind: 'redirect', to };
|
|
18
|
+
}
|
|
19
|
+
return { kind: 'not-found' };
|
|
20
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type StartupCheckReason = 'node-version-too-low' | 'site-root-missing' | 'git-binary-absent' | 'not-a-git-repo' | 'invalid-site-config' | 'invalid-token-config';
|
|
2
|
+
export declare class StartupCheckError extends Error {
|
|
3
|
+
readonly reason: StartupCheckReason;
|
|
4
|
+
constructor(reason: StartupCheckReason, message: string);
|
|
5
|
+
}
|
|
6
|
+
export interface StartupCheckOptions {
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
nodeVersion?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function runStartupChecks(siteRoot: string, options?: StartupCheckOptions): void;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export class StartupCheckError extends Error {
|
|
5
|
+
reason;
|
|
6
|
+
constructor(reason, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'StartupCheckError';
|
|
9
|
+
this.reason = reason;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function parseVersion(version) {
|
|
13
|
+
const [major = 0, minor = 0, patch = 0] = version
|
|
14
|
+
.replace(/^v/, '')
|
|
15
|
+
.split('.')
|
|
16
|
+
.map((part) => Number.parseInt(part, 10) || 0);
|
|
17
|
+
return { major, minor, patch };
|
|
18
|
+
}
|
|
19
|
+
function isBelow(version, floor) {
|
|
20
|
+
if (version.major !== floor.major) {
|
|
21
|
+
return version.major < floor.major;
|
|
22
|
+
}
|
|
23
|
+
if (version.minor !== floor.minor) {
|
|
24
|
+
return version.minor < floor.minor;
|
|
25
|
+
}
|
|
26
|
+
return version.patch < floor.patch;
|
|
27
|
+
}
|
|
28
|
+
// Reads the Node version floor from the agent's own package.json rather
|
|
29
|
+
// than hardcoding a second literal here, so the dev-toolchain minimum
|
|
30
|
+
// (package.json engines.node, driven by --experimental-strip-types) and
|
|
31
|
+
// the shipped-agent's runtime minimum can't drift apart by accident.
|
|
32
|
+
// This is the agent's own bundled file, not site data, so resolving it
|
|
33
|
+
// relative to this module's own location is correct here, the same way
|
|
34
|
+
// validation.ts resolves its own schema files.
|
|
35
|
+
function readNodeVersionFloor() {
|
|
36
|
+
const packageJsonPath = join(import.meta.dirname, '..', '..', 'package.json');
|
|
37
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
38
|
+
const floor = packageJson.engines?.node;
|
|
39
|
+
if (!floor) {
|
|
40
|
+
throw new Error('package.json is missing engines.node');
|
|
41
|
+
}
|
|
42
|
+
return floor.replace(/^[^\d]*/, '');
|
|
43
|
+
}
|
|
44
|
+
// Fail-fast, in this specific order. Checking "is a git repo" before
|
|
45
|
+
// confirming the site root exists would make a missing root fail with
|
|
46
|
+
// the same ENOENT as "git binary absent" (both execFileSync failures),
|
|
47
|
+
// which would misreport one case as the other.
|
|
48
|
+
export function runStartupChecks(siteRoot, options = {}) {
|
|
49
|
+
const nodeVersion = options.nodeVersion ?? process.version;
|
|
50
|
+
const floor = parseVersion(readNodeVersionFloor());
|
|
51
|
+
if (isBelow(parseVersion(nodeVersion), floor)) {
|
|
52
|
+
throw new StartupCheckError('node-version-too-low', `Node ${readNodeVersionFloor()} or later is required, found ${nodeVersion}`);
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
if (!statSync(siteRoot).isDirectory()) {
|
|
56
|
+
throw new Error('not a directory');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new StartupCheckError('site-root-missing', `Site root does not exist or is not a directory: ${siteRoot}`);
|
|
61
|
+
}
|
|
62
|
+
const env = options.env ?? process.env;
|
|
63
|
+
try {
|
|
64
|
+
// Deliberately not run with cwd: siteRoot, so this check never
|
|
65
|
+
// depends on the site root existing.
|
|
66
|
+
execFileSync('git', ['--version'], { env, stdio: 'ignore' });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new StartupCheckError('git-binary-absent', 'git binary not found on PATH');
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
73
|
+
cwd: siteRoot,
|
|
74
|
+
env,
|
|
75
|
+
stdio: 'ignore',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new StartupCheckError('not-a-git-repo', `Site root is not a git repository: ${siteRoot}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FastifyReply } from 'fastify';
|
|
2
|
+
export interface StaticFileMatch {
|
|
3
|
+
fullPath: string;
|
|
4
|
+
contentType: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function findStaticFile(root: string, relativePath: string): StaticFileMatch | null;
|
|
7
|
+
export declare function sendStaticFile(reply: FastifyReply, match: StaticFileMatch): void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { mimeTypeFor } from "./mime-types.js";
|
|
3
|
+
import { PathSafetyError, sanitisePath } from "./path-safety.js";
|
|
4
|
+
export function findStaticFile(root, relativePath) {
|
|
5
|
+
// sanitisePath's own symlink-escape check calls realpathSync on the
|
|
6
|
+
// root itself, which throws (a raw ENOENT, not a PathSafetyError) if
|
|
7
|
+
// the root doesn't exist at all. theme/assets/ is always created by
|
|
8
|
+
// scaffoldSite, but theme/root/ is genuinely optional (found live:
|
|
9
|
+
// this crashed with a 500 on a fixture site with no theme/root/ at
|
|
10
|
+
// all) - checked here rather than in sanitisePath itself, since
|
|
11
|
+
// every other caller of that shared, security-audited function
|
|
12
|
+
// legitimately expects its root to exist.
|
|
13
|
+
if (!existsSync(root)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const fullPath = sanitisePath(root, relativePath);
|
|
18
|
+
if (!existsSync(fullPath) || !statSync(fullPath).isFile()) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return { fullPath, contentType: mimeTypeFor(fullPath) };
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error instanceof PathSafetyError) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Access-Control-Allow-Origin: * - both callers are already
|
|
31
|
+
// deliberately unauthenticated, public routes (see assets.ts's own
|
|
32
|
+
// comment for the full reasoning: a font loaded via @font-face
|
|
33
|
+
// enforces CORS unconditionally, and the admin's preview route makes
|
|
34
|
+
// genuinely cross-origin requests for paths that look same-origin).
|
|
35
|
+
export function sendStaticFile(reply, match) {
|
|
36
|
+
reply.header('Access-Control-Allow-Origin', '*').type(match.contentType).send(readFileSync(match.fullPath));
|
|
37
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// {% schema %} ... {% endschema %} embedded in a single .liquid file,
|
|
2
|
+
// matching Shopify's own theme-section convention - the natural fit
|
|
3
|
+
// since this project already deliberately mirrors Shopify-style
|
|
4
|
+
// sections and blocks. Never a real, live Liquid tag (CLAUDE.md: no
|
|
5
|
+
// dynamically registered Liquid tags or filters, ever) - extracted by
|
|
6
|
+
// plain string parsing before the file is ever handed to the Liquid
|
|
7
|
+
// engine, and stripped out of what actually gets rendered.
|
|
8
|
+
const SCHEMA_BLOCK_PATTERN = /\{%-?\s*schema\s*-?%\}([\s\S]*?)\{%-?\s*endschema\s*-?%\}/;
|
|
9
|
+
// Returns null if no schema block is found, or its content isn't
|
|
10
|
+
// valid JSON - callers treat this the same as today's "file missing/
|
|
11
|
+
// unreadable" case (skip, don't throw). A deliberate behaviour change
|
|
12
|
+
// from the old two-file shape: previously markup and schema loaded and
|
|
13
|
+
// failed independently; now they're one file, so a broken schema block
|
|
14
|
+
// fails the whole component, not just the schema half.
|
|
15
|
+
export function parseThemeComponentFile(source) {
|
|
16
|
+
const match = SCHEMA_BLOCK_PATTERN.exec(source);
|
|
17
|
+
if (!match) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
let schema;
|
|
21
|
+
try {
|
|
22
|
+
schema = JSON.parse(match[1]);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (typeof schema !== 'object' || schema === null) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const markup = source.slice(0, match.index) + source.slice(match.index + match[0].length);
|
|
31
|
+
return { markup, schema };
|
|
32
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { validatePage } from "./validation.js";
|
|
4
|
+
// templatesRoot is agent configuration (config.templatesRoot), not a
|
|
5
|
+
// request-supplied :path parameter - same non-sanitised, boot-time-only
|
|
6
|
+
// directory walk as theme-schemas.ts's own loadTypeSchemas, never
|
|
7
|
+
// reused for untrusted request paths.
|
|
8
|
+
//
|
|
9
|
+
// Flat *.json files, one per template, named directly (e.g.
|
|
10
|
+
// blog-article.json) - mirrors loadTypeSchemas's walk exactly. Each
|
|
11
|
+
// file must already be a fully valid page (the same page.schema.json
|
|
12
|
+
// every real content/pages/*.json file is validated against, via the
|
|
13
|
+
// theme's own current section/block schemas) - a template IS a real
|
|
14
|
+
// page file, nothing more, so a theme author can create one by
|
|
15
|
+
// literally copying and adapting a real page rather than learning a
|
|
16
|
+
// new authoring convention. A template that fails to parse or fails
|
|
17
|
+
// validation is skipped, never a boot failure - same defensive
|
|
18
|
+
// handling loadTypeSchemas already gives a malformed schema.json.
|
|
19
|
+
export function loadPageTemplates(templatesRoot, themeSchemas) {
|
|
20
|
+
const templates = [];
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = readdirSync(templatesRoot, { withFileTypes: true })
|
|
24
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
25
|
+
.map((entry) => entry.name);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return templates;
|
|
29
|
+
}
|
|
30
|
+
for (const fileName of entries) {
|
|
31
|
+
const id = fileName.slice(0, -'.json'.length);
|
|
32
|
+
let raw;
|
|
33
|
+
try {
|
|
34
|
+
raw = readFileSync(join(templatesRoot, fileName), 'utf-8');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
let content;
|
|
40
|
+
try {
|
|
41
|
+
content = JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!validatePage(content, themeSchemas).valid) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const title = content.title;
|
|
50
|
+
templates.push({ id, title, content });
|
|
51
|
+
}
|
|
52
|
+
return templates;
|
|
53
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseThemeComponentFile } from "./theme-component-file.js";
|
|
4
|
+
import { requiredFieldsHaveValidDefaults } from "./validation.js";
|
|
5
|
+
// themeRoot is agent configuration (the configured site's theme
|
|
6
|
+
// directory), not a request-supplied :path parameter. This walk is
|
|
7
|
+
// deliberately NOT the Group B path-sanitisation helper and must
|
|
8
|
+
// never be reused for untrusted request paths.
|
|
9
|
+
//
|
|
10
|
+
// Flat *.liquid files, one per type, named directly (e.g. hero.liquid,
|
|
11
|
+
// media-text.liquid) - no subfolder per type. Mirrors theme-templates.ts's
|
|
12
|
+
// loadFlatTemplates walk exactly (already established for snippets/
|
|
13
|
+
// layouts), extended to extract the embedded {% schema %} block instead
|
|
14
|
+
// of returning the raw file contents.
|
|
15
|
+
function loadTypeSchemas(typesDir) {
|
|
16
|
+
const schemas = {};
|
|
17
|
+
const acceptsBlocks = {};
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = readdirSync(typesDir, { withFileTypes: true })
|
|
21
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.liquid'))
|
|
22
|
+
.map((entry) => entry.name);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return { schemas, acceptsBlocks };
|
|
26
|
+
}
|
|
27
|
+
for (const fileName of entries) {
|
|
28
|
+
const type = fileName.slice(0, -'.liquid'.length);
|
|
29
|
+
let source;
|
|
30
|
+
try {
|
|
31
|
+
source = readFileSync(join(typesDir, fileName), 'utf-8');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const parsed = parseThemeComponentFile(source);
|
|
37
|
+
if (!parsed) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// A type whose required settings fields lack usable defaults is
|
|
41
|
+
// skipped the same way a malformed schema block already is -
|
|
42
|
+
// never a boot failure, just excluded from what gets registered
|
|
43
|
+
// (theme-authoring-guide.md, Group L).
|
|
44
|
+
if (!requiredFieldsHaveValidDefaults(parsed.schema)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
schemas[type] = parsed.schema;
|
|
48
|
+
// The only place "does this type support nested blocks" is ever
|
|
49
|
+
// expressed - a markup convention (does the template loop
|
|
50
|
+
// blocksHtml), not a schema field (theme-authoring-guide.md).
|
|
51
|
+
acceptsBlocks[type] = parsed.markup.includes('blocksHtml');
|
|
52
|
+
}
|
|
53
|
+
return { schemas, acceptsBlocks };
|
|
54
|
+
}
|
|
55
|
+
export function loadThemeSchemas(themeRoot) {
|
|
56
|
+
const sections = loadTypeSchemas(join(themeRoot, 'sections'));
|
|
57
|
+
const blocks = loadTypeSchemas(join(themeRoot, 'blocks'));
|
|
58
|
+
return {
|
|
59
|
+
sections: sections.schemas,
|
|
60
|
+
blocks: blocks.schemas,
|
|
61
|
+
acceptsBlocks: { sections: sections.acceptsBlocks, blocks: blocks.acceptsBlocks },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FastifyRequest } from 'fastify';
|
|
2
|
+
import type { Scope, TokenEntry } from '../server-config.ts';
|
|
3
|
+
export type AuthReason = 'missing-token' | 'invalid-token' | 'missing-scope';
|
|
4
|
+
export declare class AuthError extends Error {
|
|
5
|
+
readonly reason: AuthReason;
|
|
6
|
+
readonly statusCode: number;
|
|
7
|
+
constructor(reason: AuthReason, message: string);
|
|
8
|
+
}
|
|
9
|
+
export declare function verifyToken(tokens: TokenEntry[], presentedToken: string): Scope[] | null;
|
|
10
|
+
export declare function requireScope(tokens: TokenEntry[], scope: Scope): (request: FastifyRequest) => Promise<void>;
|