@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,6 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ export interface AssetsRouteOptions {
4
+ config: SiteConfig;
5
+ }
6
+ export declare const assetsRoutes: FastifyPluginAsync<AssetsRouteOptions>;
@@ -0,0 +1,30 @@
1
+ import { findStaticFile, sendStaticFile } from "../services/static-file.js";
2
+ async function handleAssetRequest(request, reply, config) {
3
+ const relativePath = request.params['*'];
4
+ const match = findStaticFile(config.assetsRoot, relativePath);
5
+ if (!match) {
6
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No asset at "${relativePath}"` });
7
+ return;
8
+ }
9
+ // A synchronous buffered read, not a stream: reply.send(stream) was
10
+ // verified empirically to produce an empty body in this Fastify
11
+ // version (confirmed both via .inject() and a real socket, isolated
12
+ // down to a minimal repro) - a real, environment-specific problem,
13
+ // not a mistake in this route's own logic. No Range/If-Range support
14
+ // in this pass either way (nothing has asked for large-file
15
+ // streaming yet), so buffering the whole file is not a meaningful
16
+ // regression. sendStaticFile (services/static-file.ts) does this
17
+ // read, shared with routes/public.ts's root-mirror check.
18
+ sendStaticFile(reply, match);
19
+ }
20
+ // Deliberately and permanently unauthenticated: static assets (CSS/JS/
21
+ // images) must be fetchable by any visitor's browser without a token,
22
+ // same as the public website itself. Registered without a /v1 prefix,
23
+ // directly on app in server.ts, alongside publicRoutes. No guard
24
+ // against the public catch-all's own /* wildcard is needed here - this
25
+ // is the more specific static-prefixed route winning cleanly (verified
26
+ // empirically), not the reverse case that needed public.ts's /v1/*
27
+ // guard.
28
+ export const assetsRoutes = async (fastify, opts) => {
29
+ fastify.get('/assets/*', async (request, reply) => handleAssetRequest(request, reply, opts.config));
30
+ };
@@ -0,0 +1,10 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ import type { ThemeSchemas } from '../services/validation.ts';
4
+ import type { TokenEntry } from '../server-config.ts';
5
+ export interface BatchRouteOptions {
6
+ config: SiteConfig;
7
+ themeSchemas: ThemeSchemas;
8
+ tokens: TokenEntry[];
9
+ }
10
+ export declare const batchRoutes: FastifyPluginAsync<BatchRouteOptions>;
@@ -0,0 +1,148 @@
1
+ import { BatchError, runBatch } from "../services/batch.js";
2
+ import { isValidCommitAuthor } from "../services/git.js";
3
+ import { PathSafetyError } from "../services/path-safety.js";
4
+ import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
5
+ import { requireScope } from "../services/token-auth.js";
6
+ function isNonEmptyString(value) {
7
+ return typeof value === 'string' && value.length > 0;
8
+ }
9
+ function isPlainObject(value) {
10
+ return typeof value === 'object' && value !== null;
11
+ }
12
+ function parseOperation(value) {
13
+ if (!isPlainObject(value)) {
14
+ return null;
15
+ }
16
+ const { type } = value;
17
+ switch (type) {
18
+ case 'draft-write': {
19
+ const { path, content, expectedEtag } = value;
20
+ if (!isNonEmptyString(path) || !isPlainObject(content) || !isNonEmptyString(expectedEtag)) {
21
+ return null;
22
+ }
23
+ return { type, path, content, expectedEtag };
24
+ }
25
+ case 'draft-discard': {
26
+ const { path } = value;
27
+ if (!isNonEmptyString(path)) {
28
+ return null;
29
+ }
30
+ return { type, path };
31
+ }
32
+ case 'content-delete': {
33
+ const { path, redirectTo } = value;
34
+ if (!isNonEmptyString(path) || (redirectTo !== undefined && !isNonEmptyString(redirectTo))) {
35
+ return null;
36
+ }
37
+ return { type, path, redirectTo };
38
+ }
39
+ case 'move': {
40
+ const { from, to } = value;
41
+ if (!isNonEmptyString(from) || !isNonEmptyString(to)) {
42
+ return null;
43
+ }
44
+ return { type, from, to };
45
+ }
46
+ default:
47
+ return null;
48
+ }
49
+ }
50
+ function parsePublish(value) {
51
+ if (!isPlainObject(value)) {
52
+ return null;
53
+ }
54
+ const { paths } = value;
55
+ if (!Array.isArray(paths) || paths.length === 0 || !paths.every(isNonEmptyString)) {
56
+ return null;
57
+ }
58
+ return { relativePaths: paths };
59
+ }
60
+ const BATCH_BODY_SHAPE = 'Expected { operations: BatchOperation[], publish?: { paths: string[] }, message: string, author: { name, email } }';
61
+ function parseBatchBody(body) {
62
+ if (!isPlainObject(body)) {
63
+ return null;
64
+ }
65
+ const { operations, publish, message, author } = body;
66
+ if (!Array.isArray(operations)) {
67
+ return null;
68
+ }
69
+ const parsedOperations = [];
70
+ for (const raw of operations) {
71
+ const operation = parseOperation(raw);
72
+ if (!operation) {
73
+ return null;
74
+ }
75
+ parsedOperations.push(operation);
76
+ }
77
+ let parsedPublish;
78
+ if (publish !== undefined) {
79
+ const result = parsePublish(publish);
80
+ if (!result) {
81
+ return null;
82
+ }
83
+ parsedPublish = result;
84
+ }
85
+ if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
86
+ return null;
87
+ }
88
+ return { operations: parsedOperations, publish: parsedPublish, message, author };
89
+ }
90
+ // Maps every BatchReason to a status code, mirroring the same mapping
91
+ // each originating service's own route already applies (publish.ts's
92
+ // replyForPublishError, content.ts's move/delete handlers, drafts.ts's
93
+ // conflict/validation-failed handling) - kept as one switch here since
94
+ // batch.ts's BatchReason is a superset drawn from all four services.
95
+ function replyForBatchError(reply, error) {
96
+ switch (error.reason) {
97
+ case 'conflict':
98
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
99
+ return;
100
+ case 'destination-exists':
101
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
102
+ return;
103
+ case 'has-children':
104
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
105
+ return;
106
+ case 'validation-failed':
107
+ case 'duplicate-path':
108
+ case 'redirect-cycle':
109
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
110
+ return;
111
+ case 'page-not-found':
112
+ case 'source-not-found':
113
+ case 'draft-not-found':
114
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
115
+ return;
116
+ default:
117
+ // write-failed / commit-failed / rollback-failed: no client-side
118
+ // fix applies, fall through to the global handler's sanitised 500.
119
+ throw error;
120
+ }
121
+ }
122
+ export const batchRoutes = async (fastify, opts) => {
123
+ fastify.post('/batch', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
124
+ const parsed = parseBatchBody(request.body);
125
+ if (!parsed) {
126
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: BATCH_BODY_SHAPE });
127
+ return;
128
+ }
129
+ try {
130
+ await runBatch(opts.config, opts.themeSchemas, parsed.operations, parsed.publish, parsed.message, parsed.author);
131
+ reply.send({ ok: true });
132
+ }
133
+ catch (error) {
134
+ // PathSafetyError has no .statusCode - left uncaught it falls
135
+ // through to the global handler's sanitised 500, the same gap
136
+ // every other route touching a :path already guards against.
137
+ if (error instanceof PathSafetyError) {
138
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
139
+ return;
140
+ }
141
+ if (error instanceof BatchError) {
142
+ replyForBatchError(reply, error);
143
+ return;
144
+ }
145
+ throw error;
146
+ }
147
+ });
148
+ };
@@ -0,0 +1,5 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ export interface CapabilitiesRouteOptions {
3
+ maxUploadBytes: number;
4
+ }
5
+ export declare const capabilitiesRoutes: FastifyPluginAsync<CapabilitiesRouteOptions>;
@@ -0,0 +1,27 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { CURRENT_SCHEMA_VERSION } from "../migrations/index.js";
4
+ import { CAPABILITIES_RATE_LIMIT } from "../services/rate-limit-config.js";
5
+ import { DRIVER_NAME } from "../search/drivers/node-sqlite-driver.js";
6
+ // The agent's own bundled package.json, not site data - the same
7
+ // import.meta.dirname-relative pattern already established in
8
+ // validation.ts/startup-checks.ts for the agent's own files.
9
+ function readAgentVersion() {
10
+ const packageJsonPath = join(import.meta.dirname, '..', '..', 'package.json');
11
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
12
+ return packageJson.version ?? '0.0.0';
13
+ }
14
+ export const capabilitiesRoutes = async (fastify, opts) => {
15
+ fastify.get('/capabilities', { config: CAPABILITIES_RATE_LIMIT }, async () => ({
16
+ agentVersion: readAgentVersion(),
17
+ contentSchemaVersion: CURRENT_SCHEMA_VERSION,
18
+ sqliteDriver: DRIVER_NAME,
19
+ // Named distinctly from site.config.json's own "media.maxUploadBytes"
20
+ // key so this response reads unambiguously next to its siblings -
21
+ // lets the admin's own client-side upload check validate against
22
+ // the same limit the server will actually enforce (@fastify/multipart's
23
+ // limits.fileSize in media.ts), rather than a separately hardcoded
24
+ // guess that could drift.
25
+ maxMediaUploadBytes: opts.maxUploadBytes,
26
+ }));
27
+ };
@@ -0,0 +1,8 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ import type { TokenEntry } from '../server-config.ts';
4
+ export interface ContentRouteOptions {
5
+ config: SiteConfig;
6
+ tokens: TokenEntry[];
7
+ }
8
+ export declare const contentRoutes: FastifyPluginAsync<ContentRouteOptions>;
@@ -0,0 +1,166 @@
1
+ import { ContentReadError, listContent, readContentFile } from "../services/content-read.js";
2
+ import { DeleteContentError, deleteContent } from "../services/delete-content.js";
3
+ import { isValidCommitAuthor } from "../services/git.js";
4
+ import { MoveError, movePage } from "../services/move.js";
5
+ import { PathSafetyError } from "../services/path-safety.js";
6
+ import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
7
+ import { requireScope } from "../services/token-auth.js";
8
+ function isNonEmptyString(value) {
9
+ return typeof value === 'string' && value.length > 0;
10
+ }
11
+ // content/drafts/ and content/redirects.json sit inside contentRoot
12
+ // (Group N) but are not page-shaped content reachable through the
13
+ // generic content routes - drafts have their own dedicated /v1/drafts/*
14
+ // surface with its own semantics, and redirects.json is agent-managed
15
+ // data with its own /v1/redirects surface. Without this check, a
16
+ // client request resolving into either would either leak an unpublished
17
+ // draft's raw bytes through the "live content" read endpoint, or
18
+ // (for DELETE) unlink a file that was never git-tracked and blow up in
19
+ // commitPaths with a spurious 500 - both real bugs the nesting would
20
+ // introduce, not present when drafts/redirects.json were siteRoot
21
+ // siblings of contentRoot.
22
+ function isReservedContentPath(relativePath) {
23
+ return relativePath === 'redirects.json' || relativePath === 'drafts' || relativePath.startsWith('drafts/');
24
+ }
25
+ async function handleReadContent(request, reply, config) {
26
+ const relativePath = request.params['*'];
27
+ if (isReservedContentPath(relativePath)) {
28
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No content at "${relativePath}"` });
29
+ return;
30
+ }
31
+ try {
32
+ const { bytes, etag } = readContentFile(config.contentRoot, relativePath);
33
+ reply.header('etag', etag).type('application/json; charset=utf-8').send(bytes);
34
+ }
35
+ catch (error) {
36
+ // PathSafetyError has no .statusCode - left uncaught it would fall
37
+ // through to the global handler's generic sanitised 500, matching
38
+ // the gap preview.ts's precedent already guards against.
39
+ if (error instanceof PathSafetyError || (error instanceof ContentReadError && error.reason === 'not-found')) {
40
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No content at "${relativePath}"` });
41
+ return;
42
+ }
43
+ throw error;
44
+ }
45
+ }
46
+ function parseDeleteContentBody(body) {
47
+ if (typeof body !== 'object' || body === null) {
48
+ return null;
49
+ }
50
+ const { redirectTo, message, author } = body;
51
+ if (redirectTo !== undefined && !isNonEmptyString(redirectTo)) {
52
+ return null;
53
+ }
54
+ if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
55
+ return null;
56
+ }
57
+ return { redirectTo, message, author };
58
+ }
59
+ async function handleDeleteContent(request, reply, config) {
60
+ const relativePath = request.params['*'];
61
+ if (isReservedContentPath(relativePath)) {
62
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No content at "${relativePath}"` });
63
+ return;
64
+ }
65
+ const parsed = parseDeleteContentBody(request.body);
66
+ if (!parsed) {
67
+ reply.code(400).send({
68
+ statusCode: 400,
69
+ error: 'Bad Request',
70
+ message: 'Expected { redirectTo?: string, message: string, author: { name, email } }',
71
+ });
72
+ return;
73
+ }
74
+ try {
75
+ await deleteContent(config, relativePath, parsed.redirectTo, parsed.message, parsed.author);
76
+ reply.code(204).send();
77
+ }
78
+ catch (error) {
79
+ if (error instanceof PathSafetyError) {
80
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No content at "${relativePath}"` });
81
+ return;
82
+ }
83
+ if (error instanceof DeleteContentError) {
84
+ if (error.reason === 'page-not-found') {
85
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
86
+ return;
87
+ }
88
+ if (error.reason === 'has-children') {
89
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
90
+ return;
91
+ }
92
+ if (error.reason === 'redirect-cycle' || error.reason === 'invalid-redirect-target') {
93
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
94
+ return;
95
+ }
96
+ }
97
+ throw error;
98
+ }
99
+ }
100
+ function parseMoveContentBody(body) {
101
+ if (typeof body !== 'object' || body === null) {
102
+ return null;
103
+ }
104
+ const { from, to, message, author, createRedirect } = body;
105
+ if (!isNonEmptyString(from) || !isNonEmptyString(to)) {
106
+ return null;
107
+ }
108
+ if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
109
+ return null;
110
+ }
111
+ if (createRedirect !== undefined && typeof createRedirect !== 'boolean') {
112
+ return null;
113
+ }
114
+ return { from, to, message, author, createRedirect };
115
+ }
116
+ async function handleMoveContent(request, reply, config) {
117
+ const parsed = parseMoveContentBody(request.body);
118
+ if (!parsed) {
119
+ reply.code(400).send({
120
+ statusCode: 400,
121
+ error: 'Bad Request',
122
+ message: 'Expected { from: string, to: string, message: string, author: { name, email } }',
123
+ });
124
+ return;
125
+ }
126
+ try {
127
+ await movePage(config, parsed.from, parsed.to, parsed.message, parsed.author, {
128
+ createRedirect: parsed.createRedirect,
129
+ });
130
+ reply.send({ ok: true });
131
+ }
132
+ catch (error) {
133
+ if (error instanceof PathSafetyError) {
134
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${parsed.from}"` });
135
+ return;
136
+ }
137
+ if (error instanceof MoveError) {
138
+ if (error.reason === 'source-not-found') {
139
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
140
+ return;
141
+ }
142
+ if (error.reason === 'destination-exists') {
143
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
144
+ return;
145
+ }
146
+ }
147
+ throw error;
148
+ }
149
+ }
150
+ async function handleListContent(request, reply, config) {
151
+ const { type, prefix, draftStatus } = request.query;
152
+ const entries = listContent(config, { type, prefix, draftStatus });
153
+ reply.send(entries);
154
+ }
155
+ export const contentRoutes = async (fastify, opts) => {
156
+ fastify.get('/content', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleListContent(request, reply, opts.config));
157
+ fastify.get('/content/*', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleReadContent(request, reply, opts.config));
158
+ fastify.delete('/content/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleDeleteContent(request, reply, opts.config));
159
+ // A static path, distinct from the GET/DELETE /content/* wildcard
160
+ // above by method as well as by not matching the wildcard's own
161
+ // prefix shape at all (POST here, GET/DELETE there) - no routing
162
+ // collision, per Group C's own verified precedent that Fastify
163
+ // prefers static/exact matches over a wildcard regardless of
164
+ // registration order.
165
+ fastify.post('/content/move', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleMoveContent(request, reply, opts.config));
166
+ };
@@ -0,0 +1,10 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ import type { ThemeSchemas } from '../services/validation.ts';
4
+ import type { TokenEntry } from '../server-config.ts';
5
+ export interface DraftsRouteOptions {
6
+ config: SiteConfig;
7
+ themeSchemas: ThemeSchemas;
8
+ tokens: TokenEntry[];
9
+ }
10
+ export declare const draftsRoutes: FastifyPluginAsync<DraftsRouteOptions>;
@@ -0,0 +1,73 @@
1
+ import { ContentReadError, readContentFile } from "../services/content-read.js";
2
+ import { DraftError, discardDraft, saveDraft } from "../services/drafts.js";
3
+ import { PathSafetyError } from "../services/path-safety.js";
4
+ import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
5
+ import { requireScope } from "../services/token-auth.js";
6
+ async function handleReadDraft(request, reply, config) {
7
+ const relativePath = request.params['*'];
8
+ try {
9
+ const { bytes, etag } = readContentFile(config.draftsRoot, relativePath);
10
+ reply.header('etag', etag).type('application/json; charset=utf-8').send(bytes);
11
+ }
12
+ catch (error) {
13
+ if (error instanceof PathSafetyError || (error instanceof ContentReadError && error.reason === 'not-found')) {
14
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No draft at "${relativePath}"` });
15
+ return;
16
+ }
17
+ throw error;
18
+ }
19
+ }
20
+ async function handleSaveDraft(request, reply, config, themeSchemas) {
21
+ const relativePath = request.params['*'];
22
+ // A static per-request property (is the header present at all on
23
+ // *this* request), not shared mutable state - no TOCTOU risk in
24
+ // checking it here, before the queue, unlike the ETag comparison
25
+ // itself (E2/E4/E6), which must happen inside saveDraftJob.
26
+ const ifMatch = request.headers['if-match'];
27
+ if (typeof ifMatch !== 'string' || ifMatch.length === 0) {
28
+ reply.code(428).send({
29
+ statusCode: 428,
30
+ error: 'Precondition Required',
31
+ message: 'An If-Match header is required to save a draft',
32
+ });
33
+ return;
34
+ }
35
+ try {
36
+ const newEtag = await saveDraft(config, themeSchemas, relativePath, request.body, ifMatch);
37
+ reply.header('etag', newEtag).send({ ok: true });
38
+ }
39
+ catch (error) {
40
+ if (error instanceof PathSafetyError) {
41
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No draft at "${relativePath}"` });
42
+ return;
43
+ }
44
+ if (error instanceof DraftError && error.reason === 'conflict') {
45
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
46
+ return;
47
+ }
48
+ if (error instanceof DraftError && error.reason === 'validation-failed') {
49
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message, errors: error.errors });
50
+ return;
51
+ }
52
+ throw error;
53
+ }
54
+ }
55
+ async function handleDiscardDraft(request, reply, config) {
56
+ const relativePath = request.params['*'];
57
+ try {
58
+ await discardDraft(config, relativePath);
59
+ reply.code(204).send();
60
+ }
61
+ catch (error) {
62
+ if (error instanceof PathSafetyError) {
63
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No draft at "${relativePath}"` });
64
+ return;
65
+ }
66
+ throw error;
67
+ }
68
+ }
69
+ export const draftsRoutes = async (fastify, opts) => {
70
+ fastify.get('/drafts/*', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleReadDraft(request, reply, opts.config));
71
+ fastify.put('/drafts/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleSaveDraft(request, reply, opts.config, opts.themeSchemas));
72
+ fastify.delete('/drafts/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleDiscardDraft(request, reply, opts.config));
73
+ };
@@ -0,0 +1,8 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ import type { TokenEntry } from '../server-config.ts';
4
+ export interface GitRouteOptions {
5
+ config: SiteConfig;
6
+ tokens: TokenEntry[];
7
+ }
8
+ export declare const gitRoutes: FastifyPluginAsync<GitRouteOptions>;
@@ -0,0 +1,158 @@
1
+ import { commitWorkingTreeChanges } from "../services/git-commit.js";
2
+ import { GitShowError, getCommitLog, readFileAtRevision } from "../services/git-history.js";
3
+ import { RevertError, revertPaths } from "../services/git-revert.js";
4
+ import { isValidCommitAuthor, isValidGitRef } from "../services/git.js";
5
+ import { mimeTypeFor } from "../services/mime-types.js";
6
+ import { PathSafetyError } from "../services/path-safety.js";
7
+ import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
8
+ import { requireScope } from "../services/token-auth.js";
9
+ function isNonEmptyString(value) {
10
+ return typeof value === 'string' && value.length > 0;
11
+ }
12
+ async function handleGitLog(request, reply, config) {
13
+ const { path, limit: rawLimit } = request.query;
14
+ let limit;
15
+ if (rawLimit !== undefined) {
16
+ const parsed = Number(rawLimit);
17
+ if (!Number.isInteger(parsed) || parsed < 1) {
18
+ reply.code(400).send({
19
+ statusCode: 400,
20
+ error: 'Bad Request',
21
+ message: '"limit" must be a positive integer',
22
+ });
23
+ return;
24
+ }
25
+ limit = parsed;
26
+ }
27
+ try {
28
+ const result = getCommitLog(config, { path, limit });
29
+ reply.send(result);
30
+ }
31
+ catch (error) {
32
+ // PathSafetyError has no .statusCode - left uncaught it falls
33
+ // through to the global handler's sanitised 500, the same gap
34
+ // every other route touching a :path already guards against.
35
+ if (error instanceof PathSafetyError) {
36
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
37
+ return;
38
+ }
39
+ throw error;
40
+ }
41
+ }
42
+ async function handleGitShow(request, reply, config) {
43
+ const { ref } = request.params;
44
+ const relativePath = request.params['*'];
45
+ if (!isValidGitRef(ref)) {
46
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: `Invalid ref: "${ref}"` });
47
+ return;
48
+ }
49
+ try {
50
+ const content = readFileAtRevision(config, ref, relativePath);
51
+ reply.type(mimeTypeFor(relativePath)).send(content);
52
+ }
53
+ catch (error) {
54
+ if (error instanceof PathSafetyError) {
55
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
56
+ return;
57
+ }
58
+ if (error instanceof GitShowError) {
59
+ // Two distinct codes, not collapsed to one 404: unlike public.ts's
60
+ // deliberate traversal-vs-miss collapse (justified there because
61
+ // that route is unauthenticated, where leaking which case
62
+ // occurred would aid enumeration), this route sits behind
63
+ // requireScope already - there's no equivalent risk, and
64
+ // collapsing here would only make an admin's diff/history UI
65
+ // worse for no security benefit.
66
+ const code = error.reason === 'invalid-ref' ? 400 : 404;
67
+ reply.code(code).send({
68
+ statusCode: code,
69
+ error: code === 400 ? 'Bad Request' : 'Not Found',
70
+ message: error.message,
71
+ });
72
+ return;
73
+ }
74
+ throw error;
75
+ }
76
+ }
77
+ function parseRevertBody(body) {
78
+ if (typeof body !== 'object' || body === null) {
79
+ return null;
80
+ }
81
+ const { ref, paths, message, author } = body;
82
+ if (!isNonEmptyString(ref)) {
83
+ return null;
84
+ }
85
+ if (!Array.isArray(paths) || paths.length === 0 || !paths.every(isNonEmptyString)) {
86
+ return null;
87
+ }
88
+ if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
89
+ return null;
90
+ }
91
+ return { ref, paths, message, author };
92
+ }
93
+ async function handleGitRevert(request, reply, config) {
94
+ const parsed = parseRevertBody(request.body);
95
+ if (!parsed) {
96
+ reply.code(400).send({
97
+ statusCode: 400,
98
+ error: 'Bad Request',
99
+ message: 'Expected { ref: string, paths: string[], message: string, author: { name, email } }',
100
+ });
101
+ return;
102
+ }
103
+ try {
104
+ await revertPaths(config, parsed.ref, parsed.paths, parsed.message, parsed.author);
105
+ reply.send({ ok: true });
106
+ }
107
+ catch (error) {
108
+ if (error instanceof PathSafetyError) {
109
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
110
+ return;
111
+ }
112
+ if (error instanceof RevertError) {
113
+ if (error.reason === 'invalid-ref') {
114
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
115
+ return;
116
+ }
117
+ if (error.reason === 'path-not-found-at-ref') {
118
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
119
+ return;
120
+ }
121
+ // write-failed/commit-failed/rollback-failed: no client-side fix
122
+ // applies, fall through to the global handler's sanitised 500.
123
+ }
124
+ throw error;
125
+ }
126
+ }
127
+ function parseCommitBody(body) {
128
+ if (typeof body !== 'object' || body === null) {
129
+ return null;
130
+ }
131
+ const { message, author } = body;
132
+ if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
133
+ return null;
134
+ }
135
+ return { message, author };
136
+ }
137
+ async function handleGitCommit(request, reply, config) {
138
+ const parsed = parseCommitBody(request.body);
139
+ if (!parsed) {
140
+ reply.code(400).send({
141
+ statusCode: 400,
142
+ error: 'Bad Request',
143
+ message: 'Expected { message: string, author: { name, email } }',
144
+ });
145
+ return;
146
+ }
147
+ // Any GitOperationError here is not caught - it rethrows to the
148
+ // global error handler's sanitised 500, same as every other route's
149
+ // uncaught commitPaths failure.
150
+ const result = await commitWorkingTreeChanges(config, parsed.message, parsed.author);
151
+ reply.send({ ok: true, committed: result === 'committed' });
152
+ }
153
+ export const gitRoutes = async (fastify, opts) => {
154
+ fastify.get('/git/log', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleGitLog(request, reply, opts.config));
155
+ fastify.get('/git/show/:ref/*', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleGitShow(request, reply, opts.config));
156
+ fastify.post('/git/revert', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleGitRevert(request, reply, opts.config));
157
+ fastify.post('/git/commit', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleGitCommit(request, reply, opts.config));
158
+ };