@storyshelf/core 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -8
  3. package/dist/adapter/auth.mjs.map +1 -1
  4. package/dist/adapter/capture-runner.d.mts +4 -0
  5. package/dist/adapter/git-host/helpers.d.mts +1 -0
  6. package/dist/adapter/git-host/helpers.mjs +2 -1
  7. package/dist/adapter/git-host/helpers.mjs.map +1 -1
  8. package/dist/adapter/storage.d.mts +1 -0
  9. package/dist/adapters/capture-queue.d.mts +2 -0
  10. package/dist/adapters/webhook-events.mjs +8 -0
  11. package/dist/adapters/webhook-events.mjs.map +1 -1
  12. package/dist/capture/adapter.d.mts +11 -1
  13. package/dist/capture/adapter.mjs +19 -1
  14. package/dist/capture/adapter.mjs.map +1 -1
  15. package/dist/capture/orchestrator.d.mts +7 -0
  16. package/dist/capture/orchestrator.mjs +43 -10
  17. package/dist/capture/orchestrator.mjs.map +1 -1
  18. package/dist/capture/pipeline.d.mts +5 -3
  19. package/dist/capture/pipeline.mjs +18 -6
  20. package/dist/capture/pipeline.mjs.map +1 -1
  21. package/dist/capture/queue.d.mts +2 -0
  22. package/dist/capture/queue.mjs +2 -1
  23. package/dist/capture/queue.mjs.map +1 -1
  24. package/dist/capture/status-fanout.mjs +1 -0
  25. package/dist/capture/status-fanout.mjs.map +1 -1
  26. package/dist/capture/storybook.d.mts +1 -0
  27. package/dist/capture/storybook.mjs +12 -3
  28. package/dist/capture/storybook.mjs.map +1 -1
  29. package/dist/config.d.mts +27 -20
  30. package/dist/config.mjs +16 -2
  31. package/dist/config.mjs.map +1 -1
  32. package/dist/ddl.d.mts +2 -1
  33. package/dist/ddl.mjs +3 -0
  34. package/dist/ddl.mjs.map +1 -1
  35. package/dist/diff/engine.d.mts +8 -0
  36. package/dist/diff/engine.mjs +10 -2
  37. package/dist/diff/engine.mjs.map +1 -1
  38. package/dist/index.d.mts +7 -4
  39. package/dist/index.mjs +10 -3
  40. package/dist/index.mjs.map +1 -1
  41. package/dist/logger.d.mts +3 -3
  42. package/dist/logger.mjs.map +1 -1
  43. package/dist/middleware/csrf.mjs +2 -1
  44. package/dist/middleware/csrf.mjs.map +1 -1
  45. package/dist/middleware/rate-limit.mjs +1 -0
  46. package/dist/middleware/rate-limit.mjs.map +1 -1
  47. package/dist/models/baseline.d.mts +1 -0
  48. package/dist/models/baseline.mjs +3 -1
  49. package/dist/models/baseline.mjs.map +1 -1
  50. package/dist/models/build.d.mts +19 -1
  51. package/dist/models/build.mjs +22 -2
  52. package/dist/models/build.mjs.map +1 -1
  53. package/dist/models/comment.d.mts +2 -0
  54. package/dist/models/comment.mjs +3 -1
  55. package/dist/models/comment.mjs.map +1 -1
  56. package/dist/models/label.d.mts +6 -0
  57. package/dist/models/label.mjs +13 -1
  58. package/dist/models/label.mjs.map +1 -1
  59. package/dist/models/member.mjs +2 -1
  60. package/dist/models/member.mjs.map +1 -1
  61. package/dist/models/project.d.mts +1 -0
  62. package/dist/models/project.mjs +3 -2
  63. package/dist/models/project.mjs.map +1 -1
  64. package/dist/models/snapshot.d.mts +1 -0
  65. package/dist/models/snapshot.mjs +2 -1
  66. package/dist/models/snapshot.mjs.map +1 -1
  67. package/dist/models/status-config.mjs +2 -1
  68. package/dist/models/status-config.mjs.map +1 -1
  69. package/dist/models/token.mjs +2 -1
  70. package/dist/models/token.mjs.map +1 -1
  71. package/dist/models/webhook.d.mts +1 -0
  72. package/dist/models/webhook.mjs +2 -1
  73. package/dist/models/webhook.mjs.map +1 -1
  74. package/dist/pages/build-detail.mjs +2 -1
  75. package/dist/pages/build-detail.mjs.map +1 -1
  76. package/dist/pages/build-diff.mjs +1 -0
  77. package/dist/pages/build-diff.mjs.map +1 -1
  78. package/dist/pages/compute-jobs.mjs +2 -0
  79. package/dist/pages/compute-jobs.mjs.map +1 -1
  80. package/dist/pages/label-detail.mjs +227 -0
  81. package/dist/pages/label-detail.mjs.map +1 -0
  82. package/dist/pages/login.mjs +1 -0
  83. package/dist/pages/login.mjs.map +1 -1
  84. package/dist/pages/project-builds.mjs +1 -0
  85. package/dist/pages/project-builds.mjs.map +1 -1
  86. package/dist/pages/project-create.mjs +1 -0
  87. package/dist/pages/project-create.mjs.map +1 -1
  88. package/dist/pages/project-settings.mjs +33 -42
  89. package/dist/pages/project-settings.mjs.map +1 -1
  90. package/dist/pages/projects.mjs +1 -0
  91. package/dist/pages/projects.mjs.map +1 -1
  92. package/dist/pages/root.mjs +1 -0
  93. package/dist/pages/root.mjs.map +1 -1
  94. package/dist/pages/settings-general.mjs +1 -0
  95. package/dist/pages/settings-general.mjs.map +1 -1
  96. package/dist/pages/settings-labels.mjs +1 -0
  97. package/dist/pages/settings-labels.mjs.map +1 -1
  98. package/dist/pages/settings-members.mjs +1 -0
  99. package/dist/pages/settings-members.mjs.map +1 -1
  100. package/dist/pages/settings-status.mjs +1 -0
  101. package/dist/pages/settings-status.mjs.map +1 -1
  102. package/dist/pages/settings-tests.mjs +149 -0
  103. package/dist/pages/settings-tests.mjs.map +1 -0
  104. package/dist/pages/settings-tokens.mjs +1 -0
  105. package/dist/pages/settings-tokens.mjs.map +1 -1
  106. package/dist/pages/settings-webhooks.mjs +1 -0
  107. package/dist/pages/settings-webhooks.mjs.map +1 -1
  108. package/dist/pages/storybook.mjs +59 -0
  109. package/dist/pages/storybook.mjs.map +1 -0
  110. package/dist/retention/purge.d.mts +3 -0
  111. package/dist/retention/purge.mjs +2 -1
  112. package/dist/retention/purge.mjs.map +1 -1
  113. package/dist/routers/admin.mjs +1 -0
  114. package/dist/routers/admin.mjs.map +1 -1
  115. package/dist/routers/assets.mjs +1 -0
  116. package/dist/routers/assets.mjs.map +1 -1
  117. package/dist/routers/auth.mjs +1 -0
  118. package/dist/routers/auth.mjs.map +1 -1
  119. package/dist/routers/builds.handlers.mjs +15 -3
  120. package/dist/routers/builds.handlers.mjs.map +1 -1
  121. package/dist/routers/builds.mjs +5 -2
  122. package/dist/routers/builds.mjs.map +1 -1
  123. package/dist/routers/comments.mjs +1 -0
  124. package/dist/routers/comments.mjs.map +1 -1
  125. package/dist/routers/helpers.mjs +7 -1
  126. package/dist/routers/helpers.mjs.map +1 -1
  127. package/dist/routers/htmx.mjs +2 -0
  128. package/dist/routers/htmx.mjs.map +1 -1
  129. package/dist/routers/labels.mjs +34 -1
  130. package/dist/routers/labels.mjs.map +1 -1
  131. package/dist/routers/media.mjs +1 -0
  132. package/dist/routers/media.mjs.map +1 -1
  133. package/dist/routers/members.mjs +1 -0
  134. package/dist/routers/members.mjs.map +1 -1
  135. package/dist/routers/projects.mjs +1 -0
  136. package/dist/routers/projects.mjs.map +1 -1
  137. package/dist/routers/schemas.mjs +40 -4
  138. package/dist/routers/schemas.mjs.map +1 -1
  139. package/dist/routers/settings.mjs +22 -0
  140. package/dist/routers/settings.mjs.map +1 -1
  141. package/dist/routers/snapshots.mjs +1 -0
  142. package/dist/routers/snapshots.mjs.map +1 -1
  143. package/dist/routers/status-configs.mjs +1 -0
  144. package/dist/routers/status-configs.mjs.map +1 -1
  145. package/dist/routers/storybook.mjs +113 -0
  146. package/dist/routers/storybook.mjs.map +1 -0
  147. package/dist/routers/tokens.mjs +1 -0
  148. package/dist/routers/tokens.mjs.map +1 -1
  149. package/dist/routers/ui.mjs +25 -2
  150. package/dist/routers/ui.mjs.map +1 -1
  151. package/dist/routers/webhooks.mjs +1 -0
  152. package/dist/routers/webhooks.mjs.map +1 -1
  153. package/dist/schema-tables.mjs +166 -0
  154. package/dist/schema-tables.mjs.map +1 -0
  155. package/dist/schema.d.mts +194 -4352
  156. package/dist/schema.mjs +26 -136
  157. package/dist/schema.mjs.map +1 -1
  158. package/dist/store.mjs +2 -0
  159. package/dist/store.mjs.map +1 -1
  160. package/dist/types.d.mts +14 -1
  161. package/dist/types.mjs +8 -0
  162. package/dist/types.mjs.map +1 -1
  163. package/dist/ui/components.mjs +5 -0
  164. package/dist/ui/components.mjs.map +1 -1
  165. package/dist/ui/document.d.mts +1 -0
  166. package/dist/ui/document.mjs +10 -0
  167. package/dist/ui/document.mjs.map +1 -1
  168. package/dist/ui/styles.mjs +1 -0
  169. package/dist/ui/styles.mjs.map +1 -1
  170. package/dist/ui/theme.mjs +2 -0
  171. package/dist/ui/theme.mjs.map +1 -1
  172. package/dist/urls.d.mts +8 -0
  173. package/dist/urls.mjs +7 -0
  174. package/dist/urls.mjs.map +1 -1
  175. package/dist/utils/hash.mjs +3 -0
  176. package/dist/utils/hash.mjs.map +1 -1
  177. package/dist/utils/paths.d.mts +5 -0
  178. package/dist/utils/paths.mjs +5 -0
  179. package/dist/utils/paths.mjs.map +1 -1
  180. package/dist/utils/ulid.d.mts +2 -0
  181. package/dist/utils/ulid.mjs +5 -3
  182. package/dist/utils/ulid.mjs.map +1 -1
  183. package/package.json +76 -67
@@ -1 +1 @@
1
- {"version":3,"file":"purge.mjs","names":[],"sources":["../../src/retention/purge.ts"],"sourcesContent":["import { and, eq, inArray, lt, sql } from \"drizzle-orm\";\nimport type { Logger } from \"pino\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { LabelModel } from \"../models/label.ts\";\nimport { builds, type Project } from \"../schema.ts\";\nimport { TERMINAL_BUILD_STATUSES } from \"../types.ts\";\n\nexport interface PurgeOptions {\n ttlDays: number;\n keepLatestPerBranch: boolean;\n}\n\nexport interface PurgeResult {\n removedBuilds: number;\n removedFiles: number;\n}\nexport class Retention {\n constructor(\n private readonly db: DatabaseAdapter,\n private readonly storage: StorageAdapter,\n private readonly logger?: Logger,\n ) {}\n\n\n async purge(project: Project, options: PurgeOptions): Promise<PurgeResult> {\n const cutoff = new Date(Date.now() - options.ttlDays * 86_400_000).toISOString();\n const candidates = await this.db.list(builds, {\n where: and(eq(builds.projectId, project.id), inArray(builds.status, [...TERMINAL_BUILD_STATUSES]), lt(builds.updatedAt, cutoff)),\n });\n\n const keep = options.keepLatestPerBranch ? await this.latestPerBranch(project.id) : new Set<string>();\n const labelModel = new LabelModel(this.db);\n const target = await Promise.all(\n candidates.map(async (build) => {\n if (keep.has(build.id) || (await labelModel.hasPersistent(project.id, build.id))) {\n return null;\n }\n return build.id;\n }),\n );\n\n const buildIds = target.filter((id): id is string => id !== null);\n const results = await Promise.all(\n buildIds.map(async (buildId) => ({\n files: await this.deleteBuildFiles(project.id, buildId),\n removed: await this.removeBuild(buildId),\n })),\n );\n const removedBuilds = results.filter((r) => r.removed).length;\n const removedFiles = results.reduce((sum, r) => sum + r.files, 0);\n this.logger?.info({ projectId: project.id, removedBuilds, removedFiles }, \"build retention purge complete\");\n return { removedBuilds, removedFiles };\n }\n\n private async removeBuild(buildId: string): Promise<boolean> {\n await new BuildModel(this.db).remove(buildId);\n return true;\n }\n\n private async latestPerBranch(projectId: string): Promise<Set<string>> {\n const rows = await this.db.all<{ id: string; gitBranch: string }>(\n sql`SELECT id, gitBranch, createdAt FROM builds WHERE projectId = ${projectId} ORDER BY createdAt DESC`,\n );\n const latest = new Map<string, string>();\n for (const row of rows) {\n const current = latest.get(row.gitBranch);\n if (!current) {\n latest.set(row.gitBranch, row.id);\n }\n }\n return new Set(latest.values());\n }\n\n private async deleteBuildFiles(projectId: string, buildId: string): Promise<number> {\n const prefix = `${projectId}/builds/${buildId}/`;\n const files = await this.storage.list(prefix);\n await Promise.all(\n files.map(async (file) => {\n await this.storage.delete(file);\n }),\n );\n return files.length;\n }\n}\n"],"mappings":";;;;;;;;;AAmBA,IAAa,YAAb,MAAuB;CAEF;CACA;CACA;CAHnB,YACE,IACA,SACA,QACA;EAHiB,KAAA,KAAA;EACA,KAAA,UAAA;EACA,KAAA,SAAA;CAChB;CAGH,MAAM,MAAM,SAAkB,SAA6C;EACzE,MAAM,0BAAS,IAAI,KAAK,KAAK,IAAI,IAAI,QAAQ,UAAU,KAAU,EAAA,CAAE,YAAY;EAC/E,MAAM,aAAa,MAAM,KAAK,GAAG,KAAK,QAAQ,EAC5C,OAAO,IAAI,GAAG,OAAO,WAAW,QAAQ,EAAE,GAAG,QAAQ,OAAO,QAAQ,CAAC,GAAG,uBAAuB,CAAC,GAAG,GAAG,OAAO,WAAW,MAAM,CAAC,EACjI,CAAC;EAED,MAAM,OAAO,QAAQ,sBAAsB,MAAM,KAAK,gBAAgB,QAAQ,EAAE,oBAAI,IAAI,IAAY;EACpG,MAAM,aAAa,IAAI,WAAW,KAAK,EAAE;EAUzC,MAAM,YAAW,MATI,QAAQ,IAC3B,WAAW,IAAI,OAAO,UAAU;GAC9B,IAAI,KAAK,IAAI,MAAM,EAAE,KAAM,MAAM,WAAW,cAAc,QAAQ,IAAI,MAAM,EAAE,GAC5E,OAAO;GAET,OAAO,MAAM;EACf,CAAC,CACH,EAAA,CAEwB,QAAQ,OAAqB,OAAO,IAAI;EAChE,MAAM,UAAU,MAAM,QAAQ,IAC5B,SAAS,IAAI,OAAO,aAAa;GAC/B,OAAO,MAAM,KAAK,iBAAiB,QAAQ,IAAI,OAAO;GACtD,SAAS,MAAM,KAAK,YAAY,OAAO;EACzC,EAAE,CACJ;EACA,MAAM,gBAAgB,QAAQ,QAAQ,MAAM,EAAE,OAAO,CAAC,CAAC;EACvD,MAAM,eAAe,QAAQ,QAAQ,KAAK,MAAM,MAAM,EAAE,OAAO,CAAC;EAChE,KAAK,QAAQ,KAAK;GAAE,WAAW,QAAQ;GAAI;GAAe;EAAa,GAAG,gCAAgC;EAC1G,OAAO;GAAE;GAAe;EAAa;CACvC;CAEA,MAAc,YAAY,SAAmC;EAC3D,MAAM,IAAI,WAAW,KAAK,EAAE,CAAC,CAAC,OAAO,OAAO;EAC5C,OAAO;CACT;CAEA,MAAc,gBAAgB,WAAyC;EACrE,MAAM,OAAO,MAAM,KAAK,GAAG,IACzB,GAAG,iEAAiE,UAAU,yBAChF;EACA,MAAM,yBAAS,IAAI,IAAoB;EACvC,KAAK,MAAM,OAAO,MAEhB,IAAI,CADY,OAAO,IAAI,IAAI,SACpB,GACT,OAAO,IAAI,IAAI,WAAW,IAAI,EAAE;EAGpC,OAAO,IAAI,IAAI,OAAO,OAAO,CAAC;CAChC;CAEA,MAAc,iBAAiB,WAAmB,SAAkC;EAClF,MAAM,SAAS,GAAG,UAAU,UAAU,QAAQ;EAC9C,MAAM,QAAQ,MAAM,KAAK,QAAQ,KAAK,MAAM;EAC5C,MAAM,QAAQ,IACZ,MAAM,IAAI,OAAO,SAAS;GACxB,MAAM,KAAK,QAAQ,OAAO,IAAI;EAChC,CAAC,CACH;EACA,OAAO,MAAM;CACf;AACF"}
1
+ {"version":3,"file":"purge.mjs","names":[],"sources":["../../src/retention/purge.ts"],"sourcesContent":["import { and, eq, inArray, lt, sql } from \"drizzle-orm\";\nimport type { Logger } from \"pino\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { LabelModel } from \"../models/label.ts\";\nimport { builds } from \"../schema-tables.ts\";\nimport type { Project } from \"../schema.ts\";\nimport { TERMINAL_BUILD_STATUSES } from \"../types.ts\";\n\n/** Options controlling which builds a retention purge removes. */\nexport interface PurgeOptions {\n ttlDays: number;\n keepLatestPerBranch: boolean;\n}\n\n/** Counts of builds and files removed by a retention purge. */\nexport interface PurgeResult {\n removedBuilds: number;\n removedFiles: number;\n}\n/** Removes expired transient builds while keeping baselines and persistent builds. */\nexport class Retention {\n constructor(\n private readonly db: DatabaseAdapter,\n private readonly storage: StorageAdapter,\n private readonly logger?: Logger,\n ) {}\n\n\n async purge(project: Project, options: PurgeOptions): Promise<PurgeResult> {\n const cutoff = new Date(Date.now() - options.ttlDays * 86_400_000).toISOString();\n const candidates = await this.db.list(builds, {\n where: and(eq(builds.projectId, project.id), inArray(builds.status, [...TERMINAL_BUILD_STATUSES]), lt(builds.updatedAt, cutoff)),\n });\n\n const keep = options.keepLatestPerBranch ? await this.latestPerBranch(project.id) : new Set<string>();\n const labelModel = new LabelModel(this.db);\n const target = await Promise.all(\n candidates.map(async (build) => {\n if (keep.has(build.id) || (await labelModel.hasPersistent(project.id, build.id))) {\n return null;\n }\n return build.id;\n }),\n );\n\n const buildIds = target.filter((id): id is string => id !== null);\n const results = await Promise.all(\n buildIds.map(async (buildId) => ({\n files: await this.deleteBuildFiles(project.id, buildId),\n removed: await this.removeBuild(buildId),\n })),\n );\n const removedBuilds = results.filter((r) => r.removed).length;\n const removedFiles = results.reduce((sum, r) => sum + r.files, 0);\n this.logger?.info({ projectId: project.id, removedBuilds, removedFiles }, \"build retention purge complete\");\n return { removedBuilds, removedFiles };\n }\n\n private async removeBuild(buildId: string): Promise<boolean> {\n await new BuildModel(this.db).remove(buildId);\n return true;\n }\n\n private async latestPerBranch(projectId: string): Promise<Set<string>> {\n const rows = await this.db.all<{ id: string; gitBranch: string }>(\n sql`SELECT id, gitBranch, createdAt FROM builds WHERE projectId = ${projectId} ORDER BY createdAt DESC`,\n );\n const latest = new Map<string, string>();\n for (const row of rows) {\n const current = latest.get(row.gitBranch);\n if (!current) {\n latest.set(row.gitBranch, row.id);\n }\n }\n return new Set(latest.values());\n }\n\n private async deleteBuildFiles(projectId: string, buildId: string): Promise<number> {\n const prefix = `${projectId}/builds/${buildId}/`;\n const files = await this.storage.list(prefix);\n await Promise.all(\n files.map(async (file) => {\n await this.storage.delete(file);\n }),\n );\n return files.length;\n }\n}\n"],"mappings":";;;;;;;;;;AAuBA,IAAa,YAAb,MAAuB;CAEF;CACA;CACA;CAHnB,YACE,IACA,SACA,QACA;EAHiB,KAAA,KAAA;EACA,KAAA,UAAA;EACA,KAAA,SAAA;CAChB;CAGH,MAAM,MAAM,SAAkB,SAA6C;EACzE,MAAM,0BAAS,IAAI,KAAK,KAAK,IAAI,IAAI,QAAQ,UAAU,KAAU,EAAA,CAAE,YAAY;EAC/E,MAAM,aAAa,MAAM,KAAK,GAAG,KAAK,QAAQ,EAC5C,OAAO,IAAI,GAAG,OAAO,WAAW,QAAQ,EAAE,GAAG,QAAQ,OAAO,QAAQ,CAAC,GAAG,uBAAuB,CAAC,GAAG,GAAG,OAAO,WAAW,MAAM,CAAC,EACjI,CAAC;EAED,MAAM,OAAO,QAAQ,sBAAsB,MAAM,KAAK,gBAAgB,QAAQ,EAAE,oBAAI,IAAI,IAAY;EACpG,MAAM,aAAa,IAAI,WAAW,KAAK,EAAE;EAUzC,MAAM,YAAW,MATI,QAAQ,IAC3B,WAAW,IAAI,OAAO,UAAU;GAC9B,IAAI,KAAK,IAAI,MAAM,EAAE,KAAM,MAAM,WAAW,cAAc,QAAQ,IAAI,MAAM,EAAE,GAC5E,OAAO;GAET,OAAO,MAAM;EACf,CAAC,CACH,EAAA,CAEwB,QAAQ,OAAqB,OAAO,IAAI;EAChE,MAAM,UAAU,MAAM,QAAQ,IAC5B,SAAS,IAAI,OAAO,aAAa;GAC/B,OAAO,MAAM,KAAK,iBAAiB,QAAQ,IAAI,OAAO;GACtD,SAAS,MAAM,KAAK,YAAY,OAAO;EACzC,EAAE,CACJ;EACA,MAAM,gBAAgB,QAAQ,QAAQ,MAAM,EAAE,OAAO,CAAC,CAAC;EACvD,MAAM,eAAe,QAAQ,QAAQ,KAAK,MAAM,MAAM,EAAE,OAAO,CAAC;EAChE,KAAK,QAAQ,KAAK;GAAE,WAAW,QAAQ;GAAI;GAAe;EAAa,GAAG,gCAAgC;EAC1G,OAAO;GAAE;GAAe;EAAa;CACvC;CAEA,MAAc,YAAY,SAAmC;EAC3D,MAAM,IAAI,WAAW,KAAK,EAAE,CAAC,CAAC,OAAO,OAAO;EAC5C,OAAO;CACT;CAEA,MAAc,gBAAgB,WAAyC;EACrE,MAAM,OAAO,MAAM,KAAK,GAAG,IACzB,GAAG,iEAAiE,UAAU,yBAChF;EACA,MAAM,yBAAS,IAAI,IAAoB;EACvC,KAAK,MAAM,OAAO,MAEhB,IAAI,CADY,OAAO,IAAI,IAAI,SACpB,GACT,OAAO,IAAI,IAAI,WAAW,IAAI,EAAE;EAGpC,OAAO,IAAI,IAAI,OAAO,OAAO,CAAC;CAChC;CAEA,MAAc,iBAAiB,WAAmB,SAAkC;EAClF,MAAM,SAAS,GAAG,UAAU,UAAU,QAAQ;EAC9C,MAAM,QAAQ,MAAM,KAAK,QAAQ,KAAK,MAAM;EAC5C,MAAM,QAAQ,IACZ,MAAM,IAAI,OAAO,SAAS;GACxB,MAAM,KAAK,QAAQ,OAAO,IAAI;EAChC,CAAC,CACH;EACA,OAAO,MAAM;CACf;AACF"}
@@ -20,6 +20,7 @@ const purgeRoute = createRoute({
20
20
  ...forbidden
21
21
  }
22
22
  });
23
+ /** Register the site-admin retention purge endpoint. */
23
24
  function registerAdmin(app) {
24
25
  app.openapi(purgeRoute, async (c) => {
25
26
  requireSiteAdmin();
@@ -1 +1 @@
1
- {"version":3,"file":"admin.mjs","names":["forbiddenResponse"],"sources":["../../src/routers/admin.ts"],"sourcesContent":["import { createRoute } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\n\nimport { ProjectModel } from \"../models/project.ts\";\nimport { Retention } from \"../retention/purge.ts\";\nimport { getStore } from \"../store.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\nimport { forbidden as forbiddenResponse, purgeInputSchema, purgeSchema } from \"./schemas.ts\";\n\nconst purgeRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/admin/purge\",\n request: { body: { content: { \"application/json\": { schema: purgeInputSchema } } } },\n responses: {\n 200: { content: { \"application/json\": { schema: purgeSchema } }, description: \"Retention purge run\" },\n ...forbiddenResponse,\n },\n});\n\nexport function registerAdmin(app: ShelfApp): void {\n app.openapi(purgeRoute, async (c) => {\n requireSiteAdmin();\n const body = c.req.valid(\"json\");\n const ttlDays = body.ttlDays ?? getStore().config.purgeTtlDays ?? 30;\n const projects = await new ProjectModel(getStore().db).list();\n const retention = new Retention(getStore().db, getStore().storage);\n const results = await Promise.all(\n projects.map(async (project) => {\n return await retention.purge(project, { ttlDays, keepLatestPerBranch: true });\n }),\n );\n const removedBuilds = results.reduce((sum, result) => sum + result.removedBuilds, 0);\n return c.json({ removedBuilds });\n });\n}"],"mappings":";;;;;;;;;;AASA,MAAM,aAAa,YAAY;CAC7B,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,iBAAiB,EAAE,EAAE,EAAE;CACnF,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,YAAY,EAAE;GAAG,aAAa;EAAsB;EACpG,GAAGA;CACL;AACF,CAAC;AAED,SAAgB,cAAc,KAAqB;CACjD,IAAI,QAAQ,YAAY,OAAO,MAAM;EACnC,iBAAiB;EAEjB,MAAM,UADO,EAAE,IAAI,MAAM,MACN,CAAC,CAAC,WAAW,SAAS,CAAC,CAAC,OAAO,gBAAgB;EAClE,MAAM,WAAW,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK;EAC5D,MAAM,YAAY,IAAI,UAAU,SAAS,CAAC,CAAC,IAAI,SAAS,CAAC,CAAC,OAAO;EAMjE,MAAM,iBAAgB,MALA,QAAQ,IAC5B,SAAS,IAAI,OAAO,YAAY;GAC9B,OAAO,MAAM,UAAU,MAAM,SAAS;IAAE;IAAS,qBAAqB;GAAK,CAAC;EAC9E,CAAC,CACH,EAAA,CAC8B,QAAQ,KAAK,WAAW,MAAM,OAAO,eAAe,CAAC;EACnF,OAAO,EAAE,KAAK,EAAE,cAAc,CAAC;CACjC,CAAC;AACH"}
1
+ {"version":3,"file":"admin.mjs","names":["forbiddenResponse"],"sources":["../../src/routers/admin.ts"],"sourcesContent":["import { createRoute } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\n\nimport { ProjectModel } from \"../models/project.ts\";\nimport { Retention } from \"../retention/purge.ts\";\nimport { getStore } from \"../store.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\nimport { forbidden as forbiddenResponse, purgeInputSchema, purgeSchema } from \"./schemas.ts\";\n\nconst purgeRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/admin/purge\",\n request: { body: { content: { \"application/json\": { schema: purgeInputSchema } } } },\n responses: {\n 200: { content: { \"application/json\": { schema: purgeSchema } }, description: \"Retention purge run\" },\n ...forbiddenResponse,\n },\n});\n\n/** Register the site-admin retention purge endpoint. */\nexport function registerAdmin(app: ShelfApp): void {\n app.openapi(purgeRoute, async (c) => {\n requireSiteAdmin();\n const body = c.req.valid(\"json\");\n const ttlDays = body.ttlDays ?? getStore().config.purgeTtlDays ?? 30;\n const projects = await new ProjectModel(getStore().db).list();\n const retention = new Retention(getStore().db, getStore().storage);\n const results = await Promise.all(\n projects.map(async (project) => {\n return await retention.purge(project, { ttlDays, keepLatestPerBranch: true });\n }),\n );\n const removedBuilds = results.reduce((sum, result) => sum + result.removedBuilds, 0);\n return c.json({ removedBuilds });\n });\n}"],"mappings":";;;;;;;;;;AASA,MAAM,aAAa,YAAY;CAC7B,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,iBAAiB,EAAE,EAAE,EAAE;CACnF,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,YAAY,EAAE;GAAG,aAAa;EAAsB;EACpG,GAAGA;CACL;AACF,CAAC;;AAGD,SAAgB,cAAc,KAAqB;CACjD,IAAI,QAAQ,YAAY,OAAO,MAAM;EACnC,iBAAiB;EAEjB,MAAM,UADO,EAAE,IAAI,MAAM,MACN,CAAC,CAAC,WAAW,SAAS,CAAC,CAAC,OAAO,gBAAgB;EAClE,MAAM,WAAW,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK;EAC5D,MAAM,YAAY,IAAI,UAAU,SAAS,CAAC,CAAC,IAAI,SAAS,CAAC,CAAC,OAAO;EAMjE,MAAM,iBAAgB,MALA,QAAQ,IAC5B,SAAS,IAAI,OAAO,YAAY;GAC9B,OAAO,MAAM,UAAU,MAAM,SAAS;IAAE;IAAS,qBAAqB;GAAK,CAAC;EAC9E,CAAC,CACH,EAAA,CAC8B,QAAQ,KAAK,WAAW,MAAM,OAAO,eAAe,CAAC;EACnF,OAAO,EAAE,KAAK,EAAE,cAAc,CAAC;CACjC,CAAC;AACH"}
@@ -9,6 +9,7 @@ async function htmxScript() {
9
9
  htmxSource ??= await readFile(fileURLToPath(new URL("../assets/htmx.min.js", import.meta.url)), "utf8");
10
10
  return htmxSource;
11
11
  }
12
+ /** Register the vendored static-asset routes (HTMX bundle). */
12
13
  function registerAssets(app) {
13
14
  app.get("/assets/htmx.js", async (c) => {
14
15
  const body = await htmxScript();
@@ -1 +1 @@
1
- {"version":3,"file":"assets.mjs","names":[],"sources":["../../src/routers/assets.ts"],"sourcesContent":["import { readFile } from \"node:fs/promises\";\nimport { fileURLToPath } from \"node:url\";\n\nimport type { ShelfApp } from \"../index.tsx\";\n\nlet htmxSource: string | null = null;\n\nasync function htmxScript(): Promise<string> {\n htmxSource ??= await readFile(fileURLToPath(new URL(\"../assets/htmx.min.js\", import.meta.url)), \"utf8\");\n return htmxSource;\n}\n\nexport function registerAssets(app: ShelfApp): void {\n app.get(\"/assets/htmx.js\", async (c) => {\n const body = await htmxScript();\n return c.body(body, 200, {\n \"content-type\": \"application/javascript\",\n \"cache-control\": \"public, max-age=31536000, immutable\",\n });\n });\n}"],"mappings":";;;;;;AAKA,IAAI,aAA4B;AAEhC,eAAe,aAA8B;CAC3C,eAAe,MAAM,SAAS,cAAc,IAAI,IAAI,yBAAyB,YAAY,GAAG,CAAC,GAAG,MAAM;CACtG,OAAO;AACT;AAEA,SAAgB,eAAe,KAAqB;CAClD,IAAI,IAAI,mBAAmB,OAAO,MAAM;EACtC,MAAM,OAAO,MAAM,WAAW;EAC9B,OAAO,EAAE,KAAK,MAAM,KAAK;GACvB,gBAAgB;GAChB,iBAAiB;EACnB,CAAC;CACH,CAAC;AACH"}
1
+ {"version":3,"file":"assets.mjs","names":[],"sources":["../../src/routers/assets.ts"],"sourcesContent":["import { readFile } from \"node:fs/promises\";\nimport { fileURLToPath } from \"node:url\";\n\nimport type { ShelfApp } from \"../index.tsx\";\n\nlet htmxSource: string | null = null;\n\nasync function htmxScript(): Promise<string> {\n htmxSource ??= await readFile(fileURLToPath(new URL(\"../assets/htmx.min.js\", import.meta.url)), \"utf8\");\n return htmxSource;\n}\n\n/** Register the vendored static-asset routes (HTMX bundle). */\nexport function registerAssets(app: ShelfApp): void {\n app.get(\"/assets/htmx.js\", async (c) => {\n const body = await htmxScript();\n return c.body(body, 200, {\n \"content-type\": \"application/javascript\",\n \"cache-control\": \"public, max-age=31536000, immutable\",\n });\n });\n}"],"mappings":";;;;;;AAKA,IAAI,aAA4B;AAEhC,eAAe,aAA8B;CAC3C,eAAe,MAAM,SAAS,cAAc,IAAI,IAAI,yBAAyB,YAAY,GAAG,CAAC,GAAG,MAAM;CACtG,OAAO;AACT;;AAGA,SAAgB,eAAe,KAAqB;CAClD,IAAI,IAAI,mBAAmB,OAAO,MAAM;EACtC,MAAM,OAAO,MAAM,WAAW;EAC9B,OAAO,EAAE,KAAK,MAAM,KAAK;GACvB,gBAAgB;GAChB,iBAAiB;EACnB,CAAC;CACH,CAAC;AACH"}
@@ -32,6 +32,7 @@ function buildSsoUrl(c, auth) {
32
32
  c.header("set-cookie", `${OAUTH_STATE_COOKIE}=${state}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`);
33
33
  return auth.loginUrl(state);
34
34
  }
35
+ /** Register the password and SSO login, callback, and logout routes. */
35
36
  function registerAuth(app, auth) {
36
37
  app.get("/auth/login", async (c) => {
37
38
  if (hasSso(auth) && !hasPasswordLogin(auth)) return c.redirect(buildSsoUrl(c, auth));
@@ -1 +1 @@
1
- {"version":3,"file":"auth.mjs","names":[],"sources":["../../src/routers/auth.ts"],"sourcesContent":["import type { Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfApp } from \"../index.tsx\";\n\nimport { SESSION_COOKIE, type AuthAdapter, type AuthUser } from \"../adapters/auth.ts\";\nimport { renderLoginPage } from \"../pages/login.tsx\";\nimport { randomToken } from \"../utils/hash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\nconst OAUTH_STATE_COOKIE = \"storyshelf_oauth_state\";\nconst SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;\n\nconst SHARED_USER: AuthUser = { id: \"shared\", email: \"admin@local\", name: \"Admin\", role: \"admin\" };\n\nfunction sessionCookieHeader(token: string): string {\n return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL_SECONDS}`;\n}\n\nfunction clearSessionCookieHeader(): string {\n return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;\n}\n\ntype PasswordAuth = AuthAdapter & { login(password: string, user: AuthUser): Promise<string> };\ntype SsoAuth = AuthAdapter & { loginUrl(state: string): string };\n\nfunction hasPasswordLogin(auth: AuthAdapter): auth is PasswordAuth {\n return \"login\" in auth;\n}\n\nfunction hasSso(auth: AuthAdapter): auth is SsoAuth {\n return \"loginUrl\" in auth;\n}\n\nfunction buildSsoUrl(c: Context, auth: SsoAuth): string {\n const state = randomToken(\"shelf_\").value;\n c.header(\"set-cookie\", `${OAUTH_STATE_COOKIE}=${state}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`);\n return auth.loginUrl(state);\n}\n\nexport function registerAuth(app: ShelfApp, auth: AuthAdapter): void {\n app.get(\"/auth/login\", async (c) => {\n if (hasSso(auth) && !hasPasswordLogin(auth)) {\n return c.redirect(buildSsoUrl(c, auth));\n }\n const ssoUrl = hasSso(auth) ? buildSsoUrl(c, auth) : undefined;\n return c.html(await renderLoginPage({ ssoUrl }));\n });\n\n app.post(\"/auth/login\", async (c) => {\n if (!hasPasswordLogin(auth)) {\n throw new HTTPException(404, { message: \"Password login is not configured\" });\n }\n const form = await c.req.formData();\n const raw = form.get(\"password\");\n const password = typeof raw === \"string\" ? raw : \"\";\n try {\n const token = await auth.login(password, SHARED_USER);\n c.header(\"set-cookie\", sessionCookieHeader(token));\n return hxRedirect(c, \"/\");\n } catch {\n return c.html(await renderLoginPage({ error: \"Invalid password\" }), 401);\n }\n });\n\n app.get(\"/auth/callback\", async (c) => {\n if (!auth.handleCallback) {\n return c.notFound();\n }\n const code = c.req.query(\"code\") ?? \"\";\n const state = c.req.query(\"state\") ?? \"\";\n if (!code || !state) {\n throw new HTTPException(400, { message: \"Missing code or state\" });\n }\n const user = await auth.handleCallback({ provider: \"oidc\", code, state });\n if (!user) {\n return c.html(await renderLoginPage({ error: \"SSO sign in failed\" }), 401);\n }\n const token = await auth.createSession(user);\n c.header(\"set-cookie\", sessionCookieHeader(token));\n return c.redirect(\"/\");\n });\n\n app.post(\"/auth/logout\", async (c) => {\n await auth.destroySession(\"\");\n c.header(\"set-cookie\", clearSessionCookieHeader());\n return hxRedirect(c, \"/auth/login\");\n });\n}"],"mappings":";;;;;;;;;AASA,MAAM,qBAAqB;AAC3B,MAAM,sBAAsB;AAE5B,MAAM,cAAwB;CAAE,IAAI;CAAU,OAAO;CAAe,MAAM;CAAS,MAAM;AAAQ;AAEjG,SAAS,oBAAoB,OAAuB;CAClD,OAAO,GAAG,eAAe,GAAG,MAAM,4CAA4C;AAChF;AAEA,SAAS,2BAAmC;CAC1C,OAAO,GAAG,eAAe;AAC3B;AAKA,SAAS,iBAAiB,MAAyC;CACjE,OAAO,WAAW;AACpB;AAEA,SAAS,OAAO,MAAoC;CAClD,OAAO,cAAc;AACvB;AAEA,SAAS,YAAY,GAAY,MAAuB;CACtD,MAAM,QAAQ,YAAY,QAAQ,CAAC,CAAC;CACpC,EAAE,OAAO,cAAc,GAAG,mBAAmB,GAAG,MAAM,8CAA8C;CACpG,OAAO,KAAK,SAAS,KAAK;AAC5B;AAEA,SAAgB,aAAa,KAAe,MAAyB;CACnE,IAAI,IAAI,eAAe,OAAO,MAAM;EAClC,IAAI,OAAO,IAAI,KAAK,CAAC,iBAAiB,IAAI,GACxC,OAAO,EAAE,SAAS,YAAY,GAAG,IAAI,CAAC;EAExC,MAAM,SAAS,OAAO,IAAI,IAAI,YAAY,GAAG,IAAI,IAAI,KAAA;EACrD,OAAO,EAAE,KAAK,MAAM,gBAAgB,EAAE,OAAO,CAAC,CAAC;CACjD,CAAC;CAED,IAAI,KAAK,eAAe,OAAO,MAAM;EACnC,IAAI,CAAC,iBAAiB,IAAI,GACxB,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,mCAAmC,CAAC;EAG9E,MAAM,OAAM,MADO,EAAE,IAAI,SAAS,EAAA,CACjB,IAAI,UAAU;EAC/B,MAAM,WAAW,OAAO,QAAQ,WAAW,MAAM;EACjD,IAAI;GACF,MAAM,QAAQ,MAAM,KAAK,MAAM,UAAU,WAAW;GACpD,EAAE,OAAO,cAAc,oBAAoB,KAAK,CAAC;GACjD,OAAO,WAAW,GAAG,GAAG;EAC1B,QAAQ;GACN,OAAO,EAAE,KAAK,MAAM,gBAAgB,EAAE,OAAO,mBAAmB,CAAC,GAAG,GAAG;EACzE;CACF,CAAC;CAED,IAAI,IAAI,kBAAkB,OAAO,MAAM;EACrC,IAAI,CAAC,KAAK,gBACR,OAAO,EAAE,SAAS;EAEpB,MAAM,OAAO,EAAE,IAAI,MAAM,MAAM,KAAK;EACpC,MAAM,QAAQ,EAAE,IAAI,MAAM,OAAO,KAAK;EACtC,IAAI,CAAC,QAAQ,CAAC,OACZ,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,wBAAwB,CAAC;EAEnE,MAAM,OAAO,MAAM,KAAK,eAAe;GAAE,UAAU;GAAQ;GAAM;EAAM,CAAC;EACxE,IAAI,CAAC,MACH,OAAO,EAAE,KAAK,MAAM,gBAAgB,EAAE,OAAO,qBAAqB,CAAC,GAAG,GAAG;EAE3E,MAAM,QAAQ,MAAM,KAAK,cAAc,IAAI;EAC3C,EAAE,OAAO,cAAc,oBAAoB,KAAK,CAAC;EACjD,OAAO,EAAE,SAAS,GAAG;CACvB,CAAC;CAED,IAAI,KAAK,gBAAgB,OAAO,MAAM;EACpC,MAAM,KAAK,eAAe,EAAE;EAC5B,EAAE,OAAO,cAAc,yBAAyB,CAAC;EACjD,OAAO,WAAW,GAAG,aAAa;CACpC,CAAC;AACH"}
1
+ {"version":3,"file":"auth.mjs","names":[],"sources":["../../src/routers/auth.ts"],"sourcesContent":["import type { Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfApp } from \"../index.tsx\";\n\nimport { SESSION_COOKIE, type AuthAdapter, type AuthUser } from \"../adapters/auth.ts\";\nimport { renderLoginPage } from \"../pages/login.tsx\";\nimport { randomToken } from \"../utils/hash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\nconst OAUTH_STATE_COOKIE = \"storyshelf_oauth_state\";\nconst SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;\n\nconst SHARED_USER: AuthUser = { id: \"shared\", email: \"admin@local\", name: \"Admin\", role: \"admin\" };\n\nfunction sessionCookieHeader(token: string): string {\n return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL_SECONDS}`;\n}\n\nfunction clearSessionCookieHeader(): string {\n return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;\n}\n\ntype PasswordAuth = AuthAdapter & { login(password: string, user: AuthUser): Promise<string> };\ntype SsoAuth = AuthAdapter & { loginUrl(state: string): string };\n\nfunction hasPasswordLogin(auth: AuthAdapter): auth is PasswordAuth {\n return \"login\" in auth;\n}\n\nfunction hasSso(auth: AuthAdapter): auth is SsoAuth {\n return \"loginUrl\" in auth;\n}\n\nfunction buildSsoUrl(c: Context, auth: SsoAuth): string {\n const state = randomToken(\"shelf_\").value;\n c.header(\"set-cookie\", `${OAUTH_STATE_COOKIE}=${state}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`);\n return auth.loginUrl(state);\n}\n\n/** Register the password and SSO login, callback, and logout routes. */\nexport function registerAuth(app: ShelfApp, auth: AuthAdapter): void {\n app.get(\"/auth/login\", async (c) => {\n if (hasSso(auth) && !hasPasswordLogin(auth)) {\n return c.redirect(buildSsoUrl(c, auth));\n }\n const ssoUrl = hasSso(auth) ? buildSsoUrl(c, auth) : undefined;\n return c.html(await renderLoginPage({ ssoUrl }));\n });\n\n app.post(\"/auth/login\", async (c) => {\n if (!hasPasswordLogin(auth)) {\n throw new HTTPException(404, { message: \"Password login is not configured\" });\n }\n const form = await c.req.formData();\n const raw = form.get(\"password\");\n const password = typeof raw === \"string\" ? raw : \"\";\n try {\n const token = await auth.login(password, SHARED_USER);\n c.header(\"set-cookie\", sessionCookieHeader(token));\n return hxRedirect(c, \"/\");\n } catch {\n return c.html(await renderLoginPage({ error: \"Invalid password\" }), 401);\n }\n });\n\n app.get(\"/auth/callback\", async (c) => {\n if (!auth.handleCallback) {\n return c.notFound();\n }\n const code = c.req.query(\"code\") ?? \"\";\n const state = c.req.query(\"state\") ?? \"\";\n if (!code || !state) {\n throw new HTTPException(400, { message: \"Missing code or state\" });\n }\n const user = await auth.handleCallback({ provider: \"oidc\", code, state });\n if (!user) {\n return c.html(await renderLoginPage({ error: \"SSO sign in failed\" }), 401);\n }\n const token = await auth.createSession(user);\n c.header(\"set-cookie\", sessionCookieHeader(token));\n return c.redirect(\"/\");\n });\n\n app.post(\"/auth/logout\", async (c) => {\n await auth.destroySession(\"\");\n c.header(\"set-cookie\", clearSessionCookieHeader());\n return hxRedirect(c, \"/auth/login\");\n });\n}"],"mappings":";;;;;;;;;AASA,MAAM,qBAAqB;AAC3B,MAAM,sBAAsB;AAE5B,MAAM,cAAwB;CAAE,IAAI;CAAU,OAAO;CAAe,MAAM;CAAS,MAAM;AAAQ;AAEjG,SAAS,oBAAoB,OAAuB;CAClD,OAAO,GAAG,eAAe,GAAG,MAAM,4CAA4C;AAChF;AAEA,SAAS,2BAAmC;CAC1C,OAAO,GAAG,eAAe;AAC3B;AAKA,SAAS,iBAAiB,MAAyC;CACjE,OAAO,WAAW;AACpB;AAEA,SAAS,OAAO,MAAoC;CAClD,OAAO,cAAc;AACvB;AAEA,SAAS,YAAY,GAAY,MAAuB;CACtD,MAAM,QAAQ,YAAY,QAAQ,CAAC,CAAC;CACpC,EAAE,OAAO,cAAc,GAAG,mBAAmB,GAAG,MAAM,8CAA8C;CACpG,OAAO,KAAK,SAAS,KAAK;AAC5B;;AAGA,SAAgB,aAAa,KAAe,MAAyB;CACnE,IAAI,IAAI,eAAe,OAAO,MAAM;EAClC,IAAI,OAAO,IAAI,KAAK,CAAC,iBAAiB,IAAI,GACxC,OAAO,EAAE,SAAS,YAAY,GAAG,IAAI,CAAC;EAExC,MAAM,SAAS,OAAO,IAAI,IAAI,YAAY,GAAG,IAAI,IAAI,KAAA;EACrD,OAAO,EAAE,KAAK,MAAM,gBAAgB,EAAE,OAAO,CAAC,CAAC;CACjD,CAAC;CAED,IAAI,KAAK,eAAe,OAAO,MAAM;EACnC,IAAI,CAAC,iBAAiB,IAAI,GACxB,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,mCAAmC,CAAC;EAG9E,MAAM,OAAM,MADO,EAAE,IAAI,SAAS,EAAA,CACjB,IAAI,UAAU;EAC/B,MAAM,WAAW,OAAO,QAAQ,WAAW,MAAM;EACjD,IAAI;GACF,MAAM,QAAQ,MAAM,KAAK,MAAM,UAAU,WAAW;GACpD,EAAE,OAAO,cAAc,oBAAoB,KAAK,CAAC;GACjD,OAAO,WAAW,GAAG,GAAG;EAC1B,QAAQ;GACN,OAAO,EAAE,KAAK,MAAM,gBAAgB,EAAE,OAAO,mBAAmB,CAAC,GAAG,GAAG;EACzE;CACF,CAAC;CAED,IAAI,IAAI,kBAAkB,OAAO,MAAM;EACrC,IAAI,CAAC,KAAK,gBACR,OAAO,EAAE,SAAS;EAEpB,MAAM,OAAO,EAAE,IAAI,MAAM,MAAM,KAAK;EACpC,MAAM,QAAQ,EAAE,IAAI,MAAM,OAAO,KAAK;EACtC,IAAI,CAAC,QAAQ,CAAC,OACZ,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,wBAAwB,CAAC;EAEnE,MAAM,OAAO,MAAM,KAAK,eAAe;GAAE,UAAU;GAAQ;GAAM;EAAM,CAAC;EACxE,IAAI,CAAC,MACH,OAAO,EAAE,KAAK,MAAM,gBAAgB,EAAE,OAAO,qBAAqB,CAAC,GAAG,GAAG;EAE3E,MAAM,QAAQ,MAAM,KAAK,cAAc,IAAI;EAC3C,EAAE,OAAO,cAAc,oBAAoB,KAAK,CAAC;EACjD,OAAO,EAAE,SAAS,GAAG;CACvB,CAAC;CAED,IAAI,KAAK,gBAAgB,OAAO,MAAM;EACpC,MAAM,KAAK,eAAe,EAAE;EAC5B,EAAE,OAAO,cAAc,yBAAyB,CAAC;EACjD,OAAO,WAAW,GAAG,aAAa;CACpC,CAAC;AACH"}
@@ -11,18 +11,22 @@ import { BaselineModel } from "../models/baseline.mjs";
11
11
  import { SnapshotModel } from "../models/snapshot.mjs";
12
12
  import { z } from "@hono/zod-openapi";
13
13
  //#region src/routers/builds.handlers.ts
14
+ /** Roles permitted to view builds, snapshots, and comments. */
14
15
  const VIEW_ROLES = [
15
16
  "viewer",
16
17
  "developer",
17
18
  "approver",
18
19
  "admin"
19
20
  ];
21
+ /** Roles permitted to upload builds and write comments. */
20
22
  const DEVELOPER_ROLES = [
21
23
  "developer",
22
24
  "approver",
23
25
  "admin"
24
26
  ];
27
+ /** Roles permitted to approve or reject snapshots and delete builds. */
25
28
  const APPROVER_ROLES = ["approver", "admin"];
29
+ /** Multipart input schema for uploading a build with its Storybook bundle. */
26
30
  const buildUploadSchema = z.object({
27
31
  gitSha: z.string(),
28
32
  gitBranch: z.string(),
@@ -34,22 +38,28 @@ const buildUploadSchema = z.object({
34
38
  format: "binary"
35
39
  }).optional()
36
40
  }).openapi("BuildUpload");
41
+ /** Query filters accepted by the build list endpoint. */
37
42
  const buildListQuery = z.object({
38
43
  status: z.enum(BUILD_STATUSES).optional(),
39
- branch: z.string().optional()
44
+ branch: z.string().optional(),
45
+ labelKey: z.string().optional(),
46
+ labelValue: z.string().optional()
40
47
  });
48
+ /** Fetch a build scoped to its project, throwing 404 when it does not belong. */
41
49
  async function buildForProject(projectId, buildId) {
42
50
  const build = await new BuildModel(getStore().db).get(buildId);
43
51
  if (!build || build.projectId !== projectId) notFound("Build not found");
44
52
  return build;
45
53
  }
54
+ /** Fetch a snapshot scoped to its build, throwing 404 when it does not belong. */
46
55
  async function snapshotForBuild(build, snapshotId) {
47
56
  const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);
48
57
  if (!snapshot || snapshot.buildId !== build.id) notFound("Snapshot not found");
49
58
  return snapshot;
50
59
  }
60
+ /** Recompute a build's counts and roll its status up from its snapshots. */
51
61
  async function refreshBuild(buildId) {
52
- const db = getStore().db;
62
+ const { db } = getStore();
53
63
  await new BuildModel(db).updateCounts(buildId);
54
64
  const snapshots = await new SnapshotModel(db).listByBuild(buildId);
55
65
  const unresolved = snapshots.some((s) => s.status === "new" || s.status === "changed");
@@ -66,8 +76,9 @@ async function refreshBuild(buildId) {
66
76
  });
67
77
  }
68
78
  }
79
+ /** Approve a snapshot, promote its screenshot to baseline, and refresh the build. */
69
80
  async function approveSnapshot(snapshotId, userId) {
70
- const db = getStore().db;
81
+ const { db } = getStore();
71
82
  const snapshots = new SnapshotModel(db);
72
83
  const snapshot = await snapshots.get(snapshotId);
73
84
  if (!snapshot) notFound("Snapshot not found");
@@ -78,6 +89,7 @@ async function approveSnapshot(snapshotId, userId) {
78
89
  await new BaselineModel(db, getStore().storage).upsert(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, snapshot.id, snapshot.screenshotPath);
79
90
  await refreshBuild(build.id);
80
91
  }
92
+ /** Extract a string from a multipart form field, ignoring file entries. */
81
93
  function asString(value) {
82
94
  return typeof value === "string" ? value : void 0;
83
95
  }
@@ -1 +1 @@
1
- {"version":3,"file":"builds.handlers.mjs","names":[],"sources":["../../src/routers/builds.handlers.ts"],"sourcesContent":["import { z } from \"@hono/zod-openapi\";\n\nimport { BaselineModel } from \"../models/baseline.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport { SnapshotModel } from \"../models/snapshot.ts\";\nimport { emitWebhookEvent } from \"../adapters/webhook-events.ts\";\nimport { getStore } from \"../store.ts\";\nimport { type ProjectRole, BUILD_STATUSES } from \"../types.ts\";\nimport { notFound } from \"./helpers.ts\";\n\nexport const VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nexport const DEVELOPER_ROLES: readonly ProjectRole[] = [\"developer\", \"approver\", \"admin\"];\nexport const APPROVER_ROLES: readonly ProjectRole[] = [\"approver\", \"admin\"];\n\nexport const buildUploadSchema = z.object({\n gitSha: z.string(),\n gitBranch: z.string(),\n authorEmail: z.string().optional(),\n authorName: z.string().optional(),\n message: z.string().optional(),\n zip: z.instanceof(File).openapi({ type: \"string\", format: \"binary\" }).optional(),\n}).openapi(\"BuildUpload\");\n\nexport const buildListQuery = z.object({\n status: z.enum(BUILD_STATUSES).optional(),\n branch: z.string().optional(),\n});\n\nexport async function buildForProject(projectId: string, buildId: string): Promise<import(\"../models/build.ts\").Build> {\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || build.projectId !== projectId) {\n notFound(\"Build not found\");\n }\n return build;\n}\n\nexport async function snapshotForBuild(build: { id: string }, snapshotId: string): Promise<import(\"../models/snapshot.ts\").Snapshot> {\n const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);\n if (!snapshot || snapshot.buildId !== build.id) {\n notFound(\"Snapshot not found\");\n }\n return snapshot;\n}\n\nexport async function refreshBuild(buildId: string): Promise<void> {\n const db = getStore().db;\n await new BuildModel(db).updateCounts(buildId);\n const snapshots = await new SnapshotModel(db).listByBuild(buildId);\n const unresolved = snapshots.some((s) => s.status === \"new\" || s.status === \"changed\");\n let status: \"reviewing\" | \"rejected\" | \"approved\";\n if (unresolved) {\n status = \"reviewing\";\n } else {\n const rejected = snapshots.some((s) => s.status === \"rejected\");\n status = rejected ? \"rejected\" : \"approved\";\n }\n const build = await new BuildModel(db).get(buildId);\n if (build) {\n await new BuildModel(db).setStatus(buildId, status);\n await emitWebhookEvent(db, build.projectId, `build:${status}`, {\n buildId,\n status,\n snapshotCount: snapshots.length,\n });\n }\n}\n\nexport async function approveSnapshot(snapshotId: string, userId: string): Promise<void> {\n const db = getStore().db;\n const snapshots = new SnapshotModel(db);\n const snapshot = await snapshots.get(snapshotId);\n if (!snapshot) {\n notFound(\"Snapshot not found\");\n }\n const project = await new ProjectModel(db).get(snapshot.projectId);\n const build = await new BuildModel(db).get(snapshot.buildId);\n if (!project || !build) {\n notFound(\"Project or build not found\");\n }\n await snapshots.review(snapshotId, \"approved\", userId);\n const baselines = new BaselineModel(db, getStore().storage);\n await baselines.upsert(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, snapshot.id, snapshot.screenshotPath);\n await refreshBuild(build.id);\n}\n\nfunction asString(value: FormDataEntryValue | null): string | undefined {\n return typeof value === \"string\" ? value : undefined;\n}\n\nexport { asString };"],"mappings":";;;;;;;;;;;;;AAWA,MAAa,aAAqC;CAAC;CAAU;CAAa;CAAY;AAAO;AAC7F,MAAa,kBAA0C;CAAC;CAAa;CAAY;AAAO;AACxF,MAAa,iBAAyC,CAAC,YAAY,OAAO;AAE1E,MAAa,oBAAoB,EAAE,OAAO;CACxC,QAAQ,EAAE,OAAO;CACjB,WAAW,EAAE,OAAO;CACpB,aAAa,EAAE,OAAO,CAAC,CAAC,SAAS;CACjC,YAAY,EAAE,OAAO,CAAC,CAAC,SAAS;CAChC,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAC7B,KAAK,EAAE,WAAW,IAAI,CAAC,CAAC,QAAQ;EAAE,MAAM;EAAU,QAAQ;CAAS,CAAC,CAAC,CAAC,SAAS;AACjF,CAAC,CAAC,CAAC,QAAQ,aAAa;AAExB,MAAa,iBAAiB,EAAE,OAAO;CACrC,QAAQ,EAAE,KAAK,cAAc,CAAC,CAAC,SAAS;CACxC,QAAQ,EAAE,OAAO,CAAC,CAAC,SAAS;AAC9B,CAAC;AAED,eAAsB,gBAAgB,WAAmB,SAA8D;CACrH,MAAM,QAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,OAAO;CAC7D,IAAI,CAAC,SAAS,MAAM,cAAc,WAChC,SAAS,iBAAiB;CAE5B,OAAO;AACT;AAEA,eAAsB,iBAAiB,OAAuB,YAAuE;CACnI,MAAM,WAAW,MAAM,IAAI,cAAc,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,UAAU;CACtE,IAAI,CAAC,YAAY,SAAS,YAAY,MAAM,IAC1C,SAAS,oBAAoB;CAE/B,OAAO;AACT;AAEA,eAAsB,aAAa,SAAgC;CACjE,MAAM,KAAK,SAAS,CAAC,CAAC;CACtB,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,aAAa,OAAO;CAC7C,MAAM,YAAY,MAAM,IAAI,cAAc,EAAE,CAAC,CAAC,YAAY,OAAO;CACjE,MAAM,aAAa,UAAU,MAAM,MAAM,EAAE,WAAW,SAAS,EAAE,WAAW,SAAS;CACrF,IAAI;CACJ,IAAI,YACF,SAAS;MAGT,SADiB,UAAU,MAAM,MAAM,EAAE,WAAW,UACpC,IAAI,aAAa;CAEnC,MAAM,QAAQ,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,IAAI,OAAO;CAClD,IAAI,OAAO;EACT,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,UAAU,SAAS,MAAM;EAClD,MAAM,iBAAiB,IAAI,MAAM,WAAW,SAAS,UAAU;GAC7D;GACA;GACA,eAAe,UAAU;EAC3B,CAAC;CACH;AACF;AAEA,eAAsB,gBAAgB,YAAoB,QAA+B;CACvF,MAAM,KAAK,SAAS,CAAC,CAAC;CACtB,MAAM,YAAY,IAAI,cAAc,EAAE;CACtC,MAAM,WAAW,MAAM,UAAU,IAAI,UAAU;CAC/C,IAAI,CAAC,UACH,SAAS,oBAAoB;CAE/B,MAAM,UAAU,MAAM,IAAI,aAAa,EAAE,CAAC,CAAC,IAAI,SAAS,SAAS;CACjE,MAAM,QAAQ,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,IAAI,SAAS,OAAO;CAC3D,IAAI,CAAC,WAAW,CAAC,OACf,SAAS,4BAA4B;CAEvC,MAAM,UAAU,OAAO,YAAY,YAAY,MAAM;CAErD,MAAM,IADgB,cAAc,IAAI,SAAS,CAAC,CAAC,OACrC,CAAC,CAAC,OAAO,QAAQ,IAAI,SAAS,SAAS,SAAS,cAAc,MAAM,WAAW,SAAS,IAAI,SAAS,cAAc;CACjI,MAAM,aAAa,MAAM,EAAE;AAC7B;AAEA,SAAS,SAAS,OAAsD;CACtE,OAAO,OAAO,UAAU,WAAW,QAAQ,KAAA;AAC7C"}
1
+ {"version":3,"file":"builds.handlers.mjs","names":[],"sources":["../../src/routers/builds.handlers.ts"],"sourcesContent":["import { z } from \"@hono/zod-openapi\";\n\nimport { BaselineModel } from \"../models/baseline.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport { SnapshotModel } from \"../models/snapshot.ts\";\nimport { emitWebhookEvent } from \"../adapters/webhook-events.ts\";\nimport { getStore } from \"../store.ts\";\nimport { type ProjectRole, BUILD_STATUSES } from \"../types.ts\";\nimport { notFound } from \"./helpers.ts\";\n\n/** Roles permitted to view builds, snapshots, and comments. */\nexport const VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\n/** Roles permitted to upload builds and write comments. */\nexport const DEVELOPER_ROLES: readonly ProjectRole[] = [\"developer\", \"approver\", \"admin\"];\n/** Roles permitted to approve or reject snapshots and delete builds. */\nexport const APPROVER_ROLES: readonly ProjectRole[] = [\"approver\", \"admin\"];\n\n/** Multipart input schema for uploading a build with its Storybook bundle. */\nexport const buildUploadSchema = z.object({\n gitSha: z.string(),\n gitBranch: z.string(),\n authorEmail: z.string().optional(),\n authorName: z.string().optional(),\n message: z.string().optional(),\n zip: z.instanceof(File).openapi({ type: \"string\", format: \"binary\" }).optional(),\n}).openapi(\"BuildUpload\");\n\n/** Query filters accepted by the build list endpoint. */\nexport const buildListQuery = z.object({\n status: z.enum(BUILD_STATUSES).optional(),\n branch: z.string().optional(),\n labelKey: z.string().optional(),\n labelValue: z.string().optional(),\n});\n\n/** Fetch a build scoped to its project, throwing 404 when it does not belong. */\nexport async function buildForProject(projectId: string, buildId: string): Promise<import(\"../models/build.ts\").Build> {\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || build.projectId !== projectId) {\n notFound(\"Build not found\");\n }\n return build;\n}\n\n/** Fetch a snapshot scoped to its build, throwing 404 when it does not belong. */\nexport async function snapshotForBuild(build: { id: string }, snapshotId: string): Promise<import(\"../models/snapshot.ts\").Snapshot> {\n const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);\n if (!snapshot || snapshot.buildId !== build.id) {\n notFound(\"Snapshot not found\");\n }\n return snapshot;\n}\n\n/** Recompute a build's counts and roll its status up from its snapshots. */\nexport async function refreshBuild(buildId: string): Promise<void> {\n const {db} = getStore();\n await new BuildModel(db).updateCounts(buildId);\n const snapshots = await new SnapshotModel(db).listByBuild(buildId);\n const unresolved = snapshots.some((s) => s.status === \"new\" || s.status === \"changed\");\n let status: \"reviewing\" | \"rejected\" | \"approved\";\n if (unresolved) {\n status = \"reviewing\";\n } else {\n const rejected = snapshots.some((s) => s.status === \"rejected\");\n status = rejected ? \"rejected\" : \"approved\";\n }\n const build = await new BuildModel(db).get(buildId);\n if (build) {\n await new BuildModel(db).setStatus(buildId, status);\n await emitWebhookEvent(db, build.projectId, `build:${status}`, {\n buildId,\n status,\n snapshotCount: snapshots.length,\n });\n }\n}\n\n/** Approve a snapshot, promote its screenshot to baseline, and refresh the build. */\nexport async function approveSnapshot(snapshotId: string, userId: string): Promise<void> {\n const {db} = getStore();\n const snapshots = new SnapshotModel(db);\n const snapshot = await snapshots.get(snapshotId);\n if (!snapshot) {\n notFound(\"Snapshot not found\");\n }\n const project = await new ProjectModel(db).get(snapshot.projectId);\n const build = await new BuildModel(db).get(snapshot.buildId);\n if (!project || !build) {\n notFound(\"Project or build not found\");\n }\n await snapshots.review(snapshotId, \"approved\", userId);\n const baselines = new BaselineModel(db, getStore().storage);\n await baselines.upsert(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, snapshot.id, snapshot.screenshotPath);\n await refreshBuild(build.id);\n}\n\n/** Extract a string from a multipart form field, ignoring file entries. */\nfunction asString(value: FormDataEntryValue | null): string | undefined {\n return typeof value === \"string\" ? value : undefined;\n}\n\nexport { asString };"],"mappings":"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"}
@@ -89,13 +89,16 @@ const deleteBuildRoute = createRoute({
89
89
  ...notFound
90
90
  }
91
91
  });
92
+ /** Register the build list, upload, fetch, retry, and delete endpoints. */
92
93
  function registerBuilds(app) {
93
94
  app.openapi(listBuildsRoute, async (c) => {
94
95
  const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...VIEW_ROLES);
95
- const { status, branch } = c.req.valid("query");
96
+ const { status, branch, labelKey, labelValue } = c.req.valid("query");
96
97
  const builds = new BuildModel(getStore().db).list(project.id, {
97
98
  status,
98
- branch: branch ?? void 0
99
+ branch: branch ?? void 0,
100
+ labelKey: labelKey ?? void 0,
101
+ labelValue: labelValue ?? void 0
99
102
  });
100
103
  return c.json(await builds);
101
104
  });
@@ -1 +1 @@
1
- {"version":3,"file":"builds.mjs","names":["notFoundResponse","forbiddenResponse"],"sources":["../../src/routers/builds.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\nimport { HTTPException } from \"hono/http-exception\";\n\nimport { BuildModel } from \"../models/build.ts\";\nimport { emitWebhookEvent } from \"../adapters/webhook-events.ts\";\nimport { getStore } from \"../store.ts\";\nimport { storybookZipPath } from \"../utils/paths.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { badRequest, buildSchema, forbidden as forbiddenResponse, notFound as notFoundResponse, unauthorized } from \"./schemas.ts\";\nimport { VIEW_ROLES, DEVELOPER_ROLES, APPROVER_ROLES, buildUploadSchema, buildListQuery, buildForProject, asString } from \"./builds.handlers.ts\";\nimport { registerSnapshots } from \"./snapshots.ts\";\nimport { registerComments } from \"./comments.ts\";\n\nconst listBuildsRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds\",\n request: { params: z.object({ slug: z.string() }), query: buildListQuery },\n responses: {\n 200: { content: { \"application/json\": { schema: buildSchema.array() } }, description: \"List builds for a project\" },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst createBuildRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"multipart/form-data\": { schema: buildUploadSchema } } },\n },\n responses: {\n 202: { content: { \"application/json\": { schema: buildSchema } }, description: \"Build created and capture queued\" },\n ...badRequest,\n ...forbiddenResponse,\n ...notFoundResponse,\n },\n});\n\nconst getBuildRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: buildSchema } }, description: \"Fetch a build\" },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst retryBuildRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/retry\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 202: { content: { \"application/json\": { schema: buildSchema } }, description: \"Build reset to pending\" },\n ...notFoundResponse,\n },\n});\n\nconst deleteBuildRoute = createRoute({\n method: \"delete\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 204: { description: \"Build deleted\" },\n ...notFoundResponse,\n },\n});\n\nexport function registerBuilds(app: ShelfApp): void {\n app.openapi(listBuildsRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n const { status, branch } = c.req.valid(\"query\");\n const builds = new BuildModel(getStore().db).list(project.id, { status, branch: branch ?? undefined });\n return c.json(await builds);\n });\n\n app.openapi(createBuildRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...DEVELOPER_ROLES);\n const form = await c.req.formData();\n\n const gitSha = asString(form.get(\"gitSha\")) ?? \"\";\n const gitBranch = asString(form.get(\"gitBranch\")) ?? \"\";\n if (!gitSha || !gitBranch) {\n throw new HTTPException(400, { message: \"gitSha and gitBranch are required\" });\n }\n const authorEmail = asString(form.get(\"authorEmail\"));\n const authorName = asString(form.get(\"authorName\"));\n const message = asString(form.get(\"message\"));\n\n const build = await new BuildModel(getStore().db).create(project.id, {\n gitSha,\n gitBranch,\n isDefault: gitBranch === project.gitDefaultBranch,\n authorEmail,\n authorName,\n message,\n });\n\n await emitWebhookEvent(getStore().db, project.id, \"build:created\", {\n buildId: build.id,\n gitSha,\n gitBranch,\n authorEmail,\n authorName,\n message,\n });\n\n const zip = form.get(\"zip\");\n if (zip && typeof zip !== \"string\") {\n const buffer = Buffer.from(await zip.arrayBuffer());\n await getStore().storage.write(storybookZipPath(project.id, build.id), buffer);\n }\n\n const reqId = c.get(\"requestId\");\n await getStore().enqueueCapture?.(build.id, reqId);\n return c.json(build, 202);\n });\n\n app.openapi(getBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n const build = await buildForProject(project.id, buildId);\n return c.json(build);\n });\n\n app.openapi(retryBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const updated = await new BuildModel(getStore().db).setStatus(build.id, \"pending\");\n return c.json(updated, 202);\n });\n\n app.openapi(deleteBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n await new BuildModel(getStore().db).remove(build.id);\n return c.body(null, 204);\n });\n\n registerSnapshots(app);\n registerComments(app);\n}"],"mappings":"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"}
1
+ {"version":3,"file":"builds.mjs","names":["notFoundResponse","forbiddenResponse"],"sources":["../../src/routers/builds.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\nimport { HTTPException } from \"hono/http-exception\";\n\nimport { BuildModel } from \"../models/build.ts\";\nimport { emitWebhookEvent } from \"../adapters/webhook-events.ts\";\nimport { getStore } from \"../store.ts\";\nimport { storybookZipPath } from \"../utils/paths.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { badRequest, buildSchema, forbidden as forbiddenResponse, notFound as notFoundResponse, unauthorized } from \"./schemas.ts\";\nimport { VIEW_ROLES, DEVELOPER_ROLES, APPROVER_ROLES, buildUploadSchema, buildListQuery, buildForProject, asString } from \"./builds.handlers.ts\";\nimport { registerSnapshots } from \"./snapshots.ts\";\nimport { registerComments } from \"./comments.ts\";\n\nconst listBuildsRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds\",\n request: { params: z.object({ slug: z.string() }), query: buildListQuery },\n responses: {\n 200: { content: { \"application/json\": { schema: buildSchema.array() } }, description: \"List builds for a project\" },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst createBuildRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"multipart/form-data\": { schema: buildUploadSchema } } },\n },\n responses: {\n 202: { content: { \"application/json\": { schema: buildSchema } }, description: \"Build created and capture queued\" },\n ...badRequest,\n ...forbiddenResponse,\n ...notFoundResponse,\n },\n});\n\nconst getBuildRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: buildSchema } }, description: \"Fetch a build\" },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst retryBuildRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/retry\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 202: { content: { \"application/json\": { schema: buildSchema } }, description: \"Build reset to pending\" },\n ...notFoundResponse,\n },\n});\n\nconst deleteBuildRoute = createRoute({\n method: \"delete\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 204: { description: \"Build deleted\" },\n ...notFoundResponse,\n },\n});\n\n/** Register the build list, upload, fetch, retry, and delete endpoints. */\nexport function registerBuilds(app: ShelfApp): void {\n app.openapi(listBuildsRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n const { status, branch, labelKey, labelValue } = c.req.valid(\"query\");\n const builds = new BuildModel(getStore().db).list(project.id, { status, branch: branch ?? undefined, labelKey: labelKey ?? undefined, labelValue: labelValue ?? undefined });\n return c.json(await builds);\n });\n\n app.openapi(createBuildRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...DEVELOPER_ROLES);\n const form = await c.req.formData();\n\n const gitSha = asString(form.get(\"gitSha\")) ?? \"\";\n const gitBranch = asString(form.get(\"gitBranch\")) ?? \"\";\n if (!gitSha || !gitBranch) {\n throw new HTTPException(400, { message: \"gitSha and gitBranch are required\" });\n }\n const authorEmail = asString(form.get(\"authorEmail\"));\n const authorName = asString(form.get(\"authorName\"));\n const message = asString(form.get(\"message\"));\n\n const build = await new BuildModel(getStore().db).create(project.id, {\n gitSha,\n gitBranch,\n isDefault: gitBranch === project.gitDefaultBranch,\n authorEmail,\n authorName,\n message,\n });\n\n await emitWebhookEvent(getStore().db, project.id, \"build:created\", {\n buildId: build.id,\n gitSha,\n gitBranch,\n authorEmail,\n authorName,\n message,\n });\n\n const zip = form.get(\"zip\");\n if (zip && typeof zip !== \"string\") {\n const buffer = Buffer.from(await zip.arrayBuffer());\n await getStore().storage.write(storybookZipPath(project.id, build.id), buffer);\n }\n\n const reqId = c.get(\"requestId\");\n await getStore().enqueueCapture?.(build.id, reqId);\n return c.json(build, 202);\n });\n\n app.openapi(getBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n const build = await buildForProject(project.id, buildId);\n return c.json(build);\n });\n\n app.openapi(retryBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const updated = await new BuildModel(getStore().db).setStatus(build.id, \"pending\");\n return c.json(updated, 202);\n });\n\n app.openapi(deleteBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n await new BuildModel(getStore().db).remove(build.id);\n return c.body(null, 204);\n });\n\n registerSnapshots(app);\n registerComments(app);\n}"],"mappings":"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"}
@@ -58,6 +58,7 @@ const resolveCommentRoute = createRoute({
58
58
  ...notFound$1
59
59
  }
60
60
  });
61
+ /** Register the build comment list, create, and resolve endpoints. */
61
62
  function registerComments(app) {
62
63
  app.openapi(listCommentsRoute, async (c) => {
63
64
  const { slug, buildId } = c.req.valid("param");
@@ -1 +1 @@
1
- {"version":3,"file":"comments.mjs","names":["notFound"],"sources":["../../src/routers/comments.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\n\nimport { CommentModel } from \"../models/comment.ts\";\nimport { getStore } from \"../store.ts\";\nimport { resolveAuthorizedProject, notFound as throwNotFound } from \"./helpers.ts\";\nimport { commentSchema, commentCreateSchema, notFound, unauthorized } from \"./schemas.ts\";\nimport { VIEW_ROLES, DEVELOPER_ROLES, buildForProject } from \"./builds.handlers.ts\";\n\nconst listCommentsRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: commentSchema.array() } }, description: \"List comments on a build\" },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst createCommentRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments\",\n request: {\n params: z.object({ slug: z.string(), buildId: z.string() }),\n body: { content: { \"application/json\": { schema: commentCreateSchema } } },\n },\n responses: {\n 201: { content: { \"application/json\": { schema: commentSchema } }, description: \"Comment created\" },\n ...notFound,\n },\n});\n\nconst resolveCommentRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments/{commentId}/resolve\",\n request: { params: z.object({ slug: z.string(), buildId: z.string(), commentId: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: commentSchema } }, description: \"Comment resolved\" },\n ...notFound,\n },\n});\n\nexport function registerComments(app: ShelfApp): void {\n app.openapi(listCommentsRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n const build = await buildForProject(project.id, buildId);\n return c.json(await new CommentModel(getStore().db).listByBuild(build.id));\n });\n\n app.openapi(createCommentRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const body = c.req.valid(\"json\");\n const userId = getStore().user?.id ?? \"anonymous\";\n const comment = await new CommentModel(getStore().db).create(project.id, build.id, userId, body);\n return c.json(comment, 201);\n });\n\n app.openapi(resolveCommentRoute, async (c) => {\n const { slug, buildId, commentId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const comment = await new CommentModel(getStore().db).resolve(commentId);\n if (comment.buildId !== build.id) {\n throwNotFound(\"Comment not found\");\n }\n return c.json(comment);\n });\n}"],"mappings":";;;;;;;;;;AASA,MAAM,oBAAoB,YAAY;CACpC,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;CAAE,CAAC,EAAE;CACvE,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,MAAM,EAAE,EAAE;GAAG,aAAa;EAA2B;EACnH,GAAGA;EACH,GAAG;CACL;AACF,CAAC;AAED,MAAM,qBAAqB,YAAY;CACrC,QAAQ;CACR,MAAM;CACN,SAAS;EACP,QAAQ,EAAE,OAAO;GAAE,MAAM,EAAE,OAAO;GAAG,SAAS,EAAE,OAAO;EAAE,CAAC;EAC1D,MAAM,EAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,oBAAoB,EAAE,EAAE;CAC3E;CACA,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,EAAE;GAAG,aAAa;EAAkB;EAClG,GAAGA;CACL;AACF,CAAC;AAED,MAAM,sBAAsB,YAAY;CACtC,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;EAAG,WAAW,EAAE,OAAO;CAAE,CAAC,EAAE;CAC9F,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,EAAE;GAAG,aAAa;EAAmB;EACnG,GAAGA;CACL;AACF,CAAC;AAED,SAAgB,iBAAiB,KAAqB;CACpD,IAAI,QAAQ,mBAAmB,OAAO,MAAM;EAC1C,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,UAAU;EACrE,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,OAAO,EAAE,KAAK,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE,CAAC;CAC3E,CAAC;CAED,IAAI,QAAQ,oBAAoB,OAAO,MAAM;EAC3C,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,eAAe;EAC1E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,OAAO,EAAE,IAAI,MAAM,MAAM;EAC/B,MAAM,SAAS,SAAS,CAAC,CAAC,MAAM,MAAM;EACtC,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,OAAO,QAAQ,IAAI,MAAM,IAAI,QAAQ,IAAI;EAC/F,OAAO,EAAE,KAAK,SAAS,GAAG;CAC5B,CAAC;CAED,IAAI,QAAQ,qBAAqB,OAAO,MAAM;EAC5C,MAAM,EAAE,MAAM,SAAS,cAAc,EAAE,IAAI,MAAM,OAAO;EACxD,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,eAAe;EAC1E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,QAAQ,SAAS;EACvE,IAAI,QAAQ,YAAY,MAAM,IAC5B,SAAc,mBAAmB;EAEnC,OAAO,EAAE,KAAK,OAAO;CACvB,CAAC;AACH"}
1
+ {"version":3,"file":"comments.mjs","names":["notFound"],"sources":["../../src/routers/comments.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\n\nimport { CommentModel } from \"../models/comment.ts\";\nimport { getStore } from \"../store.ts\";\nimport { resolveAuthorizedProject, notFound as throwNotFound } from \"./helpers.ts\";\nimport { commentSchema, commentCreateSchema, notFound, unauthorized } from \"./schemas.ts\";\nimport { VIEW_ROLES, DEVELOPER_ROLES, buildForProject } from \"./builds.handlers.ts\";\n\nconst listCommentsRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: commentSchema.array() } }, description: \"List comments on a build\" },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst createCommentRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments\",\n request: {\n params: z.object({ slug: z.string(), buildId: z.string() }),\n body: { content: { \"application/json\": { schema: commentCreateSchema } } },\n },\n responses: {\n 201: { content: { \"application/json\": { schema: commentSchema } }, description: \"Comment created\" },\n ...notFound,\n },\n});\n\nconst resolveCommentRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments/{commentId}/resolve\",\n request: { params: z.object({ slug: z.string(), buildId: z.string(), commentId: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: commentSchema } }, description: \"Comment resolved\" },\n ...notFound,\n },\n});\n\n/** Register the build comment list, create, and resolve endpoints. */\nexport function registerComments(app: ShelfApp): void {\n app.openapi(listCommentsRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n const build = await buildForProject(project.id, buildId);\n return c.json(await new CommentModel(getStore().db).listByBuild(build.id));\n });\n\n app.openapi(createCommentRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const body = c.req.valid(\"json\");\n const userId = getStore().user?.id ?? \"anonymous\";\n const comment = await new CommentModel(getStore().db).create(project.id, build.id, userId, body);\n return c.json(comment, 201);\n });\n\n app.openapi(resolveCommentRoute, async (c) => {\n const { slug, buildId, commentId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const comment = await new CommentModel(getStore().db).resolve(commentId);\n if (comment.buildId !== build.id) {\n throwNotFound(\"Comment not found\");\n }\n return c.json(comment);\n });\n}"],"mappings":";;;;;;;;;;AASA,MAAM,oBAAoB,YAAY;CACpC,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;CAAE,CAAC,EAAE;CACvE,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,MAAM,EAAE,EAAE;GAAG,aAAa;EAA2B;EACnH,GAAGA;EACH,GAAG;CACL;AACF,CAAC;AAED,MAAM,qBAAqB,YAAY;CACrC,QAAQ;CACR,MAAM;CACN,SAAS;EACP,QAAQ,EAAE,OAAO;GAAE,MAAM,EAAE,OAAO;GAAG,SAAS,EAAE,OAAO;EAAE,CAAC;EAC1D,MAAM,EAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,oBAAoB,EAAE,EAAE;CAC3E;CACA,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,EAAE;GAAG,aAAa;EAAkB;EAClG,GAAGA;CACL;AACF,CAAC;AAED,MAAM,sBAAsB,YAAY;CACtC,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;EAAG,WAAW,EAAE,OAAO;CAAE,CAAC,EAAE;CAC9F,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,EAAE;GAAG,aAAa;EAAmB;EACnG,GAAGA;CACL;AACF,CAAC;;AAGD,SAAgB,iBAAiB,KAAqB;CACpD,IAAI,QAAQ,mBAAmB,OAAO,MAAM;EAC1C,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,UAAU;EACrE,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,OAAO,EAAE,KAAK,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE,CAAC;CAC3E,CAAC;CAED,IAAI,QAAQ,oBAAoB,OAAO,MAAM;EAC3C,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,eAAe;EAC1E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,OAAO,EAAE,IAAI,MAAM,MAAM;EAC/B,MAAM,SAAS,SAAS,CAAC,CAAC,MAAM,MAAM;EACtC,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,OAAO,QAAQ,IAAI,MAAM,IAAI,QAAQ,IAAI;EAC/F,OAAO,EAAE,KAAK,SAAS,GAAG;CAC5B,CAAC;CAED,IAAI,QAAQ,qBAAqB,OAAO,MAAM;EAC5C,MAAM,EAAE,MAAM,SAAS,cAAc,EAAE,IAAI,MAAM,OAAO;EACxD,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,eAAe;EAC1E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,QAAQ,SAAS;EACvE,IAAI,QAAQ,YAAY,MAAM,IAC5B,SAAc,mBAAmB;EAEnC,OAAO,EAAE,KAAK,OAAO;CACvB,CAAC;AACH"}
@@ -1,7 +1,7 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { projects } from "../schema.mjs";
4
+ import { projects } from "../schema-tables.mjs";
5
5
  import { ProjectModel } from "../models/project.mjs";
6
6
  import { getStore } from "../store.mjs";
7
7
  import { MemberModel } from "../models/member.mjs";
@@ -10,20 +10,25 @@ import { sha256 } from "../utils/hash.mjs";
10
10
  import { eq } from "drizzle-orm";
11
11
  import { HTTPException } from "hono/http-exception";
12
12
  //#region src/routers/helpers.ts
13
+ /** Throw a 401 Unauthorized error for unauthenticated requests. */
13
14
  function unauthorized() {
14
15
  throw new HTTPException(401, { message: "Unauthorized" });
15
16
  }
17
+ /** Throw a 403 Forbidden error for requests lacking the required role. */
16
18
  function forbidden() {
17
19
  throw new HTTPException(403, { message: "Forbidden" });
18
20
  }
21
+ /** Throw a 404 Not Found error with a custom message. */
19
22
  function notFound(message = "Not found") {
20
23
  throw new HTTPException(404, { message });
21
24
  }
25
+ /** Resolve the current session user's effective role on a project. */
22
26
  async function currentProjectRole(projectId) {
23
27
  const { db, user } = getStore();
24
28
  if (!user) return null;
25
29
  return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);
26
30
  }
31
+ /** Look up a project by its URL slug, returning null when absent. */
27
32
  async function findProjectBySlug(slug) {
28
33
  const { db } = getStore();
29
34
  return (await db.list(projects, {
@@ -56,6 +61,7 @@ async function assertRole(projectId, ...minRoles) {
56
61
  const role = await currentProjectRole(projectId);
57
62
  if (!role || !minRoles.includes(role)) forbidden();
58
63
  }
64
+ /** Resolve a project by slug and enforce the caller's minimum role. */
59
65
  async function resolveAuthorizedProject(c, slug, ...minRoles) {
60
66
  const project = await resolveProjectByToken(c, slug);
61
67
  if (project) return project;
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.mjs","names":[],"sources":["../../src/routers/helpers.ts"],"sourcesContent":["import { eq } from \"drizzle-orm\";\nimport type { Context, Next } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ContentfulStatusCode } from \"hono/utils/http-status\";\nimport type { ZodType } from \"zod\";\n\nimport { MemberModel } from \"../models/member.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport { TokenModel } from \"../models/token.ts\";\nimport { projects, type Project } from \"../schema.ts\";\nimport { getStore } from \"../store.ts\";\nimport type { ProjectRole } from \"../types.ts\";\nimport { sha256 } from \"../utils/hash.ts\";\n\nexport function json(c: Context, data: unknown, status: ContentfulStatusCode = 200): Response {\n return c.json(data, status);\n}\n\nexport async function validJson<T>(c: Context, schema: ZodType<T>): Promise<T> {\n const result = schema.safeParse(await c.req.json());\n if (!result.success) {\n throw new HTTPException(400, { message: result.error.message });\n }\n return result.data;\n}\n\nexport function unauthorized(): never {\n throw new HTTPException(401, { message: \"Unauthorized\" });\n}\n\nexport function forbidden(): never {\n throw new HTTPException(403, { message: \"Forbidden\" });\n}\n\nexport function notFound(message = \"Not found\"): never {\n throw new HTTPException(404, { message });\n}\n\nexport async function currentProjectRole(projectId: string): Promise<ProjectRole | null> {\n const { db, user } = getStore();\n if (!user) {\n return null;\n }\n return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);\n}\n\nexport function requireRole(...roles: ProjectRole[]) {\n return async (c: Context, next: Next): Promise<void> => {\n const projectId = c.req.param(\"projectId\") ?? c.req.param(\"slug\");\n if (!projectId) {\n forbidden();\n }\n const role = await currentProjectRole(projectId);\n if (!role || !roles.includes(role)) {\n forbidden();\n }\n await next();\n };\n}\n\nexport async function findProjectBySlug(slug: string): Promise<Project | null> {\n const { db } = getStore();\n const rows = await db.list(projects, { where: eq(projects.slug, slug), limit: 1 });\n return rows[0] ?? null;\n}\n\nasync function resolveProjectByToken(c: Context, slug: string): Promise<Project | null> {\n const authHeader = c.req.header(\"authorization\");\n if (authHeader?.startsWith(\"Bearer \")) {\n const token = authHeader.slice(\"Bearer \".length);\n const found = await new TokenModel(getStore().db).findByHash(sha256(token));\n if (!found) {\n unauthorized();\n }\n const project = await new ProjectModel(getStore().db).get(found.projectId);\n if (!project || project.slug !== slug) {\n forbidden();\n }\n return project;\n }\n return null;\n}\n\nexport async function resolveProject(c: Context, slug: string): Promise<Project> {\n const project = await resolveProjectByToken(c, slug);\n if (project) return project;\n const found = await findProjectBySlug(slug);\n if (!found) {\n notFound(\"Project not found\");\n }\n return found;\n}\n\n/**\n * Enforce the caller's project role for a given project.\n *\n * Authorization model (ADR 0008):\n * - No auth adapter configured -> all operations permitted (development mode).\n * - Bearer token (CLI) -> grants access to its own project regardless of role.\n * - Session user -> must have an effective role in `minRoles`.\n */\nexport async function assertRole(projectId: string, ...minRoles: ProjectRole[]): Promise<void> {\n if (!getStore().authEnabled) {\n return;\n }\n const role = await currentProjectRole(projectId);\n if (!role || !minRoles.includes(role)) {\n forbidden();\n }\n}\n\nexport async function resolveAuthorizedProject(c: Context, slug: string, ...minRoles: ProjectRole[]): Promise<Project> {\n const project = await resolveProjectByToken(c, slug);\n if (project) return project;\n const found = await findProjectBySlug(slug);\n if (!found) {\n notFound(\"Project not found\");\n }\n await assertRole(found.id, ...minRoles);\n return found;\n}\n\n/** Require a site-level admin (or permit when auth is disabled). */\nexport function requireSiteAdmin(): void {\n if (!getStore().authEnabled) {\n return;\n }\n const { user } = getStore();\n if (user?.role !== \"admin\") {\n forbidden();\n }\n}\n\n/** Require the current session user to hold one of the given project roles. */\nexport async function requireProjectRole(projectId: string, ...minRoles: ProjectRole[]): Promise<void> {\n await assertRole(projectId, ...minRoles);\n}\n"],"mappings":";;;;;;;;;;;;AA0BA,SAAgB,eAAsB;CACpC,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,eAAe,CAAC;AAC1D;AAEA,SAAgB,YAAmB;CACjC,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,YAAY,CAAC;AACvD;AAEA,SAAgB,SAAS,UAAU,aAAoB;CACrD,MAAM,IAAI,cAAc,KAAK,EAAE,QAAQ,CAAC;AAC1C;AAEA,eAAsB,mBAAmB,WAAgD;CACvF,MAAM,EAAE,IAAI,SAAS,SAAS;CAC9B,IAAI,CAAC,MACH,OAAO;CAET,OAAO,MAAM,IAAI,YAAY,EAAE,CAAC,CAAC,cAAc,KAAK,MAAM,WAAW,KAAK,EAAE;AAC9E;AAgBA,eAAsB,kBAAkB,MAAuC;CAC7E,MAAM,EAAE,OAAO,SAAS;CAExB,QAAO,MADY,GAAG,KAAK,UAAU;EAAE,OAAO,GAAG,SAAS,MAAM,IAAI;EAAG,OAAO;CAAE,CAAC,EAAA,CACrE,MAAM;AACpB;AAEA,eAAe,sBAAsB,GAAY,MAAuC;CACtF,MAAM,aAAa,EAAE,IAAI,OAAO,eAAe;CAC/C,IAAI,YAAY,WAAW,SAAS,GAAG;EACrC,MAAM,QAAQ,WAAW,MAAM,CAAgB;EAC/C,MAAM,QAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,WAAW,OAAO,KAAK,CAAC;EAC1E,IAAI,CAAC,OACH,aAAa;EAEf,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,SAAS;EACzE,IAAI,CAAC,WAAW,QAAQ,SAAS,MAC/B,UAAU;EAEZ,OAAO;CACT;CACA,OAAO;AACT;;;;;;;;;AAoBA,eAAsB,WAAW,WAAmB,GAAG,UAAwC;CAC7F,IAAI,CAAC,SAAS,CAAC,CAAC,aACd;CAEF,MAAM,OAAO,MAAM,mBAAmB,SAAS;CAC/C,IAAI,CAAC,QAAQ,CAAC,SAAS,SAAS,IAAI,GAClC,UAAU;AAEd;AAEA,eAAsB,yBAAyB,GAAY,MAAc,GAAG,UAA2C;CACrH,MAAM,UAAU,MAAM,sBAAsB,GAAG,IAAI;CACnD,IAAI,SAAS,OAAO;CACpB,MAAM,QAAQ,MAAM,kBAAkB,IAAI;CAC1C,IAAI,CAAC,OACH,SAAS,mBAAmB;CAE9B,MAAM,WAAW,MAAM,IAAI,GAAG,QAAQ;CACtC,OAAO;AACT;;AAGA,SAAgB,mBAAyB;CACvC,IAAI,CAAC,SAAS,CAAC,CAAC,aACd;CAEF,MAAM,EAAE,SAAS,SAAS;CAC1B,IAAI,MAAM,SAAS,SACjB,UAAU;AAEd"}
1
+ {"version":3,"file":"helpers.mjs","names":[],"sources":["../../src/routers/helpers.ts"],"sourcesContent":["import { eq } from \"drizzle-orm\";\nimport type { Context, Next } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ContentfulStatusCode } from \"hono/utils/http-status\";\nimport type { ZodType } from \"zod\";\n\nimport { MemberModel } from \"../models/member.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport { TokenModel } from \"../models/token.ts\";\nimport { projects } from \"../schema-tables.ts\";\nimport type { Project } from \"../schema.ts\";\nimport { getStore } from \"../store.ts\";\nimport type { ProjectRole } from \"../types.ts\";\nimport { sha256 } from \"../utils/hash.ts\";\n\n/** Send a JSON response with the given status code. */\nexport function json(c: Context, data: unknown, status: ContentfulStatusCode = 200): Response {\n return c.json(data, status);\n}\n\n/** Parse the request body as JSON and validate it against a Zod schema. */\nexport async function validJson<T>(c: Context, schema: ZodType<T>): Promise<T> {\n const result = schema.safeParse(await c.req.json());\n if (!result.success) {\n throw new HTTPException(400, { message: result.error.message });\n }\n return result.data;\n}\n\n/** Throw a 401 Unauthorized error for unauthenticated requests. */\nexport function unauthorized(): never {\n throw new HTTPException(401, { message: \"Unauthorized\" });\n}\n\n/** Throw a 403 Forbidden error for requests lacking the required role. */\nexport function forbidden(): never {\n throw new HTTPException(403, { message: \"Forbidden\" });\n}\n\n/** Throw a 404 Not Found error with a custom message. */\nexport function notFound(message = \"Not found\"): never {\n throw new HTTPException(404, { message });\n}\n\n/** Resolve the current session user's effective role on a project. */\nexport async function currentProjectRole(projectId: string): Promise<ProjectRole | null> {\n const { db, user } = getStore();\n if (!user) {\n return null;\n }\n return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);\n}\n\n/** Build middleware that requires one of the given project roles. */\nexport function requireRole(...roles: ProjectRole[]) {\n return async (c: Context, next: Next): Promise<void> => {\n const projectId = c.req.param(\"projectId\") ?? c.req.param(\"slug\");\n if (!projectId) {\n forbidden();\n }\n const role = await currentProjectRole(projectId);\n if (!role || !roles.includes(role)) {\n forbidden();\n }\n await next();\n };\n}\n\n/** Look up a project by its URL slug, returning null when absent. */\nexport async function findProjectBySlug(slug: string): Promise<Project | null> {\n const { db } = getStore();\n const rows = await db.list(projects, { where: eq(projects.slug, slug), limit: 1 });\n return rows[0] ?? null;\n}\n\nasync function resolveProjectByToken(c: Context, slug: string): Promise<Project | null> {\n const authHeader = c.req.header(\"authorization\");\n if (authHeader?.startsWith(\"Bearer \")) {\n const token = authHeader.slice(\"Bearer \".length);\n const found = await new TokenModel(getStore().db).findByHash(sha256(token));\n if (!found) {\n unauthorized();\n }\n const project = await new ProjectModel(getStore().db).get(found.projectId);\n if (!project || project.slug !== slug) {\n forbidden();\n }\n return project;\n }\n return null;\n}\n\n/** Resolve a project by slug, honoring CLI bearer-token access. */\nexport async function resolveProject(c: Context, slug: string): Promise<Project> {\n const project = await resolveProjectByToken(c, slug);\n if (project) {return project;}\n const found = await findProjectBySlug(slug);\n if (!found) {\n notFound(\"Project not found\");\n }\n return found;\n}\n\n/**\n * Enforce the caller's project role for a given project.\n *\n * Authorization model (ADR 0008):\n * - No auth adapter configured -> all operations permitted (development mode).\n * - Bearer token (CLI) -> grants access to its own project regardless of role.\n * - Session user -> must have an effective role in `minRoles`.\n */\nexport async function assertRole(projectId: string, ...minRoles: ProjectRole[]): Promise<void> {\n if (!getStore().authEnabled) {\n return;\n }\n const role = await currentProjectRole(projectId);\n if (!role || !minRoles.includes(role)) {\n forbidden();\n }\n}\n\n/** Resolve a project by slug and enforce the caller's minimum role. */\nexport async function resolveAuthorizedProject(c: Context, slug: string, ...minRoles: ProjectRole[]): Promise<Project> {\n const project = await resolveProjectByToken(c, slug);\n if (project) {return project;}\n const found = await findProjectBySlug(slug);\n if (!found) {\n notFound(\"Project not found\");\n }\n await assertRole(found.id, ...minRoles);\n return found;\n}\n\n/** Require a site-level admin (or permit when auth is disabled). */\nexport function requireSiteAdmin(): void {\n if (!getStore().authEnabled) {\n return;\n }\n const { user } = getStore();\n if (user?.role !== \"admin\") {\n forbidden();\n }\n}\n\n/** Require the current session user to hold one of the given project roles. */\nexport async function requireProjectRole(projectId: string, ...minRoles: ProjectRole[]): Promise<void> {\n await assertRole(projectId, ...minRoles);\n}\n"],"mappings":";;;;;;;;;;;;;AA8BA,SAAgB,eAAsB;CACpC,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,eAAe,CAAC;AAC1D;;AAGA,SAAgB,YAAmB;CACjC,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,YAAY,CAAC;AACvD;;AAGA,SAAgB,SAAS,UAAU,aAAoB;CACrD,MAAM,IAAI,cAAc,KAAK,EAAE,QAAQ,CAAC;AAC1C;;AAGA,eAAsB,mBAAmB,WAAgD;CACvF,MAAM,EAAE,IAAI,SAAS,SAAS;CAC9B,IAAI,CAAC,MACH,OAAO;CAET,OAAO,MAAM,IAAI,YAAY,EAAE,CAAC,CAAC,cAAc,KAAK,MAAM,WAAW,KAAK,EAAE;AAC9E;;AAkBA,eAAsB,kBAAkB,MAAuC;CAC7E,MAAM,EAAE,OAAO,SAAS;CAExB,QAAO,MADY,GAAG,KAAK,UAAU;EAAE,OAAO,GAAG,SAAS,MAAM,IAAI;EAAG,OAAO;CAAE,CAAC,EAAA,CACrE,MAAM;AACpB;AAEA,eAAe,sBAAsB,GAAY,MAAuC;CACtF,MAAM,aAAa,EAAE,IAAI,OAAO,eAAe;CAC/C,IAAI,YAAY,WAAW,SAAS,GAAG;EACrC,MAAM,QAAQ,WAAW,MAAM,CAAgB;EAC/C,MAAM,QAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,WAAW,OAAO,KAAK,CAAC;EAC1E,IAAI,CAAC,OACH,aAAa;EAEf,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,SAAS;EACzE,IAAI,CAAC,WAAW,QAAQ,SAAS,MAC/B,UAAU;EAEZ,OAAO;CACT;CACA,OAAO;AACT;;;;;;;;;AAqBA,eAAsB,WAAW,WAAmB,GAAG,UAAwC;CAC7F,IAAI,CAAC,SAAS,CAAC,CAAC,aACd;CAEF,MAAM,OAAO,MAAM,mBAAmB,SAAS;CAC/C,IAAI,CAAC,QAAQ,CAAC,SAAS,SAAS,IAAI,GAClC,UAAU;AAEd;;AAGA,eAAsB,yBAAyB,GAAY,MAAc,GAAG,UAA2C;CACrH,MAAM,UAAU,MAAM,sBAAsB,GAAG,IAAI;CACnD,IAAI,SAAU,OAAO;CACrB,MAAM,QAAQ,MAAM,kBAAkB,IAAI;CAC1C,IAAI,CAAC,OACH,SAAS,mBAAmB;CAE9B,MAAM,WAAW,MAAM,IAAI,GAAG,QAAQ;CACtC,OAAO;AACT;;AAGA,SAAgB,mBAAyB;CACvC,IAAI,CAAC,SAAS,CAAC,CAAC,aACd;CAEF,MAAM,EAAE,SAAS,SAAS;CAC1B,IAAI,MAAM,SAAS,SACjB,UAAU;AAEd"}
@@ -2,9 +2,11 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  //#region src/routers/htmx.ts
5
+ /** Check whether the request came from HTMX (HX-Request header). */
5
6
  function isHxRequest(c) {
6
7
  return c.req.header("HX-Request") === "true";
7
8
  }
9
+ /** Redirect HTMX requests via HX-Redirect and plain requests via 302. */
8
10
  function hxRedirect(c, url) {
9
11
  if (isHxRequest(c)) {
10
12
  c.header("HX-Redirect", url);
@@ -1 +1 @@
1
- {"version":3,"file":"htmx.mjs","names":[],"sources":["../../src/routers/htmx.ts"],"sourcesContent":["import type { Context } from \"hono\";\n\nexport function isHxRequest(c: Context): boolean {\n return c.req.header(\"HX-Request\") === \"true\";\n}\n\nexport function hxRedirect(c: Context, url: string): Response {\n if (isHxRequest(c)) {\n c.header(\"HX-Redirect\", url);\n return c.body(null, 204);\n }\n return c.redirect(url, 302);\n}"],"mappings":";;;;AAEA,SAAgB,YAAY,GAAqB;CAC/C,OAAO,EAAE,IAAI,OAAO,YAAY,MAAM;AACxC;AAEA,SAAgB,WAAW,GAAY,KAAuB;CAC5D,IAAI,YAAY,CAAC,GAAG;EAClB,EAAE,OAAO,eAAe,GAAG;EAC3B,OAAO,EAAE,KAAK,MAAM,GAAG;CACzB;CACA,OAAO,EAAE,SAAS,KAAK,GAAG;AAC5B"}
1
+ {"version":3,"file":"htmx.mjs","names":[],"sources":["../../src/routers/htmx.ts"],"sourcesContent":["import type { Context } from \"hono\";\n\n/** Check whether the request came from HTMX (HX-Request header). */\nexport function isHxRequest(c: Context): boolean {\n return c.req.header(\"HX-Request\") === \"true\";\n}\n\n/** Redirect HTMX requests via HX-Redirect and plain requests via 302. */\nexport function hxRedirect(c: Context, url: string): Response {\n if (isHxRequest(c)) {\n c.header(\"HX-Redirect\", url);\n return c.body(null, 204);\n }\n return c.redirect(url, 302);\n}"],"mappings":";;;;;AAGA,SAAgB,YAAY,GAAqB;CAC/C,OAAO,EAAE,IAAI,OAAO,YAAY,MAAM;AACxC;;AAGA,SAAgB,WAAW,GAAY,KAAuB;CAC5D,IAAI,YAAY,CAAC,GAAG;EAClB,EAAE,OAAO,eAAe,GAAG;EAC3B,OAAO,EAAE,KAAK,MAAM,GAAG;CACzB;CACA,OAAO,EAAE,SAAS,KAAK,GAAG;AAC5B"}
@@ -4,8 +4,9 @@ import.meta.url;
4
4
  import { LabelModel } from "../models/label.mjs";
5
5
  import { getStore } from "../store.mjs";
6
6
  import { resolveAuthorizedProject } from "./helpers.mjs";
7
- import { labelTypeCreateSchema, labelTypeSchema, notFound, unauthorized } from "./schemas.mjs";
7
+ import { labelTypeCreateSchema, labelTypeSchema, labelTypeUpdateSchema, notFound, unauthorized } from "./schemas.mjs";
8
8
  import { createRoute, z } from "@hono/zod-openapi";
9
+ import { HTTPException } from "hono/http-exception";
9
10
  //#region src/routers/labels.ts
10
11
  const VIEW_ROLES = [
11
12
  "viewer",
@@ -54,6 +55,25 @@ const deleteLabelRoute = createRoute({
54
55
  ...notFound
55
56
  }
56
57
  });
58
+ const updateLabelRoute = createRoute({
59
+ method: "patch",
60
+ path: "/api/v1/projects/{slug}/label-types/{key}",
61
+ request: {
62
+ params: z.object({
63
+ slug: z.string(),
64
+ key: z.string()
65
+ }),
66
+ body: { content: { "application/json": { schema: labelTypeUpdateSchema } } }
67
+ },
68
+ responses: {
69
+ 200: {
70
+ content: { "application/json": { schema: labelTypeSchema } },
71
+ description: "Updated label type"
72
+ },
73
+ ...notFound
74
+ }
75
+ });
76
+ /** Register the project label-type list, create, update, and delete endpoints. */
57
77
  function registerLabels(app) {
58
78
  app.openapi(listLabelsRoute, async (c) => {
59
79
  const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...VIEW_ROLES);
@@ -70,6 +90,19 @@ function registerLabels(app) {
70
90
  await new LabelModel(getStore().db).removeType(project.id, key);
71
91
  return c.body(null, 204);
72
92
  });
93
+ app.openapi(updateLabelRoute, async (c) => {
94
+ const { slug, key } = c.req.valid("param");
95
+ const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);
96
+ const body = c.req.valid("json");
97
+ try {
98
+ const updated = await new LabelModel(getStore().db).updateType(project.id, key, body);
99
+ if (!updated) throw new HTTPException(404, { message: "Label type not found" });
100
+ return c.json(updated);
101
+ } catch (error) {
102
+ if (error instanceof HTTPException) throw error;
103
+ throw new HTTPException(400, { message: error instanceof Error ? error.message : "Cannot update label type" });
104
+ }
105
+ });
73
106
  }
74
107
  //#endregion
75
108
  export { registerLabels };
@@ -1 +1 @@
1
- {"version":3,"file":"labels.mjs","names":[],"sources":["../../src/routers/labels.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\n\nimport { LabelModel } from \"../models/label.ts\";\nimport { getStore } from \"../store.ts\";\nimport type { ProjectRole } from \"../types.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { labelTypeCreateSchema, labelTypeSchema, notFound, unauthorized } from \"./schemas.ts\";\n\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nconst ADMIN_ROLES: readonly ProjectRole[] = [\"admin\"];\n\nconst listLabelsRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/label-types\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: labelTypeSchema.array() } }, description: \"List label types\" },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst createLabelRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/label-types\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: labelTypeCreateSchema } } },\n },\n responses: {\n 201: { content: { \"application/json\": { schema: labelTypeSchema } }, description: \"Created label type\" },\n ...notFound,\n },\n});\n\nconst deleteLabelRoute = createRoute({\n method: \"delete\",\n path: \"/api/v1/projects/{slug}/label-types/{key}\",\n request: { params: z.object({ slug: z.string(), key: z.string() }) },\n responses: {\n 204: { description: \"Deleted label type\" },\n ...notFound,\n },\n});\n\nexport function registerLabels(app: ShelfApp): void {\n app.openapi(listLabelsRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n return c.json(await new LabelModel(getStore().db).listTypes(project.id));\n });\n\n app.openapi(createLabelRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n return c.json(await new LabelModel(getStore().db).createType(project.id, body), 201);\n });\n\n app.openapi(deleteLabelRoute, async (c) => {\n const { slug, key } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n await new LabelModel(getStore().db).removeType(project.id, key);\n return c.body(null, 204);\n });\n}"],"mappings":";;;;;;;;;AASA,MAAM,aAAqC;CAAC;CAAU;CAAa;CAAY;AAAO;AACtF,MAAM,cAAsC,CAAC,OAAO;AAEpD,MAAM,kBAAkB,YAAY;CAClC,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE;CAClD,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,gBAAgB,MAAM,EAAE,EAAE;GAAG,aAAa;EAAmB;EAC7G,GAAG;EACH,GAAG;CACL;AACF,CAAC;AAED,MAAM,mBAAmB,YAAY;CACnC,QAAQ;CACR,MAAM;CACN,SAAS;EACP,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;EACrC,MAAM,EAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,sBAAsB,EAAE,EAAE;CAC7E;CACA,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,gBAAgB,EAAE;GAAG,aAAa;EAAqB;EACvG,GAAG;CACL;AACF,CAAC;AAED,MAAM,mBAAmB,YAAY;CACnC,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,KAAK,EAAE,OAAO;CAAE,CAAC,EAAE;CACnE,WAAW;EACT,KAAK,EAAE,aAAa,qBAAqB;EACzC,GAAG;CACL;AACF,CAAC;AAED,SAAgB,eAAe,KAAqB;CAClD,IAAI,QAAQ,iBAAiB,OAAO,MAAM;EACxC,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,OAAO,CAAC,CAAC,MAAM,GAAG,UAAU;EAC1F,OAAO,EAAE,KAAK,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,UAAU,QAAQ,EAAE,CAAC;CACzE,CAAC;CAED,IAAI,QAAQ,kBAAkB,OAAO,MAAM;EACzC,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,OAAO,CAAC,CAAC,MAAM,GAAG,WAAW;EAC3F,MAAM,OAAO,EAAE,IAAI,MAAM,MAAM;EAC/B,OAAO,EAAE,KAAK,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,WAAW,QAAQ,IAAI,IAAI,GAAG,GAAG;CACrF,CAAC;CAED,IAAI,QAAQ,kBAAkB,OAAO,MAAM;EACzC,MAAM,EAAE,MAAM,QAAQ,EAAE,IAAI,MAAM,OAAO;EACzC,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,WAAW;EACtE,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,WAAW,QAAQ,IAAI,GAAG;EAC9D,OAAO,EAAE,KAAK,MAAM,GAAG;CACzB,CAAC;AACH"}
1
+ {"version":3,"file":"labels.mjs","names":[],"sources":["../../src/routers/labels.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfApp } from \"../index.tsx\";\n\nimport { LabelModel } from \"../models/label.ts\";\nimport { getStore } from \"../store.ts\";\nimport type { ProjectRole } from \"../types.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { labelTypeCreateSchema, labelTypeSchema, labelTypeUpdateSchema, notFound, unauthorized } from \"./schemas.ts\";\n\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nconst ADMIN_ROLES: readonly ProjectRole[] = [\"admin\"];\n\nconst listLabelsRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/label-types\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: labelTypeSchema.array() } }, description: \"List label types\" },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst createLabelRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/label-types\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: labelTypeCreateSchema } } },\n },\n responses: {\n 201: { content: { \"application/json\": { schema: labelTypeSchema } }, description: \"Created label type\" },\n ...notFound,\n },\n});\n\nconst deleteLabelRoute = createRoute({\n method: \"delete\",\n path: \"/api/v1/projects/{slug}/label-types/{key}\",\n request: { params: z.object({ slug: z.string(), key: z.string() }) },\n responses: {\n 204: { description: \"Deleted label type\" },\n ...notFound,\n },\n});\n\nconst updateLabelRoute = createRoute({\n method: \"patch\",\n path: \"/api/v1/projects/{slug}/label-types/{key}\",\n request: {\n params: z.object({ slug: z.string(), key: z.string() }),\n body: { content: { \"application/json\": { schema: labelTypeUpdateSchema } } },\n },\n responses: {\n 200: { content: { \"application/json\": { schema: labelTypeSchema } }, description: \"Updated label type\" },\n ...notFound,\n },\n});\n\n/** Register the project label-type list, create, update, and delete endpoints. */\nexport function registerLabels(app: ShelfApp): void {\n app.openapi(listLabelsRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n return c.json(await new LabelModel(getStore().db).listTypes(project.id));\n });\n\n app.openapi(createLabelRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n return c.json(await new LabelModel(getStore().db).createType(project.id, body), 201);\n });\n\n app.openapi(deleteLabelRoute, async (c) => {\n const { slug, key } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n await new LabelModel(getStore().db).removeType(project.id, key);\n return c.body(null, 204);\n });\n\n app.openapi(updateLabelRoute, async (c) => {\n const { slug, key } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n try {\n const updated = await new LabelModel(getStore().db).updateType(project.id, key, body);\n if (!updated) {\n throw new HTTPException(404, { message: \"Label type not found\" });\n }\n return c.json(updated);\n } catch (error) {\n if (error instanceof HTTPException) {\n throw error;\n }\n throw new HTTPException(400, { message: error instanceof Error ? error.message : \"Cannot update label type\" });\n }\n });\n}"],"mappings":"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"}
@@ -20,6 +20,7 @@ function imageResponse(buffer) {
20
20
  "cache-control": "private, max-age=3600"
21
21
  } });
22
22
  }
23
+ /** Register the PNG image, diff, and baseline media endpoints for snapshots. */
23
24
  function registerMedia(app) {
24
25
  app.get("/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/image", async (c) => {
25
26
  const project = await resolveAuthorizedProject(c, c.req.param("slug"), ...VIEW_ROLES);
@@ -1 +1 @@
1
- {"version":3,"file":"media.mjs","names":[],"sources":["../../src/routers/media.ts"],"sourcesContent":["import type { ShelfApp } from \"../index.tsx\";\n\nimport { BaselineModel } from \"../models/baseline.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { SnapshotModel } from \"../models/snapshot.ts\";\nimport { getStore } from \"../store.ts\";\nimport type { Snapshot } from \"../schema.ts\";\nimport type { ProjectRole } from \"../types.ts\";\nimport { notFound, resolveAuthorizedProject } from \"./helpers.ts\";\n\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\n\nconst PNG = \"image/png\";\n\nfunction imageResponse(buffer: Buffer): Response {\n return new Response(new Uint8Array(buffer), { headers: { \"content-type\": PNG, \"cache-control\": \"private, max-age=3600\" } });\n}\n\nexport function registerMedia(app: ShelfApp): void {\n app.get(\"/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/image\", async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.param(\"slug\"), ...VIEW_ROLES);\n const snapshot = await findSnapshot(c.req.param(\"buildId\"), c.req.param(\"snapshotId\"), project.id);\n return imageResponse(await getStore().storage.read(snapshot.screenshotPath));\n });\n\n app.get(\"/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/diff\", async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.param(\"slug\"), ...VIEW_ROLES);\n const snapshot = await findSnapshot(c.req.param(\"buildId\"), c.req.param(\"snapshotId\"), project.id);\n if (!snapshot.diffPath) {\n notFound(\"No diff for this snapshot\");\n }\n return imageResponse(await getStore().storage.read(snapshot.diffPath));\n });\n\n app.get(\"/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/baseline\", async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.param(\"slug\"), ...VIEW_ROLES);\n const snapshot = await findSnapshot(c.req.param(\"buildId\"), c.req.param(\"snapshotId\"), project.id);\n const build = await new BuildModel(getStore().db).get(snapshot.buildId);\n if (!build) {\n notFound(\"Build not found\");\n }\n const baselines = new BaselineModel(getStore().db, getStore().storage);\n const baseline = await baselines.resolve(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, project.gitDefaultBranch);\n if (!baseline) {\n notFound(\"No baseline for this snapshot\");\n }\n return imageResponse(await baselines.read(baseline));\n });\n}\n\nasync function findSnapshot(buildId: string, snapshotId: string, projectId: string): Promise<Snapshot> {\n const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);\n if (!snapshot || snapshot.buildId !== buildId || snapshot.projectId !== projectId) {\n notFound(\"Snapshot not found\");\n }\n return snapshot;\n}"],"mappings":";;;;;;;;;AAUA,MAAM,aAAqC;CAAC;CAAU;CAAa;CAAY;AAAO;AAEtF,MAAM,MAAM;AAEZ,SAAS,cAAc,QAA0B;CAC/C,OAAO,IAAI,SAAS,IAAI,WAAW,MAAM,GAAG,EAAE,SAAS;EAAE,gBAAgB;EAAK,iBAAiB;CAAwB,EAAE,CAAC;AAC5H;AAEA,SAAgB,cAAc,KAAqB;CACjD,IAAI,IAAI,sEAAsE,OAAO,MAAM;EACzF,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,MAAM,GAAG,GAAG,UAAU;EACpF,MAAM,WAAW,MAAM,aAAa,EAAE,IAAI,MAAM,SAAS,GAAG,EAAE,IAAI,MAAM,YAAY,GAAG,QAAQ,EAAE;EACjG,OAAO,cAAc,MAAM,SAAS,CAAC,CAAC,QAAQ,KAAK,SAAS,cAAc,CAAC;CAC7E,CAAC;CAED,IAAI,IAAI,qEAAqE,OAAO,MAAM;EACxF,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,MAAM,GAAG,GAAG,UAAU;EACpF,MAAM,WAAW,MAAM,aAAa,EAAE,IAAI,MAAM,SAAS,GAAG,EAAE,IAAI,MAAM,YAAY,GAAG,QAAQ,EAAE;EACjG,IAAI,CAAC,SAAS,UACZ,SAAS,2BAA2B;EAEtC,OAAO,cAAc,MAAM,SAAS,CAAC,CAAC,QAAQ,KAAK,SAAS,QAAQ,CAAC;CACvE,CAAC;CAED,IAAI,IAAI,yEAAyE,OAAO,MAAM;EAC5F,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,MAAM,GAAG,GAAG,UAAU;EACpF,MAAM,WAAW,MAAM,aAAa,EAAE,IAAI,MAAM,SAAS,GAAG,EAAE,IAAI,MAAM,YAAY,GAAG,QAAQ,EAAE;EACjG,MAAM,QAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,SAAS,OAAO;EACtE,IAAI,CAAC,OACH,SAAS,iBAAiB;EAE5B,MAAM,YAAY,IAAI,cAAc,SAAS,CAAC,CAAC,IAAI,SAAS,CAAC,CAAC,OAAO;EACrE,MAAM,WAAW,MAAM,UAAU,QAAQ,QAAQ,IAAI,SAAS,SAAS,SAAS,cAAc,MAAM,WAAW,QAAQ,gBAAgB;EACvI,IAAI,CAAC,UACH,SAAS,+BAA+B;EAE1C,OAAO,cAAc,MAAM,UAAU,KAAK,QAAQ,CAAC;CACrD,CAAC;AACH;AAEA,eAAe,aAAa,SAAiB,YAAoB,WAAsC;CACrG,MAAM,WAAW,MAAM,IAAI,cAAc,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,UAAU;CACtE,IAAI,CAAC,YAAY,SAAS,YAAY,WAAW,SAAS,cAAc,WACtE,SAAS,oBAAoB;CAE/B,OAAO;AACT"}
1
+ {"version":3,"file":"media.mjs","names":[],"sources":["../../src/routers/media.ts"],"sourcesContent":["import type { ShelfApp } from \"../index.tsx\";\n\nimport { BaselineModel } from \"../models/baseline.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { SnapshotModel } from \"../models/snapshot.ts\";\nimport { getStore } from \"../store.ts\";\nimport type { Snapshot } from \"../schema.ts\";\nimport type { ProjectRole } from \"../types.ts\";\nimport { notFound, resolveAuthorizedProject } from \"./helpers.ts\";\n\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\n\nconst PNG = \"image/png\";\n\nfunction imageResponse(buffer: Buffer): Response {\n return new Response(new Uint8Array(buffer), { headers: { \"content-type\": PNG, \"cache-control\": \"private, max-age=3600\" } });\n}\n\n/** Register the PNG image, diff, and baseline media endpoints for snapshots. */\nexport function registerMedia(app: ShelfApp): void {\n app.get(\"/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/image\", async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.param(\"slug\"), ...VIEW_ROLES);\n const snapshot = await findSnapshot(c.req.param(\"buildId\"), c.req.param(\"snapshotId\"), project.id);\n return imageResponse(await getStore().storage.read(snapshot.screenshotPath));\n });\n\n app.get(\"/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/diff\", async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.param(\"slug\"), ...VIEW_ROLES);\n const snapshot = await findSnapshot(c.req.param(\"buildId\"), c.req.param(\"snapshotId\"), project.id);\n if (!snapshot.diffPath) {\n notFound(\"No diff for this snapshot\");\n }\n return imageResponse(await getStore().storage.read(snapshot.diffPath));\n });\n\n app.get(\"/api/v1/projects/:slug/builds/:buildId/snapshots/:snapshotId/baseline\", async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.param(\"slug\"), ...VIEW_ROLES);\n const snapshot = await findSnapshot(c.req.param(\"buildId\"), c.req.param(\"snapshotId\"), project.id);\n const build = await new BuildModel(getStore().db).get(snapshot.buildId);\n if (!build) {\n notFound(\"Build not found\");\n }\n const baselines = new BaselineModel(getStore().db, getStore().storage);\n const baseline = await baselines.resolve(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, project.gitDefaultBranch);\n if (!baseline) {\n notFound(\"No baseline for this snapshot\");\n }\n return imageResponse(await baselines.read(baseline));\n });\n}\n\nasync function findSnapshot(buildId: string, snapshotId: string, projectId: string): Promise<Snapshot> {\n const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);\n if (!snapshot || snapshot.buildId !== buildId || snapshot.projectId !== projectId) {\n notFound(\"Snapshot not found\");\n }\n return snapshot;\n}"],"mappings":";;;;;;;;;AAUA,MAAM,aAAqC;CAAC;CAAU;CAAa;CAAY;AAAO;AAEtF,MAAM,MAAM;AAEZ,SAAS,cAAc,QAA0B;CAC/C,OAAO,IAAI,SAAS,IAAI,WAAW,MAAM,GAAG,EAAE,SAAS;EAAE,gBAAgB;EAAK,iBAAiB;CAAwB,EAAE,CAAC;AAC5H;;AAGA,SAAgB,cAAc,KAAqB;CACjD,IAAI,IAAI,sEAAsE,OAAO,MAAM;EACzF,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,MAAM,GAAG,GAAG,UAAU;EACpF,MAAM,WAAW,MAAM,aAAa,EAAE,IAAI,MAAM,SAAS,GAAG,EAAE,IAAI,MAAM,YAAY,GAAG,QAAQ,EAAE;EACjG,OAAO,cAAc,MAAM,SAAS,CAAC,CAAC,QAAQ,KAAK,SAAS,cAAc,CAAC;CAC7E,CAAC;CAED,IAAI,IAAI,qEAAqE,OAAO,MAAM;EACxF,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,MAAM,GAAG,GAAG,UAAU;EACpF,MAAM,WAAW,MAAM,aAAa,EAAE,IAAI,MAAM,SAAS,GAAG,EAAE,IAAI,MAAM,YAAY,GAAG,QAAQ,EAAE;EACjG,IAAI,CAAC,SAAS,UACZ,SAAS,2BAA2B;EAEtC,OAAO,cAAc,MAAM,SAAS,CAAC,CAAC,QAAQ,KAAK,SAAS,QAAQ,CAAC;CACvE,CAAC;CAED,IAAI,IAAI,yEAAyE,OAAO,MAAM;EAC5F,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,MAAM,GAAG,GAAG,UAAU;EACpF,MAAM,WAAW,MAAM,aAAa,EAAE,IAAI,MAAM,SAAS,GAAG,EAAE,IAAI,MAAM,YAAY,GAAG,QAAQ,EAAE;EACjG,MAAM,QAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,SAAS,OAAO;EACtE,IAAI,CAAC,OACH,SAAS,iBAAiB;EAE5B,MAAM,YAAY,IAAI,cAAc,SAAS,CAAC,CAAC,IAAI,SAAS,CAAC,CAAC,OAAO;EACrE,MAAM,WAAW,MAAM,UAAU,QAAQ,QAAQ,IAAI,SAAS,SAAS,SAAS,cAAc,MAAM,WAAW,QAAQ,gBAAgB;EACvI,IAAI,CAAC,UACH,SAAS,+BAA+B;EAE1C,OAAO,cAAc,MAAM,UAAU,KAAK,QAAQ,CAAC;CACrD,CAAC;AACH;AAEA,eAAe,aAAa,SAAiB,YAAoB,WAAsC;CACrG,MAAM,WAAW,MAAM,IAAI,cAAc,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,UAAU;CACtE,IAAI,CAAC,YAAY,SAAS,YAAY,WAAW,SAAS,cAAc,WACtE,SAAS,oBAAoB;CAE/B,OAAO;AACT"}
@@ -72,6 +72,7 @@ const deleteMemberRoute = createRoute({
72
72
  ...notFound
73
73
  }
74
74
  });
75
+ /** Register the project member list, upsert, and removal endpoints. */
75
76
  function registerMembers(app) {
76
77
  app.openapi(listMembersRoute, async (c) => {
77
78
  const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...VIEW_ROLES);
@@ -1 +1 @@
1
- {"version":3,"file":"members.mjs","names":[],"sources":["../../src/routers/members.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\n\nimport { MemberModel } from \"../models/member.ts\";\nimport { getStore } from \"../store.ts\";\nimport type { ProjectRole } from \"../types.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { memberRoleSchema, memberSchema, memberSetSchema, notFound, unauthorized } from \"./schemas.ts\";\n\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nconst ADMIN_ROLES: readonly ProjectRole[] = [\"admin\"];\n\nconst listMembersRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/members\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: memberSchema.array() } }, description: \"List project members\" },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst setMemberRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/members\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: memberSetSchema } } },\n },\n responses: {\n 201: { content: { \"application/json\": { schema: memberSchema } }, description: \"Added or updated member\" },\n ...notFound,\n },\n});\n\nconst updateMemberRoute = createRoute({\n method: \"patch\",\n path: \"/api/v1/projects/{slug}/members/{userId}\",\n request: {\n params: z.object({ slug: z.string(), userId: z.string() }),\n body: { content: { \"application/json\": { schema: memberRoleSchema } } },\n },\n responses: {\n 200: { content: { \"application/json\": { schema: memberSchema } }, description: \"Updated member role\" },\n ...notFound,\n },\n});\n\nconst deleteMemberRoute = createRoute({\n method: \"delete\",\n path: \"/api/v1/projects/{slug}/members/{userId}\",\n request: { params: z.object({ slug: z.string(), userId: z.string() }) },\n responses: {\n 204: { description: \"Removed member\" },\n ...notFound,\n },\n});\n\nexport function registerMembers(app: ShelfApp): void {\n app.openapi(listMembersRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n return c.json(await new MemberModel(getStore().db).list(project.id));\n });\n\n app.openapi(setMemberRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n return c.json(await new MemberModel(getStore().db).set(project.id, body.userId, body.role), 201);\n });\n\n app.openapi(updateMemberRoute, async (c) => {\n const { slug, userId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n return c.json(await new MemberModel(getStore().db).set(project.id, userId, body.role));\n });\n\n app.openapi(deleteMemberRoute, async (c) => {\n const { slug, userId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n await new MemberModel(getStore().db).remove(project.id, userId);\n return c.body(null, 204);\n });\n}"],"mappings":"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"}
1
+ {"version":3,"file":"members.mjs","names":[],"sources":["../../src/routers/members.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\n\nimport { MemberModel } from \"../models/member.ts\";\nimport { getStore } from \"../store.ts\";\nimport type { ProjectRole } from \"../types.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { memberRoleSchema, memberSchema, memberSetSchema, notFound, unauthorized } from \"./schemas.ts\";\n\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nconst ADMIN_ROLES: readonly ProjectRole[] = [\"admin\"];\n\nconst listMembersRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/members\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: memberSchema.array() } }, description: \"List project members\" },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst setMemberRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/members\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: memberSetSchema } } },\n },\n responses: {\n 201: { content: { \"application/json\": { schema: memberSchema } }, description: \"Added or updated member\" },\n ...notFound,\n },\n});\n\nconst updateMemberRoute = createRoute({\n method: \"patch\",\n path: \"/api/v1/projects/{slug}/members/{userId}\",\n request: {\n params: z.object({ slug: z.string(), userId: z.string() }),\n body: { content: { \"application/json\": { schema: memberRoleSchema } } },\n },\n responses: {\n 200: { content: { \"application/json\": { schema: memberSchema } }, description: \"Updated member role\" },\n ...notFound,\n },\n});\n\nconst deleteMemberRoute = createRoute({\n method: \"delete\",\n path: \"/api/v1/projects/{slug}/members/{userId}\",\n request: { params: z.object({ slug: z.string(), userId: z.string() }) },\n responses: {\n 204: { description: \"Removed member\" },\n ...notFound,\n },\n});\n\n/** Register the project member list, upsert, and removal endpoints. */\nexport function registerMembers(app: ShelfApp): void {\n app.openapi(listMembersRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n return c.json(await new MemberModel(getStore().db).list(project.id));\n });\n\n app.openapi(setMemberRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n return c.json(await new MemberModel(getStore().db).set(project.id, body.userId, body.role), 201);\n });\n\n app.openapi(updateMemberRoute, async (c) => {\n const { slug, userId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n return c.json(await new MemberModel(getStore().db).set(project.id, userId, body.role));\n });\n\n app.openapi(deleteMemberRoute, async (c) => {\n const { slug, userId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n await new MemberModel(getStore().db).remove(project.id, userId);\n return c.body(null, 204);\n });\n}"],"mappings":"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"}
@@ -83,6 +83,7 @@ const deleteProjectRoute = createRoute({
83
83
  ...notFound
84
84
  }
85
85
  });
86
+ /** Register the project list, create, fetch, update, and delete endpoints. */
86
87
  function registerProjects(app) {
87
88
  app.openapi(listProjectsRoute, async (c) => {
88
89
  requireSessionUser();