@repo-os/repoos 0.5.45
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.
- package/LICENSE.md +110 -0
- package/README.md +272 -0
- package/dist/.build-info.json +4 -0
- package/dist/.build-stamp.json +3 -0
- package/dist/cli/colors.d.ts +13 -0
- package/dist/cli/colors.js +37 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +221 -0
- package/dist/commands/check.d.ts +170 -0
- package/dist/commands/check.js +1227 -0
- package/dist/commands/docs.d.ts +2 -0
- package/dist/commands/docs.js +70 -0
- package/dist/commands/gc.d.ts +1 -0
- package/dist/commands/gc.js +57 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +699 -0
- package/dist/commands/screenshots.d.ts +7 -0
- package/dist/commands/screenshots.js +77 -0
- package/dist/commands/serve.d.ts +55 -0
- package/dist/commands/serve.js +204 -0
- package/dist/commands/status.d.ts +140 -0
- package/dist/commands/status.js +569 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +59 -0
- package/dist/commands/tasks.d.ts +47 -0
- package/dist/commands/tasks.js +334 -0
- package/dist/commands/tunnel.d.ts +1 -0
- package/dist/commands/tunnel.js +1271 -0
- package/dist/commands/ui-harness.d.ts +53 -0
- package/dist/commands/ui-harness.js +62 -0
- package/dist/commands/ui-smoke.d.ts +6 -0
- package/dist/commands/ui-smoke.js +220 -0
- package/dist/commands/upgrade.d.ts +15 -0
- package/dist/commands/upgrade.js +188 -0
- package/dist/core/auth-store.d.ts +81 -0
- package/dist/core/auth-store.js +530 -0
- package/dist/core/auth.d.ts +75 -0
- package/dist/core/auth.js +124 -0
- package/dist/core/bootstrap.d.ts +41 -0
- package/dist/core/bootstrap.js +236 -0
- package/dist/core/build.d.ts +92 -0
- package/dist/core/build.js +249 -0
- package/dist/core/canary.d.ts +10 -0
- package/dist/core/canary.js +14 -0
- package/dist/core/check-failure-summary.d.ts +10 -0
- package/dist/core/check-failure-summary.js +34 -0
- package/dist/core/config.d.ts +160 -0
- package/dist/core/config.js +1288 -0
- package/dist/core/context-pack.d.ts +74 -0
- package/dist/core/context-pack.js +778 -0
- package/dist/core/db.d.ts +236 -0
- package/dist/core/db.js +747 -0
- package/dist/core/detect.d.ts +69 -0
- package/dist/core/detect.js +251 -0
- package/dist/core/docs.d.ts +76 -0
- package/dist/core/docs.js +225 -0
- package/dist/core/frontmatter.d.ts +29 -0
- package/dist/core/frontmatter.js +215 -0
- package/dist/core/git.d.ts +505 -0
- package/dist/core/git.js +1453 -0
- package/dist/core/indexer.d.ts +33 -0
- package/dist/core/indexer.js +282 -0
- package/dist/core/input.d.ts +66 -0
- package/dist/core/input.js +222 -0
- package/dist/core/logger.d.ts +70 -0
- package/dist/core/logger.js +138 -0
- package/dist/core/models.d.ts +45 -0
- package/dist/core/models.js +291 -0
- package/dist/core/net-probe.d.ts +4 -0
- package/dist/core/net-probe.js +20 -0
- package/dist/core/providers/deepinfra.d.ts +23 -0
- package/dist/core/providers/deepinfra.js +44 -0
- package/dist/core/providers/index.d.ts +24 -0
- package/dist/core/providers/index.js +59 -0
- package/dist/core/providers/openrouter.d.ts +28 -0
- package/dist/core/providers/openrouter.js +82 -0
- package/dist/core/providers/spend.d.ts +86 -0
- package/dist/core/providers/spend.js +286 -0
- package/dist/core/providers/types.d.ts +56 -0
- package/dist/core/providers/types.js +39 -0
- package/dist/core/repoos.d.ts +59 -0
- package/dist/core/repoos.js +221 -0
- package/dist/core/runtime.d.ts +35 -0
- package/dist/core/runtime.js +167 -0
- package/dist/core/skills-registry.d.ts +39 -0
- package/dist/core/skills-registry.js +148 -0
- package/dist/core/skills-registry.test.d.ts +1 -0
- package/dist/core/skills-registry.test.js +52 -0
- package/dist/core/sysmem.d.ts +7 -0
- package/dist/core/sysmem.js +55 -0
- package/dist/core/tailscale.d.ts +2 -0
- package/dist/core/tailscale.js +19 -0
- package/dist/core/task.d.ts +53 -0
- package/dist/core/task.js +310 -0
- package/dist/core/toml-line.d.ts +20 -0
- package/dist/core/toml-line.js +52 -0
- package/dist/core/toml-validate.d.ts +44 -0
- package/dist/core/toml-validate.js +335 -0
- package/dist/core/tunnel-assistant.d.ts +30 -0
- package/dist/core/tunnel-assistant.js +65 -0
- package/dist/core/tunnel-registry.d.ts +96 -0
- package/dist/core/tunnel-registry.js +267 -0
- package/dist/core/tunnel-registry.test.d.ts +1 -0
- package/dist/core/tunnel-registry.test.js +309 -0
- package/dist/core/tunnel.d.ts +103 -0
- package/dist/core/tunnel.js +320 -0
- package/dist/core/types.d.ts +811 -0
- package/dist/core/types.js +27 -0
- package/dist/core/worktree-gc.d.ts +57 -0
- package/dist/core/worktree-gc.js +194 -0
- package/dist/server/__tests__/integration-job.test.d.ts +1 -0
- package/dist/server/__tests__/integration-job.test.js +129 -0
- package/dist/server/agents.d.ts +1020 -0
- package/dist/server/agents.js +4070 -0
- package/dist/server/attachments.d.ts +51 -0
- package/dist/server/attachments.js +130 -0
- package/dist/server/auto-engineering.d.ts +45 -0
- package/dist/server/auto-engineering.js +275 -0
- package/dist/server/branchless-release.d.ts +22 -0
- package/dist/server/branchless-release.js +67 -0
- package/dist/server/built-in-agents.d.ts +305 -0
- package/dist/server/built-in-agents.js +1838 -0
- package/dist/server/cto-monitor.d.ts +35 -0
- package/dist/server/cto-monitor.js +281 -0
- package/dist/server/cto.d.ts +125 -0
- package/dist/server/cto.js +590 -0
- package/dist/server/deployments.d.ts +119 -0
- package/dist/server/deployments.js +325 -0
- package/dist/server/diff-snapshot.d.ts +20 -0
- package/dist/server/diff-snapshot.js +73 -0
- package/dist/server/done.d.ts +160 -0
- package/dist/server/done.js +488 -0
- package/dist/server/freeform.d.ts +55 -0
- package/dist/server/freeform.js +179 -0
- package/dist/server/handoff.d.ts +99 -0
- package/dist/server/handoff.js +575 -0
- package/dist/server/hetzner.d.ts +45 -0
- package/dist/server/hetzner.js +117 -0
- package/dist/server/icons.d.ts +8 -0
- package/dist/server/icons.js +195 -0
- package/dist/server/integration-job.d.ts +75 -0
- package/dist/server/integration-job.js +144 -0
- package/dist/server/integration-orchestrator.d.ts +211 -0
- package/dist/server/integration-orchestrator.js +1299 -0
- package/dist/server/integration-status.d.ts +48 -0
- package/dist/server/integration-status.js +71 -0
- package/dist/server/live-index.d.ts +295 -0
- package/dist/server/live-index.js +452 -0
- package/dist/server/model-test.d.ts +20 -0
- package/dist/server/model-test.js +100 -0
- package/dist/server/ntfy.d.ts +57 -0
- package/dist/server/ntfy.js +112 -0
- package/dist/server/pm-attachments.d.ts +35 -0
- package/dist/server/pm-attachments.js +181 -0
- package/dist/server/pm-runs.d.ts +58 -0
- package/dist/server/pm-runs.js +80 -0
- package/dist/server/preview.d.ts +174 -0
- package/dist/server/preview.js +641 -0
- package/dist/server/release.d.ts +88 -0
- package/dist/server/release.js +449 -0
- package/dist/server/reload.d.ts +194 -0
- package/dist/server/reload.js +586 -0
- package/dist/server/remote-validation.d.ts +132 -0
- package/dist/server/remote-validation.js +478 -0
- package/dist/server/repo-lock.d.ts +36 -0
- package/dist/server/repo-lock.js +141 -0
- package/dist/server/review-guard.d.ts +14 -0
- package/dist/server/review-guard.js +159 -0
- package/dist/server/review.d.ts +243 -0
- package/dist/server/review.js +1211 -0
- package/dist/server/routes/agents.d.ts +6 -0
- package/dist/server/routes/agents.js +28 -0
- package/dist/server/routes/auth.d.ts +60 -0
- package/dist/server/routes/auth.js +755 -0
- package/dist/server/routes/config.d.ts +20 -0
- package/dist/server/routes/config.js +347 -0
- package/dist/server/routes/debugger.d.ts +33 -0
- package/dist/server/routes/debugger.js +360 -0
- package/dist/server/routes/deployments.d.ts +3 -0
- package/dist/server/routes/deployments.js +16 -0
- package/dist/server/routes/docs.d.ts +8 -0
- package/dist/server/routes/docs.js +135 -0
- package/dist/server/routes/helpers.d.ts +21 -0
- package/dist/server/routes/helpers.js +171 -0
- package/dist/server/routes/index.d.ts +19 -0
- package/dist/server/routes/index.js +18 -0
- package/dist/server/routes/info.d.ts +17 -0
- package/dist/server/routes/info.js +177 -0
- package/dist/server/routes/inputs.d.ts +7 -0
- package/dist/server/routes/inputs.js +125 -0
- package/dist/server/routes/model-providers.d.ts +27 -0
- package/dist/server/routes/model-providers.js +94 -0
- package/dist/server/routes/models.d.ts +3 -0
- package/dist/server/routes/models.js +36 -0
- package/dist/server/routes/notify.d.ts +2 -0
- package/dist/server/routes/notify.js +17 -0
- package/dist/server/routes/playground.d.ts +29 -0
- package/dist/server/routes/playground.js +81 -0
- package/dist/server/routes/release.d.ts +20 -0
- package/dist/server/routes/release.js +86 -0
- package/dist/server/routes/router.d.ts +12 -0
- package/dist/server/routes/router.js +32 -0
- package/dist/server/routes/skill-registry.d.ts +5 -0
- package/dist/server/routes/skill-registry.js +51 -0
- package/dist/server/routes/tasks.d.ts +58 -0
- package/dist/server/routes/tasks.js +1442 -0
- package/dist/server/routes/transcribe.d.ts +2 -0
- package/dist/server/routes/transcribe.js +124 -0
- package/dist/server/routes/types.d.ts +68 -0
- package/dist/server/routes/types.js +1 -0
- package/dist/server/routes/ui.d.ts +5 -0
- package/dist/server/routes/ui.js +81 -0
- package/dist/server/routes/utils.d.ts +3 -0
- package/dist/server/routes/utils.js +23 -0
- package/dist/server/serve-reaper.d.ts +135 -0
- package/dist/server/serve-reaper.js +491 -0
- package/dist/server/server.d.ts +49 -0
- package/dist/server/server.js +2171 -0
- package/dist/server/supervisor.d.ts +89 -0
- package/dist/server/supervisor.js +231 -0
- package/dist/server/system.d.ts +198 -0
- package/dist/server/system.js +480 -0
- package/dist/server/task-check.d.ts +45 -0
- package/dist/server/task-check.js +71 -0
- package/dist/server/task-transitions.d.ts +51 -0
- package/dist/server/task-transitions.js +78 -0
- package/dist/server/task-watchdog.d.ts +158 -0
- package/dist/server/task-watchdog.js +681 -0
- package/dist/server/test-run.d.ts +28 -0
- package/dist/server/test-run.js +81 -0
- package/dist/server/watcher.d.ts +21 -0
- package/dist/server/watcher.js +194 -0
- package/dist/server/write.d.ts +94 -0
- package/dist/server/write.js +345 -0
- package/dist/ui/assets/AgentsView-CF4wRvHh.css +1 -0
- package/dist/ui/assets/AgentsView-D-myL-kA.js +1 -0
- package/dist/ui/assets/ContextView-D3TC-RSp.css +1 -0
- package/dist/ui/assets/ContextView-DsOqAdyE.js +4 -0
- package/dist/ui/assets/DashboardView-BOhsIJqO.js +1 -0
- package/dist/ui/assets/DashboardView-Ck9o0RP2.css +1 -0
- package/dist/ui/assets/DeploymentsView--iIUVfEH.css +1 -0
- package/dist/ui/assets/DeploymentsView-DXu_FYR2.js +1 -0
- package/dist/ui/assets/InputsView-24HVbVPk.js +1 -0
- package/dist/ui/assets/InputsView-BLb7GHtx.css +1 -0
- package/dist/ui/assets/LoginView-6QlGg6cx.css +1 -0
- package/dist/ui/assets/LoginView-BqupI0Lt.js +1 -0
- package/dist/ui/assets/ReleasesView-BEG1A0YH.css +1 -0
- package/dist/ui/assets/ReleasesView-CAgMuxpe.js +3 -0
- package/dist/ui/assets/SettingsView-CA-NYSMK.css +1 -0
- package/dist/ui/assets/SettingsView-O3iZSnjg.js +3 -0
- package/dist/ui/assets/WorkView-D8Tl6qwy.js +1 -0
- package/dist/ui/assets/WorkView-DMYL2s3W.css +1 -0
- package/dist/ui/assets/abnfDiagram-VCTEODGH-J5lkSnEE.js +1 -0
- package/dist/ui/assets/arc-BHZnmhhM.js +1 -0
- package/dist/ui/assets/architecture-7GRP2DOG-pLdFt-xS.js +1 -0
- package/dist/ui/assets/architectureDiagram-5GKGNRK7-C3k89uKX.js +36 -0
- package/dist/ui/assets/array-BifhSqXX.js +1 -0
- package/dist/ui/assets/blockDiagram-I7D4REHJ-AWNoBzop.js +129 -0
- package/dist/ui/assets/button-lbikqSph.js +1 -0
- package/dist/ui/assets/c4Diagram-7LVT6UL2-DKG1D5gd.js +38 -0
- package/dist/ui/assets/channel-gA4qBv3A.js +1 -0
- package/dist/ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/ui/assets/chunk-4HAMMTFA-D1YR8lF_.js +62 -0
- package/dist/ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/ui/assets/chunk-75Z2AOVW-5GuBxfsL.js +2 -0
- package/dist/ui/assets/chunk-DU6HZSFF-DfdNNqwa.js +127 -0
- package/dist/ui/assets/chunk-F27PBJKO-BPZzinQ4.js +1 -0
- package/dist/ui/assets/chunk-FOHPRMQF-l2z4K2jD.js +161 -0
- package/dist/ui/assets/chunk-GMAD6QVW-DGWvSKj5.js +72 -0
- package/dist/ui/assets/chunk-GVQU2GXP-D6Aw75wJ.js +1 -0
- package/dist/ui/assets/chunk-IMKFNOWR-B8mMBKCW.js +231 -0
- package/dist/ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/ui/assets/chunk-L3NEJ4N5-D5OJuXYE.js +1 -0
- package/dist/ui/assets/chunk-OSK3NFVY-B7r9w2UW.js +10 -0
- package/dist/ui/assets/chunk-P2QGCYS3-C74X4AQX.js +1 -0
- package/dist/ui/assets/chunk-POPQ4Y6H-C5_u1gUS.js +1 -0
- package/dist/ui/assets/chunk-PWAF6VOD-DfoTLtxn.js +1 -0
- package/dist/ui/assets/chunk-SHT3W25Y-D40m2eqz.js +168 -0
- package/dist/ui/assets/chunk-SVP7TREG-euxi0Kap.js +88 -0
- package/dist/ui/assets/chunk-TICWLB2K-fkdxngzN.js +206 -0
- package/dist/ui/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
- package/dist/ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/ui/assets/classDiagram-ZZMXUADV-BzW8bq2P.js +1 -0
- package/dist/ui/assets/classDiagram-v2-VYDZK3BY-BzW8bq2P.js +1 -0
- package/dist/ui/assets/cose-bilkent-JH36ORCC-BTcgMon1.js +1 -0
- package/dist/ui/assets/cynefin-OW5HDTMX-B5m_COsS.js +1 -0
- package/dist/ui/assets/cynefinDiagram-5FMLGOSQ-C-AG6CGe.js +62 -0
- package/dist/ui/assets/cytoscape.esm-FBntH-9t.js +321 -0
- package/dist/ui/assets/dagre-GXQ25YYZ-DDfDnp8L.js +4 -0
- package/dist/ui/assets/dagre-PrKaheQc.js +1 -0
- package/dist/ui/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/ui/assets/diagram-S7CK7UJ4-DjQZAlEY.js +30 -0
- package/dist/ui/assets/diagram-UQ7AKVKN-DAZ9yp1B.js +41 -0
- package/dist/ui/assets/diagram-VSXAHHWV-BWepUKJD.js +3 -0
- package/dist/ui/assets/diagram-VX7I27RA-EcV1J81b.js +24 -0
- package/dist/ui/assets/diagram-Z3DM3KII-Dbz7XU_K.js +24 -0
- package/dist/ui/assets/dist-DTg6UBE_.js +1 -0
- package/dist/ui/assets/ebnfDiagram-PWID7BFC-BUyQw8Mm.js +1 -0
- package/dist/ui/assets/erDiagram-RLTQ6QDP-WVf8ukKt.js +99 -0
- package/dist/ui/assets/eventmodeling-NTZA5JFV-DHqyLOAn.js +1 -0
- package/dist/ui/assets/flowDiagram-HODETNUW-CYk_ze5K.js +1 -0
- package/dist/ui/assets/ganttDiagram-EL5Y4UJY-rB4MZuB-.js +292 -0
- package/dist/ui/assets/gitGraph-4MIJSDKK-BOTWM8dY.js +1 -0
- package/dist/ui/assets/gitGraphDiagram-WWUBYQGX-eYscuhtP.js +106 -0
- package/dist/ui/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/ui/assets/index-BACBfnga.css +1 -0
- package/dist/ui/assets/index-BOQ-ust6.js +26 -0
- package/dist/ui/assets/info-A6RAGUB7-_67wOORe.js +1 -0
- package/dist/ui/assets/infoDiagram-27XIBGKW-XVXbddTA.js +2 -0
- package/dist/ui/assets/init-C-OQMol4.js +1 -0
- package/dist/ui/assets/ishikawaDiagram-5VMMS53U-ClLNiEru.js +70 -0
- package/dist/ui/assets/journeyDiagram-3NMN7TZE-NRawjR6i.js +139 -0
- package/dist/ui/assets/kanban-definition-UXKFOSKX-BgSzBfIx.js +89 -0
- package/dist/ui/assets/katex-DolUETbr.js +257 -0
- package/dist/ui/assets/line-KAyfuul4.js +1 -0
- package/dist/ui/assets/linear-cCCPOpu7.js +1 -0
- package/dist/ui/assets/mermaid-parser.core-CgXZk1iN.js +7 -0
- package/dist/ui/assets/mermaid.core-BUUkChHW.js +44 -0
- package/dist/ui/assets/mindmap-definition-YA3MSWOX-Cpn7vW4r.js +96 -0
- package/dist/ui/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/ui/assets/packet-AYTQ26CC-0lZwM-d6.js +1 -0
- package/dist/ui/assets/path-COt_16Va.js +1 -0
- package/dist/ui/assets/pegDiagram-XKGWAZYB-impy0inn.js +1 -0
- package/dist/ui/assets/pie-WAS4IAKB-CqfdGHYg.js +1 -0
- package/dist/ui/assets/pieDiagram-E7YTZNPT-CxjL4I1h.js +39 -0
- package/dist/ui/assets/quadrantDiagram-AXDQQJYC-Cjd1QMIe.js +7 -0
- package/dist/ui/assets/radar-RG4KPBEZ-CRkQIwiv.js +1 -0
- package/dist/ui/assets/railroad-74A4TZTK-i67XSWTB.js +1 -0
- package/dist/ui/assets/railroad-abnf-HS5TGJTU-Cv9m5aS3.js +1 -0
- package/dist/ui/assets/railroad-ebnf-LZEXJU2U-C5ZIn-Jh.js +1 -0
- package/dist/ui/assets/railroad-peg-WCYAUIDC-CGra9Oq-.js +1 -0
- package/dist/ui/assets/railroadDiagram-O6MQD6OU-_pK0qyaY.js +1 -0
- package/dist/ui/assets/releases-B0WNV-SY.js +1 -0
- package/dist/ui/assets/repoos-cto-square.webp +0 -0
- package/dist/ui/assets/repoos-helpful-pm-square.webp +0 -0
- package/dist/ui/assets/repoos-orchestrator-square.webp +0 -0
- package/dist/ui/assets/repoos-performance-agent-square.webp +0 -0
- package/dist/ui/assets/repoos-ross-from-friends-square.webp +0 -0
- package/dist/ui/assets/repoos-tech-debt-agent-square.webp +0 -0
- package/dist/ui/assets/requirementDiagram-BXWQKSXE-CRrFX-yw.js +84 -0
- package/dist/ui/assets/rough.esm-By172zw-.js +1 -0
- package/dist/ui/assets/sankeyDiagram-P5KCCOFB-X_xFRj9E.js +40 -0
- package/dist/ui/assets/sequenceDiagram-WJ2MYXX4-Byb49lKf.js +162 -0
- package/dist/ui/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- package/dist/ui/assets/src-BH-TyZbA.js +1 -0
- package/dist/ui/assets/stateDiagram-D77RDMKH-CzaPrqYF.js +1 -0
- package/dist/ui/assets/stateDiagram-v2-MP3YSRHH-BCXckz7K.js +1 -0
- package/dist/ui/assets/swimlanes-42K2YHIH-Cx_1lnqr.js +1 -0
- package/dist/ui/assets/swimlanesDiagram-VR7AAH4N-54qsKi6A.js +8 -0
- package/dist/ui/assets/timeline-definition-24CTP7MA-B1IbZPQ-.js +120 -0
- package/dist/ui/assets/treeView-Q6P3EWNA-DFWhVE3d.js +1 -0
- package/dist/ui/assets/treemap-WGGIJYW6-BmbYPThn.js +1 -0
- package/dist/ui/assets/vendor-ui-DPm7zLxi.js +3 -0
- package/dist/ui/assets/vendor-vue-CobgyEJp.js +1 -0
- package/dist/ui/assets/vennDiagram-4TSXK5OY-Ae2AtRpt.js +34 -0
- package/dist/ui/assets/wardley-WFR3VGLG-BtD4X4tL.js +1 -0
- package/dist/ui/assets/wardleyDiagram-VM6X3IG4-B3YN2fTd.js +78 -0
- package/dist/ui/assets/xychartDiagram-S5SC5T6Z-CIzJfMKA.js +7 -0
- package/dist/ui/favicon.svg +11 -0
- package/dist/ui/index.html +49 -0
- package/dist/ui/sw.js +48 -0
- package/package.json +91 -0
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication API routes.
|
|
3
|
+
*
|
|
4
|
+
* POST /api/auth/request-otp – Send an email OTP code
|
|
5
|
+
* POST /api/auth/verify-otp – Verify OTP, create session, set cookie
|
|
6
|
+
* POST /api/auth/logout – Revoke session, clear cookie
|
|
7
|
+
* GET /api/auth/me – Current user info (or 401)
|
|
8
|
+
* GET /api/auth/status – Auth enabled? Bootstrap needed?
|
|
9
|
+
* POST /api/auth/bootstrap-admin – Create the first admin
|
|
10
|
+
*
|
|
11
|
+
* Admin-only:
|
|
12
|
+
* GET /api/auth/users – List users
|
|
13
|
+
* POST /api/auth/users – Add a user
|
|
14
|
+
* DELETE /api/auth/users/:email – Remove a user
|
|
15
|
+
* PATCH /api/auth/users/:email – Change role
|
|
16
|
+
* GET /api/auth/audit – Audit log
|
|
17
|
+
* POST /api/auth/users/:email/invite – (Re)send an invite email
|
|
18
|
+
*/
|
|
19
|
+
import { createVerify, createPublicKey } from "node:crypto";
|
|
20
|
+
import { basename } from "node:path";
|
|
21
|
+
import { json, readBody } from "./utils.js";
|
|
22
|
+
import { getAuthStore } from "../../core/auth-store.js";
|
|
23
|
+
import { generateOtp, hashOtp, timingSafeEqualStr, isValidEmail, parseCookies, buildSessionCookie, SESSION_COOKIE_NAME, DEFAULT_SESSION_MAX_AGE, otpRequestLimiter, otpVerifyLimiter, globalRateLimiter, generatePkceVerifier, pkceChallenge, randomHex, } from "../../core/auth.js";
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
function getClientIp(req) {
|
|
28
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
29
|
+
if (typeof forwarded === "string")
|
|
30
|
+
return forwarded.split(",")[0].trim();
|
|
31
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
32
|
+
}
|
|
33
|
+
function getConfiguredMaxAge(config) {
|
|
34
|
+
return config.auth?.sessionMaxAge ?? DEFAULT_SESSION_MAX_AGE;
|
|
35
|
+
}
|
|
36
|
+
function getSecureFlag(req) {
|
|
37
|
+
const proto = req.headers["x-forwarded-proto"];
|
|
38
|
+
if (proto === "https")
|
|
39
|
+
return true;
|
|
40
|
+
// In development, don't require Secure flag
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
function getSessionTokenFromRequest(req) {
|
|
44
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
45
|
+
return cookies[SESSION_COOKIE_NAME] ?? null;
|
|
46
|
+
}
|
|
47
|
+
export function getCurrentUser(req, config) {
|
|
48
|
+
if (!config.auth?.enabled)
|
|
49
|
+
return null; // auth disabled, no user context
|
|
50
|
+
const store = getAuthStore(config.root);
|
|
51
|
+
if (!store)
|
|
52
|
+
return null;
|
|
53
|
+
const token = getSessionTokenFromRequest(req);
|
|
54
|
+
if (!token)
|
|
55
|
+
return null;
|
|
56
|
+
const session = store.getSession(token);
|
|
57
|
+
if (!session)
|
|
58
|
+
return null;
|
|
59
|
+
return { email: session.email, role: session.role };
|
|
60
|
+
}
|
|
61
|
+
function requireAdmin(req, config, res) {
|
|
62
|
+
const user = getCurrentUser(req, config);
|
|
63
|
+
if (!user) {
|
|
64
|
+
json(res, 401, { error: "Authentication required" });
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (user.role !== "admin") {
|
|
68
|
+
json(res, 403, { error: "Admin access required" });
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return user;
|
|
72
|
+
}
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Email sending via Resend
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
/**
|
|
77
|
+
* The standardized default sender name: "RepoOS at <repo>", where <repo> is
|
|
78
|
+
* the checkout's directory name (same convention as the ntfy test
|
|
79
|
+
* notification in notify.ts). auth.emailProvider.fromName overrides it.
|
|
80
|
+
*/
|
|
81
|
+
function defaultFromName(config) {
|
|
82
|
+
return `RepoOS at ${basename(config.root)}`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* RFC 5322 `From` header: "Display Name <addr>". Without a configured
|
|
86
|
+
* fromName, mail clients fall back to showing the address's local part
|
|
87
|
+
* (e.g. "otp") as the sender name, so this always supplies one.
|
|
88
|
+
*/
|
|
89
|
+
export function buildFromHeader(config, provider) {
|
|
90
|
+
const name = provider.fromName || defaultFromName(config);
|
|
91
|
+
return `${name} <${provider.fromAddress}>`;
|
|
92
|
+
}
|
|
93
|
+
async function sendResendEmail(config, toEmail, subject, html) {
|
|
94
|
+
const provider = config.auth?.emailProvider;
|
|
95
|
+
if (!provider || provider.type !== "resend")
|
|
96
|
+
return false;
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetch("https://api.resend.com/emails", {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${provider.apiKey}`,
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify({
|
|
105
|
+
from: buildFromHeader(config, provider),
|
|
106
|
+
to: [toEmail],
|
|
107
|
+
subject,
|
|
108
|
+
html,
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
const detail = await res.text().catch(() => "");
|
|
113
|
+
console.error(`Resend send to ${toEmail} failed (${res.status}): ${detail.slice(0, 500)}`);
|
|
114
|
+
}
|
|
115
|
+
return res.ok;
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
console.error(`Resend send to ${toEmail} threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function sendOtpEmail(config, toEmail, code) {
|
|
123
|
+
const repoName = basename(config.root);
|
|
124
|
+
return sendResendEmail(config, toEmail, `${code} Your RepoOS Login Code — ${repoName}`, `
|
|
125
|
+
<div style="font-family: sans-serif; max-width: 480px; margin: 0 auto; padding: 32px;">
|
|
126
|
+
<h2 style="margin-bottom: 16px;">RepoOS Login Code — ${repoName}</h2>
|
|
127
|
+
<p>Your one-time login code for <strong>${repoName}</strong> is:</p>
|
|
128
|
+
<div style="font-size: 32px; font-weight: bold; letter-spacing: 8px; margin: 24px 0; text-align: center; color: #333;">
|
|
129
|
+
${code}
|
|
130
|
+
</div>
|
|
131
|
+
<p style="color: #666; font-size: 14px;">This code expires in 10 minutes and can only be used once.</p>
|
|
132
|
+
<p style="color: #999; font-size: 12px; margin-top: 24px;">If you use RepoOS on more than one repo, this code only works for <strong>${repoName}</strong> — check you're on the right one before entering it.</p>
|
|
133
|
+
<p style="color: #999; font-size: 12px; margin-top: 8px;">If you didn't request this code, you can safely ignore this email.</p>
|
|
134
|
+
</div>
|
|
135
|
+
`);
|
|
136
|
+
}
|
|
137
|
+
/** Best-effort base URL for links in outbound email: proto + host from the inviting request. */
|
|
138
|
+
function requestOrigin(req) {
|
|
139
|
+
const proto = req.headers["x-forwarded-proto"] ?? "http";
|
|
140
|
+
return `${proto}://${req.headers.host}`;
|
|
141
|
+
}
|
|
142
|
+
async function sendInviteEmail(config, toEmail, loginUrl) {
|
|
143
|
+
const repoName = basename(config.root);
|
|
144
|
+
return sendResendEmail(config, toEmail, `You're invited to RepoOS at ${repoName}`, `
|
|
145
|
+
<div style="font-family: sans-serif; max-width: 480px; margin: 0 auto; padding: 32px;">
|
|
146
|
+
<h2 style="margin-bottom: 16px;">You're invited to RepoOS at ${repoName}</h2>
|
|
147
|
+
<p>An admin has added <strong>${toEmail}</strong> to the allowlist for the <strong>${repoName}</strong> repo. Sign in any time with a one-time email code${config.auth?.google ? " or Google" : ""}:</p>
|
|
148
|
+
<p style="margin: 24px 0; text-align: center;">
|
|
149
|
+
<a href="${loginUrl}" style="display: inline-block; padding: 12px 24px; background: #3b82f6; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600;">Sign in to RepoOS</a>
|
|
150
|
+
</p>
|
|
151
|
+
<p style="color: #999; font-size: 12px; margin-top: 24px;">If you use RepoOS on more than one repo, this invite is only for <strong>${repoName}</strong> — check you're on the right one before signing in.</p>
|
|
152
|
+
<p style="color: #999; font-size: 12px; margin-top: 8px;">If you weren't expecting this, you can safely ignore this email.</p>
|
|
153
|
+
</div>
|
|
154
|
+
`);
|
|
155
|
+
}
|
|
156
|
+
let cachedJwks = null;
|
|
157
|
+
const JWKS_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
158
|
+
async function getGoogleJwks() {
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
if (cachedJwks && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
161
|
+
return cachedJwks.keys;
|
|
162
|
+
}
|
|
163
|
+
const res = await fetch("https://www.googleapis.com/oauth2/v3/certs");
|
|
164
|
+
if (!res.ok)
|
|
165
|
+
throw new Error(`Failed to fetch Google JWKS: ${res.status}`);
|
|
166
|
+
const data = (await res.json());
|
|
167
|
+
cachedJwks = { keys: data.keys, fetchedAt: now };
|
|
168
|
+
return data.keys;
|
|
169
|
+
}
|
|
170
|
+
function base64UrlToBuffer(b64url) {
|
|
171
|
+
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
|
|
172
|
+
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
|
|
173
|
+
return Buffer.from(padded, "base64");
|
|
174
|
+
}
|
|
175
|
+
function importRsaPublicKey(n, e) {
|
|
176
|
+
const nBuf = base64UrlToBuffer(n);
|
|
177
|
+
const eBuf = base64UrlToBuffer(e);
|
|
178
|
+
function encodeLength(len) {
|
|
179
|
+
if (len < 0x80)
|
|
180
|
+
return Buffer.from([len]);
|
|
181
|
+
const bytes = [];
|
|
182
|
+
let l = len;
|
|
183
|
+
while (l > 0) {
|
|
184
|
+
bytes.unshift(l & 0xff);
|
|
185
|
+
l >>= 8;
|
|
186
|
+
}
|
|
187
|
+
return Buffer.from([0x80 | bytes.length, ...bytes]);
|
|
188
|
+
}
|
|
189
|
+
function encodeInteger(buf) {
|
|
190
|
+
const prefix = buf[0] & 0x80 ? Buffer.from([0x00]) : Buffer.alloc(0);
|
|
191
|
+
return Buffer.concat([
|
|
192
|
+
Buffer.from([0x02]),
|
|
193
|
+
encodeLength(buf.length + prefix.length),
|
|
194
|
+
prefix,
|
|
195
|
+
buf,
|
|
196
|
+
]);
|
|
197
|
+
}
|
|
198
|
+
const nEncoded = encodeInteger(nBuf);
|
|
199
|
+
const eEncoded = encodeInteger(eBuf);
|
|
200
|
+
const rsaPubKey = Buffer.concat([
|
|
201
|
+
Buffer.from([0x30]),
|
|
202
|
+
encodeLength(nEncoded.length + eEncoded.length),
|
|
203
|
+
nEncoded,
|
|
204
|
+
eEncoded,
|
|
205
|
+
]);
|
|
206
|
+
const algId = Buffer.from("300d06092a864886f70d01010b0500", "hex");
|
|
207
|
+
const spki = Buffer.concat([
|
|
208
|
+
Buffer.from([0x30]),
|
|
209
|
+
encodeLength(algId.length + rsaPubKey.length + 1),
|
|
210
|
+
algId,
|
|
211
|
+
Buffer.from([0x03]),
|
|
212
|
+
encodeLength(rsaPubKey.length + 1),
|
|
213
|
+
Buffer.from([0x00]),
|
|
214
|
+
rsaPubKey,
|
|
215
|
+
]);
|
|
216
|
+
return createPublicKey({ key: spki, format: "der", type: "spki" });
|
|
217
|
+
}
|
|
218
|
+
async function verifyGoogleIdToken(idToken, clientId) {
|
|
219
|
+
const parts = idToken.split(".");
|
|
220
|
+
if (parts.length !== 3)
|
|
221
|
+
return null;
|
|
222
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
223
|
+
const header = JSON.parse(base64UrlToBuffer(headerB64).toString("utf8"));
|
|
224
|
+
if (header.alg !== "RS256" || header.typ !== "JWT")
|
|
225
|
+
return null;
|
|
226
|
+
const payload = JSON.parse(base64UrlToBuffer(payloadB64).toString("utf8"));
|
|
227
|
+
// Standard claims validation
|
|
228
|
+
if (payload.iss !== "accounts.google.com" && payload.iss !== "https://accounts.google.com") {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
if (payload.aud !== clientId)
|
|
232
|
+
return null;
|
|
233
|
+
if (typeof payload.exp !== "number" || payload.exp * 1000 < Date.now())
|
|
234
|
+
return null;
|
|
235
|
+
// Fetch JWKS and verify signature
|
|
236
|
+
try {
|
|
237
|
+
const keys = await getGoogleJwks();
|
|
238
|
+
const key = keys.find((k) => k.kid === header.kid);
|
|
239
|
+
if (!key)
|
|
240
|
+
return null;
|
|
241
|
+
const publicKey = await importRsaPublicKey(key.n, key.e);
|
|
242
|
+
const valid = createVerify("RSA-SHA256")
|
|
243
|
+
.update(idToken.split(".")[0] + "." + payloadB64)
|
|
244
|
+
.verify(publicKey, signatureB64.replace(/-/g, "+").replace(/_/g, "/"));
|
|
245
|
+
if (!valid)
|
|
246
|
+
return null;
|
|
247
|
+
if (typeof payload.email !== "string")
|
|
248
|
+
return null;
|
|
249
|
+
return { email: payload.email, sub: payload.sub };
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Route handlers
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
/** Auth status: is auth enabled? Is bootstrap needed? */
|
|
259
|
+
export const authStatus = (ctx, _req, res) => {
|
|
260
|
+
const { config } = ctx;
|
|
261
|
+
const auth = config.auth ?? {};
|
|
262
|
+
const store = getAuthStore(config.root);
|
|
263
|
+
const userCount = store?.listUsers().length ?? 0;
|
|
264
|
+
return json(res, 200, {
|
|
265
|
+
enabled: auth.enabled === true,
|
|
266
|
+
bootstrapNeeded: auth.enabled === true && userCount === 0,
|
|
267
|
+
hasGoogle: !!(auth.google?.clientId && auth.google?.clientSecret),
|
|
268
|
+
hasEmailProvider: !!(auth.emailProvider?.apiKey && auth.emailProvider?.fromAddress),
|
|
269
|
+
// So the login page can show which instance you're signing into — the
|
|
270
|
+
// same "RepoOS at <repo>" convention used in outbound email. Just the
|
|
271
|
+
// directory name, never the full server filesystem path.
|
|
272
|
+
repoName: basename(config.root),
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
/** Bootstrap the first admin account. Only works when no users exist. */
|
|
276
|
+
export const bootstrapAdmin = async (ctx, req, res) => {
|
|
277
|
+
const { config } = ctx;
|
|
278
|
+
if (!config.auth?.enabled) {
|
|
279
|
+
return json(res, 400, { error: "Auth is not enabled" });
|
|
280
|
+
}
|
|
281
|
+
const store = getAuthStore(config.root);
|
|
282
|
+
if (!store) {
|
|
283
|
+
return json(res, 500, { error: "Auth store unavailable" });
|
|
284
|
+
}
|
|
285
|
+
if (store.listUsers().length > 0) {
|
|
286
|
+
return json(res, 400, { error: "Users already exist — use the admin panel instead" });
|
|
287
|
+
}
|
|
288
|
+
// Startup already fails closed when auth is enabled with zero users and no
|
|
289
|
+
// bootstrapAdmin configured (server.ts), so this is always set here — but
|
|
290
|
+
// guard anyway rather than trust that invariant blindly.
|
|
291
|
+
const configuredAdmin = config.auth?.bootstrapAdmin?.trim().toLowerCase();
|
|
292
|
+
if (!configuredAdmin) {
|
|
293
|
+
return json(res, 400, { error: "No bootstrap admin email is configured" });
|
|
294
|
+
}
|
|
295
|
+
const body = (await readBody(req));
|
|
296
|
+
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
|
|
297
|
+
if (!isValidEmail(email)) {
|
|
298
|
+
return json(res, 400, { error: "Valid email address required" });
|
|
299
|
+
}
|
|
300
|
+
// Only the operator-configured email may claim the founding admin account —
|
|
301
|
+
// otherwise the first visitor to reach /login on a fresh instance (which,
|
|
302
|
+
// per this task's own premise, may be sitting behind a public Cloudflare
|
|
303
|
+
// Tunnel) could self-appoint as admin before the real owner ever logs in.
|
|
304
|
+
if (email !== configuredAdmin) {
|
|
305
|
+
return json(res, 403, { error: "This email is not authorized to bootstrap the admin account" });
|
|
306
|
+
}
|
|
307
|
+
// Create the admin user
|
|
308
|
+
store.upsertUser(email, "admin", null, email.split("@")[0]);
|
|
309
|
+
store.logAudit("bootstrap_admin", email, null, "Initial bootstrap");
|
|
310
|
+
// Clear the bootstrap admin config
|
|
311
|
+
if (config.auth) {
|
|
312
|
+
delete config.auth.bootstrapAdmin;
|
|
313
|
+
}
|
|
314
|
+
// Create a session for the new admin
|
|
315
|
+
const maxAge = getConfiguredMaxAge(config);
|
|
316
|
+
const token = store.createSession(email, "admin", maxAge);
|
|
317
|
+
if (!token) {
|
|
318
|
+
return json(res, 500, { error: "Failed to create session" });
|
|
319
|
+
}
|
|
320
|
+
const secure = getSecureFlag(req);
|
|
321
|
+
res.setHeader("Set-Cookie", buildSessionCookie(token, maxAge, secure));
|
|
322
|
+
return json(res, 200, {
|
|
323
|
+
ok: true,
|
|
324
|
+
email,
|
|
325
|
+
role: "admin",
|
|
326
|
+
});
|
|
327
|
+
};
|
|
328
|
+
/** Request an OTP code via email. */
|
|
329
|
+
export const requestOtp = async (ctx, req, res) => {
|
|
330
|
+
const { config, logger } = ctx;
|
|
331
|
+
if (!config.auth?.enabled) {
|
|
332
|
+
return json(res, 400, { error: "Auth is not enabled" });
|
|
333
|
+
}
|
|
334
|
+
const store = getAuthStore(config.root);
|
|
335
|
+
if (!store) {
|
|
336
|
+
return json(res, 500, { error: "Auth store unavailable" });
|
|
337
|
+
}
|
|
338
|
+
const ip = getClientIp(req);
|
|
339
|
+
if (!globalRateLimiter.tryAcquire(ip)) {
|
|
340
|
+
return json(res, 429, { error: "Too many requests. Please try again later." });
|
|
341
|
+
}
|
|
342
|
+
const body = (await readBody(req));
|
|
343
|
+
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
|
|
344
|
+
if (!isValidEmail(email)) {
|
|
345
|
+
// Non-enumerating response
|
|
346
|
+
return json(res, 200, { ok: true });
|
|
347
|
+
}
|
|
348
|
+
// Rate limit per email
|
|
349
|
+
const emailKey = `otp_req:${email}`;
|
|
350
|
+
if (!otpRequestLimiter.tryAcquire(emailKey)) {
|
|
351
|
+
return json(res, 429, { error: "Too many OTP requests. Please wait before trying again." });
|
|
352
|
+
}
|
|
353
|
+
// Check if email is in the allowlist
|
|
354
|
+
const user = store.getUser(email);
|
|
355
|
+
if (!user) {
|
|
356
|
+
// Non-enumerating: same response whether or not user exists
|
|
357
|
+
return json(res, 200, { ok: true });
|
|
358
|
+
}
|
|
359
|
+
// Generate and store OTP
|
|
360
|
+
const code = generateOtp();
|
|
361
|
+
const codeHash = hashOtp(code);
|
|
362
|
+
store.createOtpChallenge(email, codeHash, 600, ip); // 10 minutes
|
|
363
|
+
// Send email
|
|
364
|
+
const sent = await sendOtpEmail(config, email, code);
|
|
365
|
+
if (!sent) {
|
|
366
|
+
logger.system("warn", "Failed to send OTP email", { email });
|
|
367
|
+
// Still return ok to avoid enumeration
|
|
368
|
+
}
|
|
369
|
+
return json(res, 200, { ok: true });
|
|
370
|
+
};
|
|
371
|
+
/** Verify an OTP code and create a session. */
|
|
372
|
+
export const verifyOtp = async (ctx, req, res) => {
|
|
373
|
+
const { config } = ctx;
|
|
374
|
+
if (!config.auth?.enabled) {
|
|
375
|
+
return json(res, 400, { error: "Auth is not enabled" });
|
|
376
|
+
}
|
|
377
|
+
const store = getAuthStore(config.root);
|
|
378
|
+
if (!store) {
|
|
379
|
+
return json(res, 500, { error: "Auth store unavailable" });
|
|
380
|
+
}
|
|
381
|
+
const ip = getClientIp(req);
|
|
382
|
+
const body = (await readBody(req));
|
|
383
|
+
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
|
|
384
|
+
const code = typeof body.code === "string" ? body.code.trim() : "";
|
|
385
|
+
if (!isValidEmail(email) || !code) {
|
|
386
|
+
return json(res, 401, { error: "Invalid or expired code" });
|
|
387
|
+
}
|
|
388
|
+
// Rate limit verify attempts
|
|
389
|
+
if (!otpVerifyLimiter.tryAcquire(`otp_verify:${email}`)) {
|
|
390
|
+
return json(res, 429, { error: "Too many failed attempts. Please wait before trying again." });
|
|
391
|
+
}
|
|
392
|
+
const user = store.getUser(email);
|
|
393
|
+
if (!user) {
|
|
394
|
+
return json(res, 401, { error: "Invalid or expired code" });
|
|
395
|
+
}
|
|
396
|
+
// Dev backdoor: a static code, set only via REPOOS_AUTH_DEV_BACKDOOR_CODE
|
|
397
|
+
// and only ever loaded outside production (see config.ts), lets the
|
|
398
|
+
// developer and agents log into a real managed preview without a mailbox.
|
|
399
|
+
const usedBackdoor = !!config.auth.devBackdoorCode &&
|
|
400
|
+
process.env.NODE_ENV !== "production" &&
|
|
401
|
+
timingSafeEqualStr(code, config.auth.devBackdoorCode);
|
|
402
|
+
if (!usedBackdoor) {
|
|
403
|
+
const codeHash = hashOtp(code);
|
|
404
|
+
const challenge = store.findValidOtp(email, codeHash);
|
|
405
|
+
if (!challenge) {
|
|
406
|
+
// Check if any valid (non-matching) OTP exists to distinguish "wrong code" from "expired"
|
|
407
|
+
return json(res, 401, { error: "Invalid or expired code" });
|
|
408
|
+
}
|
|
409
|
+
store.markOtpUsed(challenge.id);
|
|
410
|
+
}
|
|
411
|
+
otpVerifyLimiter.reset(`otp_verify:${email}`);
|
|
412
|
+
otpRequestLimiter.reset(`otp_req:${email}`);
|
|
413
|
+
// Create session
|
|
414
|
+
const maxAge = getConfiguredMaxAge(config);
|
|
415
|
+
const token = store.createSession(email, user.role, maxAge);
|
|
416
|
+
if (!token) {
|
|
417
|
+
return json(res, 500, { error: "Failed to create session" });
|
|
418
|
+
}
|
|
419
|
+
store.logAudit("otp_login", email, email);
|
|
420
|
+
const secure = getSecureFlag(req);
|
|
421
|
+
res.setHeader("Set-Cookie", buildSessionCookie(token, maxAge, secure));
|
|
422
|
+
return json(res, 200, {
|
|
423
|
+
ok: true,
|
|
424
|
+
email,
|
|
425
|
+
role: user.role,
|
|
426
|
+
});
|
|
427
|
+
};
|
|
428
|
+
/** Google OAuth login redirect. */
|
|
429
|
+
export const googleLogin = (ctx, _req, res) => {
|
|
430
|
+
const { config } = ctx;
|
|
431
|
+
if (!config.auth?.enabled || !config.auth?.google) {
|
|
432
|
+
return json(res, 400, { error: "Google OAuth not configured" });
|
|
433
|
+
}
|
|
434
|
+
const state = randomHex(16);
|
|
435
|
+
const nonce = randomHex(16);
|
|
436
|
+
const codeVerifier = generatePkceVerifier();
|
|
437
|
+
const codeChallenge = pkceChallenge(codeVerifier);
|
|
438
|
+
const redirectUri = `${_req.headers["x-forwarded-proto"] ?? "http"}://${_req.headers.host}/api/auth/callback/google`;
|
|
439
|
+
// Store state, nonce, and PKCE verifier in short-lived cookies
|
|
440
|
+
const secure = getSecureFlag(_req);
|
|
441
|
+
const cookieBase = "HttpOnly; Path=/; Max-Age=600; SameSite=Lax" + (secure ? "; Secure" : "");
|
|
442
|
+
res.setHeader("Set-Cookie", [
|
|
443
|
+
`repoos_oauth_state=${state}; ${cookieBase}`,
|
|
444
|
+
`repoos_oauth_nonce=${nonce}; ${cookieBase}`,
|
|
445
|
+
`repoos_oauth_pkce=${codeVerifier}; ${cookieBase}`,
|
|
446
|
+
]);
|
|
447
|
+
const params = new URLSearchParams({
|
|
448
|
+
client_id: config.auth.google.clientId,
|
|
449
|
+
redirect_uri: redirectUri,
|
|
450
|
+
response_type: "code",
|
|
451
|
+
scope: "openid email profile",
|
|
452
|
+
state,
|
|
453
|
+
nonce,
|
|
454
|
+
access_type: "offline",
|
|
455
|
+
prompt: "select_account",
|
|
456
|
+
code_challenge: codeChallenge,
|
|
457
|
+
code_challenge_method: "S256",
|
|
458
|
+
});
|
|
459
|
+
res.writeHead(302, { Location: `https://accounts.google.com/o/oauth2/v2/auth?${params}` });
|
|
460
|
+
res.end();
|
|
461
|
+
};
|
|
462
|
+
/** Google OAuth callback. */
|
|
463
|
+
export const googleCallback = async (ctx, req, res) => {
|
|
464
|
+
const { config, logger } = ctx;
|
|
465
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
466
|
+
const code = url.searchParams.get("code");
|
|
467
|
+
const state = url.searchParams.get("state");
|
|
468
|
+
const error = url.searchParams.get("error");
|
|
469
|
+
if (error || !code || !state) {
|
|
470
|
+
res.writeHead(302, { Location: "/login?error=oauth_failed" });
|
|
471
|
+
res.end();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
// Verify state
|
|
475
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
476
|
+
const savedState = cookies["repoos_oauth_state"];
|
|
477
|
+
const savedNonce = cookies["repoos_oauth_nonce"];
|
|
478
|
+
const codeVerifier = cookies["repoos_oauth_pkce"];
|
|
479
|
+
if (!savedState || !timingSafeEqualStr(state, savedState)) {
|
|
480
|
+
res.writeHead(302, { Location: "/login?error=invalid_state" });
|
|
481
|
+
res.end();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// Nonce cookie must be present — reject if missing
|
|
485
|
+
if (!savedNonce) {
|
|
486
|
+
res.writeHead(302, { Location: "/login?error=invalid_nonce" });
|
|
487
|
+
res.end();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// Clear the OAuth cookies
|
|
491
|
+
res.setHeader("Set-Cookie", [
|
|
492
|
+
"repoos_oauth_state=; HttpOnly; Path=/; Max-Age=0",
|
|
493
|
+
"repoos_oauth_nonce=; HttpOnly; Path=/; Max-Age=0",
|
|
494
|
+
"repoos_oauth_pkce=; HttpOnly; Path=/; Max-Age=0",
|
|
495
|
+
]);
|
|
496
|
+
if (!config.auth?.google) {
|
|
497
|
+
res.writeHead(302, { Location: "/login?error=oauth_not_configured" });
|
|
498
|
+
res.end();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const proto = req.headers["x-forwarded-proto"] ?? "http";
|
|
502
|
+
const redirectUri = `${proto}://${req.headers.host}/api/auth/callback/google`;
|
|
503
|
+
// Exchange code for tokens
|
|
504
|
+
try {
|
|
505
|
+
const tokenBody = {
|
|
506
|
+
code,
|
|
507
|
+
client_id: config.auth.google.clientId,
|
|
508
|
+
client_secret: config.auth.google.clientSecret,
|
|
509
|
+
redirect_uri: redirectUri,
|
|
510
|
+
grant_type: "authorization_code",
|
|
511
|
+
};
|
|
512
|
+
if (codeVerifier) {
|
|
513
|
+
tokenBody.code_verifier = codeVerifier;
|
|
514
|
+
}
|
|
515
|
+
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
|
516
|
+
method: "POST",
|
|
517
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
518
|
+
body: new URLSearchParams(tokenBody),
|
|
519
|
+
});
|
|
520
|
+
if (!tokenRes.ok) {
|
|
521
|
+
logger.system("warn", "Google OAuth token exchange failed", { status: tokenRes.status });
|
|
522
|
+
res.writeHead(302, { Location: "/login?error=oauth_failed" });
|
|
523
|
+
res.end();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const tokens = (await tokenRes.json());
|
|
527
|
+
if (!tokens.id_token) {
|
|
528
|
+
res.writeHead(302, { Location: "/login?error=oauth_failed" });
|
|
529
|
+
res.end();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// Verify the id_token (aud, iss, signature, expiry) via Google JWKS
|
|
533
|
+
const verified = await verifyGoogleIdToken(tokens.id_token, config.auth.google.clientId);
|
|
534
|
+
if (!verified) {
|
|
535
|
+
res.writeHead(302, { Location: "/login?error=invalid_token" });
|
|
536
|
+
res.end();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
// Verify nonce claim matches
|
|
540
|
+
// Decode payload again for nonce check (already verified signature above)
|
|
541
|
+
const payload = JSON.parse(Buffer.from(tokens.id_token.split(".")[1], "base64url").toString("utf8"));
|
|
542
|
+
if (payload.nonce !== savedNonce) {
|
|
543
|
+
res.writeHead(302, { Location: "/login?error=invalid_nonce" });
|
|
544
|
+
res.end();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const email = verified.email;
|
|
548
|
+
// Check allowlist
|
|
549
|
+
const store = getAuthStore(config.root);
|
|
550
|
+
const user = store?.getUser(email);
|
|
551
|
+
if (!user) {
|
|
552
|
+
res.writeHead(302, { Location: "/login?error=not_allowed" });
|
|
553
|
+
res.end();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
// Create session
|
|
557
|
+
const maxAge = getConfiguredMaxAge(config);
|
|
558
|
+
const token = store.createSession(email, user.role, maxAge);
|
|
559
|
+
if (!token) {
|
|
560
|
+
res.writeHead(302, { Location: "/login?error=session_failed" });
|
|
561
|
+
res.end();
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
store.logAudit("google_login", email, email);
|
|
565
|
+
const secure = getSecureFlag(req);
|
|
566
|
+
res.setHeader("Set-Cookie", buildSessionCookie(token, maxAge, secure));
|
|
567
|
+
res.writeHead(302, { Location: "/" });
|
|
568
|
+
res.end();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
logger.system("error", "Google OAuth callback error", { error: String(err) });
|
|
573
|
+
res.writeHead(302, { Location: "/login?error=oauth_failed" });
|
|
574
|
+
res.end();
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
/** Get current user info. */
|
|
579
|
+
export const authMe = (ctx, req, res) => {
|
|
580
|
+
const { config } = ctx;
|
|
581
|
+
if (!config.auth?.enabled) {
|
|
582
|
+
return json(res, 200, { authenticated: false, authEnabled: false });
|
|
583
|
+
}
|
|
584
|
+
const user = getCurrentUser(req, config);
|
|
585
|
+
if (!user) {
|
|
586
|
+
return json(res, 200, { authenticated: false, authEnabled: true });
|
|
587
|
+
}
|
|
588
|
+
return json(res, 200, {
|
|
589
|
+
authenticated: true,
|
|
590
|
+
authEnabled: true,
|
|
591
|
+
email: user.email,
|
|
592
|
+
role: user.role,
|
|
593
|
+
});
|
|
594
|
+
};
|
|
595
|
+
/** Logout: revoke session and clear cookie. */
|
|
596
|
+
export const authLogout = (ctx, req, res) => {
|
|
597
|
+
const { config } = ctx;
|
|
598
|
+
const token = getSessionTokenFromRequest(req);
|
|
599
|
+
if (token) {
|
|
600
|
+
const store = getAuthStore(config.root);
|
|
601
|
+
store?.revokeSession(token);
|
|
602
|
+
}
|
|
603
|
+
res.setHeader("Set-Cookie", `${SESSION_COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax`);
|
|
604
|
+
return json(res, 200, { ok: true });
|
|
605
|
+
};
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
// Admin routes
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
/** List all users. */
|
|
610
|
+
export const listUsers = (ctx, req, res) => {
|
|
611
|
+
const { config } = ctx;
|
|
612
|
+
const user = requireAdmin(req, config, res);
|
|
613
|
+
if (!user)
|
|
614
|
+
return;
|
|
615
|
+
const store = getAuthStore(config.root);
|
|
616
|
+
if (!store)
|
|
617
|
+
return json(res, 500, { error: "Auth store unavailable" });
|
|
618
|
+
const users = store.listUsers();
|
|
619
|
+
return json(res, 200, { users });
|
|
620
|
+
};
|
|
621
|
+
/** Add a user. */
|
|
622
|
+
export const addUser = async (ctx, req, res) => {
|
|
623
|
+
const { config } = ctx;
|
|
624
|
+
const admin = requireAdmin(req, config, res);
|
|
625
|
+
if (!admin)
|
|
626
|
+
return;
|
|
627
|
+
const store = getAuthStore(config.root);
|
|
628
|
+
if (!store)
|
|
629
|
+
return json(res, 500, { error: "Auth store unavailable" });
|
|
630
|
+
const body = (await readBody(req));
|
|
631
|
+
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
|
|
632
|
+
const role = body.role === "admin" ? "admin" : "member";
|
|
633
|
+
if (!isValidEmail(email)) {
|
|
634
|
+
return json(res, 400, { error: "Valid email address required" });
|
|
635
|
+
}
|
|
636
|
+
const existing = store.getUser(email);
|
|
637
|
+
if (existing) {
|
|
638
|
+
return json(res, 409, { error: "User already exists" });
|
|
639
|
+
}
|
|
640
|
+
store.upsertUser(email, role, admin.email);
|
|
641
|
+
store.logAudit("add_user", email, admin.email, `role: ${role}`);
|
|
642
|
+
return json(res, 200, { ok: true, email, role });
|
|
643
|
+
};
|
|
644
|
+
/** (Re)send an invite email. Callable any time, not just once at add-time. */
|
|
645
|
+
export const sendInvite = async (ctx, req, res) => {
|
|
646
|
+
const { config } = ctx;
|
|
647
|
+
const admin = requireAdmin(req, config, res);
|
|
648
|
+
if (!admin)
|
|
649
|
+
return;
|
|
650
|
+
const store = getAuthStore(config.root);
|
|
651
|
+
if (!store)
|
|
652
|
+
return json(res, 500, { error: "Auth store unavailable" });
|
|
653
|
+
// Extract email from URL path: /api/auth/users/:email/invite
|
|
654
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
655
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
656
|
+
const email = decodeURIComponent(segments[segments.length - 2] ?? "")
|
|
657
|
+
.trim()
|
|
658
|
+
.toLowerCase();
|
|
659
|
+
if (!isValidEmail(email)) {
|
|
660
|
+
return json(res, 400, { error: "Invalid email" });
|
|
661
|
+
}
|
|
662
|
+
const user = store.getUser(email);
|
|
663
|
+
if (!user) {
|
|
664
|
+
return json(res, 404, { error: "User not found" });
|
|
665
|
+
}
|
|
666
|
+
if (!config.auth?.emailProvider) {
|
|
667
|
+
return json(res, 400, { error: "No email provider configured — cannot send invites" });
|
|
668
|
+
}
|
|
669
|
+
const loginUrl = `${requestOrigin(req)}/login`;
|
|
670
|
+
const sent = await sendInviteEmail(config, email, loginUrl);
|
|
671
|
+
if (!sent) {
|
|
672
|
+
return json(res, 502, {
|
|
673
|
+
error: "Failed to send invite email — check server logs for the provider error",
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
store.logAudit("invite_sent", email, admin.email);
|
|
677
|
+
return json(res, 200, { ok: true });
|
|
678
|
+
};
|
|
679
|
+
/** Remove a user. */
|
|
680
|
+
export const deleteUser = async (ctx, req, res) => {
|
|
681
|
+
const { config } = ctx;
|
|
682
|
+
const admin = requireAdmin(req, config, res);
|
|
683
|
+
if (!admin)
|
|
684
|
+
return;
|
|
685
|
+
const store = getAuthStore(config.root);
|
|
686
|
+
if (!store)
|
|
687
|
+
return json(res, 500, { error: "Auth store unavailable" });
|
|
688
|
+
// Extract email from URL path: /api/auth/users/:email
|
|
689
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
690
|
+
const email = decodeURIComponent(url.pathname.split("/").pop() ?? "")
|
|
691
|
+
.trim()
|
|
692
|
+
.toLowerCase();
|
|
693
|
+
if (!isValidEmail(email)) {
|
|
694
|
+
return json(res, 400, { error: "Invalid email" });
|
|
695
|
+
}
|
|
696
|
+
// Prevent removing the last admin
|
|
697
|
+
const user = store.getUser(email);
|
|
698
|
+
if (!user) {
|
|
699
|
+
return json(res, 404, { error: "User not found" });
|
|
700
|
+
}
|
|
701
|
+
if (user.role === "admin" && store.getAdminCount() <= 1) {
|
|
702
|
+
return json(res, 400, { error: "Cannot remove the last admin" });
|
|
703
|
+
}
|
|
704
|
+
store.deleteUser(email);
|
|
705
|
+
store.revokeAllSessions(email);
|
|
706
|
+
store.logAudit("remove_user", email, admin.email);
|
|
707
|
+
return json(res, 200, { ok: true });
|
|
708
|
+
};
|
|
709
|
+
/** Change a user's role. */
|
|
710
|
+
export const updateUserRole = async (ctx, req, res) => {
|
|
711
|
+
const { config } = ctx;
|
|
712
|
+
const admin = requireAdmin(req, config, res);
|
|
713
|
+
if (!admin)
|
|
714
|
+
return;
|
|
715
|
+
const store = getAuthStore(config.root);
|
|
716
|
+
if (!store)
|
|
717
|
+
return json(res, 500, { error: "Auth store unavailable" });
|
|
718
|
+
const body = (await readBody(req));
|
|
719
|
+
const role = body.role === "admin" ? "admin" : "member";
|
|
720
|
+
// Extract email from URL path
|
|
721
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
722
|
+
const email = decodeURIComponent(url.pathname.split("/").pop() ?? "")
|
|
723
|
+
.trim()
|
|
724
|
+
.toLowerCase();
|
|
725
|
+
if (!isValidEmail(email)) {
|
|
726
|
+
return json(res, 400, { error: "Invalid email" });
|
|
727
|
+
}
|
|
728
|
+
const user = store.getUser(email);
|
|
729
|
+
if (!user) {
|
|
730
|
+
return json(res, 404, { error: "User not found" });
|
|
731
|
+
}
|
|
732
|
+
// Prevent demoting the last admin
|
|
733
|
+
if (user.role === "admin" && role !== "admin" && store.getAdminCount() <= 1) {
|
|
734
|
+
return json(res, 400, { error: "Cannot demote the last admin" });
|
|
735
|
+
}
|
|
736
|
+
store.upsertUser(email, role, admin.email);
|
|
737
|
+
// Propagate the new role to all active sessions so authorization is immediate.
|
|
738
|
+
store.updateSessionRoles(email, role);
|
|
739
|
+
store.logAudit("change_role", email, admin.email, `role: ${role}`);
|
|
740
|
+
return json(res, 200, { ok: true, email, role });
|
|
741
|
+
};
|
|
742
|
+
/** Get audit log. */
|
|
743
|
+
export const getAuditLog = (ctx, req, res) => {
|
|
744
|
+
const { config } = ctx;
|
|
745
|
+
const admin = requireAdmin(req, config, res);
|
|
746
|
+
if (!admin)
|
|
747
|
+
return;
|
|
748
|
+
const store = getAuthStore(config.root);
|
|
749
|
+
if (!store)
|
|
750
|
+
return json(res, 500, { error: "Auth store unavailable" });
|
|
751
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
752
|
+
const limit = Math.min(Number(url.searchParams.get("limit") ?? "50"), 200);
|
|
753
|
+
const entries = store.getAuditLog(limit);
|
|
754
|
+
return json(res, 200, { entries });
|
|
755
|
+
};
|