@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,19 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ // A plain string constant, not the raw node:sqlite import itself - safe
3
+ // for the capabilities endpoint (Phase 2 Group A) to import without
4
+ // tripping the G5 grep test, and avoids hardcoding this name a second
5
+ // time as a literal that could drift from the actual active driver.
6
+ export const DRIVER_NAME = 'node:sqlite';
7
+ // The only file in the codebase allowed to import node:sqlite
8
+ // (checklist G5, enforced by a grep test in
9
+ // test/static/static-analysis.test.ts). DatabaseSync's own
10
+ // prepare()/exec()/close() already structurally match SearchDriver,
11
+ // so this is a thin adapter, not a reimplementation.
12
+ export function openNodeSqliteDriver(path) {
13
+ const db = new DatabaseSync(path);
14
+ return {
15
+ exec: (sql) => db.exec(sql),
16
+ prepare: (sql) => db.prepare(sql),
17
+ close: () => db.close(),
18
+ };
19
+ }
@@ -0,0 +1,5 @@
1
+ export interface SearchResult {
2
+ url: string;
3
+ title: string;
4
+ }
5
+ export declare function queryIndex(searchIndexPath: string, term: string): SearchResult[];
@@ -0,0 +1,21 @@
1
+ import { openNodeSqliteDriver } from "./drivers/node-sqlite-driver.js";
2
+ // Never queued: an in-flight query holding an open handle during a
3
+ // concurrent rebuild's unlink just keeps reading the pre-rebuild inode
4
+ // (stale but consistent, never torn) - queuing a read against the same
5
+ // queue as writes would only add latency for no correctness benefit.
6
+ export function queryIndex(searchIndexPath, term) {
7
+ const driver = openNodeSqliteDriver(searchIndexPath);
8
+ try {
9
+ const rows = driver.prepare('SELECT url, title FROM pages_fts WHERE pages_fts MATCH ?').all(term);
10
+ // node:sqlite returns rows as [Object: null prototype] instances;
11
+ // rebuilt here as plain objects so callers (and assert.deepEqual)
12
+ // never have to know that's a driver implementation detail.
13
+ return rows.map((row) => {
14
+ const { url, title } = row;
15
+ return { url, title };
16
+ });
17
+ }
18
+ finally {
19
+ driver.close();
20
+ }
21
+ }
@@ -0,0 +1,2 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export declare function rebuildIndex(config: SiteConfig): Promise<void>;
@@ -0,0 +1,111 @@
1
+ import { mkdirSync, readFileSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { listFilesRecursively } from "../services/fs-walk.js";
4
+ import { postPathToUrl } from "../services/post-urls.js";
5
+ import { pagePathToUrl } from "../services/urls.js";
6
+ import { enqueue } from "../services/write-queue.js";
7
+ import { openNodeSqliteDriver } from "./drivers/node-sqlite-driver.js";
8
+ function collectStrings(value, out) {
9
+ if (typeof value === 'string') {
10
+ out.push(value);
11
+ }
12
+ else if (Array.isArray(value)) {
13
+ for (const item of value) {
14
+ collectStrings(item, out);
15
+ }
16
+ }
17
+ else if (value !== null && typeof value === 'object') {
18
+ for (const nested of Object.values(value)) {
19
+ collectStrings(nested, out);
20
+ }
21
+ }
22
+ }
23
+ // Recursively collects every string value out of each section's
24
+ // settings and any nested blocks' settings (instance.schema.json's
25
+ // blocks is self-referential, so this covers arbitrarily deep
26
+ // nesting). Only settings values, not id/type, so the indexed body is
27
+ // actual authored content, not structural metadata.
28
+ function extractBody(instances) {
29
+ const strings = [];
30
+ const walk = (list) => {
31
+ if (!list) {
32
+ return;
33
+ }
34
+ for (const instance of list) {
35
+ collectStrings(instance.settings, strings);
36
+ walk(instance.blocks);
37
+ }
38
+ };
39
+ walk(instances);
40
+ return strings.join(' ');
41
+ }
42
+ async function rebuildIndexJob(config) {
43
+ mkdirSync(config.dataRoot, { recursive: true });
44
+ // Delete-then-recreate, not existsSync-then-unlinkSync (a TOCTOU
45
+ // gap): this makes every rebuild start from a genuinely clean file,
46
+ // which is also what makes "delete the index and rebuild produces
47
+ // equivalent results" (G2) a structural consequence rather than a
48
+ // special case.
49
+ try {
50
+ unlinkSync(config.searchIndexPath);
51
+ }
52
+ catch (error) {
53
+ if (error.code !== 'ENOENT') {
54
+ throw error;
55
+ }
56
+ }
57
+ const driver = openNodeSqliteDriver(config.searchIndexPath);
58
+ try {
59
+ driver.exec('CREATE VIRTUAL TABLE pages_fts USING fts5(url UNINDEXED, title, body)');
60
+ const insert = driver.prepare('INSERT INTO pages_fts (url, title, body) VALUES (?, ?, ?)');
61
+ // Posts are genuinely public, URL-addressable content search
62
+ // should cover, same as pages - only the root and the URL mapping
63
+ // differ. Menus are deliberately never walked here at all: they
64
+ // have no public URL to point a search result at.
65
+ const collections = [
66
+ { root: config.pagesRoot, toUrl: pagePathToUrl },
67
+ { root: config.postsRoot, toUrl: postPathToUrl },
68
+ ];
69
+ driver.exec('BEGIN');
70
+ for (const { root, toUrl } of collections) {
71
+ for (const relativePath of listFilesRecursively(root, root, '.json')) {
72
+ let page;
73
+ try {
74
+ page = JSON.parse(readFileSync(join(root, relativePath), 'utf-8'));
75
+ }
76
+ catch {
77
+ // A malformed individual file is skipped, not an all-or-nothing
78
+ // abort: the index is explicitly disposable/best-effort, and
79
+ // aborting the whole rebuild over one bad file would leave no
80
+ // working index at all - strictly worse than skipping one page.
81
+ continue;
82
+ }
83
+ // Never walks draftsRoot at all, and skips unpublished content
84
+ // here - both halves of "drafts and unpublished content are
85
+ // absent from the index" (G3) are true by construction, not by
86
+ // a filter that could be gotten wrong.
87
+ if (page.published === false) {
88
+ continue;
89
+ }
90
+ const url = toUrl(relativePath);
91
+ const title = typeof page.title === 'string' ? page.title : '';
92
+ const body = extractBody(page.sections);
93
+ insert.run(url, title, body);
94
+ }
95
+ }
96
+ driver.exec('COMMIT');
97
+ }
98
+ finally {
99
+ driver.close();
100
+ }
101
+ }
102
+ // Queued via enqueue(), not because constraint 6 literally demands it
103
+ // for a non-authoritative index, but because of a same-process race
104
+ // specific to this delete-then-recreate design: two concurrent
105
+ // rebuilds can interleave so one's unlink races another's open+CREATE,
106
+ // or one can unlink the file out from under another's in-progress
107
+ // transaction. enqueue() is a generic, domain-agnostic primitive, so
108
+ // reusing it for self-exclusion costs nothing.
109
+ export function rebuildIndex(config) {
110
+ return enqueue(() => rebuildIndexJob(config));
111
+ }
@@ -0,0 +1,22 @@
1
+ export type Scope = 'content' | 'theme' | 'media';
2
+ export interface TokenEntry {
3
+ hash: string;
4
+ scopes: Scope[];
5
+ }
6
+ export interface RateLimitConfig {
7
+ max: number;
8
+ windowMs: number;
9
+ }
10
+ export interface MediaConfig {
11
+ maxUploadBytes: number;
12
+ }
13
+ export interface ServerConfig {
14
+ port: number;
15
+ tokens: TokenEntry[];
16
+ rateLimit: RateLimitConfig;
17
+ trustProxy: boolean;
18
+ ipAllowlist: string[];
19
+ checkpointIntervalMs: number;
20
+ media: MediaConfig;
21
+ }
22
+ export declare function loadServerConfig(siteRoot: string): ServerConfig;
@@ -0,0 +1,196 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { VHOST_DIR_NAME } from "./config.js";
4
+ import { StartupCheckError } from "./services/startup-checks.js";
5
+ const DEFAULT_PORT = 3000;
6
+ const DEFAULT_RATE_LIMIT = { max: 60, windowMs: 60000 };
7
+ // 10MB (docs/cms-build-plan.md's own suggested default) - comfortably
8
+ // above a properly web-optimised image, well below an unoptimised raw
9
+ // camera export.
10
+ const DEFAULT_MEDIA_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
11
+ // The build plan's own literal example ("every 30 minutes"), adopted
12
+ // as-is rather than re-litigated - its own [REVIEW] flag was about
13
+ // whether checkpoints belong on main vs a dedicated branch (resolved:
14
+ // main), not this number specifically.
15
+ const DEFAULT_CHECKPOINT_INTERVAL_MS = 1_800_000;
16
+ const VALID_SCOPES = new Set(['content', 'theme', 'media']);
17
+ // sha256 digest, hex-encoded: exactly 64 lowercase hex characters.
18
+ const HEX64_PATTERN = /^[0-9a-f]{64}$/;
19
+ // A loose IPv4/IPv6-shaped typo-catcher, not a real IP parser -
20
+ // exact-IP-only is a deliberate scope reduction (see ip-allowlist.ts),
21
+ // so this doesn't need to be exhaustive either.
22
+ const IP_SHAPE_PATTERN = /^[0-9a-fA-F:.]+$/;
23
+ function parseTokens(value) {
24
+ if (value === undefined) {
25
+ // Absent entirely -> no configured tokens -> every scope check
26
+ // fails closed (401 on everything). The safe default, not an
27
+ // incidental side effect - see server-config.test.ts's explicit
28
+ // "no tokens configured" case.
29
+ return [];
30
+ }
31
+ if (!Array.isArray(value)) {
32
+ throw new StartupCheckError('invalid-token-config', 'site.config.json\'s "tokens" must be an array');
33
+ }
34
+ const seenHashes = new Set();
35
+ const tokens = [];
36
+ value.forEach((entry, index) => {
37
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
38
+ throw new StartupCheckError('invalid-token-config', `tokens[${index}] must be an object`);
39
+ }
40
+ const record = entry;
41
+ const hash = record.hash;
42
+ if (typeof hash !== 'string' || !HEX64_PATTERN.test(hash)) {
43
+ throw new StartupCheckError('invalid-token-config', `tokens[${index}].hash must be a 64-character hex-encoded sha256 digest`);
44
+ }
45
+ if (seenHashes.has(hash)) {
46
+ throw new StartupCheckError('invalid-token-config', `tokens[${index}].hash duplicates an earlier entry`);
47
+ }
48
+ seenHashes.add(hash);
49
+ const scopesValue = record.scopes;
50
+ if (!Array.isArray(scopesValue) || scopesValue.length === 0) {
51
+ throw new StartupCheckError('invalid-token-config', `tokens[${index}].scopes must be a non-empty array`);
52
+ }
53
+ const scopes = scopesValue.map((scope) => {
54
+ if (typeof scope !== 'string' || !VALID_SCOPES.has(scope)) {
55
+ throw new StartupCheckError('invalid-token-config', `tokens[${index}].scopes contains an unknown scope: ${JSON.stringify(scope)}`);
56
+ }
57
+ return scope;
58
+ });
59
+ tokens.push({ hash, scopes });
60
+ });
61
+ return tokens;
62
+ }
63
+ function parseRateLimit(value) {
64
+ if (value === undefined) {
65
+ // Absent entirely -> the default limit applies - matches tokens'/
66
+ // port's own "absence is not an error" philosophy.
67
+ return DEFAULT_RATE_LIMIT;
68
+ }
69
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
70
+ throw new StartupCheckError('invalid-site-config', 'site.config.json\'s "rateLimit" must be an object');
71
+ }
72
+ const record = value;
73
+ const max = record.max;
74
+ if (typeof max !== 'number' || !Number.isInteger(max) || max < 1) {
75
+ throw new StartupCheckError('invalid-site-config', `site.config.json's "rateLimit.max" must be a positive integer, got ${JSON.stringify(max)}`);
76
+ }
77
+ const windowMs = record.windowMs;
78
+ if (typeof windowMs !== 'number' || !Number.isInteger(windowMs) || windowMs < 1) {
79
+ throw new StartupCheckError('invalid-site-config', `site.config.json's "rateLimit.windowMs" must be a positive integer, got ${JSON.stringify(windowMs)}`);
80
+ }
81
+ return { max, windowMs };
82
+ }
83
+ function parseMedia(value) {
84
+ if (value === undefined) {
85
+ // Absent entirely -> the default limit applies, matching every
86
+ // other optional-object field's own "absence is not an error"
87
+ // philosophy.
88
+ return { maxUploadBytes: DEFAULT_MEDIA_MAX_UPLOAD_BYTES };
89
+ }
90
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
91
+ throw new StartupCheckError('invalid-site-config', 'site.config.json\'s "media" must be an object');
92
+ }
93
+ const record = value;
94
+ const maxUploadBytes = record.maxUploadBytes;
95
+ if (maxUploadBytes === undefined) {
96
+ return { maxUploadBytes: DEFAULT_MEDIA_MAX_UPLOAD_BYTES };
97
+ }
98
+ if (typeof maxUploadBytes !== 'number' || !Number.isInteger(maxUploadBytes) || maxUploadBytes < 1) {
99
+ throw new StartupCheckError('invalid-site-config', `site.config.json's "media.maxUploadBytes" must be a positive integer, got ${JSON.stringify(maxUploadBytes)}`);
100
+ }
101
+ return { maxUploadBytes };
102
+ }
103
+ function parseTrustProxy(value) {
104
+ if (value === undefined) {
105
+ // Absent -> false, matching a bare single-process, no-reverse-
106
+ // proxy deployment assumption unless an operator explicitly opts
107
+ // in.
108
+ return false;
109
+ }
110
+ if (typeof value !== 'boolean') {
111
+ throw new StartupCheckError('invalid-site-config', `site.config.json's "trustProxy" must be a boolean, got ${JSON.stringify(value)}`);
112
+ }
113
+ return value;
114
+ }
115
+ function parseIpAllowlist(value) {
116
+ if (value === undefined) {
117
+ // Absent -> [] -> a no-op (checklist H3), not "nothing is
118
+ // allowed" - see ip-allowlist.ts's isIpAllowed.
119
+ return [];
120
+ }
121
+ if (!Array.isArray(value)) {
122
+ throw new StartupCheckError('invalid-site-config', 'site.config.json\'s "ipAllowlist" must be an array');
123
+ }
124
+ value.forEach((entry, index) => {
125
+ if (typeof entry !== 'string' || entry.length === 0 || !IP_SHAPE_PATTERN.test(entry)) {
126
+ throw new StartupCheckError('invalid-site-config', `ipAllowlist[${index}] must be a non-empty IP-address-shaped string, got ${JSON.stringify(entry)}`);
127
+ }
128
+ });
129
+ return value;
130
+ }
131
+ function parseCheckpointIntervalMs(value) {
132
+ if (value === undefined) {
133
+ return DEFAULT_CHECKPOINT_INTERVAL_MS;
134
+ }
135
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
136
+ throw new StartupCheckError('invalid-site-config', `site.config.json's "checkpointIntervalMs" must be a positive integer, got ${JSON.stringify(value)}`);
137
+ }
138
+ return value;
139
+ }
140
+ // Kept separate from SiteConfig (src/config.ts, pure filesystem paths)
141
+ // and from BootedSite (src/boot.ts): this is site.config.json's
142
+ // non-path settings. Later groups add fields here without needing to
143
+ // restructure this module or its callers - the "media" field below is
144
+ // exactly that: the max-upload-size setting anticipated here since
145
+ // Phase 2 planning, now landed as Phase 4's local-storage foundation.
146
+ //
147
+ // Two different failure classes: the file being entirely absent is
148
+ // not an error (defaults silently) - the whole Phase 1 test estate
149
+ // has no site.config.json and shouldn't need one invented just to
150
+ // boot. The file existing but being malformed IS an error (the
151
+ // operator wrote it and got it wrong), consistent with
152
+ // startup-checks.ts's fail-fast discipline. Token rotation requires a
153
+ // restart, since this loads once at boot - matches the existing
154
+ // "agent upgrade = restart" story already established for schema
155
+ // migrations, a deliberate decision, not an accident.
156
+ export function loadServerConfig(siteRoot) {
157
+ const configPath = join(siteRoot, VHOST_DIR_NAME, 'site.config.json');
158
+ let raw;
159
+ try {
160
+ raw = readFileSync(configPath, 'utf-8');
161
+ }
162
+ catch {
163
+ return {
164
+ port: DEFAULT_PORT,
165
+ tokens: [],
166
+ rateLimit: DEFAULT_RATE_LIMIT,
167
+ trustProxy: false,
168
+ ipAllowlist: [],
169
+ checkpointIntervalMs: DEFAULT_CHECKPOINT_INTERVAL_MS,
170
+ media: { maxUploadBytes: DEFAULT_MEDIA_MAX_UPLOAD_BYTES },
171
+ };
172
+ }
173
+ let parsed;
174
+ try {
175
+ parsed = JSON.parse(raw);
176
+ }
177
+ catch (error) {
178
+ const detail = error instanceof Error ? error.message : String(error);
179
+ throw new StartupCheckError('invalid-site-config', `site.config.json is not valid JSON: ${detail}`);
180
+ }
181
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
182
+ throw new StartupCheckError('invalid-site-config', 'site.config.json must be a JSON object');
183
+ }
184
+ const record = parsed;
185
+ const port = record.port ?? DEFAULT_PORT;
186
+ if (typeof port !== 'number' || !Number.isInteger(port) || port < 0) {
187
+ throw new StartupCheckError('invalid-site-config', `site.config.json's "port" must be a non-negative integer, got ${JSON.stringify(record.port)}`);
188
+ }
189
+ const tokens = parseTokens(record.tokens);
190
+ const rateLimit = parseRateLimit(record.rateLimit);
191
+ const trustProxy = parseTrustProxy(record.trustProxy);
192
+ const ipAllowlist = parseIpAllowlist(record.ipAllowlist);
193
+ const checkpointIntervalMs = parseCheckpointIntervalMs(record.checkpointIntervalMs);
194
+ const media = parseMedia(record.media);
195
+ return { port, tokens, rateLimit, trustProxy, ipAllowlist, checkpointIntervalMs, media };
196
+ }
@@ -0,0 +1,13 @@
1
+ import { type FastifyInstance } from 'fastify';
2
+ import type { BootedSite } from './boot.ts';
3
+ import type { ServerConfig } from './server-config.ts';
4
+ import type { DevTunnel } from './services/dev-tunnel.ts';
5
+ export interface BuildServerOptions {
6
+ logger?: boolean;
7
+ }
8
+ export interface StartServerOptions extends BuildServerOptions {
9
+ tunnel?: boolean;
10
+ startTunnel?: (port: number) => Promise<DevTunnel>;
11
+ }
12
+ export declare function buildServer(booted: BootedSite, serverConfig: ServerConfig, options?: BuildServerOptions): FastifyInstance;
13
+ export declare function startServer(siteRoot: string, options?: StartServerOptions): Promise<FastifyInstance>;
package/dist/server.js ADDED
@@ -0,0 +1,163 @@
1
+ import rateLimitPlugin from '@fastify/rate-limit';
2
+ import Fastify, {} from 'fastify';
3
+ import { bootSite } from "./boot.js";
4
+ import { loadServerConfig } from "./server-config.js";
5
+ import { assetsRoutes } from "./routes/assets.js";
6
+ import { v1Routes } from "./routes/index.js";
7
+ import { mediaPublicRoutes } from "./routes/media-public.js";
8
+ import { publicRoutes } from "./routes/public.js";
9
+ import { sitemapRoutes } from "./routes/sitemap.js";
10
+ import { CHECKPOINT_AUTHOR, runCheckpoint } from "./services/checkpoint.js";
11
+ import { startDevTunnel } from "./services/dev-tunnel.js";
12
+ import { startIntervalJob } from "./services/interval-job.js";
13
+ // Never wrap v1Routes (or any route-group plugin it registers) with
14
+ // fastify-plugin (fp()): plain app.register() gives each file its own
15
+ // encapsulation scope by default, which Group B's auth preHandler
16
+ // hooks depend on. Wrapping would silently hoist decorators/hooks to
17
+ // the parent scope and break that later.
18
+ export function buildServer(booted, serverConfig, options = {}) {
19
+ const app = Fastify({ logger: options.logger ?? false, trustProxy: serverConfig.trustProxy });
20
+ // global: false - only routes that explicitly opt in via a
21
+ // config: { rateLimit: {...} } marker on their own route options
22
+ // (see src/services/rate-limit-config.ts) are ever limited. The
23
+ // max/timeWindow set here are the configured (or defaulted)
24
+ // site.config.json values - an opting-in route's own empty
25
+ // { rateLimit: {} } marker inherits these, verified empirically.
26
+ // Registered once here, on the root app (an ancestor of v1Routes and
27
+ // everything it nests), so no individual route file needs to import
28
+ // this plugin itself. Its own 429 response body shape
29
+ // ({statusCode, error, message}) already matches this project's own
30
+ // error-JSON convention exactly - verified empirically before relying
31
+ // on it, no errorResponseBuilder override needed.
32
+ app.register(rateLimitPlugin, {
33
+ global: false,
34
+ max: serverConfig.rateLimit.max,
35
+ timeWindow: serverConfig.rateLimit.windowMs,
36
+ });
37
+ // Fastify's own default 500 body leaks the raw thrown Error.message
38
+ // to the client verbatim - confirmed empirically before this was
39
+ // written. Anything below 500 (e.g. a future route's schema-
40
+ // validation failure) already carries a safe, specific message and
41
+ // is passed through unchanged; only 500-and-above gets sanitised.
42
+ app.setErrorHandler((error, request, reply) => {
43
+ const statusCode = error.statusCode ?? 500;
44
+ if (statusCode >= 500) {
45
+ request.log.error(error);
46
+ reply.code(statusCode).send({
47
+ statusCode,
48
+ error: 'Internal Server Error',
49
+ message: 'Internal Server Error',
50
+ });
51
+ return;
52
+ }
53
+ reply.code(statusCode).send({ statusCode, error: error.name || 'Bad Request', message: error.message });
54
+ });
55
+ app.register(v1Routes, {
56
+ prefix: '/v1',
57
+ config: booted.config,
58
+ themeSchemas: booted.themeSchemas,
59
+ pageTemplates: booted.pageTemplates,
60
+ themeTemplates: booted.themeTemplates,
61
+ layouts: booted.layouts,
62
+ engine: booted.engine,
63
+ tokens: serverConfig.tokens,
64
+ ipAllowlist: serverConfig.ipAllowlist,
65
+ maxUploadBytes: serverConfig.media.maxUploadBytes,
66
+ });
67
+ // Registered without a /v1 prefix, alongside v1Routes: this is the
68
+ // site's own public website, not part of the site agent API. Order
69
+ // relative to v1Routes is not load-bearing - Fastify's router
70
+ // prefers exact/prefixed matches over this route's wildcard
71
+ // regardless of registration order (verified empirically). What
72
+ // actually protects unmatched /v1/* paths from being swallowed as
73
+ // page lookups is the explicit guard inside publicRoutes itself.
74
+ app.register(publicRoutes, {
75
+ config: booted.config,
76
+ themeTemplates: booted.themeTemplates,
77
+ layouts: booted.layouts,
78
+ engine: booted.engine,
79
+ });
80
+ // A more specific static-prefixed route than the public catch-all's
81
+ // own /* wildcard - Fastify prefers it regardless of registration
82
+ // order (verified empirically), so no guard is needed here the way
83
+ // publicRoutes needs one against /v1/*.
84
+ app.register(assetsRoutes, { config: booted.config });
85
+ // Same "more specific than the public catch-all" reasoning as
86
+ // assetsRoutes above. Deliberately not registered inside v1Routes
87
+ // (media.ts's own authenticated /v1/media CRUD routes are what live
88
+ // there) - this is the public, unauthenticated read side, matching
89
+ // assetsRoutes/publicRoutes' own placement.
90
+ //
91
+ // @fastify/multipart itself is NOT registered here, only inside
92
+ // media.ts's own plugin scope - see that file's comment for why
93
+ // (confining it to media routes only, leaving every other route
94
+ // file's default JSON body parsing untouched).
95
+ app.register(mediaPublicRoutes, { config: booted.config });
96
+ // Same "more specific than the public catch-all" reasoning again -
97
+ // this exact path always wins over publicRoutes' /* wildcard, and
98
+ // also always wins over a same-named static file under
99
+ // theme/root/sitemap.xml (see routes/public.ts's root-mirror check).
100
+ app.register(sitemapRoutes, { config: booted.config });
101
+ return app;
102
+ }
103
+ // Neither a recurring timer nor process signal handling exists
104
+ // anywhere else in this codebase - this is genuinely new machinery for
105
+ // the low-frequency draft-checkpoint background job (checklist H4).
106
+ // Wired only here, never in buildServer: every existing route test
107
+ // uses buildServer + .inject() directly and must stay completely
108
+ // unaffected by any of this.
109
+ export async function startServer(siteRoot, options = {}) {
110
+ const booted = bootSite(siteRoot);
111
+ const serverConfig = loadServerConfig(siteRoot);
112
+ const app = buildServer(booted, serverConfig, options);
113
+ const doCheckpoint = () => runCheckpoint(booted.config, CHECKPOINT_AUTHOR);
114
+ const scheduler = startIntervalJob(doCheckpoint, serverConfig.checkpointIntervalMs, (error) => {
115
+ app.log.error(error, 'background draft checkpoint failed');
116
+ });
117
+ let tunnel;
118
+ // Node's default action for an unhandled SIGTERM/SIGINT is immediate
119
+ // process termination, bypassing all JS-level cleanup - without
120
+ // this, a real kill/Ctrl-C would never run the final checkpoint
121
+ // (build plan: "and on graceful shutdown"). process.once (not .on)
122
+ // self-removes after firing, and is explicitly removed again inside
123
+ // onClose below, so a test calling app.close() leaves zero global
124
+ // process-level listeners behind across the many startServer/
125
+ // buildServer calls the suite makes.
126
+ const shutdown = () => {
127
+ app.close().catch((error) => app.log.error(error, 'error during shutdown'));
128
+ };
129
+ process.once('SIGTERM', shutdown);
130
+ process.once('SIGINT', shutdown);
131
+ app.addHook('onClose', async () => {
132
+ tunnel?.close();
133
+ scheduler.stop();
134
+ process.removeListener('SIGTERM', shutdown);
135
+ process.removeListener('SIGINT', shutdown);
136
+ try {
137
+ await doCheckpoint();
138
+ }
139
+ catch (error) {
140
+ app.log.error(error, 'final draft checkpoint on shutdown failed');
141
+ }
142
+ });
143
+ // host: '0.0.0.0' - Fastify's own default binds 127.0.0.1 only,
144
+ // which is unreachable from outside any container or remote host.
145
+ await app.listen({ port: serverConfig.port, host: '0.0.0.0' });
146
+ if (options.tunnel) {
147
+ try {
148
+ tunnel = await (options.startTunnel ?? startDevTunnel)(serverConfig.port);
149
+ console.log(`Tunnel open: ${tunnel.url}`);
150
+ console.log('This URL is publicly reachable for as long as this process runs - anyone with it (and a valid API token) can reach this site. Do not leave it running unattended.');
151
+ }
152
+ catch (error) {
153
+ // The site itself is already up and correctly serving locally -
154
+ // a failed tunnel is not a reason to fail the whole boot.
155
+ app.log.error(error, 'failed to start dev tunnel');
156
+ console.error('Failed to start the dev tunnel - the site is still running locally.');
157
+ }
158
+ }
159
+ else {
160
+ console.log('Tip: run with --tunnel to expose this site publicly for testing against a hosted admin.');
161
+ }
162
+ return app;
163
+ }
@@ -0,0 +1,35 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ import type { ThemeSchemas } from './validation.ts';
4
+ export type BatchOperation = {
5
+ type: 'draft-write';
6
+ path: string;
7
+ content: unknown;
8
+ expectedEtag: string;
9
+ } | {
10
+ type: 'draft-discard';
11
+ path: string;
12
+ } | {
13
+ type: 'content-delete';
14
+ path: string;
15
+ redirectTo?: string;
16
+ } | {
17
+ type: 'move';
18
+ from: string;
19
+ to: string;
20
+ };
21
+ export interface BatchPublish {
22
+ relativePaths: string[];
23
+ }
24
+ export type BatchReason = 'validation-failed' | 'conflict' | 'page-not-found' | 'has-children' | 'redirect-cycle' | 'invalid-redirect-target' | 'source-not-found' | 'destination-exists' | 'draft-not-found' | 'duplicate-path' | 'write-failed' | 'commit-failed' | 'rollback-failed';
25
+ export declare class BatchError extends Error {
26
+ readonly reason: BatchReason;
27
+ readonly stage?: 'operation' | 'publish';
28
+ readonly operationIndex?: number;
29
+ constructor(reason: BatchReason, message: string, options?: {
30
+ cause?: unknown;
31
+ stage?: 'operation' | 'publish';
32
+ operationIndex?: number;
33
+ });
34
+ }
35
+ export declare function runBatch(config: SiteConfig, themeSchemas: ThemeSchemas, operations: BatchOperation[], publish: BatchPublish | undefined, message: string, author: CommitAuthor): Promise<void>;