@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,117 @@
1
+ import { join } from 'node:path';
2
+ import { PageRenderError, renderPage } from "../renderer/render-page.js";
3
+ import { PathSafetyError } from "../services/path-safety.js";
4
+ import { isBlogUrl } from "../services/post-urls.js";
5
+ import { resolveBlogUrl } from "../services/resolve-blog-url.js";
6
+ import { resolveUrl } from "../services/resolve-url.js";
7
+ import { findStaticFile, sendStaticFile } from "../services/static-file.js";
8
+ // resolveUrl's relativePath is relative to pagesRoot (e.g. "about.json"),
9
+ // but renderPage's relativePath is relative to contentRoot/draftsRoot
10
+ // directly (e.g. "pages/about.json") - two already-correct modules with
11
+ // two different conventions for the same string. This is the seam
12
+ // between them, not an arbitrary literal.
13
+ function toRenderPath(pagesRelativePath) {
14
+ return join('pages', pagesRelativePath);
15
+ }
16
+ // Same seam as toRenderPath above, for posts: resolveBlogUrl's
17
+ // relativePath is relative to postsRoot, renderPage's is relative to
18
+ // contentRoot/draftsRoot directly.
19
+ function toPostsRenderPath(postsRelativePath) {
20
+ return join('posts', postsRelativePath);
21
+ }
22
+ // A themed 404: content/pages/404.json, if it exists and is published,
23
+ // is rendered through the ordinary public renderPage pipeline (layout +
24
+ // sections + blocks, same as any other page) - no special-casing in
25
+ // render-page.ts itself, no separate "is this the 404 page" concept.
26
+ // The HTTP status is always 404 regardless, even though the body is a
27
+ // full HTML page. Any failure at all while attempting this - no
28
+ // 404.json, it's unpublished, or its own theme is broken - falls back
29
+ // to today's plain JSON error rather than ever turning a 404 into a
30
+ // 500 for a visitor who was already looking at a missing page.
31
+ async function sendNotFound(reply, config, themeTemplates, layouts, engine, url) {
32
+ try {
33
+ const html = await renderPage(config, themeTemplates, layouts, engine, toRenderPath('404.json'), 'public');
34
+ reply.code(404).type('text/html; charset=utf-8').send(html);
35
+ }
36
+ catch {
37
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
38
+ }
39
+ }
40
+ async function handlePublicRequest(request, reply, config, themeTemplates, layouts, engine) {
41
+ // The public catch-all is registered without a /v1 prefix alongside
42
+ // v1Routes (which has its own exact/prefixed routes). Fastify's
43
+ // router already prefers exact matches over this wildcard regardless
44
+ // of registration order, but an *unmatched* /v1/* path would
45
+ // otherwise fall through to here and get treated as a page lookup
46
+ // instead of Fastify's real 404 - this guard is what actually keeps
47
+ // A4 true for any future typo'd or removed /v1/* route, not
48
+ // registration order.
49
+ if (request.url === '/v1' || request.url.startsWith('/v1/')) {
50
+ return reply.callNotFound();
51
+ }
52
+ // theme/root/ mirrors arbitrary bare paths (robots.txt, .well-known/*)
53
+ // at the site root, checked before page resolution - unlike /assets/
54
+ // or /media/, these aren't distinguishable by a fixed prefix, so
55
+ // Fastify's own "specific route beats /*" behaviour can't do this for
56
+ // us automatically. A miss (including the bare "/" itself - a
57
+ // directory, never a file) falls straight through to the existing
58
+ // page-lookup logic below, unchanged.
59
+ const rootMirrorMatch = findStaticFile(config.rootMirrorRoot, request.params['*']);
60
+ if (rootMirrorMatch) {
61
+ sendStaticFile(reply, rootMirrorMatch);
62
+ return;
63
+ }
64
+ const url = `/${request.params['*']}`;
65
+ try {
66
+ // /blog is a permanently reserved namespace (confirmed design
67
+ // decision): checked first, and never falls through to page
68
+ // resolution even on a miss - a page manually placed at
69
+ // content/pages/blog/x.json is deliberately unreachable.
70
+ if (isBlogUrl(url)) {
71
+ const resolved = resolveBlogUrl(config, url);
72
+ if (resolved.kind === 'not-found') {
73
+ await sendNotFound(reply, config, themeTemplates, layouts, engine, url);
74
+ return;
75
+ }
76
+ if (resolved.kind === 'redirect') {
77
+ reply.code(301).header('location', resolved.to).send();
78
+ return;
79
+ }
80
+ const html = await renderPage(config, themeTemplates, layouts, engine, toPostsRenderPath(resolved.relativePath), 'public');
81
+ reply.type('text/html; charset=utf-8').send(html);
82
+ return;
83
+ }
84
+ const resolved = resolveUrl(config, url);
85
+ if (resolved.kind === 'not-found') {
86
+ await sendNotFound(reply, config, themeTemplates, layouts, engine, url);
87
+ return;
88
+ }
89
+ if (resolved.kind === 'redirect') {
90
+ reply.code(301).header('location', resolved.to).send();
91
+ return;
92
+ }
93
+ const html = await renderPage(config, themeTemplates, layouts, engine, toRenderPath(resolved.relativePath), 'public');
94
+ reply.type('text/html; charset=utf-8').send(html);
95
+ }
96
+ catch (error) {
97
+ // A traversal attempt and an ordinary miss get the identical 404 -
98
+ // deliberately not 400, so a public, unauthenticated route never
99
+ // gives an attacker a signal that distinguishes the two.
100
+ if (error instanceof PathSafetyError) {
101
+ await sendNotFound(reply, config, themeTemplates, layouts, engine, url);
102
+ return;
103
+ }
104
+ if (error instanceof PageRenderError && error.reason === 'page-not-found') {
105
+ await sendNotFound(reply, config, themeTemplates, layouts, engine, url);
106
+ return;
107
+ }
108
+ // Any other PageRenderError reason (missing-section-type,
109
+ // missing-block-type, template-error) is a broken theme/content
110
+ // problem, not something to explain to a public visitor - rethrow
111
+ // and let the global error handler sanitise it as a 500 (A5).
112
+ throw error;
113
+ }
114
+ }
115
+ export const publicRoutes = async (fastify, opts) => {
116
+ fastify.get('/*', async (request, reply) => handlePublicRequest(request, reply, opts.config, opts.themeTemplates, opts.layouts, opts.engine));
117
+ };
@@ -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 PublishRouteOptions {
6
+ config: SiteConfig;
7
+ themeSchemas: ThemeSchemas;
8
+ tokens: TokenEntry[];
9
+ }
10
+ export declare const publishRoutes: FastifyPluginAsync<PublishRouteOptions>;
@@ -0,0 +1,105 @@
1
+ import { isValidCommitAuthor } from "../services/git.js";
2
+ import { PathSafetyError } from "../services/path-safety.js";
3
+ import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
4
+ import { PublishError, publishDrafts, unpublishPage } from "../services/publish.js";
5
+ import { requireScope } from "../services/token-auth.js";
6
+ function isNonEmptyString(value) {
7
+ return typeof value === 'string' && value.length > 0;
8
+ }
9
+ function parsePublishBody(body) {
10
+ if (typeof body !== 'object' || body === null) {
11
+ return null;
12
+ }
13
+ const { paths, message, author } = body;
14
+ if (!Array.isArray(paths) || paths.length === 0 || !paths.every(isNonEmptyString)) {
15
+ return null;
16
+ }
17
+ if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
18
+ return null;
19
+ }
20
+ return { paths, message, author };
21
+ }
22
+ function parseUnpublishBody(body) {
23
+ if (typeof body !== 'object' || body === null) {
24
+ return null;
25
+ }
26
+ const { message, author } = body;
27
+ if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
28
+ return null;
29
+ }
30
+ return { message, author };
31
+ }
32
+ // Shared by both handlers below: each only ever sees the subset of
33
+ // reasons its own service function can actually throw.
34
+ function replyForPublishError(reply, error) {
35
+ if (error.reason === 'validation-failed' || error.reason === 'duplicate-path') {
36
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
37
+ return;
38
+ }
39
+ if (error.reason === 'draft-not-found' || error.reason === 'page-not-found') {
40
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
41
+ return;
42
+ }
43
+ throw error;
44
+ }
45
+ export const publishRoutes = async (fastify, opts) => {
46
+ fastify.post('/publish', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
47
+ const parsed = parsePublishBody(request.body);
48
+ if (!parsed) {
49
+ reply.code(400).send({
50
+ statusCode: 400,
51
+ error: 'Bad Request',
52
+ message: 'Expected { paths: string[], message: string, author: { name, email } }',
53
+ });
54
+ return;
55
+ }
56
+ try {
57
+ await publishDrafts(opts.config, opts.themeSchemas, parsed.paths, parsed.message, parsed.author);
58
+ reply.send({ ok: true });
59
+ }
60
+ catch (error) {
61
+ // PathSafetyError has no .statusCode - left uncaught it falls
62
+ // through to the global handler's sanitised 500, the same gap
63
+ // every other route touching a :path already guards against.
64
+ if (error instanceof PathSafetyError) {
65
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
66
+ return;
67
+ }
68
+ if (error instanceof PublishError) {
69
+ replyForPublishError(reply, error);
70
+ return;
71
+ }
72
+ throw error;
73
+ }
74
+ });
75
+ fastify.post('/unpublish/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
76
+ const parsed = parseUnpublishBody(request.body);
77
+ if (!parsed) {
78
+ reply.code(400).send({
79
+ statusCode: 400,
80
+ error: 'Bad Request',
81
+ message: 'Expected { message: string, author: { name, email } }',
82
+ });
83
+ return;
84
+ }
85
+ const relativePath = request.params['*'];
86
+ try {
87
+ await unpublishPage(opts.config, relativePath, parsed.message, parsed.author);
88
+ reply.send({ ok: true });
89
+ }
90
+ catch (error) {
91
+ // PathSafetyError has no .statusCode - left uncaught it falls
92
+ // through to the global handler's sanitised 500, the same gap
93
+ // every other route touching a :path already guards against.
94
+ if (error instanceof PathSafetyError) {
95
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
96
+ return;
97
+ }
98
+ if (error instanceof PublishError) {
99
+ replyForPublishError(reply, error);
100
+ return;
101
+ }
102
+ throw error;
103
+ }
104
+ });
105
+ };
@@ -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 RedirectsRouteOptions {
5
+ config: SiteConfig;
6
+ tokens: TokenEntry[];
7
+ }
8
+ export declare const redirectsRoutes: FastifyPluginAsync<RedirectsRouteOptions>;
@@ -0,0 +1,132 @@
1
+ import { isValidCommitAuthor } from "../services/git.js";
2
+ import { ManageRedirectError, createRedirect, deleteRedirect, updateRedirect } from "../services/manage-redirects.js";
3
+ import { loadRedirects } from "../services/redirects.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 parseUpsertRedirectBody(body) {
10
+ if (typeof body !== 'object' || body === null) {
11
+ return null;
12
+ }
13
+ const { from, to, note, message, author } = body;
14
+ if (!isNonEmptyString(from) || !isNonEmptyString(to)) {
15
+ return null;
16
+ }
17
+ if (note !== undefined && typeof note !== 'string') {
18
+ return null;
19
+ }
20
+ if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
21
+ return null;
22
+ }
23
+ return { from, to, note, message, author };
24
+ }
25
+ function parseDeleteRedirectBody(body) {
26
+ if (typeof body !== 'object' || body === null) {
27
+ return null;
28
+ }
29
+ const { from, message, author } = body;
30
+ if (!isNonEmptyString(from) || !isNonEmptyString(message) || !isValidCommitAuthor(author)) {
31
+ return null;
32
+ }
33
+ return { from, message, author };
34
+ }
35
+ function sendManageRedirectError(reply, error) {
36
+ if (error.reason === 'invalid-from' || error.reason === 'invalid-target' || error.reason === 'redirect-cycle') {
37
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
38
+ return;
39
+ }
40
+ if (error.reason === 'not-found') {
41
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
42
+ return;
43
+ }
44
+ if (error.reason === 'already-exists' || error.reason === 'live-content-exists') {
45
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
46
+ return;
47
+ }
48
+ throw error;
49
+ }
50
+ async function handleListRedirects(_request, reply, config) {
51
+ reply.send(loadRedirects(config));
52
+ }
53
+ async function handleCreateRedirect(request, reply, config) {
54
+ const parsed = parseUpsertRedirectBody(request.body);
55
+ if (!parsed) {
56
+ reply.code(400).send({
57
+ statusCode: 400,
58
+ error: 'Bad Request',
59
+ message: 'Expected { from: string, to: string, note?: string, message: string, author: { name, email } }',
60
+ });
61
+ return;
62
+ }
63
+ try {
64
+ const result = await createRedirect(config, parsed.from, parsed.to, parsed.note, parsed.message, parsed.author);
65
+ reply.send(result);
66
+ }
67
+ catch (error) {
68
+ if (error instanceof ManageRedirectError) {
69
+ sendManageRedirectError(reply, error);
70
+ return;
71
+ }
72
+ throw error;
73
+ }
74
+ }
75
+ async function handleUpdateRedirect(request, reply, config) {
76
+ const parsed = parseUpsertRedirectBody(request.body);
77
+ if (!parsed) {
78
+ reply.code(400).send({
79
+ statusCode: 400,
80
+ error: 'Bad Request',
81
+ message: 'Expected { from: string, to: string, note?: string, message: string, author: { name, email } }',
82
+ });
83
+ return;
84
+ }
85
+ try {
86
+ const result = await updateRedirect(config, parsed.from, parsed.to, parsed.note, parsed.message, parsed.author);
87
+ reply.send(result);
88
+ }
89
+ catch (error) {
90
+ if (error instanceof ManageRedirectError) {
91
+ sendManageRedirectError(reply, error);
92
+ return;
93
+ }
94
+ throw error;
95
+ }
96
+ }
97
+ async function handleDeleteRedirect(request, reply, config) {
98
+ const parsed = parseDeleteRedirectBody(request.body);
99
+ if (!parsed) {
100
+ reply.code(400).send({
101
+ statusCode: 400,
102
+ error: 'Bad Request',
103
+ message: 'Expected { from: string, message: string, author: { name, email } }',
104
+ });
105
+ return;
106
+ }
107
+ try {
108
+ await deleteRedirect(config, parsed.from, parsed.message, parsed.author);
109
+ reply.code(204).send();
110
+ }
111
+ catch (error) {
112
+ if (error instanceof ManageRedirectError) {
113
+ sendManageRedirectError(reply, error);
114
+ return;
115
+ }
116
+ throw error;
117
+ }
118
+ }
119
+ // from/to are always taken from the JSON body, never a URL path
120
+ // segment: they are arbitrary public URLs that may contain slashes,
121
+ // and critically none of these operations address a filesystem path
122
+ // at all (this mutates one entry inside redirects.json, not a file at
123
+ // a client-supplied path) - sanitisePath/path-traversal simply doesn't
124
+ // apply here, matching POST /content/move's existing body-carries-the-
125
+ // identity precedent rather than forcing an arbitrary URL into a route
126
+ // segment.
127
+ export const redirectsRoutes = async (fastify, opts) => {
128
+ fastify.get('/redirects', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleListRedirects(request, reply, opts.config));
129
+ fastify.post('/redirects', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleCreateRedirect(request, reply, opts.config));
130
+ fastify.put('/redirects', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleUpdateRedirect(request, reply, opts.config));
131
+ fastify.delete('/redirects', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleDeleteRedirect(request, reply, opts.config));
132
+ };
@@ -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 SearchRouteOptions {
5
+ config: SiteConfig;
6
+ tokens: TokenEntry[];
7
+ }
8
+ export declare const searchRoutes: FastifyPluginAsync<SearchRouteOptions>;
@@ -0,0 +1,11 @@
1
+ import { rebuildIndex } from "../search/rebuild-index.js";
2
+ import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
3
+ import { requireScope } from "../services/token-auth.js";
4
+ export const searchRoutes = async (fastify, opts) => {
5
+ fastify.post('/search/rebuild', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (_request, reply) => {
6
+ // rebuildIndex is already self-enqueue()d (search/rebuild-index.ts) -
7
+ // never wrap it in a second enqueue() here, nested calls deadlock.
8
+ await rebuildIndex(opts.config);
9
+ reply.send({ ok: true });
10
+ });
11
+ };
@@ -0,0 +1,6 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ export interface SitemapRouteOptions {
4
+ config: SiteConfig;
5
+ }
6
+ export declare const sitemapRoutes: FastifyPluginAsync<SitemapRouteOptions>;
@@ -0,0 +1,79 @@
1
+ import { join } from 'node:path';
2
+ import { readContentFile } from "../services/content-read.js";
3
+ import { listFilesRecursively } from "../services/fs-walk.js";
4
+ import { postPathToUrl } from "../services/post-urls.js";
5
+ import { pagePathToUrl } from "../services/urls.js";
6
+ // Reads through readContentFile/listFilesRecursively (both already
7
+ // gated behind sanitisePath/agent-configured roots - see their own
8
+ // files) rather than touching fs directly, so this route needs no
9
+ // allowlist entry of its own (docs/phase-1-checklist.md Group B).
10
+ function isPublished(contentRoot, subdir, relativePath) {
11
+ try {
12
+ const { bytes } = readContentFile(contentRoot, join(subdir, relativePath));
13
+ const parsed = JSON.parse(bytes.toString('utf-8'));
14
+ return parsed.published === true;
15
+ }
16
+ catch {
17
+ // Unreadable, malformed, or vanished between listing and reading -
18
+ // never worth failing the whole sitemap over one bad entry.
19
+ return false;
20
+ }
21
+ }
22
+ // Generated fresh on every request, never a saved file - matches this
23
+ // project's own standing philosophy for exactly this class of problem
24
+ // (the search index is explicitly "a derived, disposable index...
25
+ // never authoritative", see cms-build-plan.md). A saved sitemap would
26
+ // go stale the moment anything is published or unpublished; this
27
+ // can't.
28
+ function buildSitemapUrls(config) {
29
+ const urls = [];
30
+ for (const relativePath of listFilesRecursively(config.pagesRoot, config.pagesRoot, '.json')) {
31
+ // The 404 page must never be listed as a real crawlable URL,
32
+ // regardless of its own published flag - it's a fallback
33
+ // convention (docs/content-authoring-guide.md), not real content.
34
+ if (relativePath === '404.json') {
35
+ continue;
36
+ }
37
+ if (isPublished(config.contentRoot, 'pages', relativePath)) {
38
+ urls.push(pagePathToUrl(relativePath));
39
+ }
40
+ }
41
+ for (const relativePath of listFilesRecursively(config.postsRoot, config.postsRoot, '.json')) {
42
+ if (isPublished(config.contentRoot, 'posts', relativePath)) {
43
+ urls.push(postPathToUrl(relativePath));
44
+ }
45
+ }
46
+ return urls;
47
+ }
48
+ function escapeXml(value) {
49
+ return value
50
+ .replace(/&/g, '&amp;')
51
+ .replace(/</g, '&lt;')
52
+ .replace(/>/g, '&gt;')
53
+ .replace(/"/g, '&quot;')
54
+ .replace(/'/g, '&apos;');
55
+ }
56
+ async function handleSitemapRequest(request, reply, config) {
57
+ // request.protocol/request.host already respect trustProxy (wired
58
+ // natively into the Fastify() constructor, server.ts) - no separate
59
+ // config field needed for this. request.host, not request.hostname:
60
+ // the latter unconditionally strips the port (confirmed against
61
+ // Fastify's own source), which silently produces a wrong origin for
62
+ // any site not on a standard 80/443 port - request.host is the raw
63
+ // Host header (or the trustProxy-forwarded equivalent), port
64
+ // included when present.
65
+ const origin = `${request.protocol}://${request.host}`;
66
+ const urls = buildSitemapUrls(config);
67
+ const entries = urls.map((url) => ` <url><loc>${escapeXml(origin + url)}</loc></url>`).join('\n');
68
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries}\n</urlset>\n`;
69
+ reply.type('application/xml; charset=utf-8').send(xml);
70
+ }
71
+ // Deliberately and permanently unauthenticated, same reasoning as
72
+ // assetsRoutes/mediaPublicRoutes: a sitemap must be fetchable by any
73
+ // crawler without a token. This exact path always wins over a
74
+ // same-named static file at theme/root/sitemap.xml, if a developer
75
+ // ever creates one - a dynamic route is a more specific match than
76
+ // the root-mirror check inside publicRoutes' own catch-all.
77
+ export const sitemapRoutes = async (fastify, opts) => {
78
+ fastify.get('/sitemap.xml', async (request, reply) => handleSitemapRequest(request, reply, opts.config));
79
+ };
@@ -0,0 +1,10 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { ThemeSchemas } from '../services/validation.ts';
3
+ import type { PageTemplate } from '../services/theme-page-templates.ts';
4
+ import type { TokenEntry } from '../server-config.ts';
5
+ export interface ThemeRouteOptions {
6
+ themeSchemas: ThemeSchemas;
7
+ pageTemplates: PageTemplate[];
8
+ tokens: TokenEntry[];
9
+ }
10
+ export declare const themeRoutes: FastifyPluginAsync<ThemeRouteOptions>;
@@ -0,0 +1,17 @@
1
+ import { requireScope } from "../services/token-auth.js";
2
+ // Group I: the only place a caller can learn what section/block types
3
+ // the active theme supports and what settings each one accepts - the
4
+ // admin needs this to build a schema-driven editing UI instead of a
5
+ // hardcoded one. themeSchemas is already fully computed at boot
6
+ // (loadThemeSchemas) purely for internal write-time validation; this
7
+ // route just serialises the same object out, verbatim, read-only.
8
+ export const themeRoutes = async (fastify, opts) => {
9
+ fastify.get('/theme/schemas', { preHandler: requireScope(opts.tokens, 'content') }, async () => opts.themeSchemas);
10
+ // Group Q: lets the admin offer a template picker when creating a new
11
+ // page. No dedicated "create from template" endpoint - pageTemplates
12
+ // is already fully computed at boot (loadPageTemplates), same
13
+ // verbatim read-only serialisation as /theme/schemas above; actually
14
+ // creating a page from one is just the admin doing a normal
15
+ // PUT /v1/drafts/* with the chosen template's own content as the body.
16
+ fastify.get('/theme/page-templates', { preHandler: requireScope(opts.tokens, 'content') }, async () => ({ templates: opts.pageTemplates }));
17
+ };
@@ -0,0 +1,17 @@
1
+ {
2
+ "$id": "instance.schema.json",
3
+ "$schema": "http://json-schema.org/draft-07/schema#",
4
+ "title": "Section or block instance",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["id", "type", "settings"],
8
+ "properties": {
9
+ "id": { "type": "string", "minLength": 1 },
10
+ "type": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9][a-z0-9-]*$" },
11
+ "settings": { "type": "object" },
12
+ "blocks": {
13
+ "type": "array",
14
+ "items": { "$ref": "#" }
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "$id": "menu.schema.json",
3
+ "$schema": "http://json-schema.org/draft-07/schema#",
4
+ "title": "Menu",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schemaVersion", "items"],
8
+ "properties": {
9
+ "schemaVersion": { "type": "integer", "minimum": 1 },
10
+ "items": {
11
+ "type": "array",
12
+ "items": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "required": ["label", "url"],
16
+ "properties": {
17
+ "label": { "type": "string", "minLength": 1 },
18
+ "url": { "type": "string", "minLength": 1 }
19
+ }
20
+ }
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "$id": "page.schema.json",
3
+ "$schema": "http://json-schema.org/draft-07/schema#",
4
+ "title": "Page",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schemaVersion", "name", "title", "type", "layout", "published", "sections"],
8
+ "properties": {
9
+ "schemaVersion": { "type": "integer", "minimum": 1 },
10
+ "name": { "type": "string", "minLength": 1 },
11
+ "title": { "type": "string", "minLength": 1 },
12
+ "type": { "type": "string", "minLength": 1 },
13
+ "layout": { "type": "string", "minLength": 1 },
14
+ "published": { "type": "boolean" },
15
+ "sections": {
16
+ "type": "array",
17
+ "items": { "$ref": "instance.schema.json" }
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "$id": "post.schema.json",
3
+ "$schema": "http://json-schema.org/draft-07/schema#",
4
+ "title": "Post",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schemaVersion", "title", "type", "layout", "published", "author", "publishDate", "tags", "sections"],
8
+ "properties": {
9
+ "schemaVersion": { "type": "integer", "minimum": 1 },
10
+ "title": { "type": "string", "minLength": 1 },
11
+ "type": { "const": "post" },
12
+ "layout": { "type": "string", "minLength": 1 },
13
+ "published": { "type": "boolean" },
14
+ "author": { "type": "string", "minLength": 1 },
15
+ "publishDate": { "type": "string", "minLength": 1 },
16
+ "tags": {
17
+ "type": "array",
18
+ "items": { "type": "string", "minLength": 1 }
19
+ },
20
+ "sections": {
21
+ "type": "array",
22
+ "items": { "$ref": "instance.schema.json" }
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$id": "redirects.schema.json",
3
+ "$schema": "http://json-schema.org/draft-07/schema#",
4
+ "title": "Redirects",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schemaVersion", "entries"],
8
+ "properties": {
9
+ "schemaVersion": { "type": "integer", "minimum": 1 },
10
+ "entries": {
11
+ "type": "array",
12
+ "items": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "required": ["from", "to"],
16
+ "properties": {
17
+ "from": { "type": "string", "minLength": 1 },
18
+ "to": { "type": "string", "minLength": 1 },
19
+ "note": { "type": "string", "maxLength": 500 }
20
+ }
21
+ }
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,9 @@
1
+ export interface PreparedStatement {
2
+ run(...params: unknown[]): void;
3
+ all(...params: unknown[]): unknown[];
4
+ }
5
+ export interface SearchDriver {
6
+ exec(sql: string): void;
7
+ prepare(sql: string): PreparedStatement;
8
+ close(): void;
9
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { SearchDriver } from './driver.ts';
2
+ export declare const DRIVER_NAME = "node:sqlite";
3
+ export declare function openNodeSqliteDriver(path: string): SearchDriver;