Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f6203b2f7 | ||
|
|
b40cb6458b | ||
|
|
54e8042764 | ||
|
|
fadbfc8eb5 | ||
|
|
d8f9df7e77 | ||
|
|
86df178022 | ||
|
|
d45ebb8f39 | ||
|
|
a2ab1f85eb | ||
|
|
be798a9bc2 | ||
|
|
8a512774d7 | ||
|
|
592a9fd939 | ||
|
|
6d5b1992b5 | ||
|
|
649c714aea | ||
|
|
0d46225efc | ||
|
|
140ef8726b | ||
|
|
0cc278abf1 | ||
|
|
003b152230 | ||
|
|
75f835641f | ||
|
|
872923dba2 | ||
|
|
10f2bf707b | ||
|
|
f42e761e52 | ||
|
|
e208e05b83 | ||
|
|
ba21dad550 | ||
|
|
09d2451f2c | ||
|
|
e69a93a825 | ||
|
|
96e2ef8609 | ||
|
|
d450580ef5 | ||
|
|
7143c36187 | ||
|
|
7682cb77bf | ||
|
|
ed32deb4ba | ||
|
|
a2a2bee71e | ||
|
|
5660c55cb6 | ||
|
|
9e1ee75814 | ||
|
|
d9e61ce1b7 | ||
|
|
f0afc4358c | ||
|
|
273f6b8edb | ||
|
|
b105d5abae | ||
|
|
4671e51465 | ||
|
|
375dea22ca | ||
|
|
0b85caa80a |
+51
-2
@@ -211,9 +211,10 @@
|
||||
- **result:** Empirically measured against **all 4 release versions** + origin/main: v1.14.4 (broken — no src/ in tarball), v1.14.8, v1.14.9, and origin/main all produce **0 require-fixes applied** under the old script. Every patch is a no-op against every current release. Decision: **KEEP the script but repurpose it as a VERIFIER, not a patcher.** The script now performs 5 explicit checks (server.js requires correct, license-manager.js path correct, src/ directory present + non-empty + contains app.js, license-keygen.js at API root) + an informational scan of all src/ require paths. **Exits 1 if any check fails** — fails the build loudly instead of silently letting a crash-looping container reach production. Behaviour change: the OLD script would silently no-op on v1.14.4 (couldn't find src/ to patch); the NEW script reports `=== FAILED CHECKS ===` with the specific failures (e.g. `src/: directory missing — v1.14.4-class bug`). Verified against v1.14.4 tarball: old script 0 patches + exit 0, new script 2 failures + exit 1 + clear error names the v1.14.4-class bug. New regression test `dashcaddy-api/scripts/test-dashcaddy-post-deploy-verifier.sh` (17 assertions across 10 test groups including clean tree, missing server.js, broken server.js requires, missing src/, missing license-keygen.js, broken license-manager path, empty src/, missing src/app.js, absolute path resolution, non-existent API_DIR) — all pass. Tests: 1214/1214 Jest + 31 shell assertions. Lint: 150 warnings, all pre-existing in untouched files.
|
||||
|
||||
### DC-041: Add integration test for the auto-update pipeline (trigger.json → bash → docker rebuild → health check → result.json)
|
||||
- **status:** in-progress
|
||||
- **status:** done (commit 0b85caa, 5 scenarios / 37 assertions all green)
|
||||
- **owner:** hermes
|
||||
- **details:** The host-side updater has zero integration coverage. The recent DC-033 incident showed this whole chain is one big untested path. Build a test harness that: (1) creates a temporary directory mimicking `/opt/dashcaddy/updates/staging/dashcaddy-api` with a known-good tarball. (2) writes a `trigger.json` to a test `UPDATES_DIR`. (3) runs `bash /opt/dashcaddy/scripts/dashcaddy-update.sh` with paths overridden via env vars. (4) asserts `result.json` has `success: true` and the version matches. (5) cleans up. Effort: ~2 hours. Risk: medium — the script uses `docker build` so the test needs either Docker-in-Docker (DinD) or mocking the docker calls.
|
||||
- **result:** `dashcaddy-api/scripts/test-dashcaddy-update-integration.sh` (552 lines) commits and exits 0. Strategy: sandbox at `/tmp/dashcaddy-test-XXXXXX/opt/dashcaddy/` with `/opt/dashcaddy` path-rewritten via `sed`, mocked `docker` binary prepended to PATH, real `dashcaddy-post-deploy-patches.sh` verifier copied in, and a Python one-shot HTTP responder on port 33001 driving the health check (33001 chosen to avoid clashing with the live DashCaddy API on 3001). 5 scenarios: (1) happy-path update v1.14.8→v1.14.9 with mocked docker build/rm/run, backups, result.json; (2) v1.14.4-class broken tarball (no src/) — asserts the verifier IS invoked and DOES detect the bug ("Build should be ABORTED" in log); current `dashcaddy-update.sh` warns-and-continues on verifier failure, so this scenario asserts that observed behavior with a TODO note about closing that gap in a follow-up; (3) rollback to a pre-populated backup; (4) no trigger.json → no-op exit 0; (5) prerelease channel rejection when `ALLOW_PRERELEASE` is not set.
|
||||
- **impact:** Closes the biggest untested surface in DashCaddy. Would have caught the v1.14.4 packaging bug immediately on the next release.
|
||||
|
||||
### DC-042: Replace null stubs in src/app.js getTailscaleStatus() with real Tailscale manager
|
||||
@@ -249,7 +250,7 @@
|
||||
Tickets DC-033 through DC-041 were added after the DNS2 v1.14.4 / v1.14.8 / 0.0.0 incident. They are grounded in real evidence from that session — see DC-033's details for the full chain of reasoning (cross-checked by main agent + z.ai subagent).
|
||||
|
||||
### DC-044: Fix WorkflowEngine healthCheckService — servicesStateManager.getState bug (silent every-5min error spam)
|
||||
- **status:** in-progress
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `src/recipes/bundled-workflows.js:310` calls `servicesStateManager.getState()` which doesn't exist (StateManager exposes `read()`, not `getState()`). Combined with a missing `await`, the call returned a Promise (truthy), short-circuited via `|| []` to an empty array, then `for (const service of services)` silently iterated over zero services. Net effect: every `health-check-on-interval` workflow ran every 5 min, reported `Action health-check failed: servicesStateManager.getState is not a function`, sent a "Health check failed for {{serviceId}}" notification (with the unresolved template!), and produced `checked: 0, healthy: 0` results. Visible on BOTH DNS2 (production, 4 days) and test server dc-contabo-de (9 days). Fix: call `await servicesStateManager.read().catch(() => [])` — proper async + corruption-tolerant.
|
||||
- **impact:** Workflow health checks now actually check container health, instead of silently reporting 0/0 every cycle. Stops the "Health check failed" notification spam.
|
||||
@@ -305,6 +306,54 @@ Sami explicitly stated he wants email auth as an OPTION alongside TOTP, not a re
|
||||
- **Fix (three parts):** (1) `platform-paths.assertSafe({ mode })` — throws a clear FATAL in production mode if dataDir resolves into any of 11 forbidden zones (`/app/src`, `/app/routes`, `/app/scripts`, `/app/utils`, `/app/managers`, `/app/security`, `/etc`, `/etc/caddy`, `/etc/dashcaddy`, `/usr`, `/usr/local`, `/var`, `/var/lib/caddy`). Calls a second predicate `isMountedCheck(dir)` that returns false for non-writable or non-existent dirs (Windows warning, not throw). Bypassed with `SKIP_DATA_DIR_GUARD=1`. (2) `server.js:35` — calls `assertSafe` before any other startup work. Refuses to boot loudly instead of running with a path that loses data silently. (3) `start.sh:13-66` — one-time migration step runs before `docker run`. Scans 6 known image-layer zombie paths (`/opt/dashcaddy/dashcaddy-api/src/{security,utils,managers}/*`), copies any non-empty content to `${DATA_DIR}/migrated-*`, gates one-shot with a sentinel file `.migrated-from-image-layer`. Idempotent. Survives `set -e` per-file failures. Per-file `cp -a` guarded so a single unreadable zombie can't take the container down. Will recover the 140 KB `error.log` that the live DNS2 container has in its image layer (timestamp Jul 6 — pre-DC-039 era).
|
||||
- **result:** 19/19 platform-paths tests pass (8 new for assertSafe + 3 new for isMountedCheck). 5/5 start.sh migration tests pass (sentinel-skips, file-copies, idempotent-no-clobber, empty-file-skip, set-e-survives-failure). DNS2 deploys unchanged except for the new migration step running once on next recreate. Suite overall: 1066/1067 (one pre-existing public-routes-drift failure from in-flight Track A code, untouched).
|
||||
|
||||
### DC-052: License-tier enforcement — Free caps user count at 3, gates share features on Pro
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Per `/root/dashcaddy/PRODUCT-SPEC-DECISIONS.md` (locked 2026-07-20): Free = up to 3 users, Pro = unlimited. The DC-048 user-store needs a `countUsers()` helper. The `/api/v1/auth/admin/invites` POST handler must check `if (users.count() >= 3 && !licenseManager.isPro()) throw new ValidationError('upgrade required', 'tier')`. Same check on `POST /admin/users` (pre-authorize). Share-link creation routes (DC-053) gate on `licenseManager.isPro()`. **Free has NO trial path** — there is no automatic Pro trial, no time-limited upsell. The user picks Free or Pro deliberately. **LIFETIME keys are creator-only**: the API rejects any LIFETIME code at `verifyCode` time in production. The `license-keygen.js --lifetime` path stays on Sami's dev machine only; it's never wired to Stripe Checkout.
|
||||
- **impact:** First pricing enforcement. Without this, Pro is just a label. With this, every upgrade path has a clear moment to upsell.
|
||||
- **prerequisite:** DC-048 (shipped).
|
||||
- **result:** Audited the implementation already present in commit `273f6b8` (the backlog status was stale). `user-store.js` exposes atomic `countUsers()`. Auth admin routes enforce the 3-user Free cap on both `POST /admin/users` and `POST /admin/invites`, returning `PaymentRequiredError` (402) before creation; invite acceptance also enforces the cap. Share creation is Pro-gated in DC-053. `LicenseManager.activate()` rejects lifetime codes unless `ALLOW_LIFETIME_LICENSE=true`, preserving creator-only lifetime keys. Existing regression suite `license-tier-enforcement.test.js` covers the cap, Pro bypass, invite gate, lifetime behavior, and count/delete semantics. Full Jest baseline and post-audit: **52 suites, 1372 tests passed**. ESLint reported 180 existing problems (including 4 existing errors); no source files were changed in this audit, so no new lint issues were introduced.
|
||||
|
||||
### DC-053: Public share links + Tailscale-mediated share — Pro-gated
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **result:** Shipped as `PROD` commit (this session). Share-store (`src/security/share-store.js`) + share-routes (`routes/share.js`) + 53 tests (24 store + 29 routes, full suite 1372/1372). Public endpoints CSRF-exempt (token IS proof); admin POSTs gated on `licenseManager.isPro()` → 402 PaymentRequired on Free. Tailscale path mints single-use ephemeral pre-auth key, emails join link, rolls back the share record if `tailscaleCoord.createAuthKey()` throws so no orphans leak. Email-delivery failure path exposes raw `urlPath` so admins can manually deliver when SMTP is down. Drift-test parser hardened against quoted-word comments. Public-route drift test registers `routes/share.js` with a real-shape shareStore stub so the router walker enumerates the share paths. **UI side still pending** — no "Share" button on service cards yet, modal not built (admin can still exercise via curl).
|
||||
- **details:** Two new feature surfaces behind a Pro license check. (1) **Public share links** — `POST /api/v1/share` creates a signed URL (e.g. `https://status.sami/share/<token>`) for a specific service + a TTL (1h/24h/7d). The share page renders a read-only preview: service metadata + a `subscribe` button that hits `/api/v1/share/:token/subscribe` to register the visitor's email for updates. (2) **Tailscale-mediated share** — `POST /api/v1/share/tailscale` generates a Tailscale pre-auth key (one-shot, single-use, 24h) scoped to a specific device tag, emails the link to the invitee; clicking it joins them to the host's tailnet and proxies them to the service. Both surfaces gated on `licenseManager.isPro()` (DC-052). UI: a "Share" button on each service card, modal with the two tabs.
|
||||
- **impact:** The killer Pro feature. "Share your services with anyone, they don't even need a Tailscale account" — that's the pitch. Without this, Pro has no upgrade pull.
|
||||
- **prerequisite:** DC-042 + DC-043 (Tailscale manager + coord API shipped); DC-052 (license check); DC-048 (invite flow model).
|
||||
|
||||
### DC-054: License-keygen CLI improvements + Stripe webhook bridge script
|
||||
- **status:** in-progress
|
||||
- **owner:** hermes
|
||||
- **details:** Existing `dashcaddy-api/license-keygen.js` already supports durations [30, 90, 180, 365]. Three additions: (1) `--tier pro` flag (currently `--duration 30/90/180/365` — duration alone implies Pro, so the flag is just for CLI clarity). (2) `dashcaddy-api/scripts/stripe-license-bridge.js` — listens on `STRIPE_WEBHOOK_SECRET`, validates `checkout.session.completed` events, looks up the duration by SKU ID, generates a license key, emails it to the customer, returns `{delivered: true}` to Stripe. (3) `dashcaddy-api/license-keygen.js` validation path — already exists, no change. Sami generates initial keys via CLI for the launch.
|
||||
- **impact:** Closes the loop between Stripe payment and license-key delivery. Without this, every sale requires manual key generation by Sami.
|
||||
- **prerequisite:** None. Stripe-side can be set up in parallel with DC-052.
|
||||
|
||||
### DC-055: dashcaddy.net/pricing static page + Stripe Checkout integration
|
||||
- **status:** in-progress
|
||||
- **owner:** hermes
|
||||
- **details:** Static page at `/pricing` showing the 5-row tier table (Free / 1mo / 3mo / 6mo / 12mo). Stripe Checkout button per paid tier. On success, the page reveals the license key with copy-button + "Here's how to install it" link. Receipt email sent via Stripe's built-in. No account creation in this flow (Q10 decision — optional dashcaddy.net account is post-v1.0).
|
||||
- **impact:** The conversion surface. Without this, the product is real but unsellable.
|
||||
- **prerequisite:** DC-054 (Stripe webhook bridge so licenses auto-issue).
|
||||
- **result:** Hermes sprint 2026-08-02 shipped the public-routes-drift half of DC-055 (commit 86df178, grade A): public-routes-drift.test.js now correctly maps `routes/billing.js` to `/billing` prefix in the walker (was previously bare-mount, so `/api/v1/billing/checkout` was flagged as stale drift) and adds `routes/services.js` to directMounts (was missing — `/api/v1/services` + `/api/v1/services/status` were incorrectly flagged stale). Removed dead `/api/v1/billing/webhook` PUBLIC_ROUTES entry — webhooks are handled out-of-process by `scripts/stripe-license-bridge.js` and the merchant webhook secret never enters the API process. The drift test now has 14/14 pass and the dead entry is documented in code so a re-add would fail loudly. Krystie's prior uncommitted work (`src/billing/stripe-client.js`, `routes/billing.js`, `scripts/stripe-license-bridge.js`, public pricing page, license-keygen LICENSE_SECRET_FILE refactor) is now buildable: full Jest suite is 1486/1486 green, zero new ESLint errors. Remaining for the actual pricing UI commit: verify the standalone `scripts/stripe-license-bridge.js` works against a real Stripe sandbox end-to-end, then add the pricing page to the Caddy file routing.
|
||||
|
||||
### DC-057: Close checkout-to-license contract drift before public billing launch
|
||||
- **status:** todo
|
||||
- **owner:** unclaimed
|
||||
- **details:** Codex audit found the current Stripe checkout and webhook bridge cannot interoperate: `dashcaddy-api/src/billing/stripe-client.js` emits `metadata: {tier, period, product}` while `dashcaddy-api/scripts/stripe-license-bridge.js` requires `metadata.sku`, so every paid Checkout completion returns `unknown-sku` and no license is delivered. The locked product decisions define one-time 30/90/180/365-day licenses at $20/$50/$70/$99, while the current pricing page and billing client present monthly/annual subscriptions. The product spec promises a license key on the success page, while the current page only redirects to Checkout and documents email-only delivery. The bridge comments and implementation also disagree about whether email failures are recorded for retry/idempotency.
|
||||
- **impact:** Current billing can accept payment without issuing a license, which blocks public release and risks paid-customer support incidents.
|
||||
- **prerequisite:** DC-054 and DC-055 working-tree billing artifacts are present but not yet released as a coherent, verified flow.
|
||||
- **acceptance:** Define one canonical paid-product catalog shared by Checkout, webhook fulfillment, tests, and pricing labels; use the locked USD prices and one-time payment/duration semantics; feed the exact Checkout metadata into the bridge in a cross-module contract test; make duplicate/retry events unable to issue two keys; persist a recoverable license before email delivery and provide an explicit, tested SMTP-failure recovery path; reconcile success-page behavior, one-license-per-host wording, and legal/product copy with the actual secure delivery mechanism.
|
||||
- **result:** Rolled back to `todo` on 2026-08-02. The initial implementation attempt added an unintegrated catalog/fulfillment store but did not complete the client/bridge contract, crash-safe generation, production bridge topology/ingress, updater/systemd delivery, or lifetime-path audit. Preserve the evidence above for the next claimant and do not ship the partial working-tree artifacts.
|
||||
|
||||
### DC-056: ToS + Privacy Policy pages — GDPR-aware, no SOC2/HIPAA for v1.0
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Two static pages at `/legal/tos` and `/legal/privacy`. ToS covers: license terms (per-host, non-transferable), prohibited use, refund policy (pro-rated refunds within 14 days of initial purchase), termination. Privacy Policy covers: data collected (license key, host metadata, optional email), data NOT collected, third parties (Stripe — payment, Tailscale — coord API calls only when operator configures it), GDPR rights (access, deletion, portability — even though we have no central account system, we'll respond to direct requests within 30 days). No SOC2/HIPAA — that's a v2 conversation.
|
||||
- **impact:** Legal compliance for taking money. Stripe can technically sell without these but payment processors flag accounts without them.
|
||||
- **prerequisite:** None.
|
||||
- **result:** Added responsive Terms and Privacy HTML at `status.sami/legal/{terms,privacy}`, a `tos` meta-refresh redirect to `terms`, dashboard footer links, and a DNS2 deploy script that rsyncs to `/var/www/dashcaddy-status/legal/` then validates each URL via curl. Terms apply the launch requirement of pro-rated refunds within 14 days. Single canonical host (status.sami) — the aspirational `legal.dashcaddy.net` is deferred to a v1.x deploy when DNS+Caddy vhost+LE cert infra is in place.
|
||||
|
||||
### Backlog note (2026-07-14)
|
||||
|
||||
Tickets DC-046 through DC-049 implement pluggable auth + email magic link. Sami explicitly stated he wants email auth as an OPTION alongside TOTP, not a replacement — TOTP remains his primary method for personal/network-only access, email magic link is for public-product readiness. Architecture choice: `AuthProvider` interface in `src/auth/providers/` so future methods (OIDC, SAML, passkeys) plug in without further refactors. SMTP delivery reuses the existing `nodemailer` integration in `src/managers/notification-manager.js:290` — no new dependency. Sami plans to use the SMTP server his website runs (sami-ahmed.net) so the host field will be configurable. Total estimated effort: ~7 hrs, can ship in any order DC-046 → DC-047 → DC-048 → DC-049, but DC-046 is the foundation.
|
||||
|
||||
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Public share links + Tailscale-mediated share — DC-053.** Pro-tier feature behind a 402 PaymentRequired gate on Free installs. New `src/security/share-store.js` (signed tokens, HMAC binding to serviceId, atomic writes, persistent signing secret in `dataDir/.share-secret`, auto-prune, defensive dataDir resolver). New `routes/share.js` with admin endpoints `POST /api/v1/share` (1h/24h/7d public links), `POST /api/v1/share/tailscale` (single-use pre-auth key + email join link via existing `notificationManager.sendEmail`, with rollback on Tailscale API failure), `GET /api/v1/share` (list), `DELETE /api/v1/share/:id` (revoke). Public endpoints `GET /api/v1/share/:token/preview`, `POST /api/v1/share/:token/subscribe`, `POST /api/v1/share/:token/redeem-tailscale` — CSRF-exempt because the token IS the proof, same model as invite-accept. New 53-test suite (24 store + 29 routes) covers full lifecycle, signature-tamper rejection, subscription cap enforcement, Tailscale rollback on key-mint failure, email-delivery fallback path. Drift-test parser hardened against quoted-word comments. Full suite 1372/1372.
|
||||
- **Multi-user bootstrap + admin invites — DC-048.** Opt-in via `siteConfig.authProviders.email.enabled = true`. Single-user TOTP-only installs see zero behavior change. When opted in: the first email to log in becomes admin (bootstrap rule), subsequent emails must be on the allowlist. New `src/security/user-store.js` (users + allowlist + bootstrap sentinel, atomic writes, last-admin protection) and `src/security/invite-store.js` (single-use tokens, SHA-256 hashed on disk, TTL, auto-prune). New `routes/auth/admin.js` mounts `/api/v1/auth/me`, `/api/v1/auth/admin/users` (GET/POST/PATCH/DELETE), `/api/v1/auth/admin/allowlist`, `/api/v1/auth/admin/invites` (GET/POST/DELETE), public `/api/v1/auth/invites/:token` (peek) and `/api/v1/auth/invites/:token/accept` (redeem). EmailMagicLinkProvider `verify()` and TOTP `verify()` tag `req.user` for audit attribution; TOTP bootstraps a `system@totp.local` admin record on first login so current operators show up in `/admin/users` without re-login. Audit logger middleware adds `userId`/`userEmail`/`userRole`/`viaProvider` to log details. New admin UI in `status/js/admin.js` (modal overlay with users list, role-edit, delete, invite form, copy-link button, outstanding-invites list with revoke). "Admin" button auto-injects into the top bar when `/me` returns `isAdmin: true`. 35 new tests; full suite 1298/1298.
|
||||
- **Pluggable auth UI — DC-049.** `status/js/auth-gate.js` discovers enabled providers via `GET /api/v1/auth/login/methods` and renders either a provider selector (2+ enabled), the TOTP overlay with an "Or sign in with email instead →" alt-link, or the pure legacy TOTP overlay. Email provider renders inline: input + "Send sign-in link" button → POST `/api/v1/auth/login/email/initiate`. Coordination flag `window.__dc_049_handled` eliminates flicker on multi-provider installs. New SW cache hash `dashcaddy-shell-c550d0b371`.
|
||||
- **Pluggable `AuthProvider` framework + TOTP + EmailMagicLink providers (DC-046 + DC-047).** New `src/auth/providers/` directory contains the `AuthProvider` base class contract, the TOTP provider (refactored from existing `routes/auth/totp.js`), and a new `EmailMagicLinkProvider` that issues single-use base64url tokens (stored as SHA-256 hashes in `data/email-tokens.json`), sends via the existing nodemailer config (or logs to console + `log.info('email magic link issued')` in dev fallback). `createAuthProviderRegistry()` composes all providers and surfaces them via `/api/v1/auth/login/methods` (`GET`), `/api/v1/auth/login/:provider/{initiate,verify}` (`POST`), `/api/v1/auth/login/recovery-info` (`GET`), `/api/v1/auth/disable/:provider` (`POST`). Future auth methods (OIDC, SAML, passkeys) plug into the registry without auth-path refactors.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# DashCaddy Product-Spec Decisions — Locked 2026-07-20
|
||||
|
||||
> All decisions captured from clarifying questions with the operator. This
|
||||
> file is the source of truth for what gets built next. The narrative
|
||||
> PRODUCT-SPEC.md retains the longer "what we considered" context; this
|
||||
> file is what we *shipped*.
|
||||
|
||||
## 1. Pricing
|
||||
|
||||
| Tier | Duration | Price | Per-month equiv |
|
||||
|---|---|---|---|
|
||||
| Free | unlimited | $0 | $0 |
|
||||
| 1 month | 30 days | $20 | $20.00 |
|
||||
| 3 months | 90 days | $50 | $16.67 (17% off) |
|
||||
| 6 months | 180 days | $70 | $11.67 (42% off) |
|
||||
| 12 months | 365 days | $99 | $8.25 (59% off) |
|
||||
|
||||
- Stripe Checkout only (no Paddle for v1.0)
|
||||
- USD only (defer multi-currency to v1.1)
|
||||
- Stripe-standard 30-day refund
|
||||
- No launch pricing — list prices as-is
|
||||
- **Free is completely free. No Pro trial. Pro is a deliberate paid choice.**
|
||||
- **Lifetime keys are creator-only.** Only Sami (the creator) can issue a LIFETIME key via `license-keygen.js --lifetime` on his dev machine. The production API rejects any LIFETIME code at `verifyCode` time. No one else ever gets a permanent key — every other paid customer gets a 30/90/180/365-day key.
|
||||
|
||||
## 2. Tier features
|
||||
|
||||
**Free:**
|
||||
- All self-hosted features, unlimited services
|
||||
- Up to 3 users (host owner + 2 invitees)
|
||||
- NO share links (no Tailscale-mediated share, no public share URLs)
|
||||
- Host owner may use TOTP-only login (no email required)
|
||||
|
||||
**Pro (any paid duration):**
|
||||
- Unlimited users (no cap on invitees)
|
||||
- Tailscale-mediated share — invitees click a link, get scoped access via tailnet without configuring anything
|
||||
- Public share links — signed URLs for read-only previews (no Tailscale needed)
|
||||
- Cloud config backup (deferred to v1.1, but already on roadmap)
|
||||
|
||||
The host's invitees MUST use email magic link as their identity — the email IS the username for non-host users. The host themselves can stay TOTP-only.
|
||||
|
||||
## 3. Account / license model
|
||||
|
||||
- **Use existing `license-keygen.js`** (HMAC-signed 16-byte codes; VALID_DURATIONS = [30, 90, 180, 365]).
|
||||
- License keys are per-host. One license = one host. Multi-host deferred to post-v1.0.
|
||||
- License validation is **fully offline** — no phone-home, no account required for the instance.
|
||||
- Purchase flow:
|
||||
1. User picks tier on dashcaddy.net/pricing
|
||||
2. Stripe Checkout → success page shows license key
|
||||
3. Receipt email includes the license key as backup
|
||||
4. User pastes key into their instance → Pro features unlock
|
||||
- **Optional** dashcaddy.net account (post-purchase) for managing subscription, downloading past invoices, recovering license keys. Deferred to v1.1.
|
||||
|
||||
## 4. Invitee auth flow
|
||||
|
||||
When host enables email auth via `siteConfig.authProviders.email.enabled = true`:
|
||||
- First email to log in becomes the bootstrap admin (existing DC-048 behavior)
|
||||
- Host generates invite via `/api/v1/auth/admin/invites` (existing DC-048)
|
||||
- Invitee receives magic-link email → clicks → POSTs token to `/api/v1/auth/invites/:token/accept` → user record created + session cookie set
|
||||
- Magic-link TTL = 24 hours; single-use
|
||||
|
||||
## 5. What we deferred to post-v1.0
|
||||
|
||||
- Multi-host support (one license = one host for v1.0)
|
||||
- Multi-currency pricing (USD only)
|
||||
- Custom Pro trial (rely on existing EULA 30-day evaluation)
|
||||
- Launch / founders / discount codes
|
||||
- Central dashcaddy.net accounts (subscription management)
|
||||
- Cloud config backup (Pro feature placeholder)
|
||||
- SAML SSO (was Business-tier; dropped since we have no Business tier)
|
||||
- Hosted offering (cloud.dashcaddy.net — separate ops burden, deferred entirely)
|
||||
|
||||
## 6. Build order — what this enables
|
||||
|
||||
This decision set unblocks the following build items, in priority order:
|
||||
|
||||
1. **License-tier enforcement in the API.** Now that Free = up to 3 users, the existing DC-048 user-store needs a `countUsers()` helper + a check on user-creation that fires `402 Payment Required` when the cap is exceeded without a Pro license. (DC-052)
|
||||
2. **Pro-gated share-link routes.** Public-share-link routes (`/api/v1/share/:token`) + Tailscale-mediated share routes. Both gated on `licenseManager.isPro()`. (DC-053)
|
||||
3. **License-keygen CLI improvements.** The existing tool already supports the 4 durations. Needs a `--tier` flag and a Stripe-webhook bridge script (`scripts/stripe-license-bridge.js`) that converts a Stripe Checkout success → license key + email. (DC-054)
|
||||
4. **dashcaddy.net pricing page.** Static page at `/pricing` showing the tier table, Stripe Checkout button, and license-key reveal UI on success. (DC-055)
|
||||
5. **Compliance minimums.** ToS + Privacy Policy at `/legal/tos` and `/legal/privacy`. GDPR-aware, no SOC2/HIPAA. (DC-056)
|
||||
|
||||
The DC-048 multi-user foundation is the gating prerequisite for items 1-2. That foundation already shipped.
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
**Self-hosted dashboard for managing Docker apps with automatic SSL, DNS, and reverse proxy configuration.**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## What is DashCaddy?
|
||||
|
||||
@@ -397,7 +397,7 @@ Contributions are welcome! Please:
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details
|
||||
Proprietary software. All rights reserved. See [LICENSE](LICENSE) for the End-User License Agreement (EULA).
|
||||
|
||||
## Credits
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
8ea41e0
|
||||
20260722-065235-cookie-only-session-653478a
|
||||
|
||||
@@ -52,15 +52,15 @@ describe('WorkflowEngine.healthCheckService — DC-042 followup (getState bug)',
|
||||
const result = await engine.healthCheckService('{{serviceId}}');
|
||||
|
||||
expect(readMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ checked: 0, healthy: 0, results: [] });
|
||||
expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
|
||||
});
|
||||
|
||||
test('returns checked/healthy counts from read() output', async () => {
|
||||
test('returns checked/healthy counts from read() output (all healthy)', async () => {
|
||||
const docker = {
|
||||
client: {
|
||||
getContainer: jest.fn((id) => ({
|
||||
inspect: jest.fn().mockResolvedValue({
|
||||
State: { Running: id === 'c1' },
|
||||
State: { Running: true, Health: { Status: 'healthy' } },
|
||||
}),
|
||||
})),
|
||||
},
|
||||
@@ -79,10 +79,38 @@ describe('WorkflowEngine.healthCheckService — DC-042 followup (getState bug)',
|
||||
const result = await engine.healthCheckService('{{serviceId}}');
|
||||
|
||||
expect(result.checked).toBe(2); // svc-3 skipped (no containerId)
|
||||
expect(result.healthy).toBe(1); // c1 is running, c2 is not
|
||||
expect(result.healthy).toBe(2); // both containers healthy
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.results[0]).toMatchObject({ service: 'svc-1', healthy: true });
|
||||
expect(result.results[1]).toMatchObject({ service: 'svc-2', healthy: false });
|
||||
expect(result.results[1]).toMatchObject({ service: 'svc-2', healthy: true });
|
||||
expect(result.failing).toEqual([]);
|
||||
});
|
||||
|
||||
test('throws when any service is unhealthy — surfaces failing service IDs', async () => {
|
||||
const docker = {
|
||||
client: {
|
||||
getContainer: jest.fn((id) => ({
|
||||
inspect: jest.fn().mockResolvedValue({
|
||||
State: { Running: id !== 'c2', Health: { Status: id === 'c2' ? 'unhealthy' : 'healthy' } },
|
||||
}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
const engine = makeEngine({
|
||||
servicesStateManager: {
|
||||
read: jest.fn().mockResolvedValue([
|
||||
{ id: 'svc-1', containerId: 'c1' },
|
||||
{ id: 'svc-2', containerId: 'c2' },
|
||||
]),
|
||||
},
|
||||
docker,
|
||||
});
|
||||
|
||||
await expect(engine.healthCheckService('{{serviceId}}')).rejects.toThrow(/svc-2/);
|
||||
await expect(engine.healthCheckService('{{serviceId}}')).rejects.toMatchObject({
|
||||
failingServices: ['svc-2'],
|
||||
workflowResult: expect.objectContaining({ checked: 2, healthy: 1, failing: ['svc-2'] }),
|
||||
});
|
||||
});
|
||||
|
||||
test('gracefully degrades if read() throws — empty services list, no crash', async () => {
|
||||
@@ -96,7 +124,7 @@ describe('WorkflowEngine.healthCheckService — DC-042 followup (getState bug)',
|
||||
// Before the fix, this rejected because .read() wasn't called and the
|
||||
// .catch(() => []) fallback didn't exist. Now it should resolve to empty.
|
||||
const result = await engine.healthCheckService('{{serviceId}}');
|
||||
expect(result).toEqual({ checked: 0, healthy: 0, results: [] });
|
||||
expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
|
||||
});
|
||||
|
||||
test('servicesStateManager absent on ctx → no crash, empty result', async () => {
|
||||
@@ -111,7 +139,7 @@ describe('WorkflowEngine.healthCheckService — DC-042 followup (getState bug)',
|
||||
}
|
||||
|
||||
const result = await engine.healthCheckService('{{serviceId}}');
|
||||
expect(result).toEqual({ checked: 0, healthy: 0, results: [] });
|
||||
expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
|
||||
});
|
||||
|
||||
test('single service (non-template serviceId) path still works', async () => {
|
||||
@@ -128,4 +156,245 @@ describe('WorkflowEngine.healthCheckService — DC-042 followup (getState bug)',
|
||||
const result = await engine.healthCheckService('single-svc-id');
|
||||
expect(result).toEqual({ serviceId: 'single-svc-id', healthy: true });
|
||||
});
|
||||
|
||||
test('single-service check throws when container is unhealthy', async () => {
|
||||
const engine = makeEngine({
|
||||
docker: {
|
||||
client: {
|
||||
getContainer: jest.fn(() => ({
|
||||
inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(engine.healthCheckService('down-svc')).rejects.toMatchObject({
|
||||
failingServices: ['down-svc'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* DC-044 root-cause fix tests: notify-on-failure gating + template interpolation.
|
||||
*
|
||||
* The original code in executeAction had TWO latent bugs:
|
||||
* 1. notify-on-failure sent unconditionally (its comment said "Only send if
|
||||
* previous action failed" but the code never checked).
|
||||
* 2. healthCheckService returned no serviceId field, so templates like
|
||||
* `Health check failed for {{serviceId}}` never interpolated and stayed
|
||||
* literal in every alert.
|
||||
*
|
||||
* These tests exercise the full executeWorkflow path with a stub workflow
|
||||
* that pairs `health-check` with `notify-on-failure`.
|
||||
*/
|
||||
describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure + template)', () => {
|
||||
// Build an engine and call _runActions directly with arbitrary action
|
||||
// sequences. Bypasses BUNDLED_WORKFLOWS lookup so tests are isolated and
|
||||
// don't mutate module state.
|
||||
function makeEngine(opts = {}) {
|
||||
const ctx = {
|
||||
servicesStateManager: opts.servicesStateManager || { read: jest.fn().mockResolvedValue([]) },
|
||||
docker: opts.docker || { client: { getContainer: jest.fn(() => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: true } }) })) } },
|
||||
notification: opts.notification || { send: jest.fn() },
|
||||
};
|
||||
const engine = new WorkflowEngine(ctx);
|
||||
if (engine.scheduledJobs) {
|
||||
for (const job of engine.scheduledJobs.values()) clearInterval(job);
|
||||
engine.scheduledJobs.clear();
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
|
||||
test('notify-on-failure is a no-op when the previous action succeeded', async () => {
|
||||
const notify = jest.fn();
|
||||
const engine = makeEngine({
|
||||
servicesStateManager: { read: jest.fn().mockResolvedValue([{ id: 'svc-1', containerId: 'c1' }]) },
|
||||
docker: { client: { getContainer: jest.fn(() => ({
|
||||
inspect: jest.fn().mockResolvedValue({ State: { Running: true } }),
|
||||
})) } },
|
||||
notification: { send: notify },
|
||||
});
|
||||
|
||||
const results = await engine._runActions(
|
||||
[
|
||||
{ type: 'health-check', target: '{{serviceId}}' },
|
||||
{ type: 'notify-on-failure', message: 'Health check failed for {{failingServices}}' },
|
||||
],
|
||||
{ trigger: 'manual' }
|
||||
);
|
||||
|
||||
const notifyResult = results.find(r => r.action === 'notify-on-failure');
|
||||
expect(notifyResult.success).toBe(true);
|
||||
expect(notifyResult.result).toEqual({ skipped: true, reason: 'no previous failure' });
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('notify-on-failure fires and interpolates {{failingServices}} when previous action failed', async () => {
|
||||
const notify = jest.fn();
|
||||
const engine = makeEngine({
|
||||
servicesStateManager: { read: jest.fn().mockResolvedValue([{ id: 'svc-broken', containerId: 'c1' }]) },
|
||||
docker: { client: { getContainer: jest.fn(() => ({
|
||||
inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
|
||||
})) } },
|
||||
notification: { send: notify },
|
||||
});
|
||||
|
||||
const results = await engine._runActions(
|
||||
[
|
||||
{ type: 'health-check', target: '{{serviceId}}' },
|
||||
{ type: 'notify-on-failure', message: 'Health check failed for {{failingServices}}' },
|
||||
],
|
||||
{ trigger: 'manual' }
|
||||
);
|
||||
|
||||
const healthResult = results.find(r => r.action === 'health-check');
|
||||
const notifyResult = results.find(r => r.action === 'notify-on-failure');
|
||||
expect(healthResult.success).toBe(false);
|
||||
expect(healthResult.failingServices).toEqual(['svc-broken']);
|
||||
expect(notifyResult.success).toBe(true);
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
// notification.send signature: (category, title, message, level)
|
||||
const sentMessage = notify.mock.calls[0][2];
|
||||
expect(sentMessage).toBe('Health check failed for svc-broken');
|
||||
expect(sentMessage).not.toContain('{{');
|
||||
});
|
||||
|
||||
test('notify (not notify-on-failure) fires unconditionally — regression guard', async () => {
|
||||
const notify = jest.fn();
|
||||
const engine = makeEngine({ notification: { send: notify } });
|
||||
|
||||
const results = await engine._runActions(
|
||||
[{ type: 'notify', message: 'always sent' }],
|
||||
{ trigger: 'manual' }
|
||||
);
|
||||
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(notify.mock.calls[0][2]).toBe('always sent');
|
||||
expect(results[0].success).toBe(true);
|
||||
});
|
||||
|
||||
test('notify-on-failure as first action is a no-op (no previous result)', async () => {
|
||||
const notify = jest.fn();
|
||||
const engine = makeEngine({ notification: { send: notify } });
|
||||
|
||||
const results = await engine._runActions(
|
||||
[{ type: 'notify-on-failure', message: 'should not fire' }],
|
||||
{ trigger: 'manual' }
|
||||
);
|
||||
|
||||
const notifyResult = results[0];
|
||||
expect(notifyResult.success).toBe(true);
|
||||
expect(notifyResult.result).toEqual({ skipped: true, reason: 'no previous failure' });
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('multi-service batch failure: {{failingServices}} interpolates comma-joined list', async () => {
|
||||
const notify = jest.fn();
|
||||
const engine = makeEngine({
|
||||
servicesStateManager: { read: jest.fn().mockResolvedValue([
|
||||
{ id: 'svc-ok', containerId: 'c1' },
|
||||
{ id: 'svc-broken-1', containerId: 'c2' },
|
||||
{ id: 'svc-broken-2', containerId: 'c3' },
|
||||
]) },
|
||||
docker: { client: { getContainer: jest.fn((id) => ({
|
||||
inspect: jest.fn().mockResolvedValue({
|
||||
State: { Running: id === 'c1', Health: { Status: id === 'c1' ? 'healthy' : 'unhealthy' } },
|
||||
}),
|
||||
})) } },
|
||||
notification: { send: notify },
|
||||
});
|
||||
|
||||
const results = await engine._runActions(
|
||||
[
|
||||
{ type: 'health-check', target: '{{serviceId}}' },
|
||||
{ type: 'notify-on-failure', message: 'Failing: {{failingServices}}' },
|
||||
],
|
||||
{ trigger: 'manual' }
|
||||
);
|
||||
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
const sentMessage = notify.mock.calls[0][2];
|
||||
expect(sentMessage).toBe('Failing: svc-broken-1,svc-broken-2');
|
||||
expect(results.find(r => r.action === 'notify-on-failure').success).toBe(true);
|
||||
});
|
||||
|
||||
// B2 regression: hit the actual bundled health-check-on-interval workflow
|
||||
// end-to-end via executeWorkflow. The bundled template uses
|
||||
// {{failingServices}} (DC-044 fix). Earlier it used {{serviceId}} which
|
||||
// never resolved because no per-service ID is in workflow scope. This test
|
||||
// would have failed with the old template.
|
||||
test('executeWorkflow on bundled health-check-on-interval: no literal {{...}} in notification', async () => {
|
||||
const { BUNDLED_WORKFLOWS } = require('../src/recipes/bundled-workflows');
|
||||
expect(BUNDLED_WORKFLOWS['health-check-on-interval']).toBeDefined();
|
||||
|
||||
const notify = jest.fn();
|
||||
const engine = makeEngine({
|
||||
servicesStateManager: { read: jest.fn().mockResolvedValue([
|
||||
{ id: 'svc-broken', containerId: 'c1' },
|
||||
]) },
|
||||
docker: { client: { getContainer: jest.fn(() => ({
|
||||
inspect: jest.fn().mockResolvedValue({
|
||||
State: { Running: false, Health: { Status: 'unhealthy' } },
|
||||
}),
|
||||
})) } },
|
||||
notification: { send: notify },
|
||||
});
|
||||
|
||||
const result = await engine.executeWorkflow('health-check-on-interval', { trigger: 'manual' });
|
||||
|
||||
// Either the bundled workflow fired notification (with interpolated
|
||||
// message) OR every action resolved — but in NO case may a literal
|
||||
// {{...}} template token leak into notification.send.
|
||||
if (notify.mock.calls.length > 0) {
|
||||
const sentMessage = notify.mock.calls[0][2];
|
||||
expect(sentMessage).not.toMatch(/\{\{/);
|
||||
expect(sentMessage).not.toMatch(/\}\}/);
|
||||
// The new bundled template substitutes failingServices — make sure
|
||||
// the actual service ID made it through.
|
||||
expect(sentMessage).toContain('svc-broken');
|
||||
}
|
||||
// Workflow must always complete (success or failure), never throw.
|
||||
expect(result).toBeDefined();
|
||||
expect(result.workflowId).toBe('health-check-on-interval');
|
||||
});
|
||||
|
||||
// B3 regression: a running container with Health.Status === 'unhealthy'
|
||||
// must be reported as unhealthy. Previously checkContainerHealth compared
|
||||
// info.State.Health itself (an object) to the string 'unhealthy', which
|
||||
// was always false — so any container with an explicit healthcheck was
|
||||
// always considered healthy. The fix reads info.State.Health.Status.
|
||||
test('checkContainerHealth treats running-but-unhealthy container as unhealthy', async () => {
|
||||
const engine = makeEngine({
|
||||
docker: { client: { getContainer: jest.fn(() => ({
|
||||
inspect: jest.fn().mockResolvedValue({
|
||||
State: { Running: true, Health: { Status: 'unhealthy' } },
|
||||
}),
|
||||
})) } },
|
||||
});
|
||||
|
||||
const healthy = await engine.checkContainerHealth('running-but-unhealthy');
|
||||
expect(healthy).toBe(false);
|
||||
});
|
||||
|
||||
test('checkContainerHealth treats running-with-no-healthcheck as healthy', async () => {
|
||||
const engine = makeEngine({
|
||||
docker: { client: { getContainer: jest.fn(() => ({
|
||||
inspect: jest.fn().mockResolvedValue({ State: { Running: true } }),
|
||||
})) } },
|
||||
});
|
||||
|
||||
const healthy = await engine.checkContainerHealth('no-healthcheck');
|
||||
expect(healthy).toBe(true);
|
||||
});
|
||||
|
||||
test('checkContainerHealth treats stopped container as unhealthy', async () => {
|
||||
const engine = makeEngine({
|
||||
docker: { client: { getContainer: jest.fn(() => ({
|
||||
inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
|
||||
})) } },
|
||||
});
|
||||
|
||||
const healthy = await engine.checkContainerHealth('stopped');
|
||||
expect(healthy).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* Tests for dashcaddy-api/license-keygen.js
|
||||
*
|
||||
* Covers the programmatic API used by the Stripe webhook bridge and the
|
||||
* on-disk counter allocator. The CLI path is exercised through the
|
||||
* dedicated CLI regression describe block at the bottom of this file.
|
||||
*
|
||||
* - module.exports shape: verifyCode, parseCode, generateCode,
|
||||
* generateCodes, loadSecret, VALID_DURATIONS, VERSION
|
||||
* - generateCodes() validation: secret, duration, count
|
||||
* - generateCodes() counter allocator: init, increment, override via
|
||||
* startId, override via counterFile, atomic .tmp shape
|
||||
* - generateCodes() monotonic counter: 100-call ordering, range checks
|
||||
* - loadSecret() success and missing-file error
|
||||
* - generateCode() round-trip: codes verify back via verifyCode()
|
||||
* - CLI integration: omitted --start-id uses auto-counter, explicit
|
||||
* --start-id skips counter write, --lifetime/--duration mutual exclusion
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const keygen = require('../license-keygen');
|
||||
const {
|
||||
verifyCode,
|
||||
parseCode,
|
||||
generateCode,
|
||||
generateCodes,
|
||||
loadSecret,
|
||||
VALID_DURATIONS,
|
||||
VERSION,
|
||||
} = keygen;
|
||||
|
||||
function _tmpDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), `dashcaddy-${prefix}-`));
|
||||
}
|
||||
|
||||
function _cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best effort */ }
|
||||
}
|
||||
|
||||
const TEST_SECRET = 'a'.repeat(64); // 32 bytes hex
|
||||
|
||||
// ── Public surface ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('license-keygen: module.exports', () => {
|
||||
test('exports verifyCode, parseCode, generateCode, generateCodes, loadSecret, VALID_DURATIONS, VERSION', () => {
|
||||
expect(typeof verifyCode).toBe('function');
|
||||
expect(typeof parseCode).toBe('function');
|
||||
expect(typeof generateCode).toBe('function');
|
||||
expect(typeof generateCodes).toBe('function');
|
||||
expect(typeof loadSecret).toBe('function');
|
||||
expect(Array.isArray(VALID_DURATIONS)).toBe(true);
|
||||
expect(VALID_DURATIONS).toEqual([30, 90, 180, 365]);
|
||||
expect(VERSION).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateCode / parseCode / verifyCode round-trip ────────────────────────
|
||||
|
||||
describe('license-keygen: generateCode round-trip', () => {
|
||||
test('generated code verifies back via verifyCode()', () => {
|
||||
const code = generateCode(TEST_SECRET, 90, 42);
|
||||
expect(code).toMatch(/^DC-([0-9A-Z]{5})(-[0-9A-Z]{5}){4}$/);
|
||||
const result = verifyCode(TEST_SECRET, code);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.durationDays).toBe(90);
|
||||
expect(result.codeId).toBe(42);
|
||||
});
|
||||
|
||||
test('verifyCode rejects a code from a different secret', () => {
|
||||
const code = generateCode(TEST_SECRET, 30, 1);
|
||||
const result = verifyCode('b'.repeat(64), code);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.reason).toMatch(/signature/i);
|
||||
});
|
||||
|
||||
test('parseCode returns version, duration, codeId, timestamp', () => {
|
||||
const code = generateCode(TEST_SECRET, 365, 9999);
|
||||
const parsed = parseCode(code);
|
||||
expect(parsed.version).toBe(VERSION);
|
||||
expect(parsed.durationDays).toBe(365);
|
||||
expect(parsed.codeId).toBe(9999);
|
||||
expect(typeof parsed.createdTs).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateCodes: validation ───────────────────────────────────────────────
|
||||
|
||||
describe('license-keygen: generateCodes validation', () => {
|
||||
test('throws on missing secret', () => {
|
||||
expect(() => generateCodes({ secret: '', durationDays: 30 })).toThrow(/secret is required/);
|
||||
expect(() => generateCodes({ secret: 123, durationDays: 30 })).toThrow(/secret is required/);
|
||||
expect(() => generateCodes({ durationDays: 30 })).toThrow(/secret is required/);
|
||||
});
|
||||
|
||||
test('throws on invalid duration', () => {
|
||||
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 7 })).toThrow(/invalid duration/);
|
||||
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 31 })).toThrow(/invalid duration/);
|
||||
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: -1 })).toThrow(/invalid duration/);
|
||||
});
|
||||
|
||||
test('accepts LIFETIME (durationDays: 0)', () => {
|
||||
const tmp = _tmpDir('kg-lifetime');
|
||||
try {
|
||||
const codes = generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 0,
|
||||
counterFile: path.join(tmp, '.counter'),
|
||||
});
|
||||
expect(codes).toHaveLength(1);
|
||||
expect(codes[0].durationDays).toBe(0);
|
||||
} finally { _cleanup(tmp); }
|
||||
});
|
||||
|
||||
test('throws on invalid count', () => {
|
||||
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 0 })).toThrow(/invalid count/);
|
||||
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: -1 })).toThrow(/invalid count/);
|
||||
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 10001 })).toThrow(/invalid count/);
|
||||
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 1.5 })).toThrow(/invalid count/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateCodes: counter allocator ────────────────────────────────────────
|
||||
|
||||
describe('license-keygen: generateCodes counter', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = _tmpDir('kg-counter'); });
|
||||
afterEach(() => { _cleanup(tmp); });
|
||||
|
||||
test('initializes counter at 1 when file is missing', () => {
|
||||
const codes = generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
counterFile: path.join(tmp, '.counter'),
|
||||
});
|
||||
expect(codes[0].codeId).toBe(1);
|
||||
expect(fs.readFileSync(path.join(tmp, '.counter'), 'utf8').trim()).toBe('1');
|
||||
});
|
||||
|
||||
test('increments counter on subsequent calls', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const codes = generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
counterFile,
|
||||
});
|
||||
expect(codes[0].codeId).toBe(i);
|
||||
}
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('3');
|
||||
});
|
||||
|
||||
test('respects startId override and does NOT touch the counter file', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
fs.writeFileSync(counterFile, '100');
|
||||
const codes = generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
count: 3,
|
||||
startId: 500,
|
||||
counterFile,
|
||||
});
|
||||
expect(codes.map(c => c.codeId)).toEqual([500, 501, 502]);
|
||||
// Counter file unchanged — overrideStartId path skips the write.
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('100');
|
||||
});
|
||||
|
||||
test('no leftover .tmp files after a successful call', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile });
|
||||
const entries = fs.readdirSync(tmp);
|
||||
expect(entries.filter(e => e.includes('.tmp'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('counter file uses per-call unique tmp suffix (no .tmp collisions)', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
const origWrite = fs.writeFileSync;
|
||||
const tmpNames = [];
|
||||
fs.writeFileSync = (p, data, opts) => {
|
||||
if (typeof p === 'string' && p.startsWith(counterFile) && p.includes('.tmp')) {
|
||||
tmpNames.push(p);
|
||||
}
|
||||
return origWrite.call(fs, p, data, opts);
|
||||
};
|
||||
try {
|
||||
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile });
|
||||
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile });
|
||||
expect(tmpNames).toHaveLength(2);
|
||||
expect(new Set(tmpNames).size).toBe(2);
|
||||
} finally {
|
||||
fs.writeFileSync = origWrite;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateCodes: monotonic counter ────────────────────────────────────────
|
||||
//
|
||||
// generateCodes() is synchronous. Node's single-threaded event loop means
|
||||
// two synchronous calls cannot interleave, so the counter is monotonically
|
||||
// incremented without any explicit locking. The atomic write helper
|
||||
// protects against process crashes between writeFileSync and renameSync.
|
||||
// These tests verify that ordering and atomicity hold across many calls.
|
||||
|
||||
describe('license-keygen: generateCodes monotonic counter', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = _tmpDir('kg-mono'); });
|
||||
afterEach(() => { _cleanup(tmp); });
|
||||
|
||||
test('100 sequential calls produce 100 unique codeIds in monotonic order', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
const codes = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
codes.push(generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
counterFile,
|
||||
})[0]);
|
||||
}
|
||||
const ids = codes.map(c => c.codeId);
|
||||
expect(ids).toHaveLength(100);
|
||||
expect(new Set(ids).size).toBe(100);
|
||||
for (let i = 1; i < ids.length; i++) {
|
||||
expect(ids[i]).toBe(ids[i - 1] + 1);
|
||||
}
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('100');
|
||||
});
|
||||
|
||||
test('100 sequential calls each requesting 5 codes produce 500 unique IDs', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
const batches = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
batches.push(generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
count: 5,
|
||||
counterFile,
|
||||
}));
|
||||
}
|
||||
const allIds = batches.flat().map(c => c.codeId);
|
||||
expect(allIds).toHaveLength(500);
|
||||
expect(new Set(allIds).size).toBe(500);
|
||||
batches.forEach((batch, i) => {
|
||||
const start = i * 5 + 1;
|
||||
expect(batch.map(c => c.codeId)).toEqual([start, start + 1, start + 2, start + 3, start + 4]);
|
||||
});
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('500');
|
||||
});
|
||||
|
||||
test('startId override is range-checked (negative throws)', () => {
|
||||
expect(() => generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
startId: -1,
|
||||
counterFile: path.join(tmp, '.counter'),
|
||||
})).toThrow(/out of range/);
|
||||
});
|
||||
|
||||
test('startId override is range-checked (over 32-bit throws)', () => {
|
||||
expect(() => generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
startId: 0x100000000,
|
||||
counterFile: path.join(tmp, '.counter'),
|
||||
})).toThrow(/out of range/);
|
||||
});
|
||||
|
||||
test('startId override is rejected for non-integer values', () => {
|
||||
// Codex round 2: Number.isInteger(overrideStartId) returned false for
|
||||
// floats/NaN/null/strings, silently falling through to auto-counter.
|
||||
// The Object.prototype.hasOwnProperty check above fixes the dispatch.
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
fs.writeFileSync(counterFile, '99');
|
||||
for (const bad of [1.5, NaN, null, '100', undefined, false]) {
|
||||
const prevValue = fs.readFileSync(counterFile, 'utf8').trim();
|
||||
expect(() => generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
startId: bad,
|
||||
counterFile,
|
||||
})).toThrow(/out of range|non-integer/);
|
||||
// Counter file must NOT be touched when the call throws.
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe(prevValue);
|
||||
}
|
||||
});
|
||||
|
||||
test('count that would push codeId past 32-bit throws', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
fs.writeFileSync(counterFile, String(0xFFFFFFFF - 5));
|
||||
expect(() => generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
count: 10,
|
||||
counterFile,
|
||||
})).toThrow(/32-bit limit/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateCodes: counterFile override ─────────────────────────────────────
|
||||
|
||||
describe('license-keygen: generateCodes counterFile override', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = _tmpDir('kg-cf'); });
|
||||
afterEach(() => { _cleanup(tmp); });
|
||||
|
||||
test('counterFile option overrides LICENSE_COUNTER_FILE env', () => {
|
||||
const cf = path.join(tmp, '.counter');
|
||||
const prev = process.env.LICENSE_COUNTER_FILE;
|
||||
try {
|
||||
process.env.LICENSE_COUNTER_FILE = path.join(tmp, 'env-counter');
|
||||
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile: cf });
|
||||
expect(fs.existsSync(cf)).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmp, 'env-counter'))).toBe(false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.LICENSE_COUNTER_FILE;
|
||||
else process.env.LICENSE_COUNTER_FILE = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test('LICENSE_COUNTER_FILE env overrides the default __dirname counter', () => {
|
||||
const tmpForEnv = _tmpDir('kg-env');
|
||||
try {
|
||||
const target = path.join(tmpForEnv, 'env-counter');
|
||||
const prev = process.env.LICENSE_COUNTER_FILE;
|
||||
process.env.LICENSE_COUNTER_FILE = target;
|
||||
try {
|
||||
const codes = generateCodes({ secret: TEST_SECRET, durationDays: 30 });
|
||||
expect(codes[0].codeId).toBeLessThanOrEqual(1); // fresh env
|
||||
expect(fs.existsSync(target)).toBe(true);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.LICENSE_COUNTER_FILE;
|
||||
else process.env.LICENSE_COUNTER_FILE = prev;
|
||||
}
|
||||
} finally { _cleanup(tmpForEnv); }
|
||||
});
|
||||
});
|
||||
|
||||
// ── loadSecret ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('license-keygen: loadSecret', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = _tmpDir('kg-secret'); });
|
||||
afterEach(() => { _cleanup(tmp); });
|
||||
|
||||
test('returns trimmed contents of an existing secret file', () => {
|
||||
const file = path.join(tmp, '.license-secret');
|
||||
fs.writeFileSync(file, ' abc123 \n');
|
||||
expect(loadSecret(file)).toBe('abc123');
|
||||
});
|
||||
|
||||
test('throws on missing file with helpful message', () => {
|
||||
const file = path.join(tmp, 'does-not-exist');
|
||||
expect(() => loadSecret(file)).toThrow(/not found/i);
|
||||
expect(() => loadSecret(file)).toThrow(/--init-secret/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateCodes: failure modes ────────────────────────────────────────────
|
||||
|
||||
describe('license-keygen: generateCodes failure modes', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = _tmpDir('kg-fail'); });
|
||||
afterEach(() => { _cleanup(tmp); });
|
||||
|
||||
test('throws when counter file exists but contains non-numeric data', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
fs.writeFileSync(counterFile, 'not-a-number');
|
||||
expect(() =>
|
||||
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile }),
|
||||
).toThrow(/non-numeric/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── CLI regression: spawn the real binary and verify argument handling ───────
|
||||
//
|
||||
// Codex round 4 caught a regression: main() always passed
|
||||
// `startId: overrideStartId` to generateCodes(), even when --start-id was
|
||||
// omitted. The new hasOwnProperty-based validation then rejected the call
|
||||
// because startId was an explicit (undefined) value. The fix is to omit
|
||||
// the startId property from the options object when --start-id is absent.
|
||||
// These tests exercise the actual CLI binary to make sure the local fix
|
||||
// wires up correctly.
|
||||
|
||||
const KEYGEN_BIN = path.resolve(__dirname, '..', 'license-keygen.js');
|
||||
|
||||
function _runCli(args, env) {
|
||||
return execFileSync('node', [KEYGEN_BIN, ...args], {
|
||||
env: { ...process.env, ...env },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
describe('license-keygen: CLI regression', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = _tmpDir('kg-cli'); });
|
||||
afterEach(() => { _cleanup(tmp); });
|
||||
|
||||
function _setupSecret() {
|
||||
fs.writeFileSync(path.join(tmp, '.license-secret'), TEST_SECRET);
|
||||
}
|
||||
|
||||
test('omitted --start-id uses the auto-counter path (CLI integration)', () => {
|
||||
_setupSecret();
|
||||
const counterFile = path.join(tmp, '.license-counter');
|
||||
|
||||
// First call: no --start-id, expects counter to be created at 1.
|
||||
const out1 = _runCli(['--duration', '30', '--count', '1', '--json'], {
|
||||
LICENSE_COUNTER_FILE: counterFile,
|
||||
});
|
||||
const codes1 = JSON.parse(out1.split('Generated')[0]);
|
||||
expect(codes1).toHaveLength(1);
|
||||
expect(codes1[0].codeId).toBe(1);
|
||||
expect(codes1[0].durationDays).toBe(30);
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('1');
|
||||
|
||||
// Second call: counter should auto-increment to 2.
|
||||
const out2 = _runCli(['--duration', '30', '--count', '1', '--json'], {
|
||||
LICENSE_COUNTER_FILE: counterFile,
|
||||
});
|
||||
const codes2 = JSON.parse(out2.split('Generated')[0]);
|
||||
expect(codes2[0].codeId).toBe(2);
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('2');
|
||||
});
|
||||
|
||||
test('--start-id override skips counter file update (CLI integration)', () => {
|
||||
_setupSecret();
|
||||
const counterFile = path.join(tmp, '.license-counter');
|
||||
fs.writeFileSync(counterFile, '99');
|
||||
|
||||
const out = _runCli(['--duration', '30', '--start-id', '500', '--count', '2', '--json'], {
|
||||
LICENSE_COUNTER_FILE: counterFile,
|
||||
});
|
||||
const codes = JSON.parse(out.split('Generated')[0]);
|
||||
expect(codes.map(c => c.codeId)).toEqual([500, 501]);
|
||||
// Counter file untouched.
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('99');
|
||||
});
|
||||
|
||||
test('--lifetime and --duration are mutually exclusive (CLI integration)', () => {
|
||||
_setupSecret();
|
||||
expect(() =>
|
||||
_runCli(['--duration', '30', '--lifetime', '--count', '1'], {
|
||||
LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'),
|
||||
}),
|
||||
).toThrow(/mutually exclusive/);
|
||||
});
|
||||
|
||||
test('--tier pro without --duration or --lifetime still requires one of them', () => {
|
||||
_setupSecret();
|
||||
expect(() =>
|
||||
_runCli(['--tier', 'pro', '--count', '1'], {
|
||||
LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'),
|
||||
}),
|
||||
).toThrow(/--duration is required/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Tests for DC-052: license-tier enforcement.
|
||||
*
|
||||
* Coverage:
|
||||
* - licenseManager.isPro() returns false when no activation
|
||||
* - licenseManager.isPro() returns true when activation is fresh
|
||||
* - licenseManager.isPro() returns false when activation expired
|
||||
* - licenseManager.isPro() returns true for LIFETIME keys
|
||||
* - allowsLifetimeLicense() defaults false, true with env var
|
||||
* - LIFETIME code rejected at activate() in production
|
||||
* - LIFETIME code accepted at activate() when ALLOW_LIFETIME_LICENSE=true
|
||||
* - userStore.countUsers() counts every user
|
||||
* - PaymentRequiredError carries 402 status + feature key
|
||||
* - _requireProIfUserLimitReached passes when under cap
|
||||
* - _requireProIfUserLimitReached throws PaymentRequired when at cap + Free
|
||||
* - _requireProIfUserLimitReached passes when at cap + Pro
|
||||
* - /invites/:token/accept burns the invite + throws 402 at cap + Free
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
function _tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-license-test-'));
|
||||
}
|
||||
function _cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
// ── LicenseManager.isPro / allowsLifetimeLicense / activate ───────────────
|
||||
|
||||
describe('license-manager: isPro / allowsLifetimeLicense', () => {
|
||||
// Minimal stub of LicenseManager that exposes the DC-052 surface
|
||||
// without requiring the full upstream manager. We exercise the real
|
||||
// activate() flow against a mock that has a valid HMAC master secret.
|
||||
function _makeManager({ env = {} } = {}) {
|
||||
const prevEnv = { ...process.env };
|
||||
Object.assign(process.env, env);
|
||||
// Import lazily so the env mutation above sticks.
|
||||
delete require.cache[require.resolve('../src/managers/license-manager')];
|
||||
const { LicenseManager } = require('../src/managers/license-manager');
|
||||
// LicenseManager constructor takes positional args: (credentialManager, configFile, log).
|
||||
const mgr = new LicenseManager(
|
||||
{
|
||||
store: async () => undefined,
|
||||
retrieve: async () => null,
|
||||
delete: async () => undefined,
|
||||
},
|
||||
'/tmp/dashcaddy-test-nonexistent-config.json',
|
||||
{ info: () => {}, warn: () => {}, error: () => {} }
|
||||
);
|
||||
return { mgr, restore: () => { process.env = prevEnv; } };
|
||||
}
|
||||
|
||||
test('isPro() returns false when no activation', () => {
|
||||
const { mgr, restore } = _makeManager();
|
||||
try {
|
||||
expect(mgr.isPro()).toBe(false);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('allowsLifetimeLicense() defaults to false', () => {
|
||||
const { mgr, restore } = _makeManager();
|
||||
try {
|
||||
expect(mgr.allowsLifetimeLicense()).toBe(false);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('allowsLifetimeLicense() returns true with ALLOW_LIFETIME_LICENSE=true', () => {
|
||||
const { mgr, restore } = _makeManager({ env: { ALLOW_LIFETIME_LICENSE: 'true' } });
|
||||
try {
|
||||
expect(mgr.allowsLifetimeLicense()).toBe(true);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('isPro() returns true after activating a fresh non-lifetime code', async () => {
|
||||
const { mgr, restore } = _makeManager();
|
||||
try {
|
||||
// generateCode isn't exported, but verifyCode is — round-trip
|
||||
// via the master secret + parse the result. We test activate
|
||||
// through a synthesized code object instead.
|
||||
// Simpler: bypass generateCode by using verifyCode with a known
|
||||
// payload. Easier still: monkey-patch the verifyCode to inject a
|
||||
// a fresh activation directly.
|
||||
const now = new Date();
|
||||
mgr.activation = {
|
||||
code: 'DC-TEST-FRESH',
|
||||
codeId: 1,
|
||||
durationDays: 30,
|
||||
lifetime: false,
|
||||
activatedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + 30 * 86400000).toISOString(),
|
||||
machineId: 'test',
|
||||
validationMethod: 'offline',
|
||||
features: ['multi-user'],
|
||||
};
|
||||
expect(mgr.isPro()).toBe(true);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('isPro() returns false when activation is expired', async () => {
|
||||
const { mgr, restore } = _makeManager();
|
||||
try {
|
||||
const past = new Date(Date.now() - 86400000);
|
||||
mgr.activation = {
|
||||
code: 'DC-TEST-EXPIRED',
|
||||
codeId: 1,
|
||||
durationDays: 30,
|
||||
lifetime: false,
|
||||
activatedAt: past.toISOString(),
|
||||
expiresAt: past.toISOString(),
|
||||
machineId: 'test',
|
||||
validationMethod: 'offline',
|
||||
features: ['multi-user'],
|
||||
};
|
||||
expect(mgr.isExpired()).toBe(true);
|
||||
expect(mgr.isPro()).toBe(false);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('isPro() returns true for an active LIFETIME code (when allowed)', async () => {
|
||||
const { mgr, restore } = _makeManager({ env: { ALLOW_LIFETIME_LICENSE: 'true' } });
|
||||
try {
|
||||
const now = new Date();
|
||||
mgr.activation = {
|
||||
code: 'DC-TEST-LIFETIME',
|
||||
codeId: 1,
|
||||
durationDays: 0,
|
||||
lifetime: true,
|
||||
activatedAt: now.toISOString(),
|
||||
expiresAt: new Date('2099-12-31T23:59:59.999Z').toISOString(),
|
||||
machineId: 'test',
|
||||
validationMethod: 'offline',
|
||||
features: ['multi-user'],
|
||||
};
|
||||
expect(mgr.isPro()).toBe(true);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('LIFETIME code is REJECTED at activate() when ALLOW_LIFETIME_LICENSE is not set', async () => {
|
||||
const { mgr, restore } = _makeManager();
|
||||
try {
|
||||
// We can't generate codes without generateCode being exported.
|
||||
// The "rejection" path is unit-tested separately by reading
|
||||
// the activate() code path directly. Here we just verify that
|
||||
// allowsLifetimeLicense() returns false in production.
|
||||
expect(mgr.allowsLifetimeLicense()).toBe(false);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('LIFETIME rejection: directly exercise activate()', async () => {
|
||||
const { mgr, restore } = _makeManager();
|
||||
try {
|
||||
// Stub _validateOffline to return a lifetime payload.
|
||||
mgr._validateOffline = () => ({ valid: true, durationDays: 0, codeId: 1 });
|
||||
const result = await mgr.activate('DC-FAKE-LIFETIME-CODE');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toMatch(/lifetime/i);
|
||||
expect(mgr.activation).toBeNull();
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('LIFETIME accepted when ALLOW_LIFETIME_LICENSE=true', async () => {
|
||||
const { mgr, restore } = _makeManager({ env: { ALLOW_LIFETIME_LICENSE: 'true' } });
|
||||
try {
|
||||
mgr._validateOffline = () => ({ valid: true, durationDays: 0, codeId: 1 });
|
||||
const result = await mgr.activate('DC-FAKE-LIFETIME-CODE');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.activation.lifetime).toBe(true);
|
||||
expect(mgr.isPro()).toBe(true);
|
||||
} finally { restore(); }
|
||||
});
|
||||
});
|
||||
|
||||
// ── userStore.countUsers ─────────────────────────────────────────────────
|
||||
|
||||
describe('user-store: countUsers', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = require('../src/security/user-store').createUserStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('countUsers starts at 0 for fresh install', async () => {
|
||||
expect(await store.countUsers()).toBe(0);
|
||||
});
|
||||
|
||||
test('countUsers increments on login', async () => {
|
||||
await store.login({ email: 'a@x.com' });
|
||||
expect(await store.countUsers()).toBe(1);
|
||||
await store.addToAllowlist('b@x.com');
|
||||
await store.login({ email: 'b@x.com' });
|
||||
expect(await store.countUsers()).toBe(2);
|
||||
await store.addToAllowlist('c@x.com');
|
||||
await store.login({ email: 'c@x.com' });
|
||||
expect(await store.countUsers()).toBe(3);
|
||||
});
|
||||
|
||||
test('countUsers decrements on deleteUser', async () => {
|
||||
await store.login({ email: 'a@x.com' });
|
||||
await store.addToAllowlist('b@x.com');
|
||||
const r = await store.login({ email: 'b@x.com' });
|
||||
expect(await store.countUsers()).toBe(2);
|
||||
await store.deleteUser(r.user.id);
|
||||
expect(await store.countUsers()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── PaymentRequiredError ─────────────────────────────────────────────────
|
||||
|
||||
describe('PaymentRequiredError', () => {
|
||||
test('has statusCode 402 and code DC-402', () => {
|
||||
const { PaymentRequiredError } = require('../src/utilities/errors');
|
||||
const e = new PaymentRequiredError('Upgrade required', 'multi-user');
|
||||
expect(e.statusCode).toBe(402);
|
||||
expect(e.code).toBe('DC-402');
|
||||
expect(e.message).toBe('Upgrade required');
|
||||
expect(e.feature).toBe('multi-user');
|
||||
});
|
||||
|
||||
test('default message + feature null', () => {
|
||||
const { PaymentRequiredError } = require('../src/utilities/errors');
|
||||
const e = new PaymentRequiredError();
|
||||
expect(e.statusCode).toBe(402);
|
||||
expect(e.feature).toBe(null);
|
||||
expect(e.message).toMatch(/Pro/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── admin route tier-gate ────────────────────────────────────────────────
|
||||
|
||||
describe('DC-052: admin route tier-gate', () => {
|
||||
let dir, userStore;
|
||||
beforeEach(() => {
|
||||
dir = _tmpDir();
|
||||
userStore = require('../src/security/user-store').createUserStore({ dataDir: dir });
|
||||
});
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
function _buildAdminRouter({ licenseManager = null } = {}) {
|
||||
const initAdmin = require('../routes/auth/admin');
|
||||
return initAdmin({
|
||||
asyncHandler: (fn) => fn,
|
||||
errorResponse: (_res, code, msg) => {
|
||||
const err = new Error(msg); err.statusCode = code; throw err;
|
||||
},
|
||||
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
||||
session: null,
|
||||
dataDir: dir,
|
||||
licenseManager,
|
||||
userStore,
|
||||
});
|
||||
}
|
||||
|
||||
function _findRoute(router, method, pathPattern) {
|
||||
for (const layer of router.stack) {
|
||||
if (layer.route && layer.route.methods[method.toLowerCase()]) {
|
||||
if (layer.route.path === pathPattern) return layer;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _invoke(router, method, urlPath, { user, body, licenseManager, appLocals = {} } = {}) {
|
||||
const req = {
|
||||
method,
|
||||
url: urlPath,
|
||||
path: urlPath.split('?')[0],
|
||||
query: {},
|
||||
body: body || {},
|
||||
headers: {},
|
||||
ip: '127.0.0.1',
|
||||
params: {},
|
||||
user,
|
||||
app: { locals: { ...appLocals } },
|
||||
};
|
||||
const res = {
|
||||
_status: 200,
|
||||
_body: null,
|
||||
status(c) { this._status = c; return this; },
|
||||
json(b) { this._body = b; return this; },
|
||||
};
|
||||
const layer = _findRoute(router, method, urlPath);
|
||||
if (!layer) return null;
|
||||
// Walk the middleware chain (admin gate → tier gate → handler).
|
||||
const handlers = layer.route.stack.map(s => s.handle);
|
||||
return {
|
||||
layer, req, res,
|
||||
run: async () => {
|
||||
for (let i = 0; i < handlers.length; i++) {
|
||||
const h = handlers[i];
|
||||
const isLast = i === handlers.length - 1;
|
||||
const stepResult = await new Promise((resolveStep, rejectStep) => {
|
||||
let nextCalled = false;
|
||||
let nextErr = null;
|
||||
const next = (err) => {
|
||||
nextCalled = true;
|
||||
nextErr = err || null;
|
||||
resolveStep({ nextCalled, nextErr });
|
||||
};
|
||||
try {
|
||||
const ret = h(req, res, next);
|
||||
if (ret && typeof ret.then === 'function') {
|
||||
ret.then(() => {
|
||||
if (!nextCalled) resolveStep({ nextCalled, nextErr });
|
||||
}).catch(rejectStep);
|
||||
} else if (!nextCalled) {
|
||||
resolveStep({ nextCalled, nextErr });
|
||||
}
|
||||
} catch (e) { rejectStep(e); }
|
||||
});
|
||||
if (stepResult.nextErr) throw stepResult.nextErr;
|
||||
if (!stepResult.nextCalled && !isLast) {
|
||||
throw new Error('middleware chain did not call next');
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('POST /admin/users passes through when under cap + no license', async () => {
|
||||
await userStore.login({ email: 'admin@x.com' });
|
||||
const router = _buildAdminRouter({ licenseManager: null });
|
||||
const r = _invoke(router, 'POST', '/admin/users', {
|
||||
user: { id: 'x', role: 'admin' },
|
||||
body: { email: 'new@x.com' },
|
||||
appLocals: { licenseManager: null, userStore },
|
||||
});
|
||||
await r.run();
|
||||
expect(r.res._body.email).toBe('new@x.com');
|
||||
});
|
||||
|
||||
test('POST /admin/users passes through when under cap + Free', async () => {
|
||||
await userStore.login({ email: 'admin@x.com' });
|
||||
const fakeLm = { isPro: () => false };
|
||||
const router = _buildAdminRouter({ licenseManager: fakeLm });
|
||||
const r = _invoke(router, 'POST', '/admin/users', {
|
||||
user: { id: 'x', role: 'admin' },
|
||||
body: { email: 'new@x.com' },
|
||||
appLocals: { licenseManager: fakeLm, userStore },
|
||||
});
|
||||
await r.run();
|
||||
expect(r.res._body.email).toBe('new@x.com');
|
||||
});
|
||||
|
||||
test('POST /admin/users throws 402 when at cap + Free', async () => {
|
||||
// Fill up to 3 users
|
||||
await userStore.login({ email: 'admin@x.com' });
|
||||
await userStore.addToAllowlist('a@x.com');
|
||||
await userStore.login({ email: 'a@x.com' });
|
||||
await userStore.addToAllowlist('b@x.com');
|
||||
await userStore.login({ email: 'b@x.com' });
|
||||
expect(await userStore.countUsers()).toBe(3);
|
||||
|
||||
const fakeLm = { isPro: () => false };
|
||||
const router = _buildAdminRouter({ licenseManager: fakeLm });
|
||||
const r = _invoke(router, 'POST', '/admin/users', {
|
||||
user: { id: 'admin-id', role: 'admin' },
|
||||
body: { email: 'fourth@x.com' },
|
||||
appLocals: { licenseManager: fakeLm, userStore },
|
||||
});
|
||||
let caught = null;
|
||||
try { await r.run(); } catch (e) { caught = e; }
|
||||
expect(caught).toBeTruthy();
|
||||
expect(caught.statusCode).toBe(402);
|
||||
expect(caught.message).toMatch(/Pro/);
|
||||
});
|
||||
|
||||
test('POST /admin/users passes through when at cap + Pro', async () => {
|
||||
await userStore.login({ email: 'admin@x.com' });
|
||||
await userStore.addToAllowlist('a@x.com');
|
||||
await userStore.login({ email: 'a@x.com' });
|
||||
await userStore.addToAllowlist('b@x.com');
|
||||
await userStore.login({ email: 'b@x.com' });
|
||||
expect(await userStore.countUsers()).toBe(3);
|
||||
|
||||
const fakeLm = { isPro: () => true };
|
||||
const router = _buildAdminRouter({ licenseManager: fakeLm });
|
||||
const r = _invoke(router, 'POST', '/admin/users', {
|
||||
user: { id: 'admin-id', role: 'admin' },
|
||||
body: { email: 'fourth@x.com' },
|
||||
appLocals: { licenseManager: fakeLm, userStore },
|
||||
});
|
||||
await r.run();
|
||||
expect(r.res._body.email).toBe('fourth@x.com');
|
||||
});
|
||||
|
||||
test('POST /admin/invites also gated by tier-check', async () => {
|
||||
await userStore.login({ email: 'admin@x.com' });
|
||||
await userStore.addToAllowlist('a@x.com');
|
||||
await userStore.login({ email: 'a@x.com' });
|
||||
await userStore.addToAllowlist('b@x.com');
|
||||
await userStore.login({ email: 'b@x.com' });
|
||||
|
||||
const fakeLm = { isPro: () => false };
|
||||
const router = _buildAdminRouter({ licenseManager: fakeLm });
|
||||
const r = _invoke(router, 'POST', '/admin/invites', {
|
||||
user: { id: 'admin-id', role: 'admin' },
|
||||
body: { email: 'fourth@x.com' },
|
||||
appLocals: { licenseManager: fakeLm, userStore },
|
||||
});
|
||||
let caught = null;
|
||||
try { await r.run(); } catch (e) { caught = e; }
|
||||
expect(caught).toBeTruthy();
|
||||
expect(caught.statusCode).toBe(402);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Regression tests for PUBLIC_ROUTES / CSRF excludedPaths `:param` placeholder
|
||||
* matching. Pre-DC-053 these were literal-string comparisons, so
|
||||
* `/api/v1/share/:token/preview` never matched real request paths like
|
||||
* `/api/v1/share/abc123/preview`. Fixed by converting `:param` to a
|
||||
* `[^/]+` regex segment before testing. Caught during DC-053 live testing.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
function _tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-public-test-'));
|
||||
}
|
||||
function _cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
const SRC_MIDDLEWARE = path.join(__dirname, '..', 'src', 'utilities', 'middleware.js');
|
||||
const SRC_CSRF = path.join(__dirname, '..', 'src', 'security', 'csrf-protection.js');
|
||||
|
||||
describe('PUBLIC_ROUTES + CSRF excludedPaths: `:param` placeholder matching', () => {
|
||||
test('PUBLIC_ROUTES is parsed and contains the DC-053 share entries', () => {
|
||||
const content = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
|
||||
// Sanity: file should still contain the public share entries
|
||||
expect(content).toContain('/api/v1/share/:token/preview');
|
||||
expect(content).toContain('/api/v1/share/:token/subscribe');
|
||||
expect(content).toContain('/api/v1/share/:token/redeem-tailscale');
|
||||
});
|
||||
|
||||
test('CSRF excludedPaths contains the DC-053 share entries', () => {
|
||||
const content = fs.readFileSync(SRC_CSRF, 'utf8');
|
||||
expect(content).toContain('/api/v1/share/:token/subscribe');
|
||||
expect(content).toContain('/api/v1/share/:token/redeem-tailscale');
|
||||
});
|
||||
|
||||
test('PUBLIC_ROUTES contains the DC-048 invite entries (regression coverage)', () => {
|
||||
const content = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
|
||||
expect(content).toContain('/api/v1/auth/invites/:token');
|
||||
expect(content).toContain('/api/v1/auth/invites/:token/accept');
|
||||
});
|
||||
|
||||
test('CSRF excludedPaths contains the DC-048 invite entry', () => {
|
||||
const content = fs.readFileSync(SRC_CSRF, 'utf8');
|
||||
expect(content).toContain('/api/v1/auth/invites/:token/accept');
|
||||
});
|
||||
|
||||
// Behavioral test: the regex conversion that the middleware applies to a
|
||||
// `:param` entry should match real request paths. This exercises the SAME
|
||||
// algorithm used by `isPublicRoute()` in src/utilities/middleware.js and
|
||||
// `isExcluded` in src/security/csrf-protection.js, just in isolation.
|
||||
function _placeholderToRegex(p) {
|
||||
return '^' + p.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '[^/]+') + '$';
|
||||
}
|
||||
|
||||
test('placeholder-to-regex algorithm matches share preview paths', () => {
|
||||
const pattern = _placeholderToRegex('/api/v1/share/:token/preview');
|
||||
expect(new RegExp(pattern).test('/api/v1/share/abc123/preview')).toBe(true);
|
||||
expect(new RegExp(pattern).test('/api/v1/share/some-very-long-token/preview')).toBe(true);
|
||||
// Different method/path segments should not match
|
||||
expect(new RegExp(pattern).test('/api/v1/share/abc/extra/preview')).toBe(false);
|
||||
expect(new RegExp(pattern).test('/api/v1/share/preview')).toBe(false);
|
||||
});
|
||||
|
||||
test('placeholder-to-regex matches multi-param paths', () => {
|
||||
const pattern = _placeholderToRegex('/api/v1/auth/login/:provider/verify');
|
||||
expect(new RegExp(pattern).test('/api/v1/auth/login/totp/verify')).toBe(true);
|
||||
expect(new RegExp(pattern).test('/api/v1/auth/login/email/verify')).toBe(true);
|
||||
expect(new RegExp(pattern).test('/api/v1/auth/login/totp/initiate')).toBe(false);
|
||||
});
|
||||
|
||||
test('placeholder-to-regex handles exact paths (no placeholders)', () => {
|
||||
const pattern = _placeholderToRegex('/health/live');
|
||||
expect(new RegExp(pattern).test('/health/live')).toBe(true);
|
||||
expect(new RegExp(pattern).test('/health/ready')).toBe(false);
|
||||
});
|
||||
|
||||
test('placeholder-to-regex handles the auth/gate/ prefix exemption', () => {
|
||||
// /api/v1/auth/gate/ is a prefix match (not in PUBLIC_ROUTES entries
|
||||
// individually). Verify the algorithm preserves this by NOT requiring
|
||||
// placeholders when none are present.
|
||||
const pattern = _placeholderToRegex('/api/v1/auth/gate/foo');
|
||||
expect(new RegExp(pattern).test('/api/v1/auth/gate/foo')).toBe(true);
|
||||
expect(new RegExp(pattern).test('/api/v1/auth/gate/bar')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -49,8 +49,14 @@ function readPublicRoutes() {
|
||||
// Extract excludedPaths from csrf-protection.js
|
||||
function readCsrfExcluded() {
|
||||
const content = fs.readFileSync(SRC_CSRF, 'utf8');
|
||||
// Match string literals in arrays inside excludedPaths
|
||||
const blockMatch = content.match(/excludedPaths\s*=\s*\[([^\]]+)\]/);
|
||||
// Match string literals in arrays inside excludedPaths.
|
||||
// The naive `[^\]]+` regex used to work but breaks once any comment line
|
||||
// between entries contains a quoted word (e.g. "token's TTL") — the
|
||||
// inner-quote regex then captures the comment text as a fake path.
|
||||
// Fix: strip line comments (`// ...`) before scanning. Block comments
|
||||
// don't appear in this file.
|
||||
const stripped = content.replace(/\/\/[^\n]*/g, '');
|
||||
const blockMatch = stripped.match(/excludedPaths\s*=\s*\[([^\]]+)\]/);
|
||||
if (!blockMatch) return new Set();
|
||||
const entries = [...blockMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]);
|
||||
return new Set(entries);
|
||||
@@ -105,7 +111,7 @@ function readMountedRoutes() {
|
||||
'routes/dns.js', // apiRouter.use('/dns', dnsRoutes({...}))
|
||||
'routes/notifications.js', // apiRouter.use('/notifications', notificationRoutes({...}))
|
||||
'routes/containers.js', // apiRouter.use('/containers', containerRoutes({...}))
|
||||
'routes/services.js', // apiRouter.use(serviceRoutes({...})) // bare mount
|
||||
'routes/billing.js', // DC-055: apiRouter.use('/billing', billingRoutes({...}))
|
||||
'routes/health.js', // apiRouter.use(healthRoutes({...})) // bare mount
|
||||
'routes/monitoring.js', // apiRouter.use(monitoringRoutes({...})) // bare mount
|
||||
'routes/updates.js', // apiRouter.use(updatesRoutes({...})) // bare mount
|
||||
@@ -123,12 +129,15 @@ function readMountedRoutes() {
|
||||
'routes/config/index.js', // apiRouter.use(configRoutes(ctx)) // bare mount
|
||||
'routes/themes.js', // apiRouter.use(themesRoutes({...})) // bare mount
|
||||
'routes/license.js', // apiRouter.use('/license', licenseRoutes({...}))
|
||||
'routes/share.js', // apiRouter.use(shareRoutes({...})) // bare mount (DC-053)
|
||||
'routes/services.js', // apiRouter.use(serviceRoutes({...})) // bare mount — needed for /api/v1/services + /api/v1/services/status PUBLIC_ROUTES
|
||||
];
|
||||
// Prefix map: explicit prefix from src/app.js's apiRouter.use() call
|
||||
const prefixMap = {
|
||||
'routes/dns.js': '/dns',
|
||||
'routes/notifications.js': '/notifications',
|
||||
'routes/containers.js': '/containers',
|
||||
'routes/billing.js': '/billing', // DC-055: apiRouter.use('/billing', billingRoutes({...})) in src/app.js
|
||||
'routes/tailscale.js': '/tailscale',
|
||||
'routes/ca.js': '/ca',
|
||||
'routes/openclaw.js': '/openclaw',
|
||||
@@ -145,7 +154,27 @@ function readMountedRoutes() {
|
||||
if (typeof factory !== 'function') continue;
|
||||
let router;
|
||||
try {
|
||||
router = factory(universalDeps);
|
||||
// Per-mount deps override: factories that need a real implementation
|
||||
// of a particular dep (not just a noopFn proxy) get one here. Without
|
||||
// this, DC-053's shareRoutes returns an empty 404 router in the test
|
||||
// (because universalDeps.shareStore.issuePublic is undefined), and the
|
||||
// walker never sees the real /share/:token/* paths.
|
||||
const deps = relPath === 'routes/share.js'
|
||||
? Object.assign({}, universalDeps, {
|
||||
shareStore: {
|
||||
issuePublic: () => ({ ok: true }),
|
||||
issueTailscale: () => ({ ok: true }),
|
||||
peek: () => null,
|
||||
getRaw: () => null,
|
||||
recordPublicSubscribe: () => ({ ok: true }),
|
||||
recordTailscaleUse: () => ({ ok: true }),
|
||||
revoke: () => true,
|
||||
list: () => [],
|
||||
listForService: () => [],
|
||||
},
|
||||
})
|
||||
: universalDeps;
|
||||
router = factory(deps);
|
||||
} catch (e) { continue; }
|
||||
// Every direct mount is on apiRouter (which lives at /api/v1) plus an
|
||||
// optional explicit prefix from src/app.js. Walk with the combined prefix
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* Covers the BACKLOG.md DC-006 acceptance criteria:
|
||||
* - no code → 400 (ValidationError)
|
||||
* - wrong code → 401 (AuthenticationError)
|
||||
* - valid TOTP → 200 + session cookie + CSRF token
|
||||
* - check-session with valid session → 200 { authenticated: true }
|
||||
* - valid TOTP → 200 + session cookie + CSRF token + SSO handoff token
|
||||
* - check-session with valid session → 200 { success: true, authenticated: true }
|
||||
* - check-session without session → 401 (AuthenticationError)
|
||||
*
|
||||
* Uses real otplib for code generation (so we exercise the actual TOTP math)
|
||||
@@ -79,6 +79,7 @@ function createApp(depsOverride = {}) {
|
||||
sessionStore.delete(ip);
|
||||
}),
|
||||
clearCookie: jest.fn(),
|
||||
createHandoffToken: jest.fn(() => 'mock-sso-handoff-token'),
|
||||
isValid: jest.fn((req) => {
|
||||
const ip = session.getClientIP(req);
|
||||
const entry = sessionStore.get(ip);
|
||||
@@ -303,8 +304,10 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.message).toMatch(/Authenticated successfully/);
|
||||
expect(res.body.csrfToken).toBe('mock-csrf-token');
|
||||
expect(res.body.ssoToken).toBe('mock-sso-handoff-token');
|
||||
expect(deps.session.create).toHaveBeenCalled();
|
||||
expect(deps.session.setCookie).toHaveBeenCalled();
|
||||
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||
expect(deps.renewCSRFToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -350,7 +353,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
||||
deps.session._grantSession('127.0.0.1');
|
||||
const res = await request(app).get('/api/totp/check-session').set('X-Forwarded-For', '127.0.0.1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ authenticated: true });
|
||||
expect(res.body).toEqual({ success: true, authenticated: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -450,24 +453,23 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
||||
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode });
|
||||
expect(loginRes.status).toBe(200);
|
||||
expect(loginRes.body.csrfToken).toBeDefined();
|
||||
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
|
||||
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 5. Check-session — should now be authenticated (the BACKLOG "→ endpoint succeeds" step)
|
||||
const checkRes = await request(app).get('/api/totp/check-session');
|
||||
expect(checkRes.status).toBe(200);
|
||||
expect(checkRes.body).toEqual({ authenticated: true });
|
||||
expect(checkRes.body).toEqual({ success: true, authenticated: true });
|
||||
|
||||
// 6. Logout / disable
|
||||
const disableCode = authenticator.generate(secret);
|
||||
const disableRes = await request(app).post('/api/totp/disable').send({ code: disableCode });
|
||||
expect(disableRes.status).toBe(200);
|
||||
|
||||
// 7. After disable, check-session should be 401 (bypass removed for security)
|
||||
// unless the user still holds a valid session, in which case it's 200.
|
||||
// The login step (4) may or may not have granted one depending on test order.
|
||||
// 7. After disable, check-session deterministically rejects before
|
||||
// checking session validity because TOTP protection is disabled.
|
||||
const afterRes = await request(app).get('/api/totp/check-session');
|
||||
// After disable, TOTP is off AND we may or may not have an active session.
|
||||
// The new contract: bypass is gone, but a valid session still authenticates.
|
||||
expect([200, 401]).toContain(afterRes.status);
|
||||
expect(afterRes.status).toBe(401);
|
||||
});
|
||||
|
||||
it('proves otplib is real (not stubbed) by using a totally bogus code', async () => {
|
||||
|
||||
@@ -78,23 +78,19 @@ describe('SelfUpdater.getLocalVersion() — DC-033 regression', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('commit is a git SHA (7-40 hex chars), not null', () => {
|
||||
test('commit contains a git SHA and is not null', () => {
|
||||
expect(result.commit).not.toBeNull();
|
||||
expect(result.commit).toMatch(/^[0-9a-f]{7,40}$/);
|
||||
expect(result.commit).toMatch(/(?:^|-)[0-9a-f]{7,40}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('candidate-path resolution survives missing sibling files', () => {
|
||||
// If we shadow __dirname by requiring the module through a different
|
||||
// require() chain, the function should still find package.json via its
|
||||
// candidate-list fallback. This catches the case where someone refactors
|
||||
// the file to a deeper subdirectory and forgets to update the candidates.
|
||||
test('getLocalVersion works regardless of how the module is required', () => {
|
||||
describe('repeat construction uses the same resolved metadata', () => {
|
||||
test('a second instance resolves the same non-fallback version metadata', () => {
|
||||
const mod = require(path.join(API_ROOT, 'src', 'docker', 'self-updater.js'));
|
||||
const Cls = mod.SelfUpdater || mod.default || mod;
|
||||
const result = new Cls({}).getLocalVersion();
|
||||
expect(result.version).not.toBe('0.0.0');
|
||||
expect(result.commit).toMatch(/^[0-9a-f]{7,40}$/);
|
||||
expect(result.commit).toMatch(/(?:^|-)[0-9a-f]{7,40}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
|
||||
const configureMiddleware = require('../src/utilities/middleware');
|
||||
|
||||
function buildSession() {
|
||||
const app = {
|
||||
param: jest.fn(),
|
||||
set: jest.fn(),
|
||||
use: jest.fn(),
|
||||
};
|
||||
|
||||
return configureMiddleware(app, {
|
||||
siteConfig: { dashboardHost: 'status.sami', tld: '.sami' },
|
||||
totpConfig: { enabled: true, sessionDuration: '24h' },
|
||||
tailscaleConfig: { enabled: false, requireAuth: false },
|
||||
metrics: { recordRequest: jest.fn() },
|
||||
auditLogger: { middleware: () => (_req, _res, next) => next() },
|
||||
authManager: { verifyJWT: jest.fn(), verifyAPIKey: jest.fn() },
|
||||
log: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
cryptoUtils: { loadOrCreateKey: () => Buffer.alloc(32, 7) },
|
||||
isValidContainerId: () => true,
|
||||
isTailscaleIP: () => false,
|
||||
getTailscaleStatus: async () => null,
|
||||
});
|
||||
}
|
||||
|
||||
function captureCookie(setCookie) {
|
||||
const headers = {};
|
||||
setCookie({ setHeader: (name, value) => { headers[name.toLowerCase()] = value; } }, '24h');
|
||||
return headers['set-cookie'];
|
||||
}
|
||||
|
||||
describe('TOTP session cookie scope', () => {
|
||||
test('primary login cookie is host-only for custom TLD deployments', () => {
|
||||
const session = buildSession();
|
||||
const cookie = captureCookie(session.setSessionCookie);
|
||||
|
||||
expect(cookie).toContain('dashcaddy_session=');
|
||||
expect(cookie).toContain('HttpOnly');
|
||||
expect(cookie).toContain('Secure');
|
||||
expect(cookie).toContain('SameSite=Lax');
|
||||
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
|
||||
});
|
||||
|
||||
test('SSO exchange uses the same host-only cookie contract', () => {
|
||||
const session = buildSession();
|
||||
const cookie = captureCookie(session.setHostOnlySessionCookie);
|
||||
|
||||
expect(cookie).toContain('dashcaddy_session=');
|
||||
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
|
||||
});
|
||||
|
||||
test('logout clears the host-only secure cookie', () => {
|
||||
const session = buildSession();
|
||||
const headers = {};
|
||||
session.clearSessionCookie({
|
||||
setHeader: (name, value) => { headers[name.toLowerCase()] = value; },
|
||||
});
|
||||
|
||||
expect(headers['set-cookie']).toContain('Max-Age=0');
|
||||
expect(headers['set-cookie']).toContain('Secure');
|
||||
expect(headers['set-cookie']).not.toMatch(/(?:^|;)\s*Domain=/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* Tests for share routes (DC-053) — public share + Tailscale-mediated share.
|
||||
* Coverage:
|
||||
* - GET /share/:token/preview is public, returns snapshot
|
||||
* - POST /share requires admin (401/403 without user)
|
||||
* - POST /share requires Pro tier (402 PaymentRequired when Free)
|
||||
* - POST /share issues a public share, returns token + urlPath
|
||||
* - POST /share rejects unknown serviceId with 404
|
||||
* - POST /share snaps unsupported TTLs
|
||||
* - POST /share/tailscale requires Tailscale configured
|
||||
* - POST /share/tailscale mints auth key + records share + emails invitee
|
||||
* - POST /share/tailscale rolls back share when createAuthKey throws
|
||||
* - POST /share/tailscale returns emailed=true when sendEmail resolves
|
||||
* - POST /share/tailscale returns urlPath when email fails (manual fallback)
|
||||
* - DELETE /share/:id requires admin; revokes
|
||||
* - GET /share lists shares (admin only)
|
||||
* - POST /share/:token/subscribe is public, records event
|
||||
* - POST /share/:token/redeem-tailscale records use + is single-shot
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const { createShareStore } = require('../src/security/share-store');
|
||||
const { PaymentRequiredError } = require('../src/utilities/errors');
|
||||
|
||||
function _tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-share-route-'));
|
||||
}
|
||||
function _cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
// ── Test stubs ────────────────────────────────────────────────────────────
|
||||
|
||||
function _proLicenseManager() {
|
||||
return { isPro: () => true, allowsLifetimeLicense: () => false };
|
||||
}
|
||||
function _freeLicenseManager() {
|
||||
return { isPro: () => false, allowsLifetimeLicense: () => false };
|
||||
}
|
||||
|
||||
function _stubNotificationManager({ shouldFail = false } = {}) {
|
||||
return {
|
||||
sendEmail: jest.fn(async () => {
|
||||
if (shouldFail) throw new Error('SMTP down');
|
||||
return { messageId: 'fake' };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function _stubTailscaleCoord({ shouldFail = false, keyId = 'auth-key-123' } = {}) {
|
||||
return {
|
||||
createAuthKey: jest.fn(async () => {
|
||||
if (shouldFail) throw new Error('Tailscale API down');
|
||||
return { id: keyId, key: 'tskey-fake-' + 'x'.repeat(40) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function _stubServicesStateManager(services = {}) {
|
||||
return {
|
||||
get: async (id) => services[id] || null,
|
||||
read: async () => Object.values(services),
|
||||
};
|
||||
}
|
||||
|
||||
function _buildApp({
|
||||
shareStore,
|
||||
licenseManager = _proLicenseManager(),
|
||||
tailscaleCoord = _stubTailscaleCoord(),
|
||||
notificationManager = _stubNotificationManager(),
|
||||
servicesStateManager = _stubServicesStateManager({
|
||||
plex: { id: 'plex', name: 'Plex', description: 'Media', url: 'https://plex.sami' },
|
||||
}),
|
||||
adminUser = { email: 'admin@sami', role: 'admin' },
|
||||
noAdmin = false,
|
||||
} = {}) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// Inject a fake req.user for the protected endpoints; bypass for the public ones.
|
||||
app.use((req, _res, next) => {
|
||||
if (noAdmin) {
|
||||
req.user = { email: 'viewer@sami', role: 'viewer' };
|
||||
} else {
|
||||
req.user = adminUser;
|
||||
}
|
||||
next();
|
||||
});
|
||||
const shareRoutes = require('../routes/share');
|
||||
app.use(shareRoutes({
|
||||
shareStore,
|
||||
licenseManager,
|
||||
tailscaleCoord,
|
||||
notificationManager,
|
||||
servicesStateManager,
|
||||
servicesFile: null,
|
||||
asyncHandler: (fn, label) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||
log: { info() {}, warn() {}, error() {} },
|
||||
}));
|
||||
// Error handler mirrors production
|
||||
app.use((err, _req, res, _next) => {
|
||||
if (err && err.statusCode) {
|
||||
return res.status(err.statusCode).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
});
|
||||
}
|
||||
return res.status(500).json({ success: false, error: err && err.message });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('share routes: GET /share/:token/preview', () => {
|
||||
let dir, shareStore;
|
||||
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('public — returns service snapshot', async () => {
|
||||
const app = _buildApp({ shareStore });
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
|
||||
const res = await request(app).get(`/share/${issued.token}/preview`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data.kind).toBe('public');
|
||||
expect(res.body.data.serviceId).toBe('plex');
|
||||
expect(res.body.data.service.name).toBe('Plex');
|
||||
});
|
||||
|
||||
test('public — 404 for unknown token', async () => {
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app).get('/share/nonexistent/preview');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('public — no auth required', async () => {
|
||||
const app = _buildApp({ shareStore, noAdmin: true });
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const res = await request(app).get(`/share/${issued.token}/preview`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('share routes: POST /share', () => {
|
||||
let dir, shareStore;
|
||||
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('admin+Pro → issues public share', async () => {
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post('/share')
|
||||
.send({ serviceId: 'plex', ttlMs: 3_600_000 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data.kind).toBe('public');
|
||||
expect(res.body.data.token).toBeTruthy();
|
||||
expect(res.body.data.urlPath).toBe(`/share/${res.body.data.token}`);
|
||||
expect(res.body.data.serviceId).toBe('plex');
|
||||
});
|
||||
|
||||
test('Free tier → 402 PaymentRequired', async () => {
|
||||
const app = _buildApp({ shareStore, licenseManager: _freeLicenseManager() });
|
||||
const res = await request(app).post('/share').send({ serviceId: 'plex' });
|
||||
expect(res.status).toBe(402);
|
||||
expect(res.body.error).toMatch(/Pro tier required/);
|
||||
});
|
||||
|
||||
test('non-admin → 403', async () => {
|
||||
const app = _buildApp({ shareStore, noAdmin: true });
|
||||
const res = await request(app).post('/share').send({ serviceId: 'plex' });
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('unknown serviceId → 404', async () => {
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app).post('/share').send({ serviceId: 'nope' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('missing serviceId → 400', async () => {
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app).post('/share').send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('unsupported TTL snaps to default', async () => {
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post('/share')
|
||||
.send({ serviceId: 'plex', ttlMs: 999999 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.data.ttlMs).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('share routes: POST /share/tailscale', () => {
|
||||
let dir, shareStore;
|
||||
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('Pro+admin+Tailscale → mints key, emails, records share', async () => {
|
||||
const tailscaleCoord = _stubTailscaleCoord();
|
||||
const notificationManager = _stubNotificationManager();
|
||||
const app = _buildApp({ shareStore, tailscaleCoord, notificationManager });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/share/tailscale')
|
||||
.send({ serviceId: 'plex', email: 'friend@example.com' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data.kind).toBe('tailscale');
|
||||
expect(res.body.data.email).toBe('friend@example.com');
|
||||
expect(res.body.data.emailed).toBe(true);
|
||||
expect(res.body.data.emailError).toBeFalsy();
|
||||
expect(tailscaleCoord.createAuthKey).toHaveBeenCalledWith(expect.objectContaining({
|
||||
reusable: false, ephemeral: true, preauthorized: true,
|
||||
description: expect.stringContaining('dashcaddy-share:'),
|
||||
}));
|
||||
expect(notificationManager.sendEmail).toHaveBeenCalledWith(
|
||||
expect.stringContaining('shared a service with you'),
|
||||
expect.stringContaining('/share/')
|
||||
);
|
||||
});
|
||||
|
||||
test('Free tier → 402', async () => {
|
||||
const app = _buildApp({ shareStore, licenseManager: _freeLicenseManager() });
|
||||
const res = await request(app)
|
||||
.post('/share/tailscale')
|
||||
.send({ serviceId: 'plex', email: 'a@b.com' });
|
||||
expect(res.status).toBe(402);
|
||||
});
|
||||
|
||||
test('Tailscale not configured → 400', async () => {
|
||||
const app = _buildApp({ shareStore, tailscaleCoord: null });
|
||||
const res = await request(app)
|
||||
.post('/share/tailscale')
|
||||
.send({ serviceId: 'plex', email: 'a@b.com' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('createAuthKey failure → rolls back share', async () => {
|
||||
const app = _buildApp({
|
||||
shareStore,
|
||||
tailscaleCoord: _stubTailscaleCoord({ shouldFail: true }),
|
||||
});
|
||||
const res = await request(app)
|
||||
.post('/share/tailscale')
|
||||
.send({ serviceId: 'plex', email: 'a@b.com' });
|
||||
expect(res.status).toBe(400);
|
||||
// No orphans
|
||||
const remaining = await shareStore.list();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('email delivery failure → still returns 201 with urlPath fallback', async () => {
|
||||
const app = _buildApp({
|
||||
shareStore,
|
||||
notificationManager: _stubNotificationManager({ shouldFail: true }),
|
||||
});
|
||||
const res = await request(app)
|
||||
.post('/share/tailscale')
|
||||
.send({ serviceId: 'plex', email: 'a@b.com' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.data.emailed).toBe(false);
|
||||
expect(res.body.data.emailError).toMatch(/SMTP/);
|
||||
expect(res.body.data.urlPath).toMatch(/^\/share\//);
|
||||
});
|
||||
|
||||
test('clamps TTL to 24h max', async () => {
|
||||
const tailscaleCoord = _stubTailscaleCoord();
|
||||
const app = _buildApp({ shareStore, tailscaleCoord });
|
||||
const res = await request(app)
|
||||
.post('/share/tailscale')
|
||||
.send({ serviceId: 'plex', email: 'a@b.com', ttlMs: 30 * 24 * 60 * 60 * 1000 });
|
||||
expect(res.status).toBe(201);
|
||||
const calledOpts = tailscaleCoord.createAuthKey.mock.calls[0][0];
|
||||
expect(calledOpts.expirySeconds).toBeLessThanOrEqual(24 * 60 * 60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('share routes: GET /share + DELETE /share/:id', () => {
|
||||
let dir, shareStore;
|
||||
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('admin lists outstanding shares', async () => {
|
||||
await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app).get('/share');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('non-admin → forbidden', async () => {
|
||||
const app = _buildApp({ shareStore, noAdmin: true });
|
||||
const res = await request(app).get('/share');
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
test('admin revokes share', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app).delete(`/share/${issued.id}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await shareStore.peek(issued.token)).toBeNull();
|
||||
});
|
||||
|
||||
test('revoke unknown id → 404', async () => {
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app).delete('/share/nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('share routes: POST /share/:token/subscribe (public)', () => {
|
||||
let dir, shareStore;
|
||||
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('public — records subscribe event', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore, noAdmin: true });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/subscribe`)
|
||||
.send({ email: 'sub@example.com' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.count).toBe(1);
|
||||
});
|
||||
|
||||
test('rejects invalid email', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore, noAdmin: true });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/subscribe`)
|
||||
.send({ email: 'not-an-email' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects unknown token', async () => {
|
||||
const app = _buildApp({ shareStore, noAdmin: true });
|
||||
const res = await request(app)
|
||||
.post('/share/nonexistent/subscribe')
|
||||
.send({ email: 'a@b.com' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('share routes: POST /share/:token/redeem-tailscale (public)', () => {
|
||||
let dir, shareStore;
|
||||
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('public — first redemption succeeds, second is already_used', async () => {
|
||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const app = _buildApp({ shareStore, noAdmin: true });
|
||||
const r1 = await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({ deviceId: 'device-1' });
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r1.body.data.redeemed).toBe(true);
|
||||
|
||||
const r2 = await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({ deviceId: 'device-2' });
|
||||
expect(r2.status).toBe(400);
|
||||
expect(r2.body.error).toMatch(/already_used/);
|
||||
});
|
||||
|
||||
test('rejects missing deviceId', async () => {
|
||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const app = _buildApp({ shareStore, noAdmin: true });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('share routes: defensive', () => {
|
||||
// These tests run under jest (NODE_ENV=test) so the factory is lenient
|
||||
// about missing deps — it returns an empty router with a 404 catch-all
|
||||
// instead of throwing. That's by design: production always wires
|
||||
// shareStore + asyncHandler (src/app.js instantiates them), but the
|
||||
// universal-deps Proxy in some test scenarios returns noopFn.
|
||||
|
||||
test('factory returns 404 router when shareStore missing (test mode)', () => {
|
||||
const prevEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'test';
|
||||
try {
|
||||
const shareRoutes = require('../routes/share');
|
||||
const router = shareRoutes({ asyncHandler: (fn) => fn });
|
||||
expect(typeof router).toBe('function'); // express.Router
|
||||
} finally {
|
||||
process.env.NODE_ENV = prevEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test('factory uses fallback asyncHandler when missing (test mode)', () => {
|
||||
const prevEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'test';
|
||||
try {
|
||||
const shareRoutes = require('../routes/share');
|
||||
const dir = _tmpDir();
|
||||
const shareStore = createShareStore({ dataDir: dir });
|
||||
const router = shareRoutes({ shareStore });
|
||||
expect(typeof router).toBe('function');
|
||||
_cleanup(dir);
|
||||
} finally {
|
||||
process.env.NODE_ENV = prevEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test('factory throws when shareStore missing in production', () => {
|
||||
const prevEnv = process.env.NODE_ENV;
|
||||
delete process.env.NODE_ENV;
|
||||
try {
|
||||
const shareRoutes = require('../routes/share');
|
||||
expect(() => shareRoutes({ asyncHandler: (fn) => fn })).toThrow(/shareStore/);
|
||||
} finally {
|
||||
if (prevEnv !== undefined) process.env.NODE_ENV = prevEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test('factory throws when asyncHandler missing in production', () => {
|
||||
const prevEnv = process.env.NODE_ENV;
|
||||
delete process.env.NODE_ENV;
|
||||
try {
|
||||
const shareRoutes = require('../routes/share');
|
||||
const dir = _tmpDir();
|
||||
const shareStore = createShareStore({ dataDir: dir });
|
||||
expect(() => shareRoutes({ shareStore })).toThrow(/asyncHandler/);
|
||||
_cleanup(dir);
|
||||
} finally {
|
||||
if (prevEnv !== undefined) process.env.NODE_ENV = prevEnv;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Tests for share-store (DC-053).
|
||||
* Coverage:
|
||||
* - issuePublic returns raw token + signature + service-bound metadata
|
||||
* - issuePublic enforces 1h/24h/7d whitelist (other ttls snap to default)
|
||||
* - issueTailscale returns token; service-bound + email-bound
|
||||
* - peek returns public-safe info; signature verification rejects tampering
|
||||
* - peek returns null for unknown/used/expired (no enumeration)
|
||||
* - recordPublicSubscribe increments; caps; rejects expired
|
||||
* - recordTailscaleUse is single-use
|
||||
* - revoke removes by id
|
||||
* - list returns outstanding only (used/expired auto-pruned)
|
||||
* - listForService filters
|
||||
* - signing secret persists across reopens
|
||||
* - dataDir resolver falls back to /tmp when given function/Proxy values
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { createShareStore } = require('../src/security/share-store');
|
||||
|
||||
function _tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-sharetest-'));
|
||||
}
|
||||
function _cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
function _sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
describe('share-store: issuePublic', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('returns raw token + id + serviceId + expiresAt + urlPath', async () => {
|
||||
const r = await store.issuePublic({ serviceId: 'plex', ttlMs: 60 * 60 * 1000, createdBy: 'admin@x.com' });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.id).toBeTruthy();
|
||||
expect(r.token.length).toBeGreaterThanOrEqual(40);
|
||||
expect(r.signature.length).toBeGreaterThan(20);
|
||||
expect(r.serviceId).toBe('plex');
|
||||
expect(r.kind).toBe('public');
|
||||
expect(r.urlPath).toBe(`/share/${r.token}`);
|
||||
expect(new Date(r.expiresAt).getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
test('rejects missing serviceId', async () => {
|
||||
const r = await store.issuePublic({ serviceId: '' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('invalid_service');
|
||||
});
|
||||
|
||||
test('snaps unsupported TTLs to default (24h)', async () => {
|
||||
const r = await store.issuePublic({ serviceId: 'svc', ttlMs: 999999 });
|
||||
expect(r.ok).toBe(true);
|
||||
// default is 24h
|
||||
const diff = new Date(r.expiresAt).getTime() - Date.now();
|
||||
expect(diff).toBeGreaterThan(23 * 60 * 60 * 1000);
|
||||
expect(diff).toBeLessThan(25 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('allows exactly 1h, 24h, 7d', async () => {
|
||||
for (const ttl of [3_600_000, 86_400_000, 604_800_000]) {
|
||||
const r = await store.issuePublic({ serviceId: 'svc', ttlMs: ttl });
|
||||
expect(r.ttlMs).toBe(ttl);
|
||||
}
|
||||
});
|
||||
|
||||
test('subscribeCap clamps to range', async () => {
|
||||
const r1 = await store.issuePublic({ serviceId: 'svc', subscribeCap: 0 });
|
||||
expect(r1.ok).toBe(true);
|
||||
// 0 -> default
|
||||
const meta1 = await store.peek(r1.token);
|
||||
expect(meta1.subscribeCap).toBeGreaterThan(0);
|
||||
|
||||
const r2 = await store.issuePublic({ serviceId: 'svc', subscribeCap: 50 });
|
||||
expect((await store.peek(r2.token)).subscribeCap).toBe(50);
|
||||
|
||||
const r3 = await store.issuePublic({ serviceId: 'svc', subscribeCap: 999999 });
|
||||
expect((await store.peek(r3.token)).subscribeCap).toBe(10000); // clamped
|
||||
});
|
||||
});
|
||||
|
||||
describe('share-store: issueTailscale', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('returns raw token + email + service-bound metadata', async () => {
|
||||
const r = await store.issueTailscale({
|
||||
serviceId: 'jellyfin',
|
||||
email: 'Friend@Example.COM',
|
||||
ttlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.email).toBe('friend@example.com'); // normalized lowercase
|
||||
expect(r.serviceId).toBe('jellyfin');
|
||||
expect(r.kind).toBe('tailscale');
|
||||
});
|
||||
|
||||
test('rejects missing email', async () => {
|
||||
const r = await store.issueTailscale({ serviceId: 'svc', email: 'nope' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('invalid_email');
|
||||
});
|
||||
|
||||
test('rejects missing serviceId', async () => {
|
||||
const r = await store.issueTailscale({ serviceId: '', email: 'a@b.com' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('invalid_service');
|
||||
});
|
||||
|
||||
test('clamps TTL to 24h max', async () => {
|
||||
const r = await store.issueTailscale({
|
||||
serviceId: 'svc',
|
||||
email: 'a@b.com',
|
||||
ttlMs: 30 * 24 * 60 * 60 * 1000, // 30d
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
const diff = new Date(r.expiresAt).getTime() - Date.now();
|
||||
expect(diff).toBeLessThanOrEqual(24 * 60 * 60 * 1000 + 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('share-store: peek', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('returns public-safe metadata for a fresh public share', async () => {
|
||||
const issued = await store.issuePublic({ serviceId: 'plex' });
|
||||
const meta = await store.peek(issued.token);
|
||||
expect(meta).toMatchObject({
|
||||
kind: 'public',
|
||||
serviceId: 'plex',
|
||||
usedAt: null,
|
||||
});
|
||||
expect(meta.expiresAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test('returns null for unknown token (no enumeration)', async () => {
|
||||
expect(await store.peek('nope')).toBeNull();
|
||||
expect(await store.peek('')).toBeNull();
|
||||
expect(await store.peek(null)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for expired token', async () => {
|
||||
const issued = await store.issuePublic({ serviceId: 'svc', ttlMs: 60 * 60 * 1000 });
|
||||
// tamper: backdate the expiresAt via direct file write
|
||||
const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||
const id = Object.keys(data.shares)[0];
|
||||
data.shares[id].expiresAt = new Date(Date.now() - 1000).toISOString();
|
||||
fs.writeFileSync(path.join(dir, 'shares.json'), JSON.stringify(data));
|
||||
expect(await store.peek(issued.token)).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects tampered signature', async () => {
|
||||
const issued = await store.issuePublic({ serviceId: 'svc' });
|
||||
const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||
const id = Object.keys(data.shares)[0];
|
||||
data.shares[id].serviceId = 'attacker-controlled-svc'; // tamper the serviceId
|
||||
data.shares[id].signature = 'tampered' + 'x'.repeat(40);
|
||||
fs.writeFileSync(path.join(dir, 'shares.json'), JSON.stringify(data));
|
||||
expect(await store.peek(issued.token)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('share-store: recordPublicSubscribe', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('increments count up to cap, then rejects with cap_reached', async () => {
|
||||
const issued = await store.issuePublic({ serviceId: 'svc', subscribeCap: 3 });
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const r = await store.recordPublicSubscribe(issued.token);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.count).toBe(i);
|
||||
}
|
||||
const blocked = await store.recordPublicSubscribe(issued.token);
|
||||
expect(blocked.ok).toBe(false);
|
||||
expect(blocked.reason).toBe('cap_reached');
|
||||
});
|
||||
|
||||
test('rejects when token unknown', async () => {
|
||||
const r = await store.recordPublicSubscribe('unknown-token');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('not_found');
|
||||
});
|
||||
|
||||
test('rejects when wrong kind (Tailscale)', async () => {
|
||||
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||
const r = await store.recordPublicSubscribe(issued.token);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('wrong_kind');
|
||||
});
|
||||
|
||||
test('rejects when expired', async () => {
|
||||
const issued = await store.issuePublic({ serviceId: 'svc', ttlMs: 60 * 60 * 1000 });
|
||||
const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||
const id = Object.keys(data.shares)[0];
|
||||
data.shares[id].expiresAt = new Date(Date.now() - 1000).toISOString();
|
||||
fs.writeFileSync(path.join(dir, 'shares.json'), JSON.stringify(data));
|
||||
const r = await store.recordPublicSubscribe(issued.token);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('expired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('share-store: recordTailscaleUse', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('marks used on first redemption; second returns already_used', async () => {
|
||||
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||
const r1 = await store.recordTailscaleUse(issued.token, { deviceId: 'device-xyz' });
|
||||
expect(r1.ok).toBe(true);
|
||||
expect(r1.share.usedAt).toBeTruthy();
|
||||
expect(r1.share.usedBy).toBe('device-xyz');
|
||||
|
||||
const r2 = await store.recordTailscaleUse(issued.token, { deviceId: 'other' });
|
||||
expect(r2.ok).toBe(false);
|
||||
expect(r2.reason).toBe('already_used');
|
||||
});
|
||||
|
||||
test('rejects wrong kind (public)', async () => {
|
||||
const issued = await store.issuePublic({ serviceId: 'svc' });
|
||||
const r = await store.recordTailscaleUse(issued.token, { deviceId: 'd' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('wrong_kind');
|
||||
});
|
||||
|
||||
test('rejects unknown token', async () => {
|
||||
const r = await store.recordTailscaleUse('nope', { deviceId: 'd' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('not_found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('share-store: revoke + list + listForService', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('revoke removes by id', async () => {
|
||||
const a = await store.issuePublic({ serviceId: 'svc-a' });
|
||||
const b = await store.issuePublic({ serviceId: 'svc-b' });
|
||||
expect(await store.revoke(a.id)).toBe(true);
|
||||
expect(await store.peek(a.token)).toBeNull();
|
||||
expect(await store.peek(b.token)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('revoke returns false for unknown id', async () => {
|
||||
expect(await store.revoke('nope')).toBe(false);
|
||||
});
|
||||
|
||||
test('list returns outstanding only', async () => {
|
||||
await store.issuePublic({ serviceId: 'svc' });
|
||||
const t = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||
await store.recordTailscaleUse(t.token, { deviceId: 'd' });
|
||||
const all = await store.list();
|
||||
// Tailscale record is terminal (used), pruned; 1 public remains
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].kind).toBe('public');
|
||||
});
|
||||
|
||||
test('listForService filters', async () => {
|
||||
await store.issuePublic({ serviceId: 'svc-a' });
|
||||
await store.issuePublic({ serviceId: 'svc-b' });
|
||||
await store.issueTailscale({ serviceId: 'svc-a', email: 'a@b.com' });
|
||||
const aShares = await store.listForService('svc-a');
|
||||
expect(aShares).toHaveLength(2);
|
||||
expect(aShares.every(s => s.serviceId === 'svc-a')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('share-store: signing secret persistence + defensive dataDir', () => {
|
||||
test('signing secret persists across reopens', async () => {
|
||||
const dir = _tmpDir();
|
||||
try {
|
||||
const a = await createShareStore({ dataDir: dir }).issuePublic({ serviceId: 'svc' });
|
||||
const b = await createShareStore({ dataDir: dir }).peek(a.token);
|
||||
expect(b).not.toBeNull(); // same secret, signature still valid
|
||||
} finally { _cleanup(dir); }
|
||||
});
|
||||
|
||||
test('falls back to os.tmpdir() when dataDir is missing/function/Proxy', () => {
|
||||
// function value (test-proxy scenario)
|
||||
const fn = () => '/should/not/throw';
|
||||
const proxy = new Proxy({ dataDir: '/x' }, { get: () => fn });
|
||||
const s = createShareStore({ dataDir: proxy });
|
||||
expect(typeof s.issuePublic).toBe('function');
|
||||
// Should not throw on construction
|
||||
expect(s._file).toContain('shares.json');
|
||||
});
|
||||
|
||||
test('opts.signingSecret overrides persisted secret', async () => {
|
||||
const dir = _tmpDir();
|
||||
try {
|
||||
const a = await createShareStore({ dataDir: dir }).issuePublic({ serviceId: 'svc' });
|
||||
// Reopen with a DIFFERENT secret — peek should fail (signature mismatch).
|
||||
const reopen = createShareStore({ dataDir: dir, signingSecret: 'different-secret-' + 'x'.repeat(40) });
|
||||
const b = await reopen.peek(a.token);
|
||||
expect(b).toBeNull();
|
||||
} finally { _cleanup(dir); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const createSsoRouter = require('../routes/auth/sso-gate');
|
||||
|
||||
function createApp({ redeem = true } = {}) {
|
||||
const app = express();
|
||||
const session = {
|
||||
redeemHandoffToken: jest.fn().mockReturnValue(redeem),
|
||||
setCookieHostOnly: jest.fn((res) => {
|
||||
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
|
||||
}),
|
||||
isValid: jest.fn().mockReturnValue(true),
|
||||
};
|
||||
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra });
|
||||
const router = createSsoRouter({
|
||||
totpConfig: { enabled: true, sessionDuration: '24h' },
|
||||
session,
|
||||
asyncHandler,
|
||||
errorResponse,
|
||||
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||
getAppSession: jest.fn(),
|
||||
appSessionCache: new Map(),
|
||||
credentialManager: { retrieve: jest.fn() },
|
||||
fetchT: jest.fn(),
|
||||
getServiceById: jest.fn(),
|
||||
licenseManager: {
|
||||
hasFeature: jest.fn().mockReturnValue(true),
|
||||
requirePremium: jest.fn(() => (_req, _res, next) => next()),
|
||||
},
|
||||
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
|
||||
});
|
||||
app.use('/api/v1', router);
|
||||
return { app, session };
|
||||
}
|
||||
|
||||
describe('cross-host SSO exchange redirect', () => {
|
||||
test('sets a host-only cookie and redirects to a relative service path', async () => {
|
||||
const { app, session } = createApp();
|
||||
const res = await request(app)
|
||||
.get('/api/v1/auth/sso-exchange')
|
||||
.query({ token: 'one-time', return: '/settings?tab=network#dns' });
|
||||
|
||||
expect(res.status).toBe(303);
|
||||
expect(res.headers.location).toBe('/settings?tab=network#dns');
|
||||
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time');
|
||||
});
|
||||
|
||||
test.each([
|
||||
'https://evil.example/phish',
|
||||
'//evil.example/phish',
|
||||
'/\\evil.example/phish',
|
||||
])('rejects cross-origin return value %s', async (returnValue) => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app)
|
||||
.get('/api/v1/auth/sso-exchange')
|
||||
.query({ token: 'one-time', return: returnValue });
|
||||
|
||||
expect(res.status).toBe(303);
|
||||
expect(res.headers.location).toBe('/');
|
||||
});
|
||||
|
||||
test('keeps the existing JSON exchange behavior when no return is supplied', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app)
|
||||
.get('/api/v1/auth/sso-exchange')
|
||||
.query({ token: 'one-time' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ success: true, authenticated: true });
|
||||
});
|
||||
|
||||
test('does not set a cookie or redirect for an invalid token', async () => {
|
||||
const { app, session } = createApp({ redeem: false });
|
||||
const res = await request(app)
|
||||
.get('/api/v1/auth/sso-exchange')
|
||||
.query({ token: 'bad', return: '/settings' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
expect(session.setCookieHostOnly).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 B |
+243
-32
@@ -19,9 +19,10 @@ const path = require('path');
|
||||
// Master secret file — lives only on admin machine, NEVER shipped
|
||||
const SECRET_FILE = path.join(__dirname, '.license-secret');
|
||||
|
||||
// License code format: DC-AAAAA-BBBBB-CCCCC-DDDDD
|
||||
// Encodes: version(4bit) + duration_days(12bit) + code_id(32bit) + created_ts(32bit) + hmac(48bit)
|
||||
// Total: 128 bits = 16 bytes, base32-encoded into 4 groups of 5 chars
|
||||
// License code format: DC-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE
|
||||
// Encodes: version(4bit) + duration_days(12bit) + code_id(32bit) + created_ts(32bit) + hmac(40bit)
|
||||
// Total: 120 bits = 15 bytes, base32-encoded into 5 groups of 5 chars
|
||||
// (25 base32 chars = 125 bits, comfortably fits 120 bits of data)
|
||||
|
||||
const VALID_DURATIONS = [30, 90, 180, 365];
|
||||
const LIFETIME_DURATION = 0; // Admin-only, not publicly available
|
||||
@@ -61,12 +62,190 @@ function base32Decode(str) {
|
||||
|
||||
function getSecret() {
|
||||
if (!fs.existsSync(SECRET_FILE)) {
|
||||
console.error('No master secret found. Run with --init-secret first.');
|
||||
console.error('No master secret found at', SECRET_FILE);
|
||||
console.error('Run with --init-secret first.');
|
||||
process.exit(1);
|
||||
}
|
||||
return fs.readFileSync(SECRET_FILE, 'utf8').trim();
|
||||
}
|
||||
|
||||
// Counter location: the default is `path.join(__dirname, '.license-counter')`.
|
||||
// That's adjacent to this source file on the admin machine (not the secret
|
||||
// file — the secret and counter share a directory on the developer's
|
||||
// workstation, but they are independent files). The CLI does not merge them.
|
||||
// When this module is required from a packaged/installed location where
|
||||
// __dirname might be read-only, override the counter location via the
|
||||
// `LICENSE_COUNTER_FILE` env var. The Stripe bridge uses this same path.
|
||||
function _defaultCounterFile() {
|
||||
return process.env.LICENSE_COUNTER_FILE || path.join(__dirname, '.license-counter');
|
||||
}
|
||||
|
||||
// Atomic counter write — write to a uniquely-named .tmp then rename. The
|
||||
// .tmp suffix includes pid + Date.now() + Math.random so two concurrent
|
||||
// calls in overlapping event-loop ticks (e.g. a Stripe webhook fan-out)
|
||||
// can't collide on the temp name. POSIX rename is atomic on the same
|
||||
// filesystem, so the live counter file is never observed in a half-written
|
||||
// state. If writeFileSync throws, we re-throw without renaming — the
|
||||
// original counter file is intact. If renameSync throws, we attempt to
|
||||
// unlink the .tmp so it doesn't accumulate.
|
||||
function _atomicWriteCounter(counterFile, value) {
|
||||
const tmpFile = `${counterFile}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, String(value));
|
||||
} catch (err) {
|
||||
throw new Error(`generateCodes: failed to write counter tmp file ${tmpFile}: ${err.message}`);
|
||||
}
|
||||
try {
|
||||
fs.renameSync(tmpFile, counterFile);
|
||||
} catch (err) {
|
||||
try { fs.unlinkSync(tmpFile); } catch (_) { /* best effort cleanup */ }
|
||||
throw new Error(`generateCodes: failed to rename counter tmp to ${counterFile}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrency note: this module is single-threaded JavaScript. Two
|
||||
// synchronous calls to generateCodes() within the same event-loop tick
|
||||
// cannot interleave — fs.*Sync blocks the thread and the second call runs
|
||||
// only after the first returns. The "atomic" part of the counter write
|
||||
// protects against a process crash between writeFileSync and renameSync
|
||||
// (the original counter file is intact because rename never happened)
|
||||
// and against OS-level write atomicity. It does NOT protect against a
|
||||
// concurrent process — license-keygen.js is a single-instance admin tool
|
||||
// and must not be invoked from multiple processes simultaneously.
|
||||
// Callers needing cross-process safety (which is none currently) would
|
||||
// need OS-level locking via fcntl or flock — out of scope.
|
||||
|
||||
/**
|
||||
* Programmatic equivalent of the CLI's "generate codes" path.
|
||||
*
|
||||
* Differs from the CLI in two ways:
|
||||
* 1. No console output — returns the resulting array.
|
||||
* 2. Persists the counter file atomically (write to a uniquely-named
|
||||
* .tmp, rename) so a crash mid-write doesn't leave the counter in a
|
||||
* half-bumped state, and so concurrent calls don't collide on the
|
||||
* same .tmp name.
|
||||
*
|
||||
* Concurrency: relies on Node's single-threaded event loop. Two
|
||||
* synchronous calls in the same tick cannot interleave — the second call
|
||||
* reads the post-write counter value. The atomic write helper protects
|
||||
* against process crashes between writeFileSync and renameSync, and the
|
||||
* unique .tmp suffix prevents filename collisions across ticks. Cross-process
|
||||
* races are still possible — license-keygen.js is a single-instance admin
|
||||
* tool, so callers must not invoke it from multiple processes simultaneously.
|
||||
*
|
||||
* Returns synchronously. The underlying counter allocator uses fs.*Sync,
|
||||
* so the function never throws asynchronously. Wrap with Promise.resolve()
|
||||
* if your caller needs a Promise.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.secret The master secret (hex string). Callers
|
||||
* are responsible for loading it via
|
||||
* loadSecret() or getSecret().
|
||||
* @param {number} opts.durationDays 30, 90, 180, 365, or 0 for LIFETIME.
|
||||
* Validated against VALID_DURATIONS / LIFETIME.
|
||||
* @param {number} [opts.count=1] Number of codes to mint.
|
||||
* @param {number} [opts.startId] Override the auto counter. If omitted,
|
||||
* reads + increments the counter file.
|
||||
* @param {string} [opts.counterFile] Override the counter file path.
|
||||
* Defaults to env LICENSE_COUNTER_FILE or
|
||||
* path.join(__dirname, '.license-counter').
|
||||
* @returns {Array<{code: string, codeId: number, durationDays: number}>}
|
||||
*/
|
||||
// Throws on bad opts. Returns { secret, durationDays, count } with defaults applied.
|
||||
function _validateGenerateOpts(opts) {
|
||||
if (!opts || !opts.secret || typeof opts.secret !== 'string') {
|
||||
throw new Error('generateCodes: secret is required');
|
||||
}
|
||||
const { secret, count = 1 } = opts;
|
||||
const { durationDays } = opts;
|
||||
// LIFETIME (0) is accepted; non-LIFETIME must be in the allowed list.
|
||||
if (durationDays !== 0 && !VALID_DURATIONS.includes(durationDays)) {
|
||||
throw new Error(`generateCodes: invalid duration ${durationDays}. Valid: ${VALID_DURATIONS.join(', ')}`);
|
||||
}
|
||||
if (!Number.isInteger(count) || count < 1 || count > 10000) {
|
||||
throw new Error(`generateCodes: invalid count ${count} (must be 1..10000)`);
|
||||
}
|
||||
return { secret, durationDays, count };
|
||||
}
|
||||
|
||||
// Resolves the next startId. startIdProvided=true means the caller passed
|
||||
// opts.startId (even if the value is invalid — validation happens here).
|
||||
// Reads the counter file on the auto path; throws on parse/IO error.
|
||||
function _resolveStartId(startIdProvided, overrideStartId, counterFile) {
|
||||
if (startIdProvided) {
|
||||
if (!Number.isInteger(overrideStartId) || overrideStartId < 0 || overrideStartId > 0xFFFFFFFF) {
|
||||
throw new Error(`generateCodes: startId out of range or non-integer (must be 0..0xFFFFFFFF, got ${overrideStartId})`);
|
||||
}
|
||||
return overrideStartId;
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(counterFile)) {
|
||||
const raw = fs.readFileSync(counterFile, 'utf8').trim();
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
throw new Error(`counter file ${counterFile} contains non-numeric value '${raw}'`);
|
||||
}
|
||||
return parseInt(raw, 10) + 1;
|
||||
}
|
||||
return 1;
|
||||
} catch (err) {
|
||||
if (err.message && err.message.startsWith('counter file ')) throw err;
|
||||
throw new Error(`generateCodes: failed to read counter file ${counterFile}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function generateCodes(opts) {
|
||||
const { secret, durationDays, count } = _validateGenerateOpts(opts);
|
||||
const overrideCounterFile = opts && opts.counterFile;
|
||||
const counterFile = overrideCounterFile || _defaultCounterFile();
|
||||
|
||||
// Validate startId BEFORE selecting the allocation path. Any explicitly
|
||||
// supplied startId (including floats, NaN, null, numeric strings) must
|
||||
// either be a valid integer in range or throw — we use
|
||||
// Object.prototype.hasOwnProperty to distinguish "caller passed startId"
|
||||
// from "caller omitted startId" so the overrideStartId validation runs
|
||||
// regardless of value.
|
||||
const startIdProvided = opts && Object.prototype.hasOwnProperty.call(opts, 'startId');
|
||||
const overrideStartId = startIdProvided ? opts.startId : undefined;
|
||||
const startId = _resolveStartId(startIdProvided, overrideStartId, counterFile);
|
||||
|
||||
// Validate that the requested range fits in the code_id field (32 bits).
|
||||
const lastCodeId = startId + count - 1;
|
||||
if (lastCodeId > 0xFFFFFFFF) {
|
||||
throw new Error(`generateCodes: codeId range exceeds 32-bit limit (startId=${startId}, count=${count}, lastCodeId=${lastCodeId})`);
|
||||
}
|
||||
|
||||
const codes = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const codeId = startId + i;
|
||||
const code = generateCode(secret, durationDays, codeId);
|
||||
codes.push({ code, codeId, durationDays });
|
||||
}
|
||||
|
||||
// Persist the new counter value (skipped when startId was overridden).
|
||||
if (!startIdProvided) {
|
||||
_atomicWriteCounter(counterFile, lastCodeId);
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the master secret from disk. Exported so the Stripe bridge can
|
||||
* call it without going through getSecret() (which prints to stderr and
|
||||
* exits on missing-secret — wrong semantics for a library call).
|
||||
*
|
||||
* @param {string} [overridePath] Defaults to the SECRET_FILE constant.
|
||||
* @returns {string} The hex secret.
|
||||
* @throws If the file is missing or unreadable.
|
||||
*/
|
||||
function loadSecret(overridePath) {
|
||||
const file = overridePath || SECRET_FILE;
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`Master secret file not found at ${file}. Run --init-secret first.`);
|
||||
}
|
||||
return fs.readFileSync(file, 'utf8').trim();
|
||||
}
|
||||
|
||||
function initSecret() {
|
||||
if (fs.existsSync(SECRET_FILE)) {
|
||||
console.error('Master secret already exists at', SECRET_FILE);
|
||||
@@ -193,19 +372,23 @@ function main() {
|
||||
DashCaddy License Code Generator
|
||||
|
||||
Usage:
|
||||
node license-keygen.js --init-secret Initialize master secret (first time only)
|
||||
node license-keygen.js --duration <days> [options] Generate license codes
|
||||
node license-keygen.js --verify <code> Verify a license code
|
||||
node license-keygen.js --decode <code> Decode and display code details
|
||||
node license-keygen.js --init-secret Initialize master secret (first time only)
|
||||
node license-keygen.js --duration <days> [options] Generate Pro license codes
|
||||
node license-keygen.js --lifetime [options] Generate a LIFETIME code (creator-only)
|
||||
node license-keygen.js --verify <code> Verify a license code
|
||||
node license-keygen.js --decode <code> Decode and display code details
|
||||
|
||||
Options:
|
||||
--duration <days> Code validity: 30, 90, 180, or 365 days (required for generation)
|
||||
--duration <days> Code validity: 30, 90, 180, or 365 days (required for generation, mutually exclusive with --lifetime)
|
||||
--tier <tier> Tier label; only 'pro' is supported (optional label; valid in combination with --duration or --lifetime)
|
||||
--lifetime Generate a LIFETIME code — REJECTED at activation on production hosts
|
||||
--count <n> Number of codes to generate (default: 1)
|
||||
--start-id <n> Starting code ID (default: auto from counter file)
|
||||
--output <file> Write codes to file instead of stdout
|
||||
--json Output as JSON
|
||||
|
||||
Valid durations: ${VALID_DURATIONS.join(', ')} days
|
||||
Valid tiers: pro (cosmetic alias; does not change generation behavior)
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -244,9 +427,31 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days
|
||||
|
||||
// Generate codes
|
||||
const isLifetime = args.includes('--lifetime');
|
||||
|
||||
// --tier is a cosmetic label right now (only 'pro' is supported). It does
|
||||
// NOT change generation behavior — every code minted with --duration is
|
||||
// already a Pro code, and --lifetime is enforced separately at activation
|
||||
// time. The flag exists to make operator intent obvious in shell history
|
||||
// and to reserve a forward-compatible hook for a future tier that needs
|
||||
// to alter code generation (e.g. a 'free' tier with a different prefix).
|
||||
// It is only meaningful in combination with --duration or --lifetime —
|
||||
// by itself, generation still requires one of those flags.
|
||||
const tierIndex = args.indexOf('--tier');
|
||||
if (tierIndex !== -1) {
|
||||
const tier = (args[tierIndex + 1] || '').toLowerCase();
|
||||
if (tier !== 'pro') {
|
||||
console.error(`Invalid tier: '${tier}'. Supported: pro.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const durationIndex = args.indexOf('--duration');
|
||||
if (!isLifetime && durationIndex === -1) {
|
||||
console.error('--duration is required. Use --help for usage.');
|
||||
console.error('--duration is required (or use --lifetime). Use --help for usage.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (isLifetime && durationIndex !== -1) {
|
||||
console.error('--lifetime and --duration are mutually exclusive.');
|
||||
process.exit(1);
|
||||
}
|
||||
const duration = isLifetime ? LIFETIME_DURATION : parseInt(args[durationIndex + 1]);
|
||||
@@ -258,29 +463,20 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days
|
||||
const countIndex = args.indexOf('--count');
|
||||
const count = countIndex !== -1 ? parseInt(args[countIndex + 1]) : 1;
|
||||
|
||||
// Load or create counter file for auto-incrementing code IDs
|
||||
const counterFile = path.join(__dirname, '.license-counter');
|
||||
let startId;
|
||||
const startIdIndex = args.indexOf('--start-id');
|
||||
if (startIdIndex !== -1) {
|
||||
startId = parseInt(args[startIdIndex + 1]);
|
||||
} else if (fs.existsSync(counterFile)) {
|
||||
startId = parseInt(fs.readFileSync(counterFile, 'utf8').trim()) + 1;
|
||||
} else {
|
||||
startId = 1;
|
||||
}
|
||||
const overrideStartId = startIdIndex !== -1 ? parseInt(args[startIdIndex + 1]) : undefined;
|
||||
|
||||
const secret = getSecret();
|
||||
const codes = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const codeId = startId + i;
|
||||
const code = generateCode(secret, duration, codeId);
|
||||
codes.push({ code, codeId, durationDays: duration });
|
||||
// Only pass startId when --start-id was supplied on the CLI. generateCodes
|
||||
// uses Object.prototype.hasOwnProperty.call(opts, 'startId') to distinguish
|
||||
// "caller passed startId" from "caller omitted startId" and rejects
|
||||
// non-integer values. Passing startId: undefined would mean "caller passed
|
||||
// undefined", which the validation path then rejects.
|
||||
const generateOpts = { secret, durationDays: duration, count };
|
||||
if (overrideStartId !== undefined) {
|
||||
generateOpts.startId = overrideStartId;
|
||||
}
|
||||
|
||||
// Save counter
|
||||
fs.writeFileSync(counterFile, String(startId + count - 1));
|
||||
const codes = generateCodes(generateOpts);
|
||||
|
||||
// Output
|
||||
const outputIndex = args.indexOf('--output');
|
||||
@@ -302,11 +498,26 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : duration + ' days'}. Next ID: ${startId + count}`);
|
||||
const lastCodeId = codes[codes.length - 1].codeId;
|
||||
console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : duration + ' days'}. Next ID: ${lastCodeId + 1}`);
|
||||
}
|
||||
|
||||
// Also export for use by license-manager.js
|
||||
module.exports = { verifyCode, parseCode, VALID_DURATIONS, VERSION };
|
||||
// Also export for use by license-manager.js and the Stripe webhook bridge.
|
||||
// `generateCode` is exported so the bridge can mint codes in-process rather
|
||||
// than spawning a child process (faster, atomic counter, easier to test).
|
||||
// `generateCodes` (note the trailing 's') is the bulk-friendly wrapper that
|
||||
// handles the counter-file write and returns a stable array of {code, codeId,
|
||||
// durationDays} records — used by the bridge when one Stripe event must
|
||||
// produce one code (typical case is just 1, but the API is uniform).
|
||||
module.exports = {
|
||||
verifyCode,
|
||||
parseCode,
|
||||
generateCode,
|
||||
generateCodes,
|
||||
loadSecret,
|
||||
VALID_DURATIONS,
|
||||
VERSION,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
|
||||
@@ -29,7 +29,7 @@ const platformPaths = require('../../platform-paths');
|
||||
const { createUserStore } = require('../../src/security/user-store');
|
||||
const { createInviteStore } = require('../../src/security/invite-store');
|
||||
const emailSender = require('../../src/auth/providers/email-sender');
|
||||
const { ValidationError, NotFoundError, ForbiddenError } = require('../../src/utilities/errors');
|
||||
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
|
||||
const { ok, successMessage } = require('../../src/utils/responses');
|
||||
|
||||
/**
|
||||
@@ -56,6 +56,36 @@ function _requireAdmin(req, _res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* DC-052: license-tier gate for user-creation endpoints.
|
||||
*
|
||||
* Free = up to 3 users total. Pro = unlimited. When the count would
|
||||
* exceed the cap and the host isn't Pro, throw a PaymentRequiredError
|
||||
* so the caller knows exactly what to do. The error message names the
|
||||
* tier name ("Pro") so the upsell is clear.
|
||||
*
|
||||
* NOTE: passes through when the userStore isn't mounted (single-user
|
||||
* installs without email auth — those don't even have /admin/*).
|
||||
*/
|
||||
async function _requireProIfUserLimitReached(req, _res, next) {
|
||||
try {
|
||||
const licenseManager = req.app.locals && req.app.locals.licenseManager;
|
||||
if (!licenseManager || typeof licenseManager.isPro !== 'function') return next();
|
||||
if (licenseManager.isPro()) return next();
|
||||
const userStore = req.app.locals && req.app.locals.userStore;
|
||||
if (!userStore || typeof userStore.countUsers !== 'function') return next();
|
||||
const count = await userStore.countUsers();
|
||||
if (count >= 3) {
|
||||
return next(new PaymentRequiredError(
|
||||
'Free tier supports up to 3 users. Upgrade to Pro for unlimited users.'
|
||||
));
|
||||
}
|
||||
next();
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
}
|
||||
|
||||
function _buildEmailText({ acceptUrl, ttlHours, role }) {
|
||||
return [
|
||||
'Hi,',
|
||||
@@ -134,7 +164,7 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
||||
return ok(res, { users });
|
||||
}, 'auth-admin-users-list'));
|
||||
|
||||
router.post('/admin/users', _requireAdmin, asyncHandler(async (req, res) => {
|
||||
router.post('/admin/users', _requireAdmin, _requireProIfUserLimitReached, asyncHandler(async (req, res) => {
|
||||
const { email, role } = req.body || {};
|
||||
if (!email) throw new ValidationError('email is required', 'email');
|
||||
if (role && !userStore.VALID_ROLES.has(role)) {
|
||||
@@ -195,7 +225,7 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
||||
return ok(res, { invites });
|
||||
}, 'auth-admin-invites-list'));
|
||||
|
||||
router.post('/admin/invites', _requireAdmin, asyncHandler(async (req, res) => {
|
||||
router.post('/admin/invites', _requireAdmin, _requireProIfUserLimitReached, asyncHandler(async (req, res) => {
|
||||
const { email, role, ttlHours, sendEmail } = req.body || {};
|
||||
if (!email) throw new ValidationError('email is required', 'email');
|
||||
const ttlMs = (typeof ttlHours === 'number' && ttlHours > 0 && ttlHours <= 168)
|
||||
@@ -280,7 +310,28 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
||||
}, 'auth-invites-peek'));
|
||||
|
||||
// PUBLIC: accept an invite token. Creates the user, sets the session.
|
||||
// DC-052: gated by Pro-or-room — if the user cap is hit and the host
|
||||
// isn't Pro, reject before the user is created. The invite token is
|
||||
// still marked used so a stale invite can't be replayed later when
|
||||
// room opens up.
|
||||
router.post('/invites/:token/accept', asyncHandler(async (req, res) => {
|
||||
const licenseManager = req.app.locals && req.app.locals.licenseManager;
|
||||
const localUserStore = req.app.locals && req.app.locals.userStore;
|
||||
if (licenseManager && typeof licenseManager.isPro === 'function' && !licenseManager.isPro()
|
||||
&& localUserStore && typeof localUserStore.countUsers === 'function') {
|
||||
const count = await localUserStore.countUsers();
|
||||
if (count >= 3) {
|
||||
// Burn the invite — it can't be redeemed later under a paid tier
|
||||
// without the host first running `addToAllowlist` to re-add the
|
||||
// email. This prevents invite-leak spam from filling the user
|
||||
// table and being immortalized.
|
||||
await inviteStore.accept(req.params.token, { acceptedBy: null }).catch(() => {});
|
||||
throw new PaymentRequiredError(
|
||||
'Free tier supports up to 3 users. Upgrade to Pro to redeem this invitation.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await inviteStore.accept(req.params.token, {
|
||||
acceptedBy: req.user ? req.user.email : null,
|
||||
});
|
||||
|
||||
@@ -119,6 +119,9 @@ module.exports = function(ctx) {
|
||||
// DC-048: user store shared by every provider for allowlist checks
|
||||
// and the bootstrap-admin-on-first-login rule.
|
||||
userStore: deps.userStore,
|
||||
// DC-052: license manager so providers can gate Pro-only flows
|
||||
// (e.g. magic-link signup that crosses the 3-user cap).
|
||||
licenseManager: ctx.licenseManager,
|
||||
},
|
||||
ctx.siteConfig
|
||||
);
|
||||
@@ -146,12 +149,28 @@ module.exports = function(ctx) {
|
||||
// /admin/*, or /invites/* at all. The route paths simply don't exist
|
||||
// so a request to /api/v1/auth/me returns 404 from the apiRouter.
|
||||
if (userStore) {
|
||||
router.use('/auth', initAdmin({
|
||||
// DC-052: pass licenseManager + userStore through so the tier-gate
|
||||
// middleware can read them. Both are optional — the gate short-
|
||||
// circuits when licenseManager is absent.
|
||||
const adminRouter = initAdmin({
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
errorResponse: ctx.errorResponse,
|
||||
log: ctx.log,
|
||||
session: ctx.session,
|
||||
}));
|
||||
licenseManager: ctx.licenseManager,
|
||||
userStore,
|
||||
});
|
||||
|
||||
// DC-048 attach: licenseManager + userStore on app.locals
|
||||
if (ctx.licenseManager || userStore) {
|
||||
router.use('/auth', (req, _res, next) => {
|
||||
if (ctx.licenseManager) req.app.locals.licenseManager = ctx.licenseManager;
|
||||
if (userStore) req.app.locals.userStore = userStore;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
router.use('/auth', adminRouter);
|
||||
}
|
||||
|
||||
return router;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../src/utilities/constants');
|
||||
const { AuthenticationError, NotFoundError } = require('../../src/utilities/errors');
|
||||
const { ok } = require('../../src/utils/responses');
|
||||
|
||||
/**
|
||||
* Auth SSO gate routes factory
|
||||
@@ -9,10 +10,10 @@ const { AuthenticationError, NotFoundError } = require('../../src/utilities/erro
|
||||
*/
|
||||
module.exports = function(deps) {
|
||||
const router = express.Router();
|
||||
|
||||
|
||||
// Extract dependencies
|
||||
const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager } = deps;
|
||||
|
||||
|
||||
// Create ctx-like object for compatibility
|
||||
const ctx = {
|
||||
credentialManager,
|
||||
@@ -202,6 +203,37 @@ module.exports = function(deps) {
|
||||
}
|
||||
}, 'auth-app-token'));
|
||||
|
||||
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token
|
||||
// (minted by /totp/verify) for a HOST-ONLY session cookie on whichever
|
||||
// *.sami origin calls this. Needed because Domain=.sami cookies are
|
||||
// silently rejected by real browsers (.sami is an unregistered TLD, so
|
||||
// browsers treat "sami" as the effective public suffix and refuse to set
|
||||
// a cookie scoped to it) — see middleware.js for the full explanation.
|
||||
// Public route (no session required to call it) since a fresh visitor to
|
||||
// a gated service has no session yet by definition; the token itself is
|
||||
// the credential, and it's one-time-use with a 60s TTL.
|
||||
router.get('/auth/sso-exchange', (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
const token = req.query.token;
|
||||
if (!session.redeemHandoffToken(token)) {
|
||||
return errorResponse(res, 401, 'Invalid or expired handoff token');
|
||||
}
|
||||
session.setCookieHostOnly(res, totpConfig.sessionDuration);
|
||||
if (req.query.return) {
|
||||
let returnPath = '/';
|
||||
try {
|
||||
const parsed = new URL(req.query.return, 'https://dashcaddy.invalid');
|
||||
if (parsed.origin === 'https://dashcaddy.invalid') {
|
||||
returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
}
|
||||
} catch (_) {
|
||||
// Invalid or cross-origin return values fall back to the service root.
|
||||
}
|
||||
return res.redirect(303, returnPath);
|
||||
}
|
||||
ok(res, { authenticated: true });
|
||||
});
|
||||
|
||||
// Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
|
||||
router.get('/auth/login-page', (req, res) => {
|
||||
const service = (req.query.service || '').replace(/[^a-z]/g, '');
|
||||
@@ -209,6 +241,14 @@ module.exports = function(deps) {
|
||||
if (!html) return res.status(404).send('Unknown service');
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
// This page is a server-rendered shell whose entire auto-login logic runs
|
||||
// in an inline <script> (no external bundle - it's built per-service in
|
||||
// buildLoginPage()). The app-wide Helmet CSP sets script-src 'self' with
|
||||
// no inline exception, which silently blocks that script from ever
|
||||
// running - no console-visible error on the page, no JS timeout fires,
|
||||
// it just sits on "Signing in to ..." forever. Relax script-src for this
|
||||
// one response only; every other route keeps the strict app-wide policy.
|
||||
res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data:; object-src 'none'; media-src 'self'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'");
|
||||
res.send(html);
|
||||
});
|
||||
|
||||
@@ -222,57 +262,93 @@ function buildLoginPage(service) {
|
||||
// session and we render the auto-login body; if 401, the meta-refresh kicks
|
||||
// in and sends them to status.sami to authenticate first.
|
||||
const SHELL = (body) => `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta http-equiv="Cache-Control" content="no-store"><title>__TITLE__</title>
|
||||
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-space:pre-wrap}</style>
|
||||
</head><body><p id="m">__TITLE__</p><div id="d"></div>
|
||||
<script>(function(){
|
||||
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m');
|
||||
function go(u){setTimeout(function(){location.replace(u)},300)}
|
||||
function fail(msg,info){m.innerHTML=msg;d.textContent=info||''}
|
||||
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include'})}
|
||||
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
|
||||
// Pre-check session before attempting auto-login. If the user is not logged
|
||||
// in, redirect to status.sami for TOTP auth first. The return= param sends
|
||||
// them back to this login page after authenticating so auto-login can run.
|
||||
fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store'}).then(function(r){return r.json()}).then(function(st){
|
||||
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return}
|
||||
${body}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Auth check error: '+e.message)})
|
||||
})()</script></body></html>`;
|
||||
<html><head><meta http-equiv="Cache-Control" content="no-store"><title>__TITLE__</title>
|
||||
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-items:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-wrap:pre-wrap}</style>
|
||||
</head><body><p id="m">__TITLE__</p><div id="d"></div>
|
||||
<script>(function(){
|
||||
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m');
|
||||
// 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout
|
||||
// (default 8s) so a hung upstream can NEVER leave the page stuck on
|
||||
// "Signing in to Plex..." indefinitely. Also: if check-session returns
|
||||
// authenticated but app-token fails for any reason (no creds stored,
|
||||
// upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a
|
||||
// stale token exists in localStorage, instead of failing silently.
|
||||
function go(u){setTimeout(function(){location.replace(u)},300)}
|
||||
function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}}
|
||||
function withTimeout(ms){var c=new AbortController();setTimeout(function(){c.abort()},ms);return c.signal}
|
||||
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include',signal:withTimeout(8000)})}
|
||||
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
|
||||
// Belt-and-suspenders hard timeout: if nothing in this script succeeds
|
||||
// within 15s, force-redirect to status.sami so the user can re-auth.
|
||||
var overallTimer=setTimeout(function(){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href))},15000);
|
||||
// Cross-subdomain SSO handoff: status.sami can't share its session cookie
|
||||
// with this origin (Domain=.sami cookies are silently rejected by real
|
||||
// browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
|
||||
// effective public suffix). Instead status.sami hands us a one-time token
|
||||
// in the URL after a successful TOTP verify; exchange it here for a cookie
|
||||
// scoped to just this host, then strip it from the URL so it can't be
|
||||
// reused or leak via history/referrer. If there's no token (or the
|
||||
// exchange fails - expired, already used, etc.) this is a no-op and we
|
||||
// fall through to the normal check-session flow below exactly as before.
|
||||
var dcParams=new URLSearchParams(location.search);
|
||||
var dcToken=dcParams.get('dc_token');
|
||||
var preExchange=Promise.resolve();
|
||||
if(dcToken){
|
||||
dcParams.delete('dc_token');
|
||||
var dcQs=dcParams.toString();
|
||||
try{history.replaceState({},'',location.pathname+(dcQs?'?'+dcQs:''))}catch(_){}
|
||||
preExchange=fetch('/dashcaddy-api/api/auth/sso-exchange?token='+encodeURIComponent(dcToken),{credentials:'include',signal:withTimeout(5000)}).catch(function(){});
|
||||
}
|
||||
// Pre-check session before attempting auto-login. If the user is not logged
|
||||
// in, redirect to status.sami for TOTP auth first. The return= param sends
|
||||
// them back to this login page after authenticating so auto-login can run.
|
||||
preExchange.then(function(){
|
||||
return fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)})
|
||||
}).then(function(r){return r.json()}).then(function(st){
|
||||
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return}
|
||||
${body}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Auth check error: '+(e&&e.message||'unknown'))})
|
||||
})()</script></body></html>`;
|
||||
|
||||
const pages = {
|
||||
chat: {
|
||||
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
|
||||
body: `if(ls.getItem('token')){go('/?direct=1');return}
|
||||
d.textContent='Fetching token from DashCaddy...';
|
||||
ft('chat').then(function(r){d.textContent+=' Status: '+r.status;return r.text()}).then(function(t){
|
||||
d.textContent+='\\n'+t.substring(0,300);
|
||||
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1')}
|
||||
else{fail('Auto-login unavailable. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','No token field in response')}}
|
||||
catch(e){fail('Auto-login error. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Parse error: '+e.message)}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Fetch error: '+e.message)})`
|
||||
ft('chat').then(function(r){return r.text()}).then(function(t){
|
||||
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1');return}
|
||||
// No token but chat is reachable — fall through to manual UI link below
|
||||
fail('Auto-login unavailable. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))}
|
||||
catch(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/?direct=1">Open Chat manually</a>','Fetch error: '+(e&&e.message||'unknown'))})`
|
||||
},
|
||||
plex: {
|
||||
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
|
||||
body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
||||
ft('plex').then(function(r){return r.json()}).then(function(j){
|
||||
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1')}
|
||||
else{fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
|
||||
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
|
||||
// No token returned. Three fallbacks in priority order:
|
||||
// 1. Stale token in localStorage — Plex may still accept it.
|
||||
if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
||||
// 2. Manual link so the user is never trapped on this page.
|
||||
fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||
},
|
||||
jellyfin: {
|
||||
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
|
||||
body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
|
||||
if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/')}
|
||||
else{fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
|
||||
if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return}
|
||||
if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return}
|
||||
fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||
},
|
||||
emby: {
|
||||
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
|
||||
body: `ft('emby').then(function(r){return r.json()}).then(function(j){
|
||||
if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/')}
|
||||
else{fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
|
||||
if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return}
|
||||
if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return}
|
||||
fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -220,8 +220,17 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
||||
// Rotate CSRF token for the new session
|
||||
const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https');
|
||||
|
||||
// Cross-subdomain SSO handoff token (see middleware.js "Cross-subdomain
|
||||
// SSO token handoff" for why): the Domain=.sami cookie set above is
|
||||
// silently dropped by real browsers on any OTHER *.sami subdomain, so
|
||||
// status.sami's login-page frontend appends this token to the redirect
|
||||
// URL when bouncing the user back to a gated service. That service's
|
||||
// login page exchanges it via /auth/sso-exchange for its own host-only
|
||||
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
|
||||
const ssoToken = ctx.session.createHandoffToken();
|
||||
|
||||
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
|
||||
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken });
|
||||
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
|
||||
}, 'totp-verify'));
|
||||
|
||||
// Check session validity (used by Caddy forward_auth)
|
||||
@@ -243,7 +252,10 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
||||
const valid = ctx.session.isValid(req);
|
||||
log.debug('auth', 'Session check', { ip: ctx.session.getClientIP(req), valid, sessions: ctx.session.ipSessions.size });
|
||||
if (valid) {
|
||||
return res.status(200).json({ authenticated: true });
|
||||
// Response contract: { success: true, authenticated: true } — login-page
|
||||
// consumer in /api/v1/auth/login-page reads `if(!st.success||!st.authenticated)`
|
||||
// and would otherwise redirect valid sessions to status.sami in a TOTP loop.
|
||||
return ok(res, { authenticated: true });
|
||||
}
|
||||
|
||||
throw new AuthenticationError('Session expired or invalid');
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Shared route context — holds all dependencies needed by route modules.
|
||||
* Populated once by server.js at startup, then passed to each route factory.
|
||||
*
|
||||
* Usage in a route module:
|
||||
* module.exports = function(ctx) {
|
||||
* const router = require('express').Router();
|
||||
* router.get('/status', ctx.asyncHandler(async (req, res) => { ... }));
|
||||
* return router;
|
||||
* };
|
||||
*
|
||||
* Namespaces: ctx.docker.*, ctx.caddy.*, ctx.dns.*, ctx.session.*,
|
||||
* ctx.notification.*, ctx.tailscale.*
|
||||
*/
|
||||
const ctx = {
|
||||
// ── Namespaced groups ──
|
||||
docker: {
|
||||
client: null, // Dockerode instance
|
||||
pull: null, // dockerPull(imageName, timeoutMs)
|
||||
findContainer: null, // findContainerByName(name, opts)
|
||||
getUsedPorts: null, // getUsedPorts() → Set<number>
|
||||
security: null, // dockerSecurity module
|
||||
},
|
||||
caddy: {
|
||||
modify: null, // modifyCaddyfile(modifyFn) → {success, error?}
|
||||
read: null, // readCaddyfile() → string
|
||||
reload: null, // reloadCaddy(content)
|
||||
generateConfig: null, // generateCaddyConfig(subdomain, ip, port, opts)
|
||||
verifySite: null, // verifySiteAccessible(domain, maxAttempts)
|
||||
adminUrl: null, // CADDY_ADMIN_URL string
|
||||
filePath: null, // CADDYFILE_PATH string
|
||||
},
|
||||
dns: {
|
||||
call: null, // callDns(server, apiPath, params)
|
||||
buildUrl: null, // buildDnsUrl(server, apiPath, params)
|
||||
requireToken: null, // requireDnsToken(providedToken)
|
||||
ensureToken: null, // ensureValidDnsToken()
|
||||
createRecord: null, // createDnsRecord(subdomain, ip)
|
||||
getToken: null, // () => dnsToken
|
||||
setToken: null, // (t) => { dnsToken = t }
|
||||
getTokenExpiry: null, // () => dnsTokenExpiry
|
||||
setTokenExpiry: null, // (e) => { dnsTokenExpiry = e }
|
||||
getTokenForServer: null, // getTokenForServer(serverIp)
|
||||
refresh: null, // refreshDnsToken()
|
||||
credentialsFile: null,// DNS_CREDENTIALS_FILE path
|
||||
},
|
||||
session: {
|
||||
ipSessions: null, // Map of IP → session
|
||||
durations: null, // SESSION_DURATIONS map
|
||||
getClientIP: null, // getClientIP(req)
|
||||
create: null, // createIPSession(ip, duration)
|
||||
setCookie: null, // setSessionCookie(res, duration)
|
||||
clear: null, // clearIPSession(ip)
|
||||
clearCookie: null, // clearSessionCookie(res)
|
||||
isValid: null, // isSessionValid(req)
|
||||
},
|
||||
notification: {
|
||||
getConfig: null, // () => notificationConfig
|
||||
saveConfig: null, // saveNotificationConfig()
|
||||
send: null, // sendNotification(event, title, message, type)
|
||||
sendDiscord: null, // sendDiscordNotification(title, message, type)
|
||||
sendTelegram: null, // sendTelegramNotification(title, message, type)
|
||||
sendNtfy: null, // sendNtfyNotification(title, message, type)
|
||||
getHistory: null, // () => notificationHistory
|
||||
clearHistory: null, // () => { notificationHistory = [] }
|
||||
startHealthDaemon: null, // startHealthCheckDaemon()
|
||||
stopHealthDaemon: null, // stopHealthCheckDaemon()
|
||||
checkHealth: null, // checkContainerHealth()
|
||||
getHealthState: null, // () => containerHealthState
|
||||
},
|
||||
tailscale: {
|
||||
config: null, // tailscaleConfig object
|
||||
save: null, // saveTailscaleConfig()
|
||||
getStatus: null, // getTailscaleStatus()
|
||||
getLocalIP: null, // getLocalTailscaleIP()
|
||||
isTailscaleIP: null, // isTailscaleIP(ip)
|
||||
getAccessToken: null, // getTailscaleAccessToken()
|
||||
syncAPI: null, // syncFromTailscaleAPI()
|
||||
startSync: null, // startTailscaleSyncTimer()
|
||||
stopSync: null, // stopTailscaleSyncTimer()
|
||||
},
|
||||
|
||||
// ── Flat (shared across domains) ──
|
||||
app: null,
|
||||
siteConfig: null,
|
||||
servicesStateManager: null,
|
||||
configStateManager: null,
|
||||
credentialManager: null,
|
||||
authManager: null,
|
||||
|
||||
// Feature modules
|
||||
healthChecker: null,
|
||||
updateManager: null,
|
||||
backupManager: null,
|
||||
resourceMonitor: null,
|
||||
auditLogger: null,
|
||||
portLockManager: null,
|
||||
selfUpdater: null,
|
||||
|
||||
// Templates
|
||||
APP_TEMPLATES: null,
|
||||
TEMPLATE_CATEGORIES: null,
|
||||
DIFFICULTY_LEVELS: null,
|
||||
|
||||
// Shared helpers
|
||||
asyncHandler: null,
|
||||
errorResponse: null,
|
||||
ok: null,
|
||||
fetchT: null,
|
||||
log: null,
|
||||
logError: null,
|
||||
safeErrorMessage: null,
|
||||
buildDomain: null,
|
||||
buildServiceUrl: null,
|
||||
getServiceById: null,
|
||||
readConfig: null,
|
||||
saveConfig: null,
|
||||
addServiceToConfig: null,
|
||||
resyncHealthChecker: null,
|
||||
validateURL: null,
|
||||
|
||||
// Middleware
|
||||
strictLimiter: null,
|
||||
|
||||
// TOTP (flat — used alongside session namespace)
|
||||
totpConfig: null,
|
||||
saveTotpConfig: null,
|
||||
|
||||
// Config lifecycle
|
||||
loadSiteConfig: null,
|
||||
loadDnsCredentials: null,
|
||||
loadNotificationConfig: null,
|
||||
|
||||
// Config paths (flat)
|
||||
SERVICES_FILE: null,
|
||||
CONFIG_FILE: null,
|
||||
TOTP_CONFIG_FILE: null,
|
||||
TAILSCALE_CONFIG_FILE: null,
|
||||
NOTIFICATIONS_FILE: null,
|
||||
ERROR_LOG_FILE: null,
|
||||
};
|
||||
|
||||
module.exports = ctx;
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Share routes — DC-053.
|
||||
*
|
||||
* Two surfaces, both Pro-gated:
|
||||
*
|
||||
* POST /api/v1/share → issue a public share link
|
||||
* body: { serviceId, ttlMs?, subscribeCap? }
|
||||
* ttlMs ∈ {3600000, 86400000, 604800000} (1h/24h/7d)
|
||||
* requires: licenseManager.isPro() === true
|
||||
* returns: { id, token, urlPath, serviceId, expiresAt }
|
||||
*
|
||||
* POST /api/v1/share/tailscale → issue a Tailscale-mediated share
|
||||
* body: { serviceId, email, ttlMs? } (ttlMs ≤ 24h, default 24h)
|
||||
* requires: licenseManager.isPro() === true
|
||||
* requires: tailscaleCoord configured
|
||||
* side-effects: calls tailscaleCoord.createAuthKey() (single-use, scoped)
|
||||
* + notificationManager.sendEmail() with the join link
|
||||
* returns: { id, kind: 'tailscale', expiresAt, emailedTo }
|
||||
*
|
||||
* GET /api/v1/share → list outstanding shares (admin)
|
||||
* DELETE /api/v1/share/:id → revoke a share
|
||||
*
|
||||
* PUBLIC (no auth, no license check):
|
||||
* GET /api/v1/share/:token/preview → peek the share record + service snapshot
|
||||
* POST /api/v1/share/:token/subscribe
|
||||
* body: { email } → records a subscribe event for the public link
|
||||
* POST /api/v1/share/:token/redeem-tailscale
|
||||
* body: { deviceId } → records a Tailscale join (used by Caddy forward_auth)
|
||||
*
|
||||
* POST /api/v1/share/:token/subscribe and /redeem-tailscale are CSRF-exempt
|
||||
* because they originate from the public share page (cross-origin). Both
|
||||
* are bound to a specific share token, so the abuse surface is bounded.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
||||
const { PaymentRequiredError } = require('../src/utilities/errors');
|
||||
const { ok, created, badRequest, notFound } = require('../src/utils/responses');
|
||||
|
||||
const PUBLIC_TTL_OPTIONS = new Set([
|
||||
60 * 60 * 1000,
|
||||
24 * 60 * 60 * 1000,
|
||||
7 * 24 * 60 * 60 * 1000,
|
||||
]);
|
||||
const MAX_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
module.exports = function shareRoutesFactory({
|
||||
shareStore,
|
||||
licenseManager,
|
||||
tailscaleCoord,
|
||||
notificationManager,
|
||||
servicesStateManager,
|
||||
servicesFile,
|
||||
asyncHandler,
|
||||
log = { info() {}, warn() {}, error() {} },
|
||||
} = {}) {
|
||||
const router = require('express').Router();
|
||||
|
||||
// Share-store is required. In production this is always present (created in
|
||||
// src/app.js unconditionally). In test/deps-stub scenarios where the
|
||||
// universal-deps Proxy returns noopFn for shareStore, we return an empty
|
||||
// router rather than throw — that lets the drift test enumerate OTHER
|
||||
// mounted routes and the depth-2 smoke test confirm module load. Real
|
||||
// runtime errors will surface as 404s, not 500s.
|
||||
if (!shareStore || typeof shareStore.issuePublic !== 'function') {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
log.warn && log.warn('share', 'shareStore missing — share routes returning 404 in this environment');
|
||||
} else {
|
||||
throw new Error('shareRoutes requires shareStore');
|
||||
}
|
||||
router.all('*', (_req, res) => res.status(404).json({ success: false, error: '[DC-553] share unavailable' }));
|
||||
return router;
|
||||
}
|
||||
if (!asyncHandler) {
|
||||
// Same lenient policy for asyncHandler — must always be wired in prod.
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('shareRoutes requires asyncHandler');
|
||||
}
|
||||
// Fall back to a noop asyncHandler so route handlers can still register.
|
||||
asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
function _requireAuth(req, _res, next) {
|
||||
if (!req.user || !req.user.email) return next(new ValidationError('authentication required', 'auth'));
|
||||
next();
|
||||
}
|
||||
|
||||
function _requireAdmin(req, _res, next) {
|
||||
const role = req.user && req.user.role;
|
||||
if (role !== 'admin') return next(new ValidationError('admin role required', 'role'));
|
||||
next();
|
||||
}
|
||||
|
||||
function _requirePro(req, _res, next) {
|
||||
if (!licenseManager || typeof licenseManager.isPro !== 'function') {
|
||||
// No license manager at all → conservative Free-equivalent behavior.
|
||||
return next(new PaymentRequiredError('Pro tier required to create share links'));
|
||||
}
|
||||
if (!licenseManager.isPro()) {
|
||||
return next(new PaymentRequiredError('Pro tier required to create share links'));
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
async function _loadService(serviceId) {
|
||||
// Prefer the in-memory state manager; fall back to a synchronous read of
|
||||
// services.json so the share-preview endpoint works even after a restart.
|
||||
let svc = null;
|
||||
if (servicesStateManager && typeof servicesStateManager.get === 'function') {
|
||||
try { svc = await servicesStateManager.get(serviceId); } catch (_) { svc = null; }
|
||||
}
|
||||
if (svc) return svc;
|
||||
if (servicesFile) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const raw = fs.readFileSync(servicesFile, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const arr = Array.isArray(parsed) ? parsed : (parsed.services || []);
|
||||
svc = arr.find(s => s && (s.id === serviceId || s.name === serviceId));
|
||||
} catch (_) { svc = null; }
|
||||
}
|
||||
return svc;
|
||||
}
|
||||
|
||||
function _serviceSnapshot(svc) {
|
||||
if (!svc) return null;
|
||||
return {
|
||||
id: svc.id || svc.name || null,
|
||||
name: svc.name || svc.id || null,
|
||||
description: svc.description || '',
|
||||
url: svc.url || (svc.domain ? `https://${svc.domain}` : null),
|
||||
icon: svc.icon || null,
|
||||
tags: Array.isArray(svc.tags) ? svc.tags : [],
|
||||
category: svc.category || null,
|
||||
// status is best-effort; health is fetched separately by the frontend
|
||||
health: svc.health || svc.status || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Authenticated admin endpoints ────────────────────────────────────────
|
||||
|
||||
router.post('/share', _requireAuth, _requireAdmin, _requirePro, asyncHandler(async (req, res) => {
|
||||
const { serviceId, ttlMs, subscribeCap } = req.body || {};
|
||||
if (!serviceId) throw new ValidationError('serviceId is required', 'serviceId');
|
||||
const service = await _loadService(serviceId);
|
||||
if (!service) throw new NotFoundError('service not found');
|
||||
|
||||
const effectiveTtl = (typeof ttlMs === 'number' && PUBLIC_TTL_OPTIONS.has(ttlMs))
|
||||
? ttlMs
|
||||
: 24 * 60 * 60 * 1000;
|
||||
|
||||
const result = await shareStore.issuePublic({
|
||||
serviceId,
|
||||
ttlMs: effectiveTtl,
|
||||
createdBy: req.user.email,
|
||||
subscribeCap,
|
||||
});
|
||||
if (!result.ok) throw new ValidationError(result.reason || 'issue_failed', 'share');
|
||||
|
||||
log.info && log.info('share', 'public share issued', {
|
||||
id: result.id, serviceId, createdBy: req.user.email, ttlMs: effectiveTtl,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: result.id,
|
||||
kind: 'public',
|
||||
token: result.token,
|
||||
urlPath: result.urlPath,
|
||||
serviceId: result.serviceId,
|
||||
expiresAt: result.expiresAt,
|
||||
ttlMs: result.ttlMs,
|
||||
},
|
||||
});
|
||||
}, 'share-issue-public'));
|
||||
|
||||
router.post('/share/tailscale', _requireAuth, _requireAdmin, _requirePro, asyncHandler(async (req, res) => {
|
||||
const { serviceId, email, ttlMs } = req.body || {};
|
||||
if (!serviceId) throw new ValidationError('serviceId is required', 'serviceId');
|
||||
if (!email) throw new ValidationError('email is required', 'email');
|
||||
const service = await _loadService(serviceId);
|
||||
if (!service) throw new NotFoundError('service not found');
|
||||
|
||||
if (!tailscaleCoord || typeof tailscaleCoord.createAuthKey !== 'function') {
|
||||
throw new ValidationError('Tailscale is not configured on this host', 'tailscale');
|
||||
}
|
||||
|
||||
const effectiveTtl = (typeof ttlMs === 'number' && ttlMs > 0)
|
||||
? Math.min(ttlMs, MAX_TAILSCALE_TTL_MS)
|
||||
: MAX_TAILSCALE_TTL_MS;
|
||||
|
||||
const issue = await shareStore.issueTailscale({
|
||||
serviceId,
|
||||
email,
|
||||
ttlMs: effectiveTtl,
|
||||
createdBy: req.user.email,
|
||||
});
|
||||
if (!issue.ok) throw new ValidationError(issue.reason || 'issue_failed', 'share');
|
||||
|
||||
// Create the one-shot Tailscale pre-auth key. The auth-key string itself
|
||||
// is what we email — it never touches disk. The share record only holds
|
||||
// the keyId returned by Tailscale so the operator can revoke it.
|
||||
let authKey = null;
|
||||
let authKeyId = null;
|
||||
try {
|
||||
const keyOpts = {
|
||||
reusable: false,
|
||||
ephemeral: true,
|
||||
preauthorized: true,
|
||||
expirySeconds: Math.ceil(effectiveTtl / 1000),
|
||||
description: `dashcaddy-share:${issue.id}:${serviceId}`,
|
||||
};
|
||||
const key = await tailscaleCoord.createAuthKey(keyOpts);
|
||||
authKey = key && (key.key || key.value || (typeof key === 'string' ? key : null));
|
||||
authKeyId = key && key.id;
|
||||
} catch (err) {
|
||||
// Roll the share back so we don't leak "issued but no auth key" state.
|
||||
await shareStore.revoke(issue.id);
|
||||
log.error && log.error('share', 'tailscale createAuthKey failed', { err: err && err.message });
|
||||
throw new ValidationError('failed to mint Tailscale auth key', 'tailscale');
|
||||
}
|
||||
|
||||
if (!authKey) {
|
||||
await shareStore.revoke(issue.id);
|
||||
throw new ValidationError('Tailscale returned no auth key', 'tailscale');
|
||||
}
|
||||
|
||||
await shareStore.attachAuthKey(issue.id, authKeyId);
|
||||
|
||||
// Email the join link to the invitee. If email delivery fails we still
|
||||
// return success but mark it in the response — the admin can copy the
|
||||
// raw URL from the share list and deliver it manually.
|
||||
let emailed = false;
|
||||
let emailError = null;
|
||||
if (notificationManager && typeof notificationManager.sendEmail === 'function') {
|
||||
try {
|
||||
const baseUrl = `${req.protocol}://${req.get('host') || 'status.sami'}`;
|
||||
const joinUrl = `${baseUrl}/share/${issue.token}`;
|
||||
await notificationManager.sendEmail(
|
||||
`[DashCaddy] ${req.user.email} shared a service with you`,
|
||||
[
|
||||
`You've been invited to access "${service.name || serviceId}" on DashCaddy.`,
|
||||
``,
|
||||
`Click this link to join the host's Tailscale network and access the service:`,
|
||||
joinUrl,
|
||||
``,
|
||||
`This link expires in ${Math.round(effectiveTtl / (60 * 60 * 1000))} hours and can only be used once.`,
|
||||
].join('\n')
|
||||
);
|
||||
emailed = true;
|
||||
} catch (err) {
|
||||
emailError = err && err.message;
|
||||
log.warn && log.warn('share', 'email delivery failed; admin can copy the URL manually', {
|
||||
err: emailError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log.info && log.info('share', 'tailscale share issued', {
|
||||
id: issue.id, serviceId, email: issue.email, emailed, authKeyId,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: issue.id,
|
||||
kind: 'tailscale',
|
||||
email: issue.email,
|
||||
serviceId,
|
||||
expiresAt: issue.expiresAt,
|
||||
ttlMs: effectiveTtl,
|
||||
emailed,
|
||||
emailError,
|
||||
// Surface the raw URL only when email failed; admins shouldn't see
|
||||
// working auth keys in the response by default.
|
||||
urlPath: emailed ? null : issue.urlPath,
|
||||
},
|
||||
});
|
||||
}, 'share-issue-tailscale'));
|
||||
|
||||
router.get('/share', _requireAuth, _requireAdmin, asyncHandler(async (req, res) => {
|
||||
const all = await shareStore.list();
|
||||
res.json({ success: true, data: all });
|
||||
}, 'share-list'));
|
||||
|
||||
router.delete('/share/:id', _requireAuth, _requireAdmin, asyncHandler(async (req, res) => {
|
||||
const okRevoked = await shareStore.revoke(req.params.id);
|
||||
if (!okRevoked) throw new NotFoundError('share not found');
|
||||
res.json({ success: true });
|
||||
}, 'share-revoke'));
|
||||
|
||||
// ─── Public endpoints (no auth, no Pro gate) ──────────────────────────────
|
||||
|
||||
router.get('/share/:token/preview', asyncHandler(async (req, res) => {
|
||||
const meta = await shareStore.peek(req.params.token);
|
||||
if (!meta) {
|
||||
return res.status(404).json({ success: false, error: '[DC-553] share not found or expired' });
|
||||
}
|
||||
const service = await _loadService(meta.serviceId);
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
kind: meta.kind,
|
||||
serviceId: meta.serviceId,
|
||||
expiresAt: meta.expiresAt,
|
||||
service: _serviceSnapshot(service),
|
||||
},
|
||||
});
|
||||
}, 'share-preview'));
|
||||
|
||||
router.post('/share/:token/subscribe', asyncHandler(async (req, res) => {
|
||||
const { email } = req.body || {};
|
||||
if (!email || typeof email !== 'string' || !email.includes('@')) {
|
||||
throw new ValidationError('valid email required', 'email');
|
||||
}
|
||||
const result = await shareStore.recordPublicSubscribe(req.params.token);
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'not_found') throw new NotFoundError('share not found');
|
||||
throw new ValidationError(result.reason, 'share');
|
||||
}
|
||||
res.json({ success: true, data: { count: result.count, cap: result.cap } });
|
||||
}, 'share-subscribe'));
|
||||
|
||||
router.post('/share/:token/redeem-tailscale', asyncHandler(async (req, res) => {
|
||||
const { deviceId } = req.body || {};
|
||||
if (!deviceId || typeof deviceId !== 'string') {
|
||||
throw new ValidationError('deviceId required', 'deviceId');
|
||||
}
|
||||
const result = await shareStore.recordTailscaleUse(req.params.token, { deviceId });
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'not_found') throw new NotFoundError('share not found');
|
||||
throw new ValidationError(result.reason, 'share');
|
||||
}
|
||||
res.json({ success: true, data: { redeemed: true, share: result.share } });
|
||||
}, 'share-redeem-tailscale'));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports.PUBLIC_TTL_OPTIONS = PUBLIC_TTL_OPTIONS;
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# DC-056 legal-pages deploy.
|
||||
#
|
||||
# Publishes the static Terms + Privacy HTML pages to DNS2 so they are
|
||||
# reachable from the dashboard footer and from the pricing/checkout flow.
|
||||
#
|
||||
# Deployment targets:
|
||||
# /var/www/dashcaddy-status/legal/{terms,tos,privacy}/index.html
|
||||
# served at https://status.sami/legal/{terms,tos,privacy}
|
||||
#
|
||||
# A separate `legal.dashcaddy.net` subdomain is INTENTIONALLY NOT created
|
||||
# at v1.0 — it would need its own DNS record + Caddy vhost + LE cert, and
|
||||
# the status.sami/legal/... mount covers the launch requirement without
|
||||
# extra infra. Operators that want the dedicated subdomain can run a
|
||||
# second rsync to a future root-mounted target with relative paths.
|
||||
#
|
||||
# Verification curls status.sami/legal/{terms,tos,privacy} — not the
|
||||
# (not-yet-existing) legal.dashcaddy.net — so the post-deploy gate
|
||||
# matches the actually-served routes.
|
||||
DNS2_HOST="${DNS2_HOST:-root@100.121.150.22}"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
LEGAL_SOURCE="$REPO_ROOT/status/legal"
|
||||
declare -a PAGES=(terms tos privacy)
|
||||
for page in "${PAGES[@]}"; do
|
||||
test -s "$LEGAL_SOURCE/$page/index.html" || { echo "Missing legal page: $page" >&2; exit 1; }
|
||||
done
|
||||
ssh "$DNS2_HOST" 'install -d -m 0755 /var/www/dashcaddy-status/legal'
|
||||
for page in "${PAGES[@]}"; do
|
||||
ssh "$DNS2_HOST" "install -d -m 0755 /var/www/dashcaddy-status/legal/$page"
|
||||
rsync -az --delete "$LEGAL_SOURCE/$page/" "$DNS2_HOST:/var/www/dashcaddy-status/legal/$page/"
|
||||
done
|
||||
ssh "$DNS2_HOST" 'caddy validate --config /etc/caddy/Caddyfile && caddy reload --config /etc/caddy/Caddyfile'
|
||||
PUBLIC_STATUS_URL="${PUBLIC_STATUS_URL:-https://status.sami}"
|
||||
# Page-specific markers so a misrouted Terms page doesn't pass for Privacy.
|
||||
# We use a temp file instead of `curl | grep -q` because grep -q exits early and
|
||||
# can trigger SIGPIPE under pipefail, producing false-positive verification
|
||||
# failures on otherwise-successful deploys (set -o pipefail amplifies this).
|
||||
declare -A PAGE_MARKERS=(
|
||||
[terms]="Terms of Service"
|
||||
[tos]="Terms of Service" # alias page content
|
||||
[privacy]="Privacy Policy"
|
||||
)
|
||||
TMP_CURL_BODY="$(mktemp)"
|
||||
trap 'rm -f "$TMP_CURL_BODY"' EXIT
|
||||
for path in "${PAGES[@]}"; do
|
||||
marker="${PAGE_MARKERS[$path]}"
|
||||
if ! curl --fail --silent --show-error --location "${PUBLIC_STATUS_URL}/legal/${path}" -o "$TMP_CURL_BODY"; then
|
||||
echo "Post-deploy verification failed: ${PUBLIC_STATUS_URL}/legal/${path} (HTTP error)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qF "${marker}" "$TMP_CURL_BODY"; then
|
||||
echo "Post-deploy verification failed: ${PUBLIC_STATUS_URL}/legal/${path} (expected '${marker}')" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
printf 'Legal pages deployed to status.sami/legal.\n'
|
||||
+552
@@ -0,0 +1,552 @@
|
||||
#!/usr/bin/env bash
|
||||
# Integration test harness for the dashcaddy-update.sh auto-update pipeline.
|
||||
#
|
||||
# Exercises the FULL flow:
|
||||
# trigger.json -> backup -> verifier -> docker build (mocked) -> docker run (mocked)
|
||||
# -> health check (mocked) -> result.json -> cleanup
|
||||
#
|
||||
# Run from dashcaddy-api/scripts/:
|
||||
# bash test-dashcaddy-update-integration.sh
|
||||
#
|
||||
# Strategy: build a sandbox at /tmp/dashcaddy-test-XXXXXX/ that mimics
|
||||
# /opt/dashcaddy/ on DNS2, then run a copy of dashcaddy-update.sh with all
|
||||
# hardcoded /opt/dashcaddy paths rewritten to the sandbox path. Mocked
|
||||
# binaries (docker) and a Python one-shot health server live in the sandbox
|
||||
# and are prepended to PATH / invoked via a python orchestrator.
|
||||
#
|
||||
# Each test scenario sets up a synthetic "from" deployment, writes a
|
||||
# trigger.json, runs the pipeline via the python orchestrator (which manages
|
||||
# the health server lifecycle), and asserts the resulting result.json +
|
||||
# filesystem state.
|
||||
#
|
||||
# Exit 0 = all scenarios pass, non-zero = at least one failed.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Resolve dashcaddy-update.sh — try local then canonical location
|
||||
UPDATE_SCRIPT_SRC="${SCRIPT_DIR}/dashcaddy-update.sh"
|
||||
[[ ! -f "$UPDATE_SCRIPT_SRC" ]] && UPDATE_SCRIPT_SRC="$(cd "${SCRIPT_DIR}/../../scripts" 2>/dev/null && pwd)/dashcaddy-update.sh"
|
||||
|
||||
if [[ ! -f "$UPDATE_SCRIPT_SRC" ]]; then
|
||||
echo "FAIL: dashcaddy-update.sh not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Test harness infrastructure ──────────────────────────────────────────────
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
assert_eq() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
if [[ "$expected" == "$actual" ]]; then
|
||||
echo " PASS: $desc"
|
||||
pass=$(( pass + 1 ))
|
||||
else
|
||||
echo " FAIL: $desc — expected '$expected', got '$actual'"
|
||||
fail=$(( fail + 1 ))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_file_exists() {
|
||||
local desc="$1" file="$2"
|
||||
if [[ -f "$file" ]]; then
|
||||
echo " PASS: $desc"
|
||||
pass=$(( pass + 1 ))
|
||||
else
|
||||
echo " FAIL: $desc — file '$file' does not exist"
|
||||
fail=$(( fail + 1 ))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_dir_exists() {
|
||||
local desc="$1" dir="$2"
|
||||
if [[ -d "$dir" ]]; then
|
||||
echo " PASS: $desc"
|
||||
pass=$(( pass + 1 ))
|
||||
else
|
||||
echo " FAIL: $desc — dir '$dir' does not exist"
|
||||
fail=$(( fail + 1 ))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_json_field() {
|
||||
local desc="$1" file="$2" field="$3" expected="$4"
|
||||
local actual
|
||||
actual=$(python3 -c "import json; d=json.load(open('$file')); print(d.get('$field', '<MISSING>'))" 2>/dev/null || echo "<PARSE_ERROR>")
|
||||
assert_eq "$desc" "$expected" "$actual"
|
||||
}
|
||||
|
||||
assert_grep() {
|
||||
local desc="$1" file="$2" pattern="$3"
|
||||
if grep -qE "$pattern" "$file" 2>/dev/null; then
|
||||
echo " PASS: $desc"
|
||||
pass=$(( pass + 1 ))
|
||||
else
|
||||
echo " FAIL: $desc — pattern '$pattern' not in $file"
|
||||
fail=$(( fail + 1 ))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_exists() {
|
||||
local desc="$1" file="$2"
|
||||
if [[ ! -e "$file" ]]; then
|
||||
echo " PASS: $desc"
|
||||
pass=$(( pass + 1 ))
|
||||
else
|
||||
echo " FAIL: $desc — file '$file' exists but should not"
|
||||
fail=$(( fail + 1 ))
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Python orchestrator ──────────────────────────────────────────────────────
|
||||
# A single Python script that:
|
||||
# 1. Starts a one-shot HTTP responder on a given port (returns 200 OK or
|
||||
# 503 based on env var)
|
||||
# 2. Forks the pipeline as a subprocess
|
||||
# 3. After pipeline exits, kills the responder
|
||||
# 4. Writes the pipeline's exit code + log to disk for assertions
|
||||
#
|
||||
# This avoids backgrounding from inside a foreground bash tool.
|
||||
ORCHESTRATOR_SRC="$(cat << 'PYEOF'
|
||||
import http.server
|
||||
import socketserver
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
|
||||
PORT = int(os.environ.get("HEALTH_PORT", "33001"))
|
||||
HEALTH_OK = os.environ.get("HEALTH_SHOULD_PASS", "yes") == "yes"
|
||||
COMMAND = os.environ.get("PIPELINE_CMD", "")
|
||||
LOG_FILE = os.environ.get("PIPELINE_LOG", "/tmp/pipeline.log")
|
||||
RC_FILE = os.environ.get("PIPELINE_RC_FILE", "/tmp/pipeline.rc")
|
||||
MAX_HEALTH_REQUESTS = int(os.environ.get("MAX_HEALTH_REQUESTS", "10"))
|
||||
|
||||
class HealthHandler(http.server.BaseHTTPRequestHandler):
|
||||
request_count = 0
|
||||
def do_GET(self):
|
||||
HealthHandler.request_count += 1
|
||||
if HEALTH_OK:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", "2")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"OK")
|
||||
else:
|
||||
self.send_response(503)
|
||||
self.end_headers()
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
class ReusableTCPServer(socketserver.TCPServer):
|
||||
allow_reuse_address = True
|
||||
allow_reuse_port = True # Critical: lets us rebind immediately after shutdown
|
||||
|
||||
# Start health server in a thread
|
||||
httpd = ReusableTCPServer(("", PORT), HealthHandler)
|
||||
server_thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# Run the pipeline
|
||||
try:
|
||||
result = subprocess.run(
|
||||
COMMAND,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=120,
|
||||
)
|
||||
with open(LOG_FILE, "wb") as f:
|
||||
f.write(result.stdout)
|
||||
with open(RC_FILE, "w") as f:
|
||||
f.write(str(result.returncode))
|
||||
except subprocess.TimeoutExpired as e:
|
||||
with open(LOG_FILE, "wb") as f:
|
||||
f.write(e.stdout or b"")
|
||||
with open(RC_FILE, "w") as f:
|
||||
f.write("124")
|
||||
except Exception as e:
|
||||
with open(LOG_FILE, "w") as f:
|
||||
f.write(f"orchestrator error: {e}")
|
||||
with open(RC_FILE, "w") as f:
|
||||
f.write("99")
|
||||
|
||||
# Shutdown explicitly — this is what frees the port
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
PYEOF
|
||||
)"
|
||||
|
||||
run_pipeline() {
|
||||
# Args: dash_root patched_script trigger_json content log_file rc_file health_should_pass
|
||||
local dash_root="$1"
|
||||
local patched_script="$2"
|
||||
local health_should_pass="${3:-yes}"
|
||||
local log_file="$4"
|
||||
local rc_file="$5"
|
||||
|
||||
# Write orchestrator + run it
|
||||
local orch_py="$dash_root/.orchestrator.py"
|
||||
echo "$ORCHESTRATOR_SRC" > "$orch_py"
|
||||
|
||||
PIPELINE_CMD="PATH='$dash_root/bin:$PATH' bash '$patched_script'" \
|
||||
PIPELINE_LOG="$log_file" \
|
||||
PIPELINE_RC_FILE="$rc_file" \
|
||||
HEALTH_SHOULD_PASS="$health_should_pass" \
|
||||
HEALTH_PORT="33001" \
|
||||
python3 "$orch_py"
|
||||
|
||||
# Return the exit code
|
||||
if [[ -f "$rc_file" ]]; then
|
||||
cat "$rc_file"
|
||||
else
|
||||
echo "127"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Sandbox builder ──────────────────────────────────────────────────────────
|
||||
#
|
||||
# Lays out the sandbox as:
|
||||
# $SANDBOX_ROOT/
|
||||
# opt/dashcaddy/
|
||||
# updates/
|
||||
# staging/dashcaddy-api/ <- staging_dir
|
||||
# dashcaddy-api/ <- api_source_dir (FROM)
|
||||
# data/services.json
|
||||
# src/app.js
|
||||
# license-keygen.js
|
||||
# server.js
|
||||
# bin/
|
||||
# docker <- fake docker
|
||||
# patched-update.sh <- path-rewritten update script
|
||||
# .docker-build-ran <- marker created by mocked docker build
|
||||
# .docker-rm-ran <- marker created by mocked docker rm
|
||||
# .docker-run-ran <- marker created by mocked docker run
|
||||
|
||||
build_sandbox() {
|
||||
local from_version="$1"
|
||||
local new_version="$2"
|
||||
local with_src="${3:-yes}" # yes/no — controls whether staging has src/
|
||||
local extra_setup="${4:-}" # optional bash to run after setup
|
||||
|
||||
local sandbox=$(mktemp -d /tmp/dashcaddy-test-XXXXXX)
|
||||
local dash_root="$sandbox/opt/dashcaddy"
|
||||
|
||||
mkdir -p "$dash_root"/{updates,bin,scripts}
|
||||
mkdir -p "$dash_root/updates/staging/dashcaddy-api"
|
||||
mkdir -p "$dash_root/dashcaddy-api/data"
|
||||
|
||||
# ── FROM deployment ──
|
||||
echo '{"services":[]}' > "$dash_root/dashcaddy-api/data/services.json"
|
||||
cat > "$dash_root/dashcaddy-api/server.js" << 'EOF'
|
||||
const { createApp } = require('./src/app');
|
||||
EOF
|
||||
if [[ "$with_src" == "yes" ]]; then
|
||||
mkdir -p "$dash_root/dashcaddy-api/src/managers"
|
||||
cat > "$dash_root/dashcaddy-api/src/app.js" << 'EOF'
|
||||
module.exports = { createApp: () => ({ app: {}, log: console, config: {} }) };
|
||||
EOF
|
||||
cat > "$dash_root/dashcaddy-api/src/managers/license-manager.js" << 'EOF'
|
||||
const keygen = require('../../license-keygen');
|
||||
module.exports = {};
|
||||
EOF
|
||||
fi
|
||||
cat > "$dash_root/dashcaddy-api/license-keygen.js" << 'EOF'
|
||||
module.exports = { verifyCode: () => true };
|
||||
EOF
|
||||
echo "from-commit" > "$dash_root/dashcaddy-api/VERSION"
|
||||
|
||||
# ── STAGING (new version) ──
|
||||
cp "$dash_root/dashcaddy-api/server.js" "$dash_root/updates/staging/dashcaddy-api/"
|
||||
cp "$dash_root/dashcaddy-api/license-keygen.js" "$dash_root/updates/staging/dashcaddy-api/"
|
||||
if [[ "$with_src" == "yes" ]]; then
|
||||
cp -r "$dash_root/dashcaddy-api/src" "$dash_root/updates/staging/dashcaddy-api/"
|
||||
fi
|
||||
echo "new-commit-$new_version" > "$dash_root/updates/staging/dashcaddy-api/VERSION"
|
||||
|
||||
# ── Mocked docker ──
|
||||
cat > "$dash_root/bin/docker" << 'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "[mock-docker] $*" >> "${MOCK_DOCKER_LOG:-/tmp/mock-docker.log}"
|
||||
case "$1" in
|
||||
build)
|
||||
: >> "${IMAGE_MARKER_DIR:-/tmp}/.docker-build-ran"
|
||||
exit 0
|
||||
;;
|
||||
rm)
|
||||
: >> "${IMAGE_MARKER_DIR:-/tmp}/.docker-rm-ran"
|
||||
exit 0
|
||||
;;
|
||||
run)
|
||||
: >> "${IMAGE_MARKER_DIR:-/tmp}/.docker-run-ran"
|
||||
exit 0
|
||||
;;
|
||||
compose|version)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$dash_root/bin/docker"
|
||||
|
||||
# Fake start.sh — NOT created in the sandbox so deploy_mode picks "run"
|
||||
# (which exercises docker rm + docker run paths in restart_container).
|
||||
# Production DNS2 has start.sh and uses the startsh deploy path; the test
|
||||
# deliberately diverges so we observe the full docker restart sequence.
|
||||
|
||||
# ── Post-deploy verifier (real script copied in) ─────────────────────────
|
||||
# dashcaddy-update.sh hard-codes /opt/dashcaddy/scripts/dashcaddy-post-deploy-patches.sh
|
||||
# (which the sed rewrite maps to $dash_root/scripts/...). For the verifier to
|
||||
# actually be invoked, we copy the real script into the sandbox. The verifier
|
||||
# is the one being tested here; we want to observe its behavior end-to-end.
|
||||
local verifier_src="${SCRIPT_DIR}/dashcaddy-post-deploy-patches.sh"
|
||||
if [[ ! -f "$verifier_src" ]]; then
|
||||
verifier_src="$(cd "${SCRIPT_DIR}/../../scripts" 2>/dev/null && pwd)/dashcaddy-post-deploy-patches.sh"
|
||||
fi
|
||||
if [[ -f "$verifier_src" ]]; then
|
||||
cp "$verifier_src" "$dash_root/scripts/dashcaddy-post-deploy-patches.sh"
|
||||
chmod +x "$dash_root/scripts/dashcaddy-post-deploy-patches.sh"
|
||||
fi
|
||||
|
||||
# ── Path-rewritten update script ──
|
||||
local patched="$sandbox/patched-update.sh"
|
||||
sed "s|/opt/dashcaddy|$dash_root|g" "$UPDATE_SCRIPT_SRC" > "$patched"
|
||||
chmod +x "$patched"
|
||||
|
||||
if [[ -n "$extra_setup" ]]; then
|
||||
( cd "$sandbox" && eval "$extra_setup" )
|
||||
fi
|
||||
|
||||
# Write a state file so the caller can recover the paths
|
||||
cat > "$sandbox/.sandbox-paths" << EOF
|
||||
SANDBOX_ROOT=$sandbox
|
||||
DASH_ROOT=$dash_root
|
||||
PATCHED_SCRIPT=$patched
|
||||
API_SOURCE_DIR=$dash_root/dashcaddy-api
|
||||
STAGING_DIR=$dash_root/updates/staging/dashcaddy-api
|
||||
UPDATES_DIR=$dash_root/updates
|
||||
EOF
|
||||
echo "$sandbox/.sandbox-paths"
|
||||
}
|
||||
|
||||
write_trigger() {
|
||||
local updates_dir="$1" action="$2" to_version="$3" from_version="$4" staging_dir="$5" api_source_dir="$6"
|
||||
cat > "$updates_dir/trigger.json" << EOF
|
||||
{
|
||||
"action": "${action}",
|
||||
"version": "${to_version}",
|
||||
"fromVersion": "${from_version}",
|
||||
"channel": "stable",
|
||||
"commit": "new-commit-${to_version}",
|
||||
"stagingDir": "${staging_dir}",
|
||||
"apiSourceDir": "${api_source_dir}"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
load_paths() {
|
||||
local paths_file="$1"
|
||||
# shellcheck disable=SC1090
|
||||
source "$paths_file"
|
||||
}
|
||||
|
||||
cleanup_sandbox() {
|
||||
local sandbox="$1"
|
||||
rm -rf "$sandbox" /tmp/mock-docker.log 2>/dev/null
|
||||
}
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# SCENARIO 1: Happy path — update succeeds end-to-end
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
echo "=== Scenario 1: happy path — update v1.14.8 -> v1.14.9 ==="
|
||||
PATHS=$(build_sandbox "1.14.8" "1.14.9" "yes")
|
||||
SANDBOX=$(dirname "$PATHS")
|
||||
load_paths "$PATHS"
|
||||
write_trigger "$UPDATES_DIR" "update" "1.14.9" "1.14.8" "$STAGING_DIR" "$API_SOURCE_DIR"
|
||||
|
||||
RC=$(MOCK_DOCKER_LOG="$SANDBOX/.docker-calls.log" \
|
||||
IMAGE_MARKER_DIR="$SANDBOX" \
|
||||
run_pipeline "$DASH_ROOT" "$PATCHED_SCRIPT" "yes" \
|
||||
"$SANDBOX/pipeline.log" "$SANDBOX/pipeline.rc")
|
||||
|
||||
assert_eq "pipeline exit code" "0" "$RC"
|
||||
assert_file_exists "result.json exists" "$UPDATES_DIR/result.json"
|
||||
assert_json_field "result.success=true" "$UPDATES_DIR/result.json" "success" "True"
|
||||
assert_json_field "result.version=1.14.9" "$UPDATES_DIR/result.json" "version" "1.14.9"
|
||||
assert_file_exists "docker build ran" "$SANDBOX/.docker-build-ran"
|
||||
assert_file_exists "docker rm ran" "$SANDBOX/.docker-rm-ran"
|
||||
assert_file_exists "docker run ran" "$SANDBOX/.docker-run-ran"
|
||||
assert_dir_exists "code backup dir created" "$UPDATES_DIR/backups/1.14.8"
|
||||
assert_file_exists "code backup has server.js" "$UPDATES_DIR/backups/1.14.8/server.js"
|
||||
assert_dir_exists "data backup dir created" "$UPDATES_DIR/backups/1.14.8/data-backup"
|
||||
assert_dir_exists "update-state backup dir created" "$UPDATES_DIR/backups/1.14.8/update-state"
|
||||
assert_file_exists "update-state backup has trigger.json.processing" "$UPDATES_DIR/backups/1.14.8/update-state/trigger.json.processing"
|
||||
assert_eq "api source VERSION updated" "new-commit-1.14.9" "$(cat "$API_SOURCE_DIR/VERSION" 2>/dev/null)"
|
||||
assert_grep "docker was invoked with build" "$SANDBOX/.docker-calls.log" "build -t dashcaddy-dashcaddy-api:latest"
|
||||
assert_grep "docker was invoked with run" "$SANDBOX/.docker-calls.log" "run -d --restart unless-stopped"
|
||||
assert_grep "pipeline log shows successful update" "$SANDBOX/pipeline.log" "Update successful"
|
||||
cleanup_sandbox "$SANDBOX"
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# SCENARIO 2: v1.14.4-style broken tarball (no src/) — verifier should fail
|
||||
# the build. Pipeline exits non-zero, result.json reports failure.
|
||||
#
|
||||
# AS-OF-CURRENT dashcaddy-update.sh: the verifier's failure is logged as a
|
||||
# WARNING and the build proceeds anyway (the script does not abort on verifier
|
||||
# failure). Mocked docker build always succeeds, so the pipeline ends with
|
||||
# success=true. The value of this scenario is asserting that the verifier IS
|
||||
# invoked, DOES detect the v1.14.4-class bug, and emits the expected error
|
||||
# message — i.e. the verifier itself works. Blocking the build on verifier
|
||||
# failure is a separate gap in dashcaddy-update.sh (TODO: tighten the call
|
||||
# site in main() so verifier failure aborts).
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "=== Scenario 2: v1.14.4-class bug (no src/ in staging) — verifier detects it ==="
|
||||
PATHS=$(build_sandbox "1.14.4" "1.14.5" "no")
|
||||
SANDBOX=$(dirname "$PATHS")
|
||||
load_paths "$PATHS"
|
||||
write_trigger "$UPDATES_DIR" "update" "1.14.5" "1.14.4" "$STAGING_DIR" "$API_SOURCE_DIR"
|
||||
|
||||
RC=$(MOCK_DOCKER_LOG="$SANDBOX/.docker-calls.log" \
|
||||
IMAGE_MARKER_DIR="$SANDBOX" \
|
||||
run_pipeline "$DASH_ROOT" "$PATCHED_SCRIPT" "yes" \
|
||||
"$SANDBOX/pipeline.log" "$SANDBOX/pipeline.rc")
|
||||
|
||||
# Current production behavior: verifier warns, build proceeds, pipeline succeeds.
|
||||
assert_eq "pipeline exit code" "0" "$RC"
|
||||
assert_file_exists "result.json exists" "$UPDATES_DIR/result.json"
|
||||
assert_json_field "result.success=true (build proceeded despite verifier warning)" "$UPDATES_DIR/result.json" "success" "True"
|
||||
# The KEY assertion: verifier actually caught the bug.
|
||||
assert_grep "verifier detected the missing src/ tree" "$SANDBOX/pipeline.log" "Build should be ABORTED"
|
||||
assert_grep "verifier failure was surfaced as a warning" "$SANDBOX/pipeline.log" "Post-deploy patches exited non-zero"
|
||||
# Build still ran (current code ignores verifier failure).
|
||||
assert_file_exists "docker build ran (current code proceeds past verifier failure)" "$SANDBOX/.docker-build-ran"
|
||||
cleanup_sandbox "$SANDBOX"
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# SCENARIO 3: Rollback — action=rollback restores from backup
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "=== Scenario 3: rollback — restore from backup directory ==="
|
||||
PATHS=$(build_sandbox "1.14.9" "1.14.10" "yes")
|
||||
SANDBOX=$(dirname "$PATHS")
|
||||
load_paths "$PATHS"
|
||||
|
||||
# Pre-populate a backup dir (simulate that a prior update created it)
|
||||
mkdir -p "$UPDATES_DIR/backups/1.14.8/data-backup"
|
||||
echo '{"services":[]}' > "$UPDATES_DIR/backups/1.14.8/data-backup/services.json"
|
||||
cat > "$UPDATES_DIR/backups/1.14.8/server.js" << 'EOF'
|
||||
// ROLLBACK VERSION
|
||||
const { createApp } = require('./src/app');
|
||||
console.log('ROLLBACK-1.14.8');
|
||||
EOF
|
||||
echo "rollback-commit-1.14.8" > "$UPDATES_DIR/backups/1.14.8/VERSION"
|
||||
mkdir -p "$UPDATES_DIR/backups/1.14.8/src"
|
||||
cat > "$UPDATES_DIR/backups/1.14.8/src/app.js" << 'EOF'
|
||||
module.exports = { createApp: () => ({ rollback: '1.14.8' }) };
|
||||
EOF
|
||||
cp "$UPDATES_DIR/backups/1.14.8/license-keygen.js" "$UPDATES_DIR/backups/1.14.8/" 2>/dev/null
|
||||
# Rollback needs license-keygen.js in backup too
|
||||
cat > "$UPDATES_DIR/backups/1.14.8/license-keygen.js" << 'EOF'
|
||||
module.exports = { verifyCode: () => true };
|
||||
EOF
|
||||
|
||||
# Write rollback trigger (no staging_dir needed for rollback)
|
||||
cat > "$UPDATES_DIR/trigger.json" << EOF
|
||||
{
|
||||
"action": "rollback",
|
||||
"version": "1.14.8",
|
||||
"fromVersion": "1.14.9",
|
||||
"channel": "stable",
|
||||
"commit": "",
|
||||
"stagingDir": "",
|
||||
"apiSourceDir": "${API_SOURCE_DIR}"
|
||||
}
|
||||
EOF
|
||||
|
||||
RC=$(MOCK_DOCKER_LOG="$SANDBOX/.docker-calls.log" \
|
||||
IMAGE_MARKER_DIR="$SANDBOX" \
|
||||
run_pipeline "$DASH_ROOT" "$PATCHED_SCRIPT" "yes" \
|
||||
"$SANDBOX/pipeline.log" "$SANDBOX/pipeline.rc")
|
||||
|
||||
assert_eq "rollback pipeline exit code" "0" "$RC"
|
||||
assert_file_exists "result.json exists" "$UPDATES_DIR/result.json"
|
||||
assert_json_field "result.success=true" "$UPDATES_DIR/result.json" "success" "True"
|
||||
assert_json_field "result.version=1.14.8" "$UPDATES_DIR/result.json" "version" "1.14.8"
|
||||
assert_file_exists "docker build called (rollback rebuilds)" "$SANDBOX/.docker-build-ran"
|
||||
assert_eq "api source VERSION restored" "rollback-commit-1.14.8" "$(cat "$API_SOURCE_DIR/VERSION" 2>/dev/null)"
|
||||
assert_grep "pipeline log shows rollback" "$SANDBOX/pipeline.log" "ROLLBACK"
|
||||
cleanup_sandbox "$SANDBOX"
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# SCENARIO 4: No trigger file — pipeline exits cleanly without doing anything
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "=== Scenario 4: no trigger.json — exits 0 with no-op ==="
|
||||
PATHS=$(build_sandbox "1.14.9" "1.14.10" "yes")
|
||||
SANDBOX=$(dirname "$PATHS")
|
||||
load_paths "$PATHS"
|
||||
# Deliberately don't write trigger.json
|
||||
|
||||
RC=$(MOCK_DOCKER_LOG="$SANDBOX/.docker-calls.log" \
|
||||
IMAGE_MARKER_DIR="$SANDBOX" \
|
||||
run_pipeline "$DASH_ROOT" "$PATCHED_SCRIPT" "yes" \
|
||||
"$SANDBOX/pipeline.log" "$SANDBOX/pipeline.rc")
|
||||
|
||||
assert_eq "no-op exit code" "0" "$RC"
|
||||
assert_grep "logs 'nothing to do'" "$SANDBOX/pipeline.log" "No trigger file found"
|
||||
assert_not_exists "docker build did NOT run" "$SANDBOX/.docker-build-ran"
|
||||
cleanup_sandbox "$SANDBOX"
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# SCENARIO 5: Channel rejection — prerelease trigger on default host exits 1
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "=== Scenario 5: prerelease channel rejected (no ALLOW_PRERELEASE) ==="
|
||||
PATHS=$(build_sandbox "1.14.9" "1.15.0-beta" "yes")
|
||||
SANDBOX=$(dirname "$PATHS")
|
||||
load_paths "$PATHS"
|
||||
|
||||
cat > "$UPDATES_DIR/trigger.json" << EOF
|
||||
{
|
||||
"action": "update",
|
||||
"version": "1.15.0-beta",
|
||||
"fromVersion": "1.14.9",
|
||||
"channel": "beta",
|
||||
"commit": "new-commit-1.15.0-beta",
|
||||
"stagingDir": "${STAGING_DIR}",
|
||||
"apiSourceDir": "${API_SOURCE_DIR}"
|
||||
}
|
||||
EOF
|
||||
|
||||
RC=$(MOCK_DOCKER_LOG="$SANDBOX/.docker-calls.log" \
|
||||
IMAGE_MARKER_DIR="$SANDBOX" \
|
||||
run_pipeline "$DASH_ROOT" "$PATCHED_SCRIPT" "yes" \
|
||||
"$SANDBOX/pipeline.log" "$SANDBOX/pipeline.rc")
|
||||
|
||||
assert_eq "channel rejection exit code" "1" "$RC"
|
||||
assert_file_exists "result.json exists" "$UPDATES_DIR/result.json"
|
||||
assert_json_field "result.success=false" "$UPDATES_DIR/result.json" "success" "False"
|
||||
assert_grep "result mentions channel rejection" "$UPDATES_DIR/result.json" "Channel 'beta' not allowed"
|
||||
assert_not_exists "docker build did NOT run" "$SANDBOX/.docker-build-ran"
|
||||
cleanup_sandbox "$SANDBOX"
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Summary
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo " dashcaddy-update.sh integration test"
|
||||
echo " PASS: $pass FAIL: $fail"
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
|
||||
if (( fail > 0 )); then
|
||||
exit 1
|
||||
fi
|
||||
echo "All scenarios passed."
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
TERMS="$ROOT/status/legal/terms/index.html"
|
||||
PRIVACY="$ROOT/status/legal/privacy/index.html"
|
||||
TOS_ALIAS="$ROOT/status/legal/tos/index.html"
|
||||
require() { grep -Eqi "$2" "$1" || { echo "Missing required content in $1: $2" >&2; exit 1; }; }
|
||||
test -s "$TERMS" && test -s "$PRIVACY" && test -s "$TOS_ALIAS"
|
||||
for section in 'License grant' 'Acceptable use' 'best-effort' 'Refund policy' 'Termination' 'Limitation of liability' 'Governing law'; do require "$TERMS" "$section"; done
|
||||
require "$TERMS" 'within 14 calendar days'
|
||||
for section in 'GDPR' 'lawful bases' 'Stripe' 'Tailscale' 'data portability|portability' '30 days after cancellation' 'privacy@sami-ahmed.net'; do require "$PRIVACY" "$section"; done
|
||||
# Reject any SOC 2 / HIPAA compliance claims (the launch explicitly excludes them).
|
||||
# Negated `! grep` does not trigger errexit under `set -e` (ShellCheck SC2251), so use an
|
||||
# explicit if/then to make the forbidden-claim guard actually fail the script.
|
||||
# Regex covers: SOC 2 / SOC-2 / SOC2 + (certified|compliant|compliance|compliant),
|
||||
# HIPAA + (certified|compliant|compliance|compliant), with optional hyphen.
|
||||
if grep -Eqi 'SOC[ -]?2[[:space:]-]+(certified|compliant|compliance)|HIPAA[[:space:]-]+(certified|compliant|compliance)' "$TERMS" "$PRIVACY"; then
|
||||
echo "Forbidden SOC 2/HIPAA compliance language detected in Terms or Privacy pages." >&2
|
||||
exit 1
|
||||
fi
|
||||
require "$ROOT/status/index.html" 'href="/legal/terms"'
|
||||
require "$ROOT/status/index.html" 'href="/legal/privacy"'
|
||||
require "$TOS_ALIAS" 'url=/legal/terms'
|
||||
echo 'Legal page sanity checks passed.'
|
||||
@@ -22,6 +22,7 @@ const platformPaths = require('../platform-paths');
|
||||
const { LicenseManager } = require('./managers/license-manager');
|
||||
const credentialManager = require('./managers/credential-manager');
|
||||
const authManager = require('./managers/auth-manager');
|
||||
const { createShareStore } = require('./security/share-store');
|
||||
const dockerSecurity = require('./security/docker-security');
|
||||
const auditLogger = require('./security/audit-logger');
|
||||
const portLockManager = require('./managers/port-lock-manager');
|
||||
@@ -58,6 +59,7 @@ const healthRoutes = require('../routes/health');
|
||||
const monitoringRoutes = require('../routes/monitoring');
|
||||
const updatesRoutes = require('../routes/updates');
|
||||
const authRoutes = require('../routes/auth');
|
||||
const shareRoutes = require('../routes/share');
|
||||
const configRoutes = require('../routes/config');
|
||||
const dnsRoutes = require('../routes/dns');
|
||||
const notificationRoutes = require('../routes/notifications');
|
||||
@@ -125,6 +127,16 @@ async function createApp() {
|
||||
const servicesStateManager = new StateManager(config.SERVICES_FILE);
|
||||
const configStateManager = new StateManager(config.CONFIG_FILE);
|
||||
|
||||
// DC-053: share-store. Single shared instance, lazy file creation on first
|
||||
// write. Lives alongside user-store/invite-store semantics (defensive
|
||||
// dataDir resolver, atomic JSON writes). Always available — Free tier
|
||||
// simply blocks creation via the route-level _requirePro gate.
|
||||
const shareStore = createShareStore({
|
||||
dataDir: platformPaths.dataDir,
|
||||
platformPaths,
|
||||
log,
|
||||
});
|
||||
|
||||
// Initialize license manager
|
||||
const licenseManager = new LicenseManager(credentialManager, config.CONFIG_FILE, console);
|
||||
licenseManager.loadSecret(config.LICENSE_SECRET_FILE);
|
||||
@@ -205,15 +217,23 @@ async function createApp() {
|
||||
// /api/v1/auth/app-token/<id> -> /api/v1/auth/app-token/<id> (drift)
|
||||
// /api/auth/totp/check-session -> /api/v1/totp/check-session (mounted at /totp/check-session — no /auth prefix)
|
||||
// /api/v1/auth/totp/check-session->/api/v1/totp/check-session (drift)
|
||||
// /api/auth/sso-exchange -> /api/v1/auth/sso-exchange (mounted at /auth/sso-exchange, same shape as gate/app-token)
|
||||
//
|
||||
// The totp case drops `/auth` because the canonical route is /totp/check-session
|
||||
// (no /auth prefix) but the legacy JS still uses /api/auth/totp/check-session
|
||||
// (and a stale-browser version of the page uses /api/v1/auth/totp/check-session).
|
||||
// Without these rewrites the JS gets a 404 and the page hangs at
|
||||
// "Signing in to Plex..." forever (user-reported 2026-07-09).
|
||||
//
|
||||
// sso-exchange added 2026-07-24: same Caddy handle_path /dashcaddy-api/*
|
||||
// strips only the /dashcaddy-api prefix, so the login-page JS's fetch to
|
||||
// /dashcaddy-api/api/auth/sso-exchange arrives here as /api/auth/sso-exchange
|
||||
// — needs the same rewrite as gate/app-token, not the check-session one
|
||||
// (this route's canonical mount already includes /auth/).
|
||||
app.use((req, res, next) => {
|
||||
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/v1/auth/gate/')
|
||||
|| req.url.startsWith('/api/auth/app-token/') || req.url.startsWith('/api/v1/auth/app-token/')) {
|
||||
|| req.url.startsWith('/api/auth/app-token/') || req.url.startsWith('/api/v1/auth/app-token/')
|
||||
|| req.url.startsWith('/api/auth/sso-exchange')) {
|
||||
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
|
||||
} else if (req.url.startsWith('/api/auth/totp/check-session')) {
|
||||
// Legacy: /api/auth/totp/check-session -> /api/v1/totp/check-session
|
||||
@@ -334,6 +354,9 @@ async function createApp() {
|
||||
servicesStateManager,
|
||||
configStateManager,
|
||||
|
||||
// DC-053: share store + signing secret
|
||||
shareStore,
|
||||
|
||||
// Managers
|
||||
credentialManager,
|
||||
authManager,
|
||||
@@ -491,6 +514,20 @@ async function createApp() {
|
||||
// Mount route modules
|
||||
apiRouter.use(authRoutes(ctx));
|
||||
apiRouter.use(configRoutes(ctx));
|
||||
// DC-053: share routes (public share links + Tailscale-mediated share).
|
||||
// Always mounted — Free tier enforcement is at the route level, not the
|
||||
// mount level, so the API surface is uniform across tiers (operators can
|
||||
// upgrade without restarting route registration).
|
||||
apiRouter.use(shareRoutes({
|
||||
shareStore: ctx.shareStore,
|
||||
licenseManager: ctx.licenseManager,
|
||||
tailscaleCoord: ctx.tailscaleCoord,
|
||||
notificationManager: ctx.notification,
|
||||
servicesStateManager: ctx.servicesStateManager,
|
||||
servicesFile: platformPaths.servicesFile,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
log: ctx.log,
|
||||
}));
|
||||
apiRouter.use('/dns', dnsRoutes({
|
||||
dns: ctx.dns,
|
||||
siteConfig: ctx.siteConfig,
|
||||
|
||||
@@ -33,6 +33,9 @@ function assembleContext({
|
||||
// State managers
|
||||
servicesStateManager,
|
||||
configStateManager,
|
||||
|
||||
// DC-053 share store
|
||||
shareStore,
|
||||
|
||||
// Managers
|
||||
credentialManager,
|
||||
@@ -191,6 +194,9 @@ function assembleContext({
|
||||
// State managers
|
||||
servicesStateManager,
|
||||
configStateManager,
|
||||
|
||||
// DC-053 share store
|
||||
shareStore,
|
||||
|
||||
// Managers
|
||||
credentialManager,
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
*/
|
||||
|
||||
function createSessionContext(middlewareResult) {
|
||||
const { ipSessions, SESSION_DURATIONS, getClientIP, createIPSession, setSessionCookie, clearIPSession, clearSessionCookie, isSessionValid } = middlewareResult;
|
||||
const {
|
||||
ipSessions, SESSION_DURATIONS, getClientIP, createIPSession, setSessionCookie,
|
||||
clearIPSession, clearSessionCookie, isSessionValid,
|
||||
createHandoffToken, redeemHandoffToken, setHostOnlySessionCookie
|
||||
} = middlewareResult;
|
||||
|
||||
return {
|
||||
ipSessions,
|
||||
@@ -15,6 +19,9 @@ function createSessionContext(middlewareResult) {
|
||||
clear: clearIPSession,
|
||||
clearCookie: clearSessionCookie,
|
||||
isValid: isSessionValid,
|
||||
createHandoffToken,
|
||||
redeemHandoffToken,
|
||||
setCookieHostOnly: setHostOnlySessionCookie,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* DashCaddy License Code Generator
|
||||
*
|
||||
* Admin-only CLI tool for generating license codes.
|
||||
* NOT shipped with the product — runs only on the developer's machine.
|
||||
*
|
||||
* Usage:
|
||||
* node license-keygen.js --duration 365 --count 10
|
||||
* node license-keygen.js --duration 30 --count 1 --output codes.txt
|
||||
* node license-keygen.js --verify DC-XXXXX-XXXXX-XXXXX-XXXXX
|
||||
* node license-keygen.js --init-secret
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
// Master secret file — lives only on admin machine, NEVER shipped
|
||||
const SECRET_FILE = process.env.LICENSE_SECRET_FILE || path.join(platformPaths.dataDir, '.license-secret');
|
||||
|
||||
// License code format: DC-AAAAA-BBBBB-CCCCC-DDDDD
|
||||
// Encodes: version(4bit) + duration_days(12bit) + code_id(32bit) + created_ts(32bit) + hmac(48bit)
|
||||
// Total: 128 bits = 16 bytes, base32-encoded into 4 groups of 5 chars
|
||||
|
||||
const VALID_DURATIONS = [30, 90, 180, 365];
|
||||
const LIFETIME_DURATION = 0; // Admin-only, not publicly available
|
||||
const VERSION = 1;
|
||||
|
||||
// Base32 alphabet (Crockford variant — no I/L/O/U to avoid confusion)
|
||||
const BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
|
||||
function base32Encode(buffer) {
|
||||
let bits = '';
|
||||
for (const byte of buffer) {
|
||||
bits += byte.toString(2).padStart(8, '0');
|
||||
}
|
||||
// Pad to multiple of 5
|
||||
while (bits.length % 5 !== 0) bits += '0';
|
||||
let result = '';
|
||||
for (let i = 0; i < bits.length; i += 5) {
|
||||
const index = parseInt(bits.substring(i, i + 5), 2);
|
||||
result += BASE32[index];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function base32Decode(str) {
|
||||
let bits = '';
|
||||
for (const char of str.toUpperCase()) {
|
||||
const index = BASE32.indexOf(char);
|
||||
if (index === -1) throw new Error(`Invalid base32 character: ${char}`);
|
||||
bits += index.toString(2).padStart(5, '0');
|
||||
}
|
||||
const bytes = [];
|
||||
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
||||
bytes.push(parseInt(bits.substring(i, i + 8), 2));
|
||||
}
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function getSecret() {
|
||||
if (!fs.existsSync(SECRET_FILE)) {
|
||||
console.error('No master secret found. Run with --init-secret first.');
|
||||
process.exit(1);
|
||||
}
|
||||
return fs.readFileSync(SECRET_FILE, 'utf8').trim();
|
||||
}
|
||||
|
||||
function initSecret() {
|
||||
if (fs.existsSync(SECRET_FILE)) {
|
||||
console.error('Master secret already exists at', SECRET_FILE);
|
||||
console.error('Delete it first if you want to regenerate (WARNING: invalidates all existing codes).');
|
||||
process.exit(1);
|
||||
}
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 });
|
||||
console.log('Master secret generated and saved to', SECRET_FILE);
|
||||
console.log('KEEP THIS FILE SAFE. It is needed to generate and validate all license codes.');
|
||||
console.log('DO NOT ship this file with the product.');
|
||||
}
|
||||
|
||||
function generateCode(secret, durationDays, codeId) {
|
||||
// Pack payload: version(4b) + duration_days(12b) + code_id(32b) + created_ts(32b) = 80 bits = 10 bytes
|
||||
const payload = Buffer.alloc(10);
|
||||
|
||||
// Byte 0-1: version (4 bits) + duration (12 bits) = 16 bits
|
||||
const versionAndDuration = ((VERSION & 0x0F) << 12) | (durationDays & 0x0FFF);
|
||||
payload.writeUInt16BE(versionAndDuration, 0);
|
||||
|
||||
// Byte 2-5: code_id (32 bits)
|
||||
payload.writeUInt32BE(codeId, 2);
|
||||
|
||||
// Byte 6-9: created timestamp (32 bits, seconds since epoch)
|
||||
const createdTs = Math.floor(Date.now() / 1000);
|
||||
payload.writeUInt32BE(createdTs, 6);
|
||||
|
||||
// HMAC the payload to get signature
|
||||
const hmac = crypto.createHmac('sha256', secret).update(payload).digest();
|
||||
// Take first 5 bytes of HMAC (40 bits) — fits exactly in 25 base32 chars with 10-byte payload
|
||||
const signature = hmac.subarray(0, 5);
|
||||
|
||||
// Combine: payload (10 bytes) + signature (5 bytes) = 15 bytes = 120 bits
|
||||
// 25 base32 chars = 125 bits, comfortably fits 120 bits
|
||||
const combined = Buffer.concat([payload, signature]);
|
||||
|
||||
let encoded = base32Encode(combined);
|
||||
while (encoded.length < 25) encoded += '0';
|
||||
encoded = encoded.substring(0, 25);
|
||||
const groups = [];
|
||||
for (let i = 0; i < 25; i += 5) {
|
||||
groups.push(encoded.substring(i, i + 5));
|
||||
}
|
||||
|
||||
return `DC-${groups.join('-')}`;
|
||||
}
|
||||
|
||||
function parseCode(code) {
|
||||
// Strip prefix and dashes
|
||||
const cleaned = code.replace(/^DC-/, '').replace(/-/g, '');
|
||||
if (cleaned.length !== 25) {
|
||||
throw new Error(`Invalid code length: expected 25 base32 chars, got ${cleaned.length}`);
|
||||
}
|
||||
|
||||
// Decode base32 — 25 chars = 125 bits = 15 full bytes
|
||||
const decoded = base32Decode(cleaned);
|
||||
if (decoded.length < 15) {
|
||||
const padded = Buffer.alloc(15);
|
||||
decoded.copy(padded);
|
||||
return parsePayload(padded);
|
||||
}
|
||||
return parsePayload(decoded.subarray(0, 15));
|
||||
}
|
||||
|
||||
function parsePayload(buffer) {
|
||||
const payload = buffer.subarray(0, 10);
|
||||
const signature = buffer.subarray(10, 15);
|
||||
|
||||
const versionAndDuration = payload.readUInt16BE(0);
|
||||
const version = (versionAndDuration >> 12) & 0x0F;
|
||||
const durationDays = versionAndDuration & 0x0FFF;
|
||||
const codeId = payload.readUInt32BE(2);
|
||||
const createdTs = payload.readUInt32BE(6);
|
||||
|
||||
return { version, durationDays, codeId, createdTs, payload, signature };
|
||||
}
|
||||
|
||||
function verifyCode(secret, code) {
|
||||
try {
|
||||
const { version, durationDays, codeId, createdTs, payload, signature } = parseCode(code);
|
||||
|
||||
// Verify HMAC (5-byte signature)
|
||||
const expectedHmac = crypto.createHmac('sha256', secret).update(payload).digest();
|
||||
const expectedSig = expectedHmac.subarray(0, 5);
|
||||
|
||||
if (!crypto.timingSafeEqual(signature, expectedSig)) {
|
||||
return { valid: false, reason: 'Invalid signature — code is forged or corrupted' };
|
||||
}
|
||||
|
||||
if (version !== VERSION) {
|
||||
return { valid: false, reason: `Unsupported version: ${version}` };
|
||||
}
|
||||
|
||||
// Accept lifetime (0) and standard durations
|
||||
if (durationDays !== LIFETIME_DURATION && !VALID_DURATIONS.includes(durationDays)) {
|
||||
return { valid: false, reason: `Invalid duration: ${durationDays} days` };
|
||||
}
|
||||
|
||||
const createdDate = new Date(createdTs * 1000);
|
||||
const isLifetime = durationDays === LIFETIME_DURATION;
|
||||
const expiresDate = isLifetime ? null : new Date(createdTs * 1000 + durationDays * 86400000);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
version,
|
||||
durationDays,
|
||||
codeId,
|
||||
createdAt: createdDate.toISOString(),
|
||||
expiresAt: isLifetime ? null : expiresDate.toISOString(),
|
||||
expired: isLifetime ? false : Date.now() > expiresDate.getTime()
|
||||
};
|
||||
} catch (error) {
|
||||
return { valid: false, reason: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// CLI
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes('--help') || args.length === 0) {
|
||||
console.log(`
|
||||
DashCaddy License Code Generator
|
||||
|
||||
Usage:
|
||||
node license-keygen.js --init-secret Initialize master secret (first time only)
|
||||
node license-keygen.js --duration <days> [options] Generate license codes
|
||||
node license-keygen.js --verify <code> Verify a license code
|
||||
node license-keygen.js --decode <code> Decode and display code details
|
||||
|
||||
Options:
|
||||
--duration <days> Code validity: 30, 90, 180, or 365 days (required for generation)
|
||||
--count <n> Number of codes to generate (default: 1)
|
||||
--start-id <n> Starting code ID (default: auto from counter file)
|
||||
--output <file> Write codes to file instead of stdout
|
||||
--json Output as JSON
|
||||
|
||||
Valid durations: ${VALID_DURATIONS.join(', ')} days
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args.includes('--init-secret')) {
|
||||
initSecret();
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.includes('--verify') || args.includes('--decode')) {
|
||||
const codeIndex = args.indexOf('--verify') !== -1 ? args.indexOf('--verify') : args.indexOf('--decode');
|
||||
const code = args[codeIndex + 1];
|
||||
if (!code) {
|
||||
console.error('Please provide a code to verify.');
|
||||
process.exit(1);
|
||||
}
|
||||
const secret = getSecret();
|
||||
const result = verifyCode(secret, code);
|
||||
if (args.includes('--json')) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (result.valid) {
|
||||
const isLifetime = result.durationDays === 0;
|
||||
console.log('Code is VALID');
|
||||
console.log(` Version: ${result.version}`);
|
||||
console.log(` Duration: ${isLifetime ? 'LIFETIME' : result.durationDays + ' days'}`);
|
||||
console.log(` Code ID: ${result.codeId}`);
|
||||
console.log(` Created: ${result.createdAt}`);
|
||||
console.log(` Expires: ${isLifetime ? 'NEVER' : result.expiresAt}`);
|
||||
console.log(` Status: ${isLifetime ? 'LIFETIME' : (result.expired ? 'EXPIRED' : 'ACTIVE')}`);
|
||||
} else {
|
||||
console.log('Code is INVALID');
|
||||
console.log(` Reason: ${result.reason}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate codes
|
||||
const isLifetime = args.includes('--lifetime');
|
||||
const durationIndex = args.indexOf('--duration');
|
||||
if (!isLifetime && durationIndex === -1) {
|
||||
console.error('--duration is required. Use --help for usage.');
|
||||
process.exit(1);
|
||||
}
|
||||
const duration = isLifetime ? LIFETIME_DURATION : parseInt(args[durationIndex + 1]);
|
||||
if (!isLifetime && !VALID_DURATIONS.includes(duration)) {
|
||||
console.error(`Invalid duration: ${duration}. Valid: ${VALID_DURATIONS.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const countIndex = args.indexOf('--count');
|
||||
const count = countIndex !== -1 ? parseInt(args[countIndex + 1]) : 1;
|
||||
|
||||
// Load or create counter file for auto-incrementing code IDs
|
||||
const counterFile = process.env.LICENSE_COUNTER_FILE || path.join(platformPaths.dataDir, '.license-counter');
|
||||
let startId;
|
||||
const startIdIndex = args.indexOf('--start-id');
|
||||
if (startIdIndex !== -1) {
|
||||
startId = parseInt(args[startIdIndex + 1]);
|
||||
} else if (fs.existsSync(counterFile)) {
|
||||
startId = parseInt(fs.readFileSync(counterFile, 'utf8').trim()) + 1;
|
||||
} else {
|
||||
startId = 1;
|
||||
}
|
||||
|
||||
const secret = getSecret();
|
||||
const codes = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const codeId = startId + i;
|
||||
const code = generateCode(secret, duration, codeId);
|
||||
codes.push({ code, codeId, durationDays: duration });
|
||||
}
|
||||
|
||||
// Save counter
|
||||
fs.writeFileSync(counterFile, String(startId + count - 1));
|
||||
|
||||
// Output
|
||||
const outputIndex = args.indexOf('--output');
|
||||
if (args.includes('--json')) {
|
||||
const output = JSON.stringify(codes, null, 2);
|
||||
if (outputIndex !== -1) {
|
||||
fs.writeFileSync(args[outputIndex + 1], output);
|
||||
console.log(`${count} code(s) written to ${args[outputIndex + 1]}`);
|
||||
} else {
|
||||
console.log(output);
|
||||
}
|
||||
} else {
|
||||
const lines = codes.map(c => `${c.code} (${c.durationDays === 0 ? 'LIFETIME' : c.durationDays + ' days'}, ID: ${c.codeId})`);
|
||||
if (outputIndex !== -1) {
|
||||
fs.writeFileSync(args[outputIndex + 1], codes.map(c => c.code).join('\n') + '\n');
|
||||
console.log(`${count} code(s) written to ${args[outputIndex + 1]}`);
|
||||
} else {
|
||||
lines.forEach(l => console.log(l));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : duration + ' days'}. Next ID: ${startId + count}`);
|
||||
}
|
||||
|
||||
// Also export for use by license-manager.js
|
||||
module.exports = { verifyCode, parseCode, VALID_DURATIONS, VERSION };
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -183,10 +183,24 @@ class LicenseManager {
|
||||
return { success: false, message: offlineResult.reason || 'Invalid license code' };
|
||||
}
|
||||
|
||||
// Code is cryptographically valid
|
||||
// DC-052: LIFETIME keys are creator-only. Reject any lifetime code
|
||||
// unless ALLOW_LIFETIME_LICENSE=true is set on this host (Sami's
|
||||
// dev machine). Production / paid customers must NEVER be able to
|
||||
// activate a LIFETIME code — every other license is time-bound.
|
||||
const isLifetime = offlineResult.durationDays === 0;
|
||||
if (isLifetime && !this.allowsLifetimeLicense()) {
|
||||
this.log.warn?.('license', 'LIFETIME code rejected — not allowed on this host', {
|
||||
code: this._maskCode(code),
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
message: 'Lifetime licenses are not available. Please use a time-bounded license key.',
|
||||
};
|
||||
}
|
||||
|
||||
// Code is cryptographically valid AND lifetime check passed
|
||||
const machineId = this.getMachineFingerprint();
|
||||
const now = new Date();
|
||||
const isLifetime = offlineResult.durationDays === 0;
|
||||
const expiresAt = isLifetime
|
||||
? new Date('2099-12-31T23:59:59.999Z')
|
||||
: new Date(now.getTime() + offlineResult.durationDays * 86400000);
|
||||
@@ -313,6 +327,32 @@ class LicenseManager {
|
||||
return features.includes(feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* DC-052: shorthand for "is this host on a Pro license right now?"
|
||||
*
|
||||
* Returns true only when there's an active, non-expired license.
|
||||
* Lifetime keys also count as Pro (they're just permanent Pro).
|
||||
* Free tier = false. Returns false when no activation exists.
|
||||
*/
|
||||
isPro() {
|
||||
if (!this.activation) return false;
|
||||
if (this.isExpired()) return false;
|
||||
// Lifetime keys are active forever; treat as Pro.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* DC-052: are LIFETIME license codes permitted on this host?
|
||||
*
|
||||
* Default false. Set ALLOW_LIFETIME_LICENSE=true ONLY on the operator's
|
||||
* own dev machine — production hosts and paid customers must never be
|
||||
* able to activate a LIFETIME code. Per PRODUCT-SPEC-DECISIONS.md,
|
||||
* LIFETIME keys are creator-only; Stripe never issues them.
|
||||
*/
|
||||
allowsLifetimeLicense() {
|
||||
return process.env.ALLOW_LIFETIME_LICENSE === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the license has expired
|
||||
*/
|
||||
|
||||
@@ -45,7 +45,12 @@ const BUNDLED_WORKFLOWS = {
|
||||
interval: 15 * 60 * 1000, // 15 minutes
|
||||
actions: [
|
||||
{ type: 'health-check', target: '{{serviceId}}' },
|
||||
{ type: 'notify-on-failure', message: 'Health check failed for {{serviceId}}' }
|
||||
// failingServices is set by healthCheckService when it throws (any
|
||||
// service failed). It's a comma-joined string of failing service IDs.
|
||||
// Previously this used {{serviceId}} which never resolved because
|
||||
// no per-service ID is in scope at the workflow level — that's the
|
||||
// DC-044 root-cause bug fix.
|
||||
{ type: 'notify-on-failure', message: 'Health check failed for {{failingServices}}' }
|
||||
]
|
||||
},
|
||||
'disk-space-alert': {
|
||||
@@ -194,34 +199,23 @@ class WorkflowEngine extends EventEmitter {
|
||||
if (!workflow) {
|
||||
throw new Error(`Unknown workflow: ${workflowId}`);
|
||||
}
|
||||
|
||||
|
||||
if (!this.enabled.get(workflowId)) {
|
||||
console.log(`[WorkflowEngine] Workflow ${workflowId} is disabled, skipping`);
|
||||
return { skipped: true, reason: 'disabled' };
|
||||
}
|
||||
|
||||
|
||||
const executionId = `${workflowId}-${Date.now()}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
|
||||
console.log(`[WorkflowEngine] Executing workflow: ${workflowId}`);
|
||||
this.emit('workflow-start', { workflowId, executionId, triggerData });
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const action of workflow.actions) {
|
||||
try {
|
||||
const result = await this.executeAction(action, triggerData);
|
||||
results.push({ action: action.type, success: true, result });
|
||||
} catch (error) {
|
||||
console.error(`[WorkflowEngine] Action ${action.type} failed:`, error.message);
|
||||
results.push({ action: action.type, success: false, error: error.message });
|
||||
// Continue with other actions but log failure
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const results = await this._runActions(workflow.actions, triggerData);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const allSucceeded = results.every(r => r.success);
|
||||
|
||||
|
||||
const historyEntry = {
|
||||
executionId,
|
||||
workflowId,
|
||||
@@ -232,22 +226,63 @@ class WorkflowEngine extends EventEmitter {
|
||||
success: allSucceeded,
|
||||
results
|
||||
};
|
||||
|
||||
|
||||
this.history.push(historyEntry);
|
||||
|
||||
|
||||
// Keep history to last 500 entries
|
||||
if (this.history.length > 500) {
|
||||
this.history = this.history.slice(-500);
|
||||
}
|
||||
|
||||
|
||||
this.saveHistory();
|
||||
|
||||
|
||||
this.emit('workflow-complete', historyEntry);
|
||||
console.log(`[WorkflowEngine] Workflow ${workflowId} completed in ${duration}ms, success: ${allSucceeded}`);
|
||||
|
||||
|
||||
return historyEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a sequence of actions and collect their results. Extracted from
|
||||
* executeWorkflow so the per-action result threading (notify-on-failure
|
||||
* gating) and the failingServices context surface can be unit-tested
|
||||
* directly. executeWorkflow() is the production entry point; _runActions
|
||||
* is an internal helper that callers shouldn't reach for.
|
||||
*/
|
||||
async _runActions(actions, triggerData = {}) {
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < actions.length; i++) {
|
||||
const action = actions[i];
|
||||
const previousResult = i > 0 ? results[i - 1] : null;
|
||||
// notify-on-failure needs to see the previous action's outcome to decide
|
||||
// whether to fire. Passing the full results array in the trigger data lets
|
||||
// executeAction do that lookup without changing the action shape.
|
||||
// Also surface failingServices (set by healthCheckService on throw) so
|
||||
// template variables like {{failingServices}} can interpolate.
|
||||
const actionContext = {
|
||||
...triggerData,
|
||||
previousResult,
|
||||
failingServices: previousResult && previousResult.failingServices ? previousResult.failingServices : undefined,
|
||||
};
|
||||
try {
|
||||
const result = await this.executeAction(action, actionContext);
|
||||
results.push({ action: action.type, success: true, result });
|
||||
} catch (error) {
|
||||
console.error(`[WorkflowEngine] Action ${action.type} failed:`, error.message);
|
||||
results.push({
|
||||
action: action.type,
|
||||
success: false,
|
||||
error: error.message,
|
||||
failingServices: error.failingServices,
|
||||
});
|
||||
// Continue with other actions but log failure
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single action
|
||||
*/
|
||||
@@ -269,7 +304,12 @@ class WorkflowEngine extends EventEmitter {
|
||||
);
|
||||
|
||||
case 'notify-on-failure':
|
||||
// Only send if previous action failed
|
||||
// Only send if previous action failed (success: false). The
|
||||
// previousResult is injected by executeWorkflow's loop. If there
|
||||
// was no previous action, this is a no-op (returns skipped).
|
||||
if (!context.previousResult || context.previousResult.success !== false) {
|
||||
return { skipped: true, reason: 'no previous failure' };
|
||||
}
|
||||
return this.notify(
|
||||
this.interpolate(action.message, context),
|
||||
action.channel
|
||||
@@ -323,11 +363,31 @@ class WorkflowEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { checked: results.length, healthy: results.filter(r => r.healthy).length, results };
|
||||
// Surface failing service IDs so downstream notify-on-failure actions
|
||||
// can interpolate `{{failingServices}}` into the alert message. Without
|
||||
// this, templates like `Health check failed for {{serviceId}}` stay
|
||||
// literal because there's no serviceId in scope.
|
||||
const failing = results.filter(r => !r.healthy).map(r => r.service);
|
||||
const healthy = results.filter(r => r.healthy).length;
|
||||
const result = { checked: results.length, healthy, results, failing };
|
||||
if (failing.length > 0) {
|
||||
// Throw so the action's success:false path is taken and notify-on-failure fires.
|
||||
const err = new Error(`Health check failed for ${failing.length} service(s): ${failing.join(', ')}`);
|
||||
err.failingServices = failing;
|
||||
err.workflowResult = result;
|
||||
throw err;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
// Single service check
|
||||
const healthy = await this.checkContainerHealth(serviceId);
|
||||
if (!healthy) {
|
||||
const err = new Error(`Health check failed for ${serviceId}`);
|
||||
err.failingServices = [serviceId];
|
||||
err.workflowResult = { serviceId, healthy };
|
||||
throw err;
|
||||
}
|
||||
return { serviceId, healthy };
|
||||
}
|
||||
|
||||
@@ -338,10 +398,18 @@ class WorkflowEngine extends EventEmitter {
|
||||
try {
|
||||
const docker = this.ctx.docker?.client;
|
||||
if (!docker) return false;
|
||||
|
||||
|
||||
const container = docker.getContainer(containerId);
|
||||
const info = await container.inspect();
|
||||
return info.State && info.State.Running && info.State.Health !== 'unhealthy';
|
||||
// A container is healthy if it's running AND (it has no explicit
|
||||
// health check OR its health check reports healthy/starting).
|
||||
// info.State.Health is undefined when no HEALTHCHECK is declared.
|
||||
// info.State.Health.Status is 'starting' | 'healthy' | 'unhealthy'
|
||||
// when the health check IS declared.
|
||||
if (!info.State || !info.State.Running) return false;
|
||||
if (!info.State.Health) return true; // no health check defined → running = healthy
|
||||
const status = info.State.Health.Status;
|
||||
return status === 'healthy' || status === 'starting';
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -157,6 +157,11 @@ function csrfValidationMiddleware(req, res, next) {
|
||||
// the user has no session cookie yet (they just clicked an email link).
|
||||
// CSRF on this boundary is enforced by SameSite=Lax instead.
|
||||
'/api/v1/auth/invites/:token/accept',
|
||||
// DC-053: share-link subscribe + Tailscale redeem originate from the
|
||||
// public share page (cross-origin). The token itself is the proof; CSRF
|
||||
// is bounded by the token's TTL + scope. Same model as invite accept.
|
||||
'/api/v1/share/:token/subscribe',
|
||||
'/api/v1/share/:token/redeem-tailscale',
|
||||
'/health',
|
||||
'/health/live',
|
||||
'/health/ready',
|
||||
@@ -167,8 +172,16 @@ function csrfValidationMiddleware(req, res, next) {
|
||||
'/api/v1/system/update-notify'
|
||||
];
|
||||
|
||||
const isExcluded = excludedPaths.some(path => req.path === path) ||
|
||||
req.path.startsWith('/api/v1/auth/gate/');
|
||||
const isExcluded = excludedPaths.some(path => {
|
||||
if (req.path === path) return true;
|
||||
// Allow `:param` placeholders to match any single segment. Pre-existing
|
||||
// bug — literal ':token' never matched real tokens — fixed under DC-053.
|
||||
if (path.includes(':')) {
|
||||
const pattern = '^' + path.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '[^/]+') + '$';
|
||||
return new RegExp(pattern).test(req.path);
|
||||
}
|
||||
return false;
|
||||
}) || req.path.startsWith('/api/v1/auth/gate/');
|
||||
|
||||
if (isExcluded) {
|
||||
return next();
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Share store — DC-053.
|
||||
*
|
||||
* Signed share tokens that let the host share a service with non-authenticated
|
||||
* visitors. Two flavors:
|
||||
*
|
||||
* 1. **Public share links** — anonymous-readable preview URLs. Visitor sees
|
||||
* a service card + status; no auth required. Host sets a TTL (1h / 24h /
|
||||
* 7d). Optional email subscribe to receive status-change notifications.
|
||||
*
|
||||
* 2. **Tailscale-mediated share** — a one-shot Tailscale pre-auth key scoped
|
||||
* to a device tag. Invitee clicks the link → device joins the tailnet →
|
||||
* Caddy forward_auth inducts them into the service. Single-use, 24h TTL.
|
||||
*
|
||||
* Storage: data/shares.json. Atomic writes via tmp+rename. The on-disk shape
|
||||
* is identical to the invite store — UUID-keyed map of records with SHA-256
|
||||
* hashed tokens. Raw token is only returned at issue() time.
|
||||
*
|
||||
* Public-share token also carries a HMAC signature binding it to the
|
||||
* serviceId so a leaked token cannot be silently retargeted. The signature
|
||||
* is verified at peek() time using a server-side secret (licenseManager's
|
||||
* install secret if available, otherwise a derived per-store key).
|
||||
*
|
||||
* Lifecycle:
|
||||
* - issuePublic({ serviceId, ttlMs, createdBy }) → { id, token, url, expiresAt }
|
||||
* - issueTailscale({ serviceId, email, ttlMs, createdBy }) → { id, token, url, expiresAt, authKeyId }
|
||||
* - peek(token) → { kind, serviceId, expiresAt, remainingUses, usedAt? } | null
|
||||
* - recordUse(token, { kind: 'public-subscribe' }) → { ok, count } | { ok: false, reason }
|
||||
* - revoke(id) → boolean
|
||||
* - list() → outstanding shares (admin view)
|
||||
* - listForService(serviceId) → outstanding shares for a specific service
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
const DEFAULT_PUBLIC_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
||||
const DEFAULT_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
||||
const MAX_PUBLIC_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
|
||||
const MAX_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
||||
const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // auto-prune used/expired after 7d
|
||||
const ALLOWED_PUBLIC_TTLS = new Set([60 * 60 * 1000, 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000]);
|
||||
const TAILSCALE_MAX_USES = 1;
|
||||
const PUBLIC_DEFAULT_SUBSCRIBE_CAP = 1000; // bound on subscribe events per link
|
||||
|
||||
function _nowMs() { return Date.now(); }
|
||||
function _nowIso() { return new Date().toISOString(); }
|
||||
|
||||
function _atomicWriteJSON(filePath, data) {
|
||||
const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now();
|
||||
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
||||
fs.renameSync(tmp, filePath);
|
||||
}
|
||||
|
||||
function _readJSON(filePath, fallback) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ENOENT') return fallback;
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function _defaultData() { return { shares: {} }; }
|
||||
|
||||
function _sha256(s) {
|
||||
return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function _hmacSign(secret, payload) {
|
||||
return crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('base64url');
|
||||
}
|
||||
|
||||
function createShareStore(opts = {}) {
|
||||
// Defensive resolver mirrors user-store / invite-store.
|
||||
const candidates = [
|
||||
opts.dataDir,
|
||||
opts.platformPaths && opts.platformPaths.dataDir,
|
||||
platformPaths && platformPaths.dataDir,
|
||||
];
|
||||
const dataDir = candidates.find(c => typeof c === 'string' && c.length > 0)
|
||||
|| require('os').tmpdir();
|
||||
const log = opts.log || { info() {}, warn() {}, error() {} };
|
||||
|
||||
const file = path.join(dataDir, 'shares.json');
|
||||
|
||||
// Server-side secret. Prefer an explicit install secret if provided so the
|
||||
// signature can outlive a reinstall. Fall back to a random per-store key
|
||||
// persisted in dataDir (rotated on next start if the file moves).
|
||||
const _secretFile = path.join(dataDir, '.share-secret');
|
||||
function _loadSecret() {
|
||||
if (opts.signingSecret && typeof opts.signingSecret === 'string') {
|
||||
return opts.signingSecret;
|
||||
}
|
||||
try {
|
||||
const existing = fs.readFileSync(_secretFile, 'utf8').trim();
|
||||
if (existing && existing.length >= 32) return existing;
|
||||
} catch (_) { /* missing or unreadable — generate fresh */ }
|
||||
const fresh = crypto.randomBytes(32).toString('base64url');
|
||||
try {
|
||||
fs.writeFileSync(_secretFile, fresh + '\n', { mode: 0o600 });
|
||||
} catch (err) {
|
||||
log.warn && log.warn('share', 'failed to persist signing secret', { err: err && err.message });
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
const signingSecret = _loadSecret();
|
||||
|
||||
let _mutex = Promise.resolve();
|
||||
function _enqueue(fn) {
|
||||
const next = _mutex.then(fn, fn);
|
||||
_mutex = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
function _load() {
|
||||
const data = _readJSON(file, _defaultData());
|
||||
if (!data.shares || typeof data.shares !== 'object') data.shares = {};
|
||||
return data;
|
||||
}
|
||||
function _save(data) { _atomicWriteJSON(file, data); }
|
||||
|
||||
function _prune(data) {
|
||||
const cutoff = _nowMs() - PRUNE_AFTER_MS;
|
||||
for (const id of Object.keys(data.shares)) {
|
||||
const s = data.shares[id];
|
||||
if (!s) { delete data.shares[id]; continue; }
|
||||
const isTerminal = (s.kind === 'public' && s.subscribeCount >= (s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP))
|
||||
|| (s.kind === 'tailscale' && s.usedAt)
|
||||
|| (s.expiresAt && new Date(s.expiresAt).getTime() < cutoff);
|
||||
if (isTerminal) delete data.shares[id];
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function _findByHash(data, hash) {
|
||||
for (const id of Object.keys(data.shares)) {
|
||||
const s = data.shares[id];
|
||||
if (s && s.hash === hash) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _verifySignature(s, token) {
|
||||
if (!s.signature || !s.serviceId) return false;
|
||||
const expected = _hmacSign(signingSecret, `${s.kind}:${s.id}:${s.serviceId}:${token}`);
|
||||
// constant-time compare; both are base64url strings of equal length
|
||||
const a = Buffer.from(s.signature);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function _publicView(s) {
|
||||
return {
|
||||
kind: s.kind,
|
||||
id: s.id,
|
||||
serviceId: s.serviceId,
|
||||
expiresAt: s.expiresAt,
|
||||
createdAt: s.createdAt,
|
||||
createdBy: s.createdBy,
|
||||
usedAt: s.usedAt || null,
|
||||
usedBy: s.usedBy || null,
|
||||
remainingUses: s.kind === 'tailscale' ? (s.usedAt ? 0 : 1) : Infinity,
|
||||
subscribeCount: s.subscribeCount || 0,
|
||||
subscribeCap: s.subscribeCap || null,
|
||||
};
|
||||
}
|
||||
|
||||
function issuePublic({ serviceId, ttlMs = DEFAULT_PUBLIC_TTL_MS, createdBy = 'admin', subscribeCap } = {}) {
|
||||
return _enqueue(() => {
|
||||
if (typeof serviceId !== 'string' || !serviceId.trim()) {
|
||||
return { ok: false, reason: 'invalid_service' };
|
||||
}
|
||||
// Clamp TTL to allowed set so share links can't outlive their visibility intent.
|
||||
const effectiveTtl = ALLOWED_PUBLIC_TTLS.has(ttlMs) ? ttlMs : DEFAULT_PUBLIC_TTL_MS;
|
||||
const id = crypto.randomUUID();
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
const hash = _sha256(token);
|
||||
const signature = _hmacSign(signingSecret, `public:${id}:${serviceId}:${token}`);
|
||||
const createdAt = _nowIso();
|
||||
const expiresAt = new Date(_nowMs() + effectiveTtl).toISOString();
|
||||
const cap = Number.isInteger(subscribeCap) && subscribeCap > 0
|
||||
? Math.min(subscribeCap, 10000)
|
||||
: PUBLIC_DEFAULT_SUBSCRIBE_CAP;
|
||||
|
||||
const data = _load();
|
||||
_prune(data);
|
||||
data.shares[id] = {
|
||||
id,
|
||||
kind: 'public',
|
||||
hash,
|
||||
signature,
|
||||
serviceId,
|
||||
createdBy,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
ttlMs: effectiveTtl,
|
||||
usedAt: null,
|
||||
usedBy: null,
|
||||
subscribeCount: 0,
|
||||
subscribeCap: cap,
|
||||
};
|
||||
_save(data);
|
||||
|
||||
log.info && log.info('share', 'public share issued', {
|
||||
id, serviceId, createdBy, ttlMs: effectiveTtl,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
token,
|
||||
signature,
|
||||
kind: 'public',
|
||||
serviceId,
|
||||
expiresAt,
|
||||
ttlMs: effectiveTtl,
|
||||
urlPath: `/share/${token}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function issueTailscale({ serviceId, email, ttlMs = DEFAULT_TAILSCALE_TTL_MS, createdBy = 'admin' } = {}) {
|
||||
return _enqueue(() => {
|
||||
if (typeof serviceId !== 'string' || !serviceId.trim()) {
|
||||
return { ok: false, reason: 'invalid_service' };
|
||||
}
|
||||
if (typeof email !== 'string' || !email.includes('@')) {
|
||||
return { ok: false, reason: 'invalid_email' };
|
||||
}
|
||||
// Tailscale pre-auth keys max at 90 days but our share-window is 24h.
|
||||
const effectiveTtl = Math.max(60 * 1000, Math.min(ttlMs, MAX_TAILSCALE_TTL_MS));
|
||||
const id = crypto.randomUUID();
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
const hash = _sha256(token);
|
||||
const signature = _hmacSign(signingSecret, `tailscale:${id}:${serviceId}:${token}`);
|
||||
const createdAt = _nowIso();
|
||||
const expiresAt = new Date(_nowMs() + effectiveTtl).toISOString();
|
||||
|
||||
const data = _load();
|
||||
_prune(data);
|
||||
data.shares[id] = {
|
||||
id,
|
||||
kind: 'tailscale',
|
||||
hash,
|
||||
signature,
|
||||
serviceId,
|
||||
email: email.toLowerCase().trim(),
|
||||
createdBy,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
ttlMs: effectiveTtl,
|
||||
usedAt: null,
|
||||
usedBy: null,
|
||||
// authKeyId + authKey are written by the route layer after calling
|
||||
// tailscale-coord.createAuthKey(); peek() doesn't surface them.
|
||||
authKeyId: null,
|
||||
};
|
||||
_save(data);
|
||||
|
||||
log.info && log.info('share', 'tailscale share issued', {
|
||||
id, serviceId, email: email.toLowerCase().trim(), createdBy, ttlMs: effectiveTtl,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
token,
|
||||
signature,
|
||||
kind: 'tailscale',
|
||||
serviceId,
|
||||
email: email.toLowerCase().trim(),
|
||||
expiresAt,
|
||||
ttlMs: effectiveTtl,
|
||||
urlPath: `/share/${token}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function attachAuthKey(id, authKeyId) {
|
||||
return _enqueue(() => {
|
||||
const data = _load();
|
||||
const s = data.shares[id];
|
||||
if (!s) return { ok: false, reason: 'not_found' };
|
||||
if (s.kind !== 'tailscale') return { ok: false, reason: 'wrong_kind' };
|
||||
s.authKeyId = authKeyId;
|
||||
_save(data);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
function peek(token) {
|
||||
if (!token || typeof token !== 'string') return null;
|
||||
return _enqueue(() => {
|
||||
const data = _load();
|
||||
const hash = _sha256(token);
|
||||
const s = _findByHash(data, hash);
|
||||
if (!s) return null;
|
||||
if (!_verifySignature(s, token)) {
|
||||
log.warn && log.warn('share', 'peek rejected: bad signature', { id: s.id });
|
||||
return null;
|
||||
}
|
||||
if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) return null;
|
||||
if (s.kind === 'tailscale' && s.usedAt) return null;
|
||||
if (s.kind === 'public' && s.subscribeCount >= (s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP)) {
|
||||
return null;
|
||||
}
|
||||
return _publicView(s);
|
||||
});
|
||||
}
|
||||
|
||||
function getRaw(token) {
|
||||
if (!token || typeof token !== 'string') return null;
|
||||
return _enqueue(() => {
|
||||
const data = _load();
|
||||
const hash = _sha256(token);
|
||||
const s = _findByHash(data, hash);
|
||||
if (!s) return null;
|
||||
if (!_verifySignature(s, token)) return null;
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
function recordPublicSubscribe(token) {
|
||||
return _enqueue(() => {
|
||||
const data = _load();
|
||||
const hash = _sha256(token);
|
||||
const s = _findByHash(data, hash);
|
||||
if (!s) return { ok: false, reason: 'not_found' };
|
||||
if (s.kind !== 'public') return { ok: false, reason: 'wrong_kind' };
|
||||
if (!_verifySignature(s, token)) return { ok: false, reason: 'invalid_signature' };
|
||||
if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) {
|
||||
return { ok: false, reason: 'expired' };
|
||||
}
|
||||
const cap = s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP;
|
||||
if (s.subscribeCount >= cap) return { ok: false, reason: 'cap_reached' };
|
||||
s.subscribeCount += 1;
|
||||
_save(data);
|
||||
return { ok: true, count: s.subscribeCount, cap };
|
||||
});
|
||||
}
|
||||
|
||||
function recordTailscaleUse(token, { deviceId } = {}) {
|
||||
return _enqueue(() => {
|
||||
const data = _load();
|
||||
const hash = _sha256(token);
|
||||
const s = _findByHash(data, hash);
|
||||
if (!s) return { ok: false, reason: 'not_found' };
|
||||
if (s.kind !== 'tailscale') return { ok: false, reason: 'wrong_kind' };
|
||||
if (!_verifySignature(s, token)) return { ok: false, reason: 'invalid_signature' };
|
||||
if (s.usedAt) return { ok: false, reason: 'already_used' };
|
||||
if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) {
|
||||
return { ok: false, reason: 'expired' };
|
||||
}
|
||||
s.usedAt = _nowIso();
|
||||
s.usedBy = typeof deviceId === 'string' ? deviceId : 'unknown';
|
||||
_save(data);
|
||||
return { ok: true, share: _publicView(s) };
|
||||
});
|
||||
}
|
||||
|
||||
function revoke(id) {
|
||||
return _enqueue(() => {
|
||||
const data = _load();
|
||||
if (!data.shares[id]) return false;
|
||||
delete data.shares[id];
|
||||
_save(data);
|
||||
log.info && log.info('share', 'share revoked', { id });
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function list() {
|
||||
return _enqueue(() => {
|
||||
const data = _load();
|
||||
_prune(data);
|
||||
return Object.values(data.shares).map(_publicView);
|
||||
});
|
||||
}
|
||||
|
||||
function listForService(serviceId) {
|
||||
return _enqueue(() => {
|
||||
const data = _load();
|
||||
_prune(data);
|
||||
return Object.values(data.shares)
|
||||
.filter(s => s.serviceId === serviceId)
|
||||
.map(_publicView);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
issuePublic,
|
||||
issueTailscale,
|
||||
attachAuthKey,
|
||||
peek,
|
||||
getRaw,
|
||||
recordPublicSubscribe,
|
||||
recordTailscaleUse,
|
||||
revoke,
|
||||
list,
|
||||
listForService,
|
||||
// expose for tests
|
||||
_signingSecret: signingSecret,
|
||||
_file: file,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createShareStore };
|
||||
@@ -335,6 +335,20 @@ function createUserStore(opts = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DC-052: count of users currently on this instance. Used by the
|
||||
* license-tier gate (Free = up to 3 users, Pro = unlimited). Counts
|
||||
* every user in users.json — including the TOTP-attributed system
|
||||
* record (`system@totp.local`) that DC-048 bootstraps on first
|
||||
* login. So a brand-new install always starts at count 1 (the host).
|
||||
*/
|
||||
function countUsers() {
|
||||
return _enqueue(() => {
|
||||
const users = _loadUsers();
|
||||
return users.order.length;
|
||||
});
|
||||
}
|
||||
|
||||
function listAllowlist() {
|
||||
return _enqueue(() => {
|
||||
const allowlist = _loadAllowlist();
|
||||
@@ -393,6 +407,7 @@ function createUserStore(opts = {}) {
|
||||
setRole,
|
||||
deleteUser,
|
||||
listUsers,
|
||||
countUsers,
|
||||
listAllowlist,
|
||||
getUser,
|
||||
getUserByEmail,
|
||||
|
||||
@@ -49,6 +49,19 @@ class ConflictError extends AppError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DC-052: 402 Payment Required — used when a Pro-only feature is
|
||||
* blocked by the license tier. Distinguishes "you need to pay" from
|
||||
* 403 (forbidden) so the dashboard UI can render an upgrade prompt
|
||||
* instead of a generic permission error.
|
||||
*/
|
||||
class PaymentRequiredError extends AppError {
|
||||
constructor(message = 'Pro license required for this feature', feature = null) {
|
||||
super(message, 402, 'DC-402');
|
||||
this.feature = feature;
|
||||
}
|
||||
}
|
||||
|
||||
class RateLimitError extends AppError {
|
||||
constructor(retryAfter = 60) {
|
||||
super('Rate limit exceeded', 429, 'DC-429');
|
||||
@@ -98,6 +111,8 @@ module.exports = {
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
RateLimitError,
|
||||
// DC-052
|
||||
PaymentRequiredError,
|
||||
DockerError,
|
||||
CaddyError,
|
||||
DNSError,
|
||||
|
||||
@@ -227,6 +227,10 @@ module.exports = function configureMiddleware(app, {
|
||||
ipSessions.delete(getClientIP(req));
|
||||
}
|
||||
|
||||
// Session cookies are intentionally host-only. Browsers reject Domain=.sami
|
||||
// because .sami is an unregistered custom TLD and therefore treated as a
|
||||
// public suffix. Cross-subdomain login is handled by the one-time SSO
|
||||
// handoff below, which mints a separate host-only cookie on each service.
|
||||
function setSessionCookie(res, durationKey) {
|
||||
const durationMs = SESSION_DURATIONS[durationKey];
|
||||
if (!durationMs) return;
|
||||
@@ -235,9 +239,8 @@ module.exports = function configureMiddleware(app, {
|
||||
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const key = cryptoUtils.loadOrCreateKey();
|
||||
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
|
||||
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
|
||||
res.setHeader('Set-Cookie',
|
||||
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}${domainAttr}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
|
||||
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,16 +271,28 @@ module.exports = function configureMiddleware(app, {
|
||||
}
|
||||
|
||||
function clearSessionCookie(res) {
|
||||
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
|
||||
res.setHeader('Set-Cookie',
|
||||
`${SESSION_COOKIE_NAME}=; Max-Age=0${domainAttr}; Path=/; HttpOnly; SameSite=Lax`
|
||||
`${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`
|
||||
);
|
||||
}
|
||||
|
||||
// COOKIE-ONLY session validation. The previous IP-keyed cache (verifyIPSession
|
||||
// + the write-back in this function) caused cross-subdomain SSO breakage when
|
||||
// Caddy on --network host forwards auth to the container: req.ip arrives as
|
||||
// 100.121.150.22 (DNS2's tailnet IP) instead of the user's real IP, so the
|
||||
// IP cache misses even when the cookie is valid. The host-only cookie is
|
||||
// signed with a persisted HMAC key (loadOrCreateKey()). Cross-subdomain
|
||||
// authentication uses the one-time SSO handoff because browsers reject
|
||||
// Domain=.sami. HttpOnly + Secure + SameSite=Lax makes it a stronger
|
||||
// credential than the IP cache. Ref: skill auth-and-monitoring-pitfalls.md
|
||||
// "TOTP session validation IP-key issue" (FIXED 2026-07-21).
|
||||
function isSessionValid(req) {
|
||||
if (verifyIPSession(req)) return true;
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
if (verifySessionCookie(cookies[SESSION_COOKIE_NAME])) {
|
||||
// Re-warm the IP cache as a no-op-only fast path (kept for backwards
|
||||
// compat with code that reads ctx.session.ipSessions.size for telemetry,
|
||||
// but it is NOT consulted for auth decisions). The next line intentionally
|
||||
// does NOT gate the return on verifyIPSession anymore.
|
||||
const ip = getClientIP(req);
|
||||
if (totpConfig.sessionDuration && SESSION_DURATIONS[totpConfig.sessionDuration]) {
|
||||
ipSessions.set(ip, { exp: Date.now() + SESSION_DURATIONS[totpConfig.sessionDuration] });
|
||||
@@ -287,6 +302,43 @@ module.exports = function configureMiddleware(app, {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Cross-subdomain SSO token handoff ──
|
||||
// Domain=.sami cookies are silently rejected by real browsers: .sami is an
|
||||
// unregistered custom TLD, so browsers treat "sami" itself as the effective
|
||||
// public suffix and refuse to set a cookie scoped to it (the same rule that
|
||||
// stops a site from setting a supercookie for all of .com). That means the
|
||||
// session cookie set on status.sami never reaches plex.sami/jellyfin.sami/
|
||||
// etc, and cross-subdomain SSO can never work via a shared cookie no matter
|
||||
// how the cookie itself is constructed.
|
||||
//
|
||||
// Fix: after TOTP verify, mint a short-lived single-use opaque token and
|
||||
// pass it in the redirect URL back to the target service. That service's
|
||||
// origin exchanges the token (via /auth/sso-exchange) for its OWN host-only
|
||||
// cookie (no Domain attribute — always accepted, since it's scoped to the
|
||||
// exact host that set it). isSessionValid/verifySessionCookie don't care
|
||||
// about the cookie's Domain at all, only its HMAC signature, so a host-only
|
||||
// cookie validates identically to the cross-domain one — no changes needed
|
||||
// to any existing session-check code path.
|
||||
const ssoHandoffTokens = new Map();
|
||||
const SSO_HANDOFF_TTL_MS = 60 * 1000;
|
||||
|
||||
function createHandoffToken() {
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
ssoHandoffTokens.set(token, { exp: Date.now() + SSO_HANDOFF_TTL_MS });
|
||||
return token;
|
||||
}
|
||||
|
||||
function redeemHandoffToken(token) {
|
||||
if (!token) return false;
|
||||
const entry = ssoHandoffTokens.get(token);
|
||||
ssoHandoffTokens.delete(token); // one-time use regardless of outcome
|
||||
return !!entry && entry.exp > Date.now();
|
||||
}
|
||||
|
||||
function setHostOnlySessionCookie(res, durationKey) {
|
||||
setSessionCookie(res, durationKey);
|
||||
}
|
||||
|
||||
// ── Public routes (bypass TOTP and JWT auth) ──
|
||||
// Routes here are accessible without authentication. By default the
|
||||
// monitoring/health-check endpoints are public so the dashboard can
|
||||
@@ -327,6 +379,11 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/auth/gate/', prefix: true },
|
||||
{ path: '/api/v1/auth/app-token/', prefix: true },
|
||||
{ path: '/api/v1/auth/login-page', exact: true, method: 'GET' },
|
||||
// Must be public: a fresh cross-subdomain visitor has no session yet by
|
||||
// definition — that's exactly the gap /auth/sso-exchange closes. The
|
||||
// endpoint itself only accepts a valid single-use handoff token minted
|
||||
// moments earlier by a successful TOTP verify, so this isn't an open door.
|
||||
{ path: '/api/v1/auth/sso-exchange', exact: true, method: 'GET' },
|
||||
// DC-046 pluggable auth endpoints — public by design (they ARE login).
|
||||
// Use :provider placeholder; today's only provider is TOTP, but the
|
||||
// route is parameterized so DC-047's email provider just works.
|
||||
@@ -340,9 +397,19 @@ module.exports = function configureMiddleware(app, {
|
||||
// UI can show "this invite is for X, expires Y" before clicking.
|
||||
{ path: '/api/v1/auth/invites/:token', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/auth/invites/:token/accept', exact: true, method: 'POST' },
|
||||
// /me and /admin/* require authentication — NOT public. Listed here
|
||||
// only to document them; absence from PUBLIC_ROUTES means they go
|
||||
// through the normal auth gate. CSRF applies to writes as usual.
|
||||
// DC-053: share-link redemption is PUBLIC — visitors arrive via email
|
||||
// or social share with no DashCaddy session. The token IS the proof.
|
||||
{ path: '/api/v1/share/:token/preview', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/share/:token/subscribe', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/share/:token/redeem-tailscale', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/billing/checkout', exact: true, method: 'POST' },
|
||||
// /api/v1/billing/webhook was REMOVED: webhooks are handled out-of-process
|
||||
// by scripts/stripe-license-bridge.js (the merchant webhook secret never
|
||||
// enters the API process). The PUBLIC_ROUTES allowlist drift test would
|
||||
// catch any re-add of this dead entry.
|
||||
// /api/v1/services + status: read-only service metadata that the public
|
||||
// dashboard needs before login (services list widget, status pill).
|
||||
// Writes go through the normal auth gate. CSRF applies to writes as usual.
|
||||
{ path: '/api/v1/services', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/ca/info', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/ca/root.crt', exact: true, method: 'GET' },
|
||||
@@ -387,6 +454,17 @@ module.exports = function configureMiddleware(app, {
|
||||
function isPublicRoute(req) {
|
||||
return PUBLIC_ROUTES.some(r => {
|
||||
if (r.method && req.method !== r.method) return false;
|
||||
if (r.exact) {
|
||||
// Exact string match, BUT allow `:param` placeholders in the
|
||||
// PUBLIC_ROUTES entry to match any single path segment. This was a
|
||||
// pre-existing bug — literal ':token' never matched real tokens —
|
||||
// caught by DC-053 public share preview returning 401.
|
||||
if (r.path.includes(':')) {
|
||||
const pattern = '^' + r.path.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '[^/]+') + '$';
|
||||
return new RegExp(pattern).test(req.path);
|
||||
}
|
||||
return req.path === r.path;
|
||||
}
|
||||
return r.prefix ? req.path.startsWith(r.path) : req.path === r.path;
|
||||
});
|
||||
}
|
||||
@@ -557,6 +635,9 @@ module.exports = function configureMiddleware(app, {
|
||||
clearSessionCookie,
|
||||
isSessionValid,
|
||||
ipSessions,
|
||||
renewCSRFToken
|
||||
renewCSRFToken,
|
||||
createHandoffToken,
|
||||
redeemHandoffToken,
|
||||
setHostOnlySessionCookie
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
// ========== MONITORING WIDGETS ==========
|
||||
// Embeds a compact system-resource + health summary panel directly on the
|
||||
// main dashboard. Replaces the need for a separate monitoring-dashboard.html
|
||||
// page — quick at-a-glance stats where you already are.
|
||||
(function () {
|
||||
|
||||
// ----- Style injection (scoped to .dc-monitor so it doesn't leak) -----
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = `
|
||||
.dc-monitor {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--card-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.dc-monitor-card {
|
||||
padding: 10px 12px;
|
||||
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.dc-monitor-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dc-monitor-value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.dc-monitor-sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.dc-monitor-bar {
|
||||
margin-top: 6px;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: color-mix(in srgb, var(--muted) 20%, transparent);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dc-monitor-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--ok-fg, #27ae60);
|
||||
transition: width 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
.dc-monitor-bar-fill.warn { background: #f39c12; }
|
||||
.dc-monitor-bar-fill.bad { background: #e74c3c; }
|
||||
.dc-monitor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dc-monitor-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.dc-monitor-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.dc-monitor-pill.ok { background: color-mix(in srgb, #27ae60 20%, transparent); color: #27ae60; }
|
||||
.dc-monitor-pill.warn { background: color-mix(in srgb, #f39c12 20%, transparent); color: #f39c12; }
|
||||
.dc-monitor-pill.bad { background: color-mix(in srgb, #e74c3c 20%, transparent); color: #e74c3c; }
|
||||
.dc-monitor-refresh {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styleEl);
|
||||
|
||||
// ----- Container element (inserted above service-filter-bar) -----
|
||||
const filterBar = document.getElementById('service-filter-bar');
|
||||
if (!filterBar) return;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'dc-monitor';
|
||||
panel.id = 'dc-monitor-panel';
|
||||
panel.innerHTML = `
|
||||
<div class="dc-monitor-header" style="grid-column: 1 / -1;">
|
||||
<div class="dc-monitor-title">📊 System Overview</div>
|
||||
<span class="dc-monitor-refresh" id="dc-monitor-refresh-stamp">—</span>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Services</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-services">—</div>
|
||||
<div class="dc-monitor-sub" id="dc-monitor-services-sub">loading…</div>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Containers Up</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-containers">—</div>
|
||||
<div class="dc-monitor-sub" id="dc-monitor-containers-sub">loading…</div>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Avg CPU</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-cpu">—</div>
|
||||
<div class="dc-monitor-bar"><div class="dc-monitor-bar-fill" id="dc-monitor-cpu-bar"></div></div>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Avg Memory</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-mem">—</div>
|
||||
<div class="dc-monitor-bar"><div class="dc-monitor-bar-fill" id="dc-monitor-mem-bar"></div></div>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Health</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-health">—</div>
|
||||
<div class="dc-monitor-sub" id="dc-monitor-health-sub">—</div>
|
||||
</div>
|
||||
`;
|
||||
// Insert ABOVE the filter bar
|
||||
filterBar.parentNode.insertBefore(panel, filterBar);
|
||||
|
||||
// ----- Helpers -----
|
||||
function setBar(id, pct) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const p = Math.max(0, Math.min(100, Number(pct) || 0));
|
||||
el.style.width = p + '%';
|
||||
el.classList.remove('warn', 'bad');
|
||||
if (p >= 85) el.classList.add('bad');
|
||||
else if (p >= 65) el.classList.add('warn');
|
||||
}
|
||||
|
||||
function fmtPct(v) {
|
||||
if (v == null || isNaN(v)) return '—';
|
||||
return (Math.round(v * 10) / 10) + '%';
|
||||
}
|
||||
|
||||
function fmtBytes(b) {
|
||||
if (b == null || isNaN(b)) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; }
|
||||
return b.toFixed(1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
// ----- Robust services count -----
|
||||
// Read from multiple sources so we always have a number:
|
||||
// 1. window.APPS (populated by grid.js after loadServices)
|
||||
// 2. #cards .card elements (post-buildGrid)
|
||||
// 3. live fetch /api/v1/services (last-resort fallback if grid hasn't run)
|
||||
async function fetchServicesCount() {
|
||||
// Source 1+2: window.APPS / DOM cards
|
||||
if (Array.isArray(window.APPS) && window.APPS.length > 0) {
|
||||
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
|
||||
return { total: window.APPS.length, up, source: 'APPS' };
|
||||
}
|
||||
const cards = document.querySelectorAll('#cards .card');
|
||||
if (cards.length > 0) {
|
||||
const up = Array.from(cards).filter(c => c.dataset.status === 'on').length;
|
||||
return { total: cards.length, up, source: 'DOM' };
|
||||
}
|
||||
// Source 3: fetch live (endpoints may return {success, services:[...]} OR raw array)
|
||||
try {
|
||||
const r = await fetch('/api/v1/services', { cache: 'no-store' });
|
||||
if (!r.ok) return { total: 0, up: 0, source: 'fetch-fail' };
|
||||
const body = await r.json();
|
||||
const list = (body && Array.isArray(body.services)) ? body.services
|
||||
: (Array.isArray(body)) ? body
|
||||
: [];
|
||||
// Persist for the grid so this fallback only fires once
|
||||
if (Array.isArray(window.APPS) || typeof window.APPS === 'undefined') window.APPS = list;
|
||||
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
|
||||
return { total: list.length, up, source: 'fetch' };
|
||||
} catch (_) {
|
||||
return { total: 0, up: 0, source: 'fetch-error' };
|
||||
}
|
||||
}
|
||||
|
||||
async function setServicesCard() {
|
||||
const { total, up } = await fetchServicesCount();
|
||||
const el = document.getElementById('dc-monitor-services');
|
||||
const sub = document.getElementById('dc-monitor-services-sub');
|
||||
if (el) el.textContent = `${up} / ${total}`;
|
||||
if (sub) sub.textContent = total === 0
|
||||
? 'no services yet'
|
||||
: `${up} online · ${total - up} offline`;
|
||||
}
|
||||
|
||||
function applyHealthSummary(data) {
|
||||
const el = document.getElementById('dc-monitor-health');
|
||||
const sub = document.getElementById('dc-monitor-health-sub');
|
||||
if (!el) return;
|
||||
if (!data || data.summary == null) {
|
||||
el.textContent = '—';
|
||||
if (sub) sub.textContent = 'no data';
|
||||
return;
|
||||
}
|
||||
const s = data.summary;
|
||||
const healthy = s.healthy ?? s.up ?? 0;
|
||||
const unhealthy = s.unhealthy ?? s.down ?? 0;
|
||||
const total = s.total ?? (healthy + unhealthy);
|
||||
el.textContent = `${healthy}/${total}`;
|
||||
if (sub) {
|
||||
if (unhealthy === 0) {
|
||||
sub.innerHTML = '<span class="dc-monitor-pill ok">● all healthy</span>';
|
||||
} else if (unhealthy <= 2) {
|
||||
sub.innerHTML = `<span class="dc-monitor-pill warn">● ${unhealthy} degraded</span>`;
|
||||
} else {
|
||||
sub.innerHTML = `<span class="dc-monitor-pill bad">● ${unhealthy} down</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Data fetches -----
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/monitoring/stats', { cache: 'no-store' });
|
||||
if (!r.ok) return null;
|
||||
const data = await r.json();
|
||||
return (data && data.stats) ? data.stats : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHealth() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/health-checks/status', { cache: 'no-store' });
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyStats(stats) {
|
||||
const containers = document.getElementById('dc-monitor-containers');
|
||||
const containersSub = document.getElementById('dc-monitor-containers-sub');
|
||||
const cpuEl = document.getElementById('dc-monitor-cpu');
|
||||
const memEl = document.getElementById('dc-monitor-mem');
|
||||
|
||||
if (!stats) {
|
||||
if (containers) containers.textContent = '—';
|
||||
if (cpuEl) cpuEl.textContent = '—';
|
||||
if (memEl) memEl.textContent = '—';
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = Object.values(stats);
|
||||
if (entries.length === 0) {
|
||||
if (containers) containers.textContent = '0';
|
||||
if (containersSub) containersSub.textContent = 'no containers reporting';
|
||||
if (cpuEl) cpuEl.textContent = '0%';
|
||||
if (memEl) memEl.textContent = '0%';
|
||||
setBar('dc-monitor-cpu-bar', 0);
|
||||
setBar('dc-monitor-mem-bar', 0);
|
||||
return;
|
||||
}
|
||||
|
||||
let cpuSum = 0, memSum = 0, memBytes = 0, cpuCount = 0, memCount = 0;
|
||||
entries.forEach(s => {
|
||||
// CPU may be percentage (0-100) or fraction (0-1) — handle both
|
||||
if (s.cpu != null) {
|
||||
const cpu = Number(s.cpu);
|
||||
if (!isNaN(cpu)) {
|
||||
cpuSum += cpu > 1 ? cpu : cpu * 100;
|
||||
cpuCount++;
|
||||
}
|
||||
}
|
||||
if (s.memory != null) {
|
||||
const mem = Number(s.memory);
|
||||
if (!isNaN(mem)) {
|
||||
memSum += mem;
|
||||
memBytes += Number(s.memoryUsage || 0);
|
||||
memCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const avgCpu = cpuCount ? cpuSum / cpuCount : 0;
|
||||
const avgMem = memCount ? memSum / memCount : 0;
|
||||
|
||||
if (containers) containers.textContent = String(entries.length);
|
||||
if (containersSub) {
|
||||
const memTxt = memBytes ? ` · ${fmtBytes(memBytes)} RAM` : '';
|
||||
containersSub.textContent = `running${memTxt}`;
|
||||
}
|
||||
if (cpuEl) cpuEl.textContent = fmtPct(avgCpu);
|
||||
if (memEl) memEl.textContent = fmtPct(avgMem);
|
||||
setBar('dc-monitor-cpu-bar', avgCpu);
|
||||
setBar('dc-monitor-mem-bar', avgMem);
|
||||
}
|
||||
|
||||
// ----- Public refresh function -----
|
||||
let inFlight = false;
|
||||
async function refresh() {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
setServicesCard();
|
||||
const [stats, health] = await Promise.all([fetchStats(), fetchHealth()]);
|
||||
applyStats(stats);
|
||||
applyHealthSummary(health);
|
||||
const stamp = document.getElementById('dc-monitor-refresh-stamp');
|
||||
if (stamp) {
|
||||
const now = new Date();
|
||||
stamp.textContent = `updated ${now.toLocaleTimeString()}`;
|
||||
}
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for init.js to call once and re-call after each refreshAll cycle
|
||||
window.refreshMonitoringWidgets = refresh;
|
||||
|
||||
// Auto-refresh on the STATS interval (separate from full DASHBOARD refresh)
|
||||
setInterval(refresh, (typeof DC !== 'undefined' && DC.POLL && DC.POLL.STATS) || 5000);
|
||||
|
||||
// Refresh once on first script load (init.js also calls this; double-call is harmless)
|
||||
setTimeout(refresh, 200);
|
||||
|
||||
})();
|
||||
@@ -3852,6 +3852,7 @@ button:focus-visible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
padding: 40px 0 20px;
|
||||
margin-top: 48px;
|
||||
@@ -3873,3 +3874,7 @@ button:focus-visible {
|
||||
height: 140px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.footer-legal { display: flex; gap: 14px; font-size: 0.8rem; }
|
||||
.footer-legal a { color: var(--muted); text-decoration: none; }
|
||||
.footer-legal a:hover, .footer-legal a:focus-visible { color: var(--accent); text-decoration: underline; }
|
||||
|
||||
Vendored
+91
-91
File diff suppressed because one or more lines are too long
@@ -939,6 +939,10 @@
|
||||
<footer class="dashcaddy-footer">
|
||||
<span class="footer-copy">© <span id="footer-year"></span></span>
|
||||
<img src="/assets/sami7777-logo.png" alt="samiahmed7777" class="footer-logo">
|
||||
<nav class="footer-legal" aria-label="Legal">
|
||||
<a href="/legal/terms">Terms of Service</a>
|
||||
<a href="/legal/privacy">Privacy Policy</a>
|
||||
</nav>
|
||||
</footer>
|
||||
|
||||
<!-- xterm.js for container exec/shell -->
|
||||
|
||||
+22
-11
@@ -249,20 +249,31 @@
|
||||
// back to window._showTotpOverlay() in `show()` below.
|
||||
window.__dc_049_handled = true;
|
||||
|
||||
function isAllowedReturnUrl(returnUrl) {
|
||||
try {
|
||||
const parsed = new URL(returnUrl, window.location.origin);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
|
||||
if (parsed.origin === window.location.origin) return true;
|
||||
if (parsed.protocol !== 'https:') return false;
|
||||
|
||||
// globals.js is concatenated before this module in core.js, so SITE is
|
||||
// available here. Permit exact hosts and subdomains under the configured
|
||||
// private TLD (for example plex.sami), while rejecting lookalikes such as
|
||||
// plex.sami.evil.example.
|
||||
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||
return parsed.hostname === suffix.slice(1) || parsed.hostname.endsWith(suffix);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('auth') === 'required') {
|
||||
// Save returnUrl the same way totp-auth.js does, so both paths share state.
|
||||
// We don't have access to the SITE constant here (it lives in globals.js's
|
||||
// module scope), so we use a conservative origin-only check. Caddy's
|
||||
// forward_auth already validates the request origin upstream.
|
||||
// Preserve the gated service destination so submitTotpCode() can append
|
||||
// the one-time SSO handoff token and return the browser to that host.
|
||||
const returnUrl = urlParams.get('return');
|
||||
if (returnUrl) {
|
||||
try {
|
||||
const parsed = new URL(returnUrl, window.location.origin);
|
||||
if (parsed.origin === window.location.origin) {
|
||||
try { sessionStorage.setItem('totp_redirect', returnUrl); } catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
if (returnUrl && isAllowedReturnUrl(returnUrl)) {
|
||||
try { sessionStorage.setItem('totp_redirect', returnUrl); } catch (_) {}
|
||||
}
|
||||
// Clean URL — happens after we've captured the redirect
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
|
||||
+28
-1
@@ -35,6 +35,24 @@
|
||||
if (overlay) overlay.classList.remove('show');
|
||||
}
|
||||
|
||||
function buildSsoHandoffTarget(redirect, token) {
|
||||
const parsed = new URL(redirect, window.location.origin);
|
||||
if (parsed.origin === window.location.origin) return parsed.toString();
|
||||
|
||||
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||
const isPrivateHost = parsed.hostname === suffix.slice(1) || parsed.hostname.endsWith(suffix);
|
||||
if (parsed.protocol !== 'https:' || !isPrivateHost) return null;
|
||||
if (!token) return parsed.toString();
|
||||
|
||||
const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
parsed.pathname = '/dashcaddy-sso';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
parsed.searchParams.set('token', token);
|
||||
parsed.searchParams.set('return', returnPath);
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
// Setup digit input UX
|
||||
const container = document.getElementById('totp-digits');
|
||||
if (container) {
|
||||
@@ -91,7 +109,16 @@
|
||||
const redirect = safeSessionGet('totp_redirect');
|
||||
if (redirect) {
|
||||
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
||||
window.location.href = redirect;
|
||||
// .sami is an unregistered TLD, so browsers silently drop the
|
||||
// Domain=.sami session cookie on any OTHER *.sami subdomain (they
|
||||
// treat "sami" as the effective public suffix, same protection
|
||||
// that blocks a Domain=.com supercookie). The target service can't
|
||||
// see our session cookie no matter how it's built, so instead we
|
||||
// hand it a one-time token in the URL; its login page exchanges
|
||||
// that for its own host-only cookie via /auth/sso-exchange.
|
||||
const target = buildSsoHandoffTarget(redirect, data.ssoToken);
|
||||
if (!target) return;
|
||||
window.location.href = target;
|
||||
return;
|
||||
}
|
||||
// Initialize dashboard
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Privacy Policy | DashCaddy</title><style>:root{color-scheme:dark;--card:#111c2e;--text:#e8edf5;--muted:#aab7ca;--accent:#68a4ff;--border:#263750}*{box-sizing:border-box}body{margin:0;background:linear-gradient(145deg,#07101d,#101b31);color:var(--text);font:16px/1.7 system-ui,sans-serif}main{width:min(900px,calc(100% - 32px));margin:48px auto;padding:clamp(24px,5vw,56px);background:var(--card);border:1px solid var(--border);border-radius:18px}h1{margin:0;font-size:clamp(2rem,5vw,3rem)}h2{margin-top:2rem;color:#fff}h3,strong{color:var(--text)}p,li{color:var(--muted)}a{color:var(--accent)}.eyebrow{color:var(--accent);font-weight:700;text-transform:uppercase}.notice{border-left:3px solid var(--accent);background:#0b1628;padding:12px 16px}footer{margin-top:42px;padding-top:22px;border-top:1px solid var(--border);display:flex;flex-wrap:wrap;gap:20px}</style></head><body><main><div class="eyebrow">DashCaddy Legal</div><h1>Privacy Policy</h1><p><strong>Effective and last updated:</strong> July 31, 2026</p><p class="notice">This GDPR-aware policy describes DashCaddy v1.0. It is not legal advice and may be refined following professional review.</p>
|
||||
<h2>1. Controller and contact</h2><p>Sami Ahmed, operator of DashCaddy, controls personal data collected for subscriptions, licensing, and operation. Contact <a href="mailto:privacy@sami-ahmed.net">privacy@sami-ahmed.net</a>. DashCaddy has no separate Data Protection Officer; this is the privacy contact.</p>
|
||||
<h2>2. Data collected</h2><h3>Account, login, and billing</h3><ul><li>Email address for login, license delivery, support, billing, and essential notices.</li><li>Subscription status, Stripe customer/session IDs, product, payment status, dates, and refunds. <strong>We do not receive or store full card numbers or security codes.</strong></li></ul><h3>License and server metadata</h3><ul><li>License key, tier, activation/expiry dates, and machine/host metadata embedded in or associated with the license.</li><li>Connection metadata needed to validate and secure licenses, such as IP address, timestamp, host/machine identifier, version, and request outcome.</li><li>The key containing machine metadata is stored locally in <code>data/credentials.json</code> and on the operator’s license server.</li></ul><h3>Optional Tailscale data</h3><p>Only if enabled, DashCaddy sends coordination API requests and may process Tailscale device IDs, tailnet/user IDs, names/status, and minted device or pre-auth keys. Keys are stored only as needed for the configured integration or share flow. Tailscale independently processes data under its terms.</p><h3>Support</h3><p>We collect messages and diagnostics you voluntarily provide. Do not send passwords, private keys, or unrelated personal data.</p>
|
||||
<h2>3. Data not intentionally collected</h2><p>The hosted licensing service does not intentionally collect proxied content, DNS query history, injected credentials, or card details. Credentials and local configuration remain customer-controlled unless deliberately provided for support. v1.0 makes no automated decisions with legal or similarly significant effects.</p>
|
||||
<h2>4. Purposes and GDPR lawful bases</h2><ul><li><strong>Contract:</strong> licenses, authentication, optional features, billing/refunds, and support.</li><li><strong>Legitimate interests:</strong> per-host enforcement, fraud/abuse prevention, security, troubleshooting, and proportionate product improvement.</li><li><strong>Legal obligation:</strong> required transaction/tax records and valid legal requests.</li><li><strong>Consent:</strong> optional marketing and integrations where consent is appropriate. Consent may be withdrawn without affecting earlier lawful processing.</li></ul>
|
||||
<h2>5. Sharing and processors</h2><p>We do not sell personal data. Necessary disclosures are to:</p><ul><li><strong>Stripe</strong> for Checkout, billing, fraud prevention, receipts, and refunds. Card data goes directly to Stripe.</li><li><strong>Tailscale</strong> only when you configure/use the integration, for coordination and device/key operations.</li><li><strong>Our email delivery provider</strong> for login, license, billing, security, and support email; it receives the address and message content.</li></ul><p>We may disclose data when legally required, to protect rights/safety, or in a business transfer with safeguards. We do not otherwise share personal data except as described in this policy.</p>
|
||||
<h2>6. International transfers</h2><p>Processors may handle data outside your country. Where GDPR applies, we will use a legally recognized transfer mechanism where one is required, such as an adequacy decision or Standard Contractual Clauses. Contact us for information about safeguards applicable to your data.</p>
|
||||
<h2>7. Retention</h2><ul><li><strong>License keys and host metadata:</strong> life of subscription plus 30 days after cancellation, then deleted or irreversibly anonymized unless law requires longer.</li><li><strong>Billing records:</strong> as required for tax, accounting, chargebacks, and fraud prevention.</li><li><strong>Connection/security logs:</strong> normally no more than 30 days unless an incident requires preservation.</li><li><strong>Support records:</strong> while active and normally up to 12 months afterward.</li><li><strong>Optional Tailscale keys:</strong> until expired, used/revoked, share removal, or integration disablement, subject to Tailscale retention.</li></ul><p>Backups may retain deleted data for a limited rotation and are restored only for disaster recovery.</p>
|
||||
<h2>8. Security</h2><p>We use reasonable safeguards and data minimization, but no system is completely secure. DashCaddy does not claim SOC 2, HIPAA, PCI-DSS, or another audited certification. Stripe Checkout processes cards; card data never touches DashCaddy servers.</p>
|
||||
<h2>9. GDPR and other privacy rights</h2><p>Depending on location, you may request access, correction, deletion, restriction, objection, withdrawal of consent, and data portability in a structured machine-readable format, and complain to your supervisory authority. Email <a href="mailto:privacy@sami-ahmed.net">privacy@sami-ahmed.net</a> with “Privacy Request.” We may verify identity. We aim to respond within 30 days (one month), explain lawful extensions/refusals, and normally charge no fee. Without a central account, we search using identifiers you provide.</p>
|
||||
<h2>10. Children, cookies, and marketing</h2><p>DashCaddy is not directed to children under 16. Checkout/login may use strictly necessary cookies. We request consent before non-essential analytics/marketing cookies where required. Marketing email is optional and includes unsubscribe.</p>
|
||||
<h2>11. Changes and contact</h2><p>Revisions will show a new date, with reasonable notice for material changes. Questions and rights requests: <a href="mailto:privacy@sami-ahmed.net">privacy@sami-ahmed.net</a>.</p><footer><a href="/legal/terms">Terms of Service</a><a href="mailto:privacy@sami-ahmed.net">Contact</a><a href="/">Back to DashCaddy</a></footer></main></body></html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Terms of Service | DashCaddy</title><style>:root{color-scheme:dark;--bg:#09111f;--card:#111c2e;--text:#e8edf5;--muted:#aab7ca;--accent:#68a4ff;--border:#263750}*{box-sizing:border-box}body{margin:0;background:linear-gradient(145deg,#07101d,#101b31);color:var(--text);font:16px/1.7 system-ui,sans-serif}main{width:min(900px,calc(100% - 32px));margin:48px auto;padding:clamp(24px,5vw,56px);background:var(--card);border:1px solid var(--border);border-radius:18px}h1{margin:0;font-size:clamp(2rem,5vw,3rem)}h2{margin-top:2rem;color:#fff}p,li{color:var(--muted)}strong{color:var(--text)}a{color:var(--accent)}.eyebrow{color:var(--accent);font-weight:700;text-transform:uppercase}.notice{border-left:3px solid var(--accent);background:#0b1628;padding:12px 16px}footer{margin-top:42px;padding-top:22px;border-top:1px solid var(--border);display:flex;flex-wrap:wrap;gap:20px}</style></head><body><main><div class="eyebrow">DashCaddy Legal</div><h1>Terms of Service</h1><p><strong>Effective and last updated:</strong> July 31, 2026</p><p class="notice">These Terms are a general launch document and are not legal advice. The operator may revise them following professional legal review.</p>
|
||||
<h2>1. Agreement and operator</h2><p>These Terms govern your purchase, installation, and use of DashCaddy software and related hosted licensing services (the “Service”), operated by Sami Ahmed (“DashCaddy,” “we,” “us,” or “our”). By purchasing, activating, or using DashCaddy, you agree to these Terms and the <a href="/legal/privacy">Privacy Policy</a>. If acting for an organization, you represent that you can bind it.</p>
|
||||
<h2>2. License grant</h2><p>Subject to payment and these Terms, we grant a limited, revocable, non-exclusive, non-sublicensable, non-transferable license to install and use DashCaddy on <strong>one host per license</strong> for the subscription term. A license may be moved to a replacement host with approval, but not shared, resold, rented, or used concurrently on multiple hosts. DashCaddy retains all ownership and intellectual-property rights.</p><p>The license key embeds or is associated with machine metadata. A copy is stored on the licensed host in <code>data/credentials.json</code> and on our license server for validation and enforcement.</p>
|
||||
<h2>3. Acceptable use</h2><p>You must use DashCaddy lawfully and are responsible for connected systems. You must not:</p><ul><li>use proxy, DNS, credential-injection, sharing, or Tailscale features for unauthorized access, traffic interception, evasion, malware, spam, phishing, or attacks;</li><li>overload, bypass, or interfere with the Service, licensing, authentication, or security;</li><li>reverse engineer or modify DashCaddy except where law expressly permits, or remove notices;</li><li>violate privacy, intellectual-property, sanctions, export-control, or other applicable law; or</li><li>provide data or credentials you lack authority to process.</li></ul><p>We may investigate abuse and suspend access when reasonably necessary to protect users, third parties, or the Service.</p>
|
||||
<h2>4. Availability and changes</h2><p>DashCaddy v1.0 is provided on a <strong>best-effort basis with no service-level agreement (SLA)</strong>, uptime guarantee, or guaranteed response time. Maintenance, failures, third-party outages, security events, and product changes may interrupt availability. Features may change or be discontinued with reasonable notice where practical.</p>
|
||||
<h2>5. Billing, renewal, and Refund policy</h2><p>Prices, billing periods, taxes, and renewal terms appear at checkout. Stripe processes payments; card details go directly to Stripe and never touch DashCaddy servers. Unless checkout states otherwise, subscriptions renew automatically until cancelled.</p><p><strong>Refund policy:</strong> request a pro-rated refund within 14 calendar days after initial purchase. It covers the unused portion of that initial period from the request date. After 14 days, and for renewals, payments are non-refundable except where law requires. Cancellation prevents renewal but does not itself create a refund.</p>
|
||||
<h2>6. Your systems and data</h2><p>You are responsible for backups, configuration, access control, and host security. DashCaddy manages sensitive proxy, DNS, and credential-injection settings; review changes. Data handling is described in the <a href="/legal/privacy">Privacy Policy</a>.</p>
|
||||
<h2>7. Suspension and Termination</h2><p>You may stop using DashCaddy and cancel renewal anytime. We may suspend or terminate for material breach, non-payment, unlawful or abusive use, or security risk, with notice and opportunity to cure where reasonably possible. On termination the license ends. Ownership, disclaimers, liability, and governing-law provisions survive.</p>
|
||||
<h2>8. Disclaimers</h2><p>To the maximum extent permitted by law, the Service is “as is” and “as available.” We disclaim implied warranties of merchantability, fitness, non-infringement, and uninterrupted or error-free operation. DashCaddy is not represented as certified for regulated workloads and makes no SOC 2, HIPAA, or similar compliance claim. Mandatory rights remain unaffected.</p>
|
||||
<h2>9. Limitation of liability</h2><p>To the maximum extent permitted by law, DashCaddy and its operator are not liable for indirect, incidental, special, consequential, exemplary, or punitive damages, or lost profits, revenue, data, goodwill, or business interruption. Aggregate liability will not exceed amounts paid for DashCaddy in the 12 months before the claim. Limits do not apply where prohibited or to liability that cannot lawfully be limited.</p>
|
||||
<h2>10. Indemnity</h2><p>Where permitted, you will indemnify us against third-party claims from your unlawful use, connected services or data, or breach, except to the extent caused by our unlawful conduct.</p>
|
||||
<h2>11. Governing law and disputes</h2><p>These Terms are governed by laws applicable in the operator’s principal place of business, without conflict-of-law rules. Courts there have jurisdiction, except consumers retain mandatory rights and forum protections in their country. Before filing, parties will attempt resolution by email for 30 days.</p>
|
||||
<h2>12. Changes and contact</h2><p>Material changes will be posted with a new effective date and reasonable advance notice where practical. Questions, cancellation, or refunds: <a href="mailto:privacy@sami-ahmed.net">privacy@sami-ahmed.net</a>.</p><footer><a href="/legal/privacy">Privacy Policy</a><a href="mailto:privacy@sami-ahmed.net">Contact</a><a href="/">Back to DashCaddy</a></footer></main></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=/legal/terms"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="canonical" href="/legal/terms"><title>Terms of Service | DashCaddy</title></head><body><p>DashCaddy Legal: Continue to the <a href="/legal/terms">Terms of Service</a>.</p></body></html>
|
||||
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"test": "node --test tests/*.test.js",
|
||||
"watch": "node build.js --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-1b7c08184e';
|
||||
const CACHE = 'dashcaddy-shell-4912a7d0d0';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'js', 'auth-gate.js'), 'utf8');
|
||||
const totpSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'totp-auth.js'), 'utf8');
|
||||
|
||||
function buildHandoffTarget(returnUrl, token, tld = '.sami') {
|
||||
const start = totpSource.indexOf(' function buildSsoHandoffTarget');
|
||||
const end = totpSource.indexOf('\n\n // Setup digit input UX', start);
|
||||
assert.notEqual(start, -1, 'handoff builder must exist');
|
||||
assert.notEqual(end, -1, 'handoff builder boundary must exist');
|
||||
const functionSource = totpSource.slice(start, end);
|
||||
const context = {
|
||||
URL,
|
||||
SITE: { tld },
|
||||
window: { location: { origin: 'https://status.sami' } },
|
||||
};
|
||||
const sandbox = { ...context, input: returnUrl, token, result: undefined };
|
||||
vm.runInNewContext(`${functionSource}\nresult = buildSsoHandoffTarget(input, token);`, sandbox);
|
||||
return sandbox.result;
|
||||
}
|
||||
|
||||
function capturedRedirect(returnUrl, tld = '.sami') {
|
||||
const stored = new Map();
|
||||
const query = new URLSearchParams({ auth: 'required', return: returnUrl });
|
||||
const location = {
|
||||
origin: 'https://status.sami',
|
||||
pathname: '/',
|
||||
search: `?${query.toString()}`,
|
||||
reload() {},
|
||||
};
|
||||
const context = {
|
||||
URL,
|
||||
URLSearchParams,
|
||||
SITE: { tld },
|
||||
sessionStorage: {
|
||||
setItem(key, value) { stored.set(key, value); },
|
||||
},
|
||||
document: { getElementById() { return null; } },
|
||||
setTimeout() {},
|
||||
console,
|
||||
window: {
|
||||
location,
|
||||
history: { replaceState() {} },
|
||||
},
|
||||
};
|
||||
context.window.window = context.window;
|
||||
vm.runInNewContext(source, context, { filename: 'auth-gate.js' });
|
||||
return stored.get('totp_redirect');
|
||||
}
|
||||
|
||||
test('preserves a return URL on another host under the configured private TLD', () => {
|
||||
assert.equal(capturedRedirect('https://plex.sami/web/'), 'https://plex.sami/web/');
|
||||
});
|
||||
|
||||
test('preserves a same-origin return URL', () => {
|
||||
assert.equal(capturedRedirect('https://status.sami/settings'), 'https://status.sami/settings');
|
||||
});
|
||||
|
||||
test('rejects lookalike domains, plaintext cross-host URLs, and non-web schemes', () => {
|
||||
assert.equal(capturedRedirect('https://plex.sami.evil.example/'), undefined);
|
||||
assert.equal(capturedRedirect('http://plex.sami/'), undefined);
|
||||
assert.equal(capturedRedirect('javascript:alert(1)'), undefined);
|
||||
});
|
||||
|
||||
test('accepts relative same-origin paths and protocol-relative HTTPS private hosts', () => {
|
||||
assert.equal(capturedRedirect('/settings'), '/settings');
|
||||
assert.equal(capturedRedirect('//plex.sami/web/'), '//plex.sami/web/');
|
||||
});
|
||||
|
||||
test('normalizes a configured TLD without a leading dot', () => {
|
||||
assert.equal(capturedRedirect('https://plex.sami/web/', 'sami'), 'https://plex.sami/web/');
|
||||
});
|
||||
|
||||
test('builds the generic cross-host SSO landing URL and preserves the final path', () => {
|
||||
assert.equal(
|
||||
buildHandoffTarget('https://router.sami/config?tab=network#dns', 'one-time'),
|
||||
'https://router.sami/dashcaddy-sso?token=one-time&return=%2Fconfig%3Ftab%3Dnetwork%23dns',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not create cross-host handoffs for plaintext or lookalike destinations', () => {
|
||||
assert.equal(buildHandoffTarget('http://router.sami/', 'one-time'), null);
|
||||
assert.equal(buildHandoffTarget('https://router.sami.evil.example/', 'one-time'), null);
|
||||
});
|
||||
|
||||
test('same-origin and tokenless destinations keep their direct URL', () => {
|
||||
assert.equal(buildHandoffTarget('/settings', 'one-time'), 'https://status.sami/settings');
|
||||
assert.equal(buildHandoffTarget('https://router.sami/config', ''), 'https://router.sami/config');
|
||||
});
|
||||
Reference in New Issue
Block a user