@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 type { FastifyPluginAsync } from 'fastify';
2
+ import type { Liquid } from 'liquidjs';
3
+ import type { SiteConfig } from '../config.ts';
4
+ import type { ThemeTemplates } from '../renderer/theme-templates.ts';
5
+ import type { TokenEntry } from '../server-config.ts';
6
+ import type { ThemeSchemas } from '../services/validation.ts';
7
+ import type { PageTemplate } from '../services/theme-page-templates.ts';
8
+ export interface V1RouteOptions {
9
+ config: SiteConfig;
10
+ themeSchemas: ThemeSchemas;
11
+ pageTemplates: PageTemplate[];
12
+ themeTemplates: ThemeTemplates;
13
+ layouts: Record<string, string>;
14
+ engine: Liquid;
15
+ tokens: TokenEntry[];
16
+ ipAllowlist: string[];
17
+ maxUploadBytes: number;
18
+ }
19
+ export declare const v1Routes: FastifyPluginAsync<V1RouteOptions>;
@@ -0,0 +1,84 @@
1
+ import { batchRoutes } from "./batch.js";
2
+ import { capabilitiesRoutes } from "./capabilities.js";
3
+ import { contentRoutes } from "./content.js";
4
+ import { draftsRoutes } from "./drafts.js";
5
+ import { gitRoutes } from "./git.js";
6
+ import { mediaRoutes } from "./media.js";
7
+ import { menusRoutes } from "./menus.js";
8
+ import { previewRevisionRoutes } from "./preview-revision.js";
9
+ import { previewRoutes } from "./preview.js";
10
+ import { publishRoutes } from "./publish.js";
11
+ import { redirectsRoutes } from "./redirects.js";
12
+ import { searchRoutes } from "./search.js";
13
+ import { themeRoutes } from "./theme.js";
14
+ import { ipAllowlistGuard } from "../services/ip-allowlist.js";
15
+ // The /v1 aggregator: buildServer registers this once with
16
+ // { prefix: '/v1', ...V1RouteOptions }, and every route-group plugin
17
+ // (capabilities.ts, preview.ts, and later content/drafts/publish/etc.)
18
+ // registers itself here with a bare, unprefixed path. Each route-group
19
+ // plugin is registered plainly (never wrapped in fastify-plugin's
20
+ // fp()), so it keeps its own encapsulation scope - required for Group
21
+ // B's auth preHandler hooks to gate only the routes that need them.
22
+ // Plugins that don't need site data (capabilities.ts) simply ignore
23
+ // the passed-through opts.
24
+ export const v1Routes = async (fastify, opts) => {
25
+ // A coarse "is this client's network address allowed to talk to the
26
+ // API at all" gate, applied to the entire /v1 surface uniformly
27
+ // (including capabilities.ts and every GET route) - orthogonal to
28
+ // requireScope's per-route, per-scope exemptions. Added on this
29
+ // fastify instance (v1Routes's own parameter, never wrapped in fp())
30
+ // before any nested register() call, so it cascades to every plugin
31
+ // registered as a child below but never to publicRoutes/assetsRoutes
32
+ // (siblings registered directly on the root app, outside this
33
+ // encapsulation context) - verified with a real request, not just
34
+ // asserted.
35
+ fastify.addHook('onRequest', ipAllowlistGuard(opts.ipAllowlist));
36
+ fastify.register(capabilitiesRoutes, { maxUploadBytes: opts.maxUploadBytes });
37
+ // Fastify passes the *entire* options object a plugin was registered
38
+ // with - including `prefix` - through as this plugin's own second
39
+ // argument. Passing `opts` straight through to a nested register()
40
+ // call would carry that `prefix: '/v1'` along with it, double-
41
+ // applying the prefix (confirmed empirically: routes ended up
42
+ // registered at /v1/v1/preview/*). Re-pack only the site-data
43
+ // fields, never `opts` itself, for any nested plugin registration.
44
+ fastify.register(previewRoutes, {
45
+ config: opts.config,
46
+ themeTemplates: opts.themeTemplates,
47
+ layouts: opts.layouts,
48
+ engine: opts.engine,
49
+ tokens: opts.tokens,
50
+ });
51
+ fastify.register(previewRevisionRoutes, {
52
+ config: opts.config,
53
+ themeTemplates: opts.themeTemplates,
54
+ layouts: opts.layouts,
55
+ engine: opts.engine,
56
+ tokens: opts.tokens,
57
+ });
58
+ // contentRoutes needs no themeTemplates (it never renders anything),
59
+ // so it gets its own narrower options shape rather than the full
60
+ // bundle out of convenience - matching capabilities.ts's precedent
61
+ // of registering with only what it actually uses.
62
+ fastify.register(contentRoutes, { config: opts.config, tokens: opts.tokens });
63
+ fastify.register(draftsRoutes, {
64
+ config: opts.config,
65
+ themeSchemas: opts.themeSchemas,
66
+ tokens: opts.tokens,
67
+ });
68
+ fastify.register(publishRoutes, {
69
+ config: opts.config,
70
+ themeSchemas: opts.themeSchemas,
71
+ tokens: opts.tokens,
72
+ });
73
+ fastify.register(batchRoutes, {
74
+ config: opts.config,
75
+ themeSchemas: opts.themeSchemas,
76
+ tokens: opts.tokens,
77
+ });
78
+ fastify.register(gitRoutes, { config: opts.config, tokens: opts.tokens });
79
+ fastify.register(mediaRoutes, { config: opts.config, tokens: opts.tokens, maxUploadBytes: opts.maxUploadBytes });
80
+ fastify.register(searchRoutes, { config: opts.config, tokens: opts.tokens });
81
+ fastify.register(redirectsRoutes, { config: opts.config, tokens: opts.tokens });
82
+ fastify.register(menusRoutes, { config: opts.config, tokens: opts.tokens });
83
+ fastify.register(themeRoutes, { themeSchemas: opts.themeSchemas, pageTemplates: opts.pageTemplates, tokens: opts.tokens });
84
+ };
@@ -0,0 +1,5 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ export declare const mediaPublicRoutes: FastifyPluginAsync<{
4
+ config: SiteConfig;
5
+ }>;
@@ -0,0 +1,48 @@
1
+ import { openLocalFsMediaDriver } from "../media/drivers/local-fs-driver.js";
2
+ import { mimeTypeFor } from "../services/mime-types.js";
3
+ async function handleMediaRequest(request, reply, config) {
4
+ const relativePath = request.params['*'];
5
+ // Never read: Fastify doesn't require query params to be declared or
6
+ // consumed, so a Shopify-style ?width=1500 suffix
7
+ // (docs/cms-build-plan.md's Phase 4) is automatically inert with no
8
+ // code needed to "ignore" it - this route is the MVP passthrough
9
+ // driver, real resizing is separately-scoped future work.
10
+ const bytes = await openLocalFsMediaDriver(config.mediaRoot).get(relativePath);
11
+ if (bytes === null) {
12
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No media file at "${relativePath}"` });
13
+ return;
14
+ }
15
+ // nosniff: mimeTypeFor() falls back to application/octet-stream for
16
+ // anything outside its small known-extension table (uploads aren't
17
+ // restricted to that table, only to the images-only allowlist in
18
+ // media.ts), and without this header some browsers will still
19
+ // content-sniff an octet-stream response - the same stored-content
20
+ // risk class SVG rejection already exists to avoid for that specific
21
+ // case.
22
+ // Access-Control-Allow-Origin: * - same reasoning as assets.ts:
23
+ // fonts specifically enforce CORS unconditionally, and the admin's
24
+ // preview route proxies a site's HTML into its own origin (with a
25
+ // <base href> fix), making genuinely cross-origin browser requests
26
+ // for what looks like a same-origin path. Consistent with this
27
+ // route already being deliberately unauthenticated and public.
28
+ reply
29
+ .header('X-Content-Type-Options', 'nosniff')
30
+ .header('Access-Control-Allow-Origin', '*')
31
+ .type(mimeTypeFor(relativePath))
32
+ .send(bytes);
33
+ }
34
+ // Deliberately and permanently unauthenticated, same reasoning as
35
+ // assets.ts: uploaded media must be fetchable by any visitor's browser
36
+ // without a token, same as the public website itself. Registered
37
+ // without a /v1 prefix, directly on app in server.ts, alongside
38
+ // assetsRoutes/publicRoutes.
39
+ //
40
+ // Reads go through MediaStorageDriver.get() rather than raw fs calls
41
+ // (unlike assets.ts, which touches fs directly since theme assets are
42
+ // always local) - the one deliberate deviation from assets.ts's own
43
+ // shape, since a future object-storage driver needs this route to
44
+ // stay storage-agnostic. sanitisePath is called inside the driver
45
+ // itself (local-fs-driver.ts), not here - see that file's own comment.
46
+ export const mediaPublicRoutes = async (fastify, opts) => {
47
+ fastify.get('/media/*', async (request, reply) => handleMediaRequest(request, reply, opts.config));
48
+ };
@@ -0,0 +1,9 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ import type { TokenEntry } from '../server-config.ts';
4
+ export interface MediaRouteOptions {
5
+ config: SiteConfig;
6
+ tokens: TokenEntry[];
7
+ maxUploadBytes: number;
8
+ }
9
+ export declare const mediaRoutes: FastifyPluginAsync<MediaRouteOptions>;
@@ -0,0 +1,84 @@
1
+ import { extname } from 'node:path';
2
+ import multipart from '@fastify/multipart';
3
+ import { ManageMediaError, deleteMedia, listMedia, putMedia } from "../media/manage-media.js";
4
+ import { PathSafetyError } from "../services/path-safety.js";
5
+ import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
6
+ import { requireScope } from "../services/token-auth.js";
7
+ // Images only - confirmed with the user, not a general document
8
+ // library. Checked against the *original* uploaded filename, not the
9
+ // client-supplied mimetype header (trivially spoofable) and not the
10
+ // stored content-addressed filename (built only after this check
11
+ // passes, from the same already-validated extension). .svg is
12
+ // rejected regardless of this list even though it's technically an
13
+ // image format - docs/cms-build-plan.md's own "SVG rejected outright,
14
+ // not sanitised" decision.
15
+ const ALLOWED_UPLOAD_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
16
+ function sendManageMediaError(reply, error) {
17
+ if (error.reason === 'not-found') {
18
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
19
+ return;
20
+ }
21
+ throw error;
22
+ }
23
+ async function handleListMedia(_request, reply, config) {
24
+ reply.send(await listMedia(config));
25
+ }
26
+ async function handleUploadMedia(request, reply, config) {
27
+ const data = await request.file();
28
+ if (!data) {
29
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: 'Expected a multipart file upload' });
30
+ return;
31
+ }
32
+ const extension = extname(data.filename).toLowerCase();
33
+ if (!ALLOWED_UPLOAD_EXTENSIONS.has(extension)) {
34
+ reply.code(415).send({
35
+ statusCode: 415,
36
+ error: 'Unsupported Media Type',
37
+ message: `"${extension}" is not an accepted image type`,
38
+ });
39
+ return;
40
+ }
41
+ // No try/catch around toBuffer(): @fastify/multipart's own
42
+ // RequestFileTooLargeError already carries .statusCode = 413, so it
43
+ // passes straight through server.ts's existing global error handler
44
+ // unchanged - the same "just throw and let the existing handler map
45
+ // it" pattern already used for AuthError elsewhere. busboy enforces
46
+ // limits.fileSize *during* the stream (rejecting once the cap is
47
+ // hit, never silently truncating), not after buffering an unbounded
48
+ // body into memory first - this is what "capped at the stream
49
+ // level" actually means in practice.
50
+ const bytes = await data.toBuffer();
51
+ const result = await putMedia(config, data.filename, bytes);
52
+ reply.code(201).send(result);
53
+ }
54
+ async function handleDeleteMedia(request, reply, config) {
55
+ const filename = request.params['*'];
56
+ try {
57
+ await deleteMedia(config, filename);
58
+ reply.code(204).send();
59
+ }
60
+ catch (error) {
61
+ if (error instanceof PathSafetyError) {
62
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No media file at "${filename}"` });
63
+ return;
64
+ }
65
+ if (error instanceof ManageMediaError) {
66
+ sendManageMediaError(reply, error);
67
+ return;
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+ export const mediaRoutes = async (fastify, opts) => {
73
+ // Registered inside this plugin's own encapsulation scope (media.ts
74
+ // is registered plainly, never wrapped in fastify-plugin's fp() -
75
+ // matching this codebase's house rule so Group B's auth preHandler
76
+ // hooks keep working), not globally in server.ts - confines the
77
+ // multipart content-type parser and .file()/.files() decorations to
78
+ // media routes only, leaving every other route file's default JSON
79
+ // body parsing completely untouched.
80
+ await fastify.register(multipart, { limits: { fileSize: opts.maxUploadBytes } });
81
+ fastify.get('/media', { preHandler: requireScope(opts.tokens, 'media') }, async (request, reply) => handleListMedia(request, reply, opts.config));
82
+ fastify.post('/media', { preHandler: requireScope(opts.tokens, 'media'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleUploadMedia(request, reply, opts.config));
83
+ fastify.delete('/media/*', { preHandler: requireScope(opts.tokens, 'media'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleDeleteMedia(request, reply, opts.config));
84
+ };
@@ -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 MenusRouteOptions {
5
+ config: SiteConfig;
6
+ tokens: TokenEntry[];
7
+ }
8
+ export declare const menusRoutes: FastifyPluginAsync<MenusRouteOptions>;
@@ -0,0 +1,77 @@
1
+ import { isValidCommitAuthor } from "../services/git.js";
2
+ import { ManageMenuError, saveMenu } from "../services/manage-menus.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
+ // The body is wrapped ({ content, message, author }), not raw menu
10
+ // content the way PUT /v1/drafts/:path is: unlike a draft save, this
11
+ // commits immediately, so it needs the same message/author metadata
12
+ // every other commit-creating write in this API takes in its body
13
+ // (redirects, publish, delete, move) - a header-based convention would
14
+ // be a new, inconsistent pattern for no reason.
15
+ function parseSaveMenuBody(body) {
16
+ if (typeof body !== 'object' || body === null) {
17
+ return null;
18
+ }
19
+ const { content, message, author } = body;
20
+ if (content === undefined || !isNonEmptyString(message) || !isValidCommitAuthor(author)) {
21
+ return null;
22
+ }
23
+ return { content, message, author };
24
+ }
25
+ async function handleSaveMenu(request, reply, config) {
26
+ const relativePath = request.params['*'];
27
+ // A static per-request property (is the header present at all on
28
+ // *this* request), not shared mutable state - no TOCTOU risk in
29
+ // checking it here, before the queue, matching drafts.ts's own
30
+ // precedent exactly (the ETag comparison itself must happen inside
31
+ // the queued job, this presence check does not).
32
+ const ifMatch = request.headers['if-match'];
33
+ if (typeof ifMatch !== 'string' || ifMatch.length === 0) {
34
+ reply.code(428).send({
35
+ statusCode: 428,
36
+ error: 'Precondition Required',
37
+ message: 'An If-Match header is required to save a menu',
38
+ });
39
+ return;
40
+ }
41
+ const parsed = parseSaveMenuBody(request.body);
42
+ if (!parsed) {
43
+ reply.code(400).send({
44
+ statusCode: 400,
45
+ error: 'Bad Request',
46
+ message: 'Expected { content: object, message: string, author: { name, email } }',
47
+ });
48
+ return;
49
+ }
50
+ try {
51
+ const newEtag = await saveMenu(config, relativePath, parsed.content, ifMatch, parsed.message, parsed.author);
52
+ reply.header('etag', newEtag).send({ ok: true });
53
+ }
54
+ catch (error) {
55
+ if (error instanceof PathSafetyError) {
56
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No menu at "${relativePath}"` });
57
+ return;
58
+ }
59
+ if (error instanceof ManageMenuError && error.reason === 'conflict') {
60
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
61
+ return;
62
+ }
63
+ if (error instanceof ManageMenuError && error.reason === 'validation-failed') {
64
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
65
+ return;
66
+ }
67
+ throw error;
68
+ }
69
+ }
70
+ // GET/DELETE/move for menus/ paths deliberately stay on the existing
71
+ // generic /v1/content routes (Group N) - only this write path is new.
72
+ // Its own namespace (/v1/menus, not wedged into /v1/content), matching
73
+ // how redirects got /v1/redirects rather than overloading /v1/content
74
+ // with type-conditional write behaviour.
75
+ export const menusRoutes = async (fastify, opts) => {
76
+ fastify.put('/menus/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleSaveMenu(request, reply, opts.config));
77
+ };
@@ -0,0 +1,13 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { Liquid } from 'liquidjs';
3
+ import type { SiteConfig } from '../config.ts';
4
+ import type { ThemeTemplates } from '../renderer/theme-templates.ts';
5
+ import type { TokenEntry } from '../server-config.ts';
6
+ export interface PreviewRevisionRouteOptions {
7
+ config: SiteConfig;
8
+ themeTemplates: ThemeTemplates;
9
+ layouts: Record<string, string>;
10
+ engine: Liquid;
11
+ tokens: TokenEntry[];
12
+ }
13
+ export declare const previewRevisionRoutes: FastifyPluginAsync<PreviewRevisionRouteOptions>;
@@ -0,0 +1,109 @@
1
+ import { join } from 'node:path';
2
+ import { PageRenderError, parsePageContent, renderLoadedPage } from "../renderer/render-page.js";
3
+ import { GitShowError, readFileAtRevision } from "../services/git-history.js";
4
+ import { isValidGitRef } from "../services/git.js";
5
+ import { PathSafetyError } from "../services/path-safety.js";
6
+ import { isBlogUrl, urlToPostPath } from "../services/post-urls.js";
7
+ import { requireScope } from "../services/token-auth.js";
8
+ import { urlToPagePath } from "../services/urls.js";
9
+ // A dedicated top-level route rather than nesting under /preview/*
10
+ // (e.g. /preview/:ref/*): page URLs can be arbitrarily nested
11
+ // (E1 - content/pages/about/team.json resolves at /about/team), so a
12
+ // parametric+wildcard sibling registered under the same prefix as the
13
+ // existing bare /preview/* wildcard would make any multi-segment
14
+ // preview URL genuinely ambiguous between "page team under about" and
15
+ // "ref=about, page /team". Keeping this on its own /preview-revision
16
+ // prefix, a sibling of /preview rather than nested inside it, avoids
17
+ // that collision entirely - the same shape /git/show/:ref/* already
18
+ // uses alongside /preview/*.
19
+ //
20
+ // readFileAtRevision resolves paths relative to config.siteRoot, not
21
+ // config.contentRoot (confirmed by git-history.ts/git.test.ts's own
22
+ // "content/pages/about.json" usage) - so unlike preview.ts's
23
+ // toRenderPath/toPostsRenderPath (contentRoot-relative), the path
24
+ // built here is prefixed with "content/".
25
+ function toRevisionPath(pagesRelativePath) {
26
+ return join('content', 'pages', pagesRelativePath);
27
+ }
28
+ function toPostsRevisionPath(postsRelativePath) {
29
+ return join('content', 'posts', postsRelativePath);
30
+ }
31
+ async function handlePreviewRevisionRequest(request, reply, config, themeTemplates, layouts, engine) {
32
+ const { ref } = request.params;
33
+ const url = `/${request.params['*']}`;
34
+ if (!isValidGitRef(ref)) {
35
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: `Invalid ref: "${ref}"` });
36
+ return;
37
+ }
38
+ try {
39
+ const isPost = isBlogUrl(url);
40
+ let repoRelativePath;
41
+ if (isPost) {
42
+ const relativePath = urlToPostPath(url);
43
+ if (relativePath === null) {
44
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
45
+ return;
46
+ }
47
+ repoRelativePath = toPostsRevisionPath(relativePath);
48
+ }
49
+ else {
50
+ repoRelativePath = toRevisionPath(urlToPagePath(url));
51
+ }
52
+ const raw = readFileAtRevision(config, ref, repoRelativePath);
53
+ const page = parsePageContent(raw.toString('utf-8'));
54
+ const html = await renderLoadedPage(page, config, themeTemplates, layouts, engine);
55
+ reply.type('text/html; charset=utf-8').send(html);
56
+ }
57
+ catch (error) {
58
+ if (error instanceof PathSafetyError) {
59
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
60
+ return;
61
+ }
62
+ if (error instanceof GitShowError) {
63
+ const code = error.reason === 'invalid-ref' ? 400 : 404;
64
+ reply.code(code).send({
65
+ statusCode: code,
66
+ error: code === 400 ? 'Bad Request' : 'Not Found',
67
+ message: error.message,
68
+ });
69
+ return;
70
+ }
71
+ if (error instanceof SyntaxError) {
72
+ reply.code(404).send({
73
+ statusCode: 404,
74
+ error: 'Not Found',
75
+ message: `No renderable page at "${url}" at revision "${ref}"`,
76
+ });
77
+ return;
78
+ }
79
+ if (error instanceof PageRenderError) {
80
+ if (error.reason === 'page-not-found') {
81
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
82
+ return;
83
+ }
84
+ // missing-section-type/missing-block-type/missing-layout/
85
+ // template-error: a deliberate divergence from preview.ts and
86
+ // public.ts, which let these reasons bubble to an uncaught 500.
87
+ // For those routes, a broken theme/content combination reachable
88
+ // today is genuinely an operational bug worth alerting on. Here,
89
+ // "today's theme doesn't have a section type this old revision
90
+ // used" is an expected, user-facing outcome (themes/layouts/menus
91
+ // are never revision-pinned, only the page body is - see
92
+ // render-page.ts's renderLoadedPage comment), not a bug - the
93
+ // admin needs a stable 4xx it can catch and turn into "this
94
+ // revision can't be previewed with the current theme" copy,
95
+ // not a raw 500.
96
+ reply.code(422).send({
97
+ statusCode: 422,
98
+ error: 'Unprocessable Entity',
99
+ message: error.message,
100
+ reason: error.reason,
101
+ });
102
+ return;
103
+ }
104
+ throw error;
105
+ }
106
+ }
107
+ export const previewRevisionRoutes = async (fastify, opts) => {
108
+ fastify.get('/preview-revision/:ref/*', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handlePreviewRevisionRequest(request, reply, opts.config, opts.themeTemplates, opts.layouts, opts.engine));
109
+ };
@@ -0,0 +1,13 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { Liquid } from 'liquidjs';
3
+ import type { SiteConfig } from '../config.ts';
4
+ import type { ThemeTemplates } from '../renderer/theme-templates.ts';
5
+ import type { TokenEntry } from '../server-config.ts';
6
+ export interface PreviewRouteOptions {
7
+ config: SiteConfig;
8
+ themeTemplates: ThemeTemplates;
9
+ layouts: Record<string, string>;
10
+ engine: Liquid;
11
+ tokens: TokenEntry[];
12
+ }
13
+ export declare const previewRoutes: FastifyPluginAsync<PreviewRouteOptions>;
@@ -0,0 +1,52 @@
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, urlToPostPath } from "../services/post-urls.js";
5
+ import { requireScope } from "../services/token-auth.js";
6
+ import { urlToPagePath } from "../services/urls.js";
7
+ // Same pagesRoot/contentRoot seam as public.ts - see that file's
8
+ // toRenderPath comment. Preview never consults redirects.json:
9
+ // redirects are a public-URL concept, and an editor previewing a
10
+ // specific page path isn't redirected.
11
+ function toRenderPath(pagesRelativePath) {
12
+ return join('pages', pagesRelativePath);
13
+ }
14
+ // Same seam as toRenderPath above, for posts.
15
+ function toPostsRenderPath(postsRelativePath) {
16
+ return join('posts', postsRelativePath);
17
+ }
18
+ async function handlePreviewRequest(request, reply, config, themeTemplates, layouts, engine) {
19
+ const url = `/${request.params['*']}`;
20
+ try {
21
+ // /blog is a permanently reserved namespace, same as public.ts -
22
+ // no redirects.json fallback here either way (preview never
23
+ // consults it, per this file's own established comment above).
24
+ if (isBlogUrl(url)) {
25
+ const relativePath = urlToPostPath(url);
26
+ if (relativePath === null) {
27
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
28
+ return;
29
+ }
30
+ const html = await renderPage(config, themeTemplates, layouts, engine, toPostsRenderPath(relativePath), 'preview');
31
+ reply.type('text/html; charset=utf-8').send(html);
32
+ return;
33
+ }
34
+ const relativePath = urlToPagePath(url);
35
+ const html = await renderPage(config, themeTemplates, layouts, engine, toRenderPath(relativePath), 'preview');
36
+ reply.type('text/html; charset=utf-8').send(html);
37
+ }
38
+ catch (error) {
39
+ if (error instanceof PathSafetyError) {
40
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
41
+ return;
42
+ }
43
+ if (error instanceof PageRenderError && error.reason === 'page-not-found') {
44
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
45
+ return;
46
+ }
47
+ throw error;
48
+ }
49
+ }
50
+ export const previewRoutes = async (fastify, opts) => {
51
+ fastify.get('/preview/*', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handlePreviewRequest(request, reply, opts.config, opts.themeTemplates, opts.layouts, opts.engine));
52
+ };
@@ -0,0 +1,11 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { Liquid } from 'liquidjs';
3
+ import type { SiteConfig } from '../config.ts';
4
+ import type { ThemeTemplates } from '../renderer/theme-templates.ts';
5
+ export interface PublicRouteOptions {
6
+ config: SiteConfig;
7
+ themeTemplates: ThemeTemplates;
8
+ layouts: Record<string, string>;
9
+ engine: Liquid;
10
+ }
11
+ export declare const publicRoutes: FastifyPluginAsync<PublicRouteOptions>;