@storyshelf/core 0.1.3 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/README.md +50 -46
  2. package/dist/adapter/auth.d.mts +4 -4
  3. package/dist/adapter/auth.mjs.map +1 -1
  4. package/dist/{adapters → adapter}/capture-queue.d.mts +4 -4
  5. package/dist/adapter/capture-queue.mjs +4 -0
  6. package/dist/adapter/capture-runner.d.mts +7 -7
  7. package/dist/adapter/database.d.mts +20 -9
  8. package/dist/adapter/database.mjs +60 -1
  9. package/dist/adapter/database.mjs.map +1 -0
  10. package/dist/adapter/git-host/comments.d.mts +41 -0
  11. package/dist/adapter/git-host/comments.mjs +54 -0
  12. package/dist/adapter/git-host/comments.mjs.map +1 -0
  13. package/dist/adapter/git-host.d.mts +3 -7
  14. package/dist/adapter/init.d.mts +58 -0
  15. package/dist/adapter/init.mjs +93 -0
  16. package/dist/adapter/init.mjs.map +1 -0
  17. package/dist/adapter/metadata.d.mts +83 -0
  18. package/dist/adapter/metadata.mjs +4 -0
  19. package/dist/adapter/storage.d.mts +15 -4
  20. package/dist/adapters/webhook-events.mjs +12 -4
  21. package/dist/adapters/webhook-events.mjs.map +1 -1
  22. package/dist/capture/adapter.d.mts +5 -1
  23. package/dist/capture/dispatch.mjs +150 -0
  24. package/dist/capture/dispatch.mjs.map +1 -0
  25. package/dist/capture/orchestrator.d.mts +2 -0
  26. package/dist/capture/orchestrator.mjs +5 -44
  27. package/dist/capture/orchestrator.mjs.map +1 -1
  28. package/dist/capture/pipeline.d.mts +4 -1
  29. package/dist/capture/pipeline.mjs +6 -6
  30. package/dist/capture/pipeline.mjs.map +1 -1
  31. package/dist/capture/queue.d.mts +2 -1
  32. package/dist/capture/queue.mjs +2 -1
  33. package/dist/capture/queue.mjs.map +1 -1
  34. package/dist/capture/skip-checks.mjs +76 -0
  35. package/dist/capture/skip-checks.mjs.map +1 -0
  36. package/dist/capture/statics.mjs +124 -0
  37. package/dist/capture/statics.mjs.map +1 -0
  38. package/dist/capture/status-fanout.mjs.map +1 -1
  39. package/dist/capture/storybook.mjs.map +1 -1
  40. package/dist/capture.d.mts +8 -0
  41. package/dist/capture.mjs +9 -0
  42. package/dist/config.d.mts +10 -2
  43. package/dist/config.mjs +14 -2
  44. package/dist/config.mjs.map +1 -1
  45. package/dist/ddl.d.mts +1 -1
  46. package/dist/ddl.mjs +1 -1
  47. package/dist/ddl.mjs.map +1 -1
  48. package/dist/diff/engine.mjs.map +1 -1
  49. package/dist/diff/options.d.mts +3 -1
  50. package/dist/diff.d.mts +5 -0
  51. package/dist/diff.mjs +6 -0
  52. package/dist/index.d.mts +32 -25
  53. package/dist/index.mjs +207 -253
  54. package/dist/index.mjs.map +1 -1
  55. package/dist/logger.d.mts +3 -3
  56. package/dist/middleware/auth-gate.mjs +36 -0
  57. package/dist/middleware/auth-gate.mjs.map +1 -0
  58. package/dist/middleware/csrf.mjs.map +1 -1
  59. package/dist/middleware/request-log.mjs +28 -0
  60. package/dist/middleware/request-log.mjs.map +1 -0
  61. package/dist/middleware/store-scope.mjs +29 -0
  62. package/dist/middleware/store-scope.mjs.map +1 -0
  63. package/dist/models/baseline.mjs +7 -5
  64. package/dist/models/baseline.mjs.map +1 -1
  65. package/dist/models/build.mjs +3 -1
  66. package/dist/models/build.mjs.map +1 -1
  67. package/dist/models/comment.mjs +2 -1
  68. package/dist/models/comment.mjs.map +1 -1
  69. package/dist/models/label.mjs +2 -1
  70. package/dist/models/label.mjs.map +1 -1
  71. package/dist/models/member.mjs +1 -1
  72. package/dist/models/member.mjs.map +1 -1
  73. package/dist/models/project.mjs +1 -1
  74. package/dist/models/project.mjs.map +1 -1
  75. package/dist/models/snapshot.mjs +1 -1
  76. package/dist/models/snapshot.mjs.map +1 -1
  77. package/dist/models/status-config.mjs +1 -1
  78. package/dist/models/status-config.mjs.map +1 -1
  79. package/dist/models/token.mjs +1 -1
  80. package/dist/models/token.mjs.map +1 -1
  81. package/dist/models/webhook.mjs +11 -3
  82. package/dist/models/webhook.mjs.map +1 -1
  83. package/dist/pages/build-detail.mjs +9 -4
  84. package/dist/pages/build-detail.mjs.map +1 -1
  85. package/dist/pages/build-diff-comments.mjs +113 -0
  86. package/dist/pages/build-diff-comments.mjs.map +1 -0
  87. package/dist/pages/build-diff-header.mjs +163 -0
  88. package/dist/pages/build-diff-header.mjs.map +1 -0
  89. package/dist/pages/build-diff-nav.mjs +66 -0
  90. package/dist/pages/build-diff-nav.mjs.map +1 -0
  91. package/dist/pages/build-diff-viewer.mjs +197 -0
  92. package/dist/pages/build-diff-viewer.mjs.map +1 -0
  93. package/dist/pages/build-diff.mjs +65 -413
  94. package/dist/pages/build-diff.mjs.map +1 -1
  95. package/dist/pages/compute-jobs.mjs +1 -1
  96. package/dist/pages/compute-jobs.mjs.map +1 -1
  97. package/dist/pages/label-detail.mjs +4 -3
  98. package/dist/pages/label-detail.mjs.map +1 -1
  99. package/dist/pages/login.mjs.map +1 -1
  100. package/dist/pages/project-builds.mjs +5 -3
  101. package/dist/pages/project-builds.mjs.map +1 -1
  102. package/dist/pages/project-create.mjs.map +1 -1
  103. package/dist/pages/project-settings.mjs.map +1 -1
  104. package/dist/pages/projects.mjs +11 -6
  105. package/dist/pages/projects.mjs.map +1 -1
  106. package/dist/pages/root.mjs +6 -3
  107. package/dist/pages/root.mjs.map +1 -1
  108. package/dist/pages/settings-general.mjs.map +1 -1
  109. package/dist/pages/settings-labels.mjs +1 -1
  110. package/dist/pages/settings-labels.mjs.map +1 -1
  111. package/dist/pages/settings-members.mjs +1 -1
  112. package/dist/pages/settings-members.mjs.map +1 -1
  113. package/dist/pages/settings-status.mjs +2 -1
  114. package/dist/pages/settings-status.mjs.map +1 -1
  115. package/dist/pages/settings-tests.mjs +3 -3
  116. package/dist/pages/settings-tests.mjs.map +1 -1
  117. package/dist/pages/settings-tokens.mjs.map +1 -1
  118. package/dist/pages/settings-webhooks.mjs.map +1 -1
  119. package/dist/pages/storybook.mjs +38 -1
  120. package/dist/pages/storybook.mjs.map +1 -1
  121. package/dist/paths.mjs.map +1 -0
  122. package/dist/retention/purge.mjs +6 -3
  123. package/dist/retention/purge.mjs.map +1 -1
  124. package/dist/routers/admin.mjs +1 -1
  125. package/dist/routers/admin.mjs.map +1 -1
  126. package/dist/routers/assets.mjs.map +1 -1
  127. package/dist/routers/auth.mjs.map +1 -1
  128. package/dist/routers/builds.handlers.mjs +106 -21
  129. package/dist/routers/builds.handlers.mjs.map +1 -1
  130. package/dist/routers/builds.mjs +54 -34
  131. package/dist/routers/builds.mjs.map +1 -1
  132. package/dist/routers/comments.mjs.map +1 -1
  133. package/dist/routers/health.mjs +137 -0
  134. package/dist/routers/health.mjs.map +1 -0
  135. package/dist/routers/helpers.mjs +2 -2
  136. package/dist/routers/helpers.mjs.map +1 -1
  137. package/dist/routers/htmx.mjs.map +1 -1
  138. package/dist/routers/labels.mjs +1 -1
  139. package/dist/routers/labels.mjs.map +1 -1
  140. package/dist/routers/media.mjs +2 -2
  141. package/dist/routers/media.mjs.map +1 -1
  142. package/dist/routers/members.mjs.map +1 -1
  143. package/dist/routers/projects.mjs +1 -1
  144. package/dist/routers/projects.mjs.map +1 -1
  145. package/dist/routers/schemas.mjs.map +1 -1
  146. package/dist/routers/settings-general.mjs +95 -0
  147. package/dist/routers/settings-general.mjs.map +1 -0
  148. package/dist/routers/settings-labels.mjs +49 -0
  149. package/dist/routers/settings-labels.mjs.map +1 -0
  150. package/dist/routers/settings-members.mjs +30 -0
  151. package/dist/routers/settings-members.mjs.map +1 -0
  152. package/dist/routers/settings-status.mjs +73 -0
  153. package/dist/routers/settings-status.mjs.map +1 -0
  154. package/dist/routers/settings-tokens.mjs +40 -0
  155. package/dist/routers/settings-tokens.mjs.map +1 -0
  156. package/dist/routers/settings-webhooks.mjs +63 -0
  157. package/dist/routers/settings-webhooks.mjs.map +1 -0
  158. package/dist/routers/settings.handlers.mjs +93 -0
  159. package/dist/routers/settings.handlers.mjs.map +1 -0
  160. package/dist/routers/settings.mjs +13 -247
  161. package/dist/routers/settings.mjs.map +1 -1
  162. package/dist/routers/snapshots.mjs +1 -1
  163. package/dist/routers/snapshots.mjs.map +1 -1
  164. package/dist/routers/status-configs.mjs +1 -1
  165. package/dist/routers/status-configs.mjs.map +1 -1
  166. package/dist/routers/storybook.mjs +11 -4
  167. package/dist/routers/storybook.mjs.map +1 -1
  168. package/dist/routers/tokens.mjs +1 -1
  169. package/dist/routers/tokens.mjs.map +1 -1
  170. package/dist/routers/ui.mjs +4 -4
  171. package/dist/routers/ui.mjs.map +1 -1
  172. package/dist/routers/webhooks.mjs +6 -6
  173. package/dist/routers/webhooks.mjs.map +1 -1
  174. package/dist/schema/baseline.d.mts +18 -0
  175. package/dist/schema/baseline.mjs +22 -0
  176. package/dist/schema/baseline.mjs.map +1 -0
  177. package/dist/schema/build.d.mts +26 -0
  178. package/dist/schema/build.mjs +29 -0
  179. package/dist/schema/build.mjs.map +1 -0
  180. package/dist/schema/comment.d.mts +19 -0
  181. package/dist/schema/comment.mjs +26 -0
  182. package/dist/schema/comment.mjs.map +1 -0
  183. package/dist/schema/label.d.mts +25 -0
  184. package/dist/schema/label.mjs +30 -0
  185. package/dist/schema/label.mjs.map +1 -0
  186. package/dist/schema/member.d.mts +15 -0
  187. package/dist/schema/member.mjs +19 -0
  188. package/dist/schema/member.mjs.map +1 -0
  189. package/dist/schema/project.d.mts +34 -0
  190. package/dist/schema/project.mjs +25 -0
  191. package/dist/schema/project.mjs.map +1 -0
  192. package/dist/schema/snapshot.d.mts +30 -0
  193. package/dist/schema/snapshot.mjs +34 -0
  194. package/dist/schema/snapshot.mjs.map +1 -0
  195. package/dist/schema/status-config.d.mts +16 -0
  196. package/dist/schema/status-config.mjs +20 -0
  197. package/dist/schema/status-config.mjs.map +1 -0
  198. package/dist/schema/token.d.mts +15 -0
  199. package/dist/schema/token.mjs +19 -0
  200. package/dist/schema/token.mjs.map +1 -0
  201. package/dist/schema/user.d.mts +17 -0
  202. package/dist/schema/user.mjs +19 -0
  203. package/dist/schema/user.mjs.map +1 -0
  204. package/dist/schema/webhook.d.mts +16 -0
  205. package/dist/schema/webhook.mjs +20 -0
  206. package/dist/schema/webhook.mjs.map +1 -0
  207. package/dist/schema.d.mts +13 -168
  208. package/dist/schema.mjs +12 -2
  209. package/dist/schema.mjs.map +1 -1
  210. package/dist/store.mjs.map +1 -1
  211. package/dist/types.d.mts +1 -2
  212. package/dist/types.mjs.map +1 -1
  213. package/dist/ui/document.mjs.map +1 -1
  214. package/dist/ui/feedback.mjs +24 -0
  215. package/dist/ui/feedback.mjs.map +1 -0
  216. package/dist/ui/{components.mjs → forms.mjs} +3 -18
  217. package/dist/ui/forms.mjs.map +1 -0
  218. package/dist/ui/styles.mjs.map +1 -1
  219. package/dist/urls.mjs.map +1 -1
  220. package/package.json +47 -45
  221. package/dist/adapters/metadata.d.mts +0 -24
  222. package/dist/models/baseline.d.mts +0 -21
  223. package/dist/models/build.d.mts +0 -53
  224. package/dist/models/comment.d.mts +0 -22
  225. package/dist/models/label.d.mts +0 -49
  226. package/dist/models/member.d.mts +0 -27
  227. package/dist/models/project.d.mts +0 -43
  228. package/dist/models/snapshot.d.mts +0 -47
  229. package/dist/models/token.d.mts +0 -33
  230. package/dist/models/webhook.d.mts +0 -38
  231. package/dist/retention/purge.d.mts +0 -31
  232. package/dist/schema-tables.mjs +0 -166
  233. package/dist/schema-tables.mjs.map +0 -1
  234. package/dist/ui/components.mjs.map +0 -1
  235. package/dist/ui/document.d.mts +0 -9
  236. package/dist/utils/paths.mjs.map +0 -1
  237. package/dist/utils/ulid.d.mts +0 -10
  238. /package/dist/{utils/paths.d.mts → paths.d.mts} +0 -0
  239. /package/dist/{utils/paths.mjs → paths.mjs} +0 -0
@@ -0,0 +1,36 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ //#region src/middleware/auth-gate.ts
6
+ /** Resolve the request user from the auth adapter (null when auth is off or a token is used). */
7
+ async function resolveRequestUser(c, auth) {
8
+ if (!auth) return null;
9
+ if (c.req.header("authorization")) return null;
10
+ return await auth.check(c.req.raw);
11
+ }
12
+ /**
13
+ * Gate the server-rendered UI behind auth (ADR 0008): unauthenticated HTML
14
+ * requests redirect to login. API/auth routes answer 401/403 themselves, and
15
+ * published-Storybook routes enforce their own auth (public builds are
16
+ * viewable without a session, ADR 0011).
17
+ */
18
+ function authGate() {
19
+ return async (c, next) => {
20
+ const { user, authEnabled: isAuthEnabled } = getStore();
21
+ const { path } = c.req;
22
+ if (!isAuthEnabled || user || path.startsWith("/api/") || path.startsWith("/auth/") || path.startsWith("/assets/") || path.startsWith("/projects/") && path.includes("/storybook")) {
23
+ await next();
24
+ return;
25
+ }
26
+ if (c.req.header("HX-Request") === "true") {
27
+ c.header("HX-Redirect", "/auth/login");
28
+ return c.body(null, 204);
29
+ }
30
+ return c.redirect("/auth/login", 302);
31
+ };
32
+ }
33
+ //#endregion
34
+ export { authGate, resolveRequestUser };
35
+
36
+ //# sourceMappingURL=auth-gate.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-gate.mjs","names":[],"sources":["../../src/middleware/auth-gate.ts"],"sourcesContent":["import type { Context, Next } from \"hono\";\nimport type { AuthAdapter, AuthUser } from \"../adapters/auth.ts\";\nimport { getStore } from \"../store.ts\";\n\n/** Resolve the request user from the auth adapter (null when auth is off or a token is used). */\nexport async function resolveRequestUser(\n c: Pick<Context, \"req\">,\n auth?: AuthAdapter,\n): Promise<AuthUser | null> {\n if (!auth) {\n return null;\n }\n if (c.req.header(\"authorization\")) {\n return null;\n }\n return await auth.check(c.req.raw);\n}\n\n/**\n * Gate the server-rendered UI behind auth (ADR 0008): unauthenticated HTML\n * requests redirect to login. API/auth routes answer 401/403 themselves, and\n * published-Storybook routes enforce their own auth (public builds are\n * viewable without a session, ADR 0011).\n */\nexport function authGate() {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const { user, authEnabled: isAuthEnabled } = getStore();\n const { path } = c.req;\n if (\n !isAuthEnabled ||\n user ||\n path.startsWith(\"/api/\") ||\n path.startsWith(\"/auth/\") ||\n path.startsWith(\"/assets/\") ||\n (path.startsWith(\"/projects/\") && path.includes(\"/storybook\"))\n ) {\n await next();\n return;\n }\n if (c.req.header(\"HX-Request\") === \"true\") {\n c.header(\"HX-Redirect\", \"/auth/login\");\n return c.body(null, 204);\n }\n return c.redirect(\"/auth/login\", 302);\n };\n}\n"],"mappings":";;;;;;AAKA,eAAsB,mBACpB,GACA,MAC0B;CAC1B,IAAI,CAAC,MACH,OAAO;CAET,IAAI,EAAE,IAAI,OAAO,eAAe,GAC9B,OAAO;CAET,OAAO,MAAM,KAAK,MAAM,EAAE,IAAI,GAAG;AACnC;;;;;;;AAQA,SAAgB,WAAW;CAEzB,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,EAAE,MAAM,aAAa,kBAAkB,SAAS;EACtD,MAAM,EAAE,SAAS,EAAE;EACnB,IACE,CAAC,iBACD,QACA,KAAK,WAAW,OAAO,KACvB,KAAK,WAAW,QAAQ,KACxB,KAAK,WAAW,UAAU,KACzB,KAAK,WAAW,YAAY,KAAK,KAAK,SAAS,YAAY,GAC5D;GACA,MAAM,KAAK;GACX;EACF;EACA,IAAI,EAAE,IAAI,OAAO,YAAY,MAAM,QAAQ;GACzC,EAAE,OAAO,eAAe,aAAa;GACrC,OAAO,EAAE,KAAK,MAAM,GAAG;EACzB;EACA,OAAO,EAAE,SAAS,eAAe,GAAG;CACtC;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"csrf.mjs","names":[],"sources":["../../src/middleware/csrf.ts"],"sourcesContent":["import { createHash, randomBytes } from \"node:crypto\";\nimport type { Context, Next } from \"hono\";\n\nconst CSRF_SECRET = process.env[\"CSRF_SECRET\"] ?? randomBytes(32).toString(\"hex\");\n\nfunction generateToken(sessionId: string): string {\n const timestamp = Date.now().toString(36);\n const payload = `${sessionId}:${timestamp}`;\n const signature = createHash(\"sha256\").update(`${CSRF_SECRET}:${payload}`).digest(\"hex\").slice(0, 16);\n return `${payload}:${signature}`;\n}\n\nfunction verifyToken(token: string, sessionId: string): boolean {\n const parts = token.split(\":\");\n if (parts.length !== 3) {\n return false;\n }\n const [payloadTimestamp, signature] = parts.slice(1);\n if (!payloadTimestamp || !signature) {\n return false;\n }\n const expectedSignature = createHash(\"sha256\").update(`${CSRF_SECRET}:${sessionId}:${payloadTimestamp}`).digest(\"hex\").slice(0, 16);\n if (signature !== expectedSignature) {\n return false;\n }\n const timestamp = Number.parseInt(payloadTimestamp, 36);\n const age = Date.now() - timestamp;\n return age > 0 && age < 24 * 60 * 60 * 1000;\n}\n\n/** Hono middleware issuing CSRF tokens on safe methods and verifying them on writes. */\nexport function csrf() {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const {method} = c.req;\n if (method === \"GET\" || method === \"HEAD\" || method === \"OPTIONS\") {\n const sessionId = c.req.header(\"session-id\") ?? \"default\";\n const token = generateToken(sessionId);\n c.header(\"X-CSRF-Token\", token);\n await next();\n return;\n }\n const sessionId = c.req.header(\"session-id\") ?? \"default\";\n const token = c.req.header(\"x-csrf-token\") ?? c.req.query(\"csrf_token\");\n if (!token || !verifyToken(token, sessionId)) {\n return c.json({ error: \"Invalid CSRF token\" }, 403);\n }\n await next();\n };\n}\n\n/** Generate a CSRF token for the given session. */\nexport function getCsrfToken(sessionId: string): string {\n return generateToken(sessionId);\n}\n\n"],"mappings":";;;;;AAGA,MAAM,cAAc,QAAQ,IAAI,kBAAkB,YAAY,EAAE,CAAC,CAAC,SAAS,KAAK;AAEhF,SAAS,cAAc,WAA2B;CAEhD,MAAM,UAAU,GAAG,UAAU,GADX,KAAK,IAAI,CAAC,CAAC,SAAS,EACE;CAExC,OAAO,GAAG,QAAQ,GADA,WAAW,QAAQ,CAAC,CAAC,OAAO,GAAG,YAAY,GAAG,SAAS,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,MAAM,GAAG,EACrE;AAC/B;AAEA,SAAS,YAAY,OAAe,WAA4B;CAC9D,MAAM,QAAQ,MAAM,MAAM,GAAG;CAC7B,IAAI,MAAM,WAAW,GACnB,OAAO;CAET,MAAM,CAAC,kBAAkB,aAAa,MAAM,MAAM,CAAC;CACnD,IAAI,CAAC,oBAAoB,CAAC,WACxB,OAAO;CAGT,IAAI,cADsB,WAAW,QAAQ,CAAC,CAAC,OAAO,GAAG,YAAY,GAAG,UAAU,GAAG,kBAAkB,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,MAAM,GAAG,EAC9F,GAChC,OAAO;CAET,MAAM,YAAY,OAAO,SAAS,kBAAkB,EAAE;CACtD,MAAM,MAAM,KAAK,IAAI,IAAI;CACzB,OAAO,MAAM,KAAK,MAAM;AAC1B;;AAGA,SAAgB,OAAO;CAErB,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,EAAC,WAAU,EAAE;EACnB,IAAI,WAAW,SAAS,WAAW,UAAU,WAAW,WAAW;GAEjE,MAAM,QAAQ,cADI,EAAE,IAAI,OAAO,YAAY,KAAK,SACX;GACrC,EAAE,OAAO,gBAAgB,KAAK;GAC9B,MAAM,KAAK;GACX;EACF;EACA,MAAM,YAAY,EAAE,IAAI,OAAO,YAAY,KAAK;EAChD,MAAM,QAAQ,EAAE,IAAI,OAAO,cAAc,KAAK,EAAE,IAAI,MAAM,YAAY;EACtE,IAAI,CAAC,SAAS,CAAC,YAAY,OAAO,SAAS,GACzC,OAAO,EAAE,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;EAEpD,MAAM,KAAK;CACb;AACF"}
1
+ {"version":3,"file":"csrf.mjs","names":[],"sources":["../../src/middleware/csrf.ts"],"sourcesContent":["import type { Context, Next } from \"hono\";\nimport { createHash, randomBytes } from \"node:crypto\";\n\nconst CSRF_SECRET = process.env[\"CSRF_SECRET\"] ?? randomBytes(32).toString(\"hex\");\n\nfunction generateToken(sessionId: string): string {\n const timestamp = Date.now().toString(36);\n const payload = `${sessionId}:${timestamp}`;\n const signature = createHash(\"sha256\")\n .update(`${CSRF_SECRET}:${payload}`)\n .digest(\"hex\")\n .slice(0, 16);\n return `${payload}:${signature}`;\n}\n\nfunction verifyToken(token: string, sessionId: string): boolean {\n const parts = token.split(\":\");\n if (parts.length !== 3) {\n return false;\n }\n const [payloadTimestamp, signature] = parts.slice(1);\n if (!payloadTimestamp || !signature) {\n return false;\n }\n const expectedSignature = createHash(\"sha256\")\n .update(`${CSRF_SECRET}:${sessionId}:${payloadTimestamp}`)\n .digest(\"hex\")\n .slice(0, 16);\n if (signature !== expectedSignature) {\n return false;\n }\n const timestamp = Number.parseInt(payloadTimestamp, 36);\n const age = Date.now() - timestamp;\n return age > 0 && age < 24 * 60 * 60 * 1000;\n}\n\n/** Hono middleware issuing CSRF tokens on safe methods and verifying them on writes. */\nexport function csrf() {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const { method } = c.req;\n if (method === \"GET\" || method === \"HEAD\" || method === \"OPTIONS\") {\n const sessionId = c.req.header(\"session-id\") ?? \"default\";\n const token = generateToken(sessionId);\n c.header(\"X-CSRF-Token\", token);\n await next();\n return;\n }\n const sessionId = c.req.header(\"session-id\") ?? \"default\";\n const token = c.req.header(\"x-csrf-token\") ?? c.req.query(\"csrf_token\");\n if (!token || !verifyToken(token, sessionId)) {\n return c.json({ error: \"Invalid CSRF token\" }, 403);\n }\n await next();\n };\n}\n\n/** Generate a CSRF token for the given session. */\nexport function getCsrfToken(sessionId: string): string {\n return generateToken(sessionId);\n}\n"],"mappings":";;;;;AAGA,MAAM,cAAc,QAAQ,IAAI,kBAAkB,YAAY,EAAE,CAAC,CAAC,SAAS,KAAK;AAEhF,SAAS,cAAc,WAA2B;CAEhD,MAAM,UAAU,GAAG,UAAU,GADX,KAAK,IAAI,CAAC,CAAC,SAAS,EACE;CAKxC,OAAO,GAAG,QAAQ,GAJA,WAAW,QAAQ,CAAC,CACnC,OAAO,GAAG,YAAY,GAAG,SAAS,CAAC,CACnC,OAAO,KAAK,CAAC,CACb,MAAM,GAAG,EACiB;AAC/B;AAEA,SAAS,YAAY,OAAe,WAA4B;CAC9D,MAAM,QAAQ,MAAM,MAAM,GAAG;CAC7B,IAAI,MAAM,WAAW,GACnB,OAAO;CAET,MAAM,CAAC,kBAAkB,aAAa,MAAM,MAAM,CAAC;CACnD,IAAI,CAAC,oBAAoB,CAAC,WACxB,OAAO;CAMT,IAAI,cAJsB,WAAW,QAAQ,CAAC,CAC3C,OAAO,GAAG,YAAY,GAAG,UAAU,GAAG,kBAAkB,CAAC,CACzD,OAAO,KAAK,CAAC,CACb,MAAM,GAAG,EACsB,GAChC,OAAO;CAET,MAAM,YAAY,OAAO,SAAS,kBAAkB,EAAE;CACtD,MAAM,MAAM,KAAK,IAAI,IAAI;CACzB,OAAO,MAAM,KAAK,MAAM;AAC1B;;AAGA,SAAgB,OAAO;CAErB,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,EAAE,WAAW,EAAE;EACrB,IAAI,WAAW,SAAS,WAAW,UAAU,WAAW,WAAW;GAEjE,MAAM,QAAQ,cADI,EAAE,IAAI,OAAO,YAAY,KAAK,SACX;GACrC,EAAE,OAAO,gBAAgB,KAAK;GAC9B,MAAM,KAAK;GACX;EACF;EACA,MAAM,YAAY,EAAE,IAAI,OAAO,YAAY,KAAK;EAChD,MAAM,QAAQ,EAAE,IAAI,OAAO,cAAc,KAAK,EAAE,IAAI,MAAM,YAAY;EACtE,IAAI,CAAC,SAAS,CAAC,YAAY,OAAO,SAAS,GACzC,OAAO,EAAE,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;EAEpD,MAAM,KAAK;CACb;AACF"}
@@ -0,0 +1,28 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region src/middleware/request-log.ts
5
+ /** Hono middleware logging request start/end with duration. */
6
+ function requestLogging(logger) {
7
+ return async (c, next) => {
8
+ const started = performance.now();
9
+ const id = c.get("requestId");
10
+ logger.info({
11
+ reqId: id,
12
+ method: c.req.method,
13
+ url: c.req.path
14
+ }, "request start");
15
+ await next();
16
+ logger.info({
17
+ reqId: id,
18
+ method: c.req.method,
19
+ url: c.req.path,
20
+ status: c.res.status,
21
+ durationMs: Math.round(performance.now() - started)
22
+ }, "request end");
23
+ };
24
+ }
25
+ //#endregion
26
+ export { requestLogging };
27
+
28
+ //# sourceMappingURL=request-log.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request-log.mjs","names":[],"sources":["../../src/middleware/request-log.ts"],"sourcesContent":["import type { Context, Next } from \"hono\";\nimport type { Logger } from \"../logger.ts\";\n\n/** Hono middleware logging request start/end with duration. */\nexport function requestLogging(logger: Logger) {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const started = performance.now();\n const id = c.get(\"requestId\");\n logger.info({ reqId: id, method: c.req.method, url: c.req.path }, \"request start\");\n await next();\n logger.info(\n {\n reqId: id,\n method: c.req.method,\n url: c.req.path,\n status: c.res.status,\n durationMs: Math.round(performance.now() - started),\n },\n \"request end\",\n );\n };\n}\n"],"mappings":";;;;;AAIA,SAAgB,eAAe,QAAgB;CAE7C,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,UAAU,YAAY,IAAI;EAChC,MAAM,KAAK,EAAE,IAAI,WAAW;EAC5B,OAAO,KAAK;GAAE,OAAO;GAAI,QAAQ,EAAE,IAAI;GAAQ,KAAK,EAAE,IAAI;EAAK,GAAG,eAAe;EACjF,MAAM,KAAK;EACX,OAAO,KACL;GACE,OAAO;GACP,QAAQ,EAAE,IAAI;GACd,KAAK,EAAE,IAAI;GACX,QAAQ,EAAE,IAAI;GACd,YAAY,KAAK,MAAM,YAAY,IAAI,IAAI,OAAO;EACpD,GACA,aACF;CACF;AACF"}
@@ -0,0 +1,29 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { runWithStore } from "../store.mjs";
5
+ //#region src/middleware/store-scope.ts
6
+ /** Hono middleware running downstream handlers inside the request store. */
7
+ function storeScope(deps) {
8
+ return async (c, next) => {
9
+ const user = await deps.resolveUser(c);
10
+ await runWithStore({
11
+ db: deps.db,
12
+ storage: deps.storage,
13
+ config: deps.config,
14
+ ui: deps.ui,
15
+ logger: deps.logger,
16
+ user,
17
+ authEnabled: deps.authEnabled,
18
+ enqueueCapture: deps.enqueueCapture,
19
+ captureQueue: deps.captureQueue,
20
+ gitHosts: deps.gitHosts
21
+ }, async () => {
22
+ await next();
23
+ });
24
+ };
25
+ }
26
+ //#endregion
27
+ export { storeScope };
28
+
29
+ //# sourceMappingURL=store-scope.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-scope.mjs","names":[],"sources":["../../src/middleware/store-scope.ts"],"sourcesContent":["import type { Context, Next } from \"hono\";\nimport type { AuthUser } from \"../adapters/auth.ts\";\nimport type { CaptureQueue } from \"../adapters/capture-queue.ts\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { GitHostProvider } from \"../adapters/git-host/index.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport type { ShelfConfig, UIConfig } from \"../config.ts\";\nimport type { Logger } from \"../logger.ts\";\nimport { runWithStore } from \"../store.ts\";\n\n/** Dependencies scoped into the request store for router handlers. */\nexport interface StoreScopeDeps {\n db: DatabaseAdapter;\n storage: StorageAdapter;\n config: ShelfConfig;\n ui: UIConfig;\n logger: Logger;\n authEnabled: boolean;\n enqueueCapture?: (buildId: string, reqId?: string) => Promise<void>;\n captureQueue: CaptureQueue | null;\n gitHosts: GitHostProvider[];\n resolveUser: (c: Context) => Promise<AuthUser | null>;\n}\n\n/** Hono middleware running downstream handlers inside the request store. */\nexport function storeScope(deps: StoreScopeDeps) {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const user = await deps.resolveUser(c);\n await runWithStore(\n {\n db: deps.db,\n storage: deps.storage,\n config: deps.config,\n ui: deps.ui,\n logger: deps.logger,\n user,\n authEnabled: deps.authEnabled,\n enqueueCapture: deps.enqueueCapture,\n captureQueue: deps.captureQueue,\n gitHosts: deps.gitHosts,\n },\n async () => {\n await next();\n },\n );\n };\n}\n"],"mappings":";;;;;;AAyBA,SAAgB,WAAW,MAAsB;CAE/C,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,OAAO,MAAM,KAAK,YAAY,CAAC;EACrC,MAAM,aACJ;GACE,IAAI,KAAK;GACT,SAAS,KAAK;GACd,QAAQ,KAAK;GACb,IAAI,KAAK;GACT,QAAQ,KAAK;GACb;GACA,aAAa,KAAK;GAClB,gBAAgB,KAAK;GACrB,cAAc,KAAK;GACnB,UAAU,KAAK;EACjB,GACA,YAAY;GACV,MAAM,KAAK;EACb,CACF;CACF;AACF"}
@@ -1,10 +1,10 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { baselines } from "../schema-tables.mjs";
5
4
  import { ulid } from "../utils/ulid.mjs";
6
5
  import { emitWebhookEvent } from "../adapters/webhook-events.mjs";
7
- import { baselinePath } from "../utils/paths.mjs";
6
+ import { baselines } from "../schema/baseline.mjs";
7
+ import { baselinePath } from "../paths.mjs";
8
8
  import { and, eq } from "drizzle-orm";
9
9
  //#region src/models/baseline.ts
10
10
  /** Per-branch baseline screenshots with default-branch fallback. */
@@ -12,9 +12,11 @@ import { and, eq } from "drizzle-orm";
12
12
  var BaselineModel = class {
13
13
  db;
14
14
  storage;
15
- constructor(db, storage) {
15
+ secret;
16
+ constructor(db, storage, secret) {
16
17
  this.db = db;
17
18
  this.storage = storage;
19
+ this.secret = secret;
18
20
  }
19
21
  async getFor(projectId, storyId, viewport, branch) {
20
22
  return (await this.db.list(baselines, {
@@ -50,7 +52,7 @@ var BaselineModel = class {
50
52
  viewport,
51
53
  branch,
52
54
  snapshotId
53
- });
55
+ }, this.secret);
54
56
  } else {
55
57
  const now = (/* @__PURE__ */ new Date()).toISOString();
56
58
  baseline = await this.db.insert(baselines, {
@@ -70,7 +72,7 @@ var BaselineModel = class {
70
72
  viewport,
71
73
  branch,
72
74
  snapshotId
73
- });
75
+ }, this.secret);
74
76
  }
75
77
  return baseline;
76
78
  }
@@ -1 +1 @@
1
- {"version":3,"file":"baseline.mjs","names":[],"sources":["../../src/models/baseline.ts"],"sourcesContent":["/** Per-branch baseline screenshots with default-branch fallback. */\nimport { and, eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { emitWebhookEvent } from \"../adapters/webhook-events.ts\";\nimport { baselines } from \"../schema-tables.ts\";\nimport type { Baseline } from \"../schema.ts\";\nimport { baselinePath } from \"../utils/paths.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Data operations for baseline screenshots. */\nexport class BaselineModel {\n constructor(\n private readonly db: DatabaseAdapter,\n private readonly storage: StorageAdapter,\n ) {}\n\n async getFor(projectId: string, storyId: string, viewport: string, branch: string): Promise<Baseline | null> {\n const rows = await this.db.list(baselines, {\n where: and(eq(baselines.projectId, projectId), eq(baselines.storyId, storyId), eq(baselines.viewportName, viewport), eq(baselines.branch, branch)),\n limit: 1,\n });\n return rows[0] ?? null;\n }\n\n async resolve(projectId: string, storyId: string, viewport: string, branch: string, defaultBranch: string): Promise<Baseline | null> {\n if (branch !== defaultBranch) {\n const own = await this.getFor(projectId, storyId, viewport, branch);\n if (own) {\n return own;\n }\n }\n return this.getFor(projectId, storyId, viewport, defaultBranch);\n }\n\n async read(baseline: Baseline): Promise<Buffer> {\n return await this.storage.read(baseline.screenshotPath);\n }\n\n async upsert(\n projectId: string,\n storyId: string,\n viewport: string,\n branch: string,\n snapshotId: string,\n sourcePath: string,\n ): Promise<Baseline> {\n const screenshotPath = baselinePath(projectId, branch, storyId, viewport);\n const source = await this.storage.read(sourcePath);\n await this.storage.write(screenshotPath, source);\n\n const existing = await this.getFor(projectId, storyId, viewport, branch);\n let baseline: Baseline;\n if (existing) {\n baseline = await this.db.update(baselines, existing.id, {\n snapshotId,\n screenshotPath,\n updatedAt: new Date().toISOString(),\n });\n await emitWebhookEvent(this.db, projectId, \"baseline:updated\", {\n baselineId: baseline.id,\n storyId,\n viewport,\n branch,\n snapshotId,\n });\n } else {\n const now = new Date().toISOString();\n baseline = await this.db.insert(baselines, {\n id: ulid(),\n projectId,\n storyId,\n viewportName: viewport,\n branch,\n snapshotId,\n screenshotPath,\n createdAt: now,\n updatedAt: now,\n });\n await emitWebhookEvent(this.db, projectId, \"baseline:created\", {\n baselineId: baseline.id,\n storyId,\n viewport,\n branch,\n snapshotId,\n });\n }\n return baseline;\n }\n\n async list(projectId: string): Promise<Baseline[]> {\n return await this.db.list(baselines, { where: eq(baselines.projectId, projectId) });\n }\n\n async removeOrphans(projectId: string, validStoryIds: Set<string>): Promise<number> {\n const all = await this.list(projectId);\n const toRemove = all.filter((baseline) => !validStoryIds.has(baseline.storyId));\n await Promise.all(\n toRemove.map(async (baseline) => {\n await this.db.remove(baselines, baseline.id);\n }),\n );\n return toRemove.length;\n }\n}\n\nexport type { Baseline };\n"],"mappings":";;;;;;;;;;;AAYA,IAAa,gBAAb,MAA2B;CAEN;CACA;CAFnB,YACE,IACA,SACA;EAFiB,KAAA,KAAA;EACA,KAAA,UAAA;CAChB;CAEH,MAAM,OAAO,WAAmB,SAAiB,UAAkB,QAA0C;EAK3G,QAAO,MAJY,KAAK,GAAG,KAAK,WAAW;GACzC,OAAO,IAAI,GAAG,UAAU,WAAW,SAAS,GAAG,GAAG,UAAU,SAAS,OAAO,GAAG,GAAG,UAAU,cAAc,QAAQ,GAAG,GAAG,UAAU,QAAQ,MAAM,CAAC;GACjJ,OAAO;EACT,CAAC,EAAA,CACW,MAAM;CACpB;CAEA,MAAM,QAAQ,WAAmB,SAAiB,UAAkB,QAAgB,eAAiD;EACnI,IAAI,WAAW,eAAe;GAC5B,MAAM,MAAM,MAAM,KAAK,OAAO,WAAW,SAAS,UAAU,MAAM;GAClE,IAAI,KACF,OAAO;EAEX;EACA,OAAO,KAAK,OAAO,WAAW,SAAS,UAAU,aAAa;CAChE;CAEA,MAAM,KAAK,UAAqC;EAC9C,OAAO,MAAM,KAAK,QAAQ,KAAK,SAAS,cAAc;CACxD;CAEA,MAAM,OACJ,WACA,SACA,UACA,QACA,YACA,YACmB;EACnB,MAAM,iBAAiB,aAAa,WAAW,QAAQ,SAAS,QAAQ;EACxE,MAAM,SAAS,MAAM,KAAK,QAAQ,KAAK,UAAU;EACjD,MAAM,KAAK,QAAQ,MAAM,gBAAgB,MAAM;EAE/C,MAAM,WAAW,MAAM,KAAK,OAAO,WAAW,SAAS,UAAU,MAAM;EACvE,IAAI;EACJ,IAAI,UAAU;GACZ,WAAW,MAAM,KAAK,GAAG,OAAO,WAAW,SAAS,IAAI;IACtD;IACA;IACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;GACpC,CAAC;GACD,MAAM,iBAAiB,KAAK,IAAI,WAAW,oBAAoB;IAC7D,YAAY,SAAS;IACrB;IACA;IACA;IACA;GACF,CAAC;EACH,OAAO;GACL,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;GACnC,WAAW,MAAM,KAAK,GAAG,OAAO,WAAW;IACzC,IAAI,KAAK;IACT;IACA;IACA,cAAc;IACd;IACA;IACA;IACA,WAAW;IACX,WAAW;GACb,CAAC;GACD,MAAM,iBAAiB,KAAK,IAAI,WAAW,oBAAoB;IAC7D,YAAY,SAAS;IACrB;IACA;IACA;IACA;GACF,CAAC;EACH;EACA,OAAO;CACT;CAEA,MAAM,KAAK,WAAwC;EACjD,OAAO,MAAM,KAAK,GAAG,KAAK,WAAW,EAAE,OAAO,GAAG,UAAU,WAAW,SAAS,EAAE,CAAC;CACpF;CAEA,MAAM,cAAc,WAAmB,eAA6C;EAElF,MAAM,YAAW,MADC,KAAK,KAAK,SAAS,EAAA,CAChB,QAAQ,aAAa,CAAC,cAAc,IAAI,SAAS,OAAO,CAAC;EAC9E,MAAM,QAAQ,IACZ,SAAS,IAAI,OAAO,aAAa;GAC/B,MAAM,KAAK,GAAG,OAAO,WAAW,SAAS,EAAE;EAC7C,CAAC,CACH;EACA,OAAO,SAAS;CAClB;AACF"}
1
+ {"version":3,"file":"baseline.mjs","names":[],"sources":["../../src/models/baseline.ts"],"sourcesContent":["/** Per-branch baseline screenshots with default-branch fallback. */\nimport { and, eq } from \"drizzle-orm\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { emitWebhookEvent } from \"../adapters/webhook-events.ts\";\nimport { baselines } from \"../schema/baseline.ts\";\nimport type { Baseline } from \"../schema/baseline.ts\";\nimport { baselinePath } from \"../utils/paths.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Data operations for baseline screenshots. */\nexport class BaselineModel {\n constructor(\n private readonly db: DatabaseAdapter,\n private readonly storage: StorageAdapter,\n private readonly secret?: string,\n ) {}\n\n async getFor(\n projectId: string,\n storyId: string,\n viewport: string,\n branch: string,\n ): Promise<Baseline | null> {\n const rows = await this.db.list(baselines, {\n where: and(\n eq(baselines.projectId, projectId),\n eq(baselines.storyId, storyId),\n eq(baselines.viewportName, viewport),\n eq(baselines.branch, branch),\n ),\n limit: 1,\n });\n return rows[0] ?? null;\n }\n\n async resolve(\n projectId: string,\n storyId: string,\n viewport: string,\n branch: string,\n defaultBranch: string,\n ): Promise<Baseline | null> {\n if (branch !== defaultBranch) {\n const own = await this.getFor(projectId, storyId, viewport, branch);\n if (own) {\n return own;\n }\n }\n return this.getFor(projectId, storyId, viewport, defaultBranch);\n }\n\n async read(baseline: Baseline): Promise<Buffer> {\n return await this.storage.read(baseline.screenshotPath);\n }\n\n async upsert(\n projectId: string,\n storyId: string,\n viewport: string,\n branch: string,\n snapshotId: string,\n sourcePath: string,\n ): Promise<Baseline> {\n const screenshotPath = baselinePath(projectId, branch, storyId, viewport);\n const source = await this.storage.read(sourcePath);\n await this.storage.write(screenshotPath, source);\n\n const existing = await this.getFor(projectId, storyId, viewport, branch);\n let baseline: Baseline;\n if (existing) {\n baseline = await this.db.update(baselines, existing.id, {\n snapshotId,\n screenshotPath,\n updatedAt: new Date().toISOString(),\n });\n await emitWebhookEvent(this.db, projectId, \"baseline:updated\", {\n baselineId: baseline.id,\n storyId,\n viewport,\n branch,\n snapshotId,\n }, this.secret);\n } else {\n const now = new Date().toISOString();\n baseline = await this.db.insert(baselines, {\n id: ulid(),\n projectId,\n storyId,\n viewportName: viewport,\n branch,\n snapshotId,\n screenshotPath,\n createdAt: now,\n updatedAt: now,\n });\n await emitWebhookEvent(this.db, projectId, \"baseline:created\", {\n baselineId: baseline.id,\n storyId,\n viewport,\n branch,\n snapshotId,\n }, this.secret);\n }\n return baseline;\n }\n\n async list(projectId: string): Promise<Baseline[]> {\n return await this.db.list(baselines, { where: eq(baselines.projectId, projectId) });\n }\n\n async removeOrphans(projectId: string, validStoryIds: Set<string>): Promise<number> {\n const all = await this.list(projectId);\n const toRemove = all.filter((baseline) => !validStoryIds.has(baseline.storyId));\n await Promise.all(\n toRemove.map(async (baseline) => {\n await this.db.remove(baselines, baseline.id);\n }),\n );\n return toRemove.length;\n }\n}\n"],"mappings":";;;;;;;;;;;AAWA,IAAa,gBAAb,MAA2B;CAEN;CACA;CACA;CAHnB,YACE,IACA,SACA,QACA;EAHiB,KAAA,KAAA;EACA,KAAA,UAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,OACJ,WACA,SACA,UACA,QAC0B;EAU1B,QAAO,MATY,KAAK,GAAG,KAAK,WAAW;GACzC,OAAO,IACL,GAAG,UAAU,WAAW,SAAS,GACjC,GAAG,UAAU,SAAS,OAAO,GAC7B,GAAG,UAAU,cAAc,QAAQ,GACnC,GAAG,UAAU,QAAQ,MAAM,CAC7B;GACA,OAAO;EACT,CAAC,EAAA,CACW,MAAM;CACpB;CAEA,MAAM,QACJ,WACA,SACA,UACA,QACA,eAC0B;EAC1B,IAAI,WAAW,eAAe;GAC5B,MAAM,MAAM,MAAM,KAAK,OAAO,WAAW,SAAS,UAAU,MAAM;GAClE,IAAI,KACF,OAAO;EAEX;EACA,OAAO,KAAK,OAAO,WAAW,SAAS,UAAU,aAAa;CAChE;CAEA,MAAM,KAAK,UAAqC;EAC9C,OAAO,MAAM,KAAK,QAAQ,KAAK,SAAS,cAAc;CACxD;CAEA,MAAM,OACJ,WACA,SACA,UACA,QACA,YACA,YACmB;EACnB,MAAM,iBAAiB,aAAa,WAAW,QAAQ,SAAS,QAAQ;EACxE,MAAM,SAAS,MAAM,KAAK,QAAQ,KAAK,UAAU;EACjD,MAAM,KAAK,QAAQ,MAAM,gBAAgB,MAAM;EAE/C,MAAM,WAAW,MAAM,KAAK,OAAO,WAAW,SAAS,UAAU,MAAM;EACvE,IAAI;EACJ,IAAI,UAAU;GACZ,WAAW,MAAM,KAAK,GAAG,OAAO,WAAW,SAAS,IAAI;IACtD;IACA;IACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;GACpC,CAAC;GACD,MAAM,iBAAiB,KAAK,IAAI,WAAW,oBAAoB;IAC7D,YAAY,SAAS;IACrB;IACA;IACA;IACA;GACF,GAAG,KAAK,MAAM;EAChB,OAAO;GACL,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;GACnC,WAAW,MAAM,KAAK,GAAG,OAAO,WAAW;IACzC,IAAI,KAAK;IACT;IACA;IACA,cAAc;IACd;IACA;IACA;IACA,WAAW;IACX,WAAW;GACb,CAAC;GACD,MAAM,iBAAiB,KAAK,IAAI,WAAW,oBAAoB;IAC7D,YAAY,SAAS;IACrB;IACA;IACA;IACA;GACF,GAAG,KAAK,MAAM;EAChB;EACA,OAAO;CACT;CAEA,MAAM,KAAK,WAAwC;EACjD,OAAO,MAAM,KAAK,GAAG,KAAK,WAAW,EAAE,OAAO,GAAG,UAAU,WAAW,SAAS,EAAE,CAAC;CACpF;CAEA,MAAM,cAAc,WAAmB,eAA6C;EAElF,MAAM,YAAW,MADC,KAAK,KAAK,SAAS,EAAA,CAChB,QAAQ,aAAa,CAAC,cAAc,IAAI,SAAS,OAAO,CAAC;EAC9E,MAAM,QAAQ,IACZ,SAAS,IAAI,OAAO,aAAa;GAC/B,MAAM,KAAK,GAAG,OAAO,WAAW,SAAS,EAAE;EAC7C,CAAC,CACH;EACA,OAAO,SAAS;CAClB;AACF"}
@@ -1,7 +1,9 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { buildLabels, builds, snapshots } from "../schema-tables.mjs";
4
+ import { builds } from "../schema/build.mjs";
5
+ import { buildLabels } from "../schema/label.mjs";
6
+ import { snapshots } from "../schema/snapshot.mjs";
5
7
  import { ulid } from "../utils/ulid.mjs";
6
8
  import { and, desc, eq, inArray } from "drizzle-orm";
7
9
  //#region src/models/build.ts
@@ -1 +1 @@
1
- {"version":3,"file":"build.mjs","names":[],"sources":["../../src/models/build.ts"],"sourcesContent":["/** Build records, status transitions, and publication helpers. */\nimport { and, desc, eq, inArray } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { buildLabels, builds, snapshots } from \"../schema-tables.ts\";\nimport type { Build } from \"../schema.ts\";\nimport type { BuildStatus } from \"../types.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/**\n * Return whether a build is publicly viewable without a session.\n *\n * A build is public iff `builds.public` is set or its branch matches the\n * project's `public_branch_regex` (ADR 0011). Supplying an empty/nonexistent\n * regex makes every build require auth.\n */\nexport function isPublicBuild(project: { publicBranchRegex: string | null }, build: Pick<Build, \"public\" | \"gitBranch\">): boolean {\n if (build.public) {\n return true;\n }\n if (!project.publicBranchRegex) {\n return false;\n }\n return new RegExp(project.publicBranchRegex, \"u\").test(build.gitBranch);\n}\n\n/** Input for creating a build. */\nexport interface BuildCreateInput {\n gitSha: string;\n gitBranch: string;\n isDefault?: boolean;\n authorEmail?: string;\n authorName?: string;\n message?: string;\n public?: boolean;\n}\n\n/** Filter options for listing builds. */\nexport interface BuildListFilter {\n status?: BuildStatus;\n branch?: string;\n labelKey?: string;\n labelValue?: string;\n}\n\n/** Data operations for build records. */\nexport class BuildModel {\n constructor(private readonly db: DatabaseAdapter) {}\n\n async create(projectId: string, input: BuildCreateInput): Promise<Build> {\n const now = new Date().toISOString();\n return await this.db.insert(builds, {\n id: ulid(),\n projectId,\n gitSha: input.gitSha,\n gitBranch: input.gitBranch,\n isDefault: input.isDefault ?? false,\n authorEmail: input.authorEmail,\n authorName: input.authorName,\n message: input.message,\n public: input.public ?? false,\n status: \"pending\",\n createdAt: now,\n updatedAt: now,\n });\n }\n\n async get(id: string): Promise<Build | null> {\n return await this.db.get(builds, id);\n }\n\n async list(projectId: string, filter: BuildListFilter = {}): Promise<Build[]> {\n const conditions = [eq(builds.projectId, projectId)];\n if (filter.status) {\n conditions.push(eq(builds.status, filter.status));\n }\n if (filter.branch) {\n conditions.push(eq(builds.gitBranch, filter.branch));\n }\n if (filter.labelKey && filter.labelValue) {\n const labels = await this.db.list(buildLabels, {\n where: and(eq(buildLabels.projectId, projectId), eq(buildLabels.typeKey, filter.labelKey), eq(buildLabels.value, filter.labelValue)),\n });\n const ids = labels.map((l) => l.buildId);\n if (ids.length === 0) {\n return [];\n }\n conditions.push(inArray(builds.id, ids));\n }\n return this.db.list(builds, { where: and(...conditions), orderBy: desc(builds.createdAt) });\n }\n\n /** Return the most recent build intended for publishing, if any. */\n async latestPublished(project: { id: string; publicBranchRegex: string | null }): Promise<Build | null> {\n const rows = await this.list(project.id);\n for (const build of rows) {\n if (isPublicBuild(project, build)) {\n return build;\n }\n }\n return null;\n }\n\n async update(id: string, patch: Partial<Pick<Build, \"status\" | \"public\" | \"message\" | \"authorEmail\" | \"authorName\">>): Promise<Build> {\n return await this.db.update(builds, id, { ...patch, updatedAt: new Date().toISOString() });\n }\n\n async setStatus(id: string, status: BuildStatus): Promise<Build> {\n return await this.update(id, { status });\n }\n\n async updateCounts(id: string): Promise<Build> {\n const rows = await this.db.list(snapshots, { where: eq(snapshots.buildId, id) });\n const snapshotCount = rows.length;\n const changedCount = rows.filter((s) => s.status === \"changed\" || s.status === \"new\").length;\n const approvedCount = rows.filter((s) => s.status === \"approved\" || s.status === \"unchanged\").length;\n const rejectedCount = rows.filter((s) => s.status === \"rejected\").length;\n return this.db.update(builds, id, {\n snapshotCount,\n changedCount,\n approvedCount,\n rejectedCount,\n updatedAt: new Date().toISOString(),\n });\n }\n\n async remove(id: string): Promise<void> {\n await this.db.remove(builds, id);\n }\n}\n\nexport type { Build };\n"],"mappings":"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"}
1
+ {"version":3,"file":"build.mjs","names":[],"sources":["../../src/models/build.ts"],"sourcesContent":["/** Build records, status transitions, and publication helpers. */\nimport { and, desc, eq, inArray } from \"drizzle-orm\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { builds } from \"../schema/build.ts\";\nimport type { Build } from \"../schema/build.ts\";\nimport { buildLabels } from \"../schema/label.ts\";\nimport { snapshots } from \"../schema/snapshot.ts\";\nimport type { BuildStatus } from \"../types.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/**\n * Return whether a build is publicly viewable without a session.\n *\n * A build is public iff `builds.public` is set or its branch matches the\n * project's `public_branch_regex` (ADR 0011). Supplying an empty/nonexistent\n * regex makes every build require auth.\n */\nexport function isPublicBuild(\n project: { publicBranchRegex: string | null },\n build: Pick<Build, \"public\" | \"gitBranch\">,\n): boolean {\n if (build.public) {\n return true;\n }\n if (!project.publicBranchRegex) {\n return false;\n }\n return new RegExp(project.publicBranchRegex, \"u\").test(build.gitBranch);\n}\n\n/** Input for creating a build. */\nexport interface BuildCreateInput {\n gitSha: string;\n gitBranch: string;\n isDefault?: boolean;\n authorEmail?: string;\n authorName?: string;\n message?: string;\n public?: boolean;\n}\n\n/** Filter options for listing builds. */\nexport interface BuildListFilter {\n status?: BuildStatus;\n branch?: string;\n labelKey?: string;\n labelValue?: string;\n}\n\n/** Data operations for build records. */\nexport class BuildModel {\n constructor(private readonly db: DatabaseAdapter) {}\n\n async create(projectId: string, input: BuildCreateInput): Promise<Build> {\n const now = new Date().toISOString();\n return await this.db.insert(builds, {\n id: ulid(),\n projectId,\n gitSha: input.gitSha,\n gitBranch: input.gitBranch,\n isDefault: input.isDefault ?? false,\n authorEmail: input.authorEmail,\n authorName: input.authorName,\n message: input.message,\n public: input.public ?? false,\n status: \"pending\",\n createdAt: now,\n updatedAt: now,\n });\n }\n\n async get(id: string): Promise<Build | null> {\n return await this.db.get(builds, id);\n }\n\n async list(projectId: string, filter: BuildListFilter = {}): Promise<Build[]> {\n const conditions = [eq(builds.projectId, projectId)];\n if (filter.status) {\n conditions.push(eq(builds.status, filter.status));\n }\n if (filter.branch) {\n conditions.push(eq(builds.gitBranch, filter.branch));\n }\n if (filter.labelKey && filter.labelValue) {\n const labels = await this.db.list(buildLabels, {\n where: and(\n eq(buildLabels.projectId, projectId),\n eq(buildLabels.typeKey, filter.labelKey),\n eq(buildLabels.value, filter.labelValue),\n ),\n });\n const ids = labels.map((l) => l.buildId);\n if (ids.length === 0) {\n return [];\n }\n conditions.push(inArray(builds.id, ids));\n }\n return this.db.list(builds, { where: and(...conditions), orderBy: desc(builds.createdAt) });\n }\n\n /** Return the most recent build intended for publishing, if any. */\n async latestPublished(project: {\n id: string;\n publicBranchRegex: string | null;\n }): Promise<Build | null> {\n const rows = await this.list(project.id);\n for (const build of rows) {\n if (isPublicBuild(project, build)) {\n return build;\n }\n }\n return null;\n }\n\n async update(\n id: string,\n patch: Partial<Pick<Build, \"status\" | \"public\" | \"message\" | \"authorEmail\" | \"authorName\">>,\n ): Promise<Build> {\n return await this.db.update(builds, id, { ...patch, updatedAt: new Date().toISOString() });\n }\n\n async setStatus(id: string, status: BuildStatus): Promise<Build> {\n return await this.update(id, { status });\n }\n\n async updateCounts(id: string): Promise<Build> {\n const rows = await this.db.list(snapshots, { where: eq(snapshots.buildId, id) });\n const snapshotCount = rows.length;\n const changedCount = rows.filter((s) => s.status === \"changed\" || s.status === \"new\").length;\n const approvedCount = rows.filter(\n (s) => s.status === \"approved\" || s.status === \"unchanged\",\n ).length;\n const rejectedCount = rows.filter((s) => s.status === \"rejected\").length;\n return this.db.update(builds, id, {\n snapshotCount,\n changedCount,\n approvedCount,\n rejectedCount,\n updatedAt: new Date().toISOString(),\n });\n }\n\n async remove(id: string): Promise<void> {\n await this.db.remove(builds, id);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAiBA,SAAgB,cACd,SACA,OACS;CACT,IAAI,MAAM,QACR,OAAO;CAET,IAAI,CAAC,QAAQ,mBACX,OAAO;CAET,OAAO,IAAI,OAAO,QAAQ,mBAAmB,GAAG,CAAC,CAAC,KAAK,MAAM,SAAS;AACxE;;AAsBA,IAAa,aAAb,MAAwB;CACO;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;CAEnD,MAAM,OAAO,WAAmB,OAAyC;EACvE,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EACnC,OAAO,MAAM,KAAK,GAAG,OAAO,QAAQ;GAClC,IAAI,KAAK;GACT;GACA,QAAQ,MAAM;GACd,WAAW,MAAM;GACjB,WAAW,MAAM,aAAa;GAC9B,aAAa,MAAM;GACnB,YAAY,MAAM;GAClB,SAAS,MAAM;GACf,QAAQ,MAAM,UAAU;GACxB,QAAQ;GACR,WAAW;GACX,WAAW;EACb,CAAC;CACH;CAEA,MAAM,IAAI,IAAmC;EAC3C,OAAO,MAAM,KAAK,GAAG,IAAI,QAAQ,EAAE;CACrC;CAEA,MAAM,KAAK,WAAmB,SAA0B,CAAC,GAAqB;EAC5E,MAAM,aAAa,CAAC,GAAG,OAAO,WAAW,SAAS,CAAC;EACnD,IAAI,OAAO,QACT,WAAW,KAAK,GAAG,OAAO,QAAQ,OAAO,MAAM,CAAC;EAElD,IAAI,OAAO,QACT,WAAW,KAAK,GAAG,OAAO,WAAW,OAAO,MAAM,CAAC;EAErD,IAAI,OAAO,YAAY,OAAO,YAAY;GAQxC,MAAM,OAAM,MAPS,KAAK,GAAG,KAAK,aAAa,EAC7C,OAAO,IACL,GAAG,YAAY,WAAW,SAAS,GACnC,GAAG,YAAY,SAAS,OAAO,QAAQ,GACvC,GAAG,YAAY,OAAO,OAAO,UAAU,CACzC,EACF,CAAC,EAAA,CACkB,KAAK,MAAM,EAAE,OAAO;GACvC,IAAI,IAAI,WAAW,GACjB,OAAO,CAAC;GAEV,WAAW,KAAK,QAAQ,OAAO,IAAI,GAAG,CAAC;EACzC;EACA,OAAO,KAAK,GAAG,KAAK,QAAQ;GAAE,OAAO,IAAI,GAAG,UAAU;GAAG,SAAS,KAAK,OAAO,SAAS;EAAE,CAAC;CAC5F;;CAGA,MAAM,gBAAgB,SAGI;EACxB,MAAM,OAAO,MAAM,KAAK,KAAK,QAAQ,EAAE;EACvC,KAAK,MAAM,SAAS,MAClB,IAAI,cAAc,SAAS,KAAK,GAC9B,OAAO;EAGX,OAAO;CACT;CAEA,MAAM,OACJ,IACA,OACgB;EAChB,OAAO,MAAM,KAAK,GAAG,OAAO,QAAQ,IAAI;GAAE,GAAG;GAAO,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EAAE,CAAC;CAC3F;CAEA,MAAM,UAAU,IAAY,QAAqC;EAC/D,OAAO,MAAM,KAAK,OAAO,IAAI,EAAE,OAAO,CAAC;CACzC;CAEA,MAAM,aAAa,IAA4B;EAC7C,MAAM,OAAO,MAAM,KAAK,GAAG,KAAK,WAAW,EAAE,OAAO,GAAG,UAAU,SAAS,EAAE,EAAE,CAAC;EAC/E,MAAM,gBAAgB,KAAK;EAC3B,MAAM,eAAe,KAAK,QAAQ,MAAM,EAAE,WAAW,aAAa,EAAE,WAAW,KAAK,CAAC,CAAC;EACtF,MAAM,gBAAgB,KAAK,QACxB,MAAM,EAAE,WAAW,cAAc,EAAE,WAAW,WACjD,CAAC,CAAC;EACF,MAAM,gBAAgB,KAAK,QAAQ,MAAM,EAAE,WAAW,UAAU,CAAC,CAAC;EAClE,OAAO,KAAK,GAAG,OAAO,QAAQ,IAAI;GAChC;GACA;GACA;GACA;GACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EACpC,CAAC;CACH;CAEA,MAAM,OAAO,IAA2B;EACtC,MAAM,KAAK,GAAG,OAAO,QAAQ,EAAE;CACjC;AACF"}
@@ -1,8 +1,9 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { comments, projects } from "../schema-tables.mjs";
4
+ import { projects } from "../schema/project.mjs";
5
5
  import { ulid } from "../utils/ulid.mjs";
6
+ import { comments } from "../schema/comment.mjs";
6
7
  import { eq } from "drizzle-orm";
7
8
  //#region src/models/comment.ts
8
9
  /** Build and snapshot review comments. */
@@ -1 +1 @@
1
- {"version":3,"file":"comment.mjs","names":[],"sources":["../../src/models/comment.ts"],"sourcesContent":["/** Build and snapshot review comments. */\nimport { eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { comments, projects } from \"../schema-tables.ts\";\nimport type { Comment } from \"../schema.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Input for creating a review comment. */\nexport interface CommentCreateInput {\n body: string;\n snapshotId?: string;\n parentId?: string;\n}\n\n/** Data operations for review comments. */\nexport class CommentModel {\n constructor(private readonly db: DatabaseAdapter) {}\n\n async listByBuild(buildId: string): Promise<Comment[]> {\n return await this.db.list(comments, { where: eq(comments.buildId, buildId) });\n }\n\n async create(projectId: string, buildId: string, userId: string, input: CommentCreateInput): Promise<Comment> {\n const now = new Date().toISOString();\n const project = await this.db.get(projects, projectId);\n if (!project) {\n throw new Error(`Project not found: ${projectId}`);\n }\n return await this.db.insert(comments, {\n id: ulid(),\n projectId,\n buildId,\n snapshotId: input.snapshotId,\n userId,\n body: input.body,\n parentId: input.parentId,\n resolved: false,\n createdAt: now,\n updatedAt: now,\n });\n }\n\n async resolve(id: string): Promise<Comment> {\n return await this.db.update(comments, id, { resolved: true, updatedAt: new Date().toISOString() });\n }\n}\n\nexport type { Comment };\n"],"mappings":";;;;;;;;;AAgBA,IAAa,eAAb,MAA0B;CACK;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;CAEnD,MAAM,YAAY,SAAqC;EACrD,OAAO,MAAM,KAAK,GAAG,KAAK,UAAU,EAAE,OAAO,GAAG,SAAS,SAAS,OAAO,EAAE,CAAC;CAC9E;CAEA,MAAM,OAAO,WAAmB,SAAiB,QAAgB,OAA6C;EAC5G,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EAEnC,IAAI,CAAC,MADiB,KAAK,GAAG,IAAI,UAAU,SAAS,GAEnD,MAAM,IAAI,MAAM,sBAAsB,WAAW;EAEnD,OAAO,MAAM,KAAK,GAAG,OAAO,UAAU;GACpC,IAAI,KAAK;GACT;GACA;GACA,YAAY,MAAM;GAClB;GACA,MAAM,MAAM;GACZ,UAAU,MAAM;GAChB,UAAU;GACV,WAAW;GACX,WAAW;EACb,CAAC;CACH;CAEA,MAAM,QAAQ,IAA8B;EAC1C,OAAO,MAAM,KAAK,GAAG,OAAO,UAAU,IAAI;GAAE,UAAU;GAAM,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EAAE,CAAC;CACnG;AACF"}
1
+ {"version":3,"file":"comment.mjs","names":[],"sources":["../../src/models/comment.ts"],"sourcesContent":["/** Build and snapshot review comments. */\nimport { eq } from \"drizzle-orm\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { comments } from \"../schema/comment.ts\";\nimport type { Comment } from \"../schema/comment.ts\";\nimport { projects } from \"../schema/project.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Input for creating a review comment. */\nexport interface CommentCreateInput {\n body: string;\n snapshotId?: string;\n parentId?: string;\n}\n\n/** Data operations for review comments. */\nexport class CommentModel {\n constructor(private readonly db: DatabaseAdapter) {}\n\n async listByBuild(buildId: string): Promise<Comment[]> {\n return await this.db.list(comments, { where: eq(comments.buildId, buildId) });\n }\n\n async create(\n projectId: string,\n buildId: string,\n userId: string,\n input: CommentCreateInput,\n ): Promise<Comment> {\n const now = new Date().toISOString();\n const project = await this.db.get(projects, projectId);\n if (!project) {\n throw new Error(`Project not found: ${projectId}`);\n }\n return await this.db.insert(comments, {\n id: ulid(),\n projectId,\n buildId,\n snapshotId: input.snapshotId,\n userId,\n body: input.body,\n parentId: input.parentId,\n resolved: false,\n createdAt: now,\n updatedAt: now,\n });\n }\n\n async resolve(id: string): Promise<Comment> {\n return await this.db.update(comments, id, {\n resolved: true,\n updatedAt: new Date().toISOString(),\n });\n }\n}\n"],"mappings":";;;;;;;;;;AAgBA,IAAa,eAAb,MAA0B;CACK;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;CAEnD,MAAM,YAAY,SAAqC;EACrD,OAAO,MAAM,KAAK,GAAG,KAAK,UAAU,EAAE,OAAO,GAAG,SAAS,SAAS,OAAO,EAAE,CAAC;CAC9E;CAEA,MAAM,OACJ,WACA,SACA,QACA,OACkB;EAClB,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EAEnC,IAAI,CAAC,MADiB,KAAK,GAAG,IAAI,UAAU,SAAS,GAEnD,MAAM,IAAI,MAAM,sBAAsB,WAAW;EAEnD,OAAO,MAAM,KAAK,GAAG,OAAO,UAAU;GACpC,IAAI,KAAK;GACT;GACA;GACA,YAAY,MAAM;GAClB;GACA,MAAM,MAAM;GACZ,UAAU,MAAM;GAChB,UAAU;GACV,WAAW;GACX,WAAW;EACb,CAAC;CACH;CAEA,MAAM,QAAQ,IAA8B;EAC1C,OAAO,MAAM,KAAK,GAAG,OAAO,UAAU,IAAI;GACxC,UAAU;GACV,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EACpC,CAAC;CACH;AACF"}
@@ -1,7 +1,8 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { buildLabels, builds, labelTypes } from "../schema-tables.mjs";
4
+ import { builds } from "../schema/build.mjs";
5
+ import { buildLabels, labelTypes } from "../schema/label.mjs";
5
6
  import { ulid } from "../utils/ulid.mjs";
6
7
  import { PERSISTENT_LABEL_KEY, RESERVED_LABEL_KEYS, SEEDED_LABEL_KEYS } from "../types.mjs";
7
8
  import { and, desc, eq } from "drizzle-orm";
@@ -1 +1 @@
1
- {"version":3,"file":"label.mjs","names":[],"sources":["../../src/models/label.ts"],"sourcesContent":["/** Build labels and project label types. */\nimport { and, desc, eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { buildLabels, builds, labelTypes } from \"../schema-tables.ts\";\nimport type { BuildLabel, LabelType } from \"../schema.ts\";\nimport { PERSISTENT_LABEL_KEY, RESERVED_LABEL_KEYS, SEEDED_LABEL_KEYS } from \"../types.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\nconst SEEDED_NAMES: Record<string, string> = {\n branch: \"Branch\",\n persistent: \"Persistent\",\n pr: \"Pull request\",\n mr: \"Merge request\",\n jira: \"Jira issue\",\n linear: \"Linear issue\",\n figma: \"Figma file\",\n custom: \"Custom\",\n};\n\n/** Data operations for label types and build labels. */\nexport class LabelModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /**\n * Seed the default label types for a project if missing.\n *\n * @param projectId - Project ID.\n */\n async seedFor(projectId: string): Promise<void> {\n const now = new Date().toISOString();\n const existing = await this.listTypes(projectId);\n const existingKeys = new Set(existing.map((t) => t.key));\n const missing = SEEDED_LABEL_KEYS.filter((key) => !existingKeys.has(key));\n await Promise.all(\n missing.map(async (key) => {\n await this.db.insert(labelTypes, {\n id: ulid(),\n projectId,\n key,\n name: SEEDED_NAMES[key] ?? key,\n createdAt: now,\n });\n }),\n );\n }\n\n /** List all label types for a project. */\n async listTypes(projectId: string): Promise<LabelType[]> {\n return await this.db.list(labelTypes, { where: eq(labelTypes.projectId, projectId) });\n }\n\n /** Fetch a label type by key, or null if not found. */\n async getType(projectId: string, key: string): Promise<LabelType | null> {\n const rows = await this.db.list(labelTypes, {\n where: and(eq(labelTypes.projectId, projectId), eq(labelTypes.key, key)),\n limit: 1,\n });\n return rows[0] ?? null;\n }\n\n /** Create a custom label type for a project. */\n async createType(projectId: string, input: { key: string; name: string; linkTemplate?: string; color?: string }): Promise<LabelType> {\n return await this.db.insert(labelTypes, {\n id: ulid(),\n projectId,\n key: input.key,\n name: input.name,\n linkTemplate: input.linkTemplate,\n color: input.color,\n createdAt: new Date().toISOString(),\n });\n }\n\n /** Update a custom label type's name, template or color. */\n async updateType(projectId: string, key: string, input: { name?: string; linkTemplate?: string | null; color?: string | null }): Promise<LabelType | null> {\n if (key === PERSISTENT_LABEL_KEY || RESERVED_LABEL_KEYS.includes(key as never)) {\n throw new Error(`Label type '${key}' cannot be updated.`);\n }\n const existing = await this.getType(projectId, key);\n if (!existing) {\n return null;\n }\n return await this.db.update(labelTypes, existing.id, {\n name: input.name ?? existing.name,\n linkTemplate: input.linkTemplate === undefined ? existing.linkTemplate : input.linkTemplate,\n color: input.color === undefined ? existing.color : input.color,\n });\n }\n\n /** Remove a custom label type, rejecting reserved or persistent types. */\n async removeType(projectId: string, key: string): Promise<void> {\n if (key === PERSISTENT_LABEL_KEY || RESERVED_LABEL_KEYS.includes(key as never)) {\n throw new Error(`Label type '${key}' cannot be removed.`);\n }\n const existing = await this.getType(projectId, key);\n if (existing) {\n await this.db.remove(labelTypes, existing.id);\n }\n }\n\n /** Attach a label value to a build. */\n async attach(projectId: string, buildId: string, typeKey: string, value: string): Promise<BuildLabel> {\n return await this.db.insert(buildLabels, {\n id: ulid(),\n projectId,\n buildId,\n typeKey,\n value,\n createdAt: new Date().toISOString(),\n });\n }\n\n /** List all labels attached to a build. */\n async listForBuild(buildId: string): Promise<BuildLabel[]> {\n return await this.db.list(buildLabels, { where: eq(buildLabels.buildId, buildId) });\n }\n\n /** Return the id of the latest build carrying a given label value, if any. */\n async latestBuildId(projectId: string, typeKey: string, value: string): Promise<string | null> {\n const labels = await this.db.list(buildLabels, {\n where: and(eq(buildLabels.projectId, projectId), eq(buildLabels.typeKey, typeKey), eq(buildLabels.value, value)),\n });\n if (labels.length === 0) {\n return null;\n }\n const idSet = new Set(labels.map((l) => l.buildId));\n const rows = await this.db.list(builds, { orderBy: desc(builds.createdAt) });\n return rows.find((b) => idSet.has(b.id))?.id ?? null;\n }\n\n /** Return whether a build is marked as persistent. */\n async hasPersistent(projectId: string, buildId: string): Promise<boolean> {\n const labels = await this.db.list(buildLabels, {\n where: and(eq(buildLabels.projectId, projectId), eq(buildLabels.buildId, buildId), eq(buildLabels.typeKey, PERSISTENT_LABEL_KEY)),\n });\n return labels.length > 0;\n }\n}\n\nexport type { BuildLabel, LabelType } from \"../schema.ts\";\n"],"mappings":"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"}
1
+ {"version":3,"file":"label.mjs","names":[],"sources":["../../src/models/label.ts"],"sourcesContent":["/** Build labels and project label types. */\nimport { and, desc, eq } from \"drizzle-orm\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { builds } from \"../schema/build.ts\";\nimport { buildLabels, labelTypes } from \"../schema/label.ts\";\nimport type { BuildLabel, LabelType } from \"../schema/label.ts\";\nimport { PERSISTENT_LABEL_KEY, RESERVED_LABEL_KEYS, SEEDED_LABEL_KEYS } from \"../types.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\nconst SEEDED_NAMES: Record<string, string> = {\n branch: \"Branch\",\n persistent: \"Persistent\",\n pr: \"Pull request\",\n mr: \"Merge request\",\n jira: \"Jira issue\",\n linear: \"Linear issue\",\n figma: \"Figma file\",\n custom: \"Custom\",\n};\n\n/** Data operations for label types and build labels. */\nexport class LabelModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /**\n * Seed the default label types for a project if missing.\n *\n * @param projectId - Project ID.\n */\n async seedFor(projectId: string): Promise<void> {\n const now = new Date().toISOString();\n const existing = await this.listTypes(projectId);\n const existingKeys = new Set(existing.map((t) => t.key));\n const missing = SEEDED_LABEL_KEYS.filter((key) => !existingKeys.has(key));\n await Promise.all(\n missing.map(async (key) => {\n await this.db.insert(labelTypes, {\n id: ulid(),\n projectId,\n key,\n name: SEEDED_NAMES[key] ?? key,\n createdAt: now,\n });\n }),\n );\n }\n\n /** List all label types for a project. */\n async listTypes(projectId: string): Promise<LabelType[]> {\n return await this.db.list(labelTypes, { where: eq(labelTypes.projectId, projectId) });\n }\n\n /** Fetch a label type by key, or null if not found. */\n async getType(projectId: string, key: string): Promise<LabelType | null> {\n const rows = await this.db.list(labelTypes, {\n where: and(eq(labelTypes.projectId, projectId), eq(labelTypes.key, key)),\n limit: 1,\n });\n return rows[0] ?? null;\n }\n\n /** Create a custom label type for a project. */\n async createType(\n projectId: string,\n input: { key: string; name: string; linkTemplate?: string; color?: string },\n ): Promise<LabelType> {\n return await this.db.insert(labelTypes, {\n id: ulid(),\n projectId,\n key: input.key,\n name: input.name,\n linkTemplate: input.linkTemplate,\n color: input.color,\n createdAt: new Date().toISOString(),\n });\n }\n\n /** Update a custom label type's name, template or color. */\n async updateType(\n projectId: string,\n key: string,\n input: { name?: string; linkTemplate?: string | null; color?: string | null },\n ): Promise<LabelType | null> {\n if (key === PERSISTENT_LABEL_KEY || RESERVED_LABEL_KEYS.includes(key as never)) {\n throw new Error(`Label type '${key}' cannot be updated.`);\n }\n const existing = await this.getType(projectId, key);\n if (!existing) {\n return null;\n }\n return await this.db.update(labelTypes, existing.id, {\n name: input.name ?? existing.name,\n linkTemplate: input.linkTemplate === undefined ? existing.linkTemplate : input.linkTemplate,\n color: input.color === undefined ? existing.color : input.color,\n });\n }\n\n /** Remove a custom label type, rejecting reserved or persistent types. */\n async removeType(projectId: string, key: string): Promise<void> {\n if (key === PERSISTENT_LABEL_KEY || RESERVED_LABEL_KEYS.includes(key as never)) {\n throw new Error(`Label type '${key}' cannot be removed.`);\n }\n const existing = await this.getType(projectId, key);\n if (existing) {\n await this.db.remove(labelTypes, existing.id);\n }\n }\n\n /** Attach a label value to a build. */\n async attach(\n projectId: string,\n buildId: string,\n typeKey: string,\n value: string,\n ): Promise<BuildLabel> {\n return await this.db.insert(buildLabels, {\n id: ulid(),\n projectId,\n buildId,\n typeKey,\n value,\n createdAt: new Date().toISOString(),\n });\n }\n\n /** List all labels attached to a build. */\n async listForBuild(buildId: string): Promise<BuildLabel[]> {\n return await this.db.list(buildLabels, { where: eq(buildLabels.buildId, buildId) });\n }\n\n /** Return the id of the latest build carrying a given label value, if any. */\n async latestBuildId(projectId: string, typeKey: string, value: string): Promise<string | null> {\n const labels = await this.db.list(buildLabels, {\n where: and(\n eq(buildLabels.projectId, projectId),\n eq(buildLabels.typeKey, typeKey),\n eq(buildLabels.value, value),\n ),\n });\n if (labels.length === 0) {\n return null;\n }\n const idSet = new Set(labels.map((l) => l.buildId));\n const rows = await this.db.list(builds, { orderBy: desc(builds.createdAt) });\n return rows.find((b) => idSet.has(b.id))?.id ?? null;\n }\n\n /** Return whether a build is marked as persistent. */\n async hasPersistent(projectId: string, buildId: string): Promise<boolean> {\n const labels = await this.db.list(buildLabels, {\n where: and(\n eq(buildLabels.projectId, projectId),\n eq(buildLabels.buildId, buildId),\n eq(buildLabels.typeKey, PERSISTENT_LABEL_KEY),\n ),\n });\n return labels.length > 0;\n }\n}\n"],"mappings":";;;;;;;;;;AASA,MAAM,eAAuC;CAC3C,QAAQ;CACR,YAAY;CACZ,IAAI;CACJ,IAAI;CACJ,MAAM;CACN,QAAQ;CACR,OAAO;CACP,QAAQ;AACV;;AAGA,IAAa,aAAb,MAAwB;CAIO;;;;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;;;;;;CAOnD,MAAM,QAAQ,WAAkC;EAC9C,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EACnC,MAAM,WAAW,MAAM,KAAK,UAAU,SAAS;EAC/C,MAAM,eAAe,IAAI,IAAI,SAAS,KAAK,MAAM,EAAE,GAAG,CAAC;EACvD,MAAM,UAAU,kBAAkB,QAAQ,QAAQ,CAAC,aAAa,IAAI,GAAG,CAAC;EACxE,MAAM,QAAQ,IACZ,QAAQ,IAAI,OAAO,QAAQ;GACzB,MAAM,KAAK,GAAG,OAAO,YAAY;IAC/B,IAAI,KAAK;IACT;IACA;IACA,MAAM,aAAa,QAAQ;IAC3B,WAAW;GACb,CAAC;EACH,CAAC,CACH;CACF;;CAGA,MAAM,UAAU,WAAyC;EACvD,OAAO,MAAM,KAAK,GAAG,KAAK,YAAY,EAAE,OAAO,GAAG,WAAW,WAAW,SAAS,EAAE,CAAC;CACtF;;CAGA,MAAM,QAAQ,WAAmB,KAAwC;EAKvE,QAAO,MAJY,KAAK,GAAG,KAAK,YAAY;GAC1C,OAAO,IAAI,GAAG,WAAW,WAAW,SAAS,GAAG,GAAG,WAAW,KAAK,GAAG,CAAC;GACvE,OAAO;EACT,CAAC,EAAA,CACW,MAAM;CACpB;;CAGA,MAAM,WACJ,WACA,OACoB;EACpB,OAAO,MAAM,KAAK,GAAG,OAAO,YAAY;GACtC,IAAI,KAAK;GACT;GACA,KAAK,MAAM;GACX,MAAM,MAAM;GACZ,cAAc,MAAM;GACpB,OAAO,MAAM;GACb,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EACpC,CAAC;CACH;;CAGA,MAAM,WACJ,WACA,KACA,OAC2B;EAC3B,IAAI,QAAA,gBAAgC,oBAAoB,SAAS,GAAY,GAC3E,MAAM,IAAI,MAAM,eAAe,IAAI,qBAAqB;EAE1D,MAAM,WAAW,MAAM,KAAK,QAAQ,WAAW,GAAG;EAClD,IAAI,CAAC,UACH,OAAO;EAET,OAAO,MAAM,KAAK,GAAG,OAAO,YAAY,SAAS,IAAI;GACnD,MAAM,MAAM,QAAQ,SAAS;GAC7B,cAAc,MAAM,iBAAiB,KAAA,IAAY,SAAS,eAAe,MAAM;GAC/E,OAAO,MAAM,UAAU,KAAA,IAAY,SAAS,QAAQ,MAAM;EAC5D,CAAC;CACH;;CAGA,MAAM,WAAW,WAAmB,KAA4B;EAC9D,IAAI,QAAA,gBAAgC,oBAAoB,SAAS,GAAY,GAC3E,MAAM,IAAI,MAAM,eAAe,IAAI,qBAAqB;EAE1D,MAAM,WAAW,MAAM,KAAK,QAAQ,WAAW,GAAG;EAClD,IAAI,UACF,MAAM,KAAK,GAAG,OAAO,YAAY,SAAS,EAAE;CAEhD;;CAGA,MAAM,OACJ,WACA,SACA,SACA,OACqB;EACrB,OAAO,MAAM,KAAK,GAAG,OAAO,aAAa;GACvC,IAAI,KAAK;GACT;GACA;GACA;GACA;GACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EACpC,CAAC;CACH;;CAGA,MAAM,aAAa,SAAwC;EACzD,OAAO,MAAM,KAAK,GAAG,KAAK,aAAa,EAAE,OAAO,GAAG,YAAY,SAAS,OAAO,EAAE,CAAC;CACpF;;CAGA,MAAM,cAAc,WAAmB,SAAiB,OAAuC;EAC7F,MAAM,SAAS,MAAM,KAAK,GAAG,KAAK,aAAa,EAC7C,OAAO,IACL,GAAG,YAAY,WAAW,SAAS,GACnC,GAAG,YAAY,SAAS,OAAO,GAC/B,GAAG,YAAY,OAAO,KAAK,CAC7B,EACF,CAAC;EACD,IAAI,OAAO,WAAW,GACpB,OAAO;EAET,MAAM,QAAQ,IAAI,IAAI,OAAO,KAAK,MAAM,EAAE,OAAO,CAAC;EAElD,QAAO,MADY,KAAK,GAAG,KAAK,QAAQ,EAAE,SAAS,KAAK,OAAO,SAAS,EAAE,CAAC,EAAA,CAC/D,MAAM,MAAM,MAAM,IAAI,EAAE,EAAE,CAAC,CAAC,EAAE,MAAM;CAClD;;CAGA,MAAM,cAAc,WAAmB,SAAmC;EAQxE,QAAO,MAPc,KAAK,GAAG,KAAK,aAAa,EAC7C,OAAO,IACL,GAAG,YAAY,WAAW,SAAS,GACnC,GAAG,YAAY,SAAS,OAAO,GAC/B,GAAG,YAAY,SAAS,oBAAoB,CAC9C,EACF,CAAC,EAAA,CACa,SAAS;CACzB;AACF"}
@@ -1,8 +1,8 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { projectMembers } from "../schema-tables.mjs";
5
4
  import { ulid } from "../utils/ulid.mjs";
5
+ import { projectMembers } from "../schema/member.mjs";
6
6
  import { and, eq } from "drizzle-orm";
7
7
  //#region src/models/member.ts
8
8
  /** Project membership and role resolution. */
@@ -1 +1 @@
1
- {"version":3,"file":"member.mjs","names":[],"sources":["../../src/models/member.ts"],"sourcesContent":["/** Project membership and role resolution. */\nimport { and, eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { projectMembers } from \"../schema-tables.ts\";\nimport type { ProjectMember } from \"../schema.ts\";\nimport type { ProjectRole, SiteRole } from \"../types.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Data operations for project membership and roles. */\nexport class MemberModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /** List all members of a project. */\n async list(projectId: string): Promise<ProjectMember[]> {\n return await this.db.list(projectMembers, { where: eq(projectMembers.projectId, projectId) });\n }\n\n /** Fetch a project member by project and user id, or null if not found. */\n async get(projectId: string, userId: string): Promise<ProjectMember | null> {\n const rows = await this.db.list(projectMembers, {\n where: and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)),\n limit: 1,\n });\n return rows[0] ?? null;\n }\n\n /** Set a user's role in a project, creating the membership if needed. */\n async set(projectId: string, userId: string, role: ProjectRole): Promise<ProjectMember> {\n const existing = await this.get(projectId, userId);\n if (existing) {\n return this.db.update(projectMembers, existing.id, { role });\n }\n return this.db.insert(projectMembers, {\n id: ulid(),\n projectId,\n userId,\n role,\n createdAt: new Date().toISOString(),\n });\n }\n\n /** Remove a user from a project if they are a member. */\n async remove(projectId: string, userId: string): Promise<void> {\n const existing = await this.get(projectId, userId);\n if (existing) {\n await this.db.remove(projectMembers, existing.id);\n }\n }\n\n /** Resolve a user's effective project role, honoring site-wide admins. */\n async effectiveRole(siteRole: SiteRole, projectId: string, userId: string): Promise<ProjectRole | null> {\n if (siteRole === \"admin\") {\n return \"admin\";\n }\n const member = await this.get(projectId, userId);\n return member?.role ?? null;\n }\n}\n\nexport type { ProjectMember };\n"],"mappings":";;;;;;;;;AAUA,IAAa,cAAb,MAAyB;CAIM;;;;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;;CAGnD,MAAM,KAAK,WAA6C;EACtD,OAAO,MAAM,KAAK,GAAG,KAAK,gBAAgB,EAAE,OAAO,GAAG,eAAe,WAAW,SAAS,EAAE,CAAC;CAC9F;;CAGA,MAAM,IAAI,WAAmB,QAA+C;EAK1E,QAAO,MAJY,KAAK,GAAG,KAAK,gBAAgB;GAC9C,OAAO,IAAI,GAAG,eAAe,WAAW,SAAS,GAAG,GAAG,eAAe,QAAQ,MAAM,CAAC;GACrF,OAAO;EACT,CAAC,EAAA,CACW,MAAM;CACpB;;CAGA,MAAM,IAAI,WAAmB,QAAgB,MAA2C;EACtF,MAAM,WAAW,MAAM,KAAK,IAAI,WAAW,MAAM;EACjD,IAAI,UACF,OAAO,KAAK,GAAG,OAAO,gBAAgB,SAAS,IAAI,EAAE,KAAK,CAAC;EAE7D,OAAO,KAAK,GAAG,OAAO,gBAAgB;GACpC,IAAI,KAAK;GACT;GACA;GACA;GACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EACpC,CAAC;CACH;;CAGA,MAAM,OAAO,WAAmB,QAA+B;EAC7D,MAAM,WAAW,MAAM,KAAK,IAAI,WAAW,MAAM;EACjD,IAAI,UACF,MAAM,KAAK,GAAG,OAAO,gBAAgB,SAAS,EAAE;CAEpD;;CAGA,MAAM,cAAc,UAAoB,WAAmB,QAA6C;EACtG,IAAI,aAAa,SACf,OAAO;EAGT,QAAO,MADc,KAAK,IAAI,WAAW,MAAM,EAAA,EAChC,QAAQ;CACzB;AACF"}
1
+ {"version":3,"file":"member.mjs","names":[],"sources":["../../src/models/member.ts"],"sourcesContent":["/** Project membership and role resolution. */\nimport { and, eq } from \"drizzle-orm\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { projectMembers } from \"../schema/member.ts\";\nimport type { ProjectMember } from \"../schema/member.ts\";\nimport type { ProjectRole, SiteRole } from \"../types.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Data operations for project membership and roles. */\nexport class MemberModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /** List all members of a project. */\n async list(projectId: string): Promise<ProjectMember[]> {\n return await this.db.list(projectMembers, { where: eq(projectMembers.projectId, projectId) });\n }\n\n /** Fetch a project member by project and user id, or null if not found. */\n async get(projectId: string, userId: string): Promise<ProjectMember | null> {\n const rows = await this.db.list(projectMembers, {\n where: and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)),\n limit: 1,\n });\n return rows[0] ?? null;\n }\n\n /** Set a user's role in a project, creating the membership if needed. */\n async set(projectId: string, userId: string, role: ProjectRole): Promise<ProjectMember> {\n const existing = await this.get(projectId, userId);\n if (existing) {\n return this.db.update(projectMembers, existing.id, { role });\n }\n return this.db.insert(projectMembers, {\n id: ulid(),\n projectId,\n userId,\n role,\n createdAt: new Date().toISOString(),\n });\n }\n\n /** Remove a user from a project if they are a member. */\n async remove(projectId: string, userId: string): Promise<void> {\n const existing = await this.get(projectId, userId);\n if (existing) {\n await this.db.remove(projectMembers, existing.id);\n }\n }\n\n /** Resolve a user's effective project role, honoring site-wide admins. */\n async effectiveRole(\n siteRole: SiteRole,\n projectId: string,\n userId: string,\n ): Promise<ProjectRole | null> {\n if (siteRole === \"admin\") {\n return \"admin\";\n }\n const member = await this.get(projectId, userId);\n return member?.role ?? null;\n }\n}\n"],"mappings":";;;;;;;;;AASA,IAAa,cAAb,MAAyB;CAIM;;;;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;;CAGnD,MAAM,KAAK,WAA6C;EACtD,OAAO,MAAM,KAAK,GAAG,KAAK,gBAAgB,EAAE,OAAO,GAAG,eAAe,WAAW,SAAS,EAAE,CAAC;CAC9F;;CAGA,MAAM,IAAI,WAAmB,QAA+C;EAK1E,QAAO,MAJY,KAAK,GAAG,KAAK,gBAAgB;GAC9C,OAAO,IAAI,GAAG,eAAe,WAAW,SAAS,GAAG,GAAG,eAAe,QAAQ,MAAM,CAAC;GACrF,OAAO;EACT,CAAC,EAAA,CACW,MAAM;CACpB;;CAGA,MAAM,IAAI,WAAmB,QAAgB,MAA2C;EACtF,MAAM,WAAW,MAAM,KAAK,IAAI,WAAW,MAAM;EACjD,IAAI,UACF,OAAO,KAAK,GAAG,OAAO,gBAAgB,SAAS,IAAI,EAAE,KAAK,CAAC;EAE7D,OAAO,KAAK,GAAG,OAAO,gBAAgB;GACpC,IAAI,KAAK;GACT;GACA;GACA;GACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EACpC,CAAC;CACH;;CAGA,MAAM,OAAO,WAAmB,QAA+B;EAC7D,MAAM,WAAW,MAAM,KAAK,IAAI,WAAW,MAAM;EACjD,IAAI,UACF,MAAM,KAAK,GAAG,OAAO,gBAAgB,SAAS,EAAE;CAEpD;;CAGA,MAAM,cACJ,UACA,WACA,QAC6B;EAC7B,IAAI,aAAa,SACf,OAAO;EAGT,QAAO,MADc,KAAK,IAAI,WAAW,MAAM,EAAA,EAChC,QAAQ;CACzB;AACF"}
@@ -1,7 +1,7 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { projects } from "../schema-tables.mjs";
4
+ import { projects } from "../schema/project.mjs";
5
5
  import { slugify, ulid } from "../utils/ulid.mjs";
6
6
  import { eq } from "drizzle-orm";
7
7
  //#region src/models/project.ts
@@ -1 +1 @@
1
- {"version":3,"file":"project.mjs","names":[],"sources":["../../src/models/project.ts"],"sourcesContent":["/** Project records and slug management. */\nimport { eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { projects } from \"../schema-tables.ts\";\nimport type { Project } from \"../schema.ts\";\nimport { slugify, ulid } from \"../utils/ulid.ts\";\n\n/** Input for creating a project. */\nexport interface ProjectCreateInput {\n name: string;\n gitRepository?: string;\n gitDefaultBranch?: string;\n storybookMeta?: unknown;\n}\n\n/** Data operations for project records. */\nexport class ProjectModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /**\n * Create a project with a unique slug.\n *\n * @param input - Project creation input.\n * @returns The created project.\n */\n async create(input: ProjectCreateInput): Promise<Project> {\n const now = new Date().toISOString();\n const slug = await this.uniqueSlug(input.name);\n return this.db.insert(projects, {\n id: ulid(),\n name: input.name,\n slug,\n gitRepository: input.gitRepository,\n gitDefaultBranch: input.gitDefaultBranch ?? \"main\",\n storybookMeta: input.storybookMeta ? JSON.stringify(input.storybookMeta) : null,\n createdAt: now,\n updatedAt: now,\n });\n }\n\n /** Fetch a project by id, or null if not found. */\n async get(id: string): Promise<Project | null> {\n return await this.db.get(projects, id);\n }\n\n /** Fetch a project by its slug, or null if not found. */\n async getBySlug(slug: string): Promise<Project | null> {\n const rows = await this.db.list(projects, { where: eq(projects.slug, slug), limit: 1 });\n return rows[0] ?? null;\n }\n\n /** List all projects. */\n async list(): Promise<Project[]> {\n return await this.db.list(projects);\n }\n\n /** Update mutable fields of a project. */\n async update(\n id: string,\n patch: Partial<Omit<Project, \"storybookMeta\">> & {\n storybookMeta?: unknown;\n },\n ): Promise<Project> {\n const normalized: Partial<Project> & Record<string, unknown> = { ...patch } as unknown as Partial<Project> & Record<string, unknown>;\n if (patch.storybookMeta !== undefined && patch.storybookMeta !== null && typeof patch.storybookMeta !== \"string\") {\n normalized.storybookMeta = JSON.stringify(patch.storybookMeta);\n }\n return await this.db.update(projects, id, {\n ...normalized,\n updatedAt: new Date().toISOString(),\n });\n }\n\n /** Delete a project by id. */\n async remove(id: string): Promise<void> {\n await this.db.remove(projects, id);\n }\n\n private async uniqueSlug(name: string): Promise<string> {\n const base = slugify(name) || \"project\";\n const existing = await this.db.list(projects, { where: eq(projects.slug, base) });\n if (existing.length === 0) {\n return base;\n }\n const taken = new Set(existing.map((p) => p.slug));\n let suffix = 2;\n while (taken.has(`${base}-${suffix}`)) {\n suffix += 1;\n }\n return `${base}-${suffix}`;\n }\n}\n\nexport type { Project };\n"],"mappings":";;;;;;;;;AAiBA,IAAa,eAAb,MAA0B;CAIK;;;;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;;;;;;;CAQnD,MAAM,OAAO,OAA6C;EACxD,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EACnC,MAAM,OAAO,MAAM,KAAK,WAAW,MAAM,IAAI;EAC7C,OAAO,KAAK,GAAG,OAAO,UAAU;GAC9B,IAAI,KAAK;GACT,MAAM,MAAM;GACZ;GACA,eAAe,MAAM;GACrB,kBAAkB,MAAM,oBAAoB;GAC5C,eAAe,MAAM,gBAAgB,KAAK,UAAU,MAAM,aAAa,IAAI;GAC3E,WAAW;GACX,WAAW;EACb,CAAC;CACH;;CAGA,MAAM,IAAI,IAAqC;EAC7C,OAAO,MAAM,KAAK,GAAG,IAAI,UAAU,EAAE;CACvC;;CAGA,MAAM,UAAU,MAAuC;EAErD,QAAO,MADY,KAAK,GAAG,KAAK,UAAU;GAAE,OAAO,GAAG,SAAS,MAAM,IAAI;GAAG,OAAO;EAAE,CAAC,EAAA,CAC1E,MAAM;CACpB;;CAGA,MAAM,OAA2B;EAC/B,OAAO,MAAM,KAAK,GAAG,KAAK,QAAQ;CACpC;;CAGA,MAAM,OACJ,IACA,OAGkB;EAClB,MAAM,aAAyD,EAAE,GAAG,MAAM;EAC1E,IAAI,MAAM,kBAAkB,KAAA,KAAa,MAAM,kBAAkB,QAAQ,OAAO,MAAM,kBAAkB,UACtG,WAAW,gBAAgB,KAAK,UAAU,MAAM,aAAa;EAE/D,OAAO,MAAM,KAAK,GAAG,OAAO,UAAU,IAAI;GACxC,GAAG;GACH,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EACpC,CAAC;CACH;;CAGA,MAAM,OAAO,IAA2B;EACtC,MAAM,KAAK,GAAG,OAAO,UAAU,EAAE;CACnC;CAEA,MAAc,WAAW,MAA+B;EACtD,MAAM,OAAO,QAAQ,IAAI,KAAK;EAC9B,MAAM,WAAW,MAAM,KAAK,GAAG,KAAK,UAAU,EAAE,OAAO,GAAG,SAAS,MAAM,IAAI,EAAE,CAAC;EAChF,IAAI,SAAS,WAAW,GACtB,OAAO;EAET,MAAM,QAAQ,IAAI,IAAI,SAAS,KAAK,MAAM,EAAE,IAAI,CAAC;EACjD,IAAI,SAAS;EACb,OAAO,MAAM,IAAI,GAAG,KAAK,GAAG,QAAQ,GAClC,UAAU;EAEZ,OAAO,GAAG,KAAK,GAAG;CACpB;AACF"}
1
+ {"version":3,"file":"project.mjs","names":[],"sources":["../../src/models/project.ts"],"sourcesContent":["/** Project records and slug management. */\nimport { eq } from \"drizzle-orm\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { projects } from \"../schema/project.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport { slugify, ulid } from \"../utils/ulid.ts\";\n\n/** Input for creating a project. */\nexport interface ProjectCreateInput {\n name: string;\n gitRepository?: string;\n gitDefaultBranch?: string;\n storybookMeta?: unknown;\n}\n\n/** Data operations for project records. */\nexport class ProjectModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /**\n * Create a project with a unique slug.\n *\n * @param input - Project creation input.\n * @returns The created project.\n */\n async create(input: ProjectCreateInput): Promise<Project> {\n const now = new Date().toISOString();\n const slug = await this.uniqueSlug(input.name);\n return this.db.insert(projects, {\n id: ulid(),\n name: input.name,\n slug,\n gitRepository: input.gitRepository,\n gitDefaultBranch: input.gitDefaultBranch ?? \"main\",\n storybookMeta: input.storybookMeta ? JSON.stringify(input.storybookMeta) : null,\n createdAt: now,\n updatedAt: now,\n });\n }\n\n /** Fetch a project by id, or null if not found. */\n async get(id: string): Promise<Project | null> {\n return await this.db.get(projects, id);\n }\n\n /** Fetch a project by its slug, or null if not found. */\n async getBySlug(slug: string): Promise<Project | null> {\n const rows = await this.db.list(projects, { where: eq(projects.slug, slug), limit: 1 });\n return rows[0] ?? null;\n }\n\n /** List all projects. */\n async list(): Promise<Project[]> {\n return await this.db.list(projects);\n }\n\n /** Update mutable fields of a project. */\n async update(\n id: string,\n patch: Partial<Omit<Project, \"storybookMeta\">> & {\n storybookMeta?: unknown;\n },\n ): Promise<Project> {\n const normalized: Partial<Project> & Record<string, unknown> = {\n ...patch,\n } as unknown as Partial<Project> & Record<string, unknown>;\n if (\n patch.storybookMeta !== undefined &&\n patch.storybookMeta !== null &&\n typeof patch.storybookMeta !== \"string\"\n ) {\n normalized.storybookMeta = JSON.stringify(patch.storybookMeta);\n }\n return await this.db.update(projects, id, {\n ...normalized,\n updatedAt: new Date().toISOString(),\n });\n }\n\n /** Delete a project by id. */\n async remove(id: string): Promise<void> {\n await this.db.remove(projects, id);\n }\n\n private async uniqueSlug(name: string): Promise<string> {\n const base = slugify(name) || \"project\";\n const existing = await this.db.list(projects, { where: eq(projects.slug, base) });\n if (existing.length === 0) {\n return base;\n }\n const taken = new Set(existing.map((p) => p.slug));\n let suffix = 2;\n while (taken.has(`${base}-${suffix}`)) {\n suffix += 1;\n }\n return `${base}-${suffix}`;\n }\n}\n"],"mappings":";;;;;;;;;AAgBA,IAAa,eAAb,MAA0B;CAIK;;;;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;;;;;;;CAQnD,MAAM,OAAO,OAA6C;EACxD,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EACnC,MAAM,OAAO,MAAM,KAAK,WAAW,MAAM,IAAI;EAC7C,OAAO,KAAK,GAAG,OAAO,UAAU;GAC9B,IAAI,KAAK;GACT,MAAM,MAAM;GACZ;GACA,eAAe,MAAM;GACrB,kBAAkB,MAAM,oBAAoB;GAC5C,eAAe,MAAM,gBAAgB,KAAK,UAAU,MAAM,aAAa,IAAI;GAC3E,WAAW;GACX,WAAW;EACb,CAAC;CACH;;CAGA,MAAM,IAAI,IAAqC;EAC7C,OAAO,MAAM,KAAK,GAAG,IAAI,UAAU,EAAE;CACvC;;CAGA,MAAM,UAAU,MAAuC;EAErD,QAAO,MADY,KAAK,GAAG,KAAK,UAAU;GAAE,OAAO,GAAG,SAAS,MAAM,IAAI;GAAG,OAAO;EAAE,CAAC,EAAA,CAC1E,MAAM;CACpB;;CAGA,MAAM,OAA2B;EAC/B,OAAO,MAAM,KAAK,GAAG,KAAK,QAAQ;CACpC;;CAGA,MAAM,OACJ,IACA,OAGkB;EAClB,MAAM,aAAyD,EAC7D,GAAG,MACL;EACA,IACE,MAAM,kBAAkB,KAAA,KACxB,MAAM,kBAAkB,QACxB,OAAO,MAAM,kBAAkB,UAE/B,WAAW,gBAAgB,KAAK,UAAU,MAAM,aAAa;EAE/D,OAAO,MAAM,KAAK,GAAG,OAAO,UAAU,IAAI;GACxC,GAAG;GACH,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EACpC,CAAC;CACH;;CAGA,MAAM,OAAO,IAA2B;EACtC,MAAM,KAAK,GAAG,OAAO,UAAU,EAAE;CACnC;CAEA,MAAc,WAAW,MAA+B;EACtD,MAAM,OAAO,QAAQ,IAAI,KAAK;EAC9B,MAAM,WAAW,MAAM,KAAK,GAAG,KAAK,UAAU,EAAE,OAAO,GAAG,SAAS,MAAM,IAAI,EAAE,CAAC;EAChF,IAAI,SAAS,WAAW,GACtB,OAAO;EAET,MAAM,QAAQ,IAAI,IAAI,SAAS,KAAK,MAAM,EAAE,IAAI,CAAC;EACjD,IAAI,SAAS;EACb,OAAO,MAAM,IAAI,GAAG,KAAK,GAAG,QAAQ,GAClC,UAAU;EAEZ,OAAO,GAAG,KAAK,GAAG;CACpB;AACF"}
@@ -1,7 +1,7 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { snapshots } from "../schema-tables.mjs";
4
+ import { snapshots } from "../schema/snapshot.mjs";
5
5
  import { ulid } from "../utils/ulid.mjs";
6
6
  import { eq } from "drizzle-orm";
7
7
  //#region src/models/snapshot.ts
@@ -1 +1 @@
1
- {"version":3,"file":"snapshot.mjs","names":[],"sources":["../../src/models/snapshot.ts"],"sourcesContent":["/** Snapshot records for captured stories within a build. */\nimport { eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { snapshots } from \"../schema-tables.ts\";\nimport type { Snapshot } from \"../schema.ts\";\nimport type { SnapshotStatus } from \"../types.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Input for creating a snapshot. */\nexport interface SnapshotCreateInput {\n storyId: string;\n storyName: string;\n storyTitle: string;\n storyImportPath?: string;\n viewportName: string;\n viewportWidth: number;\n viewportHeight: number;\n screenshotPath: string;\n}\n\n/** Data operations for snapshot records. */\nexport class SnapshotModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /**\n * Create a snapshot for a story within a build.\n *\n * @param projectId - Project ID.\n * @param buildId - Build ID.\n * @param input - Snapshot creation input.\n * @returns The created snapshot.\n */\n async create(projectId: string, buildId: string, input: SnapshotCreateInput): Promise<Snapshot> {\n const now = new Date().toISOString();\n return await this.db.insert(snapshots, {\n id: ulid(),\n projectId,\n buildId,\n storyId: input.storyId,\n storyName: input.storyName,\n storyTitle: input.storyTitle,\n storyImportPath: input.storyImportPath,\n viewportName: input.viewportName,\n viewportWidth: input.viewportWidth,\n viewportHeight: input.viewportHeight,\n screenshotPath: input.screenshotPath,\n status: \"pending\",\n createdAt: now,\n updatedAt: now,\n });\n }\n\n /** List all snapshots belonging to a build. */\n async listByBuild(buildId: string): Promise<Snapshot[]> {\n return await this.db.list(snapshots, { where: eq(snapshots.buildId, buildId) });\n }\n\n /** Fetch a snapshot by id, or null if not found. */\n async get(id: string): Promise<Snapshot | null> {\n return await this.db.get(snapshots, id);\n }\n\n /** Update mutable fields of a snapshot. */\n async update(id: string, patch: Partial<Snapshot>): Promise<Snapshot> {\n return await this.db.update(snapshots, id, { ...patch, updatedAt: new Date().toISOString() });\n }\n\n /** Set the status of a snapshot. */\n async setStatus(id: string, status: SnapshotStatus): Promise<Snapshot> {\n return await this.update(id, { status });\n }\n\n /** Record a reviewer's decision on a snapshot. */\n async review(id: string, status: SnapshotStatus, userId: string): Promise<Snapshot> {\n return await this.update(id, { status, reviewedBy: userId, reviewedAt: new Date().toISOString() });\n }\n}\n\nexport type { Snapshot };\n"],"mappings":";;;;;;;;;AAsBA,IAAa,gBAAb,MAA2B;CAII;;;;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;;;;;;;;;CAUnD,MAAM,OAAO,WAAmB,SAAiB,OAA+C;EAC9F,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EACnC,OAAO,MAAM,KAAK,GAAG,OAAO,WAAW;GACrC,IAAI,KAAK;GACT;GACA;GACA,SAAS,MAAM;GACf,WAAW,MAAM;GACjB,YAAY,MAAM;GAClB,iBAAiB,MAAM;GACvB,cAAc,MAAM;GACpB,eAAe,MAAM;GACrB,gBAAgB,MAAM;GACtB,gBAAgB,MAAM;GACtB,QAAQ;GACR,WAAW;GACX,WAAW;EACb,CAAC;CACH;;CAGA,MAAM,YAAY,SAAsC;EACtD,OAAO,MAAM,KAAK,GAAG,KAAK,WAAW,EAAE,OAAO,GAAG,UAAU,SAAS,OAAO,EAAE,CAAC;CAChF;;CAGA,MAAM,IAAI,IAAsC;EAC9C,OAAO,MAAM,KAAK,GAAG,IAAI,WAAW,EAAE;CACxC;;CAGA,MAAM,OAAO,IAAY,OAA6C;EACpE,OAAO,MAAM,KAAK,GAAG,OAAO,WAAW,IAAI;GAAE,GAAG;GAAO,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EAAE,CAAC;CAC9F;;CAGA,MAAM,UAAU,IAAY,QAA2C;EACrE,OAAO,MAAM,KAAK,OAAO,IAAI,EAAE,OAAO,CAAC;CACzC;;CAGA,MAAM,OAAO,IAAY,QAAwB,QAAmC;EAClF,OAAO,MAAM,KAAK,OAAO,IAAI;GAAE;GAAQ,YAAY;GAAQ,6BAAY,IAAI,KAAK,EAAA,CAAE,YAAY;EAAE,CAAC;CACnG;AACF"}
1
+ {"version":3,"file":"snapshot.mjs","names":[],"sources":["../../src/models/snapshot.ts"],"sourcesContent":["/** Snapshot records for captured stories within a build. */\nimport { eq } from \"drizzle-orm\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { snapshots } from \"../schema/snapshot.ts\";\nimport type { Snapshot } from \"../schema/snapshot.ts\";\nimport type { SnapshotStatus } from \"../types.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Input for creating a snapshot. */\nexport interface SnapshotCreateInput {\n storyId: string;\n storyName: string;\n storyTitle: string;\n storyImportPath?: string;\n viewportName: string;\n viewportWidth: number;\n viewportHeight: number;\n screenshotPath: string;\n}\n\n/** Data operations for snapshot records. */\nexport class SnapshotModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /**\n * Create a snapshot for a story within a build.\n *\n * @param projectId - Project ID.\n * @param buildId - Build ID.\n * @param input - Snapshot creation input.\n * @returns The created snapshot.\n */\n async create(projectId: string, buildId: string, input: SnapshotCreateInput): Promise<Snapshot> {\n const now = new Date().toISOString();\n return await this.db.insert(snapshots, {\n id: ulid(),\n projectId,\n buildId,\n storyId: input.storyId,\n storyName: input.storyName,\n storyTitle: input.storyTitle,\n storyImportPath: input.storyImportPath,\n viewportName: input.viewportName,\n viewportWidth: input.viewportWidth,\n viewportHeight: input.viewportHeight,\n screenshotPath: input.screenshotPath,\n status: \"pending\",\n createdAt: now,\n updatedAt: now,\n });\n }\n\n /** List all snapshots belonging to a build. */\n async listByBuild(buildId: string): Promise<Snapshot[]> {\n return await this.db.list(snapshots, { where: eq(snapshots.buildId, buildId) });\n }\n\n /** Fetch a snapshot by id, or null if not found. */\n async get(id: string): Promise<Snapshot | null> {\n return await this.db.get(snapshots, id);\n }\n\n /** Update mutable fields of a snapshot. */\n async update(id: string, patch: Partial<Snapshot>): Promise<Snapshot> {\n return await this.db.update(snapshots, id, { ...patch, updatedAt: new Date().toISOString() });\n }\n\n /** Set the status of a snapshot. */\n async setStatus(id: string, status: SnapshotStatus): Promise<Snapshot> {\n return await this.update(id, { status });\n }\n\n /** Record a reviewer's decision on a snapshot. */\n async review(id: string, status: SnapshotStatus, userId: string): Promise<Snapshot> {\n return await this.update(id, {\n status,\n reviewedBy: userId,\n reviewedAt: new Date().toISOString(),\n });\n }\n}\n"],"mappings":";;;;;;;;;AAqBA,IAAa,gBAAb,MAA2B;CAII;;;;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;;;;;;;;;CAUnD,MAAM,OAAO,WAAmB,SAAiB,OAA+C;EAC9F,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EACnC,OAAO,MAAM,KAAK,GAAG,OAAO,WAAW;GACrC,IAAI,KAAK;GACT;GACA;GACA,SAAS,MAAM;GACf,WAAW,MAAM;GACjB,YAAY,MAAM;GAClB,iBAAiB,MAAM;GACvB,cAAc,MAAM;GACpB,eAAe,MAAM;GACrB,gBAAgB,MAAM;GACtB,gBAAgB,MAAM;GACtB,QAAQ;GACR,WAAW;GACX,WAAW;EACb,CAAC;CACH;;CAGA,MAAM,YAAY,SAAsC;EACtD,OAAO,MAAM,KAAK,GAAG,KAAK,WAAW,EAAE,OAAO,GAAG,UAAU,SAAS,OAAO,EAAE,CAAC;CAChF;;CAGA,MAAM,IAAI,IAAsC;EAC9C,OAAO,MAAM,KAAK,GAAG,IAAI,WAAW,EAAE;CACxC;;CAGA,MAAM,OAAO,IAAY,OAA6C;EACpE,OAAO,MAAM,KAAK,GAAG,OAAO,WAAW,IAAI;GAAE,GAAG;GAAO,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EAAE,CAAC;CAC9F;;CAGA,MAAM,UAAU,IAAY,QAA2C;EACrE,OAAO,MAAM,KAAK,OAAO,IAAI,EAAE,OAAO,CAAC;CACzC;;CAGA,MAAM,OAAO,IAAY,QAAwB,QAAmC;EAClF,OAAO,MAAM,KAAK,OAAO,IAAI;GAC3B;GACA,YAAY;GACZ,6BAAY,IAAI,KAAK,EAAA,CAAE,YAAY;EACrC,CAAC;CACH;AACF"}
@@ -1,9 +1,9 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { projectStatusConfigs } from "../schema-tables.mjs";
5
4
  import { ulid } from "../utils/ulid.mjs";
6
5
  import { decrypt, encrypt } from "../utils/encrypt.mjs";
6
+ import { projectStatusConfigs } from "../schema/status-config.mjs";
7
7
  import { eq } from "drizzle-orm";
8
8
  //#region src/models/status-config.ts
9
9
  /** Per-project git-provider status check configurations. */
@@ -1 +1 @@
1
- {"version":3,"file":"status-config.mjs","names":[],"sources":["../../src/models/status-config.ts"],"sourcesContent":["/* oxlint-disable eslint/no-await-in-loop, typescript/promise-function-async, eslint/require-await */\n/** Per-project git-provider status check configurations. */\nimport { eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { projectStatusConfigs } from \"../schema-tables.ts\";\nimport type { ProjectStatusConfig } from \"../schema.ts\";\nimport { decrypt, encrypt } from \"../utils/encrypt.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Input for creating a git-provider status config. */\nexport interface StatusConfigCreateInput {\n provider: string;\n config: unknown;\n token: string;\n}\n\n/** Data operations for per-project status provider configs. */\nexport class StatusConfigModel {\n constructor(\n private readonly db: DatabaseAdapter,\n private readonly secret: string | undefined,\n ) {}\n\n /** List all status configs for a project. */\n async list(projectId: string): Promise<ProjectStatusConfig[]> {\n return await this.db.list(projectStatusConfigs, { where: eq(projectStatusConfigs.projectId, projectId) });\n }\n\n /** Fetch a config by id scoped to a project, or null. */\n async get(projectId: string, id: string): Promise<ProjectStatusConfig | null> {\n const rows = await this.db.list(projectStatusConfigs, { where: eq(projectStatusConfigs.id, id), limit: 1 });\n const found = rows[0] ?? null;\n return found?.projectId === projectId ? found : null;\n }\n\n /** Decrypt the token for a stored row. */\n decryptToken(row: ProjectStatusConfig): string {\n return decrypt(this.secret, row.tokenEncrypted);\n }\n\n /** Parse the JSON config column. */\n static parseConfig(row: ProjectStatusConfig): unknown {\n return JSON.parse(row.config) as unknown;\n }\n\n async create(projectId: string, input: StatusConfigCreateInput): Promise<ProjectStatusConfig> {\n const now = new Date().toISOString();\n return await this.db.insert(projectStatusConfigs, {\n id: ulid(),\n projectId,\n provider: input.provider,\n config: JSON.stringify(input.config),\n tokenEncrypted: encrypt(this.secret, input.token),\n createdAt: now,\n updatedAt: now,\n });\n }\n\n async remove(projectId: string, id: string): Promise<void> {\n const existing = await this.get(projectId, id);\n if (existing) {\n await this.db.remove(projectStatusConfigs, existing.id);\n }\n }\n\n async removeByProject(projectId: string): Promise<void> {\n const rows = await this.list(projectId);\n for (const row of rows) {\n await this.db.remove(projectStatusConfigs, row.id);\n }\n }\n}\n\nexport type { ProjectStatusConfig };\n"],"mappings":";;;;;;;;;;AAkBA,IAAa,oBAAb,MAA+B;CAEV;CACA;CAFnB,YACE,IACA,QACA;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;;CAGH,MAAM,KAAK,WAAmD;EAC5D,OAAO,MAAM,KAAK,GAAG,KAAK,sBAAsB,EAAE,OAAO,GAAG,qBAAqB,WAAW,SAAS,EAAE,CAAC;CAC1G;;CAGA,MAAM,IAAI,WAAmB,IAAiD;EAE5E,MAAM,SAAQ,MADK,KAAK,GAAG,KAAK,sBAAsB;GAAE,OAAO,GAAG,qBAAqB,IAAI,EAAE;GAAG,OAAO;EAAE,CAAC,EAAA,CACvF,MAAM;EACzB,OAAO,OAAO,cAAc,YAAY,QAAQ;CAClD;;CAGA,aAAa,KAAkC;EAC7C,OAAO,QAAQ,KAAK,QAAQ,IAAI,cAAc;CAChD;;CAGA,OAAO,YAAY,KAAmC;EACpD,OAAO,KAAK,MAAM,IAAI,MAAM;CAC9B;CAEA,MAAM,OAAO,WAAmB,OAA8D;EAC5F,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EACnC,OAAO,MAAM,KAAK,GAAG,OAAO,sBAAsB;GAChD,IAAI,KAAK;GACT;GACA,UAAU,MAAM;GAChB,QAAQ,KAAK,UAAU,MAAM,MAAM;GACnC,gBAAgB,QAAQ,KAAK,QAAQ,MAAM,KAAK;GAChD,WAAW;GACX,WAAW;EACb,CAAC;CACH;CAEA,MAAM,OAAO,WAAmB,IAA2B;EACzD,MAAM,WAAW,MAAM,KAAK,IAAI,WAAW,EAAE;EAC7C,IAAI,UACF,MAAM,KAAK,GAAG,OAAO,sBAAsB,SAAS,EAAE;CAE1D;CAEA,MAAM,gBAAgB,WAAkC;EACtD,MAAM,OAAO,MAAM,KAAK,KAAK,SAAS;EACtC,KAAK,MAAM,OAAO,MAChB,MAAM,KAAK,GAAG,OAAO,sBAAsB,IAAI,EAAE;CAErD;AACF"}
1
+ {"version":3,"file":"status-config.mjs","names":[],"sources":["../../src/models/status-config.ts"],"sourcesContent":["/* oxlint-disable eslint/no-await-in-loop, typescript/promise-function-async, eslint/require-await */\n/** Per-project git-provider status check configurations. */\nimport { eq } from \"drizzle-orm\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { projectStatusConfigs } from \"../schema/status-config.ts\";\nimport type { ProjectStatusConfig } from \"../schema/status-config.ts\";\nimport { decrypt, encrypt } from \"../utils/encrypt.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Input for creating a git-provider status config. */\nexport interface StatusConfigCreateInput {\n provider: string;\n config: unknown;\n token: string;\n}\n\n/** Data operations for per-project status provider configs. */\nexport class StatusConfigModel {\n constructor(\n private readonly db: DatabaseAdapter,\n private readonly secret: string | undefined,\n ) {}\n\n /** List all status configs for a project. */\n async list(projectId: string): Promise<ProjectStatusConfig[]> {\n return await this.db.list(projectStatusConfigs, {\n where: eq(projectStatusConfigs.projectId, projectId),\n });\n }\n\n /** Fetch a config by id scoped to a project, or null. */\n async get(projectId: string, id: string): Promise<ProjectStatusConfig | null> {\n const rows = await this.db.list(projectStatusConfigs, {\n where: eq(projectStatusConfigs.id, id),\n limit: 1,\n });\n const found = rows[0] ?? null;\n return found?.projectId === projectId ? found : null;\n }\n\n /** Decrypt the token for a stored row. */\n decryptToken(row: ProjectStatusConfig): string {\n return decrypt(this.secret, row.tokenEncrypted);\n }\n\n /** Parse the JSON config column. */\n static parseConfig(row: ProjectStatusConfig): unknown {\n return JSON.parse(row.config) as unknown;\n }\n\n async create(projectId: string, input: StatusConfigCreateInput): Promise<ProjectStatusConfig> {\n const now = new Date().toISOString();\n return await this.db.insert(projectStatusConfigs, {\n id: ulid(),\n projectId,\n provider: input.provider,\n config: JSON.stringify(input.config),\n tokenEncrypted: encrypt(this.secret, input.token),\n createdAt: now,\n updatedAt: now,\n });\n }\n\n async remove(projectId: string, id: string): Promise<void> {\n const existing = await this.get(projectId, id);\n if (existing) {\n await this.db.remove(projectStatusConfigs, existing.id);\n }\n }\n\n async removeByProject(projectId: string): Promise<void> {\n const rows = await this.list(projectId);\n for (const row of rows) {\n await this.db.remove(projectStatusConfigs, row.id);\n }\n }\n}\n"],"mappings":";;;;;;;;;;AAiBA,IAAa,oBAAb,MAA+B;CAEV;CACA;CAFnB,YACE,IACA,QACA;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;;CAGH,MAAM,KAAK,WAAmD;EAC5D,OAAO,MAAM,KAAK,GAAG,KAAK,sBAAsB,EAC9C,OAAO,GAAG,qBAAqB,WAAW,SAAS,EACrD,CAAC;CACH;;CAGA,MAAM,IAAI,WAAmB,IAAiD;EAK5E,MAAM,SAAQ,MAJK,KAAK,GAAG,KAAK,sBAAsB;GACpD,OAAO,GAAG,qBAAqB,IAAI,EAAE;GACrC,OAAO;EACT,CAAC,EAAA,CACkB,MAAM;EACzB,OAAO,OAAO,cAAc,YAAY,QAAQ;CAClD;;CAGA,aAAa,KAAkC;EAC7C,OAAO,QAAQ,KAAK,QAAQ,IAAI,cAAc;CAChD;;CAGA,OAAO,YAAY,KAAmC;EACpD,OAAO,KAAK,MAAM,IAAI,MAAM;CAC9B;CAEA,MAAM,OAAO,WAAmB,OAA8D;EAC5F,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EACnC,OAAO,MAAM,KAAK,GAAG,OAAO,sBAAsB;GAChD,IAAI,KAAK;GACT;GACA,UAAU,MAAM;GAChB,QAAQ,KAAK,UAAU,MAAM,MAAM;GACnC,gBAAgB,QAAQ,KAAK,QAAQ,MAAM,KAAK;GAChD,WAAW;GACX,WAAW;EACb,CAAC;CACH;CAEA,MAAM,OAAO,WAAmB,IAA2B;EACzD,MAAM,WAAW,MAAM,KAAK,IAAI,WAAW,EAAE;EAC7C,IAAI,UACF,MAAM,KAAK,GAAG,OAAO,sBAAsB,SAAS,EAAE;CAE1D;CAEA,MAAM,gBAAgB,WAAkC;EACtD,MAAM,OAAO,MAAM,KAAK,KAAK,SAAS;EACtC,KAAK,MAAM,OAAO,MAChB,MAAM,KAAK,GAAG,OAAO,sBAAsB,IAAI,EAAE;CAErD;AACF"}
@@ -1,8 +1,8 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { tokens } from "../schema-tables.mjs";
5
4
  import { ulid } from "../utils/ulid.mjs";
5
+ import { tokens } from "../schema/token.mjs";
6
6
  import { and, eq } from "drizzle-orm";
7
7
  //#region src/models/token.ts
8
8
  /** CI tokens for project-scoped API access. */
@@ -1 +1 @@
1
- {"version":3,"file":"token.mjs","names":[],"sources":["../../src/models/token.ts"],"sourcesContent":["/** CI tokens for project-scoped API access. */\nimport { and, eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { tokens } from \"../schema-tables.ts\";\nimport type { Token } from \"../schema.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Data operations for CI tokens. */\nexport class TokenModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /**\n * Create a token record storing its hash.\n *\n * @param projectId - Project ID.\n * @param name - Token name.\n * @param hash - Hashed token value.\n * @returns The created token.\n */\n async create(projectId: string, name: string, hash: string): Promise<Token> {\n return await this.db.insert(tokens, {\n id: ulid(),\n projectId,\n name,\n hash,\n createdAt: new Date().toISOString(),\n });\n }\n\n /** List all tokens for a project. */\n async list(projectId: string): Promise<Token[]> {\n return await this.db.list(tokens, { where: eq(tokens.projectId, projectId) });\n }\n\n /** Fetch a token by id within a project, or null if not found. */\n async get(projectId: string, id: string): Promise<Token | null> {\n const rows = await this.db.list(tokens, {\n where: and(eq(tokens.projectId, projectId), eq(tokens.id, id)),\n limit: 1,\n });\n return rows[0] ?? null;\n }\n\n /** Fetch a token by its hashed value, or null if not found. */\n async findByHash(hash: string): Promise<Token | null> {\n const rows = await this.db.list(tokens, { where: eq(tokens.hash, hash), limit: 1 });\n return rows[0] ?? null;\n }\n\n /** Delete a token by id. */\n async remove(id: string): Promise<void> {\n await this.db.remove(tokens, id);\n }\n}\n\nexport type { Token };\n"],"mappings":";;;;;;;;;AASA,IAAa,aAAb,MAAwB;CAIO;;;;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;;;;;;;;;CAUnD,MAAM,OAAO,WAAmB,MAAc,MAA8B;EAC1E,OAAO,MAAM,KAAK,GAAG,OAAO,QAAQ;GAClC,IAAI,KAAK;GACT;GACA;GACA;GACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EACpC,CAAC;CACH;;CAGA,MAAM,KAAK,WAAqC;EAC9C,OAAO,MAAM,KAAK,GAAG,KAAK,QAAQ,EAAE,OAAO,GAAG,OAAO,WAAW,SAAS,EAAE,CAAC;CAC9E;;CAGA,MAAM,IAAI,WAAmB,IAAmC;EAK9D,QAAO,MAJY,KAAK,GAAG,KAAK,QAAQ;GACtC,OAAO,IAAI,GAAG,OAAO,WAAW,SAAS,GAAG,GAAG,OAAO,IAAI,EAAE,CAAC;GAC7D,OAAO;EACT,CAAC,EAAA,CACW,MAAM;CACpB;;CAGA,MAAM,WAAW,MAAqC;EAEpD,QAAO,MADY,KAAK,GAAG,KAAK,QAAQ;GAAE,OAAO,GAAG,OAAO,MAAM,IAAI;GAAG,OAAO;EAAE,CAAC,EAAA,CACtE,MAAM;CACpB;;CAGA,MAAM,OAAO,IAA2B;EACtC,MAAM,KAAK,GAAG,OAAO,QAAQ,EAAE;CACjC;AACF"}
1
+ {"version":3,"file":"token.mjs","names":[],"sources":["../../src/models/token.ts"],"sourcesContent":["/** CI tokens for project-scoped API access. */\nimport { and, eq } from \"drizzle-orm\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { tokens } from \"../schema/token.ts\";\nimport type { Token } from \"../schema/token.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Data operations for CI tokens. */\nexport class TokenModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /**\n * Create a token record storing its hash.\n *\n * @param projectId - Project ID.\n * @param name - Token name.\n * @param hash - Hashed token value.\n * @returns The created token.\n */\n async create(projectId: string, name: string, hash: string): Promise<Token> {\n return await this.db.insert(tokens, {\n id: ulid(),\n projectId,\n name,\n hash,\n createdAt: new Date().toISOString(),\n });\n }\n\n /** List all tokens for a project. */\n async list(projectId: string): Promise<Token[]> {\n return await this.db.list(tokens, { where: eq(tokens.projectId, projectId) });\n }\n\n /** Fetch a token by id within a project, or null if not found. */\n async get(projectId: string, id: string): Promise<Token | null> {\n const rows = await this.db.list(tokens, {\n where: and(eq(tokens.projectId, projectId), eq(tokens.id, id)),\n limit: 1,\n });\n return rows[0] ?? null;\n }\n\n /** Fetch a token by its hashed value, or null if not found. */\n async findByHash(hash: string): Promise<Token | null> {\n const rows = await this.db.list(tokens, { where: eq(tokens.hash, hash), limit: 1 });\n return rows[0] ?? null;\n }\n\n /** Delete a token by id. */\n async remove(id: string): Promise<void> {\n await this.db.remove(tokens, id);\n }\n}\n"],"mappings":";;;;;;;;;AAQA,IAAa,aAAb,MAAwB;CAIO;;;;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;;;;;;;;;CAUnD,MAAM,OAAO,WAAmB,MAAc,MAA8B;EAC1E,OAAO,MAAM,KAAK,GAAG,OAAO,QAAQ;GAClC,IAAI,KAAK;GACT;GACA;GACA;GACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EACpC,CAAC;CACH;;CAGA,MAAM,KAAK,WAAqC;EAC9C,OAAO,MAAM,KAAK,GAAG,KAAK,QAAQ,EAAE,OAAO,GAAG,OAAO,WAAW,SAAS,EAAE,CAAC;CAC9E;;CAGA,MAAM,IAAI,WAAmB,IAAmC;EAK9D,QAAO,MAJY,KAAK,GAAG,KAAK,QAAQ;GACtC,OAAO,IAAI,GAAG,OAAO,WAAW,SAAS,GAAG,GAAG,OAAO,IAAI,EAAE,CAAC;GAC7D,OAAO;EACT,CAAC,EAAA,CACW,MAAM;CACpB;;CAGA,MAAM,WAAW,MAAqC;EAEpD,QAAO,MADY,KAAK,GAAG,KAAK,QAAQ;GAAE,OAAO,GAAG,OAAO,MAAM,IAAI;GAAG,OAAO;EAAE,CAAC,EAAA,CACtE,MAAM;CACpB;;CAGA,MAAM,OAAO,IAA2B;EACtC,MAAM,KAAK,GAAG,OAAO,QAAQ,EAAE;CACjC;AACF"}
@@ -1,19 +1,23 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { webhooks } from "../schema-tables.mjs";
5
4
  import { ulid } from "../utils/ulid.mjs";
5
+ import { webhooks } from "../schema/webhook.mjs";
6
+ import { decrypt, encrypt } from "../utils/encrypt.mjs";
6
7
  import { eq } from "drizzle-orm";
7
8
  //#region src/models/webhook.ts
8
9
  /** Webhook subscriptions for project events. */
9
10
  /** Data operations for webhook subscriptions. */
10
11
  var WebhookModel = class {
11
12
  db;
13
+ secret;
12
14
  /**
13
15
  * @param db - Database adapter.
16
+ * @param secret - Server secret for webhook-secret encryption (throws on write/decrypt when unset).
14
17
  */
15
- constructor(db) {
18
+ constructor(db, secret) {
16
19
  this.db = db;
20
+ this.secret = secret;
17
21
  }
18
22
  /** List all webhooks for a project. */
19
23
  async list(projectId) {
@@ -32,12 +36,16 @@ var WebhookModel = class {
32
36
  id: ulid(),
33
37
  projectId,
34
38
  url: input.url,
35
- secret: input.secret,
39
+ secretEncrypted: encrypt(this.secret, input.secret),
36
40
  events: input.events && input.events.length > 0 ? JSON.stringify(input.events) : null,
37
41
  createdAt: now,
38
42
  updatedAt: now
39
43
  });
40
44
  }
45
+ /** Decrypt the secret for a stored row (in memory only, at send time). */
46
+ decryptSecret(row) {
47
+ return decrypt(this.secret, row.secretEncrypted);
48
+ }
41
49
  /** Fetch a webhook by id scoped to a project, or null if not found. */
42
50
  async get(projectId, id) {
43
51
  const found = (await this.db.list(webhooks, {
@@ -1 +1 @@
1
- {"version":3,"file":"webhook.mjs","names":[],"sources":["../../src/models/webhook.ts"],"sourcesContent":["/** Webhook subscriptions for project events. */\nimport { eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { webhooks } from \"../schema-tables.ts\";\nimport type { Webhook } from \"../schema.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Input for creating a webhook subscription. */\nexport interface WebhookCreateInput {\n url: string;\n events?: string[];\n secret: string;\n}\n\n/** Data operations for webhook subscriptions. */\nexport class WebhookModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /** List all webhooks for a project. */\n async list(projectId: string): Promise<Webhook[]> {\n return await this.db.list(webhooks, { where: eq(webhooks.projectId, projectId) });\n }\n\n /**\n * Create a webhook subscription for a project.\n *\n * @param projectId - Project ID.\n * @param input - Webhook creation input.\n * @returns The created webhook.\n */\n async create(projectId: string, input: WebhookCreateInput): Promise<Webhook> {\n const now = new Date().toISOString();\n return await this.db.insert(webhooks, {\n id: ulid(),\n projectId,\n url: input.url,\n secret: input.secret,\n events: input.events && input.events.length > 0 ? JSON.stringify(input.events) : null,\n createdAt: now,\n updatedAt: now,\n });\n }\n\n /** Fetch a webhook by id scoped to a project, or null if not found. */\n async get(projectId: string, id: string): Promise<Webhook | null> {\n const rows = await this.db.list(webhooks, { where: eq(webhooks.id, id), limit: 1 });\n const found = rows[0] ?? null;\n return found?.projectId === projectId ? found : null;\n }\n\n /** Remove a webhook if it belongs to the given project. */\n async remove(projectId: string, id: string): Promise<void> {\n const existing = await this.get(projectId, id);\n if (existing) {\n await this.db.remove(webhooks, existing.id);\n }\n }\n\n /** Decode the JSON-serialized event list of a webhook. */\n static eventsOf(webhook: Webhook): string[] {\n if (!webhook.events) {\n return [];\n }\n try {\n const parsed: unknown = JSON.parse(webhook.events);\n return Array.isArray(parsed) ? parsed.filter((event): event is string => typeof event === \"string\") : [];\n } catch {\n return [];\n }\n }\n}\n\nexport type { Webhook };"],"mappings":";;;;;;;;;AAgBA,IAAa,eAAb,MAA0B;CAIK;;;;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;;CAGnD,MAAM,KAAK,WAAuC;EAChD,OAAO,MAAM,KAAK,GAAG,KAAK,UAAU,EAAE,OAAO,GAAG,SAAS,WAAW,SAAS,EAAE,CAAC;CAClF;;;;;;;;CASA,MAAM,OAAO,WAAmB,OAA6C;EAC3E,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EACnC,OAAO,MAAM,KAAK,GAAG,OAAO,UAAU;GACpC,IAAI,KAAK;GACT;GACA,KAAK,MAAM;GACX,QAAQ,MAAM;GACd,QAAQ,MAAM,UAAU,MAAM,OAAO,SAAS,IAAI,KAAK,UAAU,MAAM,MAAM,IAAI;GACjF,WAAW;GACX,WAAW;EACb,CAAC;CACH;;CAGA,MAAM,IAAI,WAAmB,IAAqC;EAEhE,MAAM,SAAQ,MADK,KAAK,GAAG,KAAK,UAAU;GAAE,OAAO,GAAG,SAAS,IAAI,EAAE;GAAG,OAAO;EAAE,CAAC,EAAA,CAC/D,MAAM;EACzB,OAAO,OAAO,cAAc,YAAY,QAAQ;CAClD;;CAGA,MAAM,OAAO,WAAmB,IAA2B;EACzD,MAAM,WAAW,MAAM,KAAK,IAAI,WAAW,EAAE;EAC7C,IAAI,UACF,MAAM,KAAK,GAAG,OAAO,UAAU,SAAS,EAAE;CAE9C;;CAGA,OAAO,SAAS,SAA4B;EAC1C,IAAI,CAAC,QAAQ,QACX,OAAO,CAAC;EAEV,IAAI;GACF,MAAM,SAAkB,KAAK,MAAM,QAAQ,MAAM;GACjD,OAAO,MAAM,QAAQ,MAAM,IAAI,OAAO,QAAQ,UAA2B,OAAO,UAAU,QAAQ,IAAI,CAAC;EACzG,QAAQ;GACN,OAAO,CAAC;EACV;CACF;AACF"}
1
+ {"version":3,"file":"webhook.mjs","names":[],"sources":["../../src/models/webhook.ts"],"sourcesContent":["/** Webhook subscriptions for project events. */\nimport { eq } from \"drizzle-orm\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { webhooks } from \"../schema/webhook.ts\";\nimport type { Webhook } from \"../schema/webhook.ts\";\nimport { decrypt, encrypt } from \"../utils/encrypt.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Input for creating a webhook subscription. */\nexport interface WebhookCreateInput {\n url: string;\n events?: string[];\n secret: string;\n}\n\n/** Data operations for webhook subscriptions. */\nexport class WebhookModel {\n /**\n * @param db - Database adapter.\n * @param secret - Server secret for webhook-secret encryption (throws on write/decrypt when unset).\n */\n constructor(\n private readonly db: DatabaseAdapter,\n private readonly secret?: string,\n ) {}\n\n /** List all webhooks for a project. */\n async list(projectId: string): Promise<Webhook[]> {\n return await this.db.list(webhooks, { where: eq(webhooks.projectId, projectId) });\n }\n\n /**\n * Create a webhook subscription for a project.\n *\n * @param projectId - Project ID.\n * @param input - Webhook creation input.\n * @returns The created webhook.\n */\n async create(projectId: string, input: WebhookCreateInput): Promise<Webhook> {\n const now = new Date().toISOString();\n return await this.db.insert(webhooks, {\n id: ulid(),\n projectId,\n url: input.url,\n secretEncrypted: encrypt(this.secret, input.secret),\n events: input.events && input.events.length > 0 ? JSON.stringify(input.events) : null,\n createdAt: now,\n updatedAt: now,\n });\n }\n\n /** Decrypt the secret for a stored row (in memory only, at send time). */\n decryptSecret(row: Webhook): string {\n return decrypt(this.secret, row.secretEncrypted);\n }\n\n /** Fetch a webhook by id scoped to a project, or null if not found. */\n async get(projectId: string, id: string): Promise<Webhook | null> {\n const rows = await this.db.list(webhooks, { where: eq(webhooks.id, id), limit: 1 });\n const found = rows[0] ?? null;\n return found?.projectId === projectId ? found : null;\n }\n\n /** Remove a webhook if it belongs to the given project. */\n async remove(projectId: string, id: string): Promise<void> {\n const existing = await this.get(projectId, id);\n if (existing) {\n await this.db.remove(webhooks, existing.id);\n }\n }\n\n /** Decode the JSON-serialized event list of a webhook. */\n static eventsOf(webhook: Webhook): string[] {\n if (!webhook.events) {\n return [];\n }\n try {\n const parsed: unknown = JSON.parse(webhook.events);\n return Array.isArray(parsed)\n ? parsed.filter((event): event is string => typeof event === \"string\")\n : [];\n } catch {\n return [];\n }\n }\n}\n"],"mappings":";;;;;;;;;;AAgBA,IAAa,eAAb,MAA0B;CAML;CACA;;;;;CAFnB,YACE,IACA,QACA;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;;CAGH,MAAM,KAAK,WAAuC;EAChD,OAAO,MAAM,KAAK,GAAG,KAAK,UAAU,EAAE,OAAO,GAAG,SAAS,WAAW,SAAS,EAAE,CAAC;CAClF;;;;;;;;CASA,MAAM,OAAO,WAAmB,OAA6C;EAC3E,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EACnC,OAAO,MAAM,KAAK,GAAG,OAAO,UAAU;GACpC,IAAI,KAAK;GACT;GACA,KAAK,MAAM;GACX,iBAAiB,QAAQ,KAAK,QAAQ,MAAM,MAAM;GAClD,QAAQ,MAAM,UAAU,MAAM,OAAO,SAAS,IAAI,KAAK,UAAU,MAAM,MAAM,IAAI;GACjF,WAAW;GACX,WAAW;EACb,CAAC;CACH;;CAGA,cAAc,KAAsB;EAClC,OAAO,QAAQ,KAAK,QAAQ,IAAI,eAAe;CACjD;;CAGA,MAAM,IAAI,WAAmB,IAAqC;EAEhE,MAAM,SAAQ,MADK,KAAK,GAAG,KAAK,UAAU;GAAE,OAAO,GAAG,SAAS,IAAI,EAAE;GAAG,OAAO;EAAE,CAAC,EAAA,CAC/D,MAAM;EACzB,OAAO,OAAO,cAAc,YAAY,QAAQ;CAClD;;CAGA,MAAM,OAAO,WAAmB,IAA2B;EACzD,MAAM,WAAW,MAAM,KAAK,IAAI,WAAW,EAAE;EAC7C,IAAI,UACF,MAAM,KAAK,GAAG,OAAO,UAAU,SAAS,EAAE;CAE9C;;CAGA,OAAO,SAAS,SAA4B;EAC1C,IAAI,CAAC,QAAQ,QACX,OAAO,CAAC;EAEV,IAAI;GACF,MAAM,SAAkB,KAAK,MAAM,QAAQ,MAAM;GACjD,OAAO,MAAM,QAAQ,MAAM,IACvB,OAAO,QAAQ,UAA2B,OAAO,UAAU,QAAQ,IACnE,CAAC;EACP,QAAQ;GACN,OAAO,CAAC;EACV;CACF;AACF"}