@prismshadow/penguin-server 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/api/types.d.ts +311 -22
- package/dist/chunk-3QINBW3W.js +565 -0
- package/dist/chunk-3QINBW3W.js.map +1 -0
- package/dist/hmr/manifest.d.ts +44 -0
- package/dist/hmr/manifest.js +27 -0
- package/dist/hmr/manifest.js.map +1 -0
- package/dist/index.js +1995 -796
- package/dist/index.js.map +1 -1
- package/dist/reset-admin-password.d.ts +28 -0
- package/dist/reset-admin-password.js +39 -0
- package/dist/reset-admin-password.js.map +1 -0
- package/package.json +11 -3
- package/web-dist/assets/cpp-DIPi6g--.js +1 -0
- package/web-dist/assets/highlighter-Nvj8c0Yw.js +152 -0
- package/web-dist/assets/index--uKtXcEB.js +183 -0
- package/web-dist/assets/index-BqF51KT4.css +1 -0
- package/web-dist/assets/ruby-B13GW9FM.js +1 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/abap-BdImnpbu.js +0 -1
- package/web-dist/assets/actionscript-3-CoDkCxhg.js +0 -1
- package/web-dist/assets/ada-bCR0ucgS.js +0 -1
- package/web-dist/assets/andromeeda-C4gqWexZ.js +0 -1
- package/web-dist/assets/angular-html-DA-rfuFy.js +0 -1
- package/web-dist/assets/angular-ts-BrjP3tb8.js +0 -1
- package/web-dist/assets/apache-Pmp26Uib.js +0 -1
- package/web-dist/assets/apex-Dqspr-GT.js +0 -1
- package/web-dist/assets/apl-CORt7UWP.js +0 -1
- package/web-dist/assets/applescript-Co6uUVPk.js +0 -1
- package/web-dist/assets/ara-BRHolxvo.js +0 -1
- package/web-dist/assets/asciidoc-Ve4PFQV2.js +0 -1
- package/web-dist/assets/asm-D_Q5rh1f.js +0 -1
- package/web-dist/assets/astro-HNnZUWAn.js +0 -1
- package/web-dist/assets/aurora-x-D-2ljcwZ.js +0 -1
- package/web-dist/assets/awk-DMzUqQB5.js +0 -1
- package/web-dist/assets/ayu-dark-DYE7WIF3.js +0 -1
- package/web-dist/assets/ayu-light-BA47KaF1.js +0 -1
- package/web-dist/assets/ayu-mirage-32ctXXKs.js +0 -1
- package/web-dist/assets/ballerina-BFfxhgS-.js +0 -1
- package/web-dist/assets/bat-BkioyH1T.js +0 -1
- package/web-dist/assets/beancount-k_qm7-4y.js +0 -1
- package/web-dist/assets/berry-uYugtg8r.js +0 -1
- package/web-dist/assets/bibtex-CHM0blh-.js +0 -1
- package/web-dist/assets/bicep-Bmn6On1c.js +0 -1
- package/web-dist/assets/bird2-BIv1doCn.js +0 -1
- package/web-dist/assets/blade-2xfisSek.js +0 -1
- package/web-dist/assets/bsl-BO_Y6i37.js +0 -1
- package/web-dist/assets/c3-MRO5bC_T.js +0 -1
- package/web-dist/assets/cadence-Bv_4Rxtq.js +0 -1
- package/web-dist/assets/cairo-KRGpt6FW.js +0 -1
- package/web-dist/assets/catppuccin-frappe-DFWUc33u.js +0 -1
- package/web-dist/assets/catppuccin-latte-C9dUb6Cb.js +0 -1
- package/web-dist/assets/catppuccin-macchiato-DQyhUUbL.js +0 -1
- package/web-dist/assets/catppuccin-mocha-D87Tk5Gz.js +0 -1
- package/web-dist/assets/clarity-D53aC0YG.js +0 -1
- package/web-dist/assets/clojure-P80f7IUj.js +0 -1
- package/web-dist/assets/cmake-D1j8_8rp.js +0 -1
- package/web-dist/assets/cobol-nBiQ_Alo.js +0 -1
- package/web-dist/assets/codeowners-Bp6g37R7.js +0 -1
- package/web-dist/assets/codeql-DsOJ9woJ.js +0 -1
- package/web-dist/assets/coffee-Ch7k5sss.js +0 -1
- package/web-dist/assets/common-lisp-Cg-RD9OK.js +0 -1
- package/web-dist/assets/coq-DkFqJrB1.js +0 -1
- package/web-dist/assets/cpp-UfJy6YNI.js +0 -1
- package/web-dist/assets/crystal-DGywbUpC.js +0 -1
- package/web-dist/assets/csharp-DSvCPggb.js +0 -1
- package/web-dist/assets/csv-fuZLfV_i.js +0 -1
- package/web-dist/assets/cue-D82EKSYY.js +0 -1
- package/web-dist/assets/cypher-COkxafJQ.js +0 -1
- package/web-dist/assets/d-85-TOEBH.js +0 -1
- package/web-dist/assets/dark-plus-C3mMm8J8.js +0 -1
- package/web-dist/assets/dart-bE4Kk8sk.js +0 -1
- package/web-dist/assets/dax-CEL-wOlO.js +0 -1
- package/web-dist/assets/desktop-BmXAJ9_W.js +0 -1
- package/web-dist/assets/docker-BcOcwvcX.js +0 -1
- package/web-dist/assets/dotenv-Da5cRb03.js +0 -1
- package/web-dist/assets/dracula-BzJJZx-M.js +0 -1
- package/web-dist/assets/dracula-soft-BXkSAIEj.js +0 -1
- package/web-dist/assets/dream-maker-BtqSS_iP.js +0 -1
- package/web-dist/assets/edge-FbVlp4U3.js +0 -1
- package/web-dist/assets/elixir-CkH2-t6x.js +0 -1
- package/web-dist/assets/elm-DbKCFpqz.js +0 -1
- package/web-dist/assets/emacs-lisp-CXvaQtF9.js +0 -1
- package/web-dist/assets/erb-Dm6A9KJ5.js +0 -1
- package/web-dist/assets/erlang-DsQrWhSR.js +0 -1
- package/web-dist/assets/everforest-dark-BgDCqdQA.js +0 -1
- package/web-dist/assets/everforest-light-C8M2exoo.js +0 -1
- package/web-dist/assets/fennel-BYunw83y.js +0 -1
- package/web-dist/assets/fish-BvzEVeQv.js +0 -1
- package/web-dist/assets/fluent-C4IJs8-o.js +0 -1
- package/web-dist/assets/fortran-fixed-form-CkoXwp7k.js +0 -1
- package/web-dist/assets/fortran-free-form-BxgE0vQu.js +0 -1
- package/web-dist/assets/fsharp-CXgrBDvD.js +0 -1
- package/web-dist/assets/gdresource-BOOCDP_w.js +0 -1
- package/web-dist/assets/gdscript-C5YyOfLZ.js +0 -1
- package/web-dist/assets/gdshader-DkwncUOv.js +0 -1
- package/web-dist/assets/genie-D0YGMca9.js +0 -1
- package/web-dist/assets/gherkin-DyxjwDmM.js +0 -1
- package/web-dist/assets/git-commit-F4YmCXRG.js +0 -1
- package/web-dist/assets/git-rebase-r7XF79zn.js +0 -1
- package/web-dist/assets/github-dark-default-Cuk6v7N8.js +0 -1
- package/web-dist/assets/github-dark-dimmed-DH5Ifo-i.js +0 -1
- package/web-dist/assets/github-dark-high-contrast-E3gJ1_iC.js +0 -1
- package/web-dist/assets/github-light-default-D7oLnXFd.js +0 -1
- package/web-dist/assets/github-light-high-contrast-BfjtVDDH.js +0 -1
- package/web-dist/assets/gleam-BspZqrRM.js +0 -1
- package/web-dist/assets/glimmer-js-ByusRIyA.js +0 -1
- package/web-dist/assets/glimmer-ts-BfAWNZQY.js +0 -1
- package/web-dist/assets/glsl-DplSGwfg.js +0 -1
- package/web-dist/assets/gn-n2N0HUVH.js +0 -1
- package/web-dist/assets/gnuplot-DdkO51Og.js +0 -1
- package/web-dist/assets/graphql-ChdNCCLP.js +0 -1
- package/web-dist/assets/groovy-gcz8RCvz.js +0 -1
- package/web-dist/assets/gruvbox-dark-hard-CFHQjOhq.js +0 -1
- package/web-dist/assets/gruvbox-dark-medium-GsRaNv29.js +0 -1
- package/web-dist/assets/gruvbox-dark-soft-CVdnzihN.js +0 -1
- package/web-dist/assets/gruvbox-light-hard-CH1njM8p.js +0 -1
- package/web-dist/assets/gruvbox-light-medium-DRw_LuNl.js +0 -1
- package/web-dist/assets/gruvbox-light-soft-hJgmCMqR.js +0 -1
- package/web-dist/assets/hack-DbPARsA_.js +0 -1
- package/web-dist/assets/haml-D5jkg6IW.js +0 -1
- package/web-dist/assets/handlebars-BpdQsYii.js +0 -1
- package/web-dist/assets/haskell-Df6bDoY_.js +0 -1
- package/web-dist/assets/haxe-CzTSHFRz.js +0 -1
- package/web-dist/assets/hcl-BWvSN4gD.js +0 -1
- package/web-dist/assets/hjson-D5-asLiD.js +0 -1
- package/web-dist/assets/hlsl-D3lLCCz7.js +0 -1
- package/web-dist/assets/horizon-BUw7H-hv.js +0 -1
- package/web-dist/assets/horizon-bright-CUuTKBJd.js +0 -1
- package/web-dist/assets/houston-DnULxvSX.js +0 -1
- package/web-dist/assets/html-derivative-DlHx6ybY.js +0 -1
- package/web-dist/assets/http-jrhK8wxY.js +0 -1
- package/web-dist/assets/hurl-irOxFIW8.js +0 -1
- package/web-dist/assets/hxml-Bvhsp5Yf.js +0 -1
- package/web-dist/assets/hy-DFXneXwc.js +0 -1
- package/web-dist/assets/imba-DGztddWO.js +0 -1
- package/web-dist/assets/index-B3puu-Hr.js +0 -153
- package/web-dist/assets/index-CaDuyrN0.css +0 -1
- package/web-dist/assets/index-rgHg3m6g.js +0 -181
- package/web-dist/assets/jinja-f2NsQr07.js +0 -1
- package/web-dist/assets/jison-wvAkD_A8.js +0 -1
- package/web-dist/assets/json5-C9tS-k6U.js +0 -1
- package/web-dist/assets/jsonc-Des-eS-w.js +0 -1
- package/web-dist/assets/jsonl-DcaNXYhu.js +0 -1
- package/web-dist/assets/jsonnet-DFQXde-d.js +0 -1
- package/web-dist/assets/jssm-C2t-YnRu.js +0 -1
- package/web-dist/assets/julia-D7OTSIA_.js +0 -1
- package/web-dist/assets/just-CUsbIsdP.js +0 -1
- package/web-dist/assets/kanagawa-dragon-CkXjmgJE.js +0 -1
- package/web-dist/assets/kanagawa-lotus-CfQXZHmo.js +0 -1
- package/web-dist/assets/kanagawa-wave-DWedfzmr.js +0 -1
- package/web-dist/assets/kdl-DV7GczEv.js +0 -1
- package/web-dist/assets/kotlin-BdnUsdx6.js +0 -1
- package/web-dist/assets/kusto-wEQ09or8.js +0 -1
- package/web-dist/assets/laserwave-DUszq2jm.js +0 -1
- package/web-dist/assets/latex-CaSxy8MP.js +0 -1
- package/web-dist/assets/lean-BZvkOJ9d.js +0 -1
- package/web-dist/assets/less-B1dDrJ26.js +0 -1
- package/web-dist/assets/light-plus-B7mTdjB0.js +0 -1
- package/web-dist/assets/liquid-C0sCDyMI.js +0 -1
- package/web-dist/assets/llvm-DjAJT7YJ.js +0 -1
- package/web-dist/assets/logo-BtOb2qkB.js +0 -1
- package/web-dist/assets/lua-BaeVxFsk.js +0 -1
- package/web-dist/assets/luau-KW6xsasC.js +0 -1
- package/web-dist/assets/make-CHLpvVh8.js +0 -1
- package/web-dist/assets/marko-DjSrsDqO.js +0 -1
- package/web-dist/assets/material-theme-D5KoaKCx.js +0 -1
- package/web-dist/assets/material-theme-darker-BfHTSMKl.js +0 -1
- package/web-dist/assets/material-theme-lighter-B0m2ddpp.js +0 -1
- package/web-dist/assets/material-theme-ocean-CyktbL80.js +0 -1
- package/web-dist/assets/material-theme-palenight-Csfq5Kiy.js +0 -1
- package/web-dist/assets/matlab-D7o27uSR.js +0 -1
- package/web-dist/assets/mdc-DTYItulj.js +0 -1
- package/web-dist/assets/mdx-Cmh6b_Ma.js +0 -1
- package/web-dist/assets/mermaid-mWjccvbQ.js +0 -1
- package/web-dist/assets/min-dark-CafNBF8u.js +0 -1
- package/web-dist/assets/min-light-CTRr51gU.js +0 -1
- package/web-dist/assets/mipsasm-CKIfxQSi.js +0 -1
- package/web-dist/assets/mojo-rZm6bMo-.js +0 -1
- package/web-dist/assets/monokai-D4h5O-jR.js +0 -1
- package/web-dist/assets/moonbit-_H4v1dQx.js +0 -1
- package/web-dist/assets/move-IF9eRakj.js +0 -1
- package/web-dist/assets/narrat-DRg8JJMk.js +0 -1
- package/web-dist/assets/nextflow-C-mBbutL.js +0 -1
- package/web-dist/assets/nextflow-groovy-vE_lwT2v.js +0 -1
- package/web-dist/assets/nginx-BpAMiNFr.js +0 -1
- package/web-dist/assets/night-owl-C39BiMTA.js +0 -1
- package/web-dist/assets/night-owl-light-CMTm3GFP.js +0 -1
- package/web-dist/assets/nim-BIad80T-.js +0 -1
- package/web-dist/assets/nix-CwoSXNpI.js +0 -1
- package/web-dist/assets/nord-Ddv68eIx.js +0 -1
- package/web-dist/assets/nushell-Cz2AlsmD.js +0 -1
- package/web-dist/assets/objective-c-DXmwc3jG.js +0 -1
- package/web-dist/assets/objective-cpp-CLxacb5B.js +0 -1
- package/web-dist/assets/ocaml-C0hk2d4L.js +0 -1
- package/web-dist/assets/odin-BBf5iR-q.js +0 -1
- package/web-dist/assets/one-dark-pro-DVMEJ2y_.js +0 -1
- package/web-dist/assets/one-light-C3Wv6jpd.js +0 -1
- package/web-dist/assets/openscad-C4EeE6gA.js +0 -1
- package/web-dist/assets/pascal-D93ZcfNL.js +0 -1
- package/web-dist/assets/perl-B9cMNwum.js +0 -1
- package/web-dist/assets/pkl-u5AG7uiY.js +0 -1
- package/web-dist/assets/plastic-3e1v2bzS.js +0 -1
- package/web-dist/assets/plsql-ChMvpjG-.js +0 -1
- package/web-dist/assets/po-BTJTHyun.js +0 -1
- package/web-dist/assets/poimandres-CS3Unz2-.js +0 -1
- package/web-dist/assets/polar-C0HS_06l.js +0 -1
- package/web-dist/assets/postcss-CXtECtnM.js +0 -1
- package/web-dist/assets/powerquery-CEu0bR-o.js +0 -1
- package/web-dist/assets/powershell-Dpen1YoG.js +0 -1
- package/web-dist/assets/prisma-Dd19v3D-.js +0 -1
- package/web-dist/assets/prolog-CbFg5uaA.js +0 -1
- package/web-dist/assets/proto-C7zT0LnQ.js +0 -1
- package/web-dist/assets/pug-DKIMFp6K.js +0 -1
- package/web-dist/assets/puppet-BMWR74SV.js +0 -1
- package/web-dist/assets/purescript-CklMAg4u.js +0 -1
- package/web-dist/assets/qml-3beO22l8.js +0 -1
- package/web-dist/assets/qmldir-C8lEn-DE.js +0 -1
- package/web-dist/assets/qss-IeuSbFQv.js +0 -1
- package/web-dist/assets/r-Dspwwk_N.js +0 -1
- package/web-dist/assets/racket-BqYA7rlc.js +0 -1
- package/web-dist/assets/raku-DXvB9xmW.js +0 -1
- package/web-dist/assets/razor-BjBPvh-w.js +0 -1
- package/web-dist/assets/red-bN70gL4F.js +0 -1
- package/web-dist/assets/reg-C-SQnVFl.js +0 -1
- package/web-dist/assets/regexp-CDVJQ6XC.js +0 -1
- package/web-dist/assets/rel-C3B-1QV4.js +0 -1
- package/web-dist/assets/riscv-BM1_JUlF.js +0 -1
- package/web-dist/assets/ron-D8l8udqQ.js +0 -1
- package/web-dist/assets/rose-pine-dawn-DHQR4-dF.js +0 -1
- package/web-dist/assets/rose-pine-moon-D4_iv3hh.js +0 -1
- package/web-dist/assets/rose-pine-qdsjHGoJ.js +0 -1
- package/web-dist/assets/rosmsg-BJDFO7_C.js +0 -1
- package/web-dist/assets/rst-CpCqk9r5.js +0 -1
- package/web-dist/assets/ruby-DyJCeAvU.js +0 -1
- package/web-dist/assets/sas-DEy46yEz.js +0 -1
- package/web-dist/assets/sass-Cj5Yp3dK.js +0 -1
- package/web-dist/assets/scala-C151Ov-r.js +0 -1
- package/web-dist/assets/scheme-C98Dy4si.js +0 -1
- package/web-dist/assets/scss-D5BDwBP9.js +0 -1
- package/web-dist/assets/sdbl-DVxCFoDh.js +0 -1
- package/web-dist/assets/shaderlab-Dg9Lc6iA.js +0 -1
- package/web-dist/assets/shellsession-BADoaaVG.js +0 -1
- package/web-dist/assets/slack-dark-BthQWCQV.js +0 -1
- package/web-dist/assets/slack-ochin-DqwNpetd.js +0 -1
- package/web-dist/assets/smalltalk-BERRCDM3.js +0 -1
- package/web-dist/assets/snazzy-light-Bw305WKR.js +0 -1
- package/web-dist/assets/solarized-dark-DXbdFlpD.js +0 -1
- package/web-dist/assets/solarized-light-L9t79GZl.js +0 -1
- package/web-dist/assets/solidity-rGO070M0.js +0 -1
- package/web-dist/assets/soy-8wufbnw4.js +0 -1
- package/web-dist/assets/sparql-rVzFXLq3.js +0 -1
- package/web-dist/assets/splunk-BtCnVYZw.js +0 -1
- package/web-dist/assets/ssh-config-_ykCGR6B.js +0 -1
- package/web-dist/assets/stata-DI20mbqo.js +0 -1
- package/web-dist/assets/stylus-BEDo0Tqx.js +0 -1
- package/web-dist/assets/surrealql-Bq5Q-fJD.js +0 -1
- package/web-dist/assets/svelte-Cy7k_4gC.js +0 -1
- package/web-dist/assets/swift-D82vCrfD.js +0 -1
- package/web-dist/assets/synthwave-84-CbfX1IO0.js +0 -1
- package/web-dist/assets/system-verilog-CnnmHF94.js +0 -1
- package/web-dist/assets/systemd-4A_iFExJ.js +0 -1
- package/web-dist/assets/talonscript-CkByrt1z.js +0 -1
- package/web-dist/assets/tasl-QIJgUcNo.js +0 -1
- package/web-dist/assets/tcl-dwOrl1Do.js +0 -1
- package/web-dist/assets/templ-DhtptRzy.js +0 -1
- package/web-dist/assets/terraform-BETggiCN.js +0 -1
- package/web-dist/assets/tex-idrVyKtj.js +0 -1
- package/web-dist/assets/tokyo-night-hegEt444.js +0 -1
- package/web-dist/assets/ts-tags-D351s5mN.js +0 -1
- package/web-dist/assets/tsv-B_m7g4N7.js +0 -1
- package/web-dist/assets/turtle-BsS91CYL.js +0 -1
- package/web-dist/assets/twig-CW1WmMYd.js +0 -1
- package/web-dist/assets/typespec-CAFt9gP4.js +0 -1
- package/web-dist/assets/typst-DHCkPAjA.js +0 -1
- package/web-dist/assets/v-BcVCzyr7.js +0 -1
- package/web-dist/assets/vala-CsfeWuGM.js +0 -1
- package/web-dist/assets/vb-D17OF-Vu.js +0 -1
- package/web-dist/assets/verilog-BQ8w6xss.js +0 -1
- package/web-dist/assets/vesper-DRje8inN.js +0 -1
- package/web-dist/assets/vhdl-CeAyd5Ju.js +0 -1
- package/web-dist/assets/viml-CJc9bBzg.js +0 -1
- package/web-dist/assets/vitesse-black-Bkuqu6BP.js +0 -1
- package/web-dist/assets/vitesse-dark-D0r3Knsf.js +0 -1
- package/web-dist/assets/vitesse-light-CVO1_9PV.js +0 -1
- package/web-dist/assets/vue-D2xRrEX4.js +0 -1
- package/web-dist/assets/vue-html-AaS7Mt5G.js +0 -1
- package/web-dist/assets/vue-vine-BoDAl6tE.js +0 -1
- package/web-dist/assets/vyper-CDx5xZoG.js +0 -1
- package/web-dist/assets/wasm-CG6Dc4jp.js +0 -1
- package/web-dist/assets/wasm-MzD3tlZU.js +0 -1
- package/web-dist/assets/wenyan-BV7otONQ.js +0 -1
- package/web-dist/assets/wgsl-Dx-B1_4e.js +0 -1
- package/web-dist/assets/wikitext-BhOHFoWU.js +0 -1
- package/web-dist/assets/wit-5i3qLPDT.js +0 -1
- package/web-dist/assets/wolfram-lXgVvXCa.js +0 -1
- package/web-dist/assets/xsl-CtQFsRM5.js +0 -1
- package/web-dist/assets/zenscript-DVFEvuxE.js +0 -1
- package/web-dist/assets/zig-VOosw3JB.js +0 -1
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
// src/db/database.ts
|
|
2
|
+
import { mkdirSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
// src/db/schema.ts
|
|
6
|
+
var SCHEMA_SQL = `
|
|
7
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
8
|
+
user_id TEXT PRIMARY KEY, -- semantic id doubles as login name: ^[a-z][a-z0-9_-]{1,31}$
|
|
9
|
+
password_hash TEXT NOT NULL, -- scrypt$N$r$p$salt$hash(base64)
|
|
10
|
+
is_admin INTEGER NOT NULL DEFAULT 0, -- 1 for the built-in admin (seeded at startup)
|
|
11
|
+
password_is_initial INTEGER NOT NULL DEFAULT 0, -- 1=initial password (seeded/admin-set); cleared once the user changes it
|
|
12
|
+
created_at TEXT NOT NULL
|
|
13
|
+
);
|
|
14
|
+
CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
15
|
+
token_hash TEXT PRIMARY KEY, -- sha256(token) hex; the cookie stores only the raw token
|
|
16
|
+
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
|
17
|
+
created_at TEXT NOT NULL,
|
|
18
|
+
expires_at TEXT NOT NULL, -- 7-day sliding renewal (topped up when <6 days remain)
|
|
19
|
+
via TEXT -- 'password' | 'desktop'; NULL = legacy row (password)
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
22
|
+
project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml
|
|
23
|
+
owner_user_id TEXT NOT NULL REFERENCES users(user_id),
|
|
24
|
+
created_at TEXT NOT NULL
|
|
25
|
+
);
|
|
26
|
+
CREATE TABLE IF NOT EXISTS project_members ( -- member grants only; owners are not stored here
|
|
27
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
|
28
|
+
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
|
29
|
+
created_at TEXT NOT NULL,
|
|
30
|
+
PRIMARY KEY (project_id, user_id)
|
|
31
|
+
);
|
|
32
|
+
CREATE TABLE IF NOT EXISTS agents ( -- index only; name/description live in system_config.yaml
|
|
33
|
+
project_id TEXT NOT NULL,
|
|
34
|
+
agent_id TEXT NOT NULL,
|
|
35
|
+
created_at TEXT NOT NULL,
|
|
36
|
+
PRIMARY KEY (project_id, agent_id)
|
|
37
|
+
);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
39
|
+
session_id TEXT PRIMARY KEY,
|
|
40
|
+
project_id TEXT NOT NULL,
|
|
41
|
+
agent_id TEXT NOT NULL,
|
|
42
|
+
provider TEXT NOT NULL, -- provider group of the session model (paired with model_id as a model reference)
|
|
43
|
+
model_id TEXT NOT NULL, -- upstream model id (sent to AgentHub as-is; never concatenate <provider>/<id>)
|
|
44
|
+
workspace TEXT NOT NULL,
|
|
45
|
+
approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask
|
|
46
|
+
thinking_level TEXT, -- level pinned for THIS session (none|low|medium|high|xhigh|max); NULL=not pinned, runs follow the Agent config
|
|
47
|
+
title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat")
|
|
48
|
+
archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived")
|
|
49
|
+
client TEXT, -- creating client: 'web' (created via the Web App) | 'cli' (adopted from a CLI Trace); NULL = legacy row, treated as web
|
|
50
|
+
has_trace INTEGER NOT NULL DEFAULT 0, -- cache: a Trace record exists (set at task start / adoption / subagent registration; lazily backfilled by list hydration)
|
|
51
|
+
fork_count INTEGER NOT NULL DEFAULT 0, -- monotonically allocated child-fork number for this source Session; never decremented when a fork is deleted
|
|
52
|
+
last_active_at TEXT, -- last activity this server drove for the session (ISO; stamped once when a run starts and once when it ends, initialized to created_at); monotonic, never moves backwards; NULL only before openDatabase's one-time backfill
|
|
53
|
+
created_at TEXT NOT NULL
|
|
54
|
+
); -- the schedule/subagent SOURCE is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts); "client" is a different, DB-only axis (who created the row)
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_agent_created ON sessions(project_id, agent_id, created_at DESC);
|
|
56
|
+
CREATE TABLE IF NOT EXISTS usage_records (
|
|
57
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
58
|
+
ts TEXT NOT NULL,
|
|
59
|
+
date TEXT NOT NULL, -- local date yyyy-mm-dd (aggregation key; same convention as Trace date directories)
|
|
60
|
+
project_id TEXT NOT NULL,
|
|
61
|
+
agent_id TEXT NOT NULL,
|
|
62
|
+
session_id TEXT NOT NULL, -- top-level Session (child-session usage counts toward its main Session)
|
|
63
|
+
origin_session_id TEXT, -- immediate source child Session (last of the origin chain); NULL=main session
|
|
64
|
+
provider TEXT NOT NULL, -- provider group: paired with model_id as the attribution key; always GROUP BY provider, model_id
|
|
65
|
+
model_id TEXT NOT NULL, -- upstream model id (paired with provider; same model_id across providers aggregates separately)
|
|
66
|
+
cache_read INTEGER NOT NULL,
|
|
67
|
+
cache_write INTEGER NOT NULL,
|
|
68
|
+
output INTEGER NOT NULL,
|
|
69
|
+
total INTEGER NOT NULL, -- from token_usage.request (one row per Request)
|
|
70
|
+
status TEXT NOT NULL DEFAULT 'completed' -- request outcome: completed=success (with tokens); others=failure (0 tokens, for success rate)
|
|
71
|
+
); -- cost is not stored: computed at query time from current pricing
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_usage_project_date ON usage_records(project_id, date);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_usage_session_ts ON usage_records(session_id, ts); -- (session_id) alone was idx_usage_session (dropped on open): ts makes the last_active_at backfill's MAX(ts) a covering index scan instead of a random row lookup per usage record
|
|
74
|
+
CREATE TABLE IF NOT EXISTS error_records ( -- server-side error capture (the Costs page's "Errors" tab)
|
|
75
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
76
|
+
ts TEXT NOT NULL,
|
|
77
|
+
date TEXT NOT NULL, -- local date yyyy-mm-dd (same convention as usage_records)
|
|
78
|
+
project_id TEXT, -- nullable: sign-in/registration and process-level errors have no Project context
|
|
79
|
+
agent_id TEXT,
|
|
80
|
+
session_id TEXT,
|
|
81
|
+
source TEXT NOT NULL, -- http | session | usage | title | subagent | process | llm | environment | compaction | schedule
|
|
82
|
+
kind TEXT NOT NULL, -- expected (HttpError, business 4xx) | unexpected (500/runtime)
|
|
83
|
+
code TEXT NOT NULL, -- HttpError.code / internal / session_run_failed / ...
|
|
84
|
+
status INTEGER, -- HTTP status code; NULL for non-HTTP sources
|
|
85
|
+
message TEXT NOT NULL -- truncated to 500 chars (no stack stored: stacks go to logs only)
|
|
86
|
+
);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_error_project_date ON error_records(project_id, date);
|
|
88
|
+
CREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files are declarative intent; the system never writes back)
|
|
89
|
+
project_id TEXT NOT NULL,
|
|
90
|
+
agent_id TEXT NOT NULL,
|
|
91
|
+
name TEXT NOT NULL, -- file name (without .toml) doubles as the identifier
|
|
92
|
+
creator_user_id TEXT, -- creator (recorded on API create; hand-edited file reconciliation falls back to the Project owner)
|
|
93
|
+
start_at_ms INTEGER NOT NULL, -- defines identity: a start_at change counts as a new task instance, resetting fire state
|
|
94
|
+
def_hash TEXT NOT NULL, -- file content fingerprint: any change clears the invalid flag (the file takes effect again after edits)
|
|
95
|
+
last_slot_ms INTEGER, -- latest due slot already consumed (advances on fire or skip; no re-fire across restarts)
|
|
96
|
+
last_fired_at TEXT, -- last actual fire time (for display)
|
|
97
|
+
fired_once INTEGER NOT NULL DEFAULT 0, -- one-shot task has fired
|
|
98
|
+
missed INTEGER NOT NULL DEFAULT 0, -- one-shot task was missed (flagged by startup/registration reconciliation; not backfilled)
|
|
99
|
+
invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy
|
|
100
|
+
PRIMARY KEY (project_id, agent_id, name)
|
|
101
|
+
);
|
|
102
|
+
CREATE TABLE IF NOT EXISTS goal_state ( -- goal-mode runtime state (GOAL.yaml on disk is the model-facing protocol; this row is what the UI reads)
|
|
103
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, -- one row per goal run; a Session may run goals repeatedly, latest row wins for display
|
|
104
|
+
session_id TEXT NOT NULL,
|
|
105
|
+
project_id TEXT NOT NULL,
|
|
106
|
+
agent_id TEXT NOT NULL,
|
|
107
|
+
objective TEXT NOT NULL,
|
|
108
|
+
status TEXT NOT NULL, -- active | complete | blocked | budget_limited | aborted (aborted is server-side only; on-disk status stays active)
|
|
109
|
+
budget INTEGER NOT NULL, -- token budget; -1 = unlimited
|
|
110
|
+
used INTEGER NOT NULL DEFAULT 0, -- uncached input + output, refreshed per round (mirrors the runner's accounting)
|
|
111
|
+
rounds INTEGER NOT NULL DEFAULT 0,
|
|
112
|
+
created_at TEXT NOT NULL,
|
|
113
|
+
updated_at TEXT NOT NULL
|
|
114
|
+
);
|
|
115
|
+
CREATE INDEX IF NOT EXISTS idx_goal_session ON goal_state(session_id);
|
|
116
|
+
CREATE TABLE IF NOT EXISTS ui_prefs (
|
|
117
|
+
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
|
118
|
+
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
|
|
119
|
+
);
|
|
120
|
+
CREATE TABLE IF NOT EXISTS server_settings ( -- admin-level server-global settings (GET/PUT /api/admin/settings)
|
|
121
|
+
key TEXT PRIMARY KEY, -- setting name, e.g. 'proxy_for_app'
|
|
122
|
+
value TEXT NOT NULL -- JSON-encoded value; an absent row means the setting's built-in default
|
|
123
|
+
);
|
|
124
|
+
CREATE TABLE IF NOT EXISTS trace_files ( -- DERIVED CACHE of the on-disk Trace tree (services/trace-index.ts): the directories stay the single source of truth, every row is rebuildable from disk, and a row is never authority for absence \u2014 consumers reconcile + retry on a miss, so a stale index costs one extra scan, never a false 404
|
|
125
|
+
project_id TEXT NOT NULL,
|
|
126
|
+
agent_id TEXT NOT NULL,
|
|
127
|
+
session_id TEXT NOT NULL,
|
|
128
|
+
file_index INTEGER NOT NULL, -- shard index NNN of <session_id>_NNN.jsonl (name/path are reconstructed from the row, never stored: the data root may move)
|
|
129
|
+
date TEXT NOT NULL, -- date directory name (local yyyy-mm-dd)
|
|
130
|
+
size_bytes INTEGER NOT NULL, -- last observed size (listings stat-refresh the returned page and write back; an actively-appended shard may lag in between)
|
|
131
|
+
page_stats TEXT, -- cached message-window scan state at this shard's END (services/message-window.ts ShardPrefixRecord JSON); immutable shards only, NULL = not yet computed, nulled whenever size_bytes changes
|
|
132
|
+
PRIMARY KEY (project_id, agent_id, session_id, file_index)
|
|
133
|
+
);
|
|
134
|
+
CREATE INDEX IF NOT EXISTS idx_trace_files_agent_date ON trace_files(project_id, agent_id, date);
|
|
135
|
+
CREATE INDEX IF NOT EXISTS idx_trace_files_session ON trace_files(session_id);
|
|
136
|
+
CREATE TABLE IF NOT EXISTS trace_sessions ( -- per-session facts read ONCE at registration from the earliest shard's head (same derived-cache rules as trace_files; listing needs zero head-reads)
|
|
137
|
+
session_id TEXT PRIMARY KEY,
|
|
138
|
+
project_id TEXT NOT NULL,
|
|
139
|
+
agent_id TEXT NOT NULL,
|
|
140
|
+
source TEXT, -- session_meta origin: 'subagent' | 'schedule' | NULL = user-created (or head not yet readable)
|
|
141
|
+
workspace TEXT NOT NULL DEFAULT '',
|
|
142
|
+
title TEXT, -- first-prompt fallback title (sessions.title always wins when present)
|
|
143
|
+
provider TEXT, -- model reference from session_meta (CLI adoption reads it from here; NULL = meta unreadable / legacy without provider)
|
|
144
|
+
model_id TEXT,
|
|
145
|
+
first_ts TEXT, -- first record's timestamp (adoption's createdAt fallback when the id embeds no time)
|
|
146
|
+
meta_read INTEGER NOT NULL DEFAULT 0 -- 1 once the head parsed; 0 = facts unknown, retried by the next reconcile that touches the session
|
|
147
|
+
);
|
|
148
|
+
CREATE INDEX IF NOT EXISTS idx_trace_sessions_agent ON trace_sessions(project_id, agent_id);
|
|
149
|
+
`;
|
|
150
|
+
|
|
151
|
+
// src/db/database.ts
|
|
152
|
+
var sqlite = process.getBuiltinModule("node:sqlite");
|
|
153
|
+
function openDatabase(dbPath) {
|
|
154
|
+
if (dbPath !== ":memory:") {
|
|
155
|
+
mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
156
|
+
}
|
|
157
|
+
const db = new sqlite.DatabaseSync(dbPath);
|
|
158
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
159
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
160
|
+
db.exec(SCHEMA_SQL);
|
|
161
|
+
ensureColumn(db, "sessions", "client", "TEXT");
|
|
162
|
+
ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0");
|
|
163
|
+
ensureColumn(db, "sessions", "fork_count", "INTEGER NOT NULL DEFAULT 0");
|
|
164
|
+
ensureColumn(db, "sessions", "thinking_level", "TEXT");
|
|
165
|
+
ensureColumn(db, "auth_sessions", "via", "TEXT");
|
|
166
|
+
ensureColumn(db, "trace_files", "page_stats", "TEXT");
|
|
167
|
+
db.exec("DROP INDEX IF EXISTS idx_usage_session");
|
|
168
|
+
upgradeLastActiveAt(db);
|
|
169
|
+
return db;
|
|
170
|
+
}
|
|
171
|
+
function upgradeLastActiveAt(db) {
|
|
172
|
+
db.exec("BEGIN");
|
|
173
|
+
try {
|
|
174
|
+
if (ensureColumn(db, "sessions", "last_active_at", "TEXT")) {
|
|
175
|
+
db.exec(
|
|
176
|
+
`UPDATE sessions SET last_active_at = COALESCE(
|
|
177
|
+
(SELECT MAX(ts) FROM usage_records WHERE usage_records.session_id = sessions.session_id),
|
|
178
|
+
created_at
|
|
179
|
+
)`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
db.exec("COMMIT");
|
|
183
|
+
} catch (err) {
|
|
184
|
+
db.exec("ROLLBACK");
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function ensureColumn(db, table, column, ddl) {
|
|
189
|
+
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
190
|
+
if (cols.some((c) => c.name === column)) return false;
|
|
191
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/db/repos/auth-sessions.ts
|
|
196
|
+
var AuthSessionsRepo = class {
|
|
197
|
+
constructor(db) {
|
|
198
|
+
this.db = db;
|
|
199
|
+
}
|
|
200
|
+
db;
|
|
201
|
+
insert(row) {
|
|
202
|
+
this.db.prepare(
|
|
203
|
+
"INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at, via) VALUES (?, ?, ?, ?, ?)"
|
|
204
|
+
).run(row.tokenHash, row.userId, row.createdAt, row.expiresAt, row.via);
|
|
205
|
+
}
|
|
206
|
+
findByTokenHash(tokenHash) {
|
|
207
|
+
const r = this.db.prepare("SELECT * FROM auth_sessions WHERE token_hash = ?").get(tokenHash);
|
|
208
|
+
if (!r) return null;
|
|
209
|
+
return {
|
|
210
|
+
tokenHash: r.token_hash,
|
|
211
|
+
userId: r.user_id,
|
|
212
|
+
createdAt: r.created_at,
|
|
213
|
+
expiresAt: r.expires_at,
|
|
214
|
+
via: r.via ?? null
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/** Sliding renewal: update the expiration time. */
|
|
218
|
+
touch(tokenHash, expiresAt) {
|
|
219
|
+
this.db.prepare("UPDATE auth_sessions SET expires_at = ? WHERE token_hash = ?").run(expiresAt, tokenHash);
|
|
220
|
+
}
|
|
221
|
+
delete(tokenHash) {
|
|
222
|
+
this.db.prepare("DELETE FROM auth_sessions WHERE token_hash = ?").run(tokenHash);
|
|
223
|
+
}
|
|
224
|
+
/** Opportunistically clean up expired sessions (called during login/validation). */
|
|
225
|
+
deleteExpired(nowIso) {
|
|
226
|
+
this.db.prepare("DELETE FROM auth_sessions WHERE expires_at < ?").run(nowIso);
|
|
227
|
+
}
|
|
228
|
+
/** Clear all sessions for a user (forces re-login after an admin resets the password). */
|
|
229
|
+
deleteByUser(userId) {
|
|
230
|
+
this.db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").run(userId);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// src/db/repos/users.ts
|
|
235
|
+
function mapRow(r) {
|
|
236
|
+
return {
|
|
237
|
+
userId: r.user_id,
|
|
238
|
+
passwordHash: r.password_hash,
|
|
239
|
+
isAdmin: r.is_admin === 1,
|
|
240
|
+
passwordIsInitial: r.password_is_initial === 1,
|
|
241
|
+
createdAt: r.created_at
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
var UsersRepo = class {
|
|
245
|
+
constructor(db) {
|
|
246
|
+
this.db = db;
|
|
247
|
+
}
|
|
248
|
+
db;
|
|
249
|
+
insert(row) {
|
|
250
|
+
this.db.prepare(
|
|
251
|
+
"INSERT INTO users (user_id, password_hash, is_admin, password_is_initial, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
252
|
+
).run(
|
|
253
|
+
row.userId,
|
|
254
|
+
row.passwordHash,
|
|
255
|
+
row.isAdmin ? 1 : 0,
|
|
256
|
+
row.passwordIsInitial ? 1 : 0,
|
|
257
|
+
row.createdAt
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
findById(userId) {
|
|
261
|
+
const r = this.db.prepare("SELECT * FROM users WHERE user_id = ?").get(userId);
|
|
262
|
+
return r ? mapRow(r) : null;
|
|
263
|
+
}
|
|
264
|
+
/** All users (for the admin user backend), ordered by creation time ascending. */
|
|
265
|
+
list() {
|
|
266
|
+
const rows = this.db.prepare("SELECT * FROM users ORDER BY created_at ASC, user_id ASC").all();
|
|
267
|
+
return rows.map(mapRow);
|
|
268
|
+
}
|
|
269
|
+
count() {
|
|
270
|
+
const r = this.db.prepare("SELECT COUNT(*) AS n FROM users").get();
|
|
271
|
+
return r?.n ?? 0;
|
|
272
|
+
}
|
|
273
|
+
/** Update the password hash; isInitial marks whether the password was set by someone else (seed / admin). */
|
|
274
|
+
updatePassword(userId, passwordHash, isInitial) {
|
|
275
|
+
this.db.prepare("UPDATE users SET password_hash = ?, password_is_initial = ? WHERE user_id = ?").run(passwordHash, isInitial ? 1 : 0, userId);
|
|
276
|
+
}
|
|
277
|
+
/** Used by admin user deletion and account-creation compensation paths (owned Projects must be cleaned up first). */
|
|
278
|
+
delete(userId) {
|
|
279
|
+
this.db.prepare("DELETE FROM users WHERE user_id = ?").run(userId);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// src/auth/password.ts
|
|
284
|
+
import { randomBytes, scrypt, timingSafeEqual } from "crypto";
|
|
285
|
+
var SCRYPT_N = 16384;
|
|
286
|
+
var SCRYPT_R = 8;
|
|
287
|
+
var SCRYPT_P = 1;
|
|
288
|
+
var SALT_BYTES = 16;
|
|
289
|
+
var KEY_BYTES = 64;
|
|
290
|
+
function scryptAsync(password, salt, keyLen, n, r, p) {
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
scrypt(password, salt, keyLen, { N: n, r, p, maxmem: 128 * 1024 * 1024 }, (err, key) => {
|
|
293
|
+
if (err) reject(err);
|
|
294
|
+
else resolve(key);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
async function hashPassword(password) {
|
|
299
|
+
const salt = randomBytes(SALT_BYTES);
|
|
300
|
+
const key = await scryptAsync(password, salt, KEY_BYTES, SCRYPT_N, SCRYPT_R, SCRYPT_P);
|
|
301
|
+
return [
|
|
302
|
+
"scrypt",
|
|
303
|
+
String(SCRYPT_N),
|
|
304
|
+
String(SCRYPT_R),
|
|
305
|
+
String(SCRYPT_P),
|
|
306
|
+
salt.toString("base64"),
|
|
307
|
+
key.toString("base64")
|
|
308
|
+
].join("$");
|
|
309
|
+
}
|
|
310
|
+
async function verifyPassword(password, stored) {
|
|
311
|
+
const parts = stored.split("$");
|
|
312
|
+
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
|
|
313
|
+
const n = Number(parts[1]);
|
|
314
|
+
const r = Number(parts[2]);
|
|
315
|
+
const p = Number(parts[3]);
|
|
316
|
+
if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false;
|
|
317
|
+
let salt;
|
|
318
|
+
let expected;
|
|
319
|
+
try {
|
|
320
|
+
salt = Buffer.from(parts[4], "base64");
|
|
321
|
+
expected = Buffer.from(parts[5], "base64");
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
if (salt.length === 0 || expected.length === 0) return false;
|
|
326
|
+
let actual;
|
|
327
|
+
try {
|
|
328
|
+
actual = await scryptAsync(password, salt, expected.length, n, r, p);
|
|
329
|
+
} catch {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// src/auth/service.ts
|
|
336
|
+
import { createHash, randomBytes as randomBytes2, randomInt } from "crypto";
|
|
337
|
+
|
|
338
|
+
// src/http/errors.ts
|
|
339
|
+
var HttpError = class extends Error {
|
|
340
|
+
constructor(status, code, message) {
|
|
341
|
+
super(message);
|
|
342
|
+
this.status = status;
|
|
343
|
+
this.code = code;
|
|
344
|
+
this.name = "HttpError";
|
|
345
|
+
}
|
|
346
|
+
status;
|
|
347
|
+
code;
|
|
348
|
+
};
|
|
349
|
+
function errorBody(code, message) {
|
|
350
|
+
return { error: { code, message } };
|
|
351
|
+
}
|
|
352
|
+
function isMissingCredential(err) {
|
|
353
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
354
|
+
return /missing credentials|api[_ ]?key/i.test(message);
|
|
355
|
+
}
|
|
356
|
+
function modelCredentialMissing(modelId) {
|
|
357
|
+
return new HttpError(
|
|
358
|
+
400,
|
|
359
|
+
"model_credential_missing",
|
|
360
|
+
`Model ${modelId} has no API key yet. Configure it on the Models page first.`
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
function handleError(err, c) {
|
|
364
|
+
if (err instanceof HttpError) {
|
|
365
|
+
return c.json(errorBody(err.code, err.message), err.status);
|
|
366
|
+
}
|
|
367
|
+
console.error(`[server] Unhandled exception: ${err.stack ?? err.message}`);
|
|
368
|
+
return c.json(errorBody("internal", "Internal server error."), 500);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/auth/service.ts
|
|
372
|
+
var MIN_PASSWORD_LENGTH = 8;
|
|
373
|
+
var ADMIN_USER_ID = "admin";
|
|
374
|
+
var LOGIN_FREE_ATTEMPTS = 5;
|
|
375
|
+
var LOGIN_BACKOFF_START_MS = 1e3;
|
|
376
|
+
var LOGIN_BACKOFF_CAP_MS = 6e4;
|
|
377
|
+
var LOGIN_FAILURE_IDLE_MS = 15 * 6e4;
|
|
378
|
+
function generateInitialAdminPassword() {
|
|
379
|
+
return "penguin-" + String(randomInt(0, 1e4)).padStart(4, "0");
|
|
380
|
+
}
|
|
381
|
+
function sha256Hex(value) {
|
|
382
|
+
return createHash("sha256").update(value).digest("hex");
|
|
383
|
+
}
|
|
384
|
+
function toUserInfo(row) {
|
|
385
|
+
return {
|
|
386
|
+
userId: row.userId,
|
|
387
|
+
isAdmin: row.isAdmin,
|
|
388
|
+
passwordIsInitial: row.passwordIsInitial,
|
|
389
|
+
createdAt: row.createdAt
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
var AuthService = class {
|
|
393
|
+
constructor(deps) {
|
|
394
|
+
this.deps = deps;
|
|
395
|
+
this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
396
|
+
}
|
|
397
|
+
deps;
|
|
398
|
+
now;
|
|
399
|
+
/**
|
|
400
|
+
* Startup seeding (idempotent): creates the built-in admin and adopts
|
|
401
|
+
* default_project when the users table is empty; if the initial Project fails,
|
|
402
|
+
* the user row is rolled back and the server retries on next startup.
|
|
403
|
+
* Returns the initial password when it actually seeded — the caller prints it,
|
|
404
|
+
* the only place a generated password is ever shown — and null when users
|
|
405
|
+
* already exist.
|
|
406
|
+
*/
|
|
407
|
+
async seedAdmin() {
|
|
408
|
+
if (this.deps.users.count() > 0) return null;
|
|
409
|
+
const password = this.deps.seedAdminPassword ?? generateInitialAdminPassword();
|
|
410
|
+
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
`PENGUIN_SEED_ADMIN_PASSWORD must be at least ${MIN_PASSWORD_LENGTH} characters.`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
const user = {
|
|
416
|
+
userId: ADMIN_USER_ID,
|
|
417
|
+
passwordHash: await hashPassword(password),
|
|
418
|
+
isAdmin: true,
|
|
419
|
+
passwordIsInitial: true,
|
|
420
|
+
createdAt: this.now().toISOString()
|
|
421
|
+
};
|
|
422
|
+
this.deps.users.insert(user);
|
|
423
|
+
try {
|
|
424
|
+
await this.deps.provisionInitialProject(user, true);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
this.deps.users.delete(user.userId);
|
|
427
|
+
throw err;
|
|
428
|
+
}
|
|
429
|
+
return password;
|
|
430
|
+
}
|
|
431
|
+
/** Whether the built-in admin still runs on its initial password (drives the startup reminder notice). */
|
|
432
|
+
adminPasswordIsInitial() {
|
|
433
|
+
return this.deps.users.findById(ADMIN_USER_ID)?.passwordIsInitial === true;
|
|
434
|
+
}
|
|
435
|
+
/** Consecutive login failures per userId (see the throttling comment on the constants). */
|
|
436
|
+
loginFailures = /* @__PURE__ */ new Map();
|
|
437
|
+
/** The wait imposed after `failures` consecutive failures (0 while within the free attempts). */
|
|
438
|
+
loginDelayMs(failures) {
|
|
439
|
+
const excess = failures - LOGIN_FREE_ATTEMPTS;
|
|
440
|
+
if (excess <= 0) return 0;
|
|
441
|
+
return Math.min(LOGIN_BACKOFF_START_MS * 2 ** (excess - 1), LOGIN_BACKOFF_CAP_MS);
|
|
442
|
+
}
|
|
443
|
+
async login(userId, password) {
|
|
444
|
+
const nowMs = this.now().getTime();
|
|
445
|
+
for (const [key, entry] of this.loginFailures) {
|
|
446
|
+
if (nowMs - entry.lastFailureAt > LOGIN_FAILURE_IDLE_MS) this.loginFailures.delete(key);
|
|
447
|
+
}
|
|
448
|
+
const failed = this.loginFailures.get(userId);
|
|
449
|
+
if (failed) {
|
|
450
|
+
const readyAt = failed.lastFailureAt + this.loginDelayMs(failed.failures);
|
|
451
|
+
if (nowMs < readyAt) {
|
|
452
|
+
throw new HttpError(
|
|
453
|
+
429,
|
|
454
|
+
"too_many_attempts",
|
|
455
|
+
`Too many failed sign-in attempts. Try again in ${Math.ceil((readyAt - nowMs) / 1e3)}s.`
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const row = this.deps.users.findById(userId);
|
|
460
|
+
const ok = row !== null && await verifyPassword(password, row.passwordHash);
|
|
461
|
+
if (!row || !ok) {
|
|
462
|
+
this.loginFailures.set(userId, {
|
|
463
|
+
failures: (failed?.failures ?? 0) + 1,
|
|
464
|
+
lastFailureAt: this.now().getTime()
|
|
465
|
+
});
|
|
466
|
+
throw new HttpError(401, "invalid_credentials", "Incorrect username or password.");
|
|
467
|
+
}
|
|
468
|
+
this.loginFailures.delete(userId);
|
|
469
|
+
this.deps.authSessions.deleteExpired(this.now().toISOString());
|
|
470
|
+
return { user: toUserInfo(row), token: this.issueSession(row.userId, "password") };
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Desktop-mode sign-in: issues an admin session WITHOUT a password check — the caller
|
|
474
|
+
* (the desktop-login route) has already redeemed the shell's one-shot token, which is
|
|
475
|
+
* the credential here. Throws if the admin has not been seeded yet (desktop-login runs
|
|
476
|
+
* after startup seeding, so this only trips on a broken deployment).
|
|
477
|
+
*/
|
|
478
|
+
loginDesktop() {
|
|
479
|
+
const row = this.deps.users.findById(ADMIN_USER_ID);
|
|
480
|
+
if (!row) {
|
|
481
|
+
throw new HttpError(500, "internal", "Built-in admin has not been seeded.");
|
|
482
|
+
}
|
|
483
|
+
this.deps.authSessions.deleteExpired(this.now().toISOString());
|
|
484
|
+
return { user: toUserInfo(row), token: this.issueSession(row.userId, "desktop") };
|
|
485
|
+
}
|
|
486
|
+
/** Self password change (user settings): validates the old password, and on success clears the initial-password flag; the current session remains valid. */
|
|
487
|
+
async changePassword(userId, oldPassword, newPassword) {
|
|
488
|
+
const row = this.deps.users.findById(userId);
|
|
489
|
+
if (!row || !await verifyPassword(oldPassword, row.passwordHash)) {
|
|
490
|
+
throw new HttpError(400, "password_mismatch", "Current password is incorrect.");
|
|
491
|
+
}
|
|
492
|
+
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
493
|
+
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
|
|
494
|
+
}
|
|
495
|
+
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
|
|
496
|
+
this.deps.onPasswordChanged?.(userId);
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Desktop-session password set: no old-password check. Only reachable for sessions
|
|
500
|
+
* established via desktop-login (the me route gates on sessionVia) — the seed password
|
|
501
|
+
* of a desktop-created root is random and never shown, so its holder has nothing to
|
|
502
|
+
* type into an old-password field; the shell's token already proved machine ownership.
|
|
503
|
+
*/
|
|
504
|
+
async setPasswordDesktop(userId, newPassword) {
|
|
505
|
+
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
506
|
+
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
|
|
507
|
+
}
|
|
508
|
+
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
|
|
509
|
+
this.deps.onPasswordChanged?.(userId);
|
|
510
|
+
}
|
|
511
|
+
logout(token) {
|
|
512
|
+
this.deps.authSessions.delete(sha256Hex(token));
|
|
513
|
+
}
|
|
514
|
+
/** Validates the cookie token: returns null if expired/unknown; sliding renewal once less than 6 days remain. */
|
|
515
|
+
authenticateWithMeta(token) {
|
|
516
|
+
const tokenHash = sha256Hex(token);
|
|
517
|
+
const session = this.deps.authSessions.findByTokenHash(tokenHash);
|
|
518
|
+
if (!session) return null;
|
|
519
|
+
const now = this.now();
|
|
520
|
+
const expiresAt = Date.parse(session.expiresAt);
|
|
521
|
+
if (!(expiresAt > now.getTime())) {
|
|
522
|
+
this.deps.authSessions.delete(tokenHash);
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
if (expiresAt - now.getTime() < this.deps.sessionRenewMs) {
|
|
526
|
+
this.deps.authSessions.touch(
|
|
527
|
+
tokenHash,
|
|
528
|
+
new Date(now.getTime() + this.deps.sessionTtlMs).toISOString()
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
const user = this.deps.users.findById(session.userId);
|
|
532
|
+
if (!user) return null;
|
|
533
|
+
return { user, via: session.via === "desktop" ? "desktop" : "password" };
|
|
534
|
+
}
|
|
535
|
+
issueSession(userId, via) {
|
|
536
|
+
const token = randomBytes2(32).toString("base64url");
|
|
537
|
+
const now = this.now();
|
|
538
|
+
this.deps.authSessions.insert({
|
|
539
|
+
tokenHash: sha256Hex(token),
|
|
540
|
+
userId,
|
|
541
|
+
createdAt: now.toISOString(),
|
|
542
|
+
expiresAt: new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
|
|
543
|
+
via
|
|
544
|
+
});
|
|
545
|
+
return token;
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
export {
|
|
550
|
+
openDatabase,
|
|
551
|
+
AuthSessionsRepo,
|
|
552
|
+
UsersRepo,
|
|
553
|
+
HttpError,
|
|
554
|
+
errorBody,
|
|
555
|
+
isMissingCredential,
|
|
556
|
+
modelCredentialMissing,
|
|
557
|
+
handleError,
|
|
558
|
+
hashPassword,
|
|
559
|
+
MIN_PASSWORD_LENGTH,
|
|
560
|
+
ADMIN_USER_ID,
|
|
561
|
+
generateInitialAdminPassword,
|
|
562
|
+
toUserInfo,
|
|
563
|
+
AuthService
|
|
564
|
+
};
|
|
565
|
+
//# sourceMappingURL=chunk-3QINBW3W.js.map
|