From 4d3c7a7521cbc2d4757dbea8c20cd9b966bdddbc Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 19 Sep 2026 12:12:10 -0400 Subject: [PATCH] feat: add dataset consent and primary character model --- README.md | 6 +- docs/design-records/t05-dataset-consent.md | 25 ++ docs/parallel-worktrees.md | 24 +- docs/roadmap.md | 44 ++- docs/verification.md | 2 +- scripts/smoke-convex.ts | 2 +- scripts/smoke-restore-marker.ts | 4 + src/convex/functions.ts | 409 ++++++++++++++++++-- src/convex/schema.ts | 48 +++ src/convex/status.ts | 44 +-- src/lib/domain/model.ts | 18 + src/lib/server/application.ts | 22 +- src/lib/server/sso.ts | 16 +- src/lib/server/store.ts | 43 +- src/routes/auth/eve/+server.ts | 93 ++++- src/routes/auth/eve/callback/+server.ts | 51 ++- src/routes/characters/+page.server.ts | 14 +- src/routes/characters/+page.svelte | 55 ++- src/routes/characters/invalidate/+server.ts | 27 ++ src/routes/characters/primary/+server.ts | 27 ++ tests/convex.test.ts | 208 ++++++++++ tests/domain.test.ts | 6 + tests/sso.test.ts | 18 + 23 files changed, 1068 insertions(+), 138 deletions(-) create mode 100644 docs/design-records/t05-dataset-consent.md create mode 100644 src/routes/characters/invalidate/+server.ts create mode 100644 src/routes/characters/primary/+server.ts diff --git a/README.md b/README.md index 0aeadc6..a5771b9 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,13 @@ Use [the live deployment guide](docs/live-setup.md) for `dev.evebot.app`, the re `APP_AUTH_ISSUER`, `APP_AUTH_JWKS` and `CONVEX_BRIDGE_SECRET` must be set in **Convex's environment**, not merely the Docker process environment. The configure script handles these three. Custom JWT audience is `eve-intelligence`; the public JWKS data URI avoids a boot dependency on the web server. -6. Register an EVE application and exact callback at . Set `EVE_CLIENT_ID`, `EVE_CALLBACK_URL`, and `EVE_CLIENT_SECRET` for a confidential web client. A public PKCE client leaves the secret blank. Initial requested scope is only `esi-assets.read_assets.v1`. +6. Register an EVE application and exact callback at . Set `EVE_CLIENT_ID`, `EVE_CALLBACK_URL`, and `EVE_CLIENT_SECRET` for a confidential web client. A public PKCE client leaves the secret blank. Initial login requests `esi-assets.read_assets.v1`; later dataset upgrades preserve only server-allowlisted existing scopes. 7. Set `ESI_USER_AGENT` with your actual operator contact. Optional `TYPESAFE_API_KEY` enables server-side JEV routing. Set public/internal Convex URLs as described in `.env.example`. 8. Build the fixture reference database and start `bun run dev`. Connect a character, synchronize it, and add more characters with **Connect EVE Character**. Each connects explicitly to the current application user. **Important:** the fixture DB contains only a handful of types. Live assets absent from it remain explicitly unpriced/unknown. Do not describe a fixture-backed report as a complete real-character valuation. See [full-import handoff](docs/reference-operations.md) before ingesting real releases. -The first verified EVE character creates an application user; there is no EVE-account-wide lookup. Reconnecting a character to a different application user or with a changed owner hash fails closed. Account merging/recovery and unlink/revocation UX are not yet implemented; this is a deployment limitation, not an implicit ownership transfer policy. +The first verified EVE character creates an application user and is the sole signed-out login character; additional linked characters are data-only until an authenticated user switches the primary. Reconnecting a character to a different application user or with a changed owner hash fails closed. Local data-only-character disconnection is implemented, but remote EVE revocation, unlink/delete, transfer recovery, and account merging/recovery remain deferred. ## Single-host Docker Compose @@ -116,6 +116,6 @@ docs/research/ current official-source evidence and captured ESI con - Location IDs are shown explicitly as unresolved. Fitted/nested assets are distinct ESI items; no contents multiplier is applied to ship prices. - Conversations/execution summaries persist for authenticated live users; this first UI does not yet provide a history browser. A pending guest request is kept only in tab `sessionStorage` and rechecked after login/sync. - Reference-source state is local to the worker/artifact volume for this milestone; future Convex jobs may initiate/check it without extracting archives inside Convex. -- Add snapshot/session/execution retention, unlink/revocation, key rotation and restore-tested backups before multi-user production. Preserve encryption keys with backups: losing them loses access to stored credentials. +- Add snapshot/session/execution retention, remote revocation and full unlink/delete lifecycle, key rotation and restore-tested backups before multi-user production. Preserve encryption keys with backups: losing them loses access to stored credentials. EVE Online is a trademark of CCP Games. This project is not endorsed by CCP. diff --git a/docs/design-records/t05-dataset-consent.md b/docs/design-records/t05-dataset-consent.md new file mode 100644 index 0000000..c92e6f9 --- /dev/null +++ b/docs/design-records/t05-dataset-consent.md @@ -0,0 +1,25 @@ +# T05 design record: dataset authorization and incremental consent + +**Status:** local prototype implementation; not a production-readiness or public-launch approval. + +## Primary decision + +An application user has exactly one **primary** linked character. The primary is the only character that can authenticate the site while signed out. Additional linked characters are **data-only** and cannot authenticate signed out. While authenticated, the user can designate any currently active linked character as the primary; the old primary is demoted in the same Convex transaction. + +The active authenticated application session is sufficient to link another character. The callback binds to that initiating session and revalidates it again in the persistence transaction. A character ID supplied by a browser is only a server-resolved selector. A verified owner continuity claim is per character and never groups EVE characters into an account. + +## Consent and authority + +- Capability definitions select required scopes. The application route combines those with only server-allowlisted scopes already granted to the selected linked character when upgrading/reconnecting. +- OAuth state is one-time and records intent (`initial_login`, `link_character`, `upgrade_dataset`, or `reconnect`), capability, requested/preserved scopes, target character and initiating session hash. +- The verified provider grant, not the requested scope list, is persisted as authority. Scalar and array `scp` claims are normalized by SSO verification. Reduced grants are accepted but block/invalidate affected datasets and remove old asset generations from reads. +- Character records carry role, authorization status, grant revision, credential version and authorization epoch. Dataset statuses carry their own status and matching fences. Local cancellation revokes authority, clears active asset generations, removes credentials and quarantines character jobs where present. +- Owner mismatch never transfers data. The old character is quarantined, credentials and active data access are fenced, and future jobs are quarantined. Full transfer-to-new-owner recovery is explicitly deferred. + +Session replacement is performed transactionally with login persistence where possible: the initiating session is revoked as the new session is inserted. OAuth code consumption remains one-time, and callback failures never blindly replay a code. + +## Prototype data policy and deferrals + +This repository is pre-production and has no users beyond the operator's test character. Schema/model changes are development-only: when local data makes a change costly, wipe/reseed it rather than adding earliest-character migration, lazy-normalization or retention-compatibility machinery. The first linked character is created primary directly; no legacy migration workflow is implemented. + +The local prototype keeps essential correctness/security boundaries: server-derived authorization, tenant isolation, scope allowlists, one-time OAuth, no token leakage, fail-closed owner checks, dataset/read fencing and safe fixed callback errors. It intentionally defers T01 production evidence and full lifecycle work: CI/deployed artifact proof, browser failure injection, restore/migration rollback rehearsals, retention/GC, public quotas/admission, production session/orphan recovery policy, remote revocation outbox, unlink/delete, and complete transfer recovery. T06 typed transport and T07 durable worker transport are not implemented here. Production migration, retention, rollback and public-launch gates remain mandatory after prototype approval and before P. diff --git a/docs/parallel-worktrees.md b/docs/parallel-worktrees.md index 2781518..b05228b 100644 --- a/docs/parallel-worktrees.md +++ b/docs/parallel-worktrees.md @@ -2,9 +2,11 @@ **Status:** this document is the approved task plan. T01–T04 have since been worked in parallel; see [first-wave integration evidence](first-wave-verification.md) for implemented scope, checks and remaining gates. This plan itself does not authorize deployment or public signup. +**Prototype order:** the project is pre-production with no users beyond the operator's test character. Prioritize a functional disposable local prototype first: first login, authenticated linking, capability consent/upgrades, reduced-grant invalidation, primary switching, cancellation, sync/read fencing and tenant isolation. Defer CI/deployed-artifact proof, browser failure injection, restore/migration rollback rehearsals, retention/GC, public quotas/admission, production hardening and similar release evidence until the operator approves that prototype. Essential server-derived authorization, tenant isolation, scope allowlists, one-time OAuth, no token leakage and fail-closed ownership remain prototype requirements. Before public launch, wipe/reseed local data rather than add costly migration/retention compatibility; this exception ends at production, where migration/retention gates remain mandatory. + **Scope:** the seven work items in [roadmap §15](roadmap.md#15-first-implementation-batch-after-this-plan), split into independently reviewable tasks. The roadmap remains authoritative for security, correctness and definition of done. Later milestones and the remaining M0/public hardening stay in the backlog. -**Planning baseline:** `fdcc486` (`Initial Commit`). The roadmap records 97 passing tests; this planning pass did not rerun them. Reconfirm the baseline before implementation. Existing `jobs`, `userPreferences` and `referenceSyncMetadata` tables are placeholders, not services. +**Planning baseline:** `fdcc486` (`Initial Commit`). The roadmap records an earlier 97-test gate; each prototype change reruns the current local suite. Existing `jobs`, `userPreferences` and `referenceSyncMetadata` tables are placeholders, not services. ## Execution model @@ -13,7 +15,7 @@ - Use one integration owner for shared-file patches. Each task supplies its own required schema/store/wiring changes and tests; the integration owner serializes those patches and reruns the combined suite. This is a role, not permission to postpone authorization until integration. - Prefer feature-local modules and tests over expanding central files. Proposed paths are implementation boundaries, not established APIs. Do not undertake a general architecture rewrite. - Ready tasks need not all run at once. Assign available agents to independent lanes; more worktrees do not remove dependency or shared-file constraints. -- Every worktree uses synthetic users, isolated Convex state, separate ports/Compose project names, output directories and keys. Do not copy `.env.local`, production volumes, grants or live instance credentials. Never run existing live smoke scripts without reviewing their targets and obtaining consent. +- Every worktree uses synthetic users, isolated Convex state, separate ports/Compose project names, output directories and keys. Do not copy `.env.local`, production volumes, grants or live instance credentials. Never run existing live smoke scripts without reviewing their targets and obtaining consent. Disposable local data may be wiped/reseeded after schema/auth changes; do not build prototype-only migration machinery. - No branch enables public signup, changes the current ingress gate, performs real-user write smoke tests, or commits secrets. New functionality stays behind the server-side gate from T01 until its acceptance criteria pass. ## Task graph and suggested waves @@ -39,12 +41,12 @@ Sizes describe scope, not time estimates. Split an L task further if its contrac **Suggested schedule (dependency edges, not wave numbers, determine readiness):** -1. Start **T01 + T02 + T03 + T04** independently. -2. Start **T05 + T06** once their inputs land. T03 can continue independently. -3. Start **T07 + T08** once their inputs land. Serialize any ESI/application wiring overlap. -4. Start **T09 + T13** once their inputs land: this is the inventory/finance split. Wallet balances do not wait for inventory queries. -5. Start **T10 + T11 + T12** once T09 lands; T13 can continue alongside them. -6. Run **T14** on the merged result. Passing it permits only the agreed private slice, not public beta or a complete wealth claim. +1. **Local prototype wave:** land the minimum T01/T02 contracts and gates needed for T05, then prioritize **T05 + T06** and the existing local asset flow. T03/T04 can continue independently where they unblock functionality. +2. Start **T07 + T08** once their inputs land. Serialize any ESI/application wiring overlap; do not expand into production worker/restore evidence yet. +3. Start **T09 + T13** once their inputs land: this is the inventory/finance split. Wallet balances do not wait for inventory queries. +4. Start **T10 + T11 + T12** once T09 lands; T13 can continue alongside them. +5. **Prototype approval gate:** operator reviews functional local flows and decides whether to invest in operations/release evidence. +6. **Post-approval production wave:** run T01's deferred CI/deployed proof, browser failure injection, backup/restore/migration rollback, retention/GC, quotas/admission and hardening, then run T14 on the approved merged result. Passing prototype tests alone never permits public beta. ## Shared-file ownership and merge discipline @@ -108,11 +110,11 @@ Also freeze the first inventory query/result/filter/cursor contract, reference/l **Own:** SSO/security/auth routes, dataset capability gates, character management status/actions, initial schema/store/status changes and dedicated auth tests. -**Deliver:** persisted dataset status and grant revisions; server-allowlisted capability scopes; one-time OAuth binding to requested scopes, operation/link intent and initiating session; verified actual grants; deliberate preservation of required existing scopes. Support upgrade/reconnect, reduced/denied grants, local dataset cancellation/invalidation and visible missing requirements. +**Deliver:** persisted per-dataset status, a single primary login role plus data-only linked roles, grant revisions and credential/authorization epochs; server-allowlisted capability scopes; one-time OAuth binding to requested scopes, operation/link intent and initiating session; verified actual grants; deliberate preservation of required existing scopes. Support upgrade/reconnect, reduced/denied grants, authenticated primary switching, local dataset cancellation/invalidation and visible missing requirements. Keep wallet behind its release capability. -Implement the identity enforcement needed by the new pipeline: revalidate linking authority at persistence, trusted-character step-up for credential additions, local transfer/revocation quarantine and job/cache invalidation. Callback persistence/session replacement must be atomic where possible or explicitly compensating with bounded orphan cleanup. Uncertain consumed-code outcomes require recovery, not blind replay. Full unlink/delete/session-management UX remains later M0 work. +Implement the prototype identity enforcement needed by the pipeline: revalidate linking authority at persistence, local owner/revocation quarantine and job/cache invalidation, and atomic session replacement where practical. Never replay a consumed OAuth code; full transfer-to-new-owner recovery, unlink/delete/session-management UX and production orphan/retention policy remain deferred. -**Done:** scalar/array scopes, stale link sessions, same-character reauth, distinct owner bindings, reduced grants, persistence/cookie-delivery failures and borrowed selectors pass isolated tests. Transfer cannot expose prior-user data; quarantined credentials cannot service reads or future jobs. Schema migration is backward compatible and legacy missing state fails conservatively. +**Done:** scalar/array scopes, stale link sessions, primary-character reauthentication, signed-out data-only rejection, distinct owner bindings, reduced grants, primary switching, cancellation and borrowed selectors pass isolated tests. Transfer cannot expose prior-user data; quarantined credentials cannot service reads or future jobs. This pre-production branch may wipe/reseed local data instead of implementing legacy migrations/lazy normalization; production migration/retention gates remain required before P. ### T06 — Typed ESI transport extensions diff --git a/docs/roadmap.md b/docs/roadmap.md index 41b68fd..45449b0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,17 +1,18 @@ # Implementation roadmap **Status:** planning proposal, not implementation or a public-launch declaration. -**Baseline:** the operator has verified real EVE sign-in, asset synchronization, and matching Assets/chat reference valuations. The latest implementation gate has 97 passing tests. +**Baseline:** the operator has verified real EVE sign-in, asset synchronization, and matching Assets/chat reference valuations. The earlier implementation gate recorded 97 passing tests; local prototype checks are rerun as features land. **Evidence:** [architecture](architecture.md), [milestone 1](milestone.md), [verification](verification.md), [ESI research](research/eve.md), [expansion data/permission matrix](research/expansion-data-contracts.md), and [reference operations](reference-operations.md). ## 1. Decisions confirmed for this roadmap 1. **Inventory and personal finances deliver the next user-visible value.** -2. **Eventually support open public signup**, not just a private installation. Public signup does not make anybody's character data public. -3. **Keep EVE-only sign-in.** Explicitly linked characters remain attached to one application user; an owner hash is never an EVE-account grouping key. -4. **Include corporation features later**, behind a separate authorization/sharing design. -5. Retain Bun/TypeScript, SvelteKit/Svelte, Convex for application state, immutable reference SQLite, Infisical, and single-host Compose initially. Do not introduce another database or model provider without a measured need and a separate decision. -6. This roadmap is **read-only intelligence and planning**: no automatic market orders, ISK transfers, asset moves, industry-job submission, or ship movement. +2. **Work locally first.** The project is pre-production and currently has no users beyond the operator's test character. Functional local flows are the next gate; production-readiness evidence is deliberately deferred until the operator approves the prototype. Before public launch, wiping/reseeding development data is preferred over adding costly migration/retention compatibility. This exception ends at production/public launch: production migration, retention and rollback gates remain mandatory before P. +3. **Keep EVE-only sign-in.** One linked character is the sole primary/site-login character. Additional linked characters are data-only and cannot authenticate signed out; an authenticated user may switch the sole primary. An owner hash is never an EVE-account grouping key. +4. **Eventually support open public signup**, not just a private installation. Public signup does not make anybody's character data public. +5. **Include corporation features later**, behind a separate authorization/sharing design. +6. Retain Bun/TypeScript, SvelteKit/Svelte, Convex for application state, immutable reference SQLite, Infisical, and single-host Compose initially. Do not introduce another database or model provider without a measured need and a separate decision. +7. This roadmap is **read-only intelligence and planning**: no automatic market orders, ISK transfers, asset moves, industry-job submission, or ship movement. New service/table/path names below are proposed implementation boundaries, not existing APIs. Later phases need focused design and current contract verification before coding; they are not one large implementation task. @@ -19,7 +20,7 @@ New service/table/path names below are proposed implementation boundaries, not e | Area | Current implementation | Important gap | |---|---|---| -| Identity | Verified EVE SSO/PKCE, encrypted grants, explicit linking, opaque sessions, app JWTs | Recovery, unlink/delete, transfer lifecycle, incremental scopes, session management, multi-character live tests | +| Identity | Verified EVE SSO/PKCE, encrypted grants, one primary login character, data-only linked characters, authenticated primary switching, opaque sessions, app JWTs | Recovery, unlink/delete, transfer lifecycle, production session management, multi-character live tests | | Assets | Complete staged snapshots, tenant gates, exact reference totals | Real search/filtering, paging, containment, custom names, meaningful locations, history | | References | 53,001 types; recipes, dogma and system/gate tables; immutable publication | Runtime reader exposes only types/version; station table currently empty; search and domain-specific readers missing | | Prices | Global ESI average prices, held by the web process | Durable public feed coordination, regional/private books, history, depth allocation, fees | @@ -34,6 +35,14 @@ New service/table/path names below are proposed implementation boundaries, not e ## 3. Recommended delivery order +### Local-prototype phase (before production-readiness work) + +1. **Functional local prototype:** implement and exercise first login, authenticated linking, capability-selected consent/upgrades, reduced-grant invalidation, primary switching, cancellation, asset sync/read fencing and tenant isolation using disposable Convex data. +2. **Prototype approval:** the operator decides that the local flows are useful and stable enough to justify operational investment. +3. **Production-readiness phase:** only after approval, add CI/deployed-artifact proof, browser failure injection, restore/migration rollback rehearsals, retention/GC, public quotas/admission, lifecycle recovery, and other P/release evidence. + +Essential correctness and security boundaries remain in the prototype: server-derived authorization, tenant isolation, scope allowlists, one-time OAuth, no token leakage, fail-closed owner checks and no blind OAuth-code replay. Prototype work must not grow production operational machinery prematurely. + | Milestone | User-visible outcome | Dependencies | Relative size | |---|---|---|---| | M0 — Reliable account/data foundation | Clear connection/sync status; safe upgrades and recovery | Current baseline | L | @@ -98,12 +107,12 @@ Keep valuation methods distinct. Never quietly replace an average price with an ### Deliverables / work packages -**M0.1 — Reproducible release and safety baseline** -- Review secret exclusions and establish an operator-approved version-controlled baseline; no commit is performed by this planning work. -- Add isolated CI for tests/typecheck/build, deployed-artifact checks, migrations and browser flows. -- Add durable browser regression coverage for native POST forms, OAuth redirect/cookie paths, singleton/array scopes, linking, logout and pending-request resume. Use synthetic providers/users only in isolated test deployments, never the persistent live backend. -- Version application images; keep backwards-compatible migrations and an explicit rollback procedure. -- Define and implement server-side rollout gating. T01 provides an approved deployment configuration/capability allowlist with tests; it protects current routes, assistant dispatch, sync entry points and subscriptions. Durable jobs and any new capability still require their own reviewed entry points. Gates must protect server behavior, not merely hide UI controls. +**M0.1 — Local prototype baseline (production evidence deferred)** +- Review essential secret exclusions and establish an operator-approved local baseline; no commit is performed by this planning work. +- Run local tests/typecheck/build as useful feedback, but defer CI, deployed-artifact proof, browser failure injection, migration/rollback rehearsal and public launch evidence until prototype approval. +- Exercise native forms, OAuth redirect/cookie paths, singleton/array scopes, linking, primary switching, cancellation, tenant isolation and pending-request behavior with disposable synthetic/local data. +- Because there are no production users, wipe/reseed local data when schema/auth changes make migration compatibility costly; do not add legacy migration/lazy-normalization machinery for prototype-only rows. +- Keep server-side rollout gating and essential auth boundaries. Durable jobs and any new capability still require their own reviewed entry points later. **M0.2 — Dataset capabilities and incremental consent** - Extend the model/gate/store contracts for dataset-specific scopes and freshness. @@ -122,11 +131,12 @@ Keep valuation methods distinct. Never quietly replace an average price with an - Keep heavy reference extraction in its separate bounded worker; publish small reference health metadata to Convex only. Give each worker only its required secrets/capabilities; reference extraction needs no EVE grants, JEV key, JWT signing key or deployment-admin credential. **M0.4 — EVE-only identity lifecycle** -- Treat each linked character as a login credential under the existing model. Explain that anybody able to authenticate as one of those characters can access the application account: linking is not merely read-only data sharing. Test transfer, revocation, unlinking and loss of access. -- Require fresh proof through an already trusted login character for sensitive account actions; the new character being added is not sufficient step-up proof for adding a login credential. List/revoke sessions and detach credentials safely. Review host-bound cookie naming and test sibling-subdomain cookie injection/session fixation before public launch. +- Treat the first linked character as the sole login/primary credential under the existing model. Additional linked characters are data-only and cannot authenticate signed out; while authenticated, the user can designate another authorized linked character as the sole primary and the old primary is immediately demoted. Explain that anybody able to authenticate as the primary can access the application account: linking is not merely read-only data sharing. +- During the local prototype, test owner/revocation/cancellation fencing and primary switching. Full recovery, transfer, unlink/delete and production lifecycle evidence remain deferred until prototype approval. +- During the local prototype, an active authenticated application session is sufficient to link a data-only character or switch the primary. Revisit fresh proof for sensitive account actions, session listing/revocation, host-bound cookie naming and sibling-subdomain session-fixation tests during the post-prototype production-readiness phase. - Make session replacement and prior-session revocation atomic where possible, otherwise explicitly compensating. Revalidate the initiating link/session authority at persistence time. Test failures after grant/session persistence but before old-session revocation or browser-cookie delivery; the current callback spans separate operations. - Until an approved recovery mechanism exists, reject unlinking the final login character; account deletion is a separate confirmed action. No password/code-only login or recovery is introduced by this plan. If every linked character is inaccessible, explicitly accept/document loss of access or obtain approval for a different recovery policy before P. -- Reauthenticating an existing linked character restores the same application account. Explain that users must first sign into that account before linking a new alt. A signed-out login with an unlinked alt can create a separate application account; matching owner claims must not merge them. Document accidental-duplicate handling: authenticate the accounts separately, explicitly export/delete an unwanted account if chosen, then relink only after its binding is safely released. Do not silently migrate its history. +- Reauthenticating the current primary character restores the same application account; a data-only linked character cannot authenticate while signed out. Users must first sign into the account before linking a new alt. A signed-out login with an unlinked character can create a separate application account; matching owner claims must not merge them. For this pre-production prototype, reset/reseed disposable data instead of implementing an earliest-character migration or compatibility workflow. Production migration and accidental-duplicate handling remain launch gates. - A new owner of a transferred character must never inherit the old user's history or account. Quarantine/reconcile ownership changes and invalidate affected credentials/sessions; never merge accounts or trust character names as recovery proof. Test that a verified new owner can eventually obtain a separate active binding without the old row permanently blocking signup, and that unrelated old-user characters remain separate. - Disable local access/cancel jobs first when removing authorization, then attempt remote revocation and purge credentials. If retry requires retaining token material, use a short-lived encrypted revocation-only outbox with no active-sync access and an explicit deletion deadline. Report unavailable EVE revocation as a cleanup obligation, not successful remote revocation. @@ -317,7 +327,7 @@ Keep the initial branch focused; do not start every milestone at once. See the [ | Work item | Concrete output | Proof of completion | |---|---|---| -| 1. Baseline/release safety | Isolated test/deploy fixture and backup/migration rehearsal | Existing 97 tests remain green; restore runbook exercised without touching live test identities | +| 1. Local prototype baseline | Local test/check/build feedback and disposable isolated fixtures | Current tests remain green; production deploy/restore/migration evidence waits for prototype approval | | 2. Dataset, scope and identity contracts | Typed dataset status, scope-upgrade binding, grant revisions and revocation/transfer semantics | Scalar/array grants, reduced scopes, explicit linking, stale-session and job-invalidation tests | | 3. Durable asset job slice | Queue/worker/lease/refresh fencing for the existing asset endpoint | Kill/restart/duplicate/uncertain-refresh tests preserve good generations | | 4. Reference/location slice | Runtime search/group/system/station readers + verified station import | Versioned artifact tests; unknown/private locations stay unknown | diff --git a/docs/verification.md b/docs/verification.md index ffaf01a..57ed780 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -52,6 +52,6 @@ Audit regressions cover native-form header/CSRF boundaries, shared artifact-dire - Broad TypeSafe inference quality or the proposed 0.80 confidence threshold's calibration on EVE requests. - Compatibility with arbitrary future archive releases, station-data coverage, large-scale memory/performance benchmarks, or production-size market analytics. - End-to-end browser automation/visual accessibility audit; the web smoke verifies the built server and endpoints, not all browser interactions. -- A comprehensive TLS/proxy security audit, backup/restore drills, rotation/recovery, unlink/revocation UX, retention/GC, or multi-process refresh/job recovery. +- A comprehensive TLS/proxy security audit, backup/restore drills, rotation/recovery, remote revocation and full unlink/delete lifecycle, retention/GC, or multi-process refresh/job recovery. T05 adds local data-only-character cancellation only. These remain explicit deployment/incremental-development gates, not silent success assumptions. diff --git a/scripts/smoke-convex.ts b/scripts/smoke-convex.ts index 9f8b26f..de1f4cf 100644 --- a/scripts/smoke-convex.ts +++ b/scripts/smoke-convex.ts @@ -10,7 +10,7 @@ const url = process.env.CONVEX_SELF_HOSTED_URL!; const store = new ConvexStore(url, process.env.CONVEX_BRIDGE_SECRET!); const now = Date.now(); const hash = hashSecret(randomSecret()); const characterId = 90_000_000 + Math.floor(Math.random() * 1_000_000); -const user = await store.completeLogin({ characterId, name: 'Disposable Test Pilot', ownerHash: hashSecret(randomSecret()), scopes: ['esi-assets.read_assets.v1'], encryptedTokens: 'test-only-encrypted-placeholder', tokenExpiresAt: now + 60_000, sessionHash: hash, sessionExpiresAt: now + 60_000 }); +const user = await store.completeLogin({ characterId, name: 'Disposable Test Pilot', ownerHash: hashSecret(randomSecret()), scopes: ['esi-assets.read_assets.v1'], intent: 'initial_login', capability: 'asset_search', requestedScopes: ['esi-assets.read_assets.v1'], approvedExistingScopes: [], encryptedTokens: 'test-only-encrypted-placeholder', tokenExpiresAt: now + 60_000, sessionHash: hash, sessionExpiresAt: now + 60_000 }); assert.equal((await store.session(hash))?.userId, user.userId); const generation = await store.beginSync(user.userId, characterId); await store.stageAssets(user.userId, characterId, generation, 0, [{ itemId: '9223372036854775806', characterId, typeId: 34, quantity: 50, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar' }]); diff --git a/scripts/smoke-restore-marker.ts b/scripts/smoke-restore-marker.ts index bb8392d..bb5fdff 100644 --- a/scripts/smoke-restore-marker.ts +++ b/scripts/smoke-restore-marker.ts @@ -34,6 +34,10 @@ if (phase === 'write') { name: `Restore marker ${marker}`, ownerHash, scopes: ['esi-assets.read_assets.v1'], + intent: 'initial_login', + capability: 'asset_search', + requestedScopes: ['esi-assets.read_assets.v1'], + approvedExistingScopes: [], encryptedTokens: `restore-marker-placeholder:${marker}`, tokenExpiresAt: sessionExpiresAt, sessionHash, diff --git a/src/convex/functions.ts b/src/convex/functions.ts index 03acf70..48de8a2 100644 --- a/src/convex/functions.ts +++ b/src/convex/functions.ts @@ -6,6 +6,12 @@ import { import { v } from "convex/values"; import { assetValidator } from "./schema"; import { capabilityEnabled, parseBackendCapabilityAllowlist } from "../lib/server/release-gate"; +import { + CAPABILITY_DEFINITIONS, + SERVER_SCOPE_ALLOWLIST, + validateCapabilityScopes, +} from "../lib/domain/capabilities"; +import type { Capability } from "../lib/domain/model"; const MAX_ASSET_CHUNK = 250; const SYNC_LEASE_MS = 5 * 60 * 1000; @@ -14,6 +20,17 @@ const MAX_CHUNKS = 4_096; const MAX_ERROR_LENGTH = 160; const OAUTH_MAX_LIFETIME_MS = 10 * 60 * 1000; const SNAPSHOT_MAX_LIFETIME_MS = 60 * 60 * 1000; +const DATASET_SCOPE_REQUIREMENTS: Record = { + assets: ['esi-assets.read_assets.v1'], + wallet_balance: ['esi-wallet.read_character_wallet.v1'], + wallet_journal: ['esi-wallet.read_character_wallet.v1'], + wallet_transactions: ['esi-wallet.read_character_wallet.v1'], + blueprints: ['esi-characters.read_blueprints.v1'], + skills: ['esi-skills.read_skills.v1', 'esi-skills.read_skillqueue.v1'], +}; +const DATASET_NAMES = Object.keys(DATASET_SCOPE_REQUIREMENTS); +const CONSENT_INTENTS = ['initial_login', 'link_character', 'upgrade_dataset', 'reconnect'] as const; +const CONSENT_CAPABILITIES = Object.keys(CAPABILITY_DEFINITIONS) as Capability[]; const bridgeArgs = { bridgeSecret: v.string() }; const characterArgs = { userId: v.string(), characterId: v.number() }; @@ -84,6 +101,106 @@ async function findSessionByHash(ctx: any, sessionHash: string): Promise scope === [...right].sort()[index]); +} + +function validActualScopes(scopes: readonly string[]): boolean { + return new Set(scopes).size === scopes.length && scopes.every((scope) => SERVER_SCOPE_ALLOWLIST.includes(scope)); +} + +function datasetStatusesFor(character: any): any[] { + return character.datasetStatuses; +} + +function updateDatasetStatuses( + character: any, + scopes: readonly string[], + grantRevision: number, + authorizationEpoch: number, + credentialVersion: number, + timestamp: number, +): { statuses: any[]; assetsInvalidated: boolean } { + const previous = datasetStatusesFor(character); + const statuses: any[] = []; + let assetsInvalidated = false; + for (const dataset of DATASET_NAMES) { + const old = previous.find((entry: any) => entry.dataset === dataset); + const required = DATASET_SCOPE_REQUIREMENTS[dataset]!; + const authorized = required.every((scope) => scopes.includes(scope)); + if (!authorized) { + statuses.push({ + dataset, + status: 'blocked', + grantRevision, + authorizationEpoch, + credentialVersion, + invalidatedAt: timestamp, + ...(dataset === 'assets' ? {} : {}), + }); + if (dataset === 'assets' && (old?.status === 'ready' || old?.generation !== undefined || character.activeGeneration !== undefined)) assetsInvalidated = true; + continue; + } + // A newly authorized dataset has no coverage. Existing coverage remains + // usable only when its authority epoch/revision still matches. + const wasCurrent = old && old.grantRevision === grantRevision && old.authorizationEpoch === authorizationEpoch; + const status = wasCurrent ? old.status : old?.status === 'ready' ? 'ready' : 'never'; + statuses.push({ + dataset, + status, + grantRevision, + authorizationEpoch, + credentialVersion, + ...(status === 'ready' && old?.generation === undefined && dataset === 'assets' && character.activeGeneration !== undefined + ? { generation: character.activeGeneration } : {}), + ...(old?.generation !== undefined && status === 'ready' ? { generation: old.generation } : {}), + ...(old?.syncedAt === undefined ? {} : { syncedAt: old.syncedAt }), + ...(old?.error === undefined ? {} : { error: old.error }), + }); + } + return { statuses, assetsInvalidated }; +} + +function quarantinedDatasetStatuses(character: any, credentialVersion: number): any[] { + return DATASET_NAMES.map((dataset) => ({ + dataset, + status: 'quarantined', + grantRevision: character.grantRevision, + authorizationEpoch: character.authorizationEpoch + 1, + credentialVersion, + invalidatedAt: now(), + })); +} + +async function quarantineCharacter(ctx: any, character: any, reason = 'Authorization quarantined'): Promise { + const timestamp = now(); + const epoch = character.authorizationEpoch + 1; + await ctx.db.patch(character._id, { + authorizationStatus: 'quarantined', + authorizationEpoch: epoch, + datasetStatuses: quarantinedDatasetStatuses(character, character.credentialVersion), + state: 'error', + activeGeneration: undefined, + syncGeneration: undefined, + syncLeaseExpiresAt: undefined, + error: reason.slice(0, MAX_ERROR_LENGTH), + }); + const generations = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => + q.eq('userId', character.userId).eq('characterId', character.characterId)).collect(); + for (const generation of generations) { + if (generation.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: 'Authorization quarantined' }); + } + const jobs = await ctx.db.query('jobs').withIndex('by_user_status', (q: any) => q.eq('userId', character.userId)).collect(); + for (const job of jobs) { + if (job.characterId === character.characterId && !['succeeded', 'failed', 'cancelled'].includes(job.status)) { + await ctx.db.patch(job._id, { status: 'quarantined', updatedAt: timestamp, leaseExpiresAt: undefined }); + } + } + const token = await ctx.db.query('tokenRecords').withIndex('by_user_character', (q: any) => + q.eq('userId', character.userId).eq('characterId', character.characterId)).first(); + if (token) await ctx.db.delete(token._id); +} + function isUsableSession(session: any, timestamp = now()): boolean { return Boolean( session && @@ -125,6 +242,12 @@ export const bridgeCreateOAuth = mutationGeneric({ encryptedVerifier: v.string(), expiresAt: v.number(), returnTo: v.string(), + intent: v.string(), + capability: v.string(), + requestedScopes: v.array(v.string()), + approvedExistingScopes: v.array(v.string()), + initiatingSessionHash: v.optional(v.string()), + targetCharacterId: v.optional(v.number()), }, handler: async (ctx, args) => { assertBridge(args.bridgeSecret); @@ -136,6 +259,12 @@ export const bridgeCreateOAuth = mutationGeneric({ ) { throw new Error("Invalid OAuth state expiry"); } + if (!CONSENT_INTENTS.includes(args.intent as typeof CONSENT_INTENTS[number])) throw new Error("Invalid OAuth intent"); + if (!CONSENT_CAPABILITIES.includes(args.capability as Capability)) throw new Error("Invalid OAuth capability"); + if (!validActualScopes(args.requestedScopes)) throw new Error("Invalid OAuth requested scopes"); + if (!validActualScopes(args.approvedExistingScopes)) throw new Error("Invalid OAuth preserved scopes"); + const scopeCheck = validateCapabilityScopes(args.capability as Capability, args.requestedScopes, args.approvedExistingScopes); + if (!scopeCheck.valid) throw new Error("Invalid OAuth capability scopes"); // Keep this bounded cleanup opportunistic; retention/GC is intentionally // outside this bridge transaction. const expired = await ctx.db @@ -156,6 +285,12 @@ export const bridgeCreateOAuth = mutationGeneric({ encryptedVerifier: args.encryptedVerifier, expiresAt: args.expiresAt, returnTo: args.returnTo, + intent: args.intent, + capability: args.capability, + requestedScopes: args.requestedScopes, + approvedExistingScopes: args.approvedExistingScopes, + ...(args.initiatingSessionHash === undefined ? {} : { initiatingSessionHash: args.initiatingSessionHash }), + ...(args.targetCharacterId === undefined ? {} : { targetCharacterId: args.targetCharacterId }), }); return null; }, @@ -183,6 +318,12 @@ export const bridgeConsumeOAuth = mutationGeneric({ encryptedVerifier: state.encryptedVerifier, expiresAt: state.expiresAt, returnTo: state.returnTo, + intent: state.intent, + capability: state.capability, + requestedScopes: state.requestedScopes, + approvedExistingScopes: state.approvedExistingScopes, + ...(state.initiatingSessionHash === undefined ? {} : { initiatingSessionHash: state.initiatingSessionHash }), + ...(state.targetCharacterId === undefined ? {} : { targetCharacterId: state.targetCharacterId }), }; }, }); @@ -198,6 +339,12 @@ const completeLoginArgs = { tokenExpiresAt: v.number(), sessionHash: v.string(), sessionExpiresAt: v.number(), + intent: v.string(), + capability: v.string(), + requestedScopes: v.array(v.string()), + approvedExistingScopes: v.array(v.string()), + initiatingSessionHash: v.optional(v.string()), + targetCharacterId: v.optional(v.number()), }; /** @@ -209,83 +356,131 @@ export const bridgeCompleteLogin = mutationGeneric({ args: completeLoginArgs, handler: async (ctx, args) => { assertBridge(args.bridgeSecret); + const timestamp = now(); + if (!validActualScopes(args.scopes)) throw new Error("Invalid granted scopes"); + if (args.scopes.some((scope) => !args.requestedScopes.includes(scope))) throw new Error("Granted scopes exceed the bound request"); + if (!CONSENT_INTENTS.includes(args.intent as typeof CONSENT_INTENTS[number])) throw new Error("Invalid OAuth intent"); + if (!CONSENT_CAPABILITIES.includes(args.capability as Capability)) throw new Error("Invalid OAuth capability"); + if (!validActualScopes(args.requestedScopes)) throw new Error("Invalid OAuth requested scopes"); + if (!validActualScopes(args.approvedExistingScopes)) throw new Error("Invalid OAuth preserved scopes"); + const scopeCheck = validateCapabilityScopes(args.capability as Capability, args.requestedScopes, args.approvedExistingScopes); + if (!scopeCheck.valid) throw new Error("Invalid OAuth capability scopes"); + if (args.targetCharacterId !== undefined && args.targetCharacterId !== args.characterId) { + throw new Error("OAuth character mismatch"); + } + const byCharacterId = await ctx.db .query("characters") .withIndex("by_character_id", (q: any) => q.eq("characterId", args.characterId)) .collect(); - if (byCharacterId.some((character: any) => character.ownerHash !== args.ownerHash)) { - throw new Error("Character ownership conflict"); + const conflicting = byCharacterId.filter((character: any) => character.ownerHash !== args.ownerHash); + if (conflicting.length > 0) { + // Never attach a provider character to a new owner. Quarantine every + // old row in this transaction; recovery/transfer is not part of this + // local prototype. + for (const oldCharacter of conflicting) { + await quarantineCharacter(ctx, oldCharacter, "Character ownership changed; reconnect required"); + } + // Return a committed rejection so quarantine is not rolled back by a + // thrown Convex error. The server adapter converts this to a fixed + // callback failure without issuing a session. + return { userId: conflicting[0]!.userId, sessionId: "", rejected: "ownership_conflict" }; } const existingCharacter = byCharacterId[0] ?? null; + if (args.intent === "link_character" && existingCharacter) { + throw new Error("Character is already linked; use reconnect or upgrade"); + } let resolvedUserId: string; if (existingCharacter) { - if (existingCharacter.ownerHash !== args.ownerHash) { - throw new Error("Character ownership conflict"); - } if (args.userId !== undefined && args.userId !== existingCharacter.userId) { throw new Error("Character belongs to another user"); } resolvedUserId = existingCharacter.userId; + if (args.userId === undefined && existingCharacter.loginRole === "data_only") { + throw new Error("Data-only character cannot authenticate while signed out"); + } } else { - // A signed-out first login creates one app user for this character. A - // new character may only be attached when the current app user is - // explicitly supplied by the authenticated linking flow. resolvedUserId = args.userId ?? newIdentifier("user"); } const sameSession = await findSessionByHash(ctx, args.sessionHash); - if (sameSession) { - throw new Error("Session hash collision"); + if (sameSession) throw new Error("Session hash collision"); + + // Linking, upgrading and reconnecting are authorized by the still-live + // application session that initiated consent. + const preexistingUser = await ctx.db.query("users").withIndex("by_user_id", (q: any) => q.eq("userId", resolvedUserId)).first(); + if (args.userId !== undefined && !preexistingUser) throw new Error("User does not exist"); + let initiatingSession: any | null = null; + if (args.userId !== undefined) { + initiatingSession = args.initiatingSessionHash + ? await findSessionByHash(ctx, args.initiatingSessionHash) + : null; + if (!isUsableSession(initiatingSession, timestamp) || initiatingSession.userId !== args.userId) { + throw new Error("Initiating session is no longer valid"); + } } + if (args.intent === "upgrade_dataset" || args.intent === "reconnect") { + if (args.userId === undefined || args.targetCharacterId !== args.characterId) throw new Error("OAuth target is required"); + } + if (args.intent === "link_character" && args.userId === undefined) throw new Error("Authenticated session is required to link a character"); + const user = await ctx.db .query("users") .withIndex("by_user_id", (q: any) => q.eq("userId", resolvedUserId)) .first(); if (!user) { - if (args.userId !== undefined || existingCharacter) { - throw new Error("User does not exist"); - } - await ctx.db.insert("users", { userId: resolvedUserId, createdAt: now() }); + if (args.userId !== undefined || existingCharacter) throw new Error("User does not exist"); + await ctx.db.insert("users", { userId: resolvedUserId, createdAt: timestamp }); } let character = existingCharacter; - if (!character) { - character = await findCharacter(ctx, resolvedUserId, args.characterId); - } + if (!character) character = await findCharacter(ctx, resolvedUserId, args.characterId); + const oldScopes = character?.scopes ?? []; + const oldRevision = character?.grantRevision ?? 1; + const grantChanged = character ? !scopesEqual(oldScopes, args.scopes) : false; + const grantRevision = character ? oldRevision + (grantChanged ? 1 : 0) : 1; + const authorizationEpoch = character?.authorizationEpoch ?? 1; + const existingTokenForVersion = await ctx.db.query("tokenRecords").withIndex("by_user_character", (q: any) => q.eq("userId", resolvedUserId).eq("characterId", args.characterId)).first(); + const credentialVersion = Math.max(character?.credentialVersion ?? 0, existingTokenForVersion?.version ?? 0) + 1; + const updated = character + ? updateDatasetStatuses(character, args.scopes, grantRevision, authorizationEpoch, credentialVersion, timestamp) + : updateDatasetStatuses({ state: "never", scopes: [], datasetStatuses: [] }, args.scopes, grantRevision, authorizationEpoch, credentialVersion, timestamp); + + const basePatch = { + name: args.name, + scopes: args.scopes, + ownerHash: args.ownerHash, + loginRole: character ? character.loginRole : (args.userId === undefined ? "primary" : "data_only"), + authorizationStatus: "active" as const, + grantRevision, + credentialVersion, + authorizationEpoch, + datasetStatuses: updated.statuses, + ...(updated.assetsInvalidated ? { state: "error" as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined } : {}), + }; if (character) { - if (character.ownerHash !== args.ownerHash || character.userId !== resolvedUserId) { - throw new Error("Character ownership conflict"); - } - await ctx.db.patch(character._id, { - name: args.name, - scopes: args.scopes, - ownerHash: args.ownerHash, - }); + await ctx.db.patch(character._id, basePatch); } else { await ctx.db.insert("characters", { userId: resolvedUserId, characterId: args.characterId, - name: args.name, - ownerHash: args.ownerHash, - scopes: args.scopes, state: "never", + ...basePatch, }); } const tokenRecord = await ctx.db .query("tokenRecords") - .withIndex("by_user_character", (q: any) => - q.eq("userId", resolvedUserId).eq("characterId", args.characterId), - ) + .withIndex("by_user_character", (q: any) => q.eq("userId", resolvedUserId).eq("characterId", args.characterId)) .first(); if (tokenRecord) { await ctx.db.patch(tokenRecord._id, { encryptedTokens: args.encryptedTokens, tokenExpiresAt: args.tokenExpiresAt, scopes: args.scopes, - version: tokenRecord.version + 1, + version: credentialVersion, }); } else { await ctx.db.insert("tokenRecords", { @@ -294,7 +489,7 @@ export const bridgeCompleteLogin = mutationGeneric({ encryptedTokens: args.encryptedTokens, tokenExpiresAt: args.tokenExpiresAt, scopes: args.scopes, - version: 1, + version: credentialVersion, }); } @@ -305,6 +500,9 @@ export const bridgeCompleteLogin = mutationGeneric({ userId: resolvedUserId, expiresAt: args.sessionExpiresAt, }); + // Replacement is atomic with persistence; the callback never has to + // replay a code if cookie delivery later fails. + if (initiatingSession && !initiatingSession.revokedAt) await ctx.db.patch(initiatingSession._id, { revokedAt: timestamp }); return { userId: resolvedUserId, sessionId }; }, }); @@ -314,7 +512,7 @@ export const bridgeTokenRecord = queryGeneric({ handler: async (ctx, args) => { assertBridge(args.bridgeSecret); const character = await findCharacter(ctx, args.userId, args.characterId); - if (!character) return null; + if (!character || character.authorizationStatus !== "active") return null; return await ctx.db .query("tokenRecords") .withIndex("by_user_character", (q: any) => @@ -345,8 +543,9 @@ export const bridgeSaveTokens = mutationGeneric({ }, handler: async (ctx, args) => { assertBridge(args.bridgeSecret); + if (!validActualScopes(args.scopes)) return false; const character = await findCharacter(ctx, args.userId, args.characterId); - if (!character) return false; + if (!character || character.authorizationStatus !== "active") return false; const record = await ctx.db .query("tokenRecords") .withIndex("by_user_character", (q: any) => @@ -354,13 +553,22 @@ export const bridgeSaveTokens = mutationGeneric({ ) .first(); if (!record || record.version !== args.expectedVersion) return false; + const nextVersion = record.version + 1; + const nextRevision = character.grantRevision + (scopesEqual(character.scopes, args.scopes) ? 0 : 1); + const updated = updateDatasetStatuses(character, args.scopes, nextRevision, character.authorizationEpoch, nextVersion, now()); await ctx.db.patch(record._id, { encryptedTokens: args.encryptedTokens, tokenExpiresAt: args.tokenExpiresAt, scopes: args.scopes, - version: record.version + 1, + version: nextVersion, + }); + await ctx.db.patch(character._id, { + scopes: args.scopes, + grantRevision: nextRevision, + credentialVersion: nextVersion, + datasetStatuses: updated.statuses, + ...(updated.assetsInvalidated ? { state: "error" as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined } : {}), }); - await ctx.db.patch(character._id, { scopes: args.scopes }); return true; }, }); @@ -376,6 +584,84 @@ export const bridgeCharacters = queryGeneric({ }, }); +/** Change the one signed-out login character without changing authorization. */ +export const bridgeSetPrimary = mutationGeneric({ + args: { ...bridgeArgs, userId: v.string(), characterId: v.number(), initiatingSessionHash: v.string() }, + handler: async (ctx, args) => { + assertBridge(args.bridgeSecret); + const initiating = await findSessionByHash(ctx, args.initiatingSessionHash); + if (!isUsableSession(initiating) || initiating.userId !== args.userId) throw new Error("Session is no longer valid"); + const target = await findCharacter(ctx, args.userId, args.characterId); + const targetToken = target + ? await ctx.db.query("tokenRecords").withIndex("by_user_character", (q: any) => q.eq("userId", args.userId).eq("characterId", args.characterId)).first() + : null; + if (!target || !targetToken || target.authorizationStatus !== "active") { + throw new Error("Character is not eligible to be primary"); + } + const characters = await ctx.db.query("characters").withIndex("by_user_id", (q: any) => q.eq("userId", args.userId)).collect(); + for (const character of characters) { + if (character._id === target._id) { + await ctx.db.patch(character._id, { loginRole: "primary" }); + } else if (character.loginRole === "primary" || character.loginRole === undefined) { + await ctx.db.patch(character._id, { loginRole: "data_only" }); + } + } + return null; + }, +}); + +/** Local cancellation immediately fences private data and future work. */ +export const bridgeQuarantineAuthorization = mutationGeneric({ + args: { ...bridgeArgs, userId: v.string(), characterId: v.number() }, + handler: async (ctx, args) => { + assertBridge(args.bridgeSecret); + const character = await findCharacter(ctx, args.userId, args.characterId); + if (character) await quarantineCharacter(ctx, character, "Character ownership changed; reconnect required"); + return null; + }, +}); + +export const bridgeInvalidateAuthorization = mutationGeneric({ + args: { ...bridgeArgs, userId: v.string(), characterId: v.number(), initiatingSessionHash: v.string() }, + handler: async (ctx, args) => { + assertBridge(args.bridgeSecret); + const initiating = await findSessionByHash(ctx, args.initiatingSessionHash); + if (!isUsableSession(initiating) || initiating.userId !== args.userId) throw new Error("Session is no longer valid"); + const character = await findCharacter(ctx, args.userId, args.characterId); + if (!character) throw new Error("Character not found"); + if (character.loginRole === "primary") throw new Error("Switch the primary character before cancelling its authorization"); + const timestamp = now(); + const epoch = character.authorizationEpoch + 1; + const statuses = DATASET_NAMES.map((dataset) => ({ + dataset, + status: "blocked" as const, + grantRevision: character.grantRevision, + authorizationEpoch: epoch, + credentialVersion: character.credentialVersion, + invalidatedAt: timestamp, + })); + await ctx.db.patch(character._id, { + authorizationStatus: "revoked", + authorizationEpoch: epoch, + datasetStatuses: statuses, + state: "error", + activeGeneration: undefined, + syncGeneration: undefined, + syncLeaseExpiresAt: undefined, + error: "Authorization cancelled", + }); + const token = await ctx.db.query("tokenRecords").withIndex("by_user_character", (q: any) => q.eq("userId", args.userId).eq("characterId", args.characterId)).first(); + if (token) await ctx.db.delete(token._id); + const jobs = await ctx.db.query("jobs").withIndex("by_user_status", (q: any) => q.eq("userId", args.userId)).collect(); + for (const job of jobs) { + if (job.characterId === args.characterId && !["succeeded", "failed", "cancelled"].includes(job.status)) { + await ctx.db.patch(job._id, { status: "quarantined", updatedAt: timestamp, leaseExpiresAt: undefined }); + } + } + return null; + }, +}); + /** * Read an active snapshot one page of chunks at a time. The application adapter * walks this query's cursor, rather than issuing an unbounded Convex collect. @@ -391,7 +677,14 @@ export const bridgeAssetsPage = queryGeneric({ handler: async (ctx, args) => { assertBridge(args.bridgeSecret); const character = await findCharacter(ctx, args.userId, args.characterId); - const generation = character?.activeGeneration ?? null; + const assetStatus = character?.datasetStatuses?.find((entry: any) => entry.dataset === "assets"); + const authorized = character && character.authorizationStatus === "active" + && character.scopes.includes("esi-assets.read_assets.v1") + && assetStatus + && (assetStatus.status === "ready" || assetStatus.status === "error") + && assetStatus.grantRevision === character.grantRevision + && assetStatus.authorizationEpoch === character.authorizationEpoch; + const generation = authorized ? character.activeGeneration ?? null : null; if (!character || !generation) { return { page: [], isDone: true, continueCursor: "", generation: null }; } @@ -426,6 +719,9 @@ export const bridgeBeginSync = mutationGeneric({ assertDeploymentCapability('asset_sync'); const character = await findCharacter(ctx, args.userId, args.characterId); if (!character) throw new Error("Character not found"); + if (character.authorizationStatus !== "active" || !character.scopes.includes("esi-assets.read_assets.v1")) { + throw new Error("Asset authorization is not active"); + } const timestamp = now(); if (character.syncGeneration && (character.syncLeaseExpiresAt ?? 0) > timestamp) { throw new Error("Character synchronization is already leased"); @@ -437,9 +733,16 @@ export const bridgeBeginSync = mutationGeneric({ generation, status: "staging", stagedChunkCount: 0, + grantRevision: character.grantRevision, + authorizationEpoch: character.authorizationEpoch, + credentialVersion: character.credentialVersion, }); + const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === "assets" + ? { ...entry, status: "syncing", grantRevision: character.grantRevision, authorizationEpoch: character.authorizationEpoch } + : entry); await ctx.db.patch(character._id, { state: "syncing", + datasetStatuses: statuses, syncGeneration: generation, syncLeaseExpiresAt: timestamp + SYNC_LEASE_MS, error: undefined, @@ -565,14 +868,24 @@ export const bridgeFinishSync = mutationGeneric({ throw new Error("Snapshot is missing chunks"); } + if (generation.grantRevision !== character.grantRevision + || generation.authorizationEpoch !== character.authorizationEpoch + || generation.credentialVersion !== character.credentialVersion + || character.authorizationStatus !== "active") { + throw new Error("Synchronization authorization is stale"); + } await ctx.db.patch(generation._id, { status: "complete", chunkCount: args.chunkCount, syncedAt: args.syncedAt, expiresAt: args.expiresAt, }); + const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === "assets" + ? { ...entry, status: "ready", generation: args.generation, syncedAt: args.syncedAt, error: undefined } + : entry); await ctx.db.patch(character._id, { state: "ready", + datasetStatuses: statuses, syncedAt: args.syncedAt, expiresAt: args.expiresAt, activeGeneration: args.generation, @@ -602,16 +915,26 @@ export const bridgeFailSync = mutationGeneric({ .eq("generation", args.generation), ) .first(); - if (generation) { - await ctx.db.patch(generation._id, { - status: "failed", - error: safeSyncError(args.error).slice(0, MAX_ERROR_LENGTH), - }); + if (!generation || generation.status !== "staging" + || (character.syncLeaseExpiresAt ?? 0) <= now() + || generation.grantRevision !== character.grantRevision + || generation.authorizationEpoch !== character.authorizationEpoch + || generation.credentialVersion !== character.credentialVersion + || character.authorizationStatus !== "active") { + throw new Error("Synchronization failure publication is stale"); } + await ctx.db.patch(generation._id, { + status: "failed", + error: safeSyncError(args.error).slice(0, MAX_ERROR_LENGTH), + }); // Deliberately do not touch activeGeneration: the previous complete // snapshot remains available after a failed synchronization. + const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === "assets" + ? { ...entry, status: character.activeGeneration ? "error" : "blocked", error: safeSyncError(args.error).slice(0, MAX_ERROR_LENGTH) } + : entry); await ctx.db.patch(character._id, { state: "error", + datasetStatuses: statuses, error: safeSyncError(args.error).slice(0, MAX_ERROR_LENGTH), syncGeneration: undefined, syncLeaseExpiresAt: undefined, diff --git a/src/convex/schema.ts b/src/convex/schema.ts index b15725e..f115b5c 100644 --- a/src/convex/schema.ts +++ b/src/convex/schema.ts @@ -9,6 +9,37 @@ export const characterStateValidator = v.union( v.literal("error"), ); +export const characterLoginRoleValidator = v.union(v.literal("primary"), v.literal("data_only")); +export const authorizationStatusValidator = v.union( + v.literal("active"), + v.literal("revocation_pending"), + v.literal("quarantined"), + v.literal("revoked"), + v.literal("transferred"), +); +export const datasetStateValidator = v.union( + v.literal("never"), + v.literal("queued"), + v.literal("syncing"), + v.literal("ready"), + v.literal("partial"), + v.literal("stale"), + v.literal("blocked"), + v.literal("error"), + v.literal("quarantined"), +); +export const datasetStatusValidator = v.object({ + dataset: v.string(), + status: datasetStateValidator, + grantRevision: v.number(), + authorizationEpoch: v.number(), + credentialVersion: v.number(), + generation: v.optional(v.string()), + syncedAt: v.optional(v.number()), + invalidatedAt: v.optional(v.number()), + error: v.optional(v.string()), +}); + export const assetValidator = v.object({ itemId: v.string(), characterId: v.number(), @@ -28,6 +59,12 @@ const characterFields = { ownerHash: v.string(), scopes: v.array(v.string()), state: characterStateValidator, + loginRole: characterLoginRoleValidator, + authorizationStatus: authorizationStatusValidator, + grantRevision: v.number(), + credentialVersion: v.number(), + authorizationEpoch: v.number(), + datasetStatuses: v.array(datasetStatusValidator), syncedAt: v.optional(v.number()), expiresAt: v.optional(v.number()), error: v.optional(v.string()), @@ -65,6 +102,13 @@ export default defineSchema({ encryptedVerifier: v.string(), expiresAt: v.number(), returnTo: v.string(), + // OAuth intent is bound server-side, not reconstructed from callback input. + intent: v.string(), + capability: v.string(), + requestedScopes: v.array(v.string()), + approvedExistingScopes: v.array(v.string()), + initiatingSessionHash: v.optional(v.string()), + targetCharacterId: v.optional(v.number()), consumedAt: v.optional(v.number()), }) .index("by_state_binding", ["stateHash", "bindingHash"]) @@ -92,6 +136,9 @@ export default defineSchema({ syncedAt: v.optional(v.number()), expiresAt: v.optional(v.number()), error: v.optional(v.string()), + grantRevision: v.number(), + authorizationEpoch: v.number(), + credentialVersion: v.number(), }) .index("by_user_character_generation", ["userId", "characterId", "generation"]) .index("by_generation", ["generation"]), @@ -130,6 +177,7 @@ export default defineSchema({ createdAt: v.number(), updatedAt: v.number(), leaseExpiresAt: v.optional(v.number()), + characterId: v.optional(v.number()), }) .index("by_user_status", ["userId", "status"]) .index("by_lease_expires_at", ["leaseExpiresAt"]), diff --git a/src/convex/status.ts b/src/convex/status.ts index e504e26..6f137fb 100644 --- a/src/convex/status.ts +++ b/src/convex/status.ts @@ -1,6 +1,6 @@ import { queryGeneric } from "convex/server"; import { v } from "convex/values"; -import { characterStateValidator } from "./schema"; +import { characterStateValidator, authorizationStatusValidator, characterLoginRoleValidator, datasetStatusValidator } from "./schema"; import { capabilityEnabled, parseBackendCapabilityAllowlist } from "../lib/server/release-gate"; const safeAssetSyncValidator = v.object({ @@ -14,33 +14,28 @@ const safeCharacterValidator = v.object({ userId: v.string(), name: v.string(), scopes: v.array(v.string()), + loginRole: characterLoginRoleValidator, + authorizationStatus: authorizationStatusValidator, + grantRevision: v.number(), + credentialVersion: v.number(), + authorizationEpoch: v.number(), + datasets: v.array(datasetStatusValidator), assets: safeAssetSyncValidator, }); function isUsableSession(session: any, timestamp = Date.now()): boolean { - return Boolean( - session && - !session.revokedAt && - session.expiresAt > timestamp, - ); + return Boolean(session && !session.revokedAt && session.expiresAt > timestamp); } function deploymentCapabilityEnabled(capability: 'subscriptions'): boolean { try { return capabilityEnabled(capability, parseBackendCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST)); } catch { - // A malformed backend setting must fail closed, not expose a reactive - // tenant projection while the web process reports a configuration error. return false; } } -/** - * Browser-facing character status. Convex validates RS256 issuer/audience - * through auth.config.ts; the checks here make the application boundary and - * session revocation explicit as well. No owner hashes, tokens, generations, - * or raw synchronization errors cross this projection. - */ +/** Browser-facing status: no credentials, owner evidence, generations or raw errors. */ export const characters = queryGeneric({ args: {}, returns: v.union(v.null(), v.array(safeCharacterValidator)), @@ -48,31 +43,26 @@ export const characters = queryGeneric({ if (!deploymentCapabilityEnabled('subscriptions')) return null; const identity = await ctx.auth.getUserIdentity(); if (!identity) return null; - const expectedIssuer = process.env.APP_AUTH_ISSUER; if (!expectedIssuer || identity.issuer !== expectedIssuer) return null; - // Convex's customJwt provider enforces applicationID/audience before this - // query runs. `aud` is a reserved claim and is not exposed on UserIdentity; - // only the application issuer and session binding are checked here. const customIdentity = identity as typeof identity & { sid?: unknown }; const sessionId = typeof customIdentity.sid === "string" ? customIdentity.sid : null; if (!sessionId) return null; - const session = await ctx.db - .query("sessions") - .withIndex("by_session_id", (q: any) => q.eq("sessionId", sessionId)) - .first(); + const session = await ctx.db.query("sessions").withIndex("by_session_id", (q: any) => q.eq("sessionId", sessionId)).first(); if (!isUsableSession(session) || session.userId !== identity.subject) return null; - - const rows = await ctx.db - .query("characters") - .withIndex("by_user_id", (q: any) => q.eq("userId", identity.subject)) - .collect(); + const rows = await ctx.db.query("characters").withIndex("by_user_id", (q: any) => q.eq("userId", identity.subject)).collect(); return rows.map((character: any) => ({ id: character.characterId, userId: identity.subject, name: character.name, scopes: character.scopes, + loginRole: character.loginRole, + authorizationStatus: character.authorizationStatus, + grantRevision: character.grantRevision, + credentialVersion: character.credentialVersion, + authorizationEpoch: character.authorizationEpoch, + datasets: character.datasetStatuses, assets: { status: character.state, ...(character.syncedAt === undefined ? {} : { syncedAt: character.syncedAt }), diff --git a/src/lib/domain/model.ts b/src/lib/domain/model.ts index 39173ec..2aee212 100644 --- a/src/lib/domain/model.ts +++ b/src/lib/domain/model.ts @@ -1,10 +1,28 @@ /** Domain values are JSON-safe. EVE item/location IDs are decimal strings, not JS floats. */ export type UserId = string; +export type CharacterDatasetStatus = { + dataset: string; + status: 'never' | 'queued' | 'syncing' | 'ready' | 'partial' | 'stale' | 'blocked' | 'error' | 'quarantined'; + grantRevision: number; + authorizationEpoch: number; + credentialVersion: number; + generation?: string; + syncedAt?: number; + invalidatedAt?: number; + error?: string; +}; export type Character = { id: number; userId: UserId; name: string; scopes: string[]; + /** Exactly one active primary may authenticate while signed out. */ + loginRole: 'primary' | 'data_only'; + authorizationStatus: 'active' | 'revocation_pending' | 'quarantined' | 'revoked' | 'transferred'; + grantRevision: number; + credentialVersion: number; + authorizationEpoch: number; + datasets: CharacterDatasetStatus[]; assets: { status: 'never' | 'syncing' | 'ready' | 'error'; syncedAt?: number; expiresAt?: number; error?: string }; }; export type Asset = { diff --git a/src/lib/server/application.ts b/src/lib/server/application.ts index ba13da9..55fdebd 100644 --- a/src/lib/server/application.ts +++ b/src/lib/server/application.ts @@ -32,7 +32,16 @@ export async function session(event: RequestEvent): Promise { function demoRepository(): CharacterAssetRepository { const now = Date.now(); const characters: Character[] = [90000001, 90000002].map((id, index) => ({ - id, userId: 'demo', name: `Fixture Pilot ${index + 1}`, scopes: ['esi-assets.read_assets.v1'], + id, + userId: 'demo', + name: `Fixture Pilot ${index + 1}`, + scopes: ['esi-assets.read_assets.v1'], + loginRole: index === 0 ? 'primary' : 'data_only', + authorizationStatus: 'active', + grantRevision: 1, + credentialVersion: 1, + authorizationEpoch: 1, + datasets: [{ dataset: 'assets', status: 'ready', grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1, generation: 'demo', syncedAt: now }], assets: { status: 'ready', syncedAt: now, expiresAt: now + 3_600_000 } })); const asset = (characterId: number, itemId: string, typeId: number, quantity: number, extra = {}): Asset => ({ @@ -94,7 +103,10 @@ async function characterGrant(userId: string, characterId: number, revalidateOwn if (previous.characterId !== characterId) throw new Error('Character authorization mismatch'); if (!revalidateOwnership && previous.expiresAt > Date.now() + 60_000) return previous; const grant = await sso.refresh(previous.refreshToken); - if (grant.characterId !== characterId || grant.ownerHash !== previous.ownerHash) throw new Error('Character ownership changed; reconnect required'); + if (grant.characterId !== characterId || grant.ownerHash !== previous.ownerHash) { + await store.quarantineAuthorization(userId, characterId).catch(() => {}); + throw new Error('Character ownership changed; reconnect required'); + } const saved = await store.saveTokens(userId, characterId, { encryptedTokens: await cipher.seal(grant, `character:${characterId}`), tokenExpiresAt: grant.expiresAt, scopes: grant.scopes, expectedVersion: record.version }); if (!saved) throw new Error('Authorization changed concurrently; retry synchronization'); return grant; @@ -112,13 +124,15 @@ export async function synchronize(userId: string) { const characters = await store.characters(userId); const failed: number[] = []; let completed = 0; for (const character of characters) { + if (character.authorizationStatus !== 'active') { failed.push(character.id); continue; } if (!character.scopes.includes('esi-assets.read_assets.v1')) { failed.push(character.id); continue; } let generation: string | undefined; try { - generation = await store.beginSync(userId, character.id); - // Explicit synchronization revalidates revocation/owner continuity even when ESI assets remain cached. + // Refresh first so the generation fence records the credential version + // that actually authorizes the ESI request and publication. const grant = await characterGrant(userId, character.id, true); if (!grant.scopes.includes('esi-assets.read_assets.v1')) throw new Error('Asset authorization missing'); + generation = await store.beginSync(userId, character.id); const snapshot = await esi.getAssets(character.id, grant.accessToken, `${userId}:${character.id}:${grant.ownerHash}:${[...grant.scopes].sort().join(',')}`); const chunks = Math.ceil(snapshot.assets.length / 250); for (let index = 0; index < chunks; index++) await store.stageAssets(userId, character.id, generation, index, snapshot.assets.slice(index * 250, (index + 1) * 250)); diff --git a/src/lib/server/sso.ts b/src/lib/server/sso.ts index 5461f6c..6127136 100644 --- a/src/lib/server/sso.ts +++ b/src/lib/server/sso.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { SsoDiagnosticError } from './oauth-diagnostics'; +import { SERVER_SCOPE_ALLOWLIST } from '$lib/domain/capabilities'; import { createRemoteJWKSet, customFetch, @@ -183,9 +184,20 @@ export class EveSso { this.verificationKey = options.verificationKey; } - async authorizationUrl(state: string, challenge: string): Promise { + async authorizationUrl( + state: string, + challenge: string, + requestedScopes: readonly string[] = [ASSET_SCOPE], + ): Promise { nonEmptyString(state, 'state'); nonEmptyString(challenge, 'PKCE challenge'); + if ( + requestedScopes.length === 0 || + requestedScopes.some((scope) => typeof scope !== 'string' || !SERVER_SCOPE_ALLOWLIST.includes(scope) || !/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)) || + new Set(requestedScopes).size !== requestedScopes.length + ) { + invalid('Invalid EVE SSO requested scopes'); + } const discovery = await this.discovery(); if (!discovery.codeChallengeMethods.includes('S256')) { invalid('EVE SSO does not support PKCE S256'); @@ -195,7 +207,7 @@ export class EveSso { url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', this.clientId); url.searchParams.set('redirect_uri', this.redirectUri); - url.searchParams.set('scope', ASSET_SCOPE); + url.searchParams.set('scope', requestedScopes.join(' ')); url.searchParams.set('state', state); url.searchParams.set('code_challenge', challenge); url.searchParams.set('code_challenge_method', 'S256'); diff --git a/src/lib/server/store.ts b/src/lib/server/store.ts index 790f86d..d6a6897 100644 --- a/src/lib/server/store.ts +++ b/src/lib/server/store.ts @@ -14,6 +14,12 @@ export type OAuthStateRecord = { encryptedVerifier: string; expiresAt: number; returnTo: string; + intent: 'initial_login' | 'link_character' | 'upgrade_dataset' | 'reconnect'; + capability: string; + requestedScopes: string[]; + approvedExistingScopes: string[]; + initiatingSessionHash?: string; + targetCharacterId?: number; }; export type CompleteLoginInput = { @@ -26,6 +32,12 @@ export type CompleteLoginInput = { tokenExpiresAt: number; sessionHash: string; sessionExpiresAt: number; + intent: 'initial_login' | 'link_character' | 'upgrade_dataset' | 'reconnect'; + capability: string; + requestedScopes: string[]; + approvedExistingScopes: string[]; + initiatingSessionHash?: string; + targetCharacterId?: number; }; export type TokenRecord = { @@ -76,6 +88,9 @@ const MUTATION = { finishSync: "functions:bridgeFinishSync", failSync: "functions:bridgeFailSync", saveInteraction: "functions:bridgeSaveInteraction", + setPrimary: "functions:bridgeSetPrimary", + invalidateAuthorization: "functions:bridgeInvalidateAuthorization", + quarantineAuthorization: "functions:bridgeQuarantineAuthorization", } as const; function queryReference(name: string): any { @@ -141,6 +156,12 @@ export class ConvexStore implements CharacterAssetRepository { encryptedVerifier: string; expiresAt: number; returnTo: string; + intent: OAuthStateRecord['intent']; + capability: string; + requestedScopes: string[]; + approvedExistingScopes: string[]; + initiatingSessionHash?: string; + targetCharacterId?: number; }): Promise { await this.mutation(MUTATION.createOAuth, input); } @@ -156,16 +177,30 @@ export class ConvexStore implements CharacterAssetRepository { } public async completeLogin(input: CompleteLoginInput): Promise<{ userId: string; sessionId: string }> { - return await this.mutation<{ userId: string; sessionId: string }>( + const result = await this.mutation<{ userId: string; sessionId: string; rejected?: string }>( MUTATION.completeLogin, input, ); + if (result.rejected === 'ownership_conflict') throw new Error('Character ownership conflict'); + return result; } public async tokenRecord(userId: string, characterId: number): Promise { return await this.query(QUERY.tokenRecord, { userId, characterId }); } + public async setPrimary(userId: string, characterId: number, initiatingSessionHash: string): Promise { + await this.mutation(MUTATION.setPrimary, { userId, characterId, initiatingSessionHash }); + } + + public async invalidateAuthorization(userId: string, characterId: number, initiatingSessionHash: string): Promise { + await this.mutation(MUTATION.invalidateAuthorization, { userId, characterId, initiatingSessionHash }); + } + + public async quarantineAuthorization(userId: string, characterId: number): Promise { + await this.mutation(MUTATION.quarantineAuthorization, { userId, characterId }); + } + public async saveTokens( userId: string, characterId: number, @@ -190,6 +225,12 @@ export class ConvexStore implements CharacterAssetRepository { userId: row.userId, name: row.name, scopes: row.scopes, + loginRole: row.loginRole, + authorizationStatus: row.authorizationStatus, + grantRevision: row.grantRevision, + credentialVersion: row.credentialVersion, + authorizationEpoch: row.authorizationEpoch, + datasets: row.datasetStatuses, assets: { status: row.state, ...(row.syncedAt === undefined ? {} : { syncedAt: row.syncedAt }), diff --git a/src/routes/auth/eve/+server.ts b/src/routes/auth/eve/+server.ts index aec02de..62ea704 100644 --- a/src/routes/auth/eve/+server.ts +++ b/src/routes/auth/eve/+server.ts @@ -5,22 +5,99 @@ import { live, session, SESSION_COOKIE } from '$lib/server/application'; import { hashSecret, pkceChallenge, randomSecret } from '$lib/server/security'; import { protectMutation } from '$lib/server/http'; import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate'; +import { CAPABILITY_DEFINITIONS, SERVER_SCOPE_ALLOWLIST, validateCapabilityScopes } from '$lib/domain/capabilities'; +import type { Capability } from '$lib/domain/model'; + +const CONSENT_INTENTS = new Set(['initial_login', 'link_character', 'upgrade_dataset', 'reconnect']); +const CONSENT_CAPABILITIES = new Set([ + 'asset_search', + 'asset_valuation', + 'wallet_balance', + 'manufacturing_opportunities', +]); + +function formString(value: FormDataEntryValue | null): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} export const POST: RequestHandler = async (event) => { const config = configuration(); protectMutation(event, `oauth:${event.getClientAddress()}`, 10); if (!capabilityEnabled('identity', config.capabilityAllowlist)) error(503, capabilityDisabledReason('identity')); if (config.mode === 'demo') error(400, 'EVE authentication is disabled in fixture mode'); + let destination: string; try { - const { store, cipher, sso } = live(); + const form = await event.request.formData(); + const capabilityValue = formString(form.get('capability')) ?? 'asset_search'; + if (!CONSENT_CAPABILITIES.has(capabilityValue as Capability)) error(400, 'Unsupported consent capability'); + const capability = capabilityValue as Capability; + // Domain capability and deployment release gate are separate controls. + if ((capability === 'asset_search' || capability === 'asset_valuation' || capability === 'wallet_balance' || capability === 'manufacturing_opportunities') + && !capabilityEnabled(capability, config.capabilityAllowlist)) { + error(503, capabilityDisabledReason(capability)); + } + const current = await session(event); - const state = randomSecret(); const binding = randomSecret(); const verifier = randomSecret(); + const rawCookie = event.cookies.get(SESSION_COOKIE); + const targetValue = formString(form.get('targetCharacterId')); + const targetCharacterId = targetValue === undefined ? undefined : Number(targetValue); + if (targetCharacterId !== undefined && (!Number.isSafeInteger(targetCharacterId) || targetCharacterId <= 0)) { + error(400, 'Invalid character selection'); + } + const requestedIntent = formString(form.get('intent')); + if (requestedIntent !== undefined && !CONSENT_INTENTS.has(requestedIntent)) error(400, 'Invalid consent intent'); + const intent: 'initial_login' | 'link_character' | 'upgrade_dataset' | 'reconnect' = (requestedIntent as 'initial_login' | 'link_character' | 'upgrade_dataset' | 'reconnect' | undefined) + ?? (current ? targetCharacterId === undefined ? 'link_character' : 'reconnect' : 'initial_login'); + if (current === null && intent !== 'initial_login') error(401, 'An authenticated session is required for this action'); + if (intent === 'upgrade_dataset' || intent === 'reconnect') { + if (!current || targetCharacterId === undefined) error(400, 'A linked character is required for this action'); + } + + const { store, cipher, sso } = live(); + const owned = current ? await store.characters(current.userId) : []; + const target = targetCharacterId === undefined ? undefined : owned.find((character) => character.id === targetCharacterId); + if (targetCharacterId !== undefined && !target) error(403, 'Character is not linked to this application user'); + const approvedExistingScopes = target?.scopes.filter((scope) => SERVER_SCOPE_ALLOWLIST.includes(scope)) ?? []; + const requestedScopes = [...new Set([ + ...CAPABILITY_DEFINITIONS[capability].requiredScopes, + ...approvedExistingScopes, + ])]; + const validation = validateCapabilityScopes(capability, requestedScopes, approvedExistingScopes); + if (!validation.valid) error(400, 'Requested consent scopes are not permitted'); + + const state = randomSecret(); + const binding = randomSecret(); + const verifier = randomSecret(); const stateHash = hashSecret(state); - const encryptedVerifier = await cipher.seal({ verifier, sessionHash: current ? hashSecret(event.cookies.get(SESSION_COOKIE) ?? '') : null }, `oauth:${stateHash}`); - await store.createOAuth({ stateHash, bindingHash: hashSecret(binding), ...(current ? { userId: current.userId } : {}), encryptedVerifier, expiresAt: Date.now() + 600_000, returnTo: '/' }); - destination = await sso.authorizationUrl(state, pkceChallenge(verifier)); - event.cookies.set('eve_oauth_binding', binding, { path: '/auth/eve', httpOnly: true, sameSite: 'lax', secure: config.origin.startsWith('https:'), maxAge: 600 }); - } catch { error(503, 'EVE connection unavailable. Check server configuration.'); } - redirect(303, destination); + const initiatingSessionHash = current && rawCookie ? hashSecret(rawCookie) : undefined; + const encryptedVerifier = await cipher.seal({ + verifier, + sessionHash: initiatingSessionHash ?? null, + }, `oauth:${stateHash}`); + await store.createOAuth({ + stateHash, + bindingHash: hashSecret(binding), + ...(current ? { userId: current.userId } : {}), + encryptedVerifier, + expiresAt: Date.now() + 600_000, + returnTo: '/characters', + intent, + capability, + requestedScopes, + approvedExistingScopes, + ...(initiatingSessionHash ? { initiatingSessionHash } : {}), + ...(targetCharacterId === undefined ? {} : { targetCharacterId }), + }); + destination = await sso.authorizationUrl(state, pkceChallenge(verifier), requestedScopes); + event.cookies.set('eve_oauth_binding', binding, { + path: '/auth/eve', httpOnly: true, sameSite: 'lax', + secure: config.origin.startsWith('https:'), maxAge: 600, + }); + } catch (caught) { + // SvelteKit HTTP errors should retain their fixed status/message. + if (caught && typeof caught === 'object' && 'status' in caught) throw caught; + error(503, 'EVE connection unavailable. Check server configuration.'); + } + redirect(303, destination!); }; diff --git a/src/routes/auth/eve/callback/+server.ts b/src/routes/auth/eve/callback/+server.ts index b06d219..69a3b76 100644 --- a/src/routes/auth/eve/callback/+server.ts +++ b/src/routes/auth/eve/callback/+server.ts @@ -5,6 +5,11 @@ import { assertOAuthSessionBinding, hashSecret, randomSecret } from '$lib/server import { oauthFailureDiagnostic, type OAuthPhase } from '$lib/server/oauth-diagnostics'; import { configuration } from '$lib/server/config'; import { capabilityEnabled } from '$lib/server/release-gate'; +import { SERVER_SCOPE_ALLOWLIST } from '$lib/domain/capabilities'; + +function safeReturnTo(value: string): string { + return value === '/characters' ? value : '/characters'; +} export const GET: RequestHandler = async (event) => { const config = configuration(); @@ -12,6 +17,7 @@ export const GET: RequestHandler = async (event) => { const binding = event.cookies.get('eve_oauth_binding'); event.cookies.delete('eve_oauth_binding', { path: '/auth/eve' }); let phase: OAuthPhase = 'state_input'; + let returnTo = '/characters'; try { const state = event.url.searchParams.get('state'); if (!state) throw new Error('Missing OAuth state'); @@ -23,6 +29,7 @@ export const GET: RequestHandler = async (event) => { phase = 'state_consumption'; const pending = await store.consumeOAuth(stateHash, hashSecret(binding)); if (!pending || pending.expiresAt <= Date.now()) throw new Error('Expired OAuth state'); + returnTo = safeReturnTo(pending.returnTo); phase = 'provider_response'; const code = event.url.searchParams.get('code'); if (event.url.searchParams.has('error') || !code || code.length > 4096) throw new Error('Authorization denied'); @@ -31,25 +38,49 @@ export const GET: RequestHandler = async (event) => { phase = 'session_binding'; const current = await session(event); const oldCookie = event.cookies.get(SESSION_COOKIE); - assertOAuthSessionBinding({ expectedUserId: pending.userId, expectedSessionHash: proof.sessionHash, currentUserId: current?.userId ?? null, currentCookie: oldCookie }); + assertOAuthSessionBinding({ + expectedUserId: pending.userId, + expectedSessionHash: pending.initiatingSessionHash ?? proof.sessionHash, + currentUserId: current?.userId ?? null, + currentCookie: oldCookie, + }); phase = 'exchange'; const grant = await sso.exchange(code, proof.verifier); + if (grant.scopes.some((scope) => !SERVER_SCOPE_ALLOWLIST.includes(scope)) || new Set(grant.scopes).size !== grant.scopes.length) { + throw new Error('Invalid EVE SSO granted scopes'); + } + if (pending.targetCharacterId !== undefined && pending.targetCharacterId !== grant.characterId) { + throw new Error('OAuth character mismatch'); + } const cookie = randomSecret(); phase = 'token_encryption'; const encryptedTokens = await cipher.seal(grant, `character:${grant.characterId}`); phase = 'login_persistence'; await store.completeLogin({ - ...(pending.userId ? { userId: pending.userId } : {}), characterId: grant.characterId, name: grant.name, ownerHash: grant.ownerHash, scopes: grant.scopes, - encryptedTokens, tokenExpiresAt: grant.expiresAt, - sessionHash: hashSecret(cookie), sessionExpiresAt: Date.now() + 7 * 86400_000 + ...(pending.userId ? { userId: pending.userId } : {}), + characterId: grant.characterId, + name: grant.name, + ownerHash: grant.ownerHash, + scopes: grant.scopes, + encryptedTokens, + tokenExpiresAt: grant.expiresAt, + sessionHash: hashSecret(cookie), + sessionExpiresAt: Date.now() + 7 * 86400_000, + intent: pending.intent, + capability: pending.capability, + requestedScopes: pending.requestedScopes, + approvedExistingScopes: pending.approvedExistingScopes, + ...(pending.initiatingSessionHash ? { initiatingSessionHash: pending.initiatingSessionHash } : {}), + ...(pending.targetCharacterId === undefined ? {} : { targetCharacterId: pending.targetCharacterId }), }); - phase = 'session_revocation'; - if (oldCookie) await store.deleteSession(hashSecret(oldCookie)); phase = 'cookie_issue'; - event.cookies.set(SESSION_COOKIE, cookie, { path: '/', httpOnly: true, sameSite: 'lax', secure: config.origin.startsWith('https:'), maxAge: 7 * 86400 }); - } catch (error) { - console.error(JSON.stringify(oauthFailureDiagnostic(phase, error))); + event.cookies.set(SESSION_COOKIE, cookie, { + path: '/', httpOnly: true, sameSite: 'lax', + secure: config.origin.startsWith('https:'), maxAge: 7 * 86400, + }); + } catch (caught) { + console.error(JSON.stringify(oauthFailureDiagnostic(phase, caught))); redirect(303, '/characters?auth=failed'); } - redirect(303, '/'); + redirect(303, returnTo); }; diff --git a/src/routes/characters/+page.server.ts b/src/routes/characters/+page.server.ts index c722838..3f68418 100644 --- a/src/routes/characters/+page.server.ts +++ b/src/routes/characters/+page.server.ts @@ -10,15 +10,23 @@ export const load: PageServerLoad = async (event) => { return { characters: [], identityEnabled: false, + walletEnabled: false, error: capabilityDisabledReason('identity'), }; } const current = await session(event); + const authStatus = event.url.searchParams.get('auth'); + const error = authStatus === 'failed' + ? 'The character action failed or was cancelled. The login state may have expired, the character may be ineligible, or it may belong to another application user.' + : authStatus === 'required' + ? 'Sign in with the primary character before managing linked characters.' + : authStatus === 'disabled' + ? capabilityDisabledReason('identity') + : null; return { characters: await charactersFor(current?.userId ?? null), identityEnabled: true, - error: event.url.searchParams.get('auth') === 'failed' - ? 'EVE connection failed or was cancelled. The login state may have expired, or this character may belong to another application user. Please try again.' - : null, + walletEnabled: capabilityEnabled('wallet_balance', config.capabilityAllowlist), + error, }; }; diff --git a/src/routes/characters/+page.svelte b/src/routes/characters/+page.svelte index 5740a18..dce985e 100644 --- a/src/routes/characters/+page.svelte +++ b/src/routes/characters/+page.svelte @@ -1,17 +1,58 @@

APPLICATION-USER AGGREGATION

Characters

-

Connect each character explicitly. ESI does not expose all characters on an EVE account.

-{#if data.mode !== 'demo' && data.identityEnabled}
{/if} +

Connect each character explicitly. One primary character is the only signed-out login; linked characters are data-only until you switch the primary while authenticated.

+{#if data.mode !== 'demo' && data.identityEnabled} +
+ + +
+{/if} {#if data.error}{/if} {#if data.userId && data.convexUrl && data.mode !== 'demo'}{/if} {#each data.characters as character (character.id)} -

{character.name}

{character.assets.status}
-Character {character.id} -

Assets synchronized: {character.assets.syncedAt ? new Date(character.assets.syncedAt).toISOString() : 'Not yet'}
Granted scopes: {character.scopes.join(', ') || 'None'}

-{#if character.assets.error}

{character.assets.error}

{/if} +
+

{character.name}

{character.loginRole === 'data_only' ? 'DATA-ONLY' : 'PRIMARY'} · {character.authorizationStatus ?? 'active'}
+ Character {character.id} · grant r{character.grantRevision ?? 1} · credential v{character.credentialVersion ?? 1} · epoch {character.authorizationEpoch ?? 1} +

Assets: {character.assets.status} · synchronized: {character.assets.syncedAt ? new Date(character.assets.syncedAt).toISOString() : 'Not yet'}
Granted scopes: {character.scopes.join(', ') || 'None'}

+ {#if character.datasets} +

Datasets: {character.datasets.map((dataset) => `${datasetLabel(dataset.dataset)}=${dataset.status}`).join(' · ')}

+ {/if} + {#if character.assets.error}

{character.assets.error}

{/if} + {#if data.mode !== 'demo' && data.identityEnabled} +
+ {#if character.loginRole === 'data_only' && character.authorizationStatus === 'active'} +
+ {/if} +
+ + + + +
+ {#if character.authorizationStatus === 'active' && data.walletEnabled} +
+ + + + +
+ {/if} + {#if character.loginRole === 'data_only' && character.authorizationStatus !== 'revoked'} +
+ Disconnect character +

This immediately blocks local datasets, discards the stored credential, and cancels pending local work. Reconnecting requires EVE authorization again.

+
+ + +
+
+ {/if} +
+ {/if}
-{:else}

No connected characters. Your first verified EVE login creates your application user; further connections attach to that user.

{/each} +{:else}

No connected characters. Your first verified EVE login creates the primary application character.

{/each}

Open asset report →

diff --git a/src/routes/characters/invalidate/+server.ts b/src/routes/characters/invalidate/+server.ts new file mode 100644 index 0000000..a2e5e35 --- /dev/null +++ b/src/routes/characters/invalidate/+server.ts @@ -0,0 +1,27 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { live, session, SESSION_COOKIE } from '$lib/server/application'; +import { hashSecret } from '$lib/server/security'; +import { protectMutation } from '$lib/server/http'; +import { configuration } from '$lib/server/config'; +import { capabilityEnabled } from '$lib/server/release-gate'; + +export const POST: RequestHandler = async (event) => { + const config = configuration(); + protectMutation(event, `character-invalidate:${event.getClientAddress()}`, 20); + if (!capabilityEnabled('identity', config.capabilityAllowlist)) redirect(303, '/characters?auth=disabled'); + const current = await session(event); + if (!current) redirect(303, '/characters?auth=required'); + const form = await event.request.formData(); + const value = form.get('characterId'); + const characterId = typeof value === 'string' ? Number(value) : NaN; + if (!Number.isSafeInteger(characterId) || characterId <= 0) redirect(303, '/characters?auth=failed'); + const cookie = event.cookies.get(SESSION_COOKIE); + if (!cookie) redirect(303, '/characters?auth=required'); + try { + await live().store.invalidateAuthorization(current.userId, characterId, hashSecret(cookie)); + } catch { + redirect(303, '/characters?auth=failed'); + } + redirect(303, '/characters'); +}; diff --git a/src/routes/characters/primary/+server.ts b/src/routes/characters/primary/+server.ts new file mode 100644 index 0000000..be19746 --- /dev/null +++ b/src/routes/characters/primary/+server.ts @@ -0,0 +1,27 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { configuration } from '$lib/server/config'; +import { live, session, SESSION_COOKIE } from '$lib/server/application'; +import { hashSecret } from '$lib/server/security'; +import { protectMutation } from '$lib/server/http'; +import { capabilityEnabled } from '$lib/server/release-gate'; + +export const POST: RequestHandler = async (event) => { + const config = configuration(); + protectMutation(event, `character-primary:${event.getClientAddress()}`, 20); + if (!capabilityEnabled('identity', config.capabilityAllowlist)) redirect(303, '/characters?auth=disabled'); + const current = await session(event); + if (!current) redirect(303, '/characters?auth=required'); + const form = await event.request.formData(); + const value = form.get('characterId'); + const characterId = typeof value === 'string' ? Number(value) : NaN; + if (!Number.isSafeInteger(characterId) || characterId <= 0) redirect(303, '/characters?auth=failed'); + const cookie = event.cookies.get(SESSION_COOKIE); + if (!cookie) redirect(303, '/characters?auth=required'); + try { + await live().store.setPrimary(current.userId, characterId, hashSecret(cookie)); + } catch { + redirect(303, '/characters?auth=failed'); + } + redirect(303, '/characters'); +}; diff --git a/tests/convex.test.ts b/tests/convex.test.ts index 2c3ab67..ae1a881 100644 --- a/tests/convex.test.ts +++ b/tests/convex.test.ts @@ -37,6 +37,10 @@ async function login(t: ReturnType, input: Partial = {}) name: "Capsuleer", ownerHash: "owner-a", scopes: ["esi-assets.read_assets.v1"], + intent: "initial_login", + capability: "asset_search", + requestedScopes: ["esi-assets.read_assets.v1"], + approvedExistingScopes: [], encryptedTokens: "encrypted", tokenExpiresAt: Date.now() + 60_000, sessionHash: "session-hash", @@ -60,6 +64,10 @@ describe("Convex persistence", () => { name: "Capsuleer", ownerHash: "owner-a", scopes: [], + intent: "initial_login", + capability: "asset_search", + requestedScopes: ["esi-assets.read_assets.v1"], + approvedExistingScopes: [], encryptedTokens: "secret", tokenExpiresAt: Date.now() + 60_000, sessionHash: "hash", @@ -89,6 +97,8 @@ describe("Convex persistence", () => { sessionHash: "linked-session", ownerHash: "owner-a", userId: first.userId, + intent: "link_character", + initiatingSessionHash: "first-session", }); expect(linked.userId).toBe(first.userId); @@ -101,6 +111,57 @@ describe("Convex persistence", () => { ).rejects.toThrow("User does not exist"); }); + test("a verified wallet grant authorizes balance, journal and transaction dataset states", async () => { + const t = convexTest({ schema, modules }); + const loggedIn = await login(t, { + characterId: 105, + sessionHash: "wallet-session", + scopes: ["esi-wallet.read_character_wallet.v1"], + capability: "wallet_balance", + requestedScopes: ["esi-wallet.read_character_wallet.v1"], + }); + const characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: loggedIn.userId }); + const walletStates = characters[0].datasetStatuses.filter((entry: any) => entry.dataset.startsWith("wallet_")); + expect(walletStates).toHaveLength(3); + expect(walletStates.every((entry: any) => entry.status === "never")).toBe(true); + }); + + test("linking revalidates the initiating session in the persistence transaction", async () => { + const t = convexTest({ schema, modules }); + const first = await login(t, { characterId: 110, sessionHash: "stale-link-session" }); + await t.mutation(mutation("bridgeDeleteSession"), { ...bridge, sessionHash: "stale-link-session" }); + await expect(login(t, { + userId: first.userId, + characterId: 111, + ownerHash: "owner-alt", + sessionHash: "new-link-session", + intent: "link_character", + initiatingSessionHash: "stale-link-session", + })).rejects.toThrow("Initiating session is no longer valid"); + }); + + test("two populated tenants cannot borrow character selectors", async () => { + const t = convexTest({ schema, modules }); + const first = await login(t, { characterId: 120, sessionHash: "tenant-a-session", ownerHash: "tenant-a-owner" }); + const second = await login(t, { characterId: 121, sessionHash: "tenant-b-session", ownerHash: "tenant-b-owner" }); + for (const [account, characterId] of [[first, 120], [second, 121]] as const) { + const generation = await t.mutation(mutation("bridgeBeginSync"), { + ...bridge, userId: account.userId, characterId, + }); + await t.mutation(mutation("bridgeFinishSync"), { + ...bridge, userId: account.userId, characterId, generation, chunkCount: 0, + syncedAt: Date.now(), expiresAt: Date.now() + 60_000, + }); + } + await expect(t.query(query("bridgeAssetsPage"), { + ...bridge, userId: first.userId, characterId: 121, + paginationOpts: { numItems: 10, cursor: null }, + })).resolves.toMatchObject({ page: [], generation: null }); + await expect(t.mutation(mutation("bridgeSetPrimary"), { + ...bridge, userId: first.userId, characterId: 121, initiatingSessionHash: "tenant-a-session", + })).rejects.toThrow("not eligible"); + }); + test("OAuth state is one-time and token writes use CAS", async () => { const t = convexTest({ schema, modules }); await expect( @@ -111,6 +172,10 @@ describe("Convex persistence", () => { encryptedVerifier: "verifier", expiresAt: Date.now() - 1, returnTo: "/assets", + intent: "initial_login", + capability: "asset_search", + requestedScopes: ["esi-assets.read_assets.v1"], + approvedExistingScopes: [], }), ).rejects.toThrow("Invalid OAuth state expiry"); await expect( @@ -121,6 +186,10 @@ describe("Convex persistence", () => { encryptedVerifier: "verifier", expiresAt: Date.now() + 660_000, // safely beyond the limit, not a 1ms wall-clock race returnTo: "/assets", + intent: "initial_login", + capability: "asset_search", + requestedScopes: ["esi-assets.read_assets.v1"], + approvedExistingScopes: [], }), ).rejects.toThrow("Invalid OAuth state expiry"); await t.mutation(mutation("bridgeCreateOAuth"), { @@ -130,6 +199,10 @@ describe("Convex persistence", () => { encryptedVerifier: "verifier", expiresAt: Date.now() + 60_000, returnTo: "/assets", + intent: "initial_login", + capability: "asset_search", + requestedScopes: ["esi-assets.read_assets.v1"], + approvedExistingScopes: [], }); const consumed = await t.mutation(mutation("bridgeConsumeOAuth"), { ...bridge, @@ -279,6 +352,38 @@ describe("Convex persistence", () => { expect(changed.page).toEqual([]); }); + test("credential rotation fences an in-flight asset publication", async () => { + const t = convexTest({ schema, modules }); + const loggedIn = await login(t, { characterId: 150, sessionHash: "credential-fence-session" }); + const generation = await t.mutation(mutation("bridgeBeginSync"), { + ...bridge, userId: loggedIn.userId, characterId: 150, + }); + await expect(t.mutation(mutation("bridgeSaveTokens"), { + ...bridge, + userId: loggedIn.userId, + characterId: 150, + encryptedTokens: "rotated", + tokenExpiresAt: Date.now() + 60_000, + scopes: ["esi-assets.read_assets.v1"], + expectedVersion: 1, + })).resolves.toBe(true); + await expect(t.mutation(mutation("bridgeFinishSync"), { + ...bridge, + userId: loggedIn.userId, + characterId: 150, + generation, + chunkCount: 0, + syncedAt: Date.now(), + expiresAt: Date.now() + 60_000, + })).rejects.toThrow("authorization is stale"); + const beforeFailure = await t.query(query("bridgeCharacters"), { ...bridge, userId: loggedIn.userId }); + await expect(t.mutation(mutation("bridgeFailSync"), { + ...bridge, userId: loggedIn.userId, characterId: 150, generation, error: "stale worker", + })).rejects.toThrow("failure publication is stale"); + const afterFailure = await t.query(query("bridgeCharacters"), { ...bridge, userId: loggedIn.userId }); + expect(afterFailure).toEqual(beforeFailure); + }); + test("invalid snapshot timestamps never promote", async () => { const t = convexTest({ schema, modules }); const timestamp = Date.now(); @@ -472,6 +577,109 @@ describe("Convex persistence", () => { } }); + test("single-primary linking, consent fencing, cancellation and owner quarantine are explicit", async () => { + const t = convexTest({ schema, modules }); + const first = await login(t, { characterId: 300, sessionHash: "primary-session", ownerHash: "owner-primary" }); + const linked = await login(t, { + userId: first.userId, + characterId: 301, + sessionHash: "link-session", + initiatingSessionHash: "primary-session", + intent: "link_character", + capability: "asset_search", + requestedScopes: ["esi-assets.read_assets.v1"], + approvedExistingScopes: [], + ownerHash: "owner-alt", + }); + let characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: first.userId }); + expect(characters).toEqual(expect.arrayContaining([ + expect.objectContaining({ characterId: 300, loginRole: "primary", authorizationStatus: "active" }), + expect.objectContaining({ characterId: 301, loginRole: "data_only", authorizationStatus: "active" }), + ])); + await expect(t.query(query("bridgeSession"), { ...bridge, sessionHash: "primary-session" })).resolves.toBeNull(); + await expect(t.query(query("bridgeSession"), { ...bridge, sessionHash: "link-session" })).resolves.toMatchObject({ userId: first.userId }); + await expect(login(t, { characterId: 301, ownerHash: "owner-alt", sessionHash: "signed-out-alt" })).rejects.toThrow("Data-only"); + await expect(login(t, { + userId: first.userId, + characterId: 301, + ownerHash: "owner-alt", + sessionHash: "duplicate-link-session", + intent: "link_character", + initiatingSessionHash: "link-session", + })).rejects.toThrow("already linked"); + const other = await login(t, { characterId: 302, ownerHash: "owner-other", sessionHash: "other-session" }); + await expect(t.mutation(mutation("bridgeSetPrimary"), { + ...bridge, userId: first.userId, characterId: 302, initiatingSessionHash: "link-session", + })).rejects.toThrow("not eligible"); + expect(other.userId).not.toBe(first.userId); + + await expect(t.mutation(mutation("bridgeInvalidateAuthorization"), { + ...bridge, userId: first.userId, characterId: 300, initiatingSessionHash: "link-session", + })).rejects.toThrow("Switch the primary character"); + + await t.mutation(mutation("bridgeSetPrimary"), { + ...bridge, userId: first.userId, characterId: 301, initiatingSessionHash: "link-session", + }); + await t.mutation(mutation("bridgeInvalidateAuthorization"), { + ...bridge, userId: first.userId, characterId: 300, initiatingSessionHash: "link-session", + }); + characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: first.userId }); + expect(characters).toEqual(expect.arrayContaining([ + expect.objectContaining({ characterId: 300, loginRole: "data_only", authorizationStatus: "revoked" }), + expect.objectContaining({ characterId: 301, loginRole: "primary" }), + ])); + await expect(t.query(query("bridgeTokenRecord"), { ...bridge, userId: first.userId, characterId: 300 })).resolves.toBeNull(); + await expect(t.query(query("bridgeAssetsPage"), { + ...bridge, userId: first.userId, characterId: 300, + paginationOpts: { numItems: 10, cursor: null }, + })).resolves.toMatchObject({ page: [], generation: null }); + + const switched = await login(t, { characterId: 301, ownerHash: "owner-alt", sessionHash: "signed-in-primary" }); + expect(switched.userId).toBe(first.userId); + const reduced = await login(t, { + userId: first.userId, + characterId: 301, + sessionHash: "reduced-session", + initiatingSessionHash: "signed-in-primary", + intent: "reconnect", + capability: "asset_search", + targetCharacterId: 301, + requestedScopes: ["esi-assets.read_assets.v1"], + scopes: [], + ownerHash: "owner-alt", + }); + expect(reduced.userId).toBe(first.userId); + characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: first.userId }); + expect(characters.find((character: any) => character.characterId === 301)).toMatchObject({ + authorizationStatus: "active", + grantRevision: 2, + datasetStatuses: expect.arrayContaining([expect.objectContaining({ dataset: "assets", status: "blocked" })]), + }); + await expect(t.query(query("bridgeAssetsPage"), { + ...bridge, userId: first.userId, characterId: 301, + paginationOpts: { numItems: 10, cursor: null }, + })).resolves.toMatchObject({ page: [], generation: null }); + + const conflict = await t.mutation(mutation("bridgeCompleteLogin"), { + ...bridge, characterId: 300, name: "Borrowed", ownerHash: "different-owner", scopes: [], + encryptedTokens: "borrowed", tokenExpiresAt: Date.now() + 60_000, + sessionHash: "borrowed-session", sessionExpiresAt: Date.now() + 60_000, + intent: "initial_login", capability: "asset_search", + requestedScopes: ["esi-assets.read_assets.v1"], approvedExistingScopes: [], + }); + expect(conflict).toMatchObject({ rejected: "ownership_conflict" }); + characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: first.userId }); + expect(characters.find((character: any) => character.characterId === 300)).toMatchObject({ + authorizationStatus: "quarantined", + datasetStatuses: expect.arrayContaining([expect.objectContaining({ dataset: "assets", status: "quarantined" })]), + }); + await expect(t.query(query("bridgeTokenRecord"), { ...bridge, userId: first.userId, characterId: 300 })).resolves.toBeNull(); + await expect(t.query(query("bridgeAssetsPage"), { + ...bridge, userId: first.userId, characterId: 300, + paginationOpts: { numItems: 10, cursor: null }, + })).resolves.toMatchObject({ page: [], generation: null }); + }); + test("backend rollout gate fails closed when propagation is missing or malformed", async () => { const previous = process.env.APP_CAPABILITY_ALLOWLIST; try { diff --git a/tests/domain.test.ts b/tests/domain.test.ts index dabdc8a..bfac481 100644 --- a/tests/domain.test.ts +++ b/tests/domain.test.ts @@ -18,6 +18,12 @@ function character(id: number, overrides: Partial = {}): Character { userId: 'user-a', name: `Pilot ${id}`, scopes: [ESI_ASSETS_SCOPE], + loginRole: 'primary', + authorizationStatus: 'active', + grantRevision: 1, + credentialVersion: 1, + authorizationEpoch: 1, + datasets: [{ dataset: 'assets', status: 'ready', grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1, generation: 'fixture', syncedAt: 900 }], assets: { status: 'ready', syncedAt: 900, expiresAt: 2_000 }, ...overrides, }; diff --git a/tests/sso.test.ts b/tests/sso.test.ts index 7e4d23f..aef3c52 100644 --- a/tests/sso.test.ts +++ b/tests/sso.test.ts @@ -101,6 +101,24 @@ describe('EVE SSO adapter', () => { expect(fixture.calls[0]!.init?.signal).toBeInstanceOf(AbortSignal); }); + test('uses the server-selected capability scope list without expanding it', async () => { + const fixture = mockFetch({}); + const url = new URL(await sso(fixture).authorizationUrl('state', 'challenge', [ + 'esi-assets.read_assets.v1', + 'esi-wallet.read_character_wallet.v1', + ])); + expect(url.searchParams.get('scope')).toBe('esi-assets.read_assets.v1 esi-wallet.read_character_wallet.v1'); + }); + + test('rejects duplicate, empty and non-allowlisted authorization scopes', async () => { + const fixture = mockFetch({}); + const client = sso(fixture); + await expect(client.authorizationUrl('state', 'challenge', [])).rejects.toThrow('Invalid EVE SSO requested scopes'); + await expect(client.authorizationUrl('state', 'challenge', ['esi-assets.read_assets.v1', 'esi-assets.read_assets.v1'])).rejects.toThrow('Invalid EVE SSO requested scopes'); + await expect(client.authorizationUrl('state', 'challenge', ['esi-evil.read_everything.v1'])).rejects.toThrow('Invalid EVE SSO requested scopes'); + expect(fixture.calls).toHaveLength(0); + }); + test('refreshes cached discovery metadata after its bounded TTL', async () => { let clock = NOW; const fixture = mockFetch({});