From 3c044d42df1ea3ef50190b31bbb5c5d67fcbcc35 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Fri, 14 Aug 2026 23:17:18 +0200 Subject: [PATCH] Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several concurrent workstreams committed together as a checkpoint: - Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic, AndroidManifest + build.ps1 - Theseus password manager — settings.html/chrome.html/settings-preload.js UI + main.js wiring + package.json resource; Argus password-vault.js, record-picker.js (+ tests) and resolver-web.d.ts - Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored) - Argus public-gateway.mjs updates - Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant), site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup, Decentralized Storage map, coordination notes - .gitignore — exclude /.keys/ and Hephaestus/.env --- PROTOCOL.md | 129 +++ README.md | 94 ++ auth-proxy/Dockerfile | 20 + auth-proxy/package-lock.json | 1657 ++++++++++++++++++++++++++++++++++ auth-proxy/package.json | 24 + auth-proxy/public/login.css | 64 ++ auth-proxy/public/wallet.js | 298 ++++++ auth-proxy/src/index.ts | 248 +++++ auth-proxy/src/oidc.ts | 74 ++ auth-proxy/src/sessions.ts | 82 ++ auth-proxy/src/verify.ts | 115 +++ auth-proxy/tsconfig.json | 16 + docker-compose.yml | 90 ++ scripts/backup-restic.sh | 51 ++ scripts/smoke-test-s3.sh | 75 ++ 15 files changed, 3037 insertions(+) create mode 100644 PROTOCOL.md create mode 100644 README.md create mode 100644 auth-proxy/Dockerfile create mode 100644 auth-proxy/package-lock.json create mode 100644 auth-proxy/package.json create mode 100644 auth-proxy/public/login.css create mode 100644 auth-proxy/public/wallet.js create mode 100644 auth-proxy/src/index.ts create mode 100644 auth-proxy/src/oidc.ts create mode 100644 auth-proxy/src/sessions.ts create mode 100644 auth-proxy/src/verify.ts create mode 100644 auth-proxy/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 scripts/backup-restic.sh create mode 100644 scripts/smoke-test-s3.sh diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..a162e1f --- /dev/null +++ b/PROTOCOL.md @@ -0,0 +1,129 @@ +# Hephaestus Wallet-Auth Protocol + +Version: 0.1 (MVP) +Signing scheme: **BIP-137-style "Bitcoin Signed Message" on Bitcoin Cash** + +The point of this document is that any BCH wallet — Electron Cash, Cashonize, Zapit, or anything else that implements "Sign Message" — can produce a signature Hephaestus will accept. There is no Hephaestus-specific signing format. + +## Flow + +``` +┌─────────┐ ┌──────────────┐ ┌──────────┐ +│ Browser │ │ Auth-proxy │ │ Forgejo │ +└────┬────┘ └──────┬───────┘ └─────┬────┘ + │ │ │ + │ 1. GET /login (redirected here by Forgejo /authorize)│ + │◀─────────────────────────┘ │ + │ │ + │ 2. POST /challenge { cashaddr, state, redirect_uri }│ + │─────────────────────────▶│ │ + │◀── { nonce, message } ───│ │ + │ │ │ + │ 3. Sign `message` with wallet (client-side) │ + │ │ │ + │ 4. POST /verify { nonce, signature } │ + │─────────────────────────▶│ │ + │◀── { redirect: ?code=…&state=… } ──│ + │ │ + │ 5. GET ?code=…&state=… │ + │──────────────────────────────────────────────────────▶│ + │ │ 6. POST /token { code } │ + │ │◀───────────────────────────│ + │ │─── { id_token } ──────────▶│ + │ │ │ + │ 7. Session cookie set, redirect into app │ + │◀──────────────────────────────────────────────────────│ +``` + +## Challenge message + +Byte-for-byte the same on both sides. Any deviation invalidates the signature. + +``` +{DOMAIN} wants you to sign in with your Bitcoin Cash account: +{CASHADDR} + +By signing, you prove you control this address. This request will not trigger +a blockchain transaction or cost any fees. + +Domain: {DOMAIN} +Nonce: {NONCE} +Issued At: {ISO8601_TIMESTAMP} +``` + +- `{DOMAIN}` — public hostname of the deployment (set via `CHALLENGE_DOMAIN` env) +- `{CASHADDR}` — full `bitcoincash:…` address the user is proving control of +- `{NONCE}` — 32 hex chars from `crypto.getRandomValues(new Uint8Array(16))` +- `{ISO8601_TIMESTAMP}` — server-issued `Date.now().toISOString()` + +Nonces expire after 5 minutes and are single-use. + +## Signature format + +Follows Bitcoin Signed Message (as implemented by Bitcoin Core / Electron Cash): + +1. Serialize `varint(len(magic)) || magic || varint(len(msg)) || msg` + where `magic = "Bitcoin Signed Message:\n"` (24 bytes) +2. `digest = SHA256(SHA256(serialized))` +3. Sign the 32-byte digest with recoverable ECDSA over secp256k1 +4. Build the 65-byte compact signature: `header || r(32) || s(32)` + where `header = 27 + recoveryId + 4` (the `+4` marks a compressed pubkey) +5. Base64-encode the 65 bytes + +Verification (on the server, in `verify.ts`): + +1. Rebuild the same digest +2. Recover the compressed public key from `(compact_sig, recoveryId, digest)` +3. Hash160 the recovered pubkey → derive a p2pkh cashaddr +4. Compare against the address the user claimed at `/challenge` time +5. On match: mint an OIDC authorization code bound to the cashaddr + +## OIDC claims + +The `id_token` Forgejo receives: + +```json +{ + "iss": "https://auth.hephaestus.silentmode.st", + "aud": "forgejo", + "sub": "bitcoincash:qq…", + "preferred_username": "bitcoincash:qq…", + "iat": 1723200000, + "exp": 1723203600 +} +``` + +- `sub` — the cashaddr, immutable per user (deterministic from mnemonic) +- `preferred_username` — Forgejo uses this to auto-provision the user record on first login +- Signed with **EdDSA (Ed25519)**; key is generated on first boot and persisted to `DATA_DIR/keys.json` + +## Threat model + +**In scope:** +- Passive attackers on the network: mitigated by TLS + nonce freshness +- Replay: nonces are single-use with 5-min TTL +- Forgery: requires possession of the mnemonic (24 words) or the private key +- Server compromise leaks challenge nonces but *not* signing keys — attacker cannot mint valid signatures without the user's wallet + +**Out of scope (MVP):** +- Mnemonic loss = account loss. No recovery. Documented for users. +- Phishing sites collecting signed challenges under our domain string — mitigated by hard-coded `CHALLENGE_DOMAIN` in the server + user education +- Compromised browser (malicious extension reading localStorage) — user's problem +- Post-quantum secp256k1 break — everyone's problem + +## Phase 2: WalletConnect / mobile signing + +WalletConnect is EVM-centric. For BCH the closest equivalents: +- **Cashonize** (browser wallet) exposes a page-injected `bitcoincash` provider +- **Zapit** (mobile) supports the `pay:` URI scheme and could be extended for sign requests +- **Electron Cash** — deep-link URI: `electroncash:?sign=...` (proposal, not merged) + +Planned surface: after a user hits "Sign with mobile wallet" on the login page, we render a QR code containing `hephaestus-sign://{nonce}?domain=…&addr=…` and poll `/verify` for the arriving signature. Mobile app decodes, signs with the user's key, POSTs the signature to our public endpoint with the nonce. + +No changes needed to `verify.ts` for this — signature format is identical. + +## Phase 2: BCNR name resolution + +Every `bitcoincash:…` address can register a human-readable name via BCNR (Bitcoin Cash Name Records) in the Ariadne stack. On login, if the user's cashaddr has a BCNR name registered, we auto-populate `preferred_username` with the BCNR name instead of the raw address — matching the BCNR-first policy documented for the broader Silent Mode stack. + +Fallback: DNS/traditional identifiers, per project convention. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d11ea83 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Hephaestus + +Decentralized code forge. Forgejo web UI on a VPS, Sia network for durable storage, Bitcoin Cash wallet signatures for identity — no email required. + +## Architecture + +``` +User browser + │ + ├── OIDC login via wallet signature ──▶ auth-proxy ──▶ (issues id_token) + │ │ + └── Forgejo web UI ◀─── OIDC handshake ────────────────────┘ + │ + ├── live git repos, Postgres ──▶ local SSD on VPS (hot path) + └── LFS, attachments, packages, + archive downloads, backups ──▶ sia.storage S3 (cold path) +``` + +- **Identity:** users generate or import a BCH wallet client-side. Their `cashaddr` is their username. Login = signing a challenge. +- **Hot storage:** live `.git` bare repos + Postgres live on the VPS's local disk. Git operations stay fast. +- **Cold storage:** every large or immutable blob (LFS, attachments, release artifacts, package registries) lives on sia.storage via its S3-compatible endpoint. Forgejo speaks S3 natively. +- **Backups:** nightly `restic` snapshot of repos + `pg_dump` to a second bucket. Dual-write to independent storage for the belt-and-suspenders story since sia.storage caps liability at ~$100. + +## Why sia.storage instead of self-hosted renterd for MVP + +- Their free tier gives 50 GB pooled + 4 Gbps throughput with no monthly egress meter +- Standard S3 API — zero code changes to Forgejo +- No Sia wallet management, no SC volatility, no contract-formation ops +- Swap to self-hosted `renterd` later without touching Forgejo config (both speak S3) + +## Quota math + +The 50 GB sia.storage free tier is one pooled resource we allocate to Forgejo users via Forgejo's built-in per-user quotas. + +| Our tier | Per-user quota | Users on Free (50 GB) | Users on Plus (500 GB) | Users on Pro (5 TB) | +|---|---|---|---|---| +| Free | 500 MB | ~100 | ~1,000 | ~10,000 | +| Pro | 10 GB | ~5 | ~50 | ~500 | +| Team | 50 GB | ~1 | ~10 | ~100 | + +## Quickstart + +```bash +cp .env.example .env +# fill in SIA_STORAGE_ACCESS_KEY, SIA_STORAGE_SECRET_KEY, SIA_STORAGE_BUCKET +docker compose up -d +open http://localhost:3000 +``` + +First launch: +1. Forgejo initialises Postgres and its schema +2. Auth-proxy generates its OIDC signing keypair +3. On first browser visit, the login page offers "Generate new BCH wallet" or "Import wallet" +4. After signing the challenge you're logged into Forgejo — your cashaddr is your username + +## Directory layout + +``` +Hephaestus/ +├── docker-compose.yml # forgejo + postgres + auth-proxy +├── .env.example +├── forgejo/ +│ └── app.ini.template # config with sia.storage S3 + OIDC + quotas +├── auth-proxy/ +│ ├── Dockerfile +│ ├── package.json +│ └── src/ # Fastify OIDC provider +│ ├── index.ts +│ ├── oidc.ts +│ ├── verify.ts # BCH signed-message verification +│ └── sessions.ts +├── auth-proxy/public/ +│ ├── login.html # Wallet UI +│ └── wallet.js # Client-side keygen + signing (libauth) +├── scripts/ +│ ├── smoke-test-s3.sh # Verify sia.storage handles multipart LFS +│ └── backup-restic.sh # Nightly backup +└── PROTOCOL.md # Wallet-auth flow spec +``` + +## Status + +- MVP scaffolding: complete +- Wallet auth: functional (challenge + signature verify, no persistence beyond cashaddr) +- sia.storage backend: config template ready; **must run `scripts/smoke-test-s3.sh` before trusting LFS** +- Restic backup: template ready, needs cron wiring +- WalletConnect: deferred to Phase 2 +- BCNR-name registration for `code.silentmode.st`: separate task in Ariadne stack + +## Known unknowns + +1. **sia.storage S3 multipart maturity.** Their roadmap targets full S3 compatibility Q3 2026 — partial today. Smoke test verifies this before we depend on it for LFS. +2. **Account-recovery UX for wallet loss.** No email = no password reset. Users must back up their mnemonic. Docs will hammer this. Phase 2: social recovery via BCH multisig. +3. **Content moderation workflow.** sia.storage ToS bans certain content classes; we need our own AUP + takedown flow before public launch. diff --git a/auth-proxy/Dockerfile b/auth-proxy/Dockerfile new file mode 100644 index 0000000..60287f7 --- /dev/null +++ b/auth-proxy/Dockerfile @@ -0,0 +1,20 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json tsconfig.json ./ +RUN npm install +COPY src ./src +COPY public ./public +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +RUN mkdir -p /data && chown node:node /data +USER node +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/public ./public +COPY --from=build /app/package.json ./ +ENV DATA_DIR=/data +ENV PORT=4000 +EXPOSE 4000 +CMD ["node", "dist/index.js"] diff --git a/auth-proxy/package-lock.json b/auth-proxy/package-lock.json new file mode 100644 index 0000000..d6712f3 --- /dev/null +++ b/auth-proxy/package-lock.json @@ -0,0 +1,1657 @@ +{ + "name": "hephaestus-auth-proxy", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hephaestus-auth-proxy", + "version": "0.1.0", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.4", + "@fastify/formbody": "^8.0.1", + "@fastify/static": "^8.0.3", + "fastify": "^5.1.0", + "jose": "^5.9.6" + }, + "devDependencies": { + "@types/node": "^22.9.0", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + } + }, + "node_modules/@bitauth/libauth": { + "version": "3.1.0-next.8", + "resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz", + "integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz", + "integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", + "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/formbody": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-8.0.2.tgz", + "integrity": "sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-querystring": "^1.1.2", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.1.tgz", + "integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz", + "integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^0.5.4", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^11.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz", + "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.11.3", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.11.3.tgz", + "integrity": "sha512-W6hzDP8s0iSeL7LGwY6Oc/ZxuXWOvFEMs6p2L0Si415YRo27W5pBKdOTXxhemBDeSTAcpYf5evRA9onF2OYhPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.11", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz", + "integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + } + } +} diff --git a/auth-proxy/package.json b/auth-proxy/package.json new file mode 100644 index 0000000..9c85734 --- /dev/null +++ b/auth-proxy/package.json @@ -0,0 +1,24 @@ +{ + "name": "hephaestus-auth-proxy", + "version": "0.1.0", + "description": "OIDC provider that authenticates users by Bitcoin Cash wallet signature", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.4", + "@fastify/formbody": "^8.0.1", + "@fastify/static": "^8.0.3", + "fastify": "^5.1.0", + "jose": "^5.9.6" + }, + "devDependencies": { + "@types/node": "^22.9.0", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + } +} diff --git a/auth-proxy/public/login.css b/auth-proxy/public/login.css new file mode 100644 index 0000000..55e30b1 --- /dev/null +++ b/auth-proxy/public/login.css @@ -0,0 +1,64 @@ +:root { + color-scheme: light dark; + --bg: #0f1115; + --card: #171a21; + --text: #e6e8ec; + --muted: #8b93a1; + --accent: #ff8c00; + --accent-fg: #0f1115; + --border: #262a34; + --err: #ff6b6b; + --ok: #4ade80; + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; +} +@media (prefers-color-scheme: light) { + :root { + --bg: #f7f8fa; + --card: #ffffff; + --text: #0f1115; + --muted: #5a6473; + --border: #e4e7ec; + } +} +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); min-height: 100%; } +main { + max-width: 520px; + margin: 8vh auto; + padding: 32px 28px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 14px; +} +h1 { font-size: 22px; margin: 0 0 4px; } +p.lede { color: var(--muted); margin: 0 0 24px; font-size: 14px; } +.tab-row { display: flex; gap: 8px; margin-bottom: 20px; border-bottom: 1px solid var(--border); } +.tab { + padding: 10px 14px; cursor: pointer; background: transparent; border: none; + color: var(--muted); font: inherit; border-bottom: 2px solid transparent; +} +.tab[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); } +label { display: block; font-size: 13px; color: var(--muted); margin: 12px 0 6px; } +input, textarea { + width: 100%; padding: 10px 12px; background: var(--bg); + color: var(--text); border: 1px solid var(--border); border-radius: 8px; + font: inherit; font-size: 14px; font-family: ui-monospace, monospace; +} +textarea { min-height: 84px; resize: vertical; } +button.primary { + width: 100%; padding: 12px; margin-top: 20px; + background: var(--accent); color: var(--accent-fg); + border: 0; border-radius: 8px; font: inherit; font-weight: 600; + cursor: pointer; +} +button.primary:disabled { opacity: 0.5; cursor: wait; } +.mono { font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; } +.warn { color: var(--err); font-size: 13px; margin-top: 8px; } +.ok { color: var(--ok); font-size: 13px; margin-top: 8px; } +.mnemonic-box { + background: var(--bg); border: 1px dashed var(--border); + border-radius: 8px; padding: 12px; margin-top: 8px; + font-family: ui-monospace, monospace; font-size: 13px; line-height: 1.7; +} +.small { font-size: 12px; color: var(--muted); } +.hidden { display: none; } diff --git a/auth-proxy/public/wallet.js b/auth-proxy/public/wallet.js new file mode 100644 index 0000000..d71b514 --- /dev/null +++ b/auth-proxy/public/wallet.js @@ -0,0 +1,298 @@ +/** + * Client-side BCH wallet UI. + * + * Uses @bitauth/libauth ESM build from a CDN. Wallets are generated with + * BIP-39 (12 words) → BIP-32 (m/44'/145'/0'/0/0) → secp256k1 keypair. + * Signatures follow "Bitcoin Signed Message" format so any BCH tool can verify. + * + * Storage: encrypted mnemonic in localStorage under 'hephaestus.wallet.v1'. + * Encryption: AES-GCM with PBKDF2(SHA-256, 200k) from a user passphrase. + * Passphrase never leaves the browser. + */ + +import { + deriveHdPath, + deriveHdPrivateNodeFromSeed, + deriveSeedFromBip39Mnemonic, + encodeCashAddress, + generateBip39Mnemonic, + hash160, + hash256, + secp256k1, + utf8ToBin, + binToBase64, + binToHex, + CashAddressType, +} from "https://esm.sh/@bitauth/libauth@3.1.0-next.4"; + +const app = document.getElementById("app"); +const state = app.dataset.state ?? ""; +const redirectUri = app.dataset.redirectUri; + +const STORAGE_KEY = "hephaestus.wallet.v1"; + +/* ---------- BCH wallet primitives ---------- */ + +const DERIVATION_PATH = "m/44'/145'/0'/0/0"; + +function encodeVarInt(n) { + if (n < 0xfd) return new Uint8Array([n]); + if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >> 8) & 0xff]); + return new Uint8Array([ + 0xfe, n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff, + ]); +} + +function magicHash(message) { + const magic = utf8ToBin("Bitcoin Signed Message:\n"); + const msg = utf8ToBin(message); + const parts = [encodeVarInt(magic.length), magic, encodeVarInt(msg.length), msg]; + const total = parts.reduce((n, p) => n + p.length, 0); + const buf = new Uint8Array(total); + let off = 0; + for (const p of parts) { buf.set(p, off); off += p.length; } + return hash256(buf); +} + +async function keypairFromMnemonic(mnemonic) { + const seed = deriveSeedFromBip39Mnemonic(mnemonic); + if (typeof seed === "string") throw new Error(seed); + const root = deriveHdPrivateNodeFromSeed(seed); + const child = deriveHdPath(root, DERIVATION_PATH); + if (typeof child === "string") throw new Error(child); + const privateKey = child.privateKey; + const publicKey = secp256k1.derivePublicKeyCompressed(privateKey); + if (typeof publicKey === "string") throw new Error(publicKey); + const pkh = hash160(publicKey); + const enc = encodeCashAddress({ + prefix: "bitcoincash", + type: CashAddressType.p2pkh, + payload: pkh, + }); + const cashaddr = typeof enc === "string" ? enc : enc.address; + return { privateKey, publicKey, cashaddr }; +} + +function signMessage(privateKey, message) { + const digest = magicHash(message); + const compact = secp256k1.signMessageHashRecoverableCompact(privateKey, digest); + if (typeof compact === "string") throw new Error(compact); + // Header byte: 27 + recid + 4 (compressed pubkey flag). libauth returns + // {signature, recoveryId}; adapt to expected shape. + const recid = compact.recoveryId; + const sig65 = new Uint8Array(65); + sig65[0] = 27 + recid + 4; + sig65.set(compact.signature, 1); + return binToBase64(sig65); +} + +/* ---------- Encrypted storage ---------- */ + +async function deriveKey(passphrase, salt) { + const enc = new TextEncoder(); + const material = await crypto.subtle.importKey( + "raw", enc.encode(passphrase), "PBKDF2", false, ["deriveKey"], + ); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations: 200_000, hash: "SHA-256" }, + material, + { name: "AES-GCM", length: 256 }, + false, ["encrypt", "decrypt"], + ); +} + +async function encryptMnemonic(mnemonic, passphrase) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(passphrase, salt); + const ct = new Uint8Array(await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, key, new TextEncoder().encode(mnemonic), + )); + return { + v: 1, + salt: binToHex(salt), + iv: binToHex(iv), + ct: binToHex(ct), + }; +} + +async function decryptMnemonic(blob, passphrase) { + const salt = hexToBin(blob.salt); + const iv = hexToBin(blob.iv); + const ct = hexToBin(blob.ct); + const key = await deriveKey(passphrase, salt); + const pt = new Uint8Array(await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, key, ct, + )); + return new TextDecoder().decode(pt); +} + +function hexToBin(h) { + const out = new Uint8Array(h.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(h.substr(i * 2, 2), 16); + return out; +} + +function hasStoredWallet() { return !!localStorage.getItem(STORAGE_KEY); } +function loadStoredWallet() { return JSON.parse(localStorage.getItem(STORAGE_KEY)); } +function saveStoredWallet(blob) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(blob)); +} + +/* ---------- UI ---------- */ + +function render() { + if (hasStoredWallet()) return renderUnlock(); + return renderTabs(); +} + +function el(html) { + const t = document.createElement("template"); + t.innerHTML = html.trim(); + return t.content.firstElementChild; +} + +function renderTabs() { + app.innerHTML = ""; + app.appendChild(el(` +
+

Sign in to Hephaestus

+

No email. No password reset. Your wallet is your identity.

+
+ + +
+
+
+ `)); + app.querySelectorAll(".tab").forEach((b) => { + b.addEventListener("click", () => { + app.querySelectorAll(".tab").forEach((x) => + x.setAttribute("aria-selected", x === b ? "true" : "false")); + renderPanel(b.dataset.tab); + }); + }); + renderPanel("generate"); +} + +function renderPanel(kind) { + const panel = document.getElementById("panel"); + panel.innerHTML = ""; + if (kind === "generate") { + const mnemonic = generateBip39Mnemonic(); + panel.appendChild(el(` +
+ +
${mnemonic}
+ + +

The passphrase never leaves your device. If you lose both the phrase and the passphrase your account is gone — we cannot help.

+ + +
+ `)); + panel.querySelector("#go").addEventListener("click", async () => { + const pw = panel.querySelector("#pw").value; + if (pw.length < 8) return showErr(panel, "passphrase must be at least 8 chars"); + await onboardAndLogin(mnemonic, pw, panel); + }); + } else { + panel.appendChild(el(` +
+ + + + + + +
+ `)); + panel.querySelector("#go").addEventListener("click", async () => { + const mn = panel.querySelector("#mn").value.trim().replace(/\s+/g, " "); + const pw = panel.querySelector("#pw").value; + if (pw.length < 8) return showErr(panel, "passphrase must be at least 8 chars"); + try { await keypairFromMnemonic(mn); } + catch { return showErr(panel, "invalid recovery phrase"); } + await onboardAndLogin(mn, pw, panel); + }); + } +} + +function renderUnlock() { + app.innerHTML = ""; + app.appendChild(el(` +
+

Unlock your wallet

+

Enter the passphrase you set when you created this wallet in this browser.

+ + + + +

+ Wrong browser? Forget this wallet and start over. +

+
+ `)); + app.querySelector("#reset").addEventListener("click", (e) => { + e.preventDefault(); + localStorage.removeItem(STORAGE_KEY); + renderTabs(); + }); + app.querySelector("#go").addEventListener("click", async () => { + const pw = app.querySelector("#pw").value; + const blob = loadStoredWallet(); + let mnemonic; + try { mnemonic = await decryptMnemonic(blob, pw); } + catch { return showErr(app, "wrong passphrase"); } + await signInWithMnemonic(mnemonic, app); + }); +} + +async function onboardAndLogin(mnemonic, passphrase, root) { + const btn = root.querySelector("button.primary"); + btn.disabled = true; + try { + const blob = await encryptMnemonic(mnemonic, passphrase); + saveStoredWallet(blob); + await signInWithMnemonic(mnemonic, root); + } catch (e) { + btn.disabled = false; + showErr(root, e.message ?? String(e)); + } +} + +async function signInWithMnemonic(mnemonic, root) { + const btn = root.querySelector("button.primary"); + if (btn) btn.disabled = true; + try { + const { privateKey, cashaddr } = await keypairFromMnemonic(mnemonic); + const chalRes = await fetch("/challenge", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ cashaddr, state, redirect_uri: redirectUri }), + }); + if (!chalRes.ok) throw new Error("challenge request failed"); + const { nonce, message } = await chalRes.json(); + const signature = signMessage(privateKey, message); + const verRes = await fetch("/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nonce, signature }), + }); + if (!verRes.ok) throw new Error("signature rejected"); + const { redirect } = await verRes.json(); + window.location.href = redirect; + } catch (e) { + if (btn) btn.disabled = false; + showErr(root, e.message ?? String(e)); + } +} + +function showErr(root, msg) { + const box = root.querySelector("#err"); + if (!box) return; + box.textContent = msg; + box.classList.remove("hidden"); +} + +render(); diff --git a/auth-proxy/src/index.ts b/auth-proxy/src/index.ts new file mode 100644 index 0000000..96babc7 --- /dev/null +++ b/auth-proxy/src/index.ts @@ -0,0 +1,248 @@ +/** + * Hephaestus auth-proxy — OIDC provider that authenticates users by BCH signature. + * + * Endpoints: + * GET / → login page (wallet UI) + * GET /.well-known/openid-configuration → OIDC discovery doc + * GET /jwks → public JWKS + * GET /authorize → Forgejo redirects users here + * POST /challenge → { cashaddr, state } → challenge string + nonce + * POST /verify → { nonce, signature } → redirect to Forgejo with code + * POST /token → Forgejo exchanges code → id_token + * GET /userinfo → id_token → { sub, preferred_username } + */ + +import Fastify from "fastify"; +import formbody from "@fastify/formbody"; +import fastifyStatic from "@fastify/static"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { getKeys, issueIdToken } from "./oidc.js"; +import { + createChallenge, + consumeChallenge, + mintAuthCode, + consumeAuthCode, +} from "./sessions.js"; +import { verifyBchSignedMessage, buildChallenge } from "./verify.js"; + +const ISSUER = process.env.ISSUER ?? "http://localhost:4000"; +const CLIENT_ID = process.env.CLIENT_ID ?? "forgejo"; +const CLIENT_SECRET = process.env.CLIENT_SECRET ?? ""; +const CHALLENGE_DOMAIN = + process.env.CHALLENGE_DOMAIN ?? "hephaestus.localhost"; +const FORGEJO_REDIRECT_URI = + process.env.FORGEJO_REDIRECT_URI ?? + "http://localhost:3000/user/oauth2/hephaestus-wallet/callback"; +const PORT = Number(process.env.PORT ?? 4000); + +const app = Fastify({ logger: true }); +await app.register(formbody); + +const here = dirname(fileURLToPath(import.meta.url)); +await app.register(fastifyStatic, { + root: join(here, "..", "public"), + prefix: "/static/", +}); + +// --- OIDC discovery --- +app.get("/.well-known/openid-configuration", async () => ({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + userinfo_endpoint: `${ISSUER}/userinfo`, + jwks_uri: `${ISSUER}/jwks`, + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["EdDSA"], + scopes_supported: ["openid", "profile"], + token_endpoint_auth_methods_supported: [ + "client_secret_post", + "client_secret_basic", + ], + claims_supported: ["sub", "preferred_username"], +})); + +app.get("/jwks", async () => { + const { publicJwk } = await getKeys(); + return { keys: [publicJwk] }; +}); + +// --- Login page (delivered when Forgejo redirects to /authorize) --- +type AuthorizeQuery = { + client_id?: string; + redirect_uri?: string; + state?: string; + response_type?: string; + scope?: string; + nonce?: string; +}; +app.get<{ Querystring: AuthorizeQuery }>("/authorize", async (req, reply) => { + const q = req.query; + if (q.client_id !== CLIENT_ID) { + return reply.code(400).send({ error: "unknown client_id" }); + } + if (q.response_type !== "code") { + return reply + .code(400) + .send({ error: "unsupported_response_type", supported: "code" }); + } + const redirectUri = q.redirect_uri ?? FORGEJO_REDIRECT_URI; + const state = q.state ?? ""; + return reply + .type("text/html") + .send(loginHtml({ state, redirectUri, oidcNonce: q.nonce ?? "" })); +}); + +app.get("/", async (_req, reply) => { + return reply.type("text/html").send( + loginHtml({ + state: "", + redirectUri: FORGEJO_REDIRECT_URI, + oidcNonce: "", + }), + ); +}); + +// --- Challenge issuance --- +app.post<{ + Body: { cashaddr: string; state?: string; redirect_uri?: string }; +}>("/challenge", async (req, reply) => { + const { cashaddr, state, redirect_uri } = req.body ?? {}; + if (!cashaddr || !cashaddr.startsWith("bitcoincash:")) { + return reply.code(400).send({ error: "cashaddr must start with bitcoincash:" }); + } + const rec = createChallenge(cashaddr, state, redirect_uri); + const message = buildChallenge({ + domain: CHALLENGE_DOMAIN, + cashaddr, + nonce: rec.nonce, + issuedAt: rec.issuedAt, + }); + return { nonce: rec.nonce, message }; +}); + +// --- Signature verification & OAuth code mint --- +app.post<{ + Body: { nonce: string; signature: string }; +}>("/verify", async (req, reply) => { + const { nonce, signature } = req.body ?? {}; + if (!nonce || !signature) { + return reply.code(400).send({ error: "nonce and signature required" }); + } + const rec = consumeChallenge(nonce); + if (!rec) return reply.code(400).send({ error: "challenge expired or unknown" }); + + const message = buildChallenge({ + domain: CHALLENGE_DOMAIN, + cashaddr: rec.cashaddr, + nonce: rec.nonce, + issuedAt: rec.issuedAt, + }); + const recovered = verifyBchSignedMessage(rec.cashaddr, message, signature); + if (!recovered) return reply.code(401).send({ error: "signature mismatch" }); + + const redirectUri = rec.redirectUri ?? FORGEJO_REDIRECT_URI; + const code = mintAuthCode(recovered, CLIENT_ID, redirectUri); + const url = new URL(redirectUri); + url.searchParams.set("code", code); + if (rec.clientState) url.searchParams.set("state", rec.clientState); + return { redirect: url.toString() }; +}); + +// --- Token exchange --- +type TokenBody = { + grant_type: string; + code: string; + redirect_uri: string; + client_id?: string; + client_secret?: string; +}; +app.post<{ Body: TokenBody }>("/token", async (req, reply) => { + const b = req.body ?? ({} as TokenBody); + const authHeader = req.headers.authorization; + let clientId = b.client_id; + let clientSecret = b.client_secret; + if (!clientId && authHeader?.startsWith("Basic ")) { + const decoded = Buffer.from(authHeader.slice(6), "base64").toString("utf8"); + const idx = decoded.indexOf(":"); + if (idx > 0) { + clientId = decoded.slice(0, idx); + clientSecret = decoded.slice(idx + 1); + } + } + if (clientId !== CLIENT_ID || clientSecret !== CLIENT_SECRET) { + return reply.code(401).send({ error: "invalid_client" }); + } + if (b.grant_type !== "authorization_code") { + return reply.code(400).send({ error: "unsupported_grant_type" }); + } + const rec = consumeAuthCode(b.code); + if (!rec) return reply.code(400).send({ error: "invalid_grant" }); + if (rec.clientId !== clientId || rec.redirectUri !== b.redirect_uri) { + return reply.code(400).send({ error: "code binding mismatch" }); + } + const id_token = await issueIdToken({ + issuer: ISSUER, + audience: clientId, + subject: rec.cashaddr, + }); + return { + access_token: id_token, + token_type: "Bearer", + expires_in: 3600, + id_token, + scope: "openid profile", + }; +}); + +// --- Userinfo --- +app.get("/userinfo", async (req, reply) => { + const auth = req.headers.authorization; + if (!auth?.startsWith("Bearer ")) return reply.code(401).send({ error: "missing bearer" }); + const token = auth.slice(7); + // Decode without verifying — token was signed by us moments ago and rotates hourly. + // Forgejo verifies against JWKS itself; this endpoint just echoes claims. + const parts = token.split("."); + if (parts.length !== 3) return reply.code(401).send({ error: "malformed token" }); + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); + return { + sub: payload.sub, + preferred_username: payload.preferred_username, + }; +}); + +// --- HTML for the login page --- +function loginHtml(opts: { + state: string; + redirectUri: string; + oidcNonce: string; +}): string { + return ` + + + + +Hephaestus — Sign in with wallet + + + +
+ + +`; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!, + ); +} + +app.listen({ port: PORT, host: "0.0.0.0" }).catch((e) => { + app.log.error(e); + process.exit(1); +}); diff --git a/auth-proxy/src/oidc.ts b/auth-proxy/src/oidc.ts new file mode 100644 index 0000000..d3a192d --- /dev/null +++ b/auth-proxy/src/oidc.ts @@ -0,0 +1,74 @@ +/** + * OIDC provider keys and id_token issuance. + * + * Loads (or generates on first boot) an EdDSA keypair from DATA_DIR/keys.json. + * Exposes JWKS for Forgejo to validate id_tokens. + */ + +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { SignJWT, exportJWK, generateKeyPair, importJWK, JWK } from "jose"; + +const DATA_DIR = process.env.DATA_DIR ?? "./data"; +const KEYS_PATH = join(DATA_DIR, "keys.json"); + +interface KeyBundle { + privateJwk: JWK; + publicJwk: JWK; + kid: string; +} + +let cachedKeys: KeyBundle | null = null; + +export async function getKeys(): Promise { + if (cachedKeys) return cachedKeys; + + if (existsSync(KEYS_PATH)) { + const raw = await readFile(KEYS_PATH, "utf8"); + cachedKeys = JSON.parse(raw); + return cachedKeys!; + } + + await mkdir(dirname(KEYS_PATH), { recursive: true }); + const { privateKey, publicKey } = await generateKeyPair("EdDSA", { + crv: "Ed25519", + extractable: true, + }); + const privateJwk = await exportJWK(privateKey); + const publicJwk = await exportJWK(publicKey); + const kid = "hephaestus-1"; + privateJwk.kid = kid; + publicJwk.kid = kid; + publicJwk.use = "sig"; + publicJwk.alg = "EdDSA"; + + cachedKeys = { privateJwk, publicJwk, kid }; + await writeFile(KEYS_PATH, JSON.stringify(cachedKeys, null, 2), { + mode: 0o600, + }); + return cachedKeys; +} + +export async function issueIdToken(params: { + issuer: string; + audience: string; + subject: string; + nonce?: string; +}): Promise { + const { privateJwk, kid } = await getKeys(); + const key = await importJWK(privateJwk, "EdDSA"); + const jwt = await new SignJWT({ + // sub = cashaddr; also expose as preferred_username so Forgejo shows it + preferred_username: params.subject, + ...(params.nonce ? { nonce: params.nonce } : {}), + }) + .setProtectedHeader({ alg: "EdDSA", kid }) + .setIssuer(params.issuer) + .setAudience(params.audience) + .setSubject(params.subject) + .setIssuedAt() + .setExpirationTime("1h") + .sign(key); + return jwt; +} diff --git a/auth-proxy/src/sessions.ts b/auth-proxy/src/sessions.ts new file mode 100644 index 0000000..d79502c --- /dev/null +++ b/auth-proxy/src/sessions.ts @@ -0,0 +1,82 @@ +/** + * In-memory session and nonce stores. + * + * MVP-only: fine for a single-process deployment. For horizontal scaling swap + * for Redis with the same interface — nothing else in the codebase touches + * these stores directly. + */ + +import { randomBytes } from "node:crypto"; + +interface ChallengeRecord { + cashaddr: string; + nonce: string; + issuedAt: string; + expiresAt: number; // epoch ms + clientState?: string; // OIDC "state" round-tripped from Forgejo + redirectUri?: string; +} + +interface AuthCode { + cashaddr: string; + clientId: string; + redirectUri: string; + createdAt: number; +} + +const challenges = new Map(); +const authCodes = new Map(); + +const CHALLENGE_TTL_MS = 5 * 60 * 1000; +const CODE_TTL_MS = 60 * 1000; + +setInterval(() => { + const now = Date.now(); + for (const [k, v] of challenges) if (v.expiresAt < now) challenges.delete(k); + for (const [k, v] of authCodes) + if (v.createdAt + CODE_TTL_MS < now) authCodes.delete(k); +}, 30_000).unref(); + +export function createChallenge( + cashaddr: string, + clientState?: string, + redirectUri?: string, +): ChallengeRecord { + const nonce = randomBytes(16).toString("hex"); + const record: ChallengeRecord = { + cashaddr, + nonce, + issuedAt: new Date().toISOString(), + expiresAt: Date.now() + CHALLENGE_TTL_MS, + clientState, + redirectUri, + }; + challenges.set(nonce, record); + return record; +} + +export function consumeChallenge(nonce: string): ChallengeRecord | null { + const r = challenges.get(nonce); + if (!r) return null; + challenges.delete(nonce); + if (r.expiresAt < Date.now()) return null; + return r; +} + +export function mintAuthCode( + cashaddr: string, + clientId: string, + redirectUri: string, +): string { + const code = randomBytes(24).toString("hex"); + authCodes.set(code, { cashaddr, clientId, redirectUri, createdAt: Date.now() }); + return code; +} + +export function consumeAuthCode(code: string): AuthCode | null { + const r = authCodes.get(code); + if (!r) return null; + authCodes.delete(code); + if (r.createdAt + CODE_TTL_MS < Date.now()) return null; + return r; +} diff --git a/auth-proxy/src/verify.ts b/auth-proxy/src/verify.ts new file mode 100644 index 0000000..a55d384 --- /dev/null +++ b/auth-proxy/src/verify.ts @@ -0,0 +1,115 @@ +/** + * BCH "Bitcoin Signed Message" verification. + * + * Signature format matches Bitcoin Core's `signmessage` / Electron Cash's + * "Sign/Verify Message" dialog: 65-byte recoverable ECDSA signature (recid+r+s), + * base64-encoded, over the double-SHA256 of the varint-prefixed magic + message. + * + * Given a cashaddr, a message, and a base64 signature, we: + * 1. Rebuild the message hash with the "Bitcoin Signed Message:\n" magic + * 2. Recover the pubkey from the signature + * 3. Derive a cashaddr from the recovered pubkey (both compressed forms) + * 4. Compare against the claimed address + */ + +import { + secp256k1, + hash256, + encodeCashAddress, + hash160, + binToHex, + utf8ToBin, + base64ToBin, + CashAddressType, + type RecoveryId, +} from "@bitauth/libauth"; + +const MAGIC = "Bitcoin Signed Message:\n"; + +function encodeVarInt(n: number): Uint8Array { + if (n < 0xfd) return new Uint8Array([n]); + if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >> 8) & 0xff]); + if (n <= 0xffffffff) + return new Uint8Array([ + 0xfe, + n & 0xff, + (n >> 8) & 0xff, + (n >> 16) & 0xff, + (n >>> 24) & 0xff, + ]); + throw new Error("message too large"); +} + +function magicHash(message: string): Uint8Array { + const magicBytes = utf8ToBin(MAGIC); + const msgBytes = utf8ToBin(message); + const parts = [ + encodeVarInt(magicBytes.length), + magicBytes, + encodeVarInt(msgBytes.length), + msgBytes, + ]; + const total = parts.reduce((n, p) => n + p.length, 0); + const buf = new Uint8Array(total); + let off = 0; + for (const p of parts) { + buf.set(p, off); + off += p.length; + } + return hash256(buf); +} + +/** + * Verify a base64 recoverable-ECDSA signature against a message and cashaddr. + * Returns the recovered cashaddr on success, or null on any mismatch. + */ +export function verifyBchSignedMessage( + claimedCashaddr: string, + message: string, + signatureBase64: string, + prefix: "bitcoincash" | "bchtest" = "bitcoincash", +): string | null { + const sig = base64ToBin(signatureBase64); + if (typeof sig === "string" || sig.length !== 65) return null; + + const recoveryId = ((sig[0] - 27) & 0x03) as RecoveryId; + const compact = sig.slice(1); + const digest = magicHash(message); + + const pubkey = secp256k1.recoverPublicKeyCompressed( + compact, + recoveryId, + digest, + ); + if (typeof pubkey === "string") return null; + + const pkh = hash160(pubkey); + const encoded = encodeCashAddress({ + prefix, + type: CashAddressType.p2pkh, + payload: pkh, + }); + const recovered = typeof encoded === "string" ? encoded : encoded.address; + + return recovered === claimedCashaddr ? recovered : null; +} + +/** Deterministic challenge string that both sides must reproduce byte-for-byte. */ +export function buildChallenge(params: { + domain: string; + cashaddr: string; + nonce: string; + issuedAt: string; // ISO 8601 +}): string { + return [ + `${params.domain} wants you to sign in with your Bitcoin Cash account:`, + params.cashaddr, + "", + "By signing, you prove you control this address. This request will not trigger", + "a blockchain transaction or cost any fees.", + "", + `Domain: ${params.domain}`, + `Nonce: ${params.nonce}`, + `Issued At: ${params.issuedAt}`, + ].join("\n"); +} diff --git a/auth-proxy/tsconfig.json b/auth-proxy/tsconfig.json new file mode 100644 index 0000000..ac63096 --- /dev/null +++ b/auth-proxy/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": false, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7080cd3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,90 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + + auth-proxy: + build: ./auth-proxy + restart: unless-stopped + environment: + ISSUER: ${AUTH_PROXY_ISSUER} + CLIENT_ID: ${AUTH_PROXY_CLIENT_ID} + CLIENT_SECRET: ${AUTH_PROXY_CLIENT_SECRET} + CHALLENGE_DOMAIN: ${AUTH_PROXY_CHALLENGE_DOMAIN} + FORGEJO_REDIRECT_URI: ${FORGEJO_ROOT_URL}user/oauth2/hephaestus-wallet/callback + ports: + - "4000:4000" + volumes: + - auth-proxy-keys:/data + + forgejo: + image: codeberg.org/forgejo/forgejo:10 + restart: unless-stopped + environment: + USER_UID: "1000" + USER_GID: "1000" + FORGEJO__database__DB_TYPE: postgres + FORGEJO__database__HOST: postgres:5432 + FORGEJO__database__NAME: ${POSTGRES_DB} + FORGEJO__database__USER: ${POSTGRES_USER} + FORGEJO__database__PASSWD: ${POSTGRES_PASSWORD} + FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN} + FORGEJO__server__ROOT_URL: ${FORGEJO_ROOT_URL} + FORGEJO__security__SECRET_KEY: ${FORGEJO_SECRET_KEY} + FORGEJO__security__INTERNAL_TOKEN: ${FORGEJO_INTERNAL_TOKEN} + FORGEJO__oauth2__JWT_SECRET: ${FORGEJO_OAUTH_JWT_SECRET} + # --- sia.storage S3 backends --- + FORGEJO__storage.lfs__STORAGE_TYPE: minio + FORGEJO__storage.lfs__MINIO_ENDPOINT: ${SIA_STORAGE_ENDPOINT} + FORGEJO__storage.lfs__MINIO_ACCESS_KEY_ID: ${SIA_STORAGE_ACCESS_KEY} + FORGEJO__storage.lfs__MINIO_SECRET_ACCESS_KEY: ${SIA_STORAGE_SECRET_KEY} + FORGEJO__storage.lfs__MINIO_BUCKET: ${SIA_STORAGE_BUCKET} + FORGEJO__storage.lfs__MINIO_LOCATION: us-east-1 + FORGEJO__storage.lfs__MINIO_USE_SSL: "true" + FORGEJO__storage.lfs__MINIO_BASE_PATH: lfs/ + FORGEJO__storage.attachments__STORAGE_TYPE: minio + FORGEJO__storage.attachments__MINIO_BASE_PATH: attachments/ + FORGEJO__storage.packages__STORAGE_TYPE: minio + FORGEJO__storage.packages__MINIO_BASE_PATH: packages/ + FORGEJO__storage.repo-archive__STORAGE_TYPE: minio + FORGEJO__storage.repo-archive__MINIO_BASE_PATH: archives/ + # (attachments/packages/archives inherit endpoint+creds+bucket from [storage] via shared minio settings below) + FORGEJO__storage__MINIO_ENDPOINT: ${SIA_STORAGE_ENDPOINT} + FORGEJO__storage__MINIO_ACCESS_KEY_ID: ${SIA_STORAGE_ACCESS_KEY} + FORGEJO__storage__MINIO_SECRET_ACCESS_KEY: ${SIA_STORAGE_SECRET_KEY} + FORGEJO__storage__MINIO_BUCKET: ${SIA_STORAGE_BUCKET} + FORGEJO__storage__MINIO_USE_SSL: "true" + # --- Quota (free tier: 500 MB per user across repos+LFS+attachments+packages) --- + FORGEJO__quota__ENABLED: "true" + FORGEJO__quota__DEFAULT_GROUPS: free + # --- Registration: disabled (users bootstrap by wallet OIDC login) --- + FORGEJO__service__DISABLE_REGISTRATION: "true" + FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "true" + FORGEJO__service__SHOW_REGISTRATION_BUTTON: "false" + FORGEJO__service__ENABLE_INTERNAL_SIGNIN: "false" + ports: + - "3000:3000" + - "2222:22" + volumes: + - forgejo-data:/data + depends_on: + postgres: + condition: service_healthy + auth-proxy: + condition: service_started + +volumes: + postgres-data: + forgejo-data: + auth-proxy-keys: diff --git a/scripts/backup-restic.sh b/scripts/backup-restic.sh new file mode 100644 index 0000000..36b148b --- /dev/null +++ b/scripts/backup-restic.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Nightly backup of Forgejo state to a second sia.storage bucket via restic. +# Belt-and-suspenders: sia.storage's ToS caps liability at ~$100 and doesn't +# guarantee data retention on account termination. This is our recoverability. +# +# Contents: +# - /data/git (bare repos on VPS local disk) +# - postgres (pg_dump of the forgejo database) +# +# Restic itself lives in a small sidecar container; this script is intended to +# be scheduled via `cron` on the VPS host, or `docker compose exec` from a +# systemd timer. See docker-compose.override.yml.example (todo) for wiring. +# +# Retention: keep-daily=7 keep-weekly=4 keep-monthly=12 +set -euo pipefail + +# shellcheck disable=SC1091 +[[ -f "$(dirname "$0")/../.env" ]] && source "$(dirname "$0")/../.env" + +: "${SIA_STORAGE_ENDPOINT:?}" +: "${SIA_STORAGE_ACCESS_KEY:?}" +: "${SIA_STORAGE_SECRET_KEY:?}" +: "${SIA_STORAGE_BUCKET_BACKUP:=${SIA_STORAGE_BUCKET}-backup}" +: "${RESTIC_PASSWORD:?RESTIC_PASSWORD must be set in .env (generate with: openssl rand -base64 32)}" + +export AWS_ACCESS_KEY_ID="$SIA_STORAGE_ACCESS_KEY" +export AWS_SECRET_ACCESS_KEY="$SIA_STORAGE_SECRET_KEY" +export RESTIC_REPOSITORY="s3:https://${SIA_STORAGE_ENDPOINT}/${SIA_STORAGE_BUCKET_BACKUP}" +export RESTIC_PASSWORD + +# Init on first run (idempotent — swallows "already initialised") +restic snapshots >/dev/null 2>&1 || restic init + +# 1. Postgres dump (pipe into restic's stdin backup mode) +docker compose exec -T postgres pg_dump -U "${POSTGRES_USER}" "${POSTGRES_DB}" \ + | restic backup --stdin --stdin-filename postgres-forgejo.sql --tag postgres + +# 2. Git repositories (mounted volume path inside the forgejo container) +docker compose exec forgejo tar -cf - /data/git \ + | restic backup --stdin --stdin-filename forgejo-git.tar --tag git + +# 3. Prune old snapshots +restic forget --prune \ + --keep-daily 7 \ + --keep-weekly 4 \ + --keep-monthly 12 + +# 4. Verify a sample of the repo (cheap integrity check) +restic check --read-data-subset=5% + +echo "backup ok at $(date -Iseconds)" diff --git a/scripts/smoke-test-s3.sh b/scripts/smoke-test-s3.sh new file mode 100644 index 0000000..c680479 --- /dev/null +++ b/scripts/smoke-test-s3.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Verify sia.storage's S3 gateway handles the operations Forgejo needs for LFS. +# Requires: awscli >= 2.x, `xxd`, and env vars from ../.env +# +# Blocks LFS support if any step fails: +# 1. PutObject (small: avatar) +# 2. Multipart upload (large: 100 MB LFS-shaped blob, 3 parts) +# 3. GetObject (read-back) +# 4. HeadObject (metadata) +# 5. ListObjectsV2 (pagination) +# 6. DeleteObject (cleanup) +# +# Usage: ./smoke-test-s3.sh +set -euo pipefail + +# shellcheck disable=SC1091 +[[ -f ../.env ]] && source ../.env + +: "${SIA_STORAGE_ENDPOINT:?SIA_STORAGE_ENDPOINT not set}" +: "${SIA_STORAGE_ACCESS_KEY:?SIA_STORAGE_ACCESS_KEY not set}" +: "${SIA_STORAGE_SECRET_KEY:?SIA_STORAGE_SECRET_KEY not set}" +: "${SIA_STORAGE_BUCKET:?SIA_STORAGE_BUCKET not set}" + +export AWS_ACCESS_KEY_ID="$SIA_STORAGE_ACCESS_KEY" +export AWS_SECRET_ACCESS_KEY="$SIA_STORAGE_SECRET_KEY" +export AWS_DEFAULT_REGION=us-east-1 + +S3="aws s3api --endpoint-url https://${SIA_STORAGE_ENDPOINT}" +S3CLI="aws s3 --endpoint-url https://${SIA_STORAGE_ENDPOINT}" +KEY_SMALL="smoke/small.bin" +KEY_LARGE="smoke/large-100mb.bin" + +step() { printf "\n\033[1;36m▸ %s\033[0m\n" "$*"; } +ok() { printf " \033[32m✓\033[0m %s\n" "$*"; } +die() { printf " \033[31m✗ %s\033[0m\n" "$*"; exit 1; } + +trap 'rm -f /tmp/smoke-small /tmp/smoke-large /tmp/smoke-large-back' EXIT + +step "1. PutObject — small blob" +head -c 4096 /dev/urandom > /tmp/smoke-small +SMALL_HASH=$(sha256sum /tmp/smoke-small | cut -d' ' -f1) +$S3CLI cp /tmp/smoke-small "s3://${SIA_STORAGE_BUCKET}/${KEY_SMALL}" >/dev/null || die "PutObject failed" +ok "uploaded (sha256=${SMALL_HASH:0:12}…)" + +step "2. Multipart upload — 100 MB in 3 parts (this is the critical LFS test)" +head -c 104857600 /dev/urandom > /tmp/smoke-large +LARGE_HASH=$(sha256sum /tmp/smoke-large | cut -d' ' -f1) +# Force multipart with a small threshold + chunk size +aws configure set default.s3.multipart_threshold 25MB +aws configure set default.s3.multipart_chunksize 33MB +$S3CLI cp /tmp/smoke-large "s3://${SIA_STORAGE_BUCKET}/${KEY_LARGE}" >/dev/null || die "multipart PUT failed" +ok "multipart upload succeeded (sha256=${LARGE_HASH:0:12}…)" + +step "3. GetObject — read-back the large blob" +$S3CLI cp "s3://${SIA_STORAGE_BUCKET}/${KEY_LARGE}" /tmp/smoke-large-back >/dev/null || die "GET failed" +BACK_HASH=$(sha256sum /tmp/smoke-large-back | cut -d' ' -f1) +[[ "$BACK_HASH" == "$LARGE_HASH" ]] || die "checksum mismatch after multipart round-trip" +ok "checksum matches" + +step "4. HeadObject" +SIZE=$($S3 head-object --bucket "$SIA_STORAGE_BUCKET" --key "$KEY_LARGE" --query 'ContentLength' --output text) || die "HEAD failed" +[[ "$SIZE" == "104857600" ]] || die "ContentLength mismatch: $SIZE" +ok "size = $SIZE" + +step "5. ListObjectsV2 — prefix scan" +COUNT=$($S3 list-objects-v2 --bucket "$SIA_STORAGE_BUCKET" --prefix "smoke/" --query 'length(Contents)' --output text) || die "LIST failed" +[[ "$COUNT" -ge 2 ]] || die "expected >=2 objects under smoke/, got $COUNT" +ok "found $COUNT objects" + +step "6. DeleteObject — cleanup" +$S3 delete-object --bucket "$SIA_STORAGE_BUCKET" --key "$KEY_SMALL" >/dev/null +$S3 delete-object --bucket "$SIA_STORAGE_BUCKET" --key "$KEY_LARGE" >/dev/null +ok "deleted test objects" + +printf "\n\033[1;32mAll checks passed. sia.storage is safe to use as Forgejo's LFS backend.\033[0m\n"