@storyshelf/app 0.5.3

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 (268) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/dist/app-types.d.mts +54 -0
  4. package/dist/asset-manifest.mjs +41 -0
  5. package/dist/asset-manifest.mjs.map +1 -0
  6. package/dist/asset-paths.mjs +23 -0
  7. package/dist/asset-paths.mjs.map +1 -0
  8. package/dist/assets/htmx.min.js +3304 -0
  9. package/dist/assets/mark.svg +1 -0
  10. package/dist/capture-setup.mjs +139 -0
  11. package/dist/capture-setup.mjs.map +1 -0
  12. package/dist/credential-keys.mjs +20 -0
  13. package/dist/credential-keys.mjs.map +1 -0
  14. package/dist/credentials.mjs +56 -0
  15. package/dist/credentials.mjs.map +1 -0
  16. package/dist/index.d.mts +18 -0
  17. package/dist/index.mjs +310 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/lifecycle.mjs +132 -0
  20. package/dist/lifecycle.mjs.map +1 -0
  21. package/dist/middleware/auth-gate.mjs +36 -0
  22. package/dist/middleware/auth-gate.mjs.map +1 -0
  23. package/dist/middleware/csrf.mjs +75 -0
  24. package/dist/middleware/csrf.mjs.map +1 -0
  25. package/dist/middleware/rate-limit.mjs +68 -0
  26. package/dist/middleware/rate-limit.mjs.map +1 -0
  27. package/dist/middleware/request-log.mjs +30 -0
  28. package/dist/middleware/request-log.mjs.map +1 -0
  29. package/dist/middleware/server-timing.mjs +39 -0
  30. package/dist/middleware/server-timing.mjs.map +1 -0
  31. package/dist/middleware/setup-gate.mjs +37 -0
  32. package/dist/middleware/setup-gate.mjs.map +1 -0
  33. package/dist/middleware/store-scope.mjs +33 -0
  34. package/dist/middleware/store-scope.mjs.map +1 -0
  35. package/dist/notify.d.mts +17 -0
  36. package/dist/notify.mjs +141 -0
  37. package/dist/notify.mjs.map +1 -0
  38. package/dist/pages/admin-system.mjs +243 -0
  39. package/dist/pages/admin-system.mjs.map +1 -0
  40. package/dist/pages/build-detail.mjs +392 -0
  41. package/dist/pages/build-detail.mjs.map +1 -0
  42. package/dist/pages/build-diff-actions.mjs +162 -0
  43. package/dist/pages/build-diff-actions.mjs.map +1 -0
  44. package/dist/pages/build-diff-comments.mjs +104 -0
  45. package/dist/pages/build-diff-comments.mjs.map +1 -0
  46. package/dist/pages/build-diff-header.mjs +117 -0
  47. package/dist/pages/build-diff-header.mjs.map +1 -0
  48. package/dist/pages/build-diff-model.mjs +48 -0
  49. package/dist/pages/build-diff-model.mjs.map +1 -0
  50. package/dist/pages/build-diff-nav.mjs +122 -0
  51. package/dist/pages/build-diff-nav.mjs.map +1 -0
  52. package/dist/pages/build-diff-shortcuts.mjs +78 -0
  53. package/dist/pages/build-diff-shortcuts.mjs.map +1 -0
  54. package/dist/pages/build-diff-stale.mjs +70 -0
  55. package/dist/pages/build-diff-stale.mjs.map +1 -0
  56. package/dist/pages/build-diff-viewer.mjs +70 -0
  57. package/dist/pages/build-diff-viewer.mjs.map +1 -0
  58. package/dist/pages/build-diff.mjs +110 -0
  59. package/dist/pages/build-diff.mjs.map +1 -0
  60. package/dist/pages/compute-jobs.mjs +165 -0
  61. package/dist/pages/compute-jobs.mjs.map +1 -0
  62. package/dist/pages/invite.mjs +91 -0
  63. package/dist/pages/invite.mjs.map +1 -0
  64. package/dist/pages/label-detail.mjs +154 -0
  65. package/dist/pages/label-detail.mjs.map +1 -0
  66. package/dist/pages/library.mjs +343 -0
  67. package/dist/pages/library.mjs.map +1 -0
  68. package/dist/pages/login.mjs +117 -0
  69. package/dist/pages/login.mjs.map +1 -0
  70. package/dist/pages/passkey-ceremony.mjs +97 -0
  71. package/dist/pages/passkey-ceremony.mjs.map +1 -0
  72. package/dist/pages/profile.mjs +364 -0
  73. package/dist/pages/profile.mjs.map +1 -0
  74. package/dist/pages/project-builds-table.mjs +151 -0
  75. package/dist/pages/project-builds-table.mjs.map +1 -0
  76. package/dist/pages/project-builds.mjs +143 -0
  77. package/dist/pages/project-builds.mjs.map +1 -0
  78. package/dist/pages/project-create.mjs +90 -0
  79. package/dist/pages/project-create.mjs.map +1 -0
  80. package/dist/pages/project-settings.mjs +95 -0
  81. package/dist/pages/project-settings.mjs.map +1 -0
  82. package/dist/pages/projects-card.mjs +194 -0
  83. package/dist/pages/projects-card.mjs.map +1 -0
  84. package/dist/pages/projects.mjs +129 -0
  85. package/dist/pages/projects.mjs.map +1 -0
  86. package/dist/pages/root.mjs +98 -0
  87. package/dist/pages/root.mjs.map +1 -0
  88. package/dist/pages/settings-general.mjs +138 -0
  89. package/dist/pages/settings-general.mjs.map +1 -0
  90. package/dist/pages/settings-labels.mjs +107 -0
  91. package/dist/pages/settings-labels.mjs.map +1 -0
  92. package/dist/pages/settings-members.mjs +192 -0
  93. package/dist/pages/settings-members.mjs.map +1 -0
  94. package/dist/pages/settings-notifications.mjs +240 -0
  95. package/dist/pages/settings-notifications.mjs.map +1 -0
  96. package/dist/pages/settings-status.mjs +137 -0
  97. package/dist/pages/settings-status.mjs.map +1 -0
  98. package/dist/pages/settings-tests.mjs +111 -0
  99. package/dist/pages/settings-tests.mjs.map +1 -0
  100. package/dist/pages/settings-tokens.mjs +90 -0
  101. package/dist/pages/settings-tokens.mjs.map +1 -0
  102. package/dist/pages/settings-webhooks.mjs +104 -0
  103. package/dist/pages/settings-webhooks.mjs.map +1 -0
  104. package/dist/pages/storybook.mjs +116 -0
  105. package/dist/pages/storybook.mjs.map +1 -0
  106. package/dist/retention-timer.mjs +65 -0
  107. package/dist/retention-timer.mjs.map +1 -0
  108. package/dist/routers/admin-pages.mjs +48 -0
  109. package/dist/routers/admin-pages.mjs.map +1 -0
  110. package/dist/routers/admin.mjs +113 -0
  111. package/dist/routers/admin.mjs.map +1 -0
  112. package/dist/routers/assets.mjs +64 -0
  113. package/dist/routers/assets.mjs.map +1 -0
  114. package/dist/routers/attempts.mjs +96 -0
  115. package/dist/routers/attempts.mjs.map +1 -0
  116. package/dist/routers/auth-sync.mjs +64 -0
  117. package/dist/routers/auth-sync.mjs.map +1 -0
  118. package/dist/routers/auth.mjs +236 -0
  119. package/dist/routers/auth.mjs.map +1 -0
  120. package/dist/routers/baseline-guard.mjs +83 -0
  121. package/dist/routers/baseline-guard.mjs.map +1 -0
  122. package/dist/routers/builds.handlers.mjs +217 -0
  123. package/dist/routers/builds.handlers.mjs.map +1 -0
  124. package/dist/routers/builds.mjs +381 -0
  125. package/dist/routers/builds.mjs.map +1 -0
  126. package/dist/routers/comments.mjs +109 -0
  127. package/dist/routers/comments.mjs.map +1 -0
  128. package/dist/routers/flash.mjs +35 -0
  129. package/dist/routers/flash.mjs.map +1 -0
  130. package/dist/routers/health-report.mjs +159 -0
  131. package/dist/routers/health-report.mjs.map +1 -0
  132. package/dist/routers/health.mjs +46 -0
  133. package/dist/routers/health.mjs.map +1 -0
  134. package/dist/routers/helpers.mjs +110 -0
  135. package/dist/routers/helpers.mjs.map +1 -0
  136. package/dist/routers/htmx.mjs +24 -0
  137. package/dist/routers/htmx.mjs.map +1 -0
  138. package/dist/routers/labels.mjs +118 -0
  139. package/dist/routers/labels.mjs.map +1 -0
  140. package/dist/routers/media.mjs +53 -0
  141. package/dist/routers/media.mjs.map +1 -0
  142. package/dist/routers/members.mjs +177 -0
  143. package/dist/routers/members.mjs.map +1 -0
  144. package/dist/routers/notification-channels.mjs +190 -0
  145. package/dist/routers/notification-channels.mjs.map +1 -0
  146. package/dist/routers/notification-subscriptions.mjs +120 -0
  147. package/dist/routers/notification-subscriptions.mjs.map +1 -0
  148. package/dist/routers/profile.mjs +276 -0
  149. package/dist/routers/profile.mjs.map +1 -0
  150. package/dist/routers/projects.mjs +133 -0
  151. package/dist/routers/projects.mjs.map +1 -0
  152. package/dist/routers/review-next.mjs +47 -0
  153. package/dist/routers/review-next.mjs.map +1 -0
  154. package/dist/routers/schemas.mjs +337 -0
  155. package/dist/routers/schemas.mjs.map +1 -0
  156. package/dist/routers/settings-general.mjs +104 -0
  157. package/dist/routers/settings-general.mjs.map +1 -0
  158. package/dist/routers/settings-labels.mjs +52 -0
  159. package/dist/routers/settings-labels.mjs.map +1 -0
  160. package/dist/routers/settings-members.mjs +58 -0
  161. package/dist/routers/settings-members.mjs.map +1 -0
  162. package/dist/routers/settings-notifications.mjs +108 -0
  163. package/dist/routers/settings-notifications.mjs.map +1 -0
  164. package/dist/routers/settings-status.mjs +77 -0
  165. package/dist/routers/settings-status.mjs.map +1 -0
  166. package/dist/routers/settings-tokens.mjs +40 -0
  167. package/dist/routers/settings-tokens.mjs.map +1 -0
  168. package/dist/routers/settings-webhooks.mjs +67 -0
  169. package/dist/routers/settings-webhooks.mjs.map +1 -0
  170. package/dist/routers/settings.handlers.mjs +149 -0
  171. package/dist/routers/settings.handlers.mjs.map +1 -0
  172. package/dist/routers/settings.mjs +25 -0
  173. package/dist/routers/settings.mjs.map +1 -0
  174. package/dist/routers/snapshots.mjs +240 -0
  175. package/dist/routers/snapshots.mjs.map +1 -0
  176. package/dist/routers/status-configs.mjs +108 -0
  177. package/dist/routers/status-configs.mjs.map +1 -0
  178. package/dist/routers/storybook.mjs +171 -0
  179. package/dist/routers/storybook.mjs.map +1 -0
  180. package/dist/routers/tokens.mjs +91 -0
  181. package/dist/routers/tokens.mjs.map +1 -0
  182. package/dist/routers/ui.mjs +166 -0
  183. package/dist/routers/ui.mjs.map +1 -0
  184. package/dist/routers/webhooks.mjs +97 -0
  185. package/dist/routers/webhooks.mjs.map +1 -0
  186. package/dist/routers/well-known.mjs +43 -0
  187. package/dist/routers/well-known.mjs.map +1 -0
  188. package/dist/runtime.mjs +52 -0
  189. package/dist/runtime.mjs.map +1 -0
  190. package/dist/store.mjs +20 -0
  191. package/dist/store.mjs.map +1 -0
  192. package/dist/ui/avatar.mjs +76 -0
  193. package/dist/ui/avatar.mjs.map +1 -0
  194. package/dist/ui/buttons.mjs +205 -0
  195. package/dist/ui/buttons.mjs.map +1 -0
  196. package/dist/ui/code-block.mjs +47 -0
  197. package/dist/ui/code-block.mjs.map +1 -0
  198. package/dist/ui/compare-stage.mjs +344 -0
  199. package/dist/ui/compare-stage.mjs.map +1 -0
  200. package/dist/ui/csrf-field.mjs +20 -0
  201. package/dist/ui/csrf-field.mjs.map +1 -0
  202. package/dist/ui/css.mjs +36 -0
  203. package/dist/ui/css.mjs.map +1 -0
  204. package/dist/ui/document.mjs +130 -0
  205. package/dist/ui/document.mjs.map +1 -0
  206. package/dist/ui/dropdown.mjs +181 -0
  207. package/dist/ui/dropdown.mjs.map +1 -0
  208. package/dist/ui/feedback.mjs +341 -0
  209. package/dist/ui/feedback.mjs.map +1 -0
  210. package/dist/ui/filter-input.mjs +58 -0
  211. package/dist/ui/filter-input.mjs.map +1 -0
  212. package/dist/ui/forms.mjs +291 -0
  213. package/dist/ui/forms.mjs.map +1 -0
  214. package/dist/ui/icons/icon.mjs +53 -0
  215. package/dist/ui/icons/icon.mjs.map +1 -0
  216. package/dist/ui/icons/paths.mjs +61 -0
  217. package/dist/ui/icons/paths.mjs.map +1 -0
  218. package/dist/ui/icons/sprite.mjs +26 -0
  219. package/dist/ui/icons/sprite.mjs.map +1 -0
  220. package/dist/ui/kbd.mjs +33 -0
  221. package/dist/ui/kbd.mjs.map +1 -0
  222. package/dist/ui/layout.mjs +173 -0
  223. package/dist/ui/layout.mjs.map +1 -0
  224. package/dist/ui/progress.mjs +44 -0
  225. package/dist/ui/progress.mjs.map +1 -0
  226. package/dist/ui/segmented.mjs +77 -0
  227. package/dist/ui/segmented.mjs.map +1 -0
  228. package/dist/ui/shell/account.mjs +161 -0
  229. package/dist/ui/shell/account.mjs.map +1 -0
  230. package/dist/ui/shell/bare.mjs +69 -0
  231. package/dist/ui/shell/bare.mjs.map +1 -0
  232. package/dist/ui/shell/boot-script.mjs +46 -0
  233. package/dist/ui/shell/boot-script.mjs.map +1 -0
  234. package/dist/ui/shell/client-script.mjs +259 -0
  235. package/dist/ui/shell/client-script.mjs.map +1 -0
  236. package/dist/ui/shell/nav.mjs +111 -0
  237. package/dist/ui/shell/nav.mjs.map +1 -0
  238. package/dist/ui/shell/review-script.mjs +195 -0
  239. package/dist/ui/shell/review-script.mjs.map +1 -0
  240. package/dist/ui/shell/sidebar.mjs +323 -0
  241. package/dist/ui/shell/sidebar.mjs.map +1 -0
  242. package/dist/ui/shell/topbar.mjs +268 -0
  243. package/dist/ui/shell/topbar.mjs.map +1 -0
  244. package/dist/ui/stacks.mjs +82 -0
  245. package/dist/ui/stacks.mjs.map +1 -0
  246. package/dist/ui/styles/base.mjs +41 -0
  247. package/dist/ui/styles/base.mjs.map +1 -0
  248. package/dist/ui/styles/behavior.mjs +43 -0
  249. package/dist/ui/styles/behavior.mjs.map +1 -0
  250. package/dist/ui/styles/review-layout.mjs +347 -0
  251. package/dist/ui/styles/review-layout.mjs.map +1 -0
  252. package/dist/ui/styles/review.mjs +86 -0
  253. package/dist/ui/styles/review.mjs.map +1 -0
  254. package/dist/ui/styles/tokens.mjs +132 -0
  255. package/dist/ui/styles/tokens.mjs.map +1 -0
  256. package/dist/ui/styles.mjs +19 -0
  257. package/dist/ui/styles.mjs.map +1 -0
  258. package/dist/ui/subnav.mjs +91 -0
  259. package/dist/ui/subnav.mjs.map +1 -0
  260. package/dist/ui/table.mjs +73 -0
  261. package/dist/ui/table.mjs.map +1 -0
  262. package/dist/ui/theme.mjs +64 -0
  263. package/dist/ui/theme.mjs.map +1 -0
  264. package/dist/ui/thumbnail.mjs +51 -0
  265. package/dist/ui/thumbnail.mjs.map +1 -0
  266. package/dist/ui/time.mjs +50 -0
  267. package/dist/ui/time.mjs.map +1 -0
  268. package/package.json +92 -0
@@ -0,0 +1,159 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { sanitizeErrorText } from "@storyshelf/core/utils";
5
+ //#region src/routers/health-report.ts
6
+ const PROBE_TIMEOUT_MS = 2e3;
7
+ const DETAIL_MAX_LENGTH = 500;
8
+ function pushTarget(targets, adapter) {
9
+ if (adapter) targets.push(adapter);
10
+ }
11
+ function pushAll(targets, adapters) {
12
+ for (const adapter of adapters) pushTarget(targets, adapter);
13
+ }
14
+ function targetsOf(sources) {
15
+ const targets = [];
16
+ pushAll(targets, [
17
+ sources.database,
18
+ sources.storage,
19
+ sources.captureRunner,
20
+ sources.captureQueue
21
+ ]);
22
+ pushAll(targets, sources.gitHosts ?? []);
23
+ pushAll(targets, sources.notifiers ?? []);
24
+ pushTarget(targets, sources.emailSender);
25
+ return targets;
26
+ }
27
+ function sanitize(detail) {
28
+ return sanitizeErrorText(detail, DETAIL_MAX_LENGTH);
29
+ }
30
+ function messageOf(reason) {
31
+ return sanitize(reason instanceof Error ? reason.message : String(reason));
32
+ }
33
+ async function timeoutError() {
34
+ await new Promise((resolve) => {
35
+ setTimeout(resolve, PROBE_TIMEOUT_MS);
36
+ });
37
+ throw new Error("health probe timed out");
38
+ }
39
+ function baseReport(target) {
40
+ const { category, kind, name, version, description } = target.metadata;
41
+ return {
42
+ category,
43
+ kind,
44
+ name,
45
+ version,
46
+ ...description === void 0 ? {} : { description },
47
+ hasLifecycle: target.lifecycle?.health !== void 0
48
+ };
49
+ }
50
+ function okReport(target, latencyMs, detail) {
51
+ return {
52
+ ...baseReport(target),
53
+ state: "ok",
54
+ latencyMs,
55
+ ...detail === void 0 ? {} : { detail }
56
+ };
57
+ }
58
+ function failedReport(target, latencyMs, detail) {
59
+ return {
60
+ ...baseReport(target),
61
+ state: "failed",
62
+ latencyMs,
63
+ detail
64
+ };
65
+ }
66
+ function setupFailedReport(target, detail) {
67
+ return {
68
+ ...baseReport(target),
69
+ state: "failed",
70
+ detail
71
+ };
72
+ }
73
+ function startingReport(target) {
74
+ return {
75
+ ...baseReport(target),
76
+ state: "starting"
77
+ };
78
+ }
79
+ function noLifecycleReport(target) {
80
+ return {
81
+ ...baseReport(target),
82
+ state: "ok"
83
+ };
84
+ }
85
+ function detailOf(result, fallback) {
86
+ return result.detail === void 0 ? fallback : sanitize(result.detail);
87
+ }
88
+ async function raceProbe(target, probe, started) {
89
+ try {
90
+ const result = await Promise.race([probe(), timeoutError()]);
91
+ const latencyMs = Date.now() - started;
92
+ if (!result.ok) return failedReport(target, latencyMs, detailOf(result, "probe reported failure"));
93
+ return okReport(target, latencyMs, result.detail === void 0 ? void 0 : sanitize(result.detail));
94
+ } catch (error) {
95
+ return failedReport(target, Date.now() - started, messageOf(error));
96
+ }
97
+ }
98
+ async function probeHealth(target) {
99
+ const probe = target.lifecycle?.health;
100
+ if (!probe) return noLifecycleReport(target);
101
+ return await raceProbe(target, probe, Date.now());
102
+ }
103
+ async function probeTarget(target, setupErrors, starting) {
104
+ const setupError = setupErrors.get(`${target.metadata.category}/${target.metadata.kind}`);
105
+ if (setupError !== void 0) return setupFailedReport(target, sanitize(setupError));
106
+ if (starting) return startingReport(target);
107
+ return await probeHealth(target);
108
+ }
109
+ function collectSetupErrors(settled) {
110
+ const setupErrors = /* @__PURE__ */ new Map();
111
+ if (settled) for (const failure of settled.failures) setupErrors.set(`${failure.category}/${failure.kind}`, failure.error);
112
+ return setupErrors;
113
+ }
114
+ function overallStatus(reports) {
115
+ if (reports.some((report) => report.state === "failed")) return "degraded";
116
+ if (reports.some((report) => report.state === "starting")) return "starting";
117
+ return "ok";
118
+ }
119
+ /** Pseudo-adapter entry reporting whether stored credentials decrypt with the configured secret. */
120
+ function credentialsEntry(probe) {
121
+ const unreadable = probe.unreadable.length;
122
+ let detail;
123
+ if (unreadable > 0) detail = `${unreadable} stored credential(s) cannot be decrypted; check SECRET and SECRET_PREVIOUS`;
124
+ else if (probe.previous > 0) detail = `${probe.previous} credential(s) still encrypted with the previous secret; re-encrypt from the System page`;
125
+ return {
126
+ category: "credentials",
127
+ kind: "secret",
128
+ name: "Credential encryption",
129
+ version: "-",
130
+ hasLifecycle: false,
131
+ state: unreadable > 0 ? "failed" : "ok",
132
+ ...detail === void 0 ? {} : { detail }
133
+ };
134
+ }
135
+ /**
136
+ * Probe every wired adapter and assemble the full health report.
137
+ *
138
+ * @param sources - Live adapter instances to probe.
139
+ * @param settled - Setup settlement (null while the eager run is in flight).
140
+ * @param bootTimeMs - Process boot timestamp for uptime.
141
+ * @param version - Server version for the report header.
142
+ * @param credentials - Stored-credential probe (omit when no secret is configured).
143
+ */
144
+ async function collectHealthReport(sources, settled, bootTimeMs, version, credentials) {
145
+ const setupErrors = collectSetupErrors(settled);
146
+ const targets = targetsOf(sources);
147
+ const adapters = await Promise.all(targets.map(async (target) => await probeTarget(target, setupErrors, settled === null)));
148
+ if (credentials) adapters.push(credentialsEntry(credentials));
149
+ return {
150
+ status: overallStatus(adapters),
151
+ uptimeSecs: Math.floor((Date.now() - bootTimeMs) / 1e3),
152
+ version,
153
+ adapters
154
+ };
155
+ }
156
+ //#endregion
157
+ export { collectHealthReport };
158
+
159
+ //# sourceMappingURL=health-report.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"health-report.mjs","names":[],"sources":["../../src/routers/health-report.ts"],"sourcesContent":["import type {\n AdapterHealth,\n AdapterLifecycle,\n AdapterMetadata,\n} from \"@storyshelf/core/adapter/metadata\";\n/**\n * Shared adapter health probing for the JSON API and the admin System page.\n *\n * `POST /api/v1/health` and `GET /admin` both consume\n * {@link collectHealthReport}; the API strips the page-only metadata fields\n * so its response shape never changes. Probes stay parallel, time-bounded,\n * and secret-free (details are truncated, never raw config).\n */\nimport type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { CredentialProbe } from \"@storyshelf/core/models\";\nimport { sanitizeErrorText } from \"@storyshelf/core/utils\";\n\nexport type AdapterState = \"ok\" | \"starting\" | \"failed\";\n\n/** Per-adapter readiness entry with identity for the admin page. */\nexport interface AdapterReportFull {\n category: string;\n kind: string;\n name: string;\n version: string;\n description?: string;\n hasLifecycle: boolean;\n state: AdapterState;\n latencyMs?: number;\n detail?: string;\n}\n\n/** Collected health report: overall status plus one entry per adapter. */\nexport interface HealthReport {\n status: \"ok\" | \"starting\" | \"degraded\";\n uptimeSecs: number;\n version: string;\n adapters: AdapterReportFull[];\n}\n\ninterface ProbeTarget {\n readonly metadata: AdapterMetadata;\n readonly lifecycle?: AdapterLifecycle;\n}\n\nconst PROBE_TIMEOUT_MS = 2000;\nconst DETAIL_MAX_LENGTH = 500;\n\nfunction pushTarget(targets: ProbeTarget[], adapter: ProbeTarget | undefined): void {\n if (adapter) {\n targets.push(adapter);\n }\n}\n\nfunction pushAll(targets: ProbeTarget[], adapters: ReadonlyArray<ProbeTarget | undefined>): void {\n for (const adapter of adapters) {\n pushTarget(targets, adapter);\n }\n}\n\nfunction targetsOf(sources: AdapterSetupSources): ProbeTarget[] {\n const targets: ProbeTarget[] = [];\n pushAll(targets, [\n sources.database,\n sources.storage,\n sources.captureRunner,\n sources.captureQueue,\n ]);\n pushAll(targets, sources.gitHosts ?? []);\n pushAll(targets, sources.notifiers ?? []);\n pushTarget(targets, sources.emailSender);\n return targets;\n}\n\nfunction sanitize(detail: string): string {\n return sanitizeErrorText(detail, DETAIL_MAX_LENGTH);\n}\n\nfunction messageOf(reason: unknown): string {\n const message = reason instanceof Error ? reason.message : String(reason);\n return sanitize(message);\n}\n\nasync function timeoutError(): Promise<never> {\n await new Promise<void>((resolve) => {\n setTimeout(resolve, PROBE_TIMEOUT_MS);\n });\n throw new Error(\"health probe timed out\");\n}\n\nfunction baseReport(\n target: ProbeTarget,\n): Omit<AdapterReportFull, \"state\" | \"latencyMs\" | \"detail\"> {\n const { category, kind, name, version, description } = target.metadata;\n return {\n category,\n kind,\n name,\n version,\n ...(description === undefined ? {} : { description }),\n hasLifecycle: target.lifecycle?.health !== undefined,\n };\n}\n\nfunction okReport(\n target: ProbeTarget,\n latencyMs: number,\n detail: string | undefined,\n): AdapterReportFull {\n return {\n ...baseReport(target),\n state: \"ok\",\n latencyMs,\n ...(detail === undefined ? {} : { detail }),\n };\n}\n\nfunction failedReport(target: ProbeTarget, latencyMs: number, detail: string): AdapterReportFull {\n return { ...baseReport(target), state: \"failed\", latencyMs, detail };\n}\n\nfunction setupFailedReport(target: ProbeTarget, detail: string): AdapterReportFull {\n return { ...baseReport(target), state: \"failed\", detail };\n}\n\nfunction startingReport(target: ProbeTarget): AdapterReportFull {\n return { ...baseReport(target), state: \"starting\" };\n}\n\nfunction noLifecycleReport(target: ProbeTarget): AdapterReportFull {\n return { ...baseReport(target), state: \"ok\" };\n}\n\nfunction detailOf(result: AdapterHealth, fallback: string): string {\n return result.detail === undefined ? fallback : sanitize(result.detail);\n}\n\nasync function raceProbe(\n target: ProbeTarget,\n probe: () => Promise<AdapterHealth>,\n started: number,\n): Promise<AdapterReportFull> {\n try {\n const result = await Promise.race([probe(), timeoutError()]);\n const latencyMs = Date.now() - started;\n if (!result.ok) {\n return failedReport(target, latencyMs, detailOf(result, \"probe reported failure\"));\n }\n return okReport(\n target,\n latencyMs,\n result.detail === undefined ? undefined : sanitize(result.detail),\n );\n } catch (error) {\n return failedReport(target, Date.now() - started, messageOf(error));\n }\n}\n\nasync function probeHealth(target: ProbeTarget): Promise<AdapterReportFull> {\n const probe = target.lifecycle?.health;\n if (!probe) {\n return noLifecycleReport(target);\n }\n return await raceProbe(target, probe, Date.now());\n}\n\nasync function probeTarget(\n target: ProbeTarget,\n setupErrors: ReadonlyMap<string, string>,\n starting: boolean,\n): Promise<AdapterReportFull> {\n const setupError = setupErrors.get(`${target.metadata.category}/${target.metadata.kind}`);\n if (setupError !== undefined) {\n return setupFailedReport(target, sanitize(setupError));\n }\n if (starting) {\n return startingReport(target);\n }\n return await probeHealth(target);\n}\n\nfunction collectSetupErrors(settled: AdapterSetupResult | null): ReadonlyMap<string, string> {\n const setupErrors = new Map<string, string>();\n if (settled) {\n for (const failure of settled.failures) {\n setupErrors.set(`${failure.category}/${failure.kind}`, failure.error);\n }\n }\n return setupErrors;\n}\n\nfunction overallStatus(reports: AdapterReportFull[]): HealthReport[\"status\"] {\n if (reports.some((report) => report.state === \"failed\")) {\n return \"degraded\";\n }\n if (reports.some((report) => report.state === \"starting\")) {\n return \"starting\";\n }\n return \"ok\";\n}\n\n/** Pseudo-adapter entry reporting whether stored credentials decrypt with the configured secret. */\nfunction credentialsEntry(probe: CredentialProbe): AdapterReportFull {\n const unreadable = probe.unreadable.length;\n let detail: string | undefined;\n if (unreadable > 0) {\n detail = `${unreadable} stored credential(s) cannot be decrypted; check SECRET and SECRET_PREVIOUS`;\n } else if (probe.previous > 0) {\n detail = `${probe.previous} credential(s) still encrypted with the previous secret; re-encrypt from the System page`;\n }\n return {\n category: \"credentials\",\n kind: \"secret\",\n name: \"Credential encryption\",\n version: \"-\",\n hasLifecycle: false,\n state: unreadable > 0 ? \"failed\" : \"ok\",\n ...(detail === undefined ? {} : { detail }),\n };\n}\n\n/**\n * Probe every wired adapter and assemble the full health report.\n *\n * @param sources - Live adapter instances to probe.\n * @param settled - Setup settlement (null while the eager run is in flight).\n * @param bootTimeMs - Process boot timestamp for uptime.\n * @param version - Server version for the report header.\n * @param credentials - Stored-credential probe (omit when no secret is configured).\n */\nexport async function collectHealthReport(\n sources: AdapterSetupSources,\n settled: AdapterSetupResult | null,\n bootTimeMs: number,\n version: string,\n credentials?: CredentialProbe | null,\n): Promise<HealthReport> {\n const setupErrors = collectSetupErrors(settled);\n const targets = targetsOf(sources);\n const adapters = await Promise.all(\n targets.map(async (target) => await probeTarget(target, setupErrors, settled === null)),\n );\n if (credentials) {\n adapters.push(credentialsEntry(credentials));\n }\n return {\n status: overallStatus(adapters),\n uptimeSecs: Math.floor((Date.now() - bootTimeMs) / 1000),\n version,\n adapters,\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,46 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { loadCredentialProbe } from "../credentials.mjs";
6
+ import { collectHealthReport } from "./health-report.mjs";
7
+ import { requireSiteAdmin } from "./helpers.mjs";
8
+ //#region src/routers/health.ts
9
+ /** Register `GET /api/v1/health` (open liveness) and `POST` (authed deep readiness). */
10
+ function registerHealth(app, deps) {
11
+ app.get("/api/v1/health", (c) => {
12
+ const body = {
13
+ status: "ok",
14
+ uptimeSecs: Math.floor((Date.now() - deps.bootTimeMs) / 1e3),
15
+ version: deps.version
16
+ };
17
+ c.header("Cache-Control", "no-store");
18
+ return c.json(body);
19
+ });
20
+ app.post("/api/v1/health", async (c) => {
21
+ requireSiteAdmin(c);
22
+ const { db, config } = getStore();
23
+ const report = await collectHealthReport(deps.sources, deps.getSettled(), deps.bootTimeMs, deps.version, await loadCredentialProbe(db, config));
24
+ const adapters = report.adapters.map((adapter) => {
25
+ const entry = {
26
+ category: adapter.category,
27
+ kind: adapter.kind,
28
+ name: adapter.name,
29
+ state: adapter.state
30
+ };
31
+ if (adapter.latencyMs !== void 0) entry.latencyMs = adapter.latencyMs;
32
+ if (adapter.detail !== void 0) entry.detail = adapter.detail;
33
+ return entry;
34
+ });
35
+ const body = {
36
+ status: report.status,
37
+ adapters
38
+ };
39
+ c.header("Cache-Control", "no-store");
40
+ return c.json(body, body.status === "ok" ? 200 : 503);
41
+ });
42
+ }
43
+ //#endregion
44
+ export { registerHealth };
45
+
46
+ //# sourceMappingURL=health.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"health.mjs","names":[],"sources":["../../src/routers/health.ts"],"sourcesContent":["/**\n * Two-tier health: lightweight liveness (GET, no auth) and deep readiness\n * (POST, site-admin). Neither is gated by adapter setup — liveness must\n * answer mid-migration so orchestrators don't kill the machine, and deep\n * reports the setup settlement state instead of being masked by the gate.\n *\n * Probing lives in `health-report.ts` (shared with the admin System page);\n * this module keeps the API response shape stable.\n */\nimport type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { loadCredentialProbe } from \"../credentials.ts\";\nimport { getStore } from \"../store.ts\";\nimport { collectHealthReport, type AdapterState } from \"./health-report.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\n\n/** Dependencies for the health routes (no adapter I/O on the GET path). */\nexport interface HealthDeps {\n sources: AdapterSetupSources;\n getSettled: () => AdapterSetupResult | null;\n bootTimeMs: number;\n version: string;\n}\n\n/** Liveness body: process-level only, safe for unauthenticated probes. */\nexport interface LivenessBody {\n status: \"ok\";\n uptimeSecs: number;\n version: string;\n}\n\n/** Per-adapter readiness entry (error text sanitized — no secrets). */\nexport interface AdapterReport {\n category: string;\n kind: string;\n name: string;\n state: AdapterState;\n latencyMs?: number;\n detail?: string;\n}\n\n/** Deep readiness body. */\nexport interface ReadinessBody {\n status: \"ok\" | \"starting\" | \"degraded\";\n adapters: AdapterReport[];\n}\n\n/** Register `GET /api/v1/health` (open liveness) and `POST` (authed deep readiness). */\nexport function registerHealth(app: ShelfRouter, deps: HealthDeps): void {\n app.get(\"/api/v1/health\", (c) => {\n const body: LivenessBody = {\n status: \"ok\",\n uptimeSecs: Math.floor((Date.now() - deps.bootTimeMs) / 1000),\n version: deps.version,\n };\n c.header(\"Cache-Control\", \"no-store\");\n return c.json(body);\n });\n\n app.post(\"/api/v1/health\", async (c) => {\n requireSiteAdmin(c);\n const { db, config } = getStore();\n const report = await collectHealthReport(\n deps.sources,\n deps.getSettled(),\n deps.bootTimeMs,\n deps.version,\n await loadCredentialProbe(db, config),\n );\n const adapters: AdapterReport[] = report.adapters.map((adapter) => {\n const entry: AdapterReport = {\n category: adapter.category,\n kind: adapter.kind,\n name: adapter.name,\n state: adapter.state,\n };\n if (adapter.latencyMs !== undefined) {\n entry.latencyMs = adapter.latencyMs;\n }\n if (adapter.detail !== undefined) {\n entry.detail = adapter.detail;\n }\n return entry;\n });\n const body: ReadinessBody = { status: report.status, adapters };\n c.header(\"Cache-Control\", \"no-store\");\n return c.json(body, body.status === \"ok\" ? 200 : 503);\n });\n}\n"],"mappings":";;;;;;;;;AAgDA,SAAgB,eAAe,KAAkB,MAAwB;CACvE,IAAI,IAAI,mBAAmB,MAAM;EAC/B,MAAM,OAAqB;GACzB,QAAQ;GACR,YAAY,KAAK,OAAO,KAAK,IAAI,IAAI,KAAK,cAAc,GAAI;GAC5D,SAAS,KAAK;EAChB;EACA,EAAE,OAAO,iBAAiB,UAAU;EACpC,OAAO,EAAE,KAAK,IAAI;CACpB,CAAC;CAED,IAAI,KAAK,kBAAkB,OAAO,MAAM;EACtC,iBAAiB,CAAC;EAClB,MAAM,EAAE,IAAI,WAAW,SAAS;EAChC,MAAM,SAAS,MAAM,oBACnB,KAAK,SACL,KAAK,WAAW,GAChB,KAAK,YACL,KAAK,SACL,MAAM,oBAAoB,IAAI,MAAM,CACtC;EACA,MAAM,WAA4B,OAAO,SAAS,KAAK,YAAY;GACjE,MAAM,QAAuB;IAC3B,UAAU,QAAQ;IAClB,MAAM,QAAQ;IACd,MAAM,QAAQ;IACd,OAAO,QAAQ;GACjB;GACA,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,YAAY,QAAQ;GAE5B,IAAI,QAAQ,WAAW,KAAA,GACrB,MAAM,SAAS,QAAQ;GAEzB,OAAO;EACT,CAAC;EACD,MAAM,OAAsB;GAAE,QAAQ,OAAO;GAAQ;EAAS;EAC9D,EAAE,OAAO,iBAAiB,UAAU;EACpC,OAAO,EAAE,KAAK,MAAM,KAAK,WAAW,OAAO,MAAM,GAAG;CACtD,CAAC;AACH"}
@@ -0,0 +1,110 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { MemberModel, ProjectModel, TokenModel, UserModel } from "@storyshelf/core/models";
6
+ import { sha256, timingSafeEqualString } from "@storyshelf/core/utils";
7
+ import { HTTPException } from "hono/http-exception";
8
+ //#region src/routers/helpers.ts
9
+ /** Throw a 401 Unauthorized error for unauthenticated requests. */
10
+ function unauthorized() {
11
+ throw new HTTPException(401, { message: "Unauthorized" });
12
+ }
13
+ /** Throw a 403 Forbidden error for requests lacking the required role. */
14
+ function forbidden() {
15
+ throw new HTTPException(403, { message: "Forbidden" });
16
+ }
17
+ /** Throw a 404 Not Found error with a custom message. */
18
+ function notFound(message = "Not found") {
19
+ throw new HTTPException(404, { message });
20
+ }
21
+ /** Resolve the current session user's effective role on a project. */
22
+ async function currentProjectRole(projectId) {
23
+ const { db, user } = getStore();
24
+ if (!user) return null;
25
+ return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);
26
+ }
27
+ /** Look up a project by its URL slug, returning null when absent. */
28
+ async function findProjectBySlug(slug) {
29
+ const { db } = getStore();
30
+ return await new ProjectModel(db).getBySlug(slug);
31
+ }
32
+ async function resolveProjectByToken(c, slug) {
33
+ const authHeader = c.req.header("authorization");
34
+ if (authHeader?.startsWith("Bearer ")) {
35
+ const token = authHeader.slice(7);
36
+ const found = await new TokenModel(getStore().db).findByHash(sha256(token));
37
+ if (!found) unauthorized();
38
+ const project = await new ProjectModel(getStore().db).get(found.projectId);
39
+ if (!project || project.slug !== slug) forbidden();
40
+ return {
41
+ project,
42
+ token: found
43
+ };
44
+ }
45
+ return null;
46
+ }
47
+ /**
48
+ * Resolve a bearer token to an effective project role.
49
+ * Tokens without an owner (legacy pre-binding rows) resolve as viewer;
50
+ * tokens whose owner no longer exists resolve to null (deny).
51
+ */
52
+ async function tokenProjectRole(token, projectId) {
53
+ if (!token.userId) return "viewer";
54
+ const { db } = getStore();
55
+ const user = await new UserModel(db).get(token.userId);
56
+ if (!user) return null;
57
+ return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);
58
+ }
59
+ /**
60
+ * True when the request bears the configured bootstrap admin token.
61
+ * Covers site-admin routes AND project routes (as admin) so a fresh
62
+ * instance is fully operable before any user exists. Timing-safe;
63
+ * unset `adminToken` never matches.
64
+ */
65
+ function requestHasAdminToken(c) {
66
+ const adminToken = getStore().config.adminToken;
67
+ if (!adminToken) return false;
68
+ const header = c.req.header("authorization");
69
+ if (!header?.startsWith("Bearer ")) return false;
70
+ return timingSafeEqualString(header.slice(7), adminToken);
71
+ }
72
+ /**
73
+ * Enforce the caller's project role for a given project.
74
+ *
75
+ * Authorization model (ADR 0008):
76
+ * - No auth adapter configured -> all operations permitted (development mode).
77
+ * - Bootstrap admin token -> grants access as admin (see `requestHasAdminToken`).
78
+ * - Bearer token (CLI) -> grants access to its own project regardless of role.
79
+ * - Session user -> must have an effective role in `minRoles`.
80
+ */
81
+ async function assertRole(projectId, ...minRoles) {
82
+ if (!getStore().authEnabled) return;
83
+ const role = await currentProjectRole(projectId);
84
+ if (!role || !minRoles.includes(role)) forbidden();
85
+ }
86
+ /** Resolve a project by slug and enforce the caller's minimum role. */
87
+ async function resolveAuthorizedProject(c, slug, ...minRoles) {
88
+ const viaToken = await resolveProjectByToken(c, slug);
89
+ if (viaToken) {
90
+ const role = await tokenProjectRole(viaToken.token, viaToken.project.id);
91
+ if (!role || !minRoles.includes(role)) forbidden();
92
+ return viaToken.project;
93
+ }
94
+ const found = await findProjectBySlug(slug);
95
+ if (!found) notFound("Project not found");
96
+ if (requestHasAdminToken(c)) return found;
97
+ await assertRole(found.id, ...minRoles);
98
+ return found;
99
+ }
100
+ /** Require a site-level admin (or permit when auth is disabled). */
101
+ function requireSiteAdmin(c) {
102
+ if (!getStore().authEnabled) return;
103
+ const { user } = getStore();
104
+ if (user?.role === "admin" || requestHasAdminToken(c)) return;
105
+ forbidden();
106
+ }
107
+ //#endregion
108
+ export { assertRole, currentProjectRole, findProjectBySlug, forbidden, notFound, requestHasAdminToken, requireSiteAdmin, resolveAuthorizedProject, unauthorized };
109
+
110
+ //# sourceMappingURL=helpers.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"helpers.mjs","names":[],"sources":["../../src/routers/helpers.ts"],"sourcesContent":["import { MemberModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport { TokenModel } from \"@storyshelf/core/models\";\nimport { UserModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Token } from \"@storyshelf/core/schema\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport { sha256, timingSafeEqualString } from \"@storyshelf/core/utils\";\nimport type { Context, Next } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ContentfulStatusCode } from \"hono/utils/http-status\";\nimport type { ZodType } from \"zod\";\nimport { getStore } from \"../store.ts\";\n/** Send a JSON response with the given status code. */\nexport function json(c: Context, data: unknown, status: ContentfulStatusCode = 200): Response {\n return c.json(data, status);\n}\n\n/** Parse the request body as JSON and validate it against a Zod schema. */\nexport async function validJson<T>(c: Context, schema: ZodType<T>): Promise<T> {\n const result = schema.safeParse(await c.req.json());\n if (!result.success) {\n throw new HTTPException(400, { message: result.error.message });\n }\n return result.data;\n}\n\n/** Throw a 401 Unauthorized error for unauthenticated requests. */\nexport function unauthorized(): never {\n throw new HTTPException(401, { message: \"Unauthorized\" });\n}\n\n/** Throw a 403 Forbidden error for requests lacking the required role. */\nexport function forbidden(): never {\n throw new HTTPException(403, { message: \"Forbidden\" });\n}\n\n/** Throw a 404 Not Found error with a custom message. */\nexport function notFound(message = \"Not found\"): never {\n throw new HTTPException(404, { message });\n}\n\n/** Resolve the current session user's effective role on a project. */\nexport async function currentProjectRole(projectId: string): Promise<ProjectRole | null> {\n const { db, user } = getStore();\n if (!user) {\n return null;\n }\n return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);\n}\n\n/** Build middleware that requires one of the given project roles. */\nexport function requireRole(...roles: ProjectRole[]) {\n return async (c: Context, next: Next): Promise<void> => {\n const projectId = c.req.param(\"projectId\") ?? c.req.param(\"slug\");\n if (!projectId) {\n forbidden();\n }\n const role = await currentProjectRole(projectId);\n if (!role || !roles.includes(role)) {\n forbidden();\n }\n await next();\n };\n}\n\n/** Look up a project by its URL slug, returning null when absent. */\nexport async function findProjectBySlug(slug: string): Promise<Project | null> {\n const { db } = getStore();\n return await new ProjectModel(db).getBySlug(slug);\n}\n\nasync function resolveProjectByToken(\n c: Context,\n slug: string,\n): Promise<{ project: Project; token: Token } | null> {\n const authHeader = c.req.header(\"authorization\");\n if (authHeader?.startsWith(\"Bearer \")) {\n const token = authHeader.slice(\"Bearer \".length);\n const found = await new TokenModel(getStore().db).findByHash(sha256(token));\n if (!found) {\n unauthorized();\n }\n const project = await new ProjectModel(getStore().db).get(found.projectId);\n if (!project || project.slug !== slug) {\n forbidden();\n }\n return { project, token: found };\n }\n return null;\n}\n\n/**\n * Resolve a bearer token to an effective project role.\n * Tokens without an owner (legacy pre-binding rows) resolve as viewer;\n * tokens whose owner no longer exists resolve to null (deny).\n */\nasync function tokenProjectRole(token: Token, projectId: string): Promise<ProjectRole | null> {\n if (!token.userId) {\n return \"viewer\";\n }\n const { db } = getStore();\n const user = await new UserModel(db).get(token.userId);\n if (!user) {\n return null;\n }\n return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);\n}\n\n/** Resolve a project by slug, honoring CLI bearer-token access. */\nexport async function resolveProject(c: Context, slug: string): Promise<Project> {\n const viaToken = await resolveProjectByToken(c, slug);\n if (viaToken) {\n return viaToken.project;\n }\n const found = await findProjectBySlug(slug);\n if (!found) {\n notFound(\"Project not found\");\n }\n return found;\n}\n\n/**\n * True when the request bears the configured bootstrap admin token.\n * Covers site-admin routes AND project routes (as admin) so a fresh\n * instance is fully operable before any user exists. Timing-safe;\n * unset `adminToken` never matches.\n */\nexport function requestHasAdminToken(c: Context): boolean {\n const adminToken = getStore().config.adminToken;\n if (!adminToken) {\n return false;\n }\n const header = c.req.header(\"authorization\");\n if (!header?.startsWith(\"Bearer \")) {\n return false;\n }\n return timingSafeEqualString(header.slice(\"Bearer \".length), adminToken);\n}\n\n/**\n * Enforce the caller's project role for a given project.\n *\n * Authorization model (ADR 0008):\n * - No auth adapter configured -> all operations permitted (development mode).\n * - Bootstrap admin token -> grants access as admin (see `requestHasAdminToken`).\n * - Bearer token (CLI) -> grants access to its own project regardless of role.\n * - Session user -> must have an effective role in `minRoles`.\n */\nexport async function assertRole(projectId: string, ...minRoles: ProjectRole[]): Promise<void> {\n if (!getStore().authEnabled) {\n return;\n }\n const role = await currentProjectRole(projectId);\n if (!role || !minRoles.includes(role)) {\n forbidden();\n }\n}\n\n/** Resolve a project by slug and enforce the caller's minimum role. */\nexport async function resolveAuthorizedProject(\n c: Context,\n slug: string,\n ...minRoles: ProjectRole[]\n): Promise<Project> {\n const viaToken = await resolveProjectByToken(c, slug);\n if (viaToken) {\n const role = await tokenProjectRole(viaToken.token, viaToken.project.id);\n if (!role || !minRoles.includes(role)) {\n forbidden();\n }\n return viaToken.project;\n }\n const found = await findProjectBySlug(slug);\n if (!found) {\n notFound(\"Project not found\");\n }\n if (requestHasAdminToken(c)) {\n return found;\n }\n await assertRole(found.id, ...minRoles);\n return found;\n}\n\n/** Require a site-level admin (or permit when auth is disabled). */\nexport function requireSiteAdmin(c: Context): void {\n if (!getStore().authEnabled) {\n return;\n }\n const { user } = getStore();\n if (user?.role === \"admin\" || requestHasAdminToken(c)) {\n return;\n }\n forbidden();\n}\n\n/** Require the current session user to hold one of the given project roles. */\nexport async function requireProjectRole(\n projectId: string,\n ...minRoles: ProjectRole[]\n): Promise<void> {\n await assertRole(projectId, ...minRoles);\n}\n"],"mappings":";;;;;;;;;AA4BA,SAAgB,eAAsB;CACpC,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,eAAe,CAAC;AAC1D;;AAGA,SAAgB,YAAmB;CACjC,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,YAAY,CAAC;AACvD;;AAGA,SAAgB,SAAS,UAAU,aAAoB;CACrD,MAAM,IAAI,cAAc,KAAK,EAAE,QAAQ,CAAC;AAC1C;;AAGA,eAAsB,mBAAmB,WAAgD;CACvF,MAAM,EAAE,IAAI,SAAS,SAAS;CAC9B,IAAI,CAAC,MACH,OAAO;CAET,OAAO,MAAM,IAAI,YAAY,EAAE,CAAC,CAAC,cAAc,KAAK,MAAM,WAAW,KAAK,EAAE;AAC9E;;AAkBA,eAAsB,kBAAkB,MAAuC;CAC7E,MAAM,EAAE,OAAO,SAAS;CACxB,OAAO,MAAM,IAAI,aAAa,EAAE,CAAC,CAAC,UAAU,IAAI;AAClD;AAEA,eAAe,sBACb,GACA,MACoD;CACpD,MAAM,aAAa,EAAE,IAAI,OAAO,eAAe;CAC/C,IAAI,YAAY,WAAW,SAAS,GAAG;EACrC,MAAM,QAAQ,WAAW,MAAM,CAAgB;EAC/C,MAAM,QAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,WAAW,OAAO,KAAK,CAAC;EAC1E,IAAI,CAAC,OACH,aAAa;EAEf,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,SAAS;EACzE,IAAI,CAAC,WAAW,QAAQ,SAAS,MAC/B,UAAU;EAEZ,OAAO;GAAE;GAAS,OAAO;EAAM;CACjC;CACA,OAAO;AACT;;;;;;AAOA,eAAe,iBAAiB,OAAc,WAAgD;CAC5F,IAAI,CAAC,MAAM,QACT,OAAO;CAET,MAAM,EAAE,OAAO,SAAS;CACxB,MAAM,OAAO,MAAM,IAAI,UAAU,EAAE,CAAC,CAAC,IAAI,MAAM,MAAM;CACrD,IAAI,CAAC,MACH,OAAO;CAET,OAAO,MAAM,IAAI,YAAY,EAAE,CAAC,CAAC,cAAc,KAAK,MAAM,WAAW,KAAK,EAAE;AAC9E;;;;;;;AAqBA,SAAgB,qBAAqB,GAAqB;CACxD,MAAM,aAAa,SAAS,CAAC,CAAC,OAAO;CACrC,IAAI,CAAC,YACH,OAAO;CAET,MAAM,SAAS,EAAE,IAAI,OAAO,eAAe;CAC3C,IAAI,CAAC,QAAQ,WAAW,SAAS,GAC/B,OAAO;CAET,OAAO,sBAAsB,OAAO,MAAM,CAAgB,GAAG,UAAU;AACzE;;;;;;;;;;AAWA,eAAsB,WAAW,WAAmB,GAAG,UAAwC;CAC7F,IAAI,CAAC,SAAS,CAAC,CAAC,aACd;CAEF,MAAM,OAAO,MAAM,mBAAmB,SAAS;CAC/C,IAAI,CAAC,QAAQ,CAAC,SAAS,SAAS,IAAI,GAClC,UAAU;AAEd;;AAGA,eAAsB,yBACpB,GACA,MACA,GAAG,UACe;CAClB,MAAM,WAAW,MAAM,sBAAsB,GAAG,IAAI;CACpD,IAAI,UAAU;EACZ,MAAM,OAAO,MAAM,iBAAiB,SAAS,OAAO,SAAS,QAAQ,EAAE;EACvE,IAAI,CAAC,QAAQ,CAAC,SAAS,SAAS,IAAI,GAClC,UAAU;EAEZ,OAAO,SAAS;CAClB;CACA,MAAM,QAAQ,MAAM,kBAAkB,IAAI;CAC1C,IAAI,CAAC,OACH,SAAS,mBAAmB;CAE9B,IAAI,qBAAqB,CAAC,GACxB,OAAO;CAET,MAAM,WAAW,MAAM,IAAI,GAAG,QAAQ;CACtC,OAAO;AACT;;AAGA,SAAgB,iBAAiB,GAAkB;CACjD,IAAI,CAAC,SAAS,CAAC,CAAC,aACd;CAEF,MAAM,EAAE,SAAS,SAAS;CAC1B,IAAI,MAAM,SAAS,WAAW,qBAAqB,CAAC,GAClD;CAEF,UAAU;AACZ"}
@@ -0,0 +1,24 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region src/routers/htmx.ts
5
+ /** Check whether the request came from HTMX (HX-Request header). */
6
+ function isHxRequest(c) {
7
+ return c.req.header("HX-Request") === "true";
8
+ }
9
+ /** Redirect HTMX requests via HX-Redirect and plain requests via 302. */
10
+ function hxRedirect(c, url) {
11
+ if (isHxRequest(c)) {
12
+ c.header("HX-Redirect", url);
13
+ return c.body(null, 204);
14
+ }
15
+ return c.redirect(url, 302);
16
+ }
17
+ /** Ask HTMX to reload the current page (review forms post with `hx-target="body"`). */
18
+ function hxRefresh(c) {
19
+ if (isHxRequest(c)) c.header("HX-Refresh", "true");
20
+ }
21
+ //#endregion
22
+ export { hxRedirect, hxRefresh, isHxRequest };
23
+
24
+ //# sourceMappingURL=htmx.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"htmx.mjs","names":[],"sources":["../../src/routers/htmx.ts"],"sourcesContent":["import type { Context } from \"hono\";\n\n/** Check whether the request came from HTMX (HX-Request header). */\nexport function isHxRequest(c: Context): boolean {\n return c.req.header(\"HX-Request\") === \"true\";\n}\n\n/** Redirect HTMX requests via HX-Redirect and plain requests via 302. */\nexport function hxRedirect(c: Context, url: string): Response {\n if (isHxRequest(c)) {\n c.header(\"HX-Redirect\", url);\n return c.body(null, 204);\n }\n return c.redirect(url, 302);\n}\n\n/** Ask HTMX to reload the current page (review forms post with `hx-target=\"body\"`). */\nexport function hxRefresh(c: Context): void {\n if (isHxRequest(c)) {\n c.header(\"HX-Refresh\", \"true\");\n }\n}\n"],"mappings":";;;;;AAGA,SAAgB,YAAY,GAAqB;CAC/C,OAAO,EAAE,IAAI,OAAO,YAAY,MAAM;AACxC;;AAGA,SAAgB,WAAW,GAAY,KAAuB;CAC5D,IAAI,YAAY,CAAC,GAAG;EAClB,EAAE,OAAO,eAAe,GAAG;EAC3B,OAAO,EAAE,KAAK,MAAM,GAAG;CACzB;CACA,OAAO,EAAE,SAAS,KAAK,GAAG;AAC5B;;AAGA,SAAgB,UAAU,GAAkB;CAC1C,IAAI,YAAY,CAAC,GACf,EAAE,OAAO,cAAc,MAAM;AAEjC"}
@@ -0,0 +1,118 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { resolveAuthorizedProject } from "./helpers.mjs";
6
+ import { labelTypeCreateSchema, labelTypeSchema, labelTypeUpdateSchema, notFound, unauthorized } from "./schemas.mjs";
7
+ import { createRoute, z } from "@hono/zod-openapi";
8
+ import { LabelModel } from "@storyshelf/core/models";
9
+ import { HTTPException } from "hono/http-exception";
10
+ //#region src/routers/labels.ts
11
+ const VIEW_ROLES = [
12
+ "viewer",
13
+ "developer",
14
+ "approver",
15
+ "admin"
16
+ ];
17
+ const ADMIN_ROLES = ["admin"];
18
+ const listLabelsRoute = createRoute({
19
+ method: "get",
20
+ tags: ["Labels"],
21
+ summary: "List label types",
22
+ path: "/api/v1/projects/{slug}/label-types",
23
+ request: { params: z.object({ slug: z.string() }) },
24
+ responses: {
25
+ 200: {
26
+ content: { "application/json": { schema: labelTypeSchema.array() } },
27
+ description: "List label types"
28
+ },
29
+ ...notFound,
30
+ ...unauthorized
31
+ }
32
+ });
33
+ const createLabelRoute = createRoute({
34
+ method: "post",
35
+ tags: ["Labels"],
36
+ summary: "Create a label type",
37
+ path: "/api/v1/projects/{slug}/label-types",
38
+ request: {
39
+ params: z.object({ slug: z.string() }),
40
+ body: { content: { "application/json": { schema: labelTypeCreateSchema } } }
41
+ },
42
+ responses: {
43
+ 201: {
44
+ content: { "application/json": { schema: labelTypeSchema } },
45
+ description: "Created label type"
46
+ },
47
+ ...notFound
48
+ }
49
+ });
50
+ const deleteLabelRoute = createRoute({
51
+ method: "delete",
52
+ tags: ["Labels"],
53
+ summary: "Delete a label type",
54
+ path: "/api/v1/projects/{slug}/label-types/{key}",
55
+ request: { params: z.object({
56
+ slug: z.string(),
57
+ key: z.string()
58
+ }) },
59
+ responses: {
60
+ 204: { description: "Deleted label type" },
61
+ ...notFound
62
+ }
63
+ });
64
+ const updateLabelRoute = createRoute({
65
+ method: "patch",
66
+ tags: ["Labels"],
67
+ summary: "Update a label type",
68
+ path: "/api/v1/projects/{slug}/label-types/{key}",
69
+ request: {
70
+ params: z.object({
71
+ slug: z.string(),
72
+ key: z.string()
73
+ }),
74
+ body: { content: { "application/json": { schema: labelTypeUpdateSchema } } }
75
+ },
76
+ responses: {
77
+ 200: {
78
+ content: { "application/json": { schema: labelTypeSchema } },
79
+ description: "Updated label type"
80
+ },
81
+ ...notFound
82
+ }
83
+ });
84
+ /** Register the project label-type list, create, update, and delete endpoints. */
85
+ function registerLabels(app) {
86
+ app.openapi(listLabelsRoute, async (c) => {
87
+ const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...VIEW_ROLES);
88
+ return c.json(await new LabelModel(getStore().db).listTypes(project.id));
89
+ });
90
+ app.openapi(createLabelRoute, async (c) => {
91
+ const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...ADMIN_ROLES);
92
+ const body = c.req.valid("json");
93
+ return c.json(await new LabelModel(getStore().db).createType(project.id, body), 201);
94
+ });
95
+ app.openapi(deleteLabelRoute, async (c) => {
96
+ const { slug, key } = c.req.valid("param");
97
+ const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);
98
+ await new LabelModel(getStore().db).removeType(project.id, key);
99
+ return c.body(null, 204);
100
+ });
101
+ app.openapi(updateLabelRoute, async (c) => {
102
+ const { slug, key } = c.req.valid("param");
103
+ const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);
104
+ const body = c.req.valid("json");
105
+ try {
106
+ const updated = await new LabelModel(getStore().db).updateType(project.id, key, body);
107
+ if (!updated) throw new HTTPException(404, { message: "Label type not found" });
108
+ return c.json(updated);
109
+ } catch (error) {
110
+ if (error instanceof HTTPException) throw error;
111
+ throw new HTTPException(400, { message: error instanceof Error ? error.message : "Cannot update label type" });
112
+ }
113
+ });
114
+ }
115
+ //#endregion
116
+ export { registerLabels };
117
+
118
+ //# sourceMappingURL=labels.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"labels.mjs","names":[],"sources":["../../src/routers/labels.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport {\n labelTypeCreateSchema,\n labelTypeSchema,\n labelTypeUpdateSchema,\n notFound,\n unauthorized,\n} from \"./schemas.ts\";\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nconst ADMIN_ROLES: readonly ProjectRole[] = [\"admin\"];\n\nconst listLabelsRoute = createRoute({\n method: \"get\",\n tags: [\"Labels\"],\n summary: \"List label types\",\n path: \"/api/v1/projects/{slug}/label-types\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: labelTypeSchema.array() } },\n description: \"List label types\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst createLabelRoute = createRoute({\n method: \"post\",\n tags: [\"Labels\"],\n summary: \"Create a label type\",\n path: \"/api/v1/projects/{slug}/label-types\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: labelTypeCreateSchema } } },\n },\n responses: {\n 201: {\n content: { \"application/json\": { schema: labelTypeSchema } },\n description: \"Created label type\",\n },\n ...notFound,\n },\n});\n\nconst deleteLabelRoute = createRoute({\n method: \"delete\",\n tags: [\"Labels\"],\n summary: \"Delete a label type\",\n path: \"/api/v1/projects/{slug}/label-types/{key}\",\n request: { params: z.object({ slug: z.string(), key: z.string() }) },\n responses: {\n 204: { description: \"Deleted label type\" },\n ...notFound,\n },\n});\n\nconst updateLabelRoute = createRoute({\n method: \"patch\",\n tags: [\"Labels\"],\n summary: \"Update a label type\",\n path: \"/api/v1/projects/{slug}/label-types/{key}\",\n request: {\n params: z.object({ slug: z.string(), key: z.string() }),\n body: { content: { \"application/json\": { schema: labelTypeUpdateSchema } } },\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: labelTypeSchema } },\n description: \"Updated label type\",\n },\n ...notFound,\n },\n});\n\n/** Register the project label-type list, create, update, and delete endpoints. */\nexport function registerLabels(app: ShelfRouter): void {\n app.openapi(listLabelsRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n return c.json(await new LabelModel(getStore().db).listTypes(project.id));\n });\n\n app.openapi(createLabelRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n return c.json(await new LabelModel(getStore().db).createType(project.id, body), 201);\n });\n\n app.openapi(deleteLabelRoute, async (c) => {\n const { slug, key } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n await new LabelModel(getStore().db).removeType(project.id, key);\n return c.body(null, 204);\n });\n\n app.openapi(updateLabelRoute, async (c) => {\n const { slug, key } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n try {\n const updated = await new LabelModel(getStore().db).updateType(project.id, key, body);\n if (!updated) {\n throw new HTTPException(404, { message: \"Label type not found\" });\n }\n return c.json(updated);\n } catch (error) {\n if (error instanceof HTTPException) {\n throw error;\n }\n throw new HTTPException(400, {\n message: error instanceof Error ? error.message : \"Cannot update label type\",\n });\n }\n });\n}\n"],"mappings":"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"}
@@ -0,0 +1,53 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { notFound, resolveAuthorizedProject } from "./helpers.mjs";
6
+ import { BaselineModel, BuildModel, SnapshotModel } from "@storyshelf/core/models";
7
+ //#region src/routers/media.ts
8
+ const VIEW_ROLES = [
9
+ "viewer",
10
+ "developer",
11
+ "approver",
12
+ "admin"
13
+ ];
14
+ const PNG = "image/png";
15
+ function imageResponse(buffer) {
16
+ return new Response(new Uint8Array(buffer), { headers: {
17
+ "content-type": PNG,
18
+ "cache-control": "private, max-age=3600"
19
+ } });
20
+ }
21
+ /** Register the PNG image, diff, and baseline media endpoints for snapshots. */
22
+ function registerMedia(app) {
23
+ app.get("/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/image", async (c) => {
24
+ const project = await resolveAuthorizedProject(c, c.req.param("slug"), ...VIEW_ROLES);
25
+ const snapshot = await findSnapshot(c.req.param("buildId"), c.req.param("snapshotId"), project.id);
26
+ return imageResponse(await getStore().storage.read(snapshot.screenshotPath));
27
+ });
28
+ app.get("/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/diff", async (c) => {
29
+ const project = await resolveAuthorizedProject(c, c.req.param("slug"), ...VIEW_ROLES);
30
+ const snapshot = await findSnapshot(c.req.param("buildId"), c.req.param("snapshotId"), project.id);
31
+ if (!snapshot.diffPath) notFound("No diff for this snapshot");
32
+ return imageResponse(await getStore().storage.read(snapshot.diffPath));
33
+ });
34
+ app.get("/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/baseline", async (c) => {
35
+ const project = await resolveAuthorizedProject(c, c.req.param("slug"), ...VIEW_ROLES);
36
+ const snapshot = await findSnapshot(c.req.param("buildId"), c.req.param("snapshotId"), project.id);
37
+ const build = await new BuildModel(getStore().db).get(snapshot.buildId);
38
+ if (!build) notFound("Build not found");
39
+ const baselines = new BaselineModel(getStore().db, void 0, getStore().storage);
40
+ const baseline = await baselines.resolve(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, project.gitDefaultBranch);
41
+ if (!baseline) notFound("No baseline for this snapshot");
42
+ return imageResponse(await baselines.read(baseline));
43
+ });
44
+ }
45
+ async function findSnapshot(buildId, snapshotId, projectId) {
46
+ const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);
47
+ if (!snapshot || snapshot.buildId !== buildId || snapshot.projectId !== projectId) notFound("Snapshot not found");
48
+ return snapshot;
49
+ }
50
+ //#endregion
51
+ export { registerMedia };
52
+
53
+ //# sourceMappingURL=media.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"media.mjs","names":[],"sources":["../../src/routers/media.ts"],"sourcesContent":["import { BaselineModel } from \"@storyshelf/core/models\";\nimport { BuildModel } from \"@storyshelf/core/models\";\nimport { SnapshotModel } from \"@storyshelf/core/models\";\nimport type { Snapshot } from \"@storyshelf/core/schema\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { notFound, resolveAuthorizedProject } from \"./helpers.ts\";\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\n\nconst PNG = \"image/png\";\n\nfunction imageResponse(buffer: Buffer): Response {\n return new Response(new Uint8Array(buffer), {\n headers: { \"content-type\": PNG, \"cache-control\": \"private, max-age=3600\" },\n });\n}\n\n/** Register the PNG image, diff, and baseline media endpoints for snapshots. */\nexport function registerMedia(app: ShelfRouter): void {\n app.get(\"/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/image\", async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.param(\"slug\"), ...VIEW_ROLES);\n const snapshot = await findSnapshot(\n c.req.param(\"buildId\"),\n c.req.param(\"snapshotId\"),\n project.id,\n );\n return imageResponse(await getStore().storage.read(snapshot.screenshotPath));\n });\n\n app.get(\"/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/diff\", async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.param(\"slug\"), ...VIEW_ROLES);\n const snapshot = await findSnapshot(\n c.req.param(\"buildId\"),\n c.req.param(\"snapshotId\"),\n project.id,\n );\n if (!snapshot.diffPath) {\n notFound(\"No diff for this snapshot\");\n }\n return imageResponse(await getStore().storage.read(snapshot.diffPath));\n });\n\n app.get(\"/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/baseline\", async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.param(\"slug\"), ...VIEW_ROLES);\n const snapshot = await findSnapshot(\n c.req.param(\"buildId\"),\n c.req.param(\"snapshotId\"),\n project.id,\n );\n const build = await new BuildModel(getStore().db).get(snapshot.buildId);\n if (!build) {\n notFound(\"Build not found\");\n }\n const baselines = new BaselineModel(getStore().db, undefined, getStore().storage);\n const baseline = await baselines.resolve(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n project.gitDefaultBranch,\n );\n if (!baseline) {\n notFound(\"No baseline for this snapshot\");\n }\n return imageResponse(await baselines.read(baseline));\n });\n}\n\nasync function findSnapshot(\n buildId: string,\n snapshotId: string,\n projectId: string,\n): Promise<Snapshot> {\n const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);\n if (!snapshot || snapshot.buildId !== buildId || snapshot.projectId !== projectId) {\n notFound(\"Snapshot not found\");\n }\n return snapshot;\n}\n"],"mappings":";;;;;;;AAQA,MAAM,aAAqC;CAAC;CAAU;CAAa;CAAY;AAAO;AAEtF,MAAM,MAAM;AAEZ,SAAS,cAAc,QAA0B;CAC/C,OAAO,IAAI,SAAS,IAAI,WAAW,MAAM,GAAG,EAC1C,SAAS;EAAE,gBAAgB;EAAK,iBAAiB;CAAwB,EAC3E,CAAC;AACH;;AAGA,SAAgB,cAAc,KAAwB;CACpD,IAAI,IAAI,sEAAsE,OAAO,MAAM;EACzF,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,MAAM,GAAG,GAAG,UAAU;EACpF,MAAM,WAAW,MAAM,aACrB,EAAE,IAAI,MAAM,SAAS,GACrB,EAAE,IAAI,MAAM,YAAY,GACxB,QAAQ,EACV;EACA,OAAO,cAAc,MAAM,SAAS,CAAC,CAAC,QAAQ,KAAK,SAAS,cAAc,CAAC;CAC7E,CAAC;CAED,IAAI,IAAI,qEAAqE,OAAO,MAAM;EACxF,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,MAAM,GAAG,GAAG,UAAU;EACpF,MAAM,WAAW,MAAM,aACrB,EAAE,IAAI,MAAM,SAAS,GACrB,EAAE,IAAI,MAAM,YAAY,GACxB,QAAQ,EACV;EACA,IAAI,CAAC,SAAS,UACZ,SAAS,2BAA2B;EAEtC,OAAO,cAAc,MAAM,SAAS,CAAC,CAAC,QAAQ,KAAK,SAAS,QAAQ,CAAC;CACvE,CAAC;CAED,IAAI,IAAI,yEAAyE,OAAO,MAAM;EAC5F,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,MAAM,GAAG,GAAG,UAAU;EACpF,MAAM,WAAW,MAAM,aACrB,EAAE,IAAI,MAAM,SAAS,GACrB,EAAE,IAAI,MAAM,YAAY,GACxB,QAAQ,EACV;EACA,MAAM,QAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,SAAS,OAAO;EACtE,IAAI,CAAC,OACH,SAAS,iBAAiB;EAE5B,MAAM,YAAY,IAAI,cAAc,SAAS,CAAC,CAAC,IAAI,KAAA,GAAW,SAAS,CAAC,CAAC,OAAO;EAChF,MAAM,WAAW,MAAM,UAAU,QAC/B,QAAQ,IACR,SAAS,SACT,SAAS,cACT,MAAM,WACN,QAAQ,gBACV;EACA,IAAI,CAAC,UACH,SAAS,+BAA+B;EAE1C,OAAO,cAAc,MAAM,UAAU,KAAK,QAAQ,CAAC;CACrD,CAAC;AACH;AAEA,eAAe,aACb,SACA,YACA,WACmB;CACnB,MAAM,WAAW,MAAM,IAAI,cAAc,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,UAAU;CACtE,IAAI,CAAC,YAAY,SAAS,YAAY,WAAW,SAAS,cAAc,WACtE,SAAS,oBAAoB;CAE/B,OAAO;AACT"}