@minamocms/framework 0.0.0-stage → 0.10.2

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 (286) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/LICENSE +105 -0
  3. package/README.md +133 -2
  4. package/dist/boot.d.ts +19 -0
  5. package/dist/boot.js +22 -0
  6. package/dist/config.d.ts +18 -0
  7. package/dist/config.js +44 -0
  8. package/dist/create-site/cli.d.ts +2 -0
  9. package/dist/create-site/cli.js +31 -0
  10. package/dist/create-site/generate-site.d.ts +12 -0
  11. package/dist/create-site/generate-site.js +168 -0
  12. package/dist/create-site/mint-token-cli.d.ts +2 -0
  13. package/dist/create-site/mint-token-cli.js +26 -0
  14. package/dist/create-site/mint-token.d.ts +7 -0
  15. package/dist/create-site/mint-token.js +49 -0
  16. package/dist/create-site/template/AGENTS.md +473 -0
  17. package/dist/create-site/template/content/menus/footerCompany.json +9 -0
  18. package/dist/create-site/template/content/menus/footerProduct.json +9 -0
  19. package/dist/create-site/template/content/menus/footerResources.json +9 -0
  20. package/dist/create-site/template/content/menus/main.json +9 -0
  21. package/dist/create-site/template/content/pages/404.json +23 -0
  22. package/dist/create-site/template/content/pages/about/careers.json +22 -0
  23. package/dist/create-site/template/content/pages/about/team.json +22 -0
  24. package/dist/create-site/template/content/pages/about.json +22 -0
  25. package/dist/create-site/template/content/pages/docs/deployment.json +22 -0
  26. package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +22 -0
  27. package/dist/create-site/template/content/pages/docs/getting-started.json +24 -0
  28. package/dist/create-site/template/content/pages/docs.json +22 -0
  29. package/dist/create-site/template/content/pages/index.json +182 -0
  30. package/dist/create-site/template/content/redirects.json +4 -0
  31. package/dist/create-site/template/dockerignore +6 -0
  32. package/dist/create-site/template/gitignore +11 -0
  33. package/dist/create-site/template/theme/assets/site.js +49 -0
  34. package/dist/create-site/template/theme/assets/style.css +1223 -0
  35. package/dist/create-site/template/theme/blocks/button.liquid +21 -0
  36. package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
  37. package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
  38. package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
  39. package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
  40. package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
  41. package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
  42. package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
  43. package/dist/create-site/template/theme/config/site_settings.json +11 -0
  44. package/dist/create-site/template/theme/layouts/theme.liquid +102 -0
  45. package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
  46. package/dist/create-site/template/theme/sections/cta-banner.liquid +36 -0
  47. package/dist/create-site/template/theme/sections/faq.liquid +27 -0
  48. package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
  49. package/dist/create-site/template/theme/sections/hero.liquid +48 -0
  50. package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
  51. package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
  52. package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
  53. package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
  54. package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
  55. package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
  56. package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
  57. package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
  58. package/dist/create-site/template/vhost/Dockerfile +33 -0
  59. package/dist/create-site/template/vhost/docker-entrypoint.sh +58 -0
  60. package/dist/index.d.ts +2 -0
  61. package/dist/index.js +4 -0
  62. package/dist/media/drivers/driver.d.ts +11 -0
  63. package/dist/media/drivers/driver.js +1 -0
  64. package/dist/media/drivers/local-fs-driver.d.ts +3 -0
  65. package/dist/media/drivers/local-fs-driver.js +94 -0
  66. package/dist/media/filename.d.ts +2 -0
  67. package/dist/media/filename.js +54 -0
  68. package/dist/media/manage-media.d.ts +19 -0
  69. package/dist/media/manage-media.js +45 -0
  70. package/dist/media/seed-media-cli.d.ts +2 -0
  71. package/dist/media/seed-media-cli.js +22 -0
  72. package/dist/media/seed-media.d.ts +11 -0
  73. package/dist/media/seed-media.js +76 -0
  74. package/dist/migrations/index.d.ts +3 -0
  75. package/dist/migrations/index.js +83 -0
  76. package/dist/pull-site/cli.d.ts +2 -0
  77. package/dist/pull-site/cli.js +78 -0
  78. package/dist/pull-site/prompt-token.d.ts +16 -0
  79. package/dist/pull-site/prompt-token.js +69 -0
  80. package/dist/pull-site/prompts.d.ts +22 -0
  81. package/dist/pull-site/prompts.js +127 -0
  82. package/dist/pull-site/pull-site.d.ts +23 -0
  83. package/dist/pull-site/pull-site.js +211 -0
  84. package/dist/renderer/engine.d.ts +2 -0
  85. package/dist/renderer/engine.js +48 -0
  86. package/dist/renderer/render-cache.d.ts +11 -0
  87. package/dist/renderer/render-cache.js +11 -0
  88. package/dist/renderer/render-page.d.ts +34 -0
  89. package/dist/renderer/render-page.js +209 -0
  90. package/dist/renderer/theme-templates.d.ts +7 -0
  91. package/dist/renderer/theme-templates.js +82 -0
  92. package/dist/routes/admin-redirect.d.ts +5 -0
  93. package/dist/routes/admin-redirect.js +26 -0
  94. package/dist/routes/assets.d.ts +6 -0
  95. package/dist/routes/assets.js +30 -0
  96. package/dist/routes/batch.d.ts +10 -0
  97. package/dist/routes/batch.js +148 -0
  98. package/dist/routes/capabilities.d.ts +6 -0
  99. package/dist/routes/capabilities.js +27 -0
  100. package/dist/routes/content.d.ts +8 -0
  101. package/dist/routes/content.js +169 -0
  102. package/dist/routes/deleted-pages.d.ts +8 -0
  103. package/dist/routes/deleted-pages.js +9 -0
  104. package/dist/routes/drafts.d.ts +10 -0
  105. package/dist/routes/drafts.js +73 -0
  106. package/dist/routes/git.d.ts +8 -0
  107. package/dist/routes/git.js +158 -0
  108. package/dist/routes/index.d.ts +12 -0
  109. package/dist/routes/index.js +86 -0
  110. package/dist/routes/links.d.ts +8 -0
  111. package/dist/routes/links.js +18 -0
  112. package/dist/routes/media-public.d.ts +11 -0
  113. package/dist/routes/media-public.js +125 -0
  114. package/dist/routes/media.d.ts +9 -0
  115. package/dist/routes/media.js +80 -0
  116. package/dist/routes/menus.d.ts +8 -0
  117. package/dist/routes/menus.js +148 -0
  118. package/dist/routes/preview-revision.d.ts +10 -0
  119. package/dist/routes/preview-revision.js +96 -0
  120. package/dist/routes/preview.d.ts +10 -0
  121. package/dist/routes/preview.js +56 -0
  122. package/dist/routes/public.d.ts +10 -0
  123. package/dist/routes/public.js +123 -0
  124. package/dist/routes/publish.d.ts +10 -0
  125. package/dist/routes/publish.js +139 -0
  126. package/dist/routes/redirects.d.ts +8 -0
  127. package/dist/routes/redirects.js +132 -0
  128. package/dist/routes/search-public.d.ts +6 -0
  129. package/dist/routes/search-public.js +104 -0
  130. package/dist/routes/search.d.ts +8 -0
  131. package/dist/routes/search.js +15 -0
  132. package/dist/routes/settings.d.ts +10 -0
  133. package/dist/routes/settings.js +68 -0
  134. package/dist/routes/sitemap.d.ts +7 -0
  135. package/dist/routes/sitemap.js +79 -0
  136. package/dist/routes/theme.d.ts +10 -0
  137. package/dist/routes/theme.js +116 -0
  138. package/dist/schemas/instance.schema.json +17 -0
  139. package/dist/schemas/menu.schema.json +24 -0
  140. package/dist/schemas/page.schema.json +26 -0
  141. package/dist/schemas/redirects.schema.json +24 -0
  142. package/dist/search/drivers/driver.d.ts +9 -0
  143. package/dist/search/drivers/driver.js +1 -0
  144. package/dist/search/drivers/node-sqlite-driver.d.ts +7 -0
  145. package/dist/search/drivers/node-sqlite-driver.js +19 -0
  146. package/dist/search/query-content.d.ts +32 -0
  147. package/dist/search/query-content.js +207 -0
  148. package/dist/search/query-index.d.ts +5 -0
  149. package/dist/search/query-index.js +21 -0
  150. package/dist/search/rebuild-index.d.ts +3 -0
  151. package/dist/search/rebuild-index.js +320 -0
  152. package/dist/server-config.d.ts +23 -0
  153. package/dist/server-config.js +237 -0
  154. package/dist/server.d.ts +13 -0
  155. package/dist/server.js +235 -0
  156. package/dist/services/batch.d.ts +35 -0
  157. package/dist/services/batch.js +145 -0
  158. package/dist/services/checkpoint.d.ts +4 -0
  159. package/dist/services/checkpoint.js +45 -0
  160. package/dist/services/content-read.d.ts +27 -0
  161. package/dist/services/content-read.js +151 -0
  162. package/dist/services/delete-content.d.ts +12 -0
  163. package/dist/services/delete-content.js +166 -0
  164. package/dist/services/deleted-pages.d.ts +11 -0
  165. package/dist/services/deleted-pages.js +83 -0
  166. package/dist/services/dev-tunnel.d.ts +5 -0
  167. package/dist/services/dev-tunnel.js +5 -0
  168. package/dist/services/drafts.d.ts +16 -0
  169. package/dist/services/drafts.js +164 -0
  170. package/dist/services/etag.d.ts +2 -0
  171. package/dist/services/etag.js +30 -0
  172. package/dist/services/fs-walk.d.ts +1 -0
  173. package/dist/services/fs-walk.js +26 -0
  174. package/dist/services/git-commit.d.ts +3 -0
  175. package/dist/services/git-commit.js +12 -0
  176. package/dist/services/git-history.d.ts +28 -0
  177. package/dist/services/git-history.js +131 -0
  178. package/dist/services/git-revert.d.ts +12 -0
  179. package/dist/services/git-revert.js +140 -0
  180. package/dist/services/git.d.ts +16 -0
  181. package/dist/services/git.js +127 -0
  182. package/dist/services/interval-job.d.ts +4 -0
  183. package/dist/services/interval-job.js +9 -0
  184. package/dist/services/ip-allowlist.d.ts +7 -0
  185. package/dist/services/ip-allowlist.js +31 -0
  186. package/dist/services/links.d.ts +22 -0
  187. package/dist/services/links.js +169 -0
  188. package/dist/services/manage-menus.d.ts +15 -0
  189. package/dist/services/manage-menus.js +138 -0
  190. package/dist/services/manage-redirects.d.ts +25 -0
  191. package/dist/services/manage-redirects.js +156 -0
  192. package/dist/services/menu-references.d.ts +3 -0
  193. package/dist/services/menu-references.js +38 -0
  194. package/dist/services/menus.d.ts +9 -0
  195. package/dist/services/menus.js +43 -0
  196. package/dist/services/migration-runner.d.ts +16 -0
  197. package/dist/services/migration-runner.js +169 -0
  198. package/dist/services/mime-types.d.ts +3 -0
  199. package/dist/services/mime-types.js +43 -0
  200. package/dist/services/move.d.ts +16 -0
  201. package/dist/services/move.js +236 -0
  202. package/dist/services/path-safety.d.ts +6 -0
  203. package/dist/services/path-safety.js +112 -0
  204. package/dist/services/pid-file.d.ts +13 -0
  205. package/dist/services/pid-file.js +54 -0
  206. package/dist/services/post-urls.d.ts +3 -0
  207. package/dist/services/post-urls.js +27 -0
  208. package/dist/services/prepared-operation.d.ts +4 -0
  209. package/dist/services/prepared-operation.js +1 -0
  210. package/dist/services/publish.d.ts +15 -0
  211. package/dist/services/publish.js +313 -0
  212. package/dist/services/rate-limit-config.d.ts +9 -0
  213. package/dist/services/rate-limit-config.js +22 -0
  214. package/dist/services/redirects.d.ts +27 -0
  215. package/dist/services/redirects.js +175 -0
  216. package/dist/services/reindex-on-write.d.ts +3 -0
  217. package/dist/services/reindex-on-write.js +30 -0
  218. package/dist/services/resolve-blog-url.d.ts +11 -0
  219. package/dist/services/resolve-blog-url.js +31 -0
  220. package/dist/services/resolve-url.d.ts +11 -0
  221. package/dist/services/resolve-url.js +20 -0
  222. package/dist/services/site-settings.d.ts +28 -0
  223. package/dist/services/site-settings.js +116 -0
  224. package/dist/services/startup-checks.d.ts +10 -0
  225. package/dist/services/startup-checks.js +81 -0
  226. package/dist/services/static-file.d.ts +7 -0
  227. package/dist/services/static-file.js +37 -0
  228. package/dist/services/theme-component-file.d.ts +6 -0
  229. package/dist/services/theme-component-file.js +39 -0
  230. package/dist/services/theme-files.d.ts +33 -0
  231. package/dist/services/theme-files.js +148 -0
  232. package/dist/services/theme-page-templates.d.ts +7 -0
  233. package/dist/services/theme-page-templates.js +53 -0
  234. package/dist/services/theme-schemas.d.ts +2 -0
  235. package/dist/services/theme-schemas.js +137 -0
  236. package/dist/services/token-auth.d.ts +10 -0
  237. package/dist/services/token-auth.js +64 -0
  238. package/dist/services/urls.d.ts +2 -0
  239. package/dist/services/urls.js +20 -0
  240. package/dist/services/validation.d.ts +26 -0
  241. package/dist/services/validation.js +195 -0
  242. package/dist/services/write-queue.d.ts +1 -0
  243. package/dist/services/write-queue.js +14 -0
  244. package/dist/site-check/cli.d.ts +2 -0
  245. package/dist/site-check/cli.js +48 -0
  246. package/dist/site-check/run-check.d.ts +12 -0
  247. package/dist/site-check/run-check.js +188 -0
  248. package/dist/site-sync/cli-helpers.d.ts +23 -0
  249. package/dist/site-sync/cli-helpers.js +75 -0
  250. package/dist/site-sync/deploy.d.ts +36 -0
  251. package/dist/site-sync/deploy.js +124 -0
  252. package/dist/site-sync/live-content.d.ts +30 -0
  253. package/dist/site-sync/live-content.js +68 -0
  254. package/dist/site-sync/prompts.d.ts +30 -0
  255. package/dist/site-sync/prompts.js +237 -0
  256. package/dist/site-sync/pull-cli.d.ts +2 -0
  257. package/dist/site-sync/pull-cli.js +75 -0
  258. package/dist/site-sync/pull-site.d.ts +28 -0
  259. package/dist/site-sync/pull-site.js +171 -0
  260. package/dist/site-sync/push-cli.d.ts +2 -0
  261. package/dist/site-sync/push-cli.js +268 -0
  262. package/dist/site-sync/push-site.d.ts +96 -0
  263. package/dist/site-sync/push-site.js +376 -0
  264. package/dist/site-sync/remote-site.d.ts +38 -0
  265. package/dist/site-sync/remote-site.js +122 -0
  266. package/dist/site-sync/sync-record.d.ts +26 -0
  267. package/dist/site-sync/sync-record.js +76 -0
  268. package/dist/site-sync/theme-sync.d.ts +34 -0
  269. package/dist/site-sync/theme-sync.js +128 -0
  270. package/dist/stop-site/cli.d.ts +2 -0
  271. package/dist/stop-site/cli.js +28 -0
  272. package/dist/stop-site/stop-site.d.ts +26 -0
  273. package/dist/stop-site/stop-site.js +56 -0
  274. package/dist/theme-state.d.ts +20 -0
  275. package/dist/theme-state.js +33 -0
  276. package/dist/upgrade/changelog.d.ts +8 -0
  277. package/dist/upgrade/changelog.js +46 -0
  278. package/dist/upgrade/finish.d.ts +9 -0
  279. package/dist/upgrade/finish.js +30 -0
  280. package/dist/upgrade/owned-files.d.ts +11 -0
  281. package/dist/upgrade/owned-files.js +42 -0
  282. package/dist/upgrade/package-names.d.ts +4 -0
  283. package/dist/upgrade/package-names.js +26 -0
  284. package/dist/upgrade/upgrade-cli.d.ts +2 -0
  285. package/dist/upgrade/upgrade-cli.js +170 -0
  286. package/package.json +64 -4
@@ -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 { NO_AUTH_ROUTE_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
+ export 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: NO_AUTH_ROUTE_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,169 @@
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' ||
24
+ relativePath === 'settings.json' ||
25
+ relativePath === 'drafts' ||
26
+ relativePath.startsWith('drafts/'));
27
+ }
28
+ async function handleReadContent(request, reply, config) {
29
+ const relativePath = request.params['*'];
30
+ if (isReservedContentPath(relativePath)) {
31
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No content at "${relativePath}"` });
32
+ return;
33
+ }
34
+ try {
35
+ const { bytes, etag } = readContentFile(config.contentRoot, relativePath);
36
+ reply.header('etag', etag).type('application/json; charset=utf-8').send(bytes);
37
+ }
38
+ catch (error) {
39
+ // PathSafetyError has no .statusCode - left uncaught it would fall
40
+ // through to the global handler's generic sanitised 500, matching
41
+ // the gap preview.ts's precedent already guards against.
42
+ if (error instanceof PathSafetyError || (error instanceof ContentReadError && error.reason === 'not-found')) {
43
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No content at "${relativePath}"` });
44
+ return;
45
+ }
46
+ throw error;
47
+ }
48
+ }
49
+ function parseDeleteContentBody(body) {
50
+ if (typeof body !== 'object' || body === null) {
51
+ return null;
52
+ }
53
+ const { redirectTo, message, author } = body;
54
+ if (redirectTo !== undefined && !isNonEmptyString(redirectTo)) {
55
+ return null;
56
+ }
57
+ if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
58
+ return null;
59
+ }
60
+ return { redirectTo, message, author };
61
+ }
62
+ async function handleDeleteContent(request, reply, config) {
63
+ const relativePath = request.params['*'];
64
+ if (isReservedContentPath(relativePath)) {
65
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No content at "${relativePath}"` });
66
+ return;
67
+ }
68
+ const parsed = parseDeleteContentBody(request.body);
69
+ if (!parsed) {
70
+ reply.code(400).send({
71
+ statusCode: 400,
72
+ error: 'Bad Request',
73
+ message: 'Expected { redirectTo?: string, message: string, author: { name, email } }',
74
+ });
75
+ return;
76
+ }
77
+ try {
78
+ await deleteContent(config, relativePath, parsed.redirectTo, parsed.message, parsed.author);
79
+ reply.code(204).send();
80
+ }
81
+ catch (error) {
82
+ if (error instanceof PathSafetyError) {
83
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No content at "${relativePath}"` });
84
+ return;
85
+ }
86
+ if (error instanceof DeleteContentError) {
87
+ if (error.reason === 'page-not-found') {
88
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
89
+ return;
90
+ }
91
+ if (error.reason === 'has-children') {
92
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
93
+ return;
94
+ }
95
+ if (error.reason === 'redirect-cycle' || error.reason === 'invalid-redirect-target') {
96
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
97
+ return;
98
+ }
99
+ }
100
+ throw error;
101
+ }
102
+ }
103
+ function parseMoveContentBody(body) {
104
+ if (typeof body !== 'object' || body === null) {
105
+ return null;
106
+ }
107
+ const { from, to, message, author, createRedirect } = body;
108
+ if (!isNonEmptyString(from) || !isNonEmptyString(to)) {
109
+ return null;
110
+ }
111
+ if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
112
+ return null;
113
+ }
114
+ if (createRedirect !== undefined && typeof createRedirect !== 'boolean') {
115
+ return null;
116
+ }
117
+ return { from, to, message, author, createRedirect };
118
+ }
119
+ async function handleMoveContent(request, reply, config) {
120
+ const parsed = parseMoveContentBody(request.body);
121
+ if (!parsed) {
122
+ reply.code(400).send({
123
+ statusCode: 400,
124
+ error: 'Bad Request',
125
+ message: 'Expected { from: string, to: string, message: string, author: { name, email } }',
126
+ });
127
+ return;
128
+ }
129
+ try {
130
+ await movePage(config, parsed.from, parsed.to, parsed.message, parsed.author, {
131
+ createRedirect: parsed.createRedirect,
132
+ });
133
+ reply.send({ ok: true });
134
+ }
135
+ catch (error) {
136
+ if (error instanceof PathSafetyError) {
137
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${parsed.from}"` });
138
+ return;
139
+ }
140
+ if (error instanceof MoveError) {
141
+ if (error.reason === 'source-not-found') {
142
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
143
+ return;
144
+ }
145
+ if (error.reason === 'destination-exists') {
146
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
147
+ return;
148
+ }
149
+ }
150
+ throw error;
151
+ }
152
+ }
153
+ async function handleListContent(request, reply, config) {
154
+ const { type, prefix, draftStatus } = request.query;
155
+ const entries = listContent(config, { type, prefix, draftStatus });
156
+ reply.send(entries);
157
+ }
158
+ export const contentRoutes = async (fastify, opts) => {
159
+ fastify.get('/content', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleListContent(request, reply, opts.config));
160
+ fastify.get('/content/*', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleReadContent(request, reply, opts.config));
161
+ fastify.delete('/content/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleDeleteContent(request, reply, opts.config));
162
+ // A static path, distinct from the GET/DELETE /content/* wildcard
163
+ // above by method as well as by not matching the wildcard's own
164
+ // prefix shape at all (POST here, GET/DELETE there) - no routing
165
+ // collision, per Group C's own verified precedent that Fastify
166
+ // prefers static/exact matches over a wildcard regardless of
167
+ // registration order.
168
+ fastify.post('/content/move', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleMoveContent(request, reply, opts.config));
169
+ };
@@ -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 DeletedPagesRouteOptions {
5
+ config: SiteConfig;
6
+ tokens: TokenEntry[];
7
+ }
8
+ export declare const deletedPagesRoutes: FastifyPluginAsync<DeletedPagesRouteOptions>;
@@ -0,0 +1,9 @@
1
+ import { listDeletedPages } from "../services/deleted-pages.js";
2
+ import { requireScope } from "../services/token-auth.js";
3
+ // GET /v1/deleted-pages: pages that were deleted and aren't back, newest
4
+ // first, each with the ref to restore it from (POST /v1/git/revert).
5
+ export const deletedPagesRoutes = async (fastify, opts) => {
6
+ fastify.get('/deleted-pages', { preHandler: requireScope(opts.tokens, 'content') }, async (_request, reply) => {
7
+ reply.send({ pages: listDeletedPages(opts.config) });
8
+ });
9
+ };
@@ -0,0 +1,10 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ import type { ThemeState } from '../theme-state.ts';
4
+ import type { TokenEntry } from '../server-config.ts';
5
+ export interface DraftsRouteOptions {
6
+ config: SiteConfig;
7
+ theme: ThemeState;
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.theme.current.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
+ };
@@ -0,0 +1,12 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ import type { TokenEntry } from '../server-config.ts';
4
+ import type { ThemeState } from '../theme-state.ts';
5
+ export interface V1RouteOptions {
6
+ config: SiteConfig;
7
+ theme: ThemeState;
8
+ tokens: TokenEntry[];
9
+ ipAllowlist: string[];
10
+ maxUploadBytes: number;
11
+ }
12
+ export declare const v1Routes: FastifyPluginAsync<V1RouteOptions>;
@@ -0,0 +1,86 @@
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 { deletedPagesRoutes } from "./deleted-pages.js";
14
+ import { linksRoutes } from "./links.js";
15
+ import { settingsRoutes } from "./settings.js";
16
+ import { themeRoutes } from "./theme.js";
17
+ import { ipAllowlistGuard } from "../services/ip-allowlist.js";
18
+ // The /v1 aggregator: buildServer registers this once with
19
+ // { prefix: '/v1', ...V1RouteOptions }, and every route-group plugin
20
+ // (capabilities.ts, preview.ts, and later content/drafts/publish/etc.)
21
+ // registers itself here with a bare, unprefixed path. Each route-group
22
+ // plugin is registered plainly (never wrapped in fastify-plugin's
23
+ // fp()), so it keeps its own encapsulation scope - required for Group
24
+ // B's auth preHandler hooks to gate only the routes that need them.
25
+ // Plugins that don't need site data (capabilities.ts) simply ignore
26
+ // the passed-through opts.
27
+ export const v1Routes = async (fastify, opts) => {
28
+ // A coarse "is this client's network address allowed to talk to the
29
+ // API at all" gate, applied to the entire /v1 surface uniformly
30
+ // (including capabilities.ts and every GET route) - orthogonal to
31
+ // requireScope's per-route, per-scope exemptions. Added on this
32
+ // fastify instance (v1Routes's own parameter, never wrapped in fp())
33
+ // before any nested register() call, so it cascades to every plugin
34
+ // registered as a child below but never to publicRoutes/assetsRoutes
35
+ // (siblings registered directly on the root app, outside this
36
+ // encapsulation context) - verified with a real request, not just
37
+ // asserted.
38
+ fastify.addHook('onRequest', ipAllowlistGuard(opts.ipAllowlist));
39
+ fastify.register(capabilitiesRoutes, { maxUploadBytes: opts.maxUploadBytes });
40
+ // Fastify passes the *entire* options object a plugin was registered
41
+ // with - including `prefix` - through as this plugin's own second
42
+ // argument. Passing `opts` straight through to a nested register()
43
+ // call would carry that `prefix: '/v1'` along with it, double-
44
+ // applying the prefix (confirmed empirically: routes ended up
45
+ // registered at /v1/v1/preview/*). Re-pack only the site-data
46
+ // fields, never `opts` itself, for any nested plugin registration.
47
+ fastify.register(previewRoutes, {
48
+ config: opts.config,
49
+ theme: opts.theme,
50
+ tokens: opts.tokens,
51
+ });
52
+ fastify.register(previewRevisionRoutes, {
53
+ config: opts.config,
54
+ theme: opts.theme,
55
+ tokens: opts.tokens,
56
+ });
57
+ // contentRoutes needs no themeTemplates (it never renders anything),
58
+ // so it gets its own narrower options shape rather than the full
59
+ // bundle out of convenience - matching capabilities.ts's precedent
60
+ // of registering with only what it actually uses.
61
+ fastify.register(contentRoutes, { config: opts.config, tokens: opts.tokens });
62
+ fastify.register(draftsRoutes, {
63
+ config: opts.config,
64
+ theme: opts.theme,
65
+ tokens: opts.tokens,
66
+ });
67
+ fastify.register(publishRoutes, {
68
+ config: opts.config,
69
+ theme: opts.theme,
70
+ tokens: opts.tokens,
71
+ });
72
+ fastify.register(batchRoutes, {
73
+ config: opts.config,
74
+ theme: opts.theme,
75
+ tokens: opts.tokens,
76
+ });
77
+ fastify.register(gitRoutes, { config: opts.config, tokens: opts.tokens });
78
+ fastify.register(mediaRoutes, { config: opts.config, tokens: opts.tokens, maxUploadBytes: opts.maxUploadBytes });
79
+ fastify.register(searchRoutes, { config: opts.config, tokens: opts.tokens });
80
+ fastify.register(redirectsRoutes, { config: opts.config, tokens: opts.tokens });
81
+ fastify.register(menusRoutes, { config: opts.config, tokens: opts.tokens });
82
+ fastify.register(themeRoutes, { config: opts.config, theme: opts.theme, tokens: opts.tokens });
83
+ fastify.register(settingsRoutes, { config: opts.config, theme: opts.theme, tokens: opts.tokens });
84
+ fastify.register(linksRoutes, { config: opts.config, tokens: opts.tokens });
85
+ fastify.register(deletedPagesRoutes, { config: opts.config, tokens: opts.tokens });
86
+ };
@@ -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 LinksRouteOptions {
5
+ config: SiteConfig;
6
+ tokens: TokenEntry[];
7
+ }
8
+ export declare const linksRoutes: FastifyPluginAsync<LinksRouteOptions>;
@@ -0,0 +1,18 @@
1
+ import { findReferences, linkTarget } from "../services/links.js";
2
+ import { requireScope } from "../services/token-auth.js";
3
+ // GET /v1/links?to=/about: every page, draft, menu and the site settings
4
+ // that link to that page - what the admin lists before a page is
5
+ // deleted. `to` is only ever compared against links found in content,
6
+ // never used as a file path. Found by reading the content each time
7
+ // rather than kept in an index: always current, and cheap at a site's
8
+ // size.
9
+ export const linksRoutes = async (fastify, opts) => {
10
+ fastify.get('/links', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => {
11
+ const to = request.query.to;
12
+ if (typeof to !== 'string' || linkTarget(to) === null) {
13
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: 'Expected ?to=/a-page-path' });
14
+ return;
15
+ }
16
+ reply.send({ to: linkTarget(to), references: findReferences(opts.config, to) });
17
+ });
18
+ };
@@ -0,0 +1,11 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ type ResolvedRange = {
4
+ start: number;
5
+ end: number;
6
+ } | null | 'unsatisfiable';
7
+ export declare function resolveRange(header: string | undefined, size: number): ResolvedRange;
8
+ export declare const mediaPublicRoutes: FastifyPluginAsync<{
9
+ config: SiteConfig;
10
+ }>;
11
+ export {};