pandorga 1.2.1

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 (376) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +77 -0
  3. data/DESIGN.md +1434 -0
  4. data/LICENSE +21 -0
  5. data/README.md +81 -0
  6. data/_data/themes/dark.yml +97 -0
  7. data/_data/themes/light.yml +120 -0
  8. data/_data/themes/shared.yml +37 -0
  9. data/_data/translations.yml +85 -0
  10. data/_includes/chord-tab.html +38 -0
  11. data/_includes/content-runtime/00-config.html +22 -0
  12. data/_includes/content-runtime/10-utils.html +176 -0
  13. data/_includes/content-runtime/20-markdown.html +264 -0
  14. data/_includes/content-runtime/25-code-blocks.html +315 -0
  15. data/_includes/content-runtime/30-includes-bib.html +190 -0
  16. data/_includes/content-runtime/30-includes-xcite.html +122 -0
  17. data/_includes/content-runtime/30-includes.html +365 -0
  18. data/_includes/content-runtime/40-listing.html +225 -0
  19. data/_includes/content-runtime/41-listing-helpers.html +71 -0
  20. data/_includes/content-runtime/42-listing-filter.html +208 -0
  21. data/_includes/content-runtime/43-blocks.html +379 -0
  22. data/_includes/content-runtime/50-detail.html +861 -0
  23. data/_includes/content-runtime/55-toc.html +176 -0
  24. data/_includes/content-runtime/60-fragments.html +567 -0
  25. data/_includes/content-runtime/90-bootstrap.html +76 -0
  26. data/_includes/cut-in.html +10 -0
  27. data/_includes/cv/cv-box.html +18 -0
  28. data/_includes/dict-card.html +24 -0
  29. data/_includes/figure.html +36 -0
  30. data/_includes/home/band-index.html +43 -0
  31. data/_includes/home/band-listing.html +49 -0
  32. data/_includes/mathjax.html +10 -0
  33. data/_includes/ongoing-product.html +1 -0
  34. data/_includes/page/content-runtime.html +20 -0
  35. data/_includes/page/date_evolution.html +19 -0
  36. data/_includes/page/figure-lightbox.html +102 -0
  37. data/_includes/page/formatted_date.html +15 -0
  38. data/_includes/page/hero-default.html +18 -0
  39. data/_includes/page/listing-runtime-attrs.html +8 -0
  40. data/_includes/page/mermaid.html +101 -0
  41. data/_includes/page/network-graph.html +1253 -0
  42. data/_includes/page/page-header.html +41 -0
  43. data/_includes/page/professional-connections.html +2 -0
  44. data/_includes/page/site-footer.html +101 -0
  45. data/_includes/page/timeline-section.html +868 -0
  46. data/_includes/plotly.html +22 -0
  47. data/_includes/theme/dev-reexport.html +100 -0
  48. data/_includes/theme/favicons.html +4 -0
  49. data/_includes/theme/font-loader.html +55 -0
  50. data/_includes/theme/shell-theme-boot.html +115 -0
  51. data/_includes/theme/site-logo.html +6 -0
  52. data/_includes/theme/tailwind-theme-colors.json.liquid +65 -0
  53. data/_includes/theme/tailwind-theme-fonts.json.liquid +6 -0
  54. data/_includes/theme/theme-context.liquid +20 -0
  55. data/_includes/theme/theme-toggle.html +94 -0
  56. data/_includes/theme/theme-vars-block.liquid +161 -0
  57. data/_includes/theme/theme-vars.html +16 -0
  58. data/_includes/transcription.html +80 -0
  59. data/_includes/tweet.html +22 -0
  60. data/_includes/video.html +11 -0
  61. data/_includes/xcite.html +3 -0
  62. data/_includes/youtube-short.html +26 -0
  63. data/_includes/youtube.html +12 -0
  64. data/_layouts/article.html +27 -0
  65. data/_layouts/articles.html +534 -0
  66. data/_layouts/bibliography.html +417 -0
  67. data/_layouts/blog.html +544 -0
  68. data/_layouts/cv.html +393 -0
  69. data/_layouts/home.html +319 -0
  70. data/_layouts/network.html +9 -0
  71. data/_layouts/pandorga/articles.html +4 -0
  72. data/_layouts/pandorga/bibliography.html +4 -0
  73. data/_layouts/pandorga/blog.html +4 -0
  74. data/_layouts/pandorga/cv.html +4 -0
  75. data/_layouts/pandorga/media.html +4 -0
  76. data/_layouts/pandorga/network.html +4 -0
  77. data/_layouts/pandorga/portfolio.html +4 -0
  78. data/_layouts/post.html +7 -0
  79. data/_layouts/product.html +5 -0
  80. data/_layouts/project.html +27 -0
  81. data/_layouts/projects.html +534 -0
  82. data/_layouts/resources.html +387 -0
  83. data/_layouts/shell.html +203 -0
  84. data/_layouts/text.html +246 -0
  85. data/_plugins/bibliography_artifact.rb +57 -0
  86. data/_plugins/content_backend.rb +37 -0
  87. data/_plugins/content_collections.rb +51 -0
  88. data/_plugins/content_detail_paths.rb +58 -0
  89. data/_plugins/content_headers.rb +73 -0
  90. data/_plugins/content_keys.rb +310 -0
  91. data/_plugins/content_local_serve.rb +168 -0
  92. data/_plugins/content_site_adapter.rb +159 -0
  93. data/_plugins/content_validators_hook.rb +20 -0
  94. data/_plugins/content_writing_index.rb +52 -0
  95. data/_plugins/content_writing_paths.rb +55 -0
  96. data/_plugins/force_utf8_encoding.rb +13 -0
  97. data/_plugins/git_chronology.rb +211 -0
  98. data/_plugins/git_metadata.rb +43 -0
  99. data/_plugins/job_sections.rb +25 -0
  100. data/_plugins/lib/content_validators.rb +27 -0
  101. data/_plugins/lib/resource_validator.rb +56 -0
  102. data/_plugins/lib/tag_validator.rb +153 -0
  103. data/_plugins/lib/text_excerpt.rb +56 -0
  104. data/_plugins/lib/writing_entry.rb +107 -0
  105. data/_plugins/local_detail_rewrites.rb +194 -0
  106. data/_plugins/timeline.rb +507 -0
  107. data/_plugins/writing_slug_limits.rb +70 -0
  108. data/assets/css/article.css +1330 -0
  109. data/assets/css/base.css +1432 -0
  110. data/assets/css/cv.css +13 -0
  111. data/assets/css/global-link.css +94 -0
  112. data/assets/css/ledger/bibliography.css +307 -0
  113. data/assets/css/ledger/chrome.css +118 -0
  114. data/assets/css/ledger/cv.css +836 -0
  115. data/assets/css/ledger/detail.css +1270 -0
  116. data/assets/css/ledger/footer.css +298 -0
  117. data/assets/css/ledger/home.css +1490 -0
  118. data/assets/css/ledger/network.css +106 -0
  119. data/assets/css/ledger/projects.css +716 -0
  120. data/assets/css/ledger/rascunhos.css +379 -0
  121. data/assets/css/ledger/sources.css +327 -0
  122. data/assets/css/ledger/writing.css +754 -0
  123. data/assets/tailwind-play.js +15 -0
  124. data/examples/full/Gemfile +7 -0
  125. data/examples/full/_config.yml +92 -0
  126. data/examples/full/_redirects +3 -0
  127. data/examples/full/content/collections/articles/2026-01-01-welcome.md +9 -0
  128. data/examples/full/content/collections/data/contacts.yml +3 -0
  129. data/examples/full/content/collections/data/education.yml +4 -0
  130. data/examples/full/content/collections/jobs/research-fellow.md +12 -0
  131. data/examples/full/content/collections/posts/2026-01-02-note.md +9 -0
  132. data/examples/full/content/collections/products/ledger-tool.md +6 -0
  133. data/examples/full/content/collections/projects/demo-kit.md +10 -0
  134. data/examples/full/content/collections/resources/style-guide.md +7 -0
  135. data/examples/full/content/pages/cv/summary.md +1 -0
  136. data/examples/full/content/pages/headers.yml +20 -0
  137. data/examples/full/index.html +4 -0
  138. data/examples/minimal/Gemfile +7 -0
  139. data/examples/minimal/_config.yml +47 -0
  140. data/examples/minimal/_redirects +3 -0
  141. data/examples/minimal/content/collections/articles/2026-01-01-hello.md +10 -0
  142. data/examples/minimal/content/collections/articles/2026-01-02-second.md +9 -0
  143. data/examples/minimal/content/pages/headers.yml +6 -0
  144. data/examples/minimal/index.html +4 -0
  145. data/exe/pandorga +7 -0
  146. data/functions/api/studio/[[path]].js +562 -0
  147. data/functions/api/studio/_lib/auth.js +131 -0
  148. data/functions/api/studio/_lib/blocks.js +156 -0
  149. data/functions/api/studio/_lib/gemini.js +62 -0
  150. data/functions/api/studio/_lib/github.js +278 -0
  151. data/functions/api/studio/_lib/guidelines.js +143 -0
  152. data/functions/api/studio/_lib/paths.js +76 -0
  153. data/functions/api/studio/_lib/prompts.js +165 -0
  154. data/functions/api/studio/_lib/rate-limit.js +40 -0
  155. data/functions/api/studio/_lib/rewrite.js +136 -0
  156. data/functions/api/studio/_lib/sanitize.js +143 -0
  157. data/lib/pandorga/cli.rb +66 -0
  158. data/lib/pandorga/commands/doctor.rb +66 -0
  159. data/lib/pandorga/commands/export.rb +30 -0
  160. data/lib/pandorga/commands/install_functions.rb +41 -0
  161. data/lib/pandorga/commands/new.rb +113 -0
  162. data/lib/pandorga/commands/publish.rb +53 -0
  163. data/lib/pandorga/commands/serve.rb +34 -0
  164. data/lib/pandorga/commands/studio_schema.rb +309 -0
  165. data/lib/pandorga/jekyll/generator.rb +74 -0
  166. data/lib/pandorga/jekyll/plugins/bibliography_artifact.rb +57 -0
  167. data/lib/pandorga/jekyll/plugins/content_backend.rb +37 -0
  168. data/lib/pandorga/jekyll/plugins/content_collections.rb +51 -0
  169. data/lib/pandorga/jekyll/plugins/content_detail_paths.rb +58 -0
  170. data/lib/pandorga/jekyll/plugins/content_headers.rb +73 -0
  171. data/lib/pandorga/jekyll/plugins/content_keys.rb +310 -0
  172. data/lib/pandorga/jekyll/plugins/content_local_serve.rb +168 -0
  173. data/lib/pandorga/jekyll/plugins/content_site_adapter.rb +159 -0
  174. data/lib/pandorga/jekyll/plugins/content_validators_hook.rb +20 -0
  175. data/lib/pandorga/jekyll/plugins/content_writing_index.rb +52 -0
  176. data/lib/pandorga/jekyll/plugins/content_writing_paths.rb +55 -0
  177. data/lib/pandorga/jekyll/plugins/force_utf8_encoding.rb +13 -0
  178. data/lib/pandorga/jekyll/plugins/git_chronology.rb +211 -0
  179. data/lib/pandorga/jekyll/plugins/git_metadata.rb +43 -0
  180. data/lib/pandorga/jekyll/plugins/job_sections.rb +25 -0
  181. data/lib/pandorga/jekyll/plugins/lib/content_validators.rb +27 -0
  182. data/lib/pandorga/jekyll/plugins/lib/resource_validator.rb +56 -0
  183. data/lib/pandorga/jekyll/plugins/lib/tag_validator.rb +153 -0
  184. data/lib/pandorga/jekyll/plugins/lib/text_excerpt.rb +56 -0
  185. data/lib/pandorga/jekyll/plugins/lib/writing_entry.rb +107 -0
  186. data/lib/pandorga/jekyll/plugins/local_detail_rewrites.rb +194 -0
  187. data/lib/pandorga/jekyll/plugins/timeline.rb +507 -0
  188. data/lib/pandorga/jekyll/plugins/writing_slug_limits.rb +70 -0
  189. data/lib/pandorga/jekyll.rb +15 -0
  190. data/lib/pandorga/object_store_env.rb +51 -0
  191. data/lib/pandorga/registry/schema.yml +115 -0
  192. data/lib/pandorga/registry.rb +279 -0
  193. data/lib/pandorga/version.rb +5 -0
  194. data/lib/pandorga.rb +19 -0
  195. data/scripts/cdn-allowlist.json +19 -0
  196. data/scripts/content/backfill-content-keys.rb +99 -0
  197. data/scripts/content/band_art.rb +362 -0
  198. data/scripts/content/export-content-json.rb +741 -0
  199. data/scripts/content/flatten-ansi-capture.mjs +153 -0
  200. data/scripts/content/hero_portraits.rb +28 -0
  201. data/scripts/content/publish-content-to-object-store.sh +78 -0
  202. data/scripts/content/publish-content-to-r2.sh +5 -0
  203. data/scripts/content/sync-content-json-to-r2.rb +167 -0
  204. data/scripts/content/sync-content-redirects.rb +12 -0
  205. data/scripts/generate-configuration-doc.rb +127 -0
  206. data/scripts/lib/content_license.rb +50 -0
  207. data/scripts/lib/content_storage_config.rb +70 -0
  208. data/scripts/lib/normalize-display-math.mjs +52 -0
  209. data/scripts/lib/r2_json_sync.rb +76 -0
  210. data/scripts/lib/withdrawn_public_objects.rb +23 -0
  211. data/scripts/lib/writing_index_contract.rb +61 -0
  212. data/scripts/pdf/md-to-pdf.mjs +1400 -0
  213. data/scripts/pdf/md-to-pdf.sh +27 -0
  214. data/scripts/test/fixtures/dual-template/_config.yml +32 -0
  215. data/scripts/test/fixtures/dual-template/index.html +4 -0
  216. data/scripts/test/render-parity/article-note.json +5 -0
  217. data/scripts/test/render-parity/blockquote.json +5 -0
  218. data/scripts/test/render-parity/blocks/ansi-basic.json +7 -0
  219. data/scripts/test/render-parity/blocks/ansi-bold-is-bright.json +7 -0
  220. data/scripts/test/render-parity/blocks/ansi-drops-cursor-moves.json +7 -0
  221. data/scripts/test/render-parity/blocks/ansi-escapes-markup.json +7 -0
  222. data/scripts/test/render-parity/blocks/ansi-extended-colour.json +7 -0
  223. data/scripts/test/render-parity/blocks/ansi-real-control-byte.json +7 -0
  224. data/scripts/test/render-parity/blocks/archive-card-date-once.json +19 -0
  225. data/scripts/test/render-parity/blocks/archive-card-empty-foot.json +7 -0
  226. data/scripts/test/render-parity/blocks/archive-card-footer-dates.json +22 -0
  227. data/scripts/test/render-parity/blocks/archive-card-meta-parts.json +26 -0
  228. data/scripts/test/render-parity/blocks/archive-card.json +32 -0
  229. data/scripts/test/render-parity/blocks/cta-link.json +13 -0
  230. data/scripts/test/render-parity/blocks/lang-pill-pt.json +8 -0
  231. data/scripts/test/render-parity/blocks/lang-pill.json +8 -0
  232. data/scripts/test/render-parity/blocks/ledger-row-meta-parts.json +29 -0
  233. data/scripts/test/render-parity/blocks/ledger-row.json +35 -0
  234. data/scripts/test/render-parity/blocks/meta-strip.json +27 -0
  235. data/scripts/test/render-parity/blocks/poster-media-image.json +8 -0
  236. data/scripts/test/render-parity/blocks/poster-media-video.json +9 -0
  237. data/scripts/test/render-parity/blocks/section-banner.json +13 -0
  238. data/scripts/test/render-parity/blocks/system-card-custom-meta.json +22 -0
  239. data/scripts/test/render-parity/blocks/system-card.json +48 -0
  240. data/scripts/test/render-parity/blocks/tag-chips-links.json +12 -0
  241. data/scripts/test/render-parity/blocks/tag-chips.json +9 -0
  242. data/scripts/test/render-parity/cut-in-blank-line.json +9 -0
  243. data/scripts/test/render-parity/cut-in-right.json +9 -0
  244. data/scripts/test/render-parity/cut-in.json +8 -0
  245. data/scripts/test/render-parity/figure.json +5 -0
  246. data/scripts/test/render-parity/load-runtime.mjs +337 -0
  247. data/scripts/test/render-parity/ongoing-product.json +5 -0
  248. data/scripts/test/render-parity/plotly.json +5 -0
  249. data/scripts/test/render-parity/poem.json +5 -0
  250. data/scripts/test/render-parity/run-absent-field-checks.mjs +232 -0
  251. data/scripts/test/render-parity/run-block-fixture.mjs +86 -0
  252. data/scripts/test/render-parity/run-detail-parity-checks.mjs +123 -0
  253. data/scripts/test/render-parity/run-field-flow-checks.mjs +227 -0
  254. data/scripts/test/render-parity/run-fixture.mjs +159 -0
  255. data/scripts/test/render-parity/selectors.mjs +43 -0
  256. data/scripts/test/render-parity/video.json +5 -0
  257. data/scripts/test/render-parity/youtube-short.json +10 -0
  258. data/scripts/test/render-parity/youtube.json +8 -0
  259. data/scripts/test/test-absent-field-absent-element.rb +43 -0
  260. data/scripts/test/test-cdn-pins.rb +44 -0
  261. data/scripts/test/test-cloudflare-watch-paths-doc.rb +31 -0
  262. data/scripts/test/test-configuration-doc.rb +36 -0
  263. data/scripts/test/test-content-detail-urls.rb +346 -0
  264. data/scripts/test/test-content-keys.rb +110 -0
  265. data/scripts/test/test-content-license.rb +45 -0
  266. data/scripts/test/test-content-render-parity.rb +38 -0
  267. data/scripts/test/test-content-validators.rb +12 -0
  268. data/scripts/test/test-css-structure.rb +72 -0
  269. data/scripts/test/test-cv-export-contract.rb +158 -0
  270. data/scripts/test/test-detail-render-parity.rb +40 -0
  271. data/scripts/test/test-dev-reexport.rb +122 -0
  272. data/scripts/test/test-doctor.rb +39 -0
  273. data/scripts/test/test-dual-template-instances.rb +87 -0
  274. data/scripts/test/test-dual-template-runtime.rb +167 -0
  275. data/scripts/test/test-examples-build.rb +60 -0
  276. data/scripts/test/test-export-content-json-incremental.rb +138 -0
  277. data/scripts/test/test-export.rb +53 -0
  278. data/scripts/test/test-exported-fields-reach-the-view.rb +60 -0
  279. data/scripts/test/test-form-control-theming.rb +143 -0
  280. data/scripts/test/test-git-chronology.rb +414 -0
  281. data/scripts/test/test-hero-portraits.mjs +97 -0
  282. data/scripts/test/test-hero-portraits.rb +162 -0
  283. data/scripts/test/test-home-band-art.rb +135 -0
  284. data/scripts/test/test-install-functions.rb +39 -0
  285. data/scripts/test/test-language-export.rb +79 -0
  286. data/scripts/test/test-listing-filter-contract.rb +119 -0
  287. data/scripts/test/test-math-row-breaks.mjs +79 -0
  288. data/scripts/test/test-math-row-breaks.rb +18 -0
  289. data/scripts/test/test-new.rb +113 -0
  290. data/scripts/test/test-no-personal-data.rb +85 -0
  291. data/scripts/test/test-public-cv-export.rb +164 -0
  292. data/scripts/test/test-r2-config-consistency.rb +57 -0
  293. data/scripts/test/test-r2-json-sync.rb +193 -0
  294. data/scripts/test/test-registry-home.rb +34 -0
  295. data/scripts/test/test-runtime-js-syntax.rb +64 -0
  296. data/scripts/test/test-slim-writing-index.rb +126 -0
  297. data/scripts/test/test-studio-api-contract.rb +99 -0
  298. data/scripts/test/test-studio-content-files.rb +79 -0
  299. data/scripts/test/test-studio-liquid-roundtrip.mjs +179 -0
  300. data/scripts/test/test-studio-rate-limit.mjs +70 -0
  301. data/scripts/test/test-studio-rewrite-api-contract.rb +59 -0
  302. data/scripts/test/test-studio-rewrite-sanitize.mjs +210 -0
  303. data/scripts/test/test-studio-schema-composition.rb +126 -0
  304. data/scripts/test/test-studio-slug-config.rb +167 -0
  305. data/scripts/test/test-template-packages.rb +51 -0
  306. data/scripts/test/test-text-excerpt.rb +51 -0
  307. data/scripts/test/test-unwrap-media-src.mjs +39 -0
  308. data/scripts/test/test-unwrap-media-src.rb +19 -0
  309. data/scripts/test/test-withdrawn-content-export.rb +69 -0
  310. data/scripts/test/test-writing-index-export-slugs.rb +84 -0
  311. data/scripts/test/test-writing-slug-limits.rb +61 -0
  312. data/scripts/test/test-xcite-chronology.rb +106 -0
  313. data/scripts/validate.sh +74 -0
  314. data/scripts/visual/build-cv-pages-json.js +33 -0
  315. data/scripts/visual/build-site-pages-json.js +94 -0
  316. data/scripts/visual/publish-visual-inspection-to-r2.sh +42 -0
  317. data/scripts/visual/take-screenshots.js +88 -0
  318. data/scripts/visual/visual-inspection-pages.json +113 -0
  319. data/studio/assets/index-B_j0pMD5.css +1 -0
  320. data/studio/assets/index-Ch4YEv9g.js +285 -0
  321. data/studio/index.html +27 -0
  322. data/studio/schema.yml +869 -0
  323. data/studio-app/README.md +27 -0
  324. data/studio-app/index.html +26 -0
  325. data/studio-app/package-lock.json +2499 -0
  326. data/studio-app/package.json +26 -0
  327. data/studio-app/skills-lock.json +47 -0
  328. data/studio-app/src/api/client.js +71 -0
  329. data/studio-app/src/editor/body-editor.js +70 -0
  330. data/studio-app/src/editor/liquid-core.js +382 -0
  331. data/studio-app/src/editor/liquid.js +77 -0
  332. data/studio-app/src/lib/document.js +207 -0
  333. data/studio-app/src/lib/list-controls.js +317 -0
  334. data/studio-app/src/lib/schema.js +66 -0
  335. data/studio-app/src/lib/yaml-doc.js +100 -0
  336. data/studio-app/src/main.js +3693 -0
  337. data/studio-app/src/media/browser.js +578 -0
  338. data/studio-app/src/preview/render.js +430 -0
  339. data/studio-app/src/styles/studio.css +2826 -0
  340. data/studio-app/vite.config.js +25 -0
  341. data/templates/articles/README.md +40 -0
  342. data/templates/articles/fixtures/article.md +9 -0
  343. data/templates/articles/screenshots/README.md +6 -0
  344. data/templates/articles/studio.yml +73 -0
  345. data/templates/articles/template.yml +21 -0
  346. data/templates/bibliography/README.md +41 -0
  347. data/templates/bibliography/fixtures/references.json +9 -0
  348. data/templates/bibliography/screenshots/README.md +6 -0
  349. data/templates/bibliography/studio.yml +66 -0
  350. data/templates/bibliography/template.yml +18 -0
  351. data/templates/blog/README.md +38 -0
  352. data/templates/blog/fixtures/post.md +9 -0
  353. data/templates/blog/screenshots/README.md +6 -0
  354. data/templates/blog/studio.yml +69 -0
  355. data/templates/blog/template.yml +20 -0
  356. data/templates/cv/README.md +34 -0
  357. data/templates/cv/fixtures/job.md +12 -0
  358. data/templates/cv/screenshots/README.md +6 -0
  359. data/templates/cv/studio.yml +200 -0
  360. data/templates/cv/template.yml +25 -0
  361. data/templates/media/README.md +38 -0
  362. data/templates/media/fixtures/resource.md +7 -0
  363. data/templates/media/screenshots/README.md +6 -0
  364. data/templates/media/studio.yml +58 -0
  365. data/templates/media/template.yml +17 -0
  366. data/templates/network/README.md +34 -0
  367. data/templates/network/fixtures/sources.json +3 -0
  368. data/templates/network/screenshots/README.md +6 -0
  369. data/templates/network/studio.yml +28 -0
  370. data/templates/network/template.yml +10 -0
  371. data/templates/portfolio/README.md +39 -0
  372. data/templates/portfolio/fixtures/project.md +10 -0
  373. data/templates/portfolio/screenshots/README.md +6 -0
  374. data/templates/portfolio/studio.yml +89 -0
  375. data/templates/portfolio/template.yml +18 -0
  376. metadata +505 -0
@@ -0,0 +1,562 @@
1
+ /**
2
+ * Studio API — Cloudflare Pages Function.
3
+ * Spec: docs/features/studio.md §5 (AC-STU-01, AC-STU-02, AC-STU-09).
4
+ */
5
+
6
+ import { verifyClerkRequest } from "./_lib/auth.js";
7
+ import {
8
+ getFile,
9
+ putFile,
10
+ putBinaryFile,
11
+ getFileMeta,
12
+ listDir,
13
+ listEntries,
14
+ peekMeta,
15
+ } from "./_lib/github.js";
16
+ import { normalizeRepoPath, listSchemaCollections } from "./_lib/paths.js";
17
+ import { clientAddress, isMutatingMethod, takeToken } from "./_lib/rate-limit.js";
18
+ import { rewriteRateLimit, runRewrite } from "./_lib/rewrite.js";
19
+
20
+ const WRITE_RATE = new Map();
21
+ const WRITE_WINDOW_MS = 60_000;
22
+ const WRITE_MAX = 60;
23
+
24
+ /** Soft cap under GitHub Contents API 100 MB hard limit (base64 JSON body). */
25
+ const MAX_MEDIA_BYTES = 25 * 1024 * 1024;
26
+ const MEDIA_ROOT = "content/media";
27
+ const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
28
+
29
+ /** Bound per-request in onRequest so corsHeaders/json stay signature-stable. */
30
+ let requestEnv = {};
31
+
32
+ function allowedOrigins() {
33
+ const fromEnv = String(requestEnv.STUDIO_ALLOWED_ORIGINS || "")
34
+ .split(",")
35
+ .map((s) => s.trim())
36
+ .filter(Boolean);
37
+ // Local wrangler / jekyll serve always allowed; production must set the list.
38
+ return new Set([
39
+ ...fromEnv,
40
+ "http://localhost:4000",
41
+ "http://127.0.0.1:4000",
42
+ "http://localhost:8788",
43
+ "http://127.0.0.1:8788",
44
+ ]);
45
+ }
46
+
47
+ function corsHeaders(request) {
48
+ const origin = request.headers.get("Origin") || "";
49
+ const allow =
50
+ !origin ||
51
+ origin.includes("localhost") ||
52
+ origin.includes("127.0.0.1") ||
53
+ allowedOrigins().has(origin);
54
+ return {
55
+ "Access-Control-Allow-Origin": allow ? origin || "*" : "null",
56
+ "Access-Control-Allow-Headers": "Authorization, Content-Type",
57
+ "Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS",
58
+ "Access-Control-Max-Age": "86400",
59
+ };
60
+ }
61
+
62
+ function json(data, status, request, extraHeaders) {
63
+ return new Response(JSON.stringify(data), {
64
+ status,
65
+ headers: {
66
+ "Content-Type": "application/json; charset=utf-8",
67
+ "Cache-Control": "no-store",
68
+ ...corsHeaders(request),
69
+ ...(extraHeaders || {}),
70
+ },
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Spec §4 throttles mutating routes. Counting every GET (tree prefetch, open,
76
+ * cite search) shared one 60/min bucket with Save, so a normal editing
77
+ * session returned `rate_limited` on the commit.
78
+ */
79
+ function tooManyChanges(request, userId) {
80
+ const decision = takeToken(
81
+ WRITE_RATE,
82
+ `${userId}:${clientAddress(request)}`,
83
+ Date.now(),
84
+ { windowMs: WRITE_WINDOW_MS, max: WRITE_MAX }
85
+ );
86
+ if (decision.allowed) return null;
87
+ return json(
88
+ {
89
+ error: "rate_limited",
90
+ message: "Too many changes in a short time. Wait a minute, then save again.",
91
+ },
92
+ 429,
93
+ request,
94
+ { "Retry-After": String(decision.retryAfterSec) }
95
+ );
96
+ }
97
+
98
+ async function loadSchema(env) {
99
+ // Prefer in-repo schema via GitHub so the Function does not ship a stale copy.
100
+ try {
101
+ const file = await getFile(env, "studio/schema.yml");
102
+ if (file?.content) {
103
+ return parseMinimalYamlCollections(file.content);
104
+ }
105
+ } catch {
106
+ /* fall through */
107
+ }
108
+ return { content: [] };
109
+ }
110
+
111
+ /** Tiny YAML subset sufficient for tree routing (name/path/type nesting). */
112
+ function parseMinimalYamlCollections(text) {
113
+ // For tree listing we only need collection path map; the SPA ships full schema.
114
+ // Keep Function free of a YAML dependency: SPA sends collection path, we validate prefix.
115
+ void text;
116
+ return { content: [] };
117
+ }
118
+
119
+ function routeParts(context) {
120
+ const raw = context.params.path;
121
+ if (Array.isArray(raw)) return raw.filter(Boolean);
122
+ if (typeof raw === "string" && raw.length) return raw.split("/").filter(Boolean);
123
+ return [];
124
+ }
125
+
126
+ export async function onRequestOptions(context) {
127
+ requestEnv = context.env || {};
128
+ return new Response(null, { status: 204, headers: corsHeaders(context.request) });
129
+ }
130
+
131
+ export async function onRequest(context) {
132
+ const { request, env } = context;
133
+ requestEnv = env || {};
134
+ if (request.method === "OPTIONS") {
135
+ return onRequestOptions(context);
136
+ }
137
+
138
+ const auth = await verifyClerkRequest(request, env);
139
+ if (auth.error) {
140
+ const body = { error: auth.error };
141
+ if (auth.email) body.email = auth.email;
142
+ if (auth.userId) body.userId = auth.userId;
143
+ return json(body, auth.status || 401, request);
144
+ }
145
+ if (isMutatingMethod(request.method)) {
146
+ const limited = tooManyChanges(request, auth.userId);
147
+ if (limited) return limited;
148
+ }
149
+
150
+ const parts = routeParts(context);
151
+ const head = parts[0] || "";
152
+
153
+ try {
154
+ if (head === "session" && request.method === "GET") {
155
+ return json(
156
+ { userId: auth.userId, email: auth.email || null, bypass: !!auth.bypass },
157
+ 200,
158
+ request
159
+ );
160
+ }
161
+
162
+ if (head === "tree" && request.method === "GET") {
163
+ const url = new URL(request.url);
164
+ const collectionPath = normalizeRepoPath(url.searchParams.get("path") || "");
165
+ if (!collectionPath) {
166
+ return json({ error: "invalid_path" }, 400, request);
167
+ }
168
+ // Collection roots are directories under content/
169
+ if (!collectionPath.startsWith("content/")) {
170
+ return json({ error: "invalid_path" }, 400, request);
171
+ }
172
+ const entries = await listDir(env, collectionPath);
173
+ const files = entries
174
+ .filter((e) => /\.(md|markdown|yml|yaml)$/i.test(e.name))
175
+ .slice(0, 500);
176
+ // YAML cite keys (bibliography): list from directory only — per-file getFile
177
+ // on hundreds of entries times out / rate-limits (N+1 Contents API).
178
+ const yamlOnly = files.length > 0 && files.every((e) => /\.ya?ml$/i.test(e.name));
179
+ const items = [];
180
+ if (yamlOnly) {
181
+ for (const entry of files) {
182
+ const stem = entry.name.replace(/\.ya?ml$/i, "");
183
+ items.push({
184
+ path: entry.path,
185
+ name: entry.name,
186
+ title: stem,
187
+ date: null,
188
+ start_date: null,
189
+ end_date: null,
190
+ start: null,
191
+ end: null,
192
+ thumbnail: null,
193
+ key: stem,
194
+ published: null,
195
+ language: null,
196
+ category: null,
197
+ type: null,
198
+ company: null,
199
+ status: null,
200
+ engagement: null,
201
+ tags: null,
202
+ sha: entry.sha,
203
+ });
204
+ }
205
+ items.sort((a, b) => String(a.name).localeCompare(String(b.name)));
206
+ } else {
207
+ // Markdown: peek front matter in small parallel batches.
208
+ const CONCURRENCY = 8;
209
+ for (let i = 0; i < files.length; i += CONCURRENCY) {
210
+ const batch = files.slice(i, i + CONCURRENCY);
211
+ const peeked = await Promise.all(
212
+ batch.map(async (entry) => {
213
+ let meta = {};
214
+ try {
215
+ const file = await getFile(env, entry.path);
216
+ meta = peekMeta(file.content);
217
+ } catch {
218
+ meta = {};
219
+ }
220
+ return {
221
+ path: entry.path,
222
+ name: entry.name,
223
+ title: meta.title || entry.name,
224
+ date: meta.date,
225
+ start_date: meta.start_date,
226
+ end_date: meta.end_date,
227
+ start: meta.start,
228
+ end: meta.end,
229
+ thumbnail: meta.thumbnail,
230
+ key: meta.key,
231
+ published: meta.published,
232
+ language: meta.language,
233
+ category: meta.category,
234
+ type: meta.type,
235
+ company: meta.company,
236
+ status: meta.status,
237
+ engagement: meta.engagement,
238
+ tags: meta.tags,
239
+ sha: entry.sha,
240
+ };
241
+ })
242
+ );
243
+ items.push(...peeked);
244
+ }
245
+ items.sort((a, b) => String(b.date || "").localeCompare(String(a.date || "")));
246
+ }
247
+ return json({ items }, 200, request);
248
+ }
249
+
250
+ if (head === "file" && request.method === "GET") {
251
+ const url = new URL(request.url);
252
+ const path = normalizeRepoPath(url.searchParams.get("path") || "");
253
+ if (!path) return json({ error: "invalid_path" }, 400, request);
254
+ const file = await getFile(env, path);
255
+ if (!file) return json({ error: "not_found" }, 404, request);
256
+ const parsed = splitFrontMatter(file.content);
257
+ return json(
258
+ {
259
+ path: file.path,
260
+ sha: file.sha,
261
+ content: file.content,
262
+ front_matter: parsed.frontMatter,
263
+ body: parsed.body,
264
+ },
265
+ 200,
266
+ request
267
+ );
268
+ }
269
+
270
+ if (head === "file" && request.method === "PUT") {
271
+ const body = await request.json();
272
+ const path = normalizeRepoPath(body.path || "");
273
+ if (!path) return json({ error: "invalid_path" }, 400, request);
274
+ if (typeof body.content !== "string") {
275
+ return json({ error: "content_required" }, 400, request);
276
+ }
277
+ let sha = body.sha || null;
278
+ if (!sha) {
279
+ const existing = await getFile(env, path);
280
+ sha = existing?.sha || null;
281
+ }
282
+ const result = await putFile(env, {
283
+ path,
284
+ content: body.content,
285
+ message: body.message,
286
+ sha,
287
+ });
288
+ return json(
289
+ {
290
+ ok: true,
291
+ path,
292
+ commit: result.commit?.sha || null,
293
+ content: result.content || null,
294
+ },
295
+ 200,
296
+ request
297
+ );
298
+ }
299
+
300
+ if (head === "media" && request.method === "GET") {
301
+ const url = new URL(request.url);
302
+ const rawPath = url.searchParams.get("path") || MEDIA_ROOT;
303
+ const dirPath = normalizeMediaDir(rawPath);
304
+ if (!dirPath) {
305
+ return json({ error: "invalid_media_path" }, 400, request);
306
+ }
307
+ const entries = await listEntries(env, dirPath);
308
+ const items = entries
309
+ .filter((e) => e.name !== ".gitkeep")
310
+ .map((e) => ({
311
+ name: e.name,
312
+ path: e.path,
313
+ type: e.type,
314
+ size: e.size,
315
+ sha: e.sha,
316
+ public_path: e.type === "file" ? publicMediaPath(e.path) : null,
317
+ }))
318
+ .sort((a, b) => {
319
+ if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
320
+ return String(a.name).localeCompare(String(b.name));
321
+ });
322
+ return json(
323
+ {
324
+ root: MEDIA_ROOT,
325
+ path: dirPath,
326
+ public_root: "/media",
327
+ items,
328
+ },
329
+ 200,
330
+ request
331
+ );
332
+ }
333
+
334
+ if (head === "media" && parts[1] === "folder" && request.method === "POST") {
335
+ const body = await request.json();
336
+ const dirPath = normalizeMediaDir(body.path || "");
337
+ if (!dirPath || dirPath === MEDIA_ROOT) {
338
+ return json({ error: "invalid_media_path" }, 400, request);
339
+ }
340
+ const keepPath = `${dirPath}/.gitkeep`;
341
+ const existing = await getFileMeta(env, keepPath);
342
+ if (existing) {
343
+ return json(
344
+ { ok: true, path: dirPath, public_path: publicMediaPath(dirPath), existed: true },
345
+ 200,
346
+ request
347
+ );
348
+ }
349
+ const result = await putFile(env, {
350
+ path: keepPath,
351
+ content: "\n",
352
+ message: body.message || `studio: mkdir ${dirPath}`,
353
+ sha: null,
354
+ });
355
+ return json(
356
+ {
357
+ ok: true,
358
+ path: dirPath,
359
+ public_path: publicMediaPath(dirPath),
360
+ commit: result.commit?.sha || null,
361
+ existed: false,
362
+ },
363
+ 200,
364
+ request
365
+ );
366
+ }
367
+
368
+ if (head === "media" && request.method === "POST") {
369
+ const body = await request.json();
370
+ const path = normalizeMediaFile(body.path || "");
371
+ if (!path) {
372
+ return json({ error: "invalid_media_path" }, 400, request);
373
+ }
374
+ if (typeof body.content_base64 !== "string" || !body.content_base64) {
375
+ return json({ error: "content_base64_required" }, 400, request);
376
+ }
377
+ let byteLength = 0;
378
+ try {
379
+ byteLength = Math.floor((body.content_base64.replace(/\s/g, "").length * 3) / 4);
380
+ } catch {
381
+ return json({ error: "invalid_base64" }, 400, request);
382
+ }
383
+ if (byteLength > MAX_MEDIA_BYTES) {
384
+ return json(
385
+ {
386
+ error: "file_too_large",
387
+ max_bytes: MAX_MEDIA_BYTES,
388
+ size_bytes: byteLength,
389
+ },
390
+ 413,
391
+ request
392
+ );
393
+ }
394
+ const existing = await getFileMeta(env, path);
395
+ const result = await putBinaryFile(env, {
396
+ path,
397
+ contentBase64: body.content_base64.replace(/\s/g, ""),
398
+ message: body.message || `studio: upload ${path}`,
399
+ sha: existing?.sha || null,
400
+ });
401
+ return json(
402
+ {
403
+ ok: true,
404
+ path,
405
+ public_path: publicMediaPath(path),
406
+ commit: result.commit?.sha || null,
407
+ size_bytes: byteLength,
408
+ },
409
+ 200,
410
+ request
411
+ );
412
+ }
413
+
414
+ if (head === "search" && parts[1] === "xcite" && request.method === "GET") {
415
+ const q = String(new URL(request.url).searchParams.get("q") || "")
416
+ .trim()
417
+ .toLowerCase();
418
+ const indexPath = "content/collections"; // search filenames + keys via tree peeks
419
+ const results = await searchKeys(env, q, ["articles", "posts", "products", "projects"]);
420
+ return json({ results }, 200, request);
421
+ }
422
+
423
+ if (head === "search" && parts[1] === "cite" && request.method === "GET") {
424
+ const q = String(new URL(request.url).searchParams.get("q") || "")
425
+ .trim()
426
+ .toLowerCase();
427
+ const entries = await listDir(env, "content/collections/bibliography");
428
+ const results = entries
429
+ .map((e) => e.name.replace(/\.ya?ml$/i, ""))
430
+ .filter((k) => !q || k.toLowerCase().includes(q))
431
+ .slice(0, 30)
432
+ .map((key) => ({ key, label: key }));
433
+ return json({ results }, 200, request);
434
+ }
435
+
436
+ if (head === "schema" && request.method === "GET") {
437
+ const file = await getFile(env, "studio/schema.yml");
438
+ if (!file) return json({ error: "schema_missing" }, 404, request);
439
+ return json({ yaml: file.content }, 200, request);
440
+ }
441
+
442
+ // AC-PRF-01…17 — Correct / Refine (docs/features/studio-proof.md).
443
+ if (head === "rewrite" && request.method === "POST") {
444
+ const rewriteBudget = rewriteRateLimit(request, auth.userId);
445
+ if (!rewriteBudget.allowed) {
446
+ return json(
447
+ {
448
+ error: "rate_limited",
449
+ message: "AI Proof is limited to a few runs a minute. Wait, then try again.",
450
+ },
451
+ 429,
452
+ request,
453
+ { "Retry-After": String(rewriteBudget.retryAfterSec) }
454
+ );
455
+ }
456
+ let body;
457
+ try {
458
+ body = await request.json();
459
+ } catch {
460
+ return json({ error: "invalid_body" }, 400, request);
461
+ }
462
+ try {
463
+ const result = await runRewrite(env, body);
464
+ return json(result, 200, request);
465
+ } catch (err) {
466
+ const code = err.code || "server_error";
467
+ const status = err.status || 500;
468
+ return json({ error: code, message: String(err.message || err) }, status, request);
469
+ }
470
+ }
471
+
472
+ void listSchemaCollections;
473
+ return json({ error: "not_found", path: parts }, 404, request);
474
+ } catch (err) {
475
+ const status = err.status || 500;
476
+ const body = {
477
+ error: err.code || "server_error",
478
+ message: String(err.message || err),
479
+ };
480
+ if (err.detail) body.detail = String(err.detail).slice(0, 400);
481
+ return json(body, status, request);
482
+ }
483
+ }
484
+
485
+ function splitFrontMatter(raw) {
486
+ const text = String(raw || "");
487
+ if (!text.startsWith("---")) {
488
+ return { frontMatter: {}, body: text, rawFm: "" };
489
+ }
490
+ const end = text.indexOf("\n---", 3);
491
+ if (end < 0) return { frontMatter: {}, body: text, rawFm: "" };
492
+ const rawFm = text.slice(4, end).trimEnd();
493
+ const body = text.slice(end + 4).replace(/^\r?\n/, "");
494
+ const frontMatter = {};
495
+ for (const line of rawFm.split(/\r?\n/)) {
496
+ const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
497
+ if (m && !m[2].startsWith("|") && !m[2].startsWith(">") && m[2] !== "") {
498
+ let v = m[2].trim();
499
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
500
+ v = v.slice(1, -1);
501
+ }
502
+ if (v === "true") v = true;
503
+ else if (v === "false") v = false;
504
+ frontMatter[m[1]] = v;
505
+ }
506
+ }
507
+ return { frontMatter, body, rawFm };
508
+ }
509
+
510
+ /** Repo path → cite path (`content/media/images/x.png` → `/media/images/x.png`). */
511
+ function publicMediaPath(repoPath) {
512
+ const path = String(repoPath || "");
513
+ if (path === MEDIA_ROOT || path.startsWith(`${MEDIA_ROOT}/`)) {
514
+ return path.replace(/^content/, "") || "/media";
515
+ }
516
+ return null;
517
+ }
518
+
519
+ function mediaSegmentsOk(path) {
520
+ const rest = path.slice(MEDIA_ROOT.length).replace(/^\//, "");
521
+ if (!rest) return true;
522
+ return rest.split("/").every((seg) => SAFE_SEGMENT.test(seg));
523
+ }
524
+
525
+ /** Directory under content/media (inclusive of root). */
526
+ function normalizeMediaDir(raw) {
527
+ let path = normalizeRepoPath(raw);
528
+ if (!path) return null;
529
+ path = path.replace(/\/+$/, "");
530
+ if (path === MEDIA_ROOT) return MEDIA_ROOT;
531
+ if (!path.startsWith(`${MEDIA_ROOT}/`)) return null;
532
+ if (!mediaSegmentsOk(path)) return null;
533
+ return path;
534
+ }
535
+
536
+ /** File path under content/media (not a bare directory root). */
537
+ function normalizeMediaFile(raw) {
538
+ const path = normalizeRepoPath(raw);
539
+ if (!path || !path.startsWith(`${MEDIA_ROOT}/`)) return null;
540
+ if (path.endsWith("/")) return null;
541
+ if (!mediaSegmentsOk(path)) return null;
542
+ const name = path.split("/").pop();
543
+ if (!name || name === ".gitkeep") return null;
544
+ return path;
545
+ }
546
+
547
+ async function searchKeys(env, q, collections) {
548
+ const results = [];
549
+ for (const name of collections) {
550
+ const entries = await listDir(env, `content/collections/${name}`);
551
+ for (const entry of entries) {
552
+ const stem = entry.name.replace(/\.(md|markdown)$/i, "");
553
+ const keyGuess = stem.includes("-") ? stem.replace(/^\d{4}-\d{2}-\d{2}-/, "") : stem;
554
+ if (q && !keyGuess.toLowerCase().includes(q) && !stem.toLowerCase().includes(q)) {
555
+ continue;
556
+ }
557
+ results.push({ key: keyGuess, path: entry.path, collection: name, label: keyGuess });
558
+ if (results.length >= 30) return results;
559
+ }
560
+ }
561
+ return results;
562
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Clerk JWT verification + allowlist. Spec: docs/features/studio.md §4.
3
+ */
4
+
5
+ function parseAllowlist(raw) {
6
+ return String(raw || "")
7
+ .split(/[,\s]+/)
8
+ .map((s) => s.trim().toLowerCase())
9
+ .filter(Boolean);
10
+ }
11
+
12
+ async function fetchJwks(publishableOrSecretHint, env) {
13
+ // Prefer explicit issuer; fall back to Clerk Frontend API from publishable key.
14
+ const issuer =
15
+ env.CLERK_JWT_ISSUER ||
16
+ env.CLERK_FRONTEND_API ||
17
+ inferIssuerFromPublishable(env.CLERK_PUBLISHABLE_KEY);
18
+ if (!issuer) {
19
+ throw new Error("CLERK_JWT_ISSUER or CLERK_PUBLISHABLE_KEY required");
20
+ }
21
+ const base = issuer.replace(/\/$/, "");
22
+ const res = await fetch(`${base}/.well-known/jwks.json`);
23
+ if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
24
+ return { jwks: await res.json(), issuer: base };
25
+ }
26
+
27
+ function inferIssuerFromPublishable(key) {
28
+ // pk_test_… / pk_live_… — Frontend API host is not fully recoverable from
29
+ // the key alone in all Clerk versions; prefer CLERK_JWT_ISSUER.
30
+ void key;
31
+ return null;
32
+ }
33
+
34
+ function b64urlToBytes(str) {
35
+ const pad = "=".repeat((4 - (str.length % 4)) % 4);
36
+ const b64 = (str + pad).replace(/-/g, "+").replace(/_/g, "/");
37
+ const bin = atob(b64);
38
+ const bytes = new Uint8Array(bin.length);
39
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
40
+ return bytes;
41
+ }
42
+
43
+ function decodeJwtPart(part) {
44
+ return JSON.parse(new TextDecoder().decode(b64urlToBytes(part)));
45
+ }
46
+
47
+ async function importClerkKey(jwk) {
48
+ return crypto.subtle.importKey(
49
+ "jwk",
50
+ jwk,
51
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
52
+ false,
53
+ ["verify"]
54
+ );
55
+ }
56
+
57
+ export async function verifyClerkRequest(request, env) {
58
+ if (env.STUDIO_DEV_BYPASS === "1" && env.ENVIRONMENT !== "production") {
59
+ return {
60
+ userId: "dev_bypass",
61
+ email: "dev@localhost",
62
+ bypass: true,
63
+ };
64
+ }
65
+
66
+ const header = request.headers.get("Authorization") || "";
67
+ const match = header.match(/^Bearer\s+(.+)$/i);
68
+ if (!match) {
69
+ return { error: "missing_bearer", status: 401 };
70
+ }
71
+ const token = match[1].trim();
72
+ const parts = token.split(".");
73
+ if (parts.length !== 3) {
74
+ return { error: "malformed_jwt", status: 401 };
75
+ }
76
+
77
+ let headerJson;
78
+ let payload;
79
+ try {
80
+ headerJson = decodeJwtPart(parts[0]);
81
+ payload = decodeJwtPart(parts[1]);
82
+ } catch {
83
+ return { error: "malformed_jwt", status: 401 };
84
+ }
85
+
86
+ const { jwks, issuer } = await fetchJwks(null, env);
87
+ const jwk = (jwks.keys || []).find((k) => k.kid === headerJson.kid) || jwks.keys?.[0];
88
+ if (!jwk) {
89
+ return { error: "no_jwk", status: 401 };
90
+ }
91
+
92
+ const key = await importClerkKey(jwk);
93
+ const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
94
+ const sig = b64urlToBytes(parts[2]);
95
+ const ok = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, sig, data);
96
+ if (!ok) {
97
+ return { error: "bad_signature", status: 401 };
98
+ }
99
+
100
+ const now = Math.floor(Date.now() / 1000);
101
+ if (payload.exp && payload.exp < now) {
102
+ return { error: "expired", status: 401 };
103
+ }
104
+ if (payload.nbf && payload.nbf > now + 5) {
105
+ return { error: "not_yet_valid", status: 401 };
106
+ }
107
+ if (issuer && payload.iss && !String(payload.iss).replace(/\/$/, "").startsWith(issuer)) {
108
+ // Soft check: some Clerk tokens use https://clerk.… vs Frontend API host.
109
+ // Still require allowlist match below.
110
+ }
111
+
112
+ const userId = String(payload.sub || "");
113
+ const email = String(
114
+ payload.email ||
115
+ payload.primary_email_address ||
116
+ payload.email_address ||
117
+ ""
118
+ ).toLowerCase();
119
+
120
+ const allow = parseAllowlist(env.STUDIO_ALLOWLIST);
121
+ if (!allow.length) {
122
+ return { error: "allowlist_empty", status: 403, userId, email };
123
+ }
124
+ const allowed =
125
+ allow.includes(userId.toLowerCase()) || (email && allow.includes(email));
126
+ if (!allowed) {
127
+ return { error: "not_allowlisted", status: 403, userId, email };
128
+ }
129
+
130
+ return { userId, email, payload };
131
+ }