handoff-app 1.2.2-6 → 2.0.0-0

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 (349) hide show
  1. package/Changelog.md +37 -15
  2. package/README.md +372 -118
  3. package/dist/app-builder/build.d.ts +41 -2
  4. package/dist/app-builder/build.js +455 -18
  5. package/dist/app-builder/client-config.d.ts +15 -2
  6. package/dist/app-builder/client-config.js +43 -4
  7. package/dist/app-builder/config-diff/snapshot.d.ts +1 -6
  8. package/dist/app-builder/config-diff/snapshot.js +2 -9
  9. package/dist/app-builder/index.d.ts +1 -1
  10. package/dist/app-builder/index.js +3 -1
  11. package/dist/app-builder/nav-shell.d.ts +23 -0
  12. package/dist/app-builder/nav-shell.js +80 -0
  13. package/dist/app-builder/static-export.d.ts +17 -0
  14. package/dist/app-builder/static-export.js +181 -0
  15. package/dist/app-builder/vercel-output.d.ts +39 -0
  16. package/dist/app-builder/vercel-output.js +259 -0
  17. package/dist/app-builder/watchers/utils.d.ts +2 -0
  18. package/dist/app-builder/watchers.d.ts +14 -4
  19. package/dist/app-builder/watchers.js +123 -28
  20. package/dist/app-builder/websocket.js +1 -1
  21. package/dist/artifacts/index.d.ts +2 -0
  22. package/dist/artifacts/index.js +13 -0
  23. package/dist/artifacts/types.d.ts +136 -0
  24. package/dist/artifacts/types.js +24 -0
  25. package/dist/artifacts/url.d.ts +49 -0
  26. package/dist/artifacts/url.js +86 -0
  27. package/dist/cache/build-cache.js +48 -4
  28. package/dist/commands/build/app.js +1 -2
  29. package/dist/commands/build/index.d.ts +17 -0
  30. package/dist/commands/build/index.js +66 -0
  31. package/dist/commands/checkout/index.d.ts +24 -0
  32. package/dist/commands/checkout/index.js +71 -0
  33. package/dist/commands/db/migrate.d.ts +11 -0
  34. package/dist/commands/db/migrate.js +33 -0
  35. package/dist/commands/index.js +8 -0
  36. package/dist/commands/publish/index.d.ts +21 -0
  37. package/dist/commands/publish/index.js +68 -0
  38. package/dist/config/defaults.js +15 -1
  39. package/dist/config/loader.js +6 -1
  40. package/dist/config/normalizers/declaration.js +4 -1
  41. package/dist/config/normalizers/page.d.ts +18 -0
  42. package/dist/config/normalizers/page.js +34 -0
  43. package/dist/config/normalizers/pattern.js +7 -1
  44. package/dist/config/runtime.d.ts +4 -0
  45. package/dist/config/runtime.js +53 -5
  46. package/dist/index.d.ts +65 -1
  47. package/dist/index.js +123 -3
  48. package/dist/nav/index.d.ts +83 -0
  49. package/dist/nav/index.js +9 -0
  50. package/dist/nav/registry.d.ts +15 -0
  51. package/dist/nav/registry.js +42 -0
  52. package/dist/nav/resolver.d.ts +8 -0
  53. package/dist/nav/resolver.js +192 -0
  54. package/dist/nav/workspace.d.ts +18 -0
  55. package/dist/nav/workspace.js +103 -0
  56. package/dist/pipeline/styles.js +7 -35
  57. package/dist/pipeline/token-transformers.d.ts +45 -0
  58. package/dist/pipeline/token-transformers.js +71 -0
  59. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +17 -0
  60. package/dist/registry/asset-storage/adapters/vercel-blob.js +87 -0
  61. package/dist/registry/asset-storage/define.d.ts +10 -0
  62. package/dist/registry/asset-storage/define.js +13 -0
  63. package/dist/registry/asset-storage/resolve.d.ts +45 -0
  64. package/dist/registry/asset-storage/resolve.js +36 -0
  65. package/dist/registry/asset-storage/types.d.ts +51 -0
  66. package/dist/registry/asset-storage/types.js +9 -0
  67. package/dist/registry/assets/layout.d.ts +22 -0
  68. package/dist/registry/assets/layout.js +30 -0
  69. package/dist/registry/assets/sets.d.ts +18 -0
  70. package/dist/registry/assets/sets.js +44 -0
  71. package/dist/registry/assets/transfer.d.ts +45 -0
  72. package/dist/registry/assets/transfer.js +11 -0
  73. package/dist/registry/checkout/assets.d.ts +16 -0
  74. package/dist/registry/checkout/assets.js +150 -0
  75. package/dist/registry/checkout/index.d.ts +33 -0
  76. package/dist/registry/checkout/index.js +610 -0
  77. package/dist/registry/checkout/tokens.d.ts +15 -0
  78. package/dist/registry/checkout/tokens.js +171 -0
  79. package/dist/registry/client.d.ts +61 -0
  80. package/dist/registry/client.js +211 -0
  81. package/dist/registry/connection.d.ts +30 -0
  82. package/dist/registry/connection.js +36 -0
  83. package/dist/registry/db/adapter.d.ts +43 -0
  84. package/dist/registry/db/adapter.js +59 -0
  85. package/dist/registry/db/client.d.ts +35 -0
  86. package/dist/registry/db/client.js +94 -0
  87. package/dist/registry/db/index.d.ts +5 -0
  88. package/dist/registry/db/index.js +24 -0
  89. package/dist/registry/db/migrate.d.ts +23 -0
  90. package/dist/registry/db/migrate.js +64 -0
  91. package/dist/registry/db/schema.d.ts +4869 -0
  92. package/dist/registry/db/schema.js +347 -0
  93. package/dist/registry/path.d.ts +4 -0
  94. package/dist/registry/path.js +31 -0
  95. package/dist/registry/publish/artifacts.d.ts +16 -0
  96. package/dist/registry/publish/artifacts.js +132 -0
  97. package/dist/registry/publish/assets.d.ts +16 -0
  98. package/dist/registry/publish/assets.js +129 -0
  99. package/dist/registry/publish/errors.d.ts +5 -0
  100. package/dist/registry/publish/errors.js +26 -0
  101. package/dist/registry/publish/index.d.ts +27 -0
  102. package/dist/registry/publish/index.js +126 -0
  103. package/dist/registry/publish/package.d.ts +27 -0
  104. package/dist/registry/publish/package.js +257 -0
  105. package/dist/registry/publish/publish-build.d.ts +12 -0
  106. package/dist/registry/publish/publish-build.js +43 -0
  107. package/dist/registry/publish/tokens.d.ts +18 -0
  108. package/dist/registry/publish/tokens.js +112 -0
  109. package/dist/registry/tokens/sets.d.ts +59 -0
  110. package/dist/registry/tokens/sets.js +109 -0
  111. package/dist/registry/tokens/transfer.d.ts +48 -0
  112. package/dist/registry/tokens/transfer.js +10 -0
  113. package/dist/registry/transfer.d.ts +75 -0
  114. package/dist/registry/transfer.js +11 -0
  115. package/dist/store/filesystem-assets.d.ts +17 -0
  116. package/dist/store/filesystem-assets.js +147 -0
  117. package/dist/store/filesystem.d.ts +76 -0
  118. package/dist/store/filesystem.js +208 -0
  119. package/dist/store/index.d.ts +20 -0
  120. package/dist/store/index.js +38 -0
  121. package/dist/store/registry.d.ts +77 -0
  122. package/dist/store/registry.js +350 -0
  123. package/dist/store/source-files.d.ts +24 -0
  124. package/dist/store/source-files.js +147 -0
  125. package/dist/store/types.d.ts +185 -0
  126. package/dist/store/types.js +12 -0
  127. package/dist/transformers/plugins/csf-render.js +17 -8
  128. package/dist/transformers/plugins/handlebars-previews.js +10 -3
  129. package/dist/transformers/plugins/ssr-render.js +116 -49
  130. package/dist/transformers/preview/component/api.js +3 -1
  131. package/dist/transformers/preview/component/artifacts.js +8 -1
  132. package/dist/transformers/preview/component/builder.js +13 -5
  133. package/dist/transformers/preview/component/css.js +9 -1
  134. package/dist/transformers/preview/component/javascript.d.ts +9 -3
  135. package/dist/transformers/preview/component/javascript.js +45 -17
  136. package/dist/transformers/preview/component/shared-artifacts.d.ts +83 -0
  137. package/dist/transformers/preview/component/shared-artifacts.js +92 -0
  138. package/dist/transformers/preview/component.js +5 -2
  139. package/dist/transformers/preview/pattern/builder.js +28 -9
  140. package/dist/transformers/preview/pattern/html.d.ts +13 -10
  141. package/dist/transformers/preview/pattern/html.js +62 -27
  142. package/dist/transformers/preview/types.d.ts +38 -0
  143. package/dist/transformers/utils/handlebars.d.ts +3 -0
  144. package/dist/transformers/utils/handlebars.js +19 -5
  145. package/dist/types/config.d.ts +118 -25
  146. package/dist/utils/menu-shell.d.ts +60 -0
  147. package/dist/utils/menu-shell.js +221 -0
  148. package/dist/utils/stable-stringify.d.ts +2 -0
  149. package/dist/utils/stable-stringify.js +8 -0
  150. package/docs/cli.md +4 -2
  151. package/drizzle/0000_init_registry_storage.sql +95 -0
  152. package/drizzle/0001_add_review_metadata.sql +2 -0
  153. package/drizzle/0002_add_pages.sql +28 -0
  154. package/drizzle/0003_add_tokens.sql +29 -0
  155. package/drizzle/0004_add_assets.sql +37 -0
  156. package/drizzle/meta/0000_snapshot.json +642 -0
  157. package/drizzle/meta/0001_snapshot.json +654 -0
  158. package/drizzle/meta/0002_snapshot.json +835 -0
  159. package/drizzle/meta/0003_snapshot.json +1023 -0
  160. package/drizzle/meta/0004_snapshot.json +1252 -0
  161. package/drizzle/meta/_journal.json +41 -0
  162. package/drizzle.config.ts +16 -0
  163. package/package.json +10 -1
  164. package/src/app/components/Component/ComponentLists.tsx +102 -102
  165. package/src/app/components/Component/Preview.tsx +3 -2
  166. package/src/app/components/Component/RegistryActions.tsx +150 -0
  167. package/src/app/components/Foundations/DisplayLogo.tsx +5 -3
  168. package/src/app/components/Layout/Header.tsx +21 -3
  169. package/src/app/components/Layout/Main.tsx +11 -3
  170. package/src/app/components/Navigation/MainNav.tsx +4 -2
  171. package/src/app/components/Navigation/MobileNav.tsx +4 -2
  172. package/src/app/components/Navigation/SideNav.tsx +46 -11
  173. package/src/app/components/NotFound.tsx +24 -0
  174. package/src/app/components/context/NavProvider.tsx +71 -0
  175. package/src/app/components/old/Header.tsx +3 -3
  176. package/src/app/components/util/index.ts +203 -380
  177. package/src/app/components/util/useCollectionAssets.ts +125 -0
  178. package/src/app/components/util/useFoundationTokens.ts +67 -0
  179. package/src/app/generated/default-pages.json +1 -0
  180. package/src/app/generated/nav-shell.json +1 -0
  181. package/src/app/lib/api/query.ts +6 -0
  182. package/src/app/lib/api/redact.ts +46 -0
  183. package/src/app/lib/asset-storage.ts +81 -0
  184. package/src/app/lib/docs-api/artifacts.ts +116 -0
  185. package/src/app/lib/docs-api/backend.ts +151 -0
  186. package/src/app/lib/docs-api/errors.ts +52 -0
  187. package/src/app/lib/docs-api/handler.ts +34 -0
  188. package/src/app/lib/docs-api/index.ts +7 -0
  189. package/src/app/lib/docs-api/records.ts +189 -0
  190. package/src/app/lib/docs-api/registry.ts +130 -0
  191. package/src/app/lib/docs-api/runtime-config.ts +173 -0
  192. package/src/app/lib/docs-api/serve.ts +37 -0
  193. package/src/app/lib/docs-api/token-detail.ts +19 -0
  194. package/src/app/lib/registry-api/allowlist.ts +163 -0
  195. package/src/app/lib/registry-api/asset-transfer.ts +363 -0
  196. package/src/app/lib/registry-api/auth.ts +68 -0
  197. package/src/app/lib/registry-api/entity-routes.ts +197 -0
  198. package/src/app/lib/registry-api/errors.ts +51 -0
  199. package/src/app/lib/registry-api/files.ts +135 -0
  200. package/src/app/lib/registry-api/handler.ts +96 -0
  201. package/src/app/lib/registry-api/index.ts +4 -0
  202. package/src/app/lib/registry-api/meta.ts +87 -0
  203. package/src/app/lib/registry-api/revalidate.ts +36 -0
  204. package/src/app/lib/registry-api/store.ts +348 -0
  205. package/src/app/lib/registry-api/token-transfer.ts +231 -0
  206. package/src/app/lib/registry-api/transfer.ts +513 -0
  207. package/src/app/lib/registry-api/validation.ts +74 -0
  208. package/src/app/lib/registry-connection.ts +58 -0
  209. package/src/app/lib/utils.ts +14 -0
  210. package/src/app/next.config.mjs +72 -13
  211. package/src/app/pages/[...slug]/index.tsx +74 -20
  212. package/src/app/pages/_app.tsx +5 -2
  213. package/src/app/pages/api/docs/artifacts/[...artifactPath].ts +16 -0
  214. package/src/app/pages/api/docs/assets/[collection]/[...path].ts +50 -0
  215. package/src/app/pages/api/docs/assets/[collection]/index.ts +22 -0
  216. package/src/app/pages/api/docs/components/[id]/assets/[asset].ts +33 -0
  217. package/src/app/pages/api/docs/components/[id]/inspect/[previewId].ts +20 -0
  218. package/src/app/pages/api/docs/components/[id]/previews/[previewId].ts +21 -0
  219. package/src/app/pages/api/docs/components/[id].ts +24 -0
  220. package/src/app/pages/api/docs/components.json.ts +12 -0
  221. package/src/app/pages/api/docs/nav.json.ts +48 -0
  222. package/src/app/pages/api/docs/patterns/[id]/preview.ts +19 -0
  223. package/src/app/pages/api/docs/patterns/[id].ts +24 -0
  224. package/src/app/pages/api/docs/patterns.json.ts +12 -0
  225. package/src/app/pages/api/docs/tokens/components/[id].ts +30 -0
  226. package/src/app/pages/api/docs/tokens/foundations/[type].ts +32 -0
  227. package/src/app/pages/api/registry/components/[id]/files/[...filePath].ts +19 -0
  228. package/src/app/pages/api/registry/components/[id]/files/index.ts +19 -0
  229. package/src/app/pages/api/registry/components/[id].ts +20 -0
  230. package/src/app/pages/api/registry/components/index.ts +19 -0
  231. package/src/app/pages/api/registry/health.ts +20 -0
  232. package/src/app/pages/api/registry/patterns/[id]/files/[...filePath].ts +19 -0
  233. package/src/app/pages/api/registry/patterns/[id]/files/index.ts +19 -0
  234. package/src/app/pages/api/registry/patterns/[id].ts +20 -0
  235. package/src/app/pages/api/registry/patterns/index.ts +19 -0
  236. package/src/app/pages/api/registry/transfer/[kind]/[id].ts +39 -0
  237. package/src/app/pages/api/registry/transfer/assets/[collection].ts +24 -0
  238. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +23 -0
  239. package/src/app/pages/api/registry/transfer/assets/blobs/have.ts +11 -0
  240. package/src/app/pages/api/registry/transfer/assets/index.ts +10 -0
  241. package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +26 -0
  242. package/src/app/pages/api/registry/transfer/tokens/index.ts +11 -0
  243. package/src/app/pages/assets/fonts.tsx +41 -17
  244. package/src/app/pages/assets/index.tsx +2 -2
  245. package/src/app/pages/assets/logos.tsx +2 -2
  246. package/src/app/pages/foundations/colors.tsx +18 -8
  247. package/src/app/pages/foundations/effects.tsx +18 -8
  248. package/src/app/pages/foundations/icons/[name]/index.tsx +26 -14
  249. package/src/app/pages/foundations/icons/index.tsx +13 -9
  250. package/src/app/pages/foundations/index.tsx +1 -1
  251. package/src/app/pages/foundations/logo.tsx +7 -4
  252. package/src/app/pages/foundations/typography.tsx +19 -9
  253. package/src/app/pages/index.tsx +1 -1
  254. package/src/app/pages/system/component/[component]/index.tsx +87 -17
  255. package/src/app/pages/system/index.tsx +13 -4
  256. package/src/app/pages/system/pattern/[pattern]/index.tsx +70 -14
  257. package/src/app/pages/system/tokens/components/[component]/index.tsx +42 -14
  258. package/src/app/pages/system/tokens/foundations/colors.tsx +7 -5
  259. package/src/app/pages/system/tokens/foundations/effects.tsx +7 -5
  260. package/src/app/pages/system/tokens/foundations/typography.tsx +8 -4
  261. package/src/app/tsconfig.json +2 -3
  262. package/src/app-builder/build.ts +555 -11
  263. package/src/app-builder/client-config.ts +61 -4
  264. package/src/app-builder/config-diff/snapshot.ts +1 -11
  265. package/src/app-builder/index.ts +1 -1
  266. package/src/app-builder/nav-shell.ts +74 -0
  267. package/src/app-builder/static-export.ts +215 -0
  268. package/src/app-builder/vercel-output.ts +297 -0
  269. package/src/app-builder/watchers/utils.ts +2 -0
  270. package/src/app-builder/watchers.ts +149 -25
  271. package/src/app-builder/websocket.ts +2 -2
  272. package/src/artifacts/README.md +14 -0
  273. package/src/artifacts/index.ts +17 -0
  274. package/src/artifacts/types.ts +146 -0
  275. package/src/artifacts/url.ts +90 -0
  276. package/src/cache/build-cache.ts +47 -2
  277. package/src/commands/build/app.ts +1 -2
  278. package/src/commands/build/index.ts +61 -0
  279. package/src/commands/checkout/index.ts +66 -0
  280. package/src/commands/db/migrate.ts +25 -0
  281. package/src/commands/index.ts +8 -0
  282. package/src/commands/publish/index.ts +63 -0
  283. package/src/config/defaults.ts +15 -0
  284. package/src/config/loader.ts +10 -1
  285. package/src/config/normalizers/declaration.ts +3 -0
  286. package/src/config/normalizers/page.ts +45 -0
  287. package/src/config/normalizers/pattern.ts +4 -1
  288. package/src/config/runtime.ts +48 -2
  289. package/src/index.ts +126 -3
  290. package/src/nav/index.ts +94 -0
  291. package/src/nav/registry.ts +54 -0
  292. package/src/nav/resolver.ts +217 -0
  293. package/src/nav/workspace.ts +87 -0
  294. package/src/pipeline/styles.ts +4 -35
  295. package/src/pipeline/token-transformers.ts +97 -0
  296. package/src/registry/asset-storage/adapters/vercel-blob.ts +51 -0
  297. package/src/registry/asset-storage/define.ts +15 -0
  298. package/src/registry/asset-storage/resolve.ts +72 -0
  299. package/src/registry/asset-storage/types.ts +46 -0
  300. package/src/registry/assets/layout.ts +38 -0
  301. package/src/registry/assets/sets.ts +48 -0
  302. package/src/registry/assets/transfer.ts +50 -0
  303. package/src/registry/checkout/assets.ts +126 -0
  304. package/src/registry/checkout/index.ts +596 -0
  305. package/src/registry/checkout/tokens.ts +152 -0
  306. package/src/registry/client.ts +234 -0
  307. package/src/registry/connection.ts +50 -0
  308. package/src/registry/db/adapter.ts +74 -0
  309. package/src/registry/db/client.ts +88 -0
  310. package/src/registry/db/index.ts +35 -0
  311. package/src/registry/db/migrate.ts +56 -0
  312. package/src/registry/db/schema.ts +444 -0
  313. package/src/registry/path.ts +26 -0
  314. package/src/registry/publish/artifacts.ts +147 -0
  315. package/src/registry/publish/assets.ts +129 -0
  316. package/src/registry/publish/errors.ts +34 -0
  317. package/src/registry/publish/index.ts +128 -0
  318. package/src/registry/publish/package.ts +272 -0
  319. package/src/registry/publish/publish-build.ts +51 -0
  320. package/src/registry/publish/tokens.ts +119 -0
  321. package/src/registry/tokens/sets.ts +132 -0
  322. package/src/registry/tokens/transfer.ts +53 -0
  323. package/src/registry/transfer.ts +82 -0
  324. package/src/store/filesystem-assets.ts +152 -0
  325. package/src/store/filesystem.ts +237 -0
  326. package/src/store/index.ts +62 -0
  327. package/src/store/registry.ts +334 -0
  328. package/src/store/source-files.ts +155 -0
  329. package/src/store/types.ts +210 -0
  330. package/src/transformers/plugins/csf-render.ts +31 -7
  331. package/src/transformers/plugins/handlebars-previews.ts +22 -3
  332. package/src/transformers/plugins/ssr-render.ts +145 -57
  333. package/src/transformers/preview/component/api.ts +2 -1
  334. package/src/transformers/preview/component/artifacts.ts +10 -1
  335. package/src/transformers/preview/component/builder.ts +9 -2
  336. package/src/transformers/preview/component/css.ts +10 -1
  337. package/src/transformers/preview/component/javascript.ts +55 -20
  338. package/src/transformers/preview/component/shared-artifacts.ts +146 -0
  339. package/src/transformers/preview/component.ts +5 -2
  340. package/src/transformers/preview/pattern/builder.ts +33 -7
  341. package/src/transformers/preview/pattern/html.ts +71 -27
  342. package/src/transformers/preview/types.ts +42 -0
  343. package/src/transformers/utils/handlebars.ts +31 -5
  344. package/src/types/config.ts +123 -25
  345. package/src/utils/menu-shell.ts +241 -0
  346. package/src/utils/stable-stringify.ts +7 -0
  347. package/templates/default/package.json.tpl +1 -1
  348. package/handoff.config.js +0 -90
  349. package/src/app/components/ComponentSearch.tsx +0 -93
@@ -0,0 +1,163 @@
1
+ import type { RegistryReviewMetadata } from '@handoff/registry/db/schema';
2
+ import { isSafePathSegment, isSafeRelativePath } from '@handoff/registry/path';
3
+ import { isPlainObject } from './validation';
4
+
5
+ /**
6
+ * Metadata allowlist for registry create/update.
7
+ *
8
+ * Normal metadata writes accept only review/catalog fields. Everything else — renderer, entries,
9
+ * previews, properties/docgen, source/artifact content, build metadata, declaration files, etc. —
10
+ * is render/build-defining and may only change through a fresh publish (transfer PUT).
11
+ * Any non-allowlisted field is rejected with `400 bad_request` and the offending field names, so a
12
+ * note edit can never silently alter how an entity renders.
13
+ */
14
+ export type ManagedEntityKind = 'component' | 'pattern' | 'page';
15
+
16
+ /**
17
+ * Top-level allowlisted fields per entity kind. Components additionally allow `categories`; pages
18
+ * have no promoted `tags` column, so their catalog fields stop at title/description/group.
19
+ */
20
+ const TOP_LEVEL_FIELDS: Record<ManagedEntityKind, readonly string[]> = {
21
+ component: ['title', 'description', 'group', 'tags', 'categories'],
22
+ pattern: ['title', 'description', 'group', 'tags'],
23
+ page: ['title', 'description', 'group'],
24
+ };
25
+
26
+ /** Allowlisted nested review-metadata keys. */
27
+ const METADATA_FIELDS: readonly (keyof RegistryReviewMetadata)[] = ['reviewStatus', 'notes', 'owner'];
28
+
29
+ /** String-array fields (validated element-wise). */
30
+ const ARRAY_FIELDS = new Set(['tags', 'categories']);
31
+ /** Plain-string fields. */
32
+ const STRING_FIELDS = new Set(['title', 'description', 'group']);
33
+
34
+ /** Validated, allowlist-filtered metadata write. */
35
+ export interface ValidatedMetadataWrite {
36
+ /** Stable id, present only for create (POST) when `allowId` is set. */
37
+ id?: string;
38
+ /** Allowlisted top-level field values present in the request body. */
39
+ fields: Record<string, unknown>;
40
+ /** Allowlisted review-metadata present in the request body, or `undefined` when none supplied. */
41
+ metadata?: RegistryReviewMetadata;
42
+ }
43
+
44
+ /**
45
+ * Result of {@link validateMetadataWrite}. A flat shape (rather than a discriminated union) because
46
+ * the app compiles with `strictNullChecks` off, where union narrowing is unreliable — `ok` is the
47
+ * single source of truth, with `value` set on success and `rejectedFields`/`message` on failure.
48
+ */
49
+ export interface MetadataValidation {
50
+ ok: boolean;
51
+ value?: ValidatedMetadataWrite;
52
+ rejectedFields?: string[];
53
+ message?: string;
54
+ }
55
+
56
+ const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every((item) => typeof item === 'string');
57
+
58
+ export interface ValidateMetadataOptions {
59
+ /** Allow (and require) an `id` field — used by create (POST). */
60
+ allowId?: boolean;
61
+ }
62
+
63
+ /**
64
+ * Validate and allowlist-filter a metadata create/update body for the given entity kind. Returns
65
+ * the filtered write on success, or the list of rejected field names (render/build-defining keys,
66
+ * unknown keys, or type-invalid values) on failure.
67
+ */
68
+ export const validateMetadataWrite = (
69
+ body: unknown,
70
+ kind: ManagedEntityKind,
71
+ options: ValidateMetadataOptions = {}
72
+ ): MetadataValidation => {
73
+ if (!isPlainObject(body)) {
74
+ return { ok: false, rejectedFields: [], message: 'Request body must be a JSON object.' };
75
+ }
76
+
77
+ const allowed = new Set(TOP_LEVEL_FIELDS[kind]);
78
+ const rejectedFields: string[] = [];
79
+ const fields: Record<string, unknown> = {};
80
+ let metadata: RegistryReviewMetadata | undefined;
81
+ let id: string | undefined;
82
+
83
+ for (const [key, value] of Object.entries(body)) {
84
+ if (key === 'id') {
85
+ if (!options.allowId) {
86
+ // Identity is immutable; an `id` in an update body is render/build-irrelevant but not allowed.
87
+ rejectedFields.push('id');
88
+ continue;
89
+ }
90
+ const candidate = typeof value === 'string' ? value.trim() : '';
91
+ const validId = kind === 'page' ? isSafeRelativePath(candidate) : isSafePathSegment(candidate);
92
+ if (!validId) {
93
+ rejectedFields.push('id');
94
+ continue;
95
+ }
96
+ id = candidate;
97
+ continue;
98
+ }
99
+
100
+ if (key === 'metadata') {
101
+ if (!isPlainObject(value)) {
102
+ rejectedFields.push('metadata');
103
+ continue;
104
+ }
105
+ const reviewMetadata: RegistryReviewMetadata = {};
106
+ for (const [metaKey, metaValue] of Object.entries(value)) {
107
+ if (!METADATA_FIELDS.includes(metaKey as keyof RegistryReviewMetadata)) {
108
+ rejectedFields.push(`metadata.${metaKey}`);
109
+ continue;
110
+ }
111
+ if (typeof metaValue !== 'string') {
112
+ rejectedFields.push(`metadata.${metaKey}`);
113
+ continue;
114
+ }
115
+ reviewMetadata[metaKey as keyof RegistryReviewMetadata] = metaValue;
116
+ }
117
+ metadata = reviewMetadata;
118
+ continue;
119
+ }
120
+
121
+ if (!allowed.has(key)) {
122
+ rejectedFields.push(key);
123
+ continue;
124
+ }
125
+
126
+ if (STRING_FIELDS.has(key) && typeof value !== 'string') {
127
+ rejectedFields.push(key);
128
+ continue;
129
+ }
130
+ if (ARRAY_FIELDS.has(key) && !isStringArray(value)) {
131
+ rejectedFields.push(key);
132
+ continue;
133
+ }
134
+ fields[key] = value;
135
+ }
136
+
137
+ if (options.allowId && !id) {
138
+ rejectedFields.push('id');
139
+ }
140
+
141
+ if (rejectedFields.length > 0) {
142
+ return {
143
+ ok: false,
144
+ rejectedFields,
145
+ message:
146
+ 'Only allowlisted review/catalog fields may be written here. Render/build-defining fields, ' +
147
+ 'source, and artifacts can only change through publish.',
148
+ };
149
+ }
150
+
151
+ return { ok: true, value: { id, fields, metadata } };
152
+ };
153
+
154
+ /** Merge a validated review-metadata write over the existing stored metadata. */
155
+ export const mergeReviewMetadata = (
156
+ existing: RegistryReviewMetadata | null | undefined,
157
+ update: RegistryReviewMetadata | undefined
158
+ ): RegistryReviewMetadata | null => {
159
+ if (!update) {
160
+ return existing ?? null;
161
+ }
162
+ return { ...(existing ?? {}), ...update };
163
+ };
@@ -0,0 +1,363 @@
1
+ import crypto from 'crypto';
2
+ import type { NextApiRequest, NextApiResponse } from 'next';
3
+ import { eq, inArray } from 'drizzle-orm';
4
+ import type { RegistryDatabase } from '@handoff/registry/db/client';
5
+ import { assetBlobs, assetCollections, assets } from '@handoff/registry/db/schema';
6
+ import { isAssetCollection, type AssetCollection } from '@handoff/registry/assets/sets';
7
+ import type { AssetCollectionTransferPackage, AssetManifestEntry } from '@handoff/registry/assets/transfer';
8
+ import { singleQueryValue } from '../api/query';
9
+ import { getActiveAssetStorage, getActiveAssetStorageAdapter, getAssetStorageAdapter } from '../asset-storage';
10
+ import { sendRegistryError } from './errors';
11
+ import { buildMeta } from './meta';
12
+ import { handleRegistryRoute, sendRegistryData } from './handler';
13
+ import {
14
+ asString,
15
+ invalidPackage as invalid,
16
+ isPlainObject,
17
+ isSha256Hash,
18
+ normalizeSafeRelativePath,
19
+ type PackageValidation,
20
+ validateTransferBuild,
21
+ } from './validation';
22
+
23
+ /**
24
+ * Asset transfer ingestion + serving for the registry.
25
+ *
26
+ * - `GET /api/registry/transfer/assets` lists collection summaries (collection + source hash).
27
+ * - `GET /api/registry/transfer/assets/:collection` is checkout: the collection manifest metadata.
28
+ * - `PUT /api/registry/transfer/assets/:collection` is publish ingestion: validates the manifest,
29
+ * asserts every referenced blob already exists (so a manifest never references missing content),
30
+ * then atomically upserts the `asset_collections` row and replaces its `assets` rows in one
31
+ * transaction. An unchanged collection (matching `sourceHash`) is a no-op.
32
+ * - `POST /api/registry/transfer/assets/blobs/have` returns which content hashes are missing.
33
+ * - `PUT /api/registry/transfer/assets/blobs/:hash` stores one content-addressed blob (binary body)
34
+ * through the active storage provider (inline `bytea` by default). Idempotent by hash.
35
+ * - `GET /api/registry/transfer/assets/blobs/:hash` returns/redirects to one blob's bytes.
36
+ *
37
+ * Runs behind {@link handleRegistryRoute}: runtime-mode, method, bearer-token (on mutations), and
38
+ * database guards apply before any of this executes.
39
+ */
40
+
41
+ /** Validate an asset collection publish package (manifest metadata only; bodies travel as blobs). */
42
+ const validateAssetPackage = (body: unknown, collection: string): PackageValidation<AssetCollectionTransferPackage> => {
43
+ if (!isPlainObject(body)) {
44
+ return invalid('Request body must be a JSON object.');
45
+ }
46
+ if (!isAssetCollection(collection)) {
47
+ return invalid(`Unknown asset collection "${collection}".`, { rejectedFields: ['collection'] });
48
+ }
49
+ const bodyCollection = asString(body.collection);
50
+ if (bodyCollection !== undefined && bodyCollection !== collection) {
51
+ return invalid(`Package collection "${bodyCollection}" does not match the publish target "${collection}".`, {
52
+ rejectedFields: ['collection'],
53
+ });
54
+ }
55
+
56
+ const rawAssets = body.assets ?? [];
57
+ if (!Array.isArray(rawAssets)) {
58
+ return invalid('`assets` must be an array.', { rejectedFields: ['assets'] });
59
+ }
60
+ const manifest: AssetManifestEntry[] = [];
61
+ const seenPaths = new Set<string>();
62
+ for (let i = 0; i < rawAssets.length; i += 1) {
63
+ const raw = rawAssets[i];
64
+ const field = (name: string) => `assets[${i}].${name}`;
65
+ if (!isPlainObject(raw)) {
66
+ return invalid('Each asset must be an object.', { rejectedFields: [`assets[${i}]`] });
67
+ }
68
+ const assetPath = normalizeSafeRelativePath(raw.path);
69
+ if (!assetPath) {
70
+ return invalid('Asset path must be a registry-safe relative path.', { rejectedFields: [field('path')] });
71
+ }
72
+ if (seenPaths.has(assetPath)) {
73
+ return invalid(`Duplicate asset path "${assetPath}" in manifest.`, { rejectedFields: [field('path')] });
74
+ }
75
+ seenPaths.add(assetPath);
76
+ const contentHash = asString(raw.contentHash);
77
+ if (!isSha256Hash(contentHash)) {
78
+ return invalid(`Asset "${assetPath}" is missing a valid SHA-256 contentHash.`, { rejectedFields: [field('contentHash')] });
79
+ }
80
+ manifest.push({
81
+ path: assetPath,
82
+ name: asString(raw.name) || assetPath,
83
+ contentType: asString(raw.contentType) || 'application/octet-stream',
84
+ size: typeof raw.size === 'number' ? raw.size : 0,
85
+ contentHash: contentHash.toLowerCase(),
86
+ metadata: isPlainObject(raw.metadata) ? (raw.metadata as Record<string, unknown>) : null,
87
+ });
88
+ }
89
+
90
+ const buildValidation = validateTransferBuild(body.build, {
91
+ requiredHashField: 'sourceHash',
92
+ currentMessage: 'A "current" asset build requires builtAt, builderVersion, and sourceHash.',
93
+ });
94
+ if (!buildValidation.ok) {
95
+ return invalid(buildValidation.message, buildValidation.details);
96
+ }
97
+ const build = buildValidation.value;
98
+
99
+ return {
100
+ ok: true,
101
+ value: {
102
+ collection: collection as AssetCollection,
103
+ assets: manifest,
104
+ build: {
105
+ status: build.status,
106
+ builtAt: build.builtAt,
107
+ builderVersion: build.builderVersion,
108
+ sourceHash: build.sourceHash,
109
+ },
110
+ },
111
+ };
112
+ };
113
+
114
+ /** The stored source hash for a collection, or `undefined` when it does not exist yet. */
115
+ const storedSourceHash = async (db: RegistryDatabase, collection: string): Promise<string | null | undefined> => {
116
+ const rows = await db
117
+ .select({ sourceHash: assetCollections.sourceHash })
118
+ .from(assetCollections)
119
+ .where(eq(assetCollections.collection, collection as AssetCollection))
120
+ .limit(1);
121
+ return rows.length ? rows[0].sourceHash : undefined;
122
+ };
123
+
124
+ /** Atomically upsert the collection row and replace its manifest rows in one transaction. */
125
+ const ingestAssetCollection = async (db: RegistryDatabase, pkg: AssetCollectionTransferPackage): Promise<void> => {
126
+ const collectionValues = {
127
+ sourceHash: pkg.build.sourceHash ?? null,
128
+ status: pkg.build.status,
129
+ builtAt: pkg.build.builtAt ? new Date(pkg.build.builtAt) : null,
130
+ builderVersion: pkg.build.builderVersion ?? null,
131
+ updatedAt: new Date(),
132
+ };
133
+
134
+ await db.transaction(async (tx) => {
135
+ await tx
136
+ .insert(assetCollections)
137
+ .values({ collection: pkg.collection, metadata: null, ...collectionValues } as any)
138
+ .onConflictDoUpdate({ target: assetCollections.collection, set: collectionValues as any });
139
+
140
+ await tx.delete(assets).where(eq(assets.collection, pkg.collection));
141
+ for (const asset of pkg.assets) {
142
+ await tx.insert(assets).values({
143
+ collection: pkg.collection,
144
+ path: asset.path,
145
+ name: asset.name,
146
+ contentType: asset.contentType,
147
+ size: asset.size,
148
+ contentHash: asset.contentHash,
149
+ metadata: asset.metadata ?? null,
150
+ blobHash: asset.contentHash,
151
+ } as any);
152
+ }
153
+ });
154
+ };
155
+
156
+ /** Read the raw binary request body (blob upload routes disable the JSON body parser). */
157
+ const readRawBody = (req: NextApiRequest): Promise<Buffer> =>
158
+ new Promise((resolve, reject) => {
159
+ const chunks: Buffer[] = [];
160
+ req.on('data', (chunk: Buffer) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
161
+ req.on('end', () => resolve(Buffer.concat(chunks)));
162
+ req.on('error', reject);
163
+ });
164
+
165
+ /** `GET /api/registry/transfer/assets`: list collection summaries. */
166
+ export const handleAssetSummaryRoute = (req: NextApiRequest, res: NextApiResponse): Promise<void> =>
167
+ handleRegistryRoute(req, res, ['GET'], async ({ db }) => {
168
+ const rows = await db
169
+ .select({ collection: assetCollections.collection, sourceHash: assetCollections.sourceHash, status: assetCollections.status })
170
+ .from(assetCollections);
171
+ sendRegistryData(res, 200, { collections: rows }, buildMeta());
172
+ });
173
+
174
+ /** `GET|PUT /api/registry/transfer/assets/:collection`: checkout / publish one collection manifest. */
175
+ export const handleAssetCollectionRoute = (req: NextApiRequest, res: NextApiResponse): Promise<void> =>
176
+ handleRegistryRoute(req, res, ['GET', 'PUT'], async ({ db, method }) => {
177
+ const collection = singleQueryValue(req.query.collection) ?? '';
178
+ if (!collection) {
179
+ sendRegistryError(res, 'not_found', 'Missing asset collection.');
180
+ return;
181
+ }
182
+
183
+ if (method === 'GET') {
184
+ const found = await db
185
+ .select({ collection: assetCollections.collection })
186
+ .from(assetCollections)
187
+ .where(eq(assetCollections.collection, collection as AssetCollection))
188
+ .limit(1);
189
+ if (!found.length) {
190
+ sendRegistryError(res, 'not_found', `No asset collection "${collection}" exists in the registry.`);
191
+ return;
192
+ }
193
+ const rows = await db
194
+ .select({
195
+ path: assets.path,
196
+ name: assets.name,
197
+ contentType: assets.contentType,
198
+ size: assets.size,
199
+ contentHash: assets.contentHash,
200
+ metadata: assets.metadata,
201
+ })
202
+ .from(assets)
203
+ .where(eq(assets.collection, collection as AssetCollection));
204
+ sendRegistryData(res, 200, { collection, assets: rows }, buildMeta());
205
+ return;
206
+ }
207
+
208
+ // PUT: publish ingestion.
209
+ const validation = validateAssetPackage(req.body, collection);
210
+ if (!validation.ok) {
211
+ sendRegistryError(res, 'bad_request', validation.message ?? 'Invalid asset collection package.', validation.details);
212
+ return;
213
+ }
214
+ const pkg = validation.value;
215
+
216
+ const existingHash = await storedSourceHash(db, collection);
217
+ if (existingHash && pkg.build.sourceHash && existingHash === pkg.build.sourceHash) {
218
+ sendRegistryData(res, 200, { collection, published: false, unchanged: true }, buildMeta());
219
+ return;
220
+ }
221
+
222
+ // Guard: never finalize a manifest that references a blob the registry does not have.
223
+ const referenced = Array.from(new Set(pkg.assets.map((asset) => asset.contentHash)));
224
+ if (referenced.length > 0) {
225
+ const present = await db.select({ hash: assetBlobs.hash }).from(assetBlobs).where(inArray(assetBlobs.hash, referenced));
226
+ const presentSet = new Set(present.map((row) => row.hash));
227
+ const missing = referenced.filter((hash) => !presentSet.has(hash));
228
+ if (missing.length > 0) {
229
+ sendRegistryError(
230
+ res,
231
+ 'bad_request',
232
+ `Cannot finalize collection "${collection}": ${missing.length} referenced blob(s) were not uploaded.`,
233
+ {
234
+ rejectedFields: ['assets'],
235
+ missing,
236
+ }
237
+ );
238
+ return;
239
+ }
240
+ }
241
+
242
+ await ingestAssetCollection(db, pkg);
243
+ sendRegistryData(res, 200, { collection, published: true, assets: pkg.assets.length }, buildMeta());
244
+ });
245
+
246
+ /** `POST /api/registry/transfer/assets/blobs/have`: return which of the given hashes are missing. */
247
+ export const handleAssetBlobHaveRoute = (req: NextApiRequest, res: NextApiResponse): Promise<void> =>
248
+ handleRegistryRoute(req, res, ['POST'], async ({ db }) => {
249
+ const body = req.body;
250
+ const hashes = isPlainObject(body) && Array.isArray(body.hashes) ? body.hashes.filter((h): h is string => typeof h === 'string') : [];
251
+ if (hashes.length === 0) {
252
+ sendRegistryData(res, 200, { missing: [] }, buildMeta());
253
+ return;
254
+ }
255
+ const present = await db.select({ hash: assetBlobs.hash }).from(assetBlobs).where(inArray(assetBlobs.hash, hashes));
256
+ const presentSet = new Set(present.map((row) => row.hash));
257
+ sendRegistryData(res, 200, { missing: hashes.filter((hash) => !presentSet.has(hash)) }, buildMeta());
258
+ });
259
+
260
+ /** `PUT|GET /api/registry/transfer/assets/blobs/:hash`: store / fetch one content-addressed blob. */
261
+ export const handleAssetBlobRoute = (req: NextApiRequest, res: NextApiResponse): Promise<void> =>
262
+ handleRegistryRoute(req, res, ['GET', 'PUT'], async ({ db, method }) => {
263
+ const hash = (singleQueryValue(req.query.hash) ?? '').toLowerCase();
264
+ if (!isSha256Hash(hash)) {
265
+ sendRegistryError(res, 'bad_request', 'Blob hash must be a SHA-256 hex string.');
266
+ return;
267
+ }
268
+
269
+ if (method === 'PUT') {
270
+ const bytes = await readRawBody(req);
271
+ const actual = crypto.createHash('sha256').update(bytes).digest('hex');
272
+ if (actual !== hash) {
273
+ sendRegistryError(res, 'bad_request', `Uploaded content hash "${actual}" does not match the target "${hash}".`, {
274
+ rejectedFields: ['hash'],
275
+ });
276
+ return;
277
+ }
278
+ const existing = await db.select({ hash: assetBlobs.hash }).from(assetBlobs).where(eq(assetBlobs.hash, hash)).limit(1);
279
+ if (existing.length > 0) {
280
+ sendRegistryData(res, 200, { hash, stored: true }, buildMeta());
281
+ return;
282
+ }
283
+ const contentType =
284
+ (Array.isArray(req.headers['content-type']) ? req.headers['content-type'][0] : req.headers['content-type']) ||
285
+ 'application/octet-stream';
286
+ const active = getActiveAssetStorage();
287
+
288
+ let storageProvider = active.provider;
289
+ let content: Buffer | null = null;
290
+ let storageRef: string | null = null;
291
+ if (active.provider === 'database') {
292
+ if (bytes.length > active.maxInlineBytes) {
293
+ sendRegistryError(
294
+ res,
295
+ 'bad_request',
296
+ `Blob is ${bytes.length} bytes, above the database inline limit of ${active.maxInlineBytes}. Configure object storage (runtime.registry.assetStorage) for large assets.`
297
+ );
298
+ return;
299
+ }
300
+ content = bytes;
301
+ } else {
302
+ const adapter = await getActiveAssetStorageAdapter();
303
+ if (!adapter) {
304
+ sendRegistryError(res, 'unexpected_error', `No storage adapter is available for provider "${active.provider}".`);
305
+ return;
306
+ }
307
+ ({ storageRef } = await adapter.put({ hash, bytes, contentType, size: bytes.length }));
308
+ storageProvider = active.provider;
309
+ }
310
+
311
+ await db
312
+ .insert(assetBlobs)
313
+ .values({ hash, storageProvider, content, storageRef, contentType, size: bytes.length } as any)
314
+ .onConflictDoNothing({ target: assetBlobs.hash });
315
+ sendRegistryData(res, 200, { hash, stored: true }, buildMeta());
316
+ return;
317
+ }
318
+
319
+ // GET: resolve and serve the blob bytes (redirecting to object storage where possible).
320
+ const rows = await db
321
+ .select({
322
+ content: assetBlobs.content,
323
+ storageRef: assetBlobs.storageRef,
324
+ storageProvider: assetBlobs.storageProvider,
325
+ contentType: assetBlobs.contentType,
326
+ })
327
+ .from(assetBlobs)
328
+ .where(eq(assetBlobs.hash, hash))
329
+ .limit(1);
330
+ const blob = rows[0];
331
+ if (!blob) {
332
+ sendRegistryError(res, 'not_found', `No blob "${hash}" exists in the registry.`);
333
+ return;
334
+ }
335
+
336
+ res.setHeader('ETag', `"${hash}"`);
337
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
338
+ if (blob.content != null) {
339
+ res.setHeader('Content-Type', blob.contentType);
340
+ res.status(200).send(Buffer.from(blob.content));
341
+ return;
342
+ }
343
+ if (!blob.storageRef) {
344
+ sendRegistryError(res, 'not_found', `Blob "${hash}" has no resolvable content.`);
345
+ return;
346
+ }
347
+ const adapter = await getAssetStorageAdapter(blob.storageProvider);
348
+ if (!adapter) {
349
+ sendRegistryError(res, 'unexpected_error', `No storage adapter is available for provider "${blob.storageProvider}".`);
350
+ return;
351
+ }
352
+ const result = await adapter.get(blob.storageRef);
353
+ if (result.kind === 'redirect') {
354
+ res.redirect(302, result.url);
355
+ return;
356
+ }
357
+ res.setHeader('Content-Type', result.contentType || blob.contentType);
358
+ if (result.kind === 'bytes') {
359
+ res.status(200).send(result.bytes);
360
+ return;
361
+ }
362
+ result.stream.pipe(res);
363
+ });
@@ -0,0 +1,68 @@
1
+ import type { NextApiRequest } from 'next';
2
+ import { getServerRuntimeConfig } from '../docs-api/runtime-config';
3
+
4
+ /**
5
+ * Bearer-token guard for registry management mutations.
6
+ *
7
+ * This is **proof-of-concept protection**, not production auth: a single static token, resolved
8
+ * from the configured env-var *name* at request time (never persisted in config). GET reads are
9
+ * unauthenticated; only mutations (POST/PUT/DELETE) call this guard.
10
+ *
11
+ * - Token env var unset/empty → `token_not_configured` (503).
12
+ * - `Authorization: Bearer <token>` missing or not an exact match → `unauthorized` (401).
13
+ */
14
+ /**
15
+ * Result of {@link authorizeMutation}. Flat (not a discriminated union) because the app compiles
16
+ * with `strictNullChecks` off — `ok` decides, with `code`/`message` set on failure.
17
+ */
18
+ export interface MutationAuthResult {
19
+ ok: boolean;
20
+ code?: 'token_not_configured' | 'unauthorized';
21
+ message?: string;
22
+ }
23
+
24
+ /** Length-safe constant-time-ish string comparison to avoid trivial timing leaks. */
25
+ const tokensMatch = (provided: string, expected: string): boolean => {
26
+ if (provided.length !== expected.length) {
27
+ return false;
28
+ }
29
+ let mismatch = 0;
30
+ for (let i = 0; i < expected.length; i += 1) {
31
+ mismatch |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
32
+ }
33
+ return mismatch === 0;
34
+ };
35
+
36
+ /** Extract a bearer token from the `Authorization` header, or `''` when absent/malformed. */
37
+ const bearerToken = (req: NextApiRequest): string => {
38
+ const header = req.headers.authorization;
39
+ if (typeof header !== 'string') {
40
+ return '';
41
+ }
42
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
43
+ return match ? match[1].trim() : '';
44
+ };
45
+
46
+ /** Authorize a mutation request against the configured static bearer token. */
47
+ export const authorizeMutation = (req: NextApiRequest): MutationAuthResult => {
48
+ const { registry } = getServerRuntimeConfig();
49
+ const expected = process.env[registry.apiTokenEnv]?.trim();
50
+ if (!expected) {
51
+ return {
52
+ ok: false,
53
+ code: 'token_not_configured',
54
+ message:
55
+ `Registry management mutations are disabled because the "${registry.apiTokenEnv}" environment ` +
56
+ `variable is not set. Set it to a bearer token to enable mutations.`,
57
+ };
58
+ }
59
+ const provided = bearerToken(req);
60
+ if (!provided || !tokensMatch(provided, expected)) {
61
+ return {
62
+ ok: false,
63
+ code: 'unauthorized',
64
+ message: 'A valid "Authorization: Bearer <token>" header is required for registry management mutations.',
65
+ };
66
+ }
67
+ return { ok: true };
68
+ };