@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.
Files changed (201) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +132 -0
  3. package/dist/boot.d.ts +15 -0
  4. package/dist/boot.js +33 -0
  5. package/dist/config.d.ts +19 -0
  6. package/dist/config.js +45 -0
  7. package/dist/create-site/cli.d.ts +2 -0
  8. package/dist/create-site/cli.js +31 -0
  9. package/dist/create-site/generate-site.d.ts +9 -0
  10. package/dist/create-site/generate-site.js +109 -0
  11. package/dist/create-site/mint-token-cli.d.ts +2 -0
  12. package/dist/create-site/mint-token-cli.js +26 -0
  13. package/dist/create-site/mint-token.d.ts +7 -0
  14. package/dist/create-site/mint-token.js +49 -0
  15. package/dist/create-site/template/content/menus/footerCompany.json +9 -0
  16. package/dist/create-site/template/content/menus/footerProduct.json +9 -0
  17. package/dist/create-site/template/content/menus/footerResources.json +9 -0
  18. package/dist/create-site/template/content/menus/main.json +9 -0
  19. package/dist/create-site/template/content/pages/404.json +22 -0
  20. package/dist/create-site/template/content/pages/about/careers.json +21 -0
  21. package/dist/create-site/template/content/pages/about/team.json +21 -0
  22. package/dist/create-site/template/content/pages/about.json +21 -0
  23. package/dist/create-site/template/content/pages/docs/deployment.json +21 -0
  24. package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +21 -0
  25. package/dist/create-site/template/content/pages/docs/getting-started.json +23 -0
  26. package/dist/create-site/template/content/pages/docs.json +21 -0
  27. package/dist/create-site/template/content/pages/index.json +181 -0
  28. package/dist/create-site/template/content/redirects.json +4 -0
  29. package/dist/create-site/template/dockerignore +6 -0
  30. package/dist/create-site/template/gitignore +11 -0
  31. package/dist/create-site/template/theme/assets/site.js +49 -0
  32. package/dist/create-site/template/theme/assets/style.css +1153 -0
  33. package/dist/create-site/template/theme/blocks/button.liquid +21 -0
  34. package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
  35. package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
  36. package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
  37. package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
  38. package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
  39. package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
  40. package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
  41. package/dist/create-site/template/theme/layouts/theme.liquid +99 -0
  42. package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
  43. package/dist/create-site/template/theme/sections/cta-banner.liquid +27 -0
  44. package/dist/create-site/template/theme/sections/faq.liquid +27 -0
  45. package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
  46. package/dist/create-site/template/theme/sections/hero.liquid +36 -0
  47. package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
  48. package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
  49. package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
  50. package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
  51. package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
  52. package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
  53. package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
  54. package/dist/create-site/template/vhost/Dockerfile +33 -0
  55. package/dist/create-site/template/vhost/docker-entrypoint.sh +30 -0
  56. package/dist/index.d.ts +2 -0
  57. package/dist/index.js +4 -0
  58. package/dist/media/drivers/driver.d.ts +11 -0
  59. package/dist/media/drivers/driver.js +1 -0
  60. package/dist/media/drivers/local-fs-driver.d.ts +3 -0
  61. package/dist/media/drivers/local-fs-driver.js +81 -0
  62. package/dist/media/filename.d.ts +1 -0
  63. package/dist/media/filename.js +31 -0
  64. package/dist/media/manage-media.d.ts +19 -0
  65. package/dist/media/manage-media.js +45 -0
  66. package/dist/migrations/index.d.ts +3 -0
  67. package/dist/migrations/index.js +49 -0
  68. package/dist/renderer/engine.d.ts +2 -0
  69. package/dist/renderer/engine.js +48 -0
  70. package/dist/renderer/render-page.d.ts +28 -0
  71. package/dist/renderer/render-page.js +143 -0
  72. package/dist/renderer/theme-templates.d.ts +7 -0
  73. package/dist/renderer/theme-templates.js +82 -0
  74. package/dist/routes/assets.d.ts +6 -0
  75. package/dist/routes/assets.js +30 -0
  76. package/dist/routes/batch.d.ts +10 -0
  77. package/dist/routes/batch.js +148 -0
  78. package/dist/routes/capabilities.d.ts +5 -0
  79. package/dist/routes/capabilities.js +27 -0
  80. package/dist/routes/content.d.ts +8 -0
  81. package/dist/routes/content.js +166 -0
  82. package/dist/routes/drafts.d.ts +10 -0
  83. package/dist/routes/drafts.js +73 -0
  84. package/dist/routes/git.d.ts +8 -0
  85. package/dist/routes/git.js +158 -0
  86. package/dist/routes/index.d.ts +19 -0
  87. package/dist/routes/index.js +84 -0
  88. package/dist/routes/media-public.d.ts +5 -0
  89. package/dist/routes/media-public.js +48 -0
  90. package/dist/routes/media.d.ts +9 -0
  91. package/dist/routes/media.js +84 -0
  92. package/dist/routes/menus.d.ts +8 -0
  93. package/dist/routes/menus.js +77 -0
  94. package/dist/routes/preview-revision.d.ts +13 -0
  95. package/dist/routes/preview-revision.js +109 -0
  96. package/dist/routes/preview.d.ts +13 -0
  97. package/dist/routes/preview.js +52 -0
  98. package/dist/routes/public.d.ts +11 -0
  99. package/dist/routes/public.js +117 -0
  100. package/dist/routes/publish.d.ts +10 -0
  101. package/dist/routes/publish.js +105 -0
  102. package/dist/routes/redirects.d.ts +8 -0
  103. package/dist/routes/redirects.js +132 -0
  104. package/dist/routes/search.d.ts +8 -0
  105. package/dist/routes/search.js +11 -0
  106. package/dist/routes/sitemap.d.ts +6 -0
  107. package/dist/routes/sitemap.js +79 -0
  108. package/dist/routes/theme.d.ts +10 -0
  109. package/dist/routes/theme.js +17 -0
  110. package/dist/schemas/instance.schema.json +17 -0
  111. package/dist/schemas/menu.schema.json +23 -0
  112. package/dist/schemas/page.schema.json +20 -0
  113. package/dist/schemas/post.schema.json +25 -0
  114. package/dist/schemas/redirects.schema.json +24 -0
  115. package/dist/search/drivers/driver.d.ts +9 -0
  116. package/dist/search/drivers/driver.js +1 -0
  117. package/dist/search/drivers/node-sqlite-driver.d.ts +3 -0
  118. package/dist/search/drivers/node-sqlite-driver.js +19 -0
  119. package/dist/search/query-index.d.ts +5 -0
  120. package/dist/search/query-index.js +21 -0
  121. package/dist/search/rebuild-index.d.ts +2 -0
  122. package/dist/search/rebuild-index.js +111 -0
  123. package/dist/server-config.d.ts +22 -0
  124. package/dist/server-config.js +196 -0
  125. package/dist/server.d.ts +13 -0
  126. package/dist/server.js +163 -0
  127. package/dist/services/batch.d.ts +35 -0
  128. package/dist/services/batch.js +136 -0
  129. package/dist/services/checkpoint.d.ts +4 -0
  130. package/dist/services/checkpoint.js +45 -0
  131. package/dist/services/content-read.d.ts +27 -0
  132. package/dist/services/content-read.js +156 -0
  133. package/dist/services/delete-content.d.ts +12 -0
  134. package/dist/services/delete-content.js +174 -0
  135. package/dist/services/dev-tunnel.d.ts +5 -0
  136. package/dist/services/dev-tunnel.js +5 -0
  137. package/dist/services/drafts.d.ts +15 -0
  138. package/dist/services/drafts.js +155 -0
  139. package/dist/services/etag.d.ts +2 -0
  140. package/dist/services/etag.js +30 -0
  141. package/dist/services/fs-walk.d.ts +1 -0
  142. package/dist/services/fs-walk.js +26 -0
  143. package/dist/services/git-commit.d.ts +3 -0
  144. package/dist/services/git-commit.js +12 -0
  145. package/dist/services/git-history.d.ts +28 -0
  146. package/dist/services/git-history.js +131 -0
  147. package/dist/services/git-revert.d.ts +12 -0
  148. package/dist/services/git-revert.js +140 -0
  149. package/dist/services/git.d.ts +16 -0
  150. package/dist/services/git.js +127 -0
  151. package/dist/services/interval-job.d.ts +4 -0
  152. package/dist/services/interval-job.js +9 -0
  153. package/dist/services/ip-allowlist.d.ts +7 -0
  154. package/dist/services/ip-allowlist.js +31 -0
  155. package/dist/services/manage-menus.d.ts +10 -0
  156. package/dist/services/manage-menus.js +84 -0
  157. package/dist/services/manage-redirects.d.ts +25 -0
  158. package/dist/services/manage-redirects.js +166 -0
  159. package/dist/services/menus.d.ts +8 -0
  160. package/dist/services/menus.js +42 -0
  161. package/dist/services/migration-runner.d.ts +13 -0
  162. package/dist/services/migration-runner.js +123 -0
  163. package/dist/services/mime-types.d.ts +3 -0
  164. package/dist/services/mime-types.js +36 -0
  165. package/dist/services/move.d.ts +16 -0
  166. package/dist/services/move.js +175 -0
  167. package/dist/services/path-safety.d.ts +6 -0
  168. package/dist/services/path-safety.js +112 -0
  169. package/dist/services/post-urls.d.ts +3 -0
  170. package/dist/services/post-urls.js +27 -0
  171. package/dist/services/prepared-operation.d.ts +4 -0
  172. package/dist/services/prepared-operation.js +1 -0
  173. package/dist/services/publish.d.ts +14 -0
  174. package/dist/services/publish.js +254 -0
  175. package/dist/services/rate-limit-config.d.ts +9 -0
  176. package/dist/services/rate-limit-config.js +20 -0
  177. package/dist/services/redirects.d.ts +27 -0
  178. package/dist/services/redirects.js +175 -0
  179. package/dist/services/resolve-blog-url.d.ts +11 -0
  180. package/dist/services/resolve-blog-url.js +31 -0
  181. package/dist/services/resolve-url.d.ts +11 -0
  182. package/dist/services/resolve-url.js +20 -0
  183. package/dist/services/startup-checks.d.ts +10 -0
  184. package/dist/services/startup-checks.js +81 -0
  185. package/dist/services/static-file.d.ts +7 -0
  186. package/dist/services/static-file.js +37 -0
  187. package/dist/services/theme-component-file.d.ts +5 -0
  188. package/dist/services/theme-component-file.js +32 -0
  189. package/dist/services/theme-page-templates.d.ts +7 -0
  190. package/dist/services/theme-page-templates.js +53 -0
  191. package/dist/services/theme-schemas.d.ts +2 -0
  192. package/dist/services/theme-schemas.js +63 -0
  193. package/dist/services/token-auth.d.ts +10 -0
  194. package/dist/services/token-auth.js +64 -0
  195. package/dist/services/urls.d.ts +2 -0
  196. package/dist/services/urls.js +20 -0
  197. package/dist/services/validation.d.ts +24 -0
  198. package/dist/services/validation.js +158 -0
  199. package/dist/services/write-queue.d.ts +1 -0
  200. package/dist/services/write-queue.js +14 -0
  201. package/package.json +53 -0
@@ -0,0 +1,9 @@
1
+ export declare const WRITE_ROUTE_RATE_LIMIT: {
2
+ rateLimit: {};
3
+ };
4
+ export declare const CAPABILITIES_RATE_LIMIT: {
5
+ rateLimit: {
6
+ max: number;
7
+ timeWindow: number;
8
+ };
9
+ };
@@ -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,5 @@
1
+ export interface ParsedThemeComponentFile {
2
+ markup: string;
3
+ schema: object;
4
+ }
5
+ export declare function parseThemeComponentFile(source: string): ParsedThemeComponentFile | null;
@@ -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,7 @@
1
+ import { type ThemeSchemas } from './validation.ts';
2
+ export interface PageTemplate {
3
+ id: string;
4
+ title: string;
5
+ content: unknown;
6
+ }
7
+ export declare function loadPageTemplates(templatesRoot: string, themeSchemas: ThemeSchemas): PageTemplate[];
@@ -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,2 @@
1
+ import { type ThemeSchemas } from './validation.ts';
2
+ export declare function loadThemeSchemas(themeRoot: string): ThemeSchemas;
@@ -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>;