@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,113 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { notifySystem } from "../notify.mjs";
6
+ import { reencryptWithCurrent } from "../credentials.mjs";
7
+ import { requireSiteAdmin } from "./helpers.mjs";
8
+ import { forbidden, purgeInputSchema, purgeSchema } from "./schemas.mjs";
9
+ import { createRoute, z } from "@hono/zod-openapi";
10
+ import { ProjectModel } from "@storyshelf/core/models";
11
+ import { Retention } from "@storyshelf/core/retention";
12
+ //#region src/routers/admin.ts
13
+ const purgeRoute = createRoute({
14
+ method: "post",
15
+ tags: ["Admin"],
16
+ summary: "Run the retention purge",
17
+ path: "/api/v1/admin/purge",
18
+ request: { body: { content: { "application/json": { schema: purgeInputSchema } } } },
19
+ responses: {
20
+ 200: {
21
+ content: { "application/json": { schema: purgeSchema } },
22
+ description: "Retention purge run"
23
+ },
24
+ ...forbidden
25
+ }
26
+ });
27
+ const reencryptSchema = z.object({
28
+ reencrypted: z.number().int(),
29
+ failed: z.number().int(),
30
+ unreadable: z.number().int()
31
+ });
32
+ const reencryptRoute = createRoute({
33
+ method: "post",
34
+ path: "/api/v1/admin/credentials/reencrypt",
35
+ tags: ["Admin"],
36
+ summary: "Re-encrypt stored credentials with the current secret",
37
+ responses: {
38
+ 200: {
39
+ content: { "application/json": { schema: reencryptSchema } },
40
+ description: "Credentials re-encrypted with the current secret"
41
+ },
42
+ 409: {
43
+ content: { "application/json": { schema: z.object({ error: z.string() }) } },
44
+ description: "No previousSecret configured"
45
+ },
46
+ ...forbidden
47
+ }
48
+ });
49
+ /** Register the site-admin endpoints: retention purge and credential re-encryption. */
50
+ function registerAdmin(app) {
51
+ app.openapi(reencryptRoute, async (c) => {
52
+ requireSiteAdmin(c);
53
+ const { db, config } = getStore();
54
+ if (!config.previousSecret) return c.json({ error: "No previousSecret is configured; nothing to re-encrypt" }, 409);
55
+ const result = await reencryptWithCurrent(db, config);
56
+ return c.json({
57
+ reencrypted: result.reencrypted,
58
+ failed: result.failed,
59
+ unreadable: result.unreadable.length
60
+ }, 200);
61
+ });
62
+ app.openapi(purgeRoute, async (c) => {
63
+ requireSiteAdmin(c);
64
+ const ttlDays = c.req.valid("json").ttlDays ?? getStore().config.purgeTtlDays ?? 30;
65
+ const removedBuilds = await purgeBuilds(ttlDays);
66
+ const branch = await purgeBranches();
67
+ await notifySystem("sys:purge-completed", {
68
+ removedBuilds,
69
+ removedBranches: branch.removedBranches,
70
+ removedBaselines: branch.removedBaselines,
71
+ ttlDays
72
+ });
73
+ return c.json({
74
+ removedBuilds,
75
+ removedBranches: branch.removedBranches,
76
+ removedBaselines: branch.removedBaselines
77
+ });
78
+ });
79
+ }
80
+ async function purgeBuilds(ttlDays) {
81
+ const projects = await new ProjectModel(getStore().db).list();
82
+ const retention = new Retention(getStore().db, getStore().storage, {
83
+ builds: getStore().db.tables.builds,
84
+ buildLabels: getStore().db.tables.buildLabels,
85
+ baselines: getStore().db.tables.baselines
86
+ }, getStore().logger);
87
+ return (await Promise.all(projects.map((project) => retention.purge(project, {
88
+ ttlDays,
89
+ keepLatestPerBranch: true
90
+ })))).reduce((sum, result) => sum + result.removedBuilds, 0);
91
+ }
92
+ async function purgeBranches() {
93
+ const branchTtl = getStore().config.branchTtlDays ?? 30;
94
+ if (branchTtl === null) return {
95
+ removedBranches: 0,
96
+ removedBaselines: 0
97
+ };
98
+ const projects = await new ProjectModel(getStore().db).list();
99
+ const retention = new Retention(getStore().db, getStore().storage, {
100
+ builds: getStore().db.tables.builds,
101
+ buildLabels: getStore().db.tables.buildLabels,
102
+ baselines: getStore().db.tables.baselines
103
+ }, getStore().logger);
104
+ const results = await Promise.all(projects.map((project) => retention.purgeStaleBranches(project, branchTtl)));
105
+ return {
106
+ removedBranches: results.reduce((sum, r) => sum + r.removedBranches, 0),
107
+ removedBaselines: results.reduce((sum, r) => sum + r.removedBaselines, 0)
108
+ };
109
+ }
110
+ //#endregion
111
+ export { registerAdmin };
112
+
113
+ //# sourceMappingURL=admin.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin.mjs","names":["forbiddenResponse"],"sources":["../../src/routers/admin.ts"],"sourcesContent":["/* oxlint-disable typescript/promise-function-async -- admin helpers are async by design */\nimport { createRoute, z } from \"@hono/zod-openapi\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport { Retention } from \"@storyshelf/core/retention\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { reencryptWithCurrent } from \"../credentials.ts\";\nimport { notifySystem } from \"../notify.ts\";\nimport { getStore } from \"../store.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\nimport { forbidden as forbiddenResponse, purgeInputSchema, purgeSchema } from \"./schemas.ts\";\nconst purgeRoute = createRoute({\n method: \"post\",\n tags: [\"Admin\"],\n summary: \"Run the retention purge\",\n path: \"/api/v1/admin/purge\",\n request: { body: { content: { \"application/json\": { schema: purgeInputSchema } } } },\n responses: {\n 200: {\n content: { \"application/json\": { schema: purgeSchema } },\n description: \"Retention purge run\",\n },\n ...forbiddenResponse,\n },\n});\n\nconst reencryptSchema = z.object({\n reencrypted: z.number().int(),\n failed: z.number().int(),\n unreadable: z.number().int(),\n});\n\nconst reencryptRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/admin/credentials/reencrypt\",\n tags: [\"Admin\"],\n summary: \"Re-encrypt stored credentials with the current secret\",\n responses: {\n 200: {\n content: { \"application/json\": { schema: reencryptSchema } },\n description: \"Credentials re-encrypted with the current secret\",\n },\n 409: {\n content: { \"application/json\": { schema: z.object({ error: z.string() }) } },\n description: \"No previousSecret configured\",\n },\n ...forbiddenResponse,\n },\n});\n\n/** Register the site-admin endpoints: retention purge and credential re-encryption. */\nexport function registerAdmin(app: ShelfRouter): void {\n app.openapi(reencryptRoute, async (c) => {\n requireSiteAdmin(c);\n const { db, config } = getStore();\n if (!config.previousSecret) {\n return c.json({ error: \"No previousSecret is configured; nothing to re-encrypt\" }, 409);\n }\n const result = await reencryptWithCurrent(db, config);\n return c.json(\n {\n reencrypted: result.reencrypted,\n failed: result.failed,\n unreadable: result.unreadable.length,\n },\n 200,\n );\n });\n\n app.openapi(purgeRoute, async (c) => {\n requireSiteAdmin(c);\n const body = c.req.valid(\"json\");\n const ttlDays = body.ttlDays ?? getStore().config.purgeTtlDays ?? 30;\n const removedBuilds = await purgeBuilds(ttlDays);\n const branch = await purgeBranches();\n await notifySystem(\"sys:purge-completed\", {\n removedBuilds,\n removedBranches: branch.removedBranches,\n removedBaselines: branch.removedBaselines,\n ttlDays,\n });\n return c.json({\n removedBuilds,\n removedBranches: branch.removedBranches,\n removedBaselines: branch.removedBaselines,\n });\n });\n}\n\n// oxlint-disable-next-line typescript/promise-function-async\nasync function purgeBuilds(ttlDays: number): Promise<number> {\n const projects = await new ProjectModel(getStore().db).list();\n const retention = new Retention(\n getStore().db,\n getStore().storage,\n {\n builds: getStore().db.tables.builds,\n buildLabels: getStore().db.tables.buildLabels,\n baselines: getStore().db.tables.baselines,\n },\n getStore().logger,\n );\n const results = await Promise.all(\n projects.map((project) => retention.purge(project, { ttlDays, keepLatestPerBranch: true })),\n );\n return results.reduce((sum, result) => sum + result.removedBuilds, 0);\n}\n\n// oxlint-disable-next-line typescript/promise-function-async\nasync function purgeBranches(): Promise<{ removedBranches: number; removedBaselines: number }> {\n const branchTtl = getStore().config.branchTtlDays ?? 30;\n if (branchTtl === null) {\n return { removedBranches: 0, removedBaselines: 0 };\n }\n const projects = await new ProjectModel(getStore().db).list();\n const retention = new Retention(\n getStore().db,\n getStore().storage,\n {\n builds: getStore().db.tables.builds,\n buildLabels: getStore().db.tables.buildLabels,\n baselines: getStore().db.tables.baselines,\n },\n getStore().logger,\n );\n const results = await Promise.all(\n projects.map((project) => retention.purgeStaleBranches(project, branchTtl)),\n );\n const removedBranches = results.reduce(\n (sum: number, r: { removedBranches: number }) => sum + r.removedBranches,\n 0,\n );\n const removedBaselines = results.reduce(\n (sum: number, r: { removedBaselines: number }) => sum + r.removedBaselines,\n 0,\n );\n return { removedBranches, removedBaselines };\n}\n"],"mappings":"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"}
@@ -0,0 +1,64 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { assetManifest } from "../asset-manifest.mjs";
5
+ //#region src/routers/assets.ts
6
+ const IMMUTABLE = "public, max-age=31536000, immutable";
7
+ /**
8
+ * Serve a content-addressed asset. A matching hash is cacheable forever and
9
+ * answers conditional requests with 304; a stale hash (an old page still
10
+ * open after an upgrade) gets the current content but is never cached under
11
+ * the old URL.
12
+ */
13
+ function serveHashed(c, asset, requestedHash) {
14
+ const etag = `"${asset.hash}"`;
15
+ if (requestedHash !== asset.hash) return c.body(asset.source, 200, {
16
+ "content-type": asset.contentType,
17
+ "cache-control": "no-store"
18
+ });
19
+ if (c.req.header("if-none-match") === etag) return c.body(null, 304, {
20
+ etag,
21
+ "cache-control": IMMUTABLE
22
+ });
23
+ return c.body(asset.source, 200, {
24
+ "content-type": asset.contentType,
25
+ "cache-control": IMMUTABLE,
26
+ etag
27
+ });
28
+ }
29
+ /** Register the static-asset routes (HTMX bundle and icon sprite). */
30
+ function registerAssets(app) {
31
+ const { htmx, icons, mark } = assetManifest;
32
+ app.get(String.raw`/assets/:file{htmx-[a-f0-9]+\.js}`, (c) => {
33
+ const hash = (c.req.param("file") ?? "").replaceAll(/^htmx-|\.js$/gu, "");
34
+ return serveHashed(c, {
35
+ ...htmx,
36
+ contentType: "application/javascript"
37
+ }, hash);
38
+ });
39
+ app.get(String.raw`/assets/:file{icons-[a-f0-9]+\.svg}`, (c) => {
40
+ const hash = (c.req.param("file") ?? "").replaceAll(/^icons-|\.svg$/gu, "");
41
+ return serveHashed(c, {
42
+ ...icons,
43
+ contentType: "image/svg+xml"
44
+ }, hash);
45
+ });
46
+ app.get(String.raw`/assets/:file{mark-[a-f0-9]+\.svg}`, (c) => {
47
+ const hash = (c.req.param("file") ?? "").replaceAll(/^mark-|\.svg$/gu, "");
48
+ return serveHashed(c, {
49
+ ...mark,
50
+ contentType: "image/svg+xml"
51
+ }, hash);
52
+ });
53
+ app.get("/assets/htmx.js", (c) => {
54
+ return c.body(htmx.source, 200, {
55
+ "content-type": "application/javascript",
56
+ "cache-control": "no-cache",
57
+ etag: `"${htmx.hash}"`
58
+ });
59
+ });
60
+ }
61
+ //#endregion
62
+ export { registerAssets };
63
+
64
+ //# sourceMappingURL=assets.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assets.mjs","names":[],"sources":["../../src/routers/assets.ts"],"sourcesContent":["import type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { assetManifest } from \"../asset-manifest.ts\";\n\nconst IMMUTABLE = \"public, max-age=31536000, immutable\";\n\ninterface Asset {\n source: string;\n hash: string;\n contentType: string;\n}\n\n/**\n * Serve a content-addressed asset. A matching hash is cacheable forever and\n * answers conditional requests with 304; a stale hash (an old page still\n * open after an upgrade) gets the current content but is never cached under\n * the old URL.\n */\nfunction serveHashed(c: Context, asset: Asset, requestedHash: string): Response {\n const etag = `\"${asset.hash}\"`;\n if (requestedHash !== asset.hash) {\n return c.body(asset.source, 200, {\n \"content-type\": asset.contentType,\n \"cache-control\": \"no-store\",\n });\n }\n if (c.req.header(\"if-none-match\") === etag) {\n return c.body(null, 304, { etag, \"cache-control\": IMMUTABLE });\n }\n return c.body(asset.source, 200, {\n \"content-type\": asset.contentType,\n \"cache-control\": IMMUTABLE,\n etag,\n });\n}\n\n/** Register the static-asset routes (HTMX bundle and icon sprite). */\nexport function registerAssets(app: ShelfRouter): void {\n const { htmx, icons, mark } = assetManifest;\n app.get(String.raw`/assets/:file{htmx-[a-f0-9]+\\.js}`, (c) => {\n const hash = (c.req.param(\"file\") ?? \"\").replaceAll(/^htmx-|\\.js$/gu, \"\");\n return serveHashed(c, { ...htmx, contentType: \"application/javascript\" }, hash);\n });\n app.get(String.raw`/assets/:file{icons-[a-f0-9]+\\.svg}`, (c) => {\n const hash = (c.req.param(\"file\") ?? \"\").replaceAll(/^icons-|\\.svg$/gu, \"\");\n return serveHashed(c, { ...icons, contentType: \"image/svg+xml\" }, hash);\n });\n app.get(String.raw`/assets/:file{mark-[a-f0-9]+\\.svg}`, (c) => {\n const hash = (c.req.param(\"file\") ?? \"\").replaceAll(/^mark-|\\.svg$/gu, \"\");\n return serveHashed(c, { ...mark, contentType: \"image/svg+xml\" }, hash);\n });\n // Legacy unversioned URL: kept for old pages and external embeds, but\n // revalidated (never immutable) because its content can change.\n app.get(\"/assets/htmx.js\", (c) => {\n return c.body(htmx.source, 200, {\n \"content-type\": \"application/javascript\",\n \"cache-control\": \"no-cache\",\n etag: `\"${htmx.hash}\"`,\n });\n });\n}\n"],"mappings":";;;;;AAIA,MAAM,YAAY;;;;;;;AAclB,SAAS,YAAY,GAAY,OAAc,eAAiC;CAC9E,MAAM,OAAO,IAAI,MAAM,KAAK;CAC5B,IAAI,kBAAkB,MAAM,MAC1B,OAAO,EAAE,KAAK,MAAM,QAAQ,KAAK;EAC/B,gBAAgB,MAAM;EACtB,iBAAiB;CACnB,CAAC;CAEH,IAAI,EAAE,IAAI,OAAO,eAAe,MAAM,MACpC,OAAO,EAAE,KAAK,MAAM,KAAK;EAAE;EAAM,iBAAiB;CAAU,CAAC;CAE/D,OAAO,EAAE,KAAK,MAAM,QAAQ,KAAK;EAC/B,gBAAgB,MAAM;EACtB,iBAAiB;EACjB;CACF,CAAC;AACH;;AAGA,SAAgB,eAAe,KAAwB;CACrD,MAAM,EAAE,MAAM,OAAO,SAAS;CAC9B,IAAI,IAAI,OAAO,GAAG,sCAAsC,MAAM;EAC5D,MAAM,QAAQ,EAAE,IAAI,MAAM,MAAM,KAAK,GAAA,CAAI,WAAW,kBAAkB,EAAE;EACxE,OAAO,YAAY,GAAG;GAAE,GAAG;GAAM,aAAa;EAAyB,GAAG,IAAI;CAChF,CAAC;CACD,IAAI,IAAI,OAAO,GAAG,wCAAwC,MAAM;EAC9D,MAAM,QAAQ,EAAE,IAAI,MAAM,MAAM,KAAK,GAAA,CAAI,WAAW,oBAAoB,EAAE;EAC1E,OAAO,YAAY,GAAG;GAAE,GAAG;GAAO,aAAa;EAAgB,GAAG,IAAI;CACxE,CAAC;CACD,IAAI,IAAI,OAAO,GAAG,uCAAuC,MAAM;EAC7D,MAAM,QAAQ,EAAE,IAAI,MAAM,MAAM,KAAK,GAAA,CAAI,WAAW,mBAAmB,EAAE;EACzE,OAAO,YAAY,GAAG;GAAE,GAAG;GAAM,aAAa;EAAgB,GAAG,IAAI;CACvE,CAAC;CAGD,IAAI,IAAI,oBAAoB,MAAM;EAChC,OAAO,EAAE,KAAK,KAAK,QAAQ,KAAK;GAC9B,gBAAgB;GAChB,iBAAiB;GACjB,MAAM,IAAI,KAAK,KAAK;EACtB,CAAC;CACH,CAAC;AACH"}
@@ -0,0 +1,96 @@
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 { attemptLogSchema, attemptSchema, notFound as notFound$1, unauthorized } from "./schemas.mjs";
7
+ import { VIEW_ROLES, buildForProject } from "./builds.handlers.mjs";
8
+ import { createRoute, z } from "@hono/zod-openapi";
9
+ import { CaptureAttemptModel, CaptureLogModel } from "@storyshelf/core/models";
10
+ //#region src/routers/attempts.ts
11
+ /** Register the capture-attempt history and per-attempt log endpoints. */
12
+ function registerAttempts(app) {
13
+ app.openapi(listAttemptsRoute, async (c) => {
14
+ const build = await scopedBuild(c, c.req.valid("param"));
15
+ const attempts = await new CaptureAttemptModel(getStore().db).listByBuild(build.id);
16
+ return c.json(attempts);
17
+ });
18
+ app.openapi(getAttemptRoute, async (c) => {
19
+ const build = await scopedBuild(c, c.req.valid("param"));
20
+ return c.json(await attemptForBuild(build, c.req.valid("param").attemptNo));
21
+ });
22
+ app.openapi(listAttemptLogsRoute, async (c) => {
23
+ const attempt = await attemptForBuild(await scopedBuild(c, c.req.valid("param")), c.req.valid("param").attemptNo);
24
+ const logs = await new CaptureLogModel(getStore().db).listByAttempt(attempt.id);
25
+ return c.json(logs);
26
+ });
27
+ }
28
+ const buildParams = z.object({
29
+ slug: z.string(),
30
+ buildId: z.string()
31
+ });
32
+ const attemptParams = z.object({
33
+ slug: z.string(),
34
+ buildId: z.string(),
35
+ attemptNo: z.coerce.number().int().min(1)
36
+ });
37
+ const listAttemptsRoute = createRoute({
38
+ method: "get",
39
+ tags: ["Builds"],
40
+ summary: "List capture attempts",
41
+ path: "/api/v1/projects/{slug}/builds/{buildId}/attempts",
42
+ request: { params: buildParams },
43
+ responses: {
44
+ 200: {
45
+ content: { "application/json": { schema: attemptSchema.array() } },
46
+ description: "List capture attempts for a build (oldest first)"
47
+ },
48
+ ...notFound$1,
49
+ ...unauthorized
50
+ }
51
+ });
52
+ const getAttemptRoute = createRoute({
53
+ method: "get",
54
+ tags: ["Builds"],
55
+ summary: "Get a capture attempt",
56
+ path: "/api/v1/projects/{slug}/builds/{buildId}/attempts/{attemptNo}",
57
+ request: { params: attemptParams },
58
+ responses: {
59
+ 200: {
60
+ content: { "application/json": { schema: attemptSchema } },
61
+ description: "Fetch a single capture attempt"
62
+ },
63
+ ...notFound$1,
64
+ ...unauthorized
65
+ }
66
+ });
67
+ const listAttemptLogsRoute = createRoute({
68
+ method: "get",
69
+ tags: ["Builds"],
70
+ summary: "Get capture attempt logs",
71
+ path: "/api/v1/projects/{slug}/builds/{buildId}/attempts/{attemptNo}/logs",
72
+ request: { params: attemptParams },
73
+ responses: {
74
+ 200: {
75
+ content: { "application/json": { schema: attemptLogSchema.array() } },
76
+ description: "List every log line of a capture attempt (in order)"
77
+ },
78
+ ...notFound$1,
79
+ ...unauthorized
80
+ }
81
+ });
82
+ /** Resolve the project (viewer+) and the build scoped to it. */
83
+ async function scopedBuild(c, params) {
84
+ const project = await resolveAuthorizedProject(c, params.slug, ...VIEW_ROLES);
85
+ return await buildForProject(project.id, params.buildId);
86
+ }
87
+ /** Fetch an attempt scoped to its build, throwing 404 when it does not belong. */
88
+ async function attemptForBuild(build, attemptNo) {
89
+ const attempt = await new CaptureAttemptModel(getStore().db).getByNo(build.id, attemptNo);
90
+ if (!attempt) notFound("Attempt not found");
91
+ return attempt;
92
+ }
93
+ //#endregion
94
+ export { registerAttempts };
95
+
96
+ //# sourceMappingURL=attempts.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attempts.mjs","names":["notFoundResponse"],"sources":["../../src/routers/attempts.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { CaptureAttemptModel } from \"@storyshelf/core/models\";\nimport { CaptureLogModel } from \"@storyshelf/core/models\";\nimport type { Build, CaptureAttempt } from \"@storyshelf/core/schema\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { VIEW_ROLES, buildForProject } from \"./builds.handlers.ts\";\nimport { notFound, resolveAuthorizedProject } from \"./helpers.ts\";\nimport { notFound as notFoundResponse, unauthorized } from \"./schemas.ts\";\nimport { attemptLogSchema, attemptSchema } from \"./schemas.ts\";\n\n/** Register the capture-attempt history and per-attempt log endpoints. */\nexport function registerAttempts(app: ShelfRouter): void {\n app.openapi(listAttemptsRoute, async (c) => {\n const build = await scopedBuild(c, c.req.valid(\"param\"));\n const attempts = await new CaptureAttemptModel(getStore().db).listByBuild(build.id);\n return c.json(attempts);\n });\n\n app.openapi(getAttemptRoute, async (c) => {\n const build = await scopedBuild(c, c.req.valid(\"param\"));\n return c.json(await attemptForBuild(build, c.req.valid(\"param\").attemptNo));\n });\n\n app.openapi(listAttemptLogsRoute, async (c) => {\n const build = await scopedBuild(c, c.req.valid(\"param\"));\n const attempt = await attemptForBuild(build, c.req.valid(\"param\").attemptNo);\n const logs = await new CaptureLogModel(getStore().db).listByAttempt(attempt.id);\n return c.json(logs);\n });\n}\n\nconst buildParams = z.object({ slug: z.string(), buildId: z.string() });\n\nconst attemptParams = z.object({\n slug: z.string(),\n buildId: z.string(),\n attemptNo: z.coerce.number().int().min(1),\n});\n\nconst listAttemptsRoute = createRoute({\n method: \"get\",\n tags: [\"Builds\"],\n summary: \"List capture attempts\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/attempts\",\n request: { params: buildParams },\n responses: {\n 200: {\n content: { \"application/json\": { schema: attemptSchema.array() } },\n description: \"List capture attempts for a build (oldest first)\",\n },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst getAttemptRoute = createRoute({\n method: \"get\",\n tags: [\"Builds\"],\n summary: \"Get a capture attempt\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/attempts/{attemptNo}\",\n request: { params: attemptParams },\n responses: {\n 200: {\n content: { \"application/json\": { schema: attemptSchema } },\n description: \"Fetch a single capture attempt\",\n },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst listAttemptLogsRoute = createRoute({\n method: \"get\",\n tags: [\"Builds\"],\n summary: \"Get capture attempt logs\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/attempts/{attemptNo}/logs\",\n request: { params: attemptParams },\n responses: {\n 200: {\n content: { \"application/json\": { schema: attemptLogSchema.array() } },\n description: \"List every log line of a capture attempt (in order)\",\n },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\n/** Resolve the project (viewer+) and the build scoped to it. */\nasync function scopedBuild(c: Context, params: { slug: string; buildId: string }): Promise<Build> {\n const project = await resolveAuthorizedProject(c, params.slug, ...VIEW_ROLES);\n return await buildForProject(project.id, params.buildId);\n}\n\n/** Fetch an attempt scoped to its build, throwing 404 when it does not belong. */\nasync function attemptForBuild(build: Build, attemptNo: number): Promise<CaptureAttempt> {\n const attempt = await new CaptureAttemptModel(getStore().db).getByNo(build.id, attemptNo);\n if (!attempt) {\n notFound(\"Attempt not found\");\n }\n return attempt;\n}\n"],"mappings":";;;;;;;;;;;AAaA,SAAgB,iBAAiB,KAAwB;CACvD,IAAI,QAAQ,mBAAmB,OAAO,MAAM;EAC1C,MAAM,QAAQ,MAAM,YAAY,GAAG,EAAE,IAAI,MAAM,OAAO,CAAC;EACvD,MAAM,WAAW,MAAM,IAAI,oBAAoB,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE;EAClF,OAAO,EAAE,KAAK,QAAQ;CACxB,CAAC;CAED,IAAI,QAAQ,iBAAiB,OAAO,MAAM;EACxC,MAAM,QAAQ,MAAM,YAAY,GAAG,EAAE,IAAI,MAAM,OAAO,CAAC;EACvD,OAAO,EAAE,KAAK,MAAM,gBAAgB,OAAO,EAAE,IAAI,MAAM,OAAO,CAAC,CAAC,SAAS,CAAC;CAC5E,CAAC;CAED,IAAI,QAAQ,sBAAsB,OAAO,MAAM;EAE7C,MAAM,UAAU,MAAM,gBAAgB,MADlB,YAAY,GAAG,EAAE,IAAI,MAAM,OAAO,CAAC,GACV,EAAE,IAAI,MAAM,OAAO,CAAC,CAAC,SAAS;EAC3E,MAAM,OAAO,MAAM,IAAI,gBAAgB,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,cAAc,QAAQ,EAAE;EAC9E,OAAO,EAAE,KAAK,IAAI;CACpB,CAAC;AACH;AAEA,MAAM,cAAc,EAAE,OAAO;CAAE,MAAM,EAAE,OAAO;CAAG,SAAS,EAAE,OAAO;AAAE,CAAC;AAEtE,MAAM,gBAAgB,EAAE,OAAO;CAC7B,MAAM,EAAE,OAAO;CACf,SAAS,EAAE,OAAO;CAClB,WAAW,EAAE,OAAO,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC;AAC1C,CAAC;AAED,MAAM,oBAAoB,YAAY;CACpC,QAAQ;CACR,MAAM,CAAC,QAAQ;CACf,SAAS;CACT,MAAM;CACN,SAAS,EAAE,QAAQ,YAAY;CAC/B,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,MAAM,EAAE,EAAE;GACjE,aAAa;EACf;EACA,GAAGA;EACH,GAAG;CACL;AACF,CAAC;AAED,MAAM,kBAAkB,YAAY;CAClC,QAAQ;CACR,MAAM,CAAC,QAAQ;CACf,SAAS;CACT,MAAM;CACN,SAAS,EAAE,QAAQ,cAAc;CACjC,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,EAAE;GACzD,aAAa;EACf;EACA,GAAGA;EACH,GAAG;CACL;AACF,CAAC;AAED,MAAM,uBAAuB,YAAY;CACvC,QAAQ;CACR,MAAM,CAAC,QAAQ;CACf,SAAS;CACT,MAAM;CACN,SAAS,EAAE,QAAQ,cAAc;CACjC,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,iBAAiB,MAAM,EAAE,EAAE;GACpE,aAAa;EACf;EACA,GAAGA;EACH,GAAG;CACL;AACF,CAAC;;AAGD,eAAe,YAAY,GAAY,QAA2D;CAChG,MAAM,UAAU,MAAM,yBAAyB,GAAG,OAAO,MAAM,GAAG,UAAU;CAC5E,OAAO,MAAM,gBAAgB,QAAQ,IAAI,OAAO,OAAO;AACzD;;AAGA,eAAe,gBAAgB,OAAc,WAA4C;CACvF,MAAM,UAAU,MAAM,IAAI,oBAAoB,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,QAAQ,MAAM,IAAI,SAAS;CACxF,IAAI,CAAC,SACH,SAAS,mBAAmB;CAE9B,OAAO;AACT"}
@@ -0,0 +1,64 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { MemberModel, ProjectGroupMappingModel, ProjectModel, UserModel } from "@storyshelf/core/models";
5
+ //#region src/routers/auth-sync.ts
6
+ const ROLE_RANK = {
7
+ viewer: 0,
8
+ developer: 1,
9
+ approver: 2,
10
+ admin: 3
11
+ };
12
+ /**
13
+ * Persist the authenticated identity and reconcile OIDC group memberships.
14
+ *
15
+ * Always upserts the user row. Group mappings apply per project: the highest
16
+ * matched role wins (recorded as `oidc:<group>`); memberships from groups the
17
+ * user no longer matches are removed — manual grants are never touched.
18
+ */
19
+ async function syncLoginMemberships(db, _tables, user) {
20
+ await new UserModel(db).upsert({
21
+ id: user.id,
22
+ email: user.email,
23
+ name: user.name,
24
+ avatarUrl: user.avatarUrl ?? null,
25
+ role: user.role
26
+ });
27
+ const groups = user.groups ?? [];
28
+ const projects = await new ProjectModel(db).list();
29
+ const members = new MemberModel(db);
30
+ const mappings = new ProjectGroupMappingModel(db);
31
+ await Promise.all(projects.map((project) => syncProjectMembership(members, mappings, project.id, user.id, groups)));
32
+ }
33
+ /** Highest-ranked matched mapping, or null when nothing matches. */
34
+ function topMatch(rows, groups) {
35
+ let top = null;
36
+ for (const row of rows) {
37
+ if (!groups.includes(row.groupName)) continue;
38
+ if (!top || ROLE_RANK[row.role] > ROLE_RANK[top.role]) top = {
39
+ groupName: row.groupName,
40
+ role: row.role
41
+ };
42
+ }
43
+ return top;
44
+ }
45
+ /** Remove a synced grant the user no longer matches; manual grants survive. */
46
+ async function revokeIfStale(members, projectId, userId) {
47
+ const existing = await members.get(projectId, userId);
48
+ if (existing && existing.source !== "manual") await members.remove(projectId, userId);
49
+ }
50
+ async function syncProjectMembership(members, mappings, projectId, userId, groups) {
51
+ const rows = await mappings.list(projectId);
52
+ if (rows.length === 0) return;
53
+ const top = topMatch(rows, groups);
54
+ if (!top) {
55
+ await revokeIfStale(members, projectId, userId);
56
+ return;
57
+ }
58
+ const existing = await members.get(projectId, userId);
59
+ if (!existing || existing.role !== top.role || existing.source !== `oidc:${top.groupName}`) await members.set(projectId, userId, top.role, `oidc:${top.groupName}`);
60
+ }
61
+ //#endregion
62
+ export { syncLoginMemberships };
63
+
64
+ //# sourceMappingURL=auth-sync.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-sync.mjs","names":[],"sources":["../../src/routers/auth-sync.ts"],"sourcesContent":["import type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport {\n MemberModel,\n ProjectGroupMappingModel,\n ProjectModel,\n UserModel,\n} from \"@storyshelf/core/models\";\nimport type { AuthUser } from \"@storyshelf/core/types\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport type { Table } from \"drizzle-orm\";\n\n/** Tables required to sync a login. */\nexport interface LoginSyncTables {\n users: Table;\n projects: Table;\n projectMembers: Table;\n projectGroupMappings: Table;\n}\n\nconst ROLE_RANK: Record<ProjectRole, number> = {\n viewer: 0,\n developer: 1,\n approver: 2,\n admin: 3,\n};\n\n/**\n * Persist the authenticated identity and reconcile OIDC group memberships.\n *\n * Always upserts the user row. Group mappings apply per project: the highest\n * matched role wins (recorded as `oidc:<group>`); memberships from groups the\n * user no longer matches are removed — manual grants are never touched.\n */\nexport async function syncLoginMemberships(\n db: DatabaseAdapter,\n _tables: LoginSyncTables,\n user: AuthUser,\n): Promise<void> {\n await new UserModel(db).upsert({\n id: user.id,\n email: user.email,\n name: user.name,\n avatarUrl: user.avatarUrl ?? null,\n role: user.role,\n });\n const groups = user.groups ?? [];\n const projects = await new ProjectModel(db).list();\n const members = new MemberModel(db);\n const mappings = new ProjectGroupMappingModel(db);\n await Promise.all(\n projects.map(\n // oxlint-disable-next-line typescript/promise-function-async -- returns the inner promise directly\n (project) => syncProjectMembership(members, mappings, project.id, user.id, groups),\n ),\n );\n}\n\n/** Highest-ranked matched mapping, or null when nothing matches. */\nfunction topMatch(\n rows: { groupName: string; role: ProjectRole }[],\n groups: string[],\n): { groupName: string; role: ProjectRole } | null {\n let top: { groupName: string; role: ProjectRole } | null = null;\n for (const row of rows) {\n if (!groups.includes(row.groupName)) {\n continue;\n }\n if (!top || ROLE_RANK[row.role] > ROLE_RANK[top.role]) {\n top = { groupName: row.groupName, role: row.role };\n }\n }\n return top;\n}\n\n/** Remove a synced grant the user no longer matches; manual grants survive. */\nasync function revokeIfStale(\n members: MemberModel,\n projectId: string,\n userId: string,\n): Promise<void> {\n const existing = await members.get(projectId, userId);\n if (existing && existing.source !== \"manual\") {\n await members.remove(projectId, userId);\n }\n}\n\nasync function syncProjectMembership(\n members: MemberModel,\n mappings: ProjectGroupMappingModel,\n projectId: string,\n userId: string,\n groups: string[],\n): Promise<void> {\n const rows = await mappings.list(projectId);\n if (rows.length === 0) {\n return;\n }\n const top = topMatch(rows, groups);\n if (!top) {\n await revokeIfStale(members, projectId, userId);\n return;\n }\n const existing = await members.get(projectId, userId);\n if (!existing || existing.role !== top.role || existing.source !== `oidc:${top.groupName}`) {\n await members.set(projectId, userId, top.role, `oidc:${top.groupName}`);\n }\n}\n"],"mappings":";;;;;AAmBA,MAAM,YAAyC;CAC7C,QAAQ;CACR,WAAW;CACX,UAAU;CACV,OAAO;AACT;;;;;;;;AASA,eAAsB,qBACpB,IACA,SACA,MACe;CACf,MAAM,IAAI,UAAU,EAAE,CAAC,CAAC,OAAO;EAC7B,IAAI,KAAK;EACT,OAAO,KAAK;EACZ,MAAM,KAAK;EACX,WAAW,KAAK,aAAa;EAC7B,MAAM,KAAK;CACb,CAAC;CACD,MAAM,SAAS,KAAK,UAAU,CAAC;CAC/B,MAAM,WAAW,MAAM,IAAI,aAAa,EAAE,CAAC,CAAC,KAAK;CACjD,MAAM,UAAU,IAAI,YAAY,EAAE;CAClC,MAAM,WAAW,IAAI,yBAAyB,EAAE;CAChD,MAAM,QAAQ,IACZ,SAAS,KAEN,YAAY,sBAAsB,SAAS,UAAU,QAAQ,IAAI,KAAK,IAAI,MAAM,CACnF,CACF;AACF;;AAGA,SAAS,SACP,MACA,QACiD;CACjD,IAAI,MAAuD;CAC3D,KAAK,MAAM,OAAO,MAAM;EACtB,IAAI,CAAC,OAAO,SAAS,IAAI,SAAS,GAChC;EAEF,IAAI,CAAC,OAAO,UAAU,IAAI,QAAQ,UAAU,IAAI,OAC9C,MAAM;GAAE,WAAW,IAAI;GAAW,MAAM,IAAI;EAAK;CAErD;CACA,OAAO;AACT;;AAGA,eAAe,cACb,SACA,WACA,QACe;CACf,MAAM,WAAW,MAAM,QAAQ,IAAI,WAAW,MAAM;CACpD,IAAI,YAAY,SAAS,WAAW,UAClC,MAAM,QAAQ,OAAO,WAAW,MAAM;AAE1C;AAEA,eAAe,sBACb,SACA,UACA,WACA,QACA,QACe;CACf,MAAM,OAAO,MAAM,SAAS,KAAK,SAAS;CAC1C,IAAI,KAAK,WAAW,GAClB;CAEF,MAAM,MAAM,SAAS,MAAM,MAAM;CACjC,IAAI,CAAC,KAAK;EACR,MAAM,cAAc,SAAS,WAAW,MAAM;EAC9C;CACF;CACA,MAAM,WAAW,MAAM,QAAQ,IAAI,WAAW,MAAM;CACpD,IAAI,CAAC,YAAY,SAAS,SAAS,IAAI,QAAQ,SAAS,WAAW,QAAQ,IAAI,aAC7E,MAAM,QAAQ,IAAI,WAAW,QAAQ,IAAI,MAAM,QAAQ,IAAI,WAAW;AAE1E"}
@@ -0,0 +1,236 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { notifySystem } from "../notify.mjs";
6
+ import { hxRedirect } from "./htmx.mjs";
7
+ import { renderInviteInvalidPage, renderInvitePage } from "../pages/invite.mjs";
8
+ import { renderLoginPage } from "../pages/login.mjs";
9
+ import { syncLoginMemberships } from "./auth-sync.mjs";
10
+ import { UserModel } from "@storyshelf/core/models";
11
+ import { SESSION_COOKIE } from "@storyshelf/core/types";
12
+ import { HTTPException } from "hono/http-exception";
13
+ import { MIN_PASSWORD_LENGTH } from "@storyshelf/auth";
14
+ //#region src/routers/auth.ts
15
+ /** Provider ids are URL path segments: strict charset, bounded length. */
16
+ function validProviderId(value) {
17
+ return /^[a-zA-Z0-9_-]{1,64}$/u.test(value);
18
+ }
19
+ const ENGINE_PASSWORD_ACTION = "/auth/engine/login";
20
+ /** Forward a request to the mounted engine, preserving origin and cookies. */
21
+ async function forwardToEngine(c, engine, path, body) {
22
+ return await forwardEngineRequest(c, engine, path, "POST", body);
23
+ }
24
+ async function forwardEngineRequest(c, engine, path, method, body) {
25
+ const headers = new Headers();
26
+ for (const name of [
27
+ "cookie",
28
+ "origin",
29
+ "referer",
30
+ "user-agent"
31
+ ]) {
32
+ const value = c.req.header(name);
33
+ if (value) headers.set(name, value);
34
+ }
35
+ if (method === "POST") headers.set("content-type", "application/json");
36
+ return await engine.handler(new Request(new URL(path, c.req.url), {
37
+ method,
38
+ headers,
39
+ ...method === "POST" ? { body: JSON.stringify(body ?? {}) } : {}
40
+ }));
41
+ }
42
+ function copySetCookies(c, res) {
43
+ const cookies = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : splitSetCookie(res.headers.get("set-cookie"));
44
+ for (const cookie of cookies) c.header("set-cookie", cookie, { append: true });
45
+ }
46
+ /** Split a joined Set-Cookie header (runtimes without getSetCookie). */
47
+ function splitSetCookie(header) {
48
+ if (!header) return [];
49
+ return header.split(/,\s*(?=[^;\s=]+=)/u).map((part) => part.trim()).filter((part) => part !== "");
50
+ }
51
+ function loginLinks(methods) {
52
+ return methods.map((method) => ({
53
+ ...method,
54
+ url: method.kind === "oauth" || method.kind === "sso" ? `/auth/engine/${method.id}` : ENGINE_PASSWORD_ACTION
55
+ }));
56
+ }
57
+ async function renderEngineLogin(c, engine, error, email, status) {
58
+ return c.html(await renderLoginPage({
59
+ error,
60
+ email,
61
+ engineMethods: loginLinks(engine.loginMethods())
62
+ }), status ?? 200);
63
+ }
64
+ /** Membership sync for first-seen engine users only (never demote existing roles). */
65
+ async function syncEngineUser(user) {
66
+ if (await new UserModel(getStore().db).get(user.id)) return;
67
+ await syncLoginMemberships(getStore().db, {
68
+ users: getStore().db.tables.users,
69
+ projects: getStore().db.tables.projects,
70
+ projectMembers: getStore().db.tables.projectMembers,
71
+ projectGroupMappings: getStore().db.tables.projectGroupMappings
72
+ }, {
73
+ ...user,
74
+ role: "member"
75
+ });
76
+ }
77
+ async function readCredentials(c) {
78
+ const form = await c.req.formData();
79
+ const rawEmail = form.get("email");
80
+ const rawPassword = form.get("password");
81
+ return {
82
+ email: typeof rawEmail === "string" ? rawEmail : "",
83
+ password: typeof rawPassword === "string" ? rawPassword : ""
84
+ };
85
+ }
86
+ async function finishCredentialLogin(c, engine, email, password) {
87
+ const res = await forwardToEngine(c, engine, "/api/auth/sign-in/email", {
88
+ email,
89
+ password
90
+ });
91
+ if (res.status !== 200) {
92
+ if (res.status === 429) return renderEngineLogin(c, engine, "Invalid credentials", email, 429);
93
+ if (res.status >= 500) return renderEngineLogin(c, engine, "Invalid credentials", email, 502);
94
+ await notifySystem("sys:auth-failed", { email });
95
+ return renderEngineLogin(c, engine, "Invalid credentials", email, 401);
96
+ }
97
+ copySetCookies(c, res);
98
+ await syncEngineUser((await res.json()).user);
99
+ return hxRedirect(c, "/");
100
+ }
101
+ async function handleEngineLogin(c, engine) {
102
+ const { email, password } = await readCredentials(c);
103
+ return finishCredentialLogin(c, engine, email, password);
104
+ }
105
+ function resolveEngineMethod(c, engine) {
106
+ const id = c.req.param("providerId") ?? "";
107
+ if (!validProviderId(id)) throw new HTTPException(400, { message: "Unknown provider" });
108
+ return engine.loginMethods().find((entry) => entry.id === id && entry.kind !== "password") ?? null;
109
+ }
110
+ async function finishOAuthStart(c, engine, id) {
111
+ const res = await forwardToEngine(c, engine, "/api/auth/sign-in/social", {
112
+ provider: id,
113
+ callbackURL: "/",
114
+ disableRedirect: true
115
+ });
116
+ if (res.status !== 200) throw new HTTPException(502, { message: "Sign in failed" });
117
+ const body = await res.json();
118
+ if (!body.url) throw new HTTPException(502, { message: "Sign in failed" });
119
+ copySetCookies(c, res);
120
+ return c.redirect(body.url);
121
+ }
122
+ async function finishSSOStart(c, engine, id) {
123
+ const res = await forwardToEngine(c, engine, "/api/auth/sign-in/sso", {
124
+ providerId: id,
125
+ callbackURL: "/"
126
+ });
127
+ if (res.status !== 200) throw new HTTPException(502, { message: "Sign in failed" });
128
+ const body = await res.json();
129
+ if (!body.url) throw new HTTPException(502, { message: "Sign in failed" });
130
+ copySetCookies(c, res);
131
+ return c.redirect(body.url);
132
+ }
133
+ async function handleEngineStart(c, engine) {
134
+ const method = resolveEngineMethod(c, engine);
135
+ if (!method || method.kind === "passkey") return c.notFound();
136
+ if (method.kind === "sso") return await finishSSOStart(c, engine, method.id);
137
+ return await finishOAuthStart(c, engine, method.id);
138
+ }
139
+ async function handleInvitePage(c, engine) {
140
+ const inviteId = c.req.param("inviteId") ?? "";
141
+ const token = c.req.query("token") ?? "";
142
+ c.header("Cache-Control", "no-store");
143
+ if (!inviteId || !token) return c.html(renderInviteInvalidPage("Missing invite link parameters"), 400);
144
+ try {
145
+ const { email, name } = await engine.verifyInvite({
146
+ inviteId,
147
+ token
148
+ });
149
+ return c.html(await renderInvitePage({
150
+ inviteId,
151
+ token,
152
+ email,
153
+ name
154
+ }));
155
+ } catch {
156
+ return c.html(renderInviteInvalidPage("Invalid or expired invite"), 400);
157
+ }
158
+ }
159
+ async function readInviteForm(c) {
160
+ const form = await c.req.formData();
161
+ return {
162
+ inviteId: c.req.param("inviteId") ?? "",
163
+ token: formText(form.get("token")),
164
+ password: formText(form.get("password")),
165
+ confirm: formText(form.get("confirm"))
166
+ };
167
+ }
168
+ function formText(value) {
169
+ return typeof value === "string" ? value : "";
170
+ }
171
+ async function finishInviteAccept(c, engine, inviteId, token, password) {
172
+ const user = await engine.acceptInvite({
173
+ inviteId,
174
+ token,
175
+ password
176
+ });
177
+ const res = await forwardToEngine(c, engine, "/api/auth/sign-in/email", {
178
+ email: user.email,
179
+ password
180
+ });
181
+ if (res.status !== 200) throw new Error("Sign in failed");
182
+ copySetCookies(c, res);
183
+ await syncEngineUser({
184
+ id: user.id,
185
+ email: user.email,
186
+ name: user.name
187
+ });
188
+ return c.redirect("/profile");
189
+ }
190
+ async function inviteAcceptError(c, inviteId, token, error) {
191
+ const message = error instanceof Error ? error.message : "Invalid or expired invite";
192
+ if (message.includes(`at least ${MIN_PASSWORD_LENGTH}`)) return c.html(await renderInvitePage({
193
+ inviteId,
194
+ token,
195
+ error: message
196
+ }), 400);
197
+ return c.html(renderInviteInvalidPage(message), 400);
198
+ }
199
+ async function handleInviteAccept(c, engine) {
200
+ const { inviteId, token, password, confirm } = await readInviteForm(c);
201
+ if (!inviteId || !token) return c.html(renderInviteInvalidPage("Missing invite link parameters"), 400);
202
+ if (password !== confirm) return c.html(await renderInvitePage({
203
+ inviteId,
204
+ token,
205
+ error: "Passwords do not match"
206
+ }), 400);
207
+ try {
208
+ return await finishInviteAccept(c, engine, inviteId, token, password);
209
+ } catch (error) {
210
+ return await inviteAcceptError(c, inviteId, token, error);
211
+ }
212
+ }
213
+ /** Register the engine mount and descriptor-driven login routes. */
214
+ function registerEngineAuth(app, engine) {
215
+ app.all("/api/auth/*", async (c) => await engine.handler(c.req.raw));
216
+ app.get("/auth/login", async (c) => {
217
+ const methods = engine.loginMethods();
218
+ const sole = methods.length === 1 ? methods.at(0) : void 0;
219
+ if (sole?.kind === "oauth" || sole?.kind === "sso") return c.redirect(`/auth/engine/${sole.id}`);
220
+ return await renderEngineLogin(c, engine);
221
+ });
222
+ app.post("/auth/engine/login", async (c) => await handleEngineLogin(c, engine));
223
+ app.get("/auth/engine/:providerId", async (c) => await handleEngineStart(c, engine));
224
+ app.get("/auth/invites/:inviteId", async (c) => await handleInvitePage(c, engine));
225
+ app.post("/auth/invites/:inviteId", async (c) => await handleInviteAccept(c, engine));
226
+ app.post("/auth/logout", async (c) => {
227
+ copySetCookies(c, await forwardToEngine(c, engine, "/api/auth/sign-out", {}));
228
+ const secure = new URL(c.req.url).protocol === "https:" || c.req.header("x-forwarded-proto") === "https";
229
+ c.header("set-cookie", `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure ? "; Secure" : ""}`, { append: true });
230
+ return hxRedirect(c, "/auth/login");
231
+ });
232
+ }
233
+ //#endregion
234
+ export { forwardToEngine, registerEngineAuth };
235
+
236
+ //# sourceMappingURL=auth.mjs.map