From ff11649813e11f2779953f25ecc2fec680be277a Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 12 Sep 2026 00:07:28 +0200 Subject: [PATCH] Initial commit --- .env.example | 23 + 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 | 118 ++ auth-proxy/public/wallet.js | 353 ++++++ auth-proxy/src/index.ts | 259 +++++ auth-proxy/src/oidc.ts | 85 ++ auth-proxy/src/sessions.ts | 82 ++ auth-proxy/src/verify.ts | 130 +++ auth-proxy/tsconfig.json | 16 + caddy/Caddyfile | 78 ++ docker-compose.yml | 137 +++ forgejo/Dockerfile | 10 + forgejo/silent-mode-ca.crt | 22 + scripts/backup-restic.sh | 51 + scripts/bootstrap-auth.sh | 68 ++ scripts/push-split.sh | 83 ++ scripts/smoke-test-s3.sh | 75 ++ scripts/sync-hephaestus-x-to-sia.sh | 34 + 22 files changed, 3548 insertions(+) create mode 100644 .env.example 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 caddy/Caddyfile create mode 100644 docker-compose.yml create mode 100644 forgejo/Dockerfile create mode 100644 forgejo/silent-mode-ca.crt create mode 100644 scripts/backup-restic.sh create mode 100644 scripts/bootstrap-auth.sh create mode 100644 scripts/push-split.sh create mode 100644 scripts/smoke-test-s3.sh create mode 100644 scripts/sync-hephaestus-x-to-sia.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9bbddf7 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# --- sia.storage S3 credentials (from https://sia.storage dashboard) --- +SIA_STORAGE_ENDPOINT=s3.sia.storage +SIA_STORAGE_ACCESS_KEY=changeme +SIA_STORAGE_SECRET_KEY=changeme +SIA_STORAGE_BUCKET=hephaestus + +# --- Postgres --- +POSTGRES_USER=forgejo +POSTGRES_PASSWORD=changeme-strong-random +POSTGRES_DB=forgejo + +# --- Forgejo --- +FORGEJO_DOMAIN=localhost +FORGEJO_ROOT_URL=http://localhost:3000/ +FORGEJO_SECRET_KEY=changeme-openssl-rand-hex-32 +FORGEJO_INTERNAL_TOKEN=changeme-openssl-rand-hex-32 +FORGEJO_OAUTH_JWT_SECRET=changeme-openssl-rand-base64-32 + +# --- Auth proxy --- +AUTH_PROXY_ISSUER=http://localhost:4000 +AUTH_PROXY_CLIENT_ID=forgejo +AUTH_PROXY_CLIENT_SECRET=changeme-openssl-rand-hex-32 +AUTH_PROXY_CHALLENGE_DOMAIN=hephaestus.silentmode.st 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..6ca10f2 --- /dev/null +++ b/auth-proxy/public/login.css @@ -0,0 +1,118 @@ +: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: 560px; + margin: 6vh auto; + padding: 36px 32px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 14px; +} +h1 { font-size: 22px; margin: 0 0 6px; line-height: 1.3; } +h1 .acid { color: var(--accent); } +h2 { font-size: 17px; margin: 0 0 6px; } +p.lede { color: var(--muted); margin: 0 0 20px; font-size: 14px; } +p.sub { color: var(--muted); margin: 0 0 12px; font-size: 13.5px; } + +/* --- Wallet picker: accordion of three options (Create / Import / Connect) --- */ +.wallet-picker { display: grid; gap: 10px; margin: 8px 0 18px; } +details.wallet-opt { + background: var(--bg); border: 1px solid var(--border); border-radius: 10px; + overflow: hidden; transition: border-color .1s, background .1s; +} +details.wallet-opt:hover { border-color: var(--accent); } +details.wallet-opt[open] { border-color: var(--accent); background: var(--card); } +details.wallet-opt > summary { + display: grid; grid-template-columns: 40px 1fr auto; gap: 14px; align-items: start; + padding: 14px 16px; cursor: pointer; list-style: none; user-select: none; +} +details.wallet-opt > summary::-webkit-details-marker { display: none; } +details.wallet-opt > summary::marker { content: ""; } +.wallet-opt .ico { font-size: 24px; line-height: 1; padding-top: 2px; } +.wallet-opt .body { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.wallet-opt b { color: var(--text); font-size: 14.5px; font-weight: 600; + display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.wallet-opt .sub { color: var(--muted); font-size: 13px; line-height: 1.5; } +.wallet-opt .pill { + display: inline-block; font-size: 10.5px; padding: 1px 8px; border-radius: 999px; + background: rgba(255,140,0,.14); color: var(--accent); font-weight: 500; + letter-spacing: 0.02em; text-transform: lowercase; +} +.wallet-opt .chev { + color: var(--muted); font-size: 14px; padding-top: 4px; + transition: transform .15s ease; +} +details.wallet-opt[open] > summary .chev { transform: rotate(180deg); color: var(--accent); } +p.fine { + color: var(--muted); font-size: 12.5px; margin: 4px 0 0; text-align: center; +} +p.fine a { color: var(--muted); } +p.fine a:hover { color: var(--text); } + +/* --- Expanded flow inside a wallet-opt --- */ +.wallet-flow { padding: 0 16px 16px 16px; border-top: 1px solid var(--border); } +.wallet-flow .flow { display: flex; flex-direction: column; gap: 4px; padding-top: 12px; } +.wallet-flow .flow > label { margin-top: 12px; } + +/* --- WizardConnect coming-soon panel --- */ +.wc-supported { display: grid; gap: 8px; margin: 8px 0 14px; } +.wc-wallet { + display: grid; grid-template-columns: 1fr auto auto; gap: 12px; align-items: center; + padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; + font-size: 13px; +} +.wc-wallet b { color: var(--text); } +.wc-wallet span { color: var(--dim, var(--muted)); font-size: 12px; } +.wc-wallet a { color: var(--accent); text-decoration: none; font-size: 12.5px; } +.wc-wallet a:hover { text-decoration: underline; } +.note-soon { + background: var(--bg); border: 1px dashed var(--border); border-left: 3px solid var(--accent); + border-radius: 8px; padding: 12px 14px; color: var(--muted); font-size: 13px; +} +.note-soon b { color: var(--text); } +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..48dc06d --- /dev/null +++ b/auth-proxy/public/wallet.js @@ -0,0 +1,353 @@ +/** + * 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 with your Bitcoin Cash wallet

+

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

+
+
+ + + + Create a new walleteasiest + Made here in your browser. You get a recovery phrase to write down — it is the only key. + + + +
+
+
+ + 🔑 + + Import / add a wallet + Restore a wallet you already have from its 12- or 24-word recovery phrase. + + + +
+
+
+ + 🪄 + + Connect a wallet — WizardConnectmost private + Cashonize 0.9+ or Paytaca. Your keys never leave your wallet. + + + +
+
+
+

Prefer old-school? Sign in with username & password →

+
+ `)); + app.querySelectorAll("details.wallet-opt").forEach((d) => { + d.addEventListener("toggle", () => { + if (!d.open) return; + // Accordion: close siblings + app.querySelectorAll("details.wallet-opt").forEach((other) => { + if (other !== d) other.open = false; + }); + // Lazily populate this option's flow content the first time it opens + const flow = d.querySelector(".wallet-flow"); + if (flow && !flow.dataset.ready) { + populateFlow(d.dataset.choice, flow); + flow.dataset.ready = "1"; + } + }); + }); + + // Auto-open a specific option when the URL fragment is one of #create / #import / #connect + // (e.g. links from the hephaestus.x landing dropdown). Fragments survive OAuth redirects. + const hash = (location.hash || "").replace(/^#/, "").toLowerCase(); + if (hash === "create" || hash === "import" || hash === "connect") { + const target = app.querySelector(`details.wallet-opt[data-choice="${hash}"]`); + if (target) target.open = true; + } +} + +function populateFlow(kind, flow) { + if (kind === "create") { + const mnemonic = generateBip39Mnemonic(); + flow.appendChild(el(` +
+

Your new recovery phrase. Write it down. This is the only way to recover your account — we can never help you reset it.

+
${mnemonic}
+ + +

The passphrase never leaves your device. Lose both phrase and passphrase = account is gone.

+ + +
+ `)); + flow.querySelector(".go").addEventListener("click", async () => { + const pw = flow.querySelector(".pw").value; + if (pw.length < 8) return showErr(flow, "passphrase must be at least 8 chars"); + await onboardAndLogin(mnemonic, pw, flow); + }); + } else if (kind === "import") { + flow.appendChild(el(` +
+

Paste your BIP-39 recovery phrase (12 or 24 words). Same phrase = same account.

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

Sign the challenge with an external BCH wallet — your keys never leave it. Encrypted end-to-end over Nostr; the relay only sees ciphertext.

+
+
Cashonizebrowser extension · v0.9+install →
+
Paytacamobile · iOS + Androidinstall →
+
+

Coming soon. WizardConnect for message-signing is being wired in — for now, use Create or Import above.

+
+ `)); + } +} + +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..d2faa4b --- /dev/null +++ b/auth-proxy/src/index.ts @@ -0,0 +1,259 @@ +/** + * 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 ?? {}; + // Accept any CashAddr prefix the chain actually uses: + // bitcoincash: (mainnet) + // bchtest: (chipnet / testnet — what Silent Mode's whole stack runs on today) + // bchreg: (regtest) + if (!cashaddr || !/^(bitcoincash|bchtest|bchreg):/.test(cashaddr)) { + return reply.code(400).send({ + error: "cashaddr must start with bitcoincash:, bchtest: or bchreg:", + }); + } + 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 --- +// `state` in the body is a fallback so scripted callers who bypass /challenge's +// state-passing (or use their own OAuth kickoff wrapper) can still get RFC 6749 +// §4.1.2 compliant callbacks. If state was stored via /challenge it's used +// automatically; explicit body override wins if both are present. +app.post<{ + Body: { nonce: string; signature: string; state?: string; redirect_uri?: string }; +}>("/verify", async (req, reply) => { + const { nonce, signature, state: stateOverride, redirect_uri: redirectOverride } = 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 = redirectOverride ?? rec.redirectUri ?? FORGEJO_REDIRECT_URI; + const code = mintAuthCode(recovered, CLIENT_ID, redirectUri); + const url = new URL(redirectUri); + url.searchParams.set("code", code); + const stateToEcho = stateOverride ?? rec.clientState; + if (stateToEcho) url.searchParams.set("state", stateToEcho); + 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..df397ad --- /dev/null +++ b/auth-proxy/src/oidc.ts @@ -0,0 +1,85 @@ +/** + * 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"); + // sub = full cashaddr (immutable identity). preferred_username = a Forgejo-safe + // rendering: "bch_" + first 20 chars of the address payload. Forgejo usernames + // must match [a-zA-Z0-9-_.]+ (no colons) and be <= 40 chars. + // Strip ANY CashAddr prefix (bitcoincash: / bchtest: / bchreg:) — the payload + // after the colon is the part we want to render as a Forgejo username. + const addressPayload = params.subject.replace(/^[a-z]+:/, ""); + const preferredUsername = `bch_${addressPayload.slice(0, 20)}`; + const jwt = await new SignJWT({ + preferred_username: preferredUsername, + // full cashaddr also exposed as a custom claim for downstream tooling + cashaddr: params.subject, + // Forgejo needs a synthetic email for auto-registration to succeed + email: `${preferredUsername}@wallet.hephaestus.local`, + email_verified: true, + ...(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..76e266b --- /dev/null +++ b/auth-proxy/src/verify.ts @@ -0,0 +1,130 @@ +/** + * 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. + * + * The prefix is auto-detected from the claimed cashaddr, so both + * bitcoincash:qq... (mainnet) + * bchtest:qq... (chipnet / testnet) + * bchreg:qq... (regtest) + * work with identical key material. This matters because chipnet wallets + * (which the whole Silent Mode stack runs on today) sign the same message + * with the same key, but display their address with the bchtest: prefix. + */ +const KNOWN_PREFIXES = ["bitcoincash", "bchtest", "bchreg"] as const; +type Prefix = (typeof KNOWN_PREFIXES)[number]; + +export function verifyBchSignedMessage( + claimedCashaddr: string, + message: string, + signatureBase64: string, +): string | null { + const prefixMatch = claimedCashaddr.match(/^([a-z]+):/); + if (!prefixMatch) return null; + const prefix = prefixMatch[1] as Prefix; + if (!KNOWN_PREFIXES.includes(prefix)) return 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/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..80ecceb --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,78 @@ +# Caddy fronts the whole Hephaestus stack. +# +# TWO HOSTNAMES: +# code.silentmode.st Traditional DNS + Let's Encrypt HTTP-01 cert. Same as before. +# hephaestus.x BCNR name (chipnet). TLS cert issued by Silent Mode's own +# root CA (Argonautica) — trusted by Theseus/Ariadne users; +# other browsers see a self-signed-style warning. hephaestus.x +# also serves static content from /srv/hephaestus.x/* (the +# landing pages) BEFORE falling through to Forgejo, so +# marketing/docs pages don't get eaten by Forgejo's routes. +# +# ROUTING (both hostnames): +# /auth/* → auth-proxy container (prefix stripped) +# / → Forgejo container (with per-hostname behaviour below) +# +# {$SITE_HOSTNAME} comes from docker-compose .env (SITE_HOSTNAME=code.silentmode.st). + +# --- code.silentmode.st: unchanged from before --- +{$SITE_HOSTNAME} { + encode gzip zstd + + handle_path /auth/* { + reverse_proxy auth-proxy:4000 + } + + # Make BCH-wallet login the DEFAULT for /user/login. Old-school + # username/password stays reachable via `/user/login?password=1` + # (unmatched by this rule; falls through to Forgejo). + @plain_login { + path /user/login + not query password=1 + } + redir @plain_login /user/oauth2/hephaestus-wallet 302 + + handle { + reverse_proxy forgejo:3000 + request_body { + max_size 2GB + } + } +} + +# --- hephaestus.x: static-first, Forgejo-fallback, Silent Mode CA cert --- +hephaestus.x { + encode gzip zstd + + # TLS cert from Silent Mode's Argonautica CA (name-constrained to permitted TLDs). + # Theseus + Ariadne users have this CA in their trust store per the setup docs. + tls /etc/caddy/certs/hephaestus.x.crt /etc/caddy/certs/hephaestus.x.key + + handle_path /auth/* { + reverse_proxy auth-proxy:4000 + } + + # Same wallet-login-as-default as code.silentmode.st. Old-school + # password login still reachable via /user/login?password=1. + @plain_login { + path /user/login + not query password=1 + } + redir @plain_login /user/oauth2/hephaestus-wallet 302 + + # Static-file-first, Forgejo-fallback. If /srv/hephaestus.x/{path} (or {path}/index.html) + # exists, serve it. Otherwise the request falls through to Forgejo so all git/api/user + # routes still work. + root * /srv/hephaestus.x + @static file {path} {path}index.html {path}/index.html + handle @static { + try_files {path} {path}index.html {path}/index.html + file_server + } + handle { + reverse_proxy forgejo:3000 + request_body { + max_size 2GB + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..55952ee --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,137 @@ +services: + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 + environment: + SITE_HOSTNAME: ${SITE_HOSTNAME} + volumes: + # Mount the whole caddy/ dir so both Caddyfile AND certs/ are accessible. + - ./caddy:/etc/caddy:ro + # Static content for hephaestus.x — landing pages served before falling through to Forgejo. + - /var/www/hephaestus.x:/srv/hephaestus.x:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + - forgejo + - auth-proxy + + 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 + # Caddy proxies /auth/* → auth-proxy:4000 over the internal network. No public port. + expose: + - "4000" + volumes: + - auth-proxy-keys:/data + + forgejo: + # Small extension of the official image that adds the Silent Mode CA cert + # (Argonautica root) to the OS trust store, so Forgejo's Go client can + # validate hephaestus.x's cert on OIDC-discovery calls. + build: ./forgejo + 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} + # --- Storage backend: Sia via silentmode.st's s3d (public endpoint on port 8600) --- + # Cert is a real LE cert issued to navigate.st (with SAN for s3.silentmode.st); + # Go's default TLS chain trusts it fine. No skip-verify needed. + # The 'hephaestus' bucket was pre-created on the 'sync' account. + # Path prefixes keep repos/LFS/attachments/packages/archives separated within the bucket. + 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_LOCATION: us-east-1 + FORGEJO__storage__MINIO_USE_SSL: "true" + FORGEJO__storage.lfs__STORAGE_TYPE: minio + 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/ + # Live git repos still live on local disk under /data/git — S3 is only for LFS + # and other cold blobs. Never point [repository] itself at S3. + # --- CORS (so hephaestus.x and other BCNR-served pages can list public repos) --- + FORGEJO__cors__ENABLED: "true" + FORGEJO__cors__ALLOW_DOMAIN: "*" + FORGEJO__cors__ALLOW_CREDENTIALS: "false" + FORGEJO__cors__METHODS: "GET,HEAD" + # --- Quota (free tier: 500 MB per user) --- + FORGEJO__quota__ENABLED: "true" + FORGEJO__quota__DEFAULT_GROUPS: free + # --- Registration + signin --- + # Local form is disabled via SHOW_REGISTRATION_BUTTON=false + ALLOW_ONLY_EXTERNAL_REGISTRATION=true. + # DISABLE_REGISTRATION must stay FALSE — it blocks OIDC auto-provisioning too, not just the form. + FORGEJO__service__DISABLE_REGISTRATION: "false" + FORGEJO__service__SHOW_REGISTRATION_BUTTON: "false" + FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "true" + # OIDC callback creates users automatically using the preferred_username claim (= cashaddr). + FORGEJO__oauth2_client__ENABLE_AUTO_REGISTRATION: "true" + FORGEJO__oauth2_client__ACCOUNT_LINKING: "auto" + FORGEJO__oauth2_client__USERNAME: "preferred_username" + FORGEJO__oauth2_client__UPDATE_AVATAR: "false" + # Skip the web installer wizard — everything is already configured + FORGEJO__server__INSTALL_LOCK: "true" + # hephaestus.x isn't in ICANN DNS, so the Forgejo container can't resolve + # it via its default resolver. Map it to the host gateway so OIDC-discovery + # calls (Forgejo → https://hephaestus.x/auth/…) reach Caddy on the host. + extra_hosts: + - "hephaestus.x:host-gateway" + # Caddy proxies / → forgejo:3000 over the internal network. No public HTTP port. + # 22 stays on 2222 for git-over-SSH (no TLS involved). + expose: + - "3000" + ports: + - "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: + caddy-data: + caddy-config: diff --git a/forgejo/Dockerfile b/forgejo/Dockerfile new file mode 100644 index 0000000..bf6e7b9 --- /dev/null +++ b/forgejo/Dockerfile @@ -0,0 +1,10 @@ +# Forgejo + Silent Mode CA cert baked into the trust store, so Forgejo's Go +# client can validate hephaestus.x's cert (issued by Argonautica) when it +# fetches OIDC discovery from https://hephaestus.x/auth/.well-known/... . +# +# Do NOT switch to USER git at the end — parent image's s6-overlay init needs +# to start as root (it drops to `git` internally). USER git broke s6-svscan +# with "unable to open .s6-svscan/lock: Permission denied". +FROM codeberg.org/forgejo/forgejo:10 +COPY silent-mode-ca.crt /usr/local/share/ca-certificates/silent-mode-ca.crt +RUN update-ca-certificates diff --git a/forgejo/silent-mode-ca.crt b/forgejo/silent-mode-ca.crt new file mode 100644 index 0000000..5108c5e --- /dev/null +++ b/forgejo/silent-mode-ca.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDszCCApugAwIBAgIIQnTLcHP2GA4wDQYJKoZIhvcNAQELBQAwQTEaMBgGA1UE +AxMRQk5TIExvY2FsIFJvb3QgQ0ExIzAhBgNVBAoTGkJOUyAoLmJjaCBvbiBCaXRj +b2luIENhc2gpMB4XDTI2MDgyMTAwMDEwNVoXDTM2MDgyMTAwMDEwNVowQTEaMBgG +A1UEAxMRQk5TIExvY2FsIFJvb3QgQ0ExIzAhBgNVBAoTGkJOUyAoLmJjaCBvbiBC +aXRjb2luIENhc2gpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4igg +3dQZUNedViiuXNjlwtNBpsNnjTQYCmvFqwBuUH7r5VT1zPzdANMyDwE93NrYWEmO +LQY+SqnlKKOAiaZOKVegCdJ2xUwn8D62GVR3MT7/31+VAZHvh4GqRQkHzDR7/0Ch +EhGom+aZE/N/y30FpTQytUI/0FQJnHdRtIRkRR1fKg8uUI23PhHNMuT6EYjYhObb +LMIUzXnP3f48OmI2A8die/n2sfXwrNBqrtc/OzO2XuQm0uBP38R8CBnuy+W/NeSw +BRB2BgmdawZc3e4MxnqpsTR76QcCy6XLtJVBNRtFwK7/Jb00DV7zX4xRw8BQ1N24 +yHXfSh15FvUXJMCplwIDAQABo4GuMIGrMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P +AQH/BAQDAgEGMB0GA1UdDgQWBBTD3l5ztWFyW1di/HaXt2FUNFz1tzBpBgNVHR4B +Af8EXzBdoCkwBYIDYmNoMAWCA3AycDAFggNiaXQwBYIDbmF2MAaCBHRlc3QwA4IB +eKEwMAqHCAAAAAAAAAAAMCKHIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAMA0GCSqGSIb3DQEBCwUAA4IBAQAL5JTGXJ39nR9q8U8YH4Oaq13XINbZycRv +2yG+PQaNlPCwSOucMvatN1+DQqW5dSTulu2e873KrXraBYjXphFUSfTS8nMEi92U +B5+f0l+MSAJXDKc8RwNJ/7SDYblodUg3bN6x70RHFMlAq6FcIondpTEBbOPdaDDi +pw+mLnQj+55xMXV0yxkgsvc/SxdXyLLm7uTWRzKJQi0N3p4YYOEURIyZ491gprwF +QTt0WeBYmRc1MLXbs9D0Svh6D4d0ovKZawZ83rx+TfM7nSfxV6VubbOV/urXtgyh +84Bcq9+1cZZMNAT86uBW+EF7J70mJlctSmwHdIXdxXDaVtl+rZ4o +-----END CERTIFICATE----- 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/bootstrap-auth.sh b/scripts/bootstrap-auth.sh new file mode 100644 index 0000000..4255897 --- /dev/null +++ b/scripts/bootstrap-auth.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# One-time bootstrap: register the wallet auth-proxy as Forgejo's OIDC provider. +# Idempotent — safe to re-run; will fail with "already exists" on second run. +# +# Run this AFTER `docker compose up -d` and after Forgejo's healthcheck passes +# (its Postgres schema needs to be initialised before we can add an auth source). +# +# Usage: +# bash scripts/bootstrap-auth.sh +set -euo pipefail + +# shellcheck disable=SC1091 +[[ -f "$(dirname "$0")/../.env" ]] && source "$(dirname "$0")/../.env" + +: "${AUTH_PROXY_CLIENT_ID:?}" +: "${AUTH_PROXY_CLIENT_SECRET:?}" +: "${AUTH_PROXY_ISSUER:?}" + +echo "Waiting for Forgejo to be ready…" +for i in {1..60}; do + if docker compose exec -T forgejo forgejo --version >/dev/null 2>&1; then + break + fi + sleep 2 +done + +# Same wait for auth-proxy — its /.well-known/openid-configuration must return 200 +# before Forgejo will accept the auth source (it calls discovery on add). +echo "Waiting for auth-proxy to be ready…" +for i in {1..60}; do + if docker compose exec -T forgejo wget -qO- "${AUTH_PROXY_ISSUER}/.well-known/openid-configuration" >/dev/null 2>&1; then + break + fi + sleep 2 +done + +ADMIN_USER="${HEPHAESTUS_ADMIN_USER:-hephaestus-admin}" +ADMIN_PW="${HEPHAESTUS_ADMIN_PASSWORD:-$(openssl rand -base64 24 | tr -d '=+/' | cut -c1-24)}" +ADMIN_EMAIL="${HEPHAESTUS_ADMIN_EMAIL:-admin@hephaestus.local}" + +echo "Creating admin user '${ADMIN_USER}' (idempotent — 'already exists' is fine)…" +docker compose exec -T --user 1000 forgejo forgejo admin user create \ + --admin \ + --username "${ADMIN_USER}" \ + --email "${ADMIN_EMAIL}" \ + --password "${ADMIN_PW}" \ + --must-change-password=false 2>&1 | tail -3 || true + +echo "" +echo "Registering OIDC auth source 'hephaestus-wallet' in Forgejo…" +docker compose exec -T --user 1000 forgejo forgejo admin auth add-oauth \ + --provider openidConnect \ + --name hephaestus-wallet \ + --key "${AUTH_PROXY_CLIENT_ID}" \ + --secret "${AUTH_PROXY_CLIENT_SECRET}" \ + --auto-discover-url "${AUTH_PROXY_ISSUER}/.well-known/openid-configuration" \ + --scopes "openid profile" \ + --group-claim-name "" \ + --skip-local-2fa 2>&1 | tail -5 + +echo "" +echo "=== Bootstrap complete ===" +echo "Admin credentials (save these somewhere — needed for site admin):" +echo " URL: ${FORGEJO_ROOT_URL}" +echo " Username: ${ADMIN_USER}" +echo " Password: ${ADMIN_PW}" +echo "" +echo "For normal use: log in at ${FORGEJO_ROOT_URL} → 'Sign in with hephaestus-wallet'." diff --git a/scripts/push-split.sh b/scripts/push-split.sh new file mode 100644 index 0000000..ece165e --- /dev/null +++ b/scripts/push-split.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# push-split.sh — push a subdirectory of the monorepo working-copy to its +# matching split repo on Hephaestus, preserving history via git-subtree-split. +# +# Since silentmode/silentmode was deleted and each project lives in its own +# repo on the forge (silentmode/theseus, silentmode/ariadne, silentmode/hephaestus, +# silentmode/sirius, …), cross-cutting commits made in this working copy need to +# be routed to whichever remote corresponds to the touched project. +# +# Usage: +# scripts/push-split.sh [remote-name] +# +# Examples: +# scripts/push-split.sh Hephaestus # → git remote 'hephaestus' +# scripts/push-split.sh TheseusNavigator # → git remote 'theseus' (Navigator suffix stripped) +# scripts/push-split.sh AriadneResolver # → git remote 'ariadne' (Resolver suffix stripped) +# scripts/push-split.sh site-sirius-x sirius # → remote 'sirius' (explicit override) +# +# List of configured remotes: `git remote -v` +# +# Notes: +# - Only ONE prefix per invocation. Cross-cutting changes that touched multiple +# projects need to be run once per touched directory. +# - Uses --branch , pushes, then deletes it. Nothing sticks around. +# - Push target is always :master. Passing --branch was intentional +# over --squash: split history is preserved so file blame on the split repo +# still points at the real commits. +set -euo pipefail + +prefix="${1:?usage: push-split.sh [remote-name]}" + +# Derive a remote name from the directory if not passed. +# Heuristic: lowercase, strip Navigator/Resolver suffix. Otherwise pass explicitly. +default_remote=$(echo "$prefix" | tr '[:upper:]' '[:lower:]' | sed 's/navigator$//; s/resolver$//') +remote="${2:-$default_remote}" + +[ -d "$prefix" ] || { echo "error: no such directory: $prefix" >&2; exit 1; } +git remote get-url "$remote" >/dev/null 2>&1 || { + echo "error: no git remote named '$remote'." >&2 + echo "available remotes: $(git remote | paste -sd ' ' -)" >&2 + echo "pass an explicit remote name as the 2nd arg." >&2 + exit 1 +} + +# Uncommitted changes get carried into the split — usually not what you want. +# Warn (but don't block; sometimes you know what you're doing). +if ! git diff --quiet HEAD -- "$prefix" 2>/dev/null; then + echo "warning: $prefix has uncommitted changes — those will NOT be included in the split." >&2 +fi + +branch="_push_${remote}_$$" +trap 'git branch -D "$branch" >/dev/null 2>&1 || true' EXIT + +echo "→ splitting $prefix/ into ephemeral branch $branch …" +git subtree split --prefix="$prefix" HEAD -b "$branch" >/dev/null + +# Silent Mode public repos do not carry AI-tool attribution in commit metadata +# (see CLAUDE.md at the repo root). Strip any "Co-Authored-By: Claude ..." / +# "Co-authored-by: Claude ..." trailers from the ephemeral branch before push. +# Local monorepo history is untouched — only what lands on the forge is filtered. +echo "→ scrubbing Co-Authored-By: Claude trailers from $branch …" +# filter-branch refuses to run from a dirty working tree ("You have unstaged +# changes"), and with set -e that silently aborted the push whenever another +# session had uncommitted edits. Run it from a throwaway worktree of the +# ephemeral branch instead — refs are shared, the main tree is never touched. +scrubwt=$(mktemp -d "${TMPDIR:-/tmp}/push-split-XXXXXX") +git worktree add --detach --quiet "$scrubwt" "$branch" +if ! (cd "$scrubwt" && FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch --force --msg-filter ' + sed -e "/^Co-[Aa]uthored-[Bb]y: Claude.*/d" +' "$branch" >/dev/null 2>&1); then + git worktree remove --force "$scrubwt" >/dev/null 2>&1 || true + echo "error: trailer scrub failed — not pushing." >&2 + exit 1 +fi +git worktree remove --force "$scrubwt" >/dev/null 2>&1 || true +# filter-branch leaves refs/original/refs/heads/$branch as a backup — delete it +# so it can not be pushed by accident (--mirror isn't used here, but be safe). +git update-ref -d "refs/original/refs/heads/$branch" 2>/dev/null || true + +echo "→ pushing $branch → $remote:master …" +git push --force "$remote" "$branch:master" + +echo "✓ done — see https://code.silentmode.st/silentmode/$remote" 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" diff --git a/scripts/sync-hephaestus-x-to-sia.sh b/scripts/sync-hephaestus-x-to-sia.sh new file mode 100644 index 0000000..21d1b37 --- /dev/null +++ b/scripts/sync-hephaestus-x-to-sia.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# sync-hephaestus-x-to-sia.sh — nightly disaster-recovery backup of the +# static hephaestus.x content from VPS to the Sia bucket. +# +# The BCNR record on hephaestus.x currently points at the VPS via `ip:` only, +# so users route through this VPS. This script keeps a parallel copy on Sia +# for disaster recovery: if the VPS dies, updating the BCNR record from +# `ip:` back to `s3:"bns/hephaestus.x/"` (single Argus CLI call) restores +# service from the Sia bucket in the time it takes for one BCH transaction +# to confirm on chipnet. +# +# Runs on the p2pexchange VPS via systemd timer (hephaestus-x-sia-sync.timer). +set -euo pipefail + +cd "$(dirname "$0")/.." # → /root/hephaestus +set -a; source .env; set +a + +: "${SIA_STORAGE_ENDPOINT:?}" +: "${SIA_STORAGE_ACCESS_KEY:?}" +: "${SIA_STORAGE_SECRET_KEY:?}" + +# Use rclone with an inline S3 remote — no config file needed. +export RCLONE_CONFIG_SIA_TYPE=s3 +export RCLONE_CONFIG_SIA_PROVIDER=Other +export RCLONE_CONFIG_SIA_ENDPOINT="https://${SIA_STORAGE_ENDPOINT}" +export RCLONE_CONFIG_SIA_ACCESS_KEY_ID="$SIA_STORAGE_ACCESS_KEY" +export RCLONE_CONFIG_SIA_SECRET_ACCESS_KEY="$SIA_STORAGE_SECRET_KEY" +export RCLONE_CONFIG_SIA_REGION=us-east-1 + +echo "[$(date -u +%FT%TZ)] syncing /var/www/hephaestus.x/ → sia:bns/hephaestus.x/" +rclone sync /var/www/hephaestus.x/ sia:bns/hephaestus.x/ --checksum + +echo "[$(date -u +%FT%TZ)] done. bucket contents:" +rclone size sia:bns/hephaestus.x/