diff --git a/docs/design-records/t02-contracts.md b/docs/design-records/t02-contracts.md new file mode 100644 index 0000000..bd13036 --- /dev/null +++ b/docs/design-records/t02-contracts.md @@ -0,0 +1,63 @@ +# T02 contract record: datasets, authority and pinned reports + +**Status:** proposed contracts for the first implementation batch (2026-09-19). This record freezes shapes and invariants, not persistence or worker behavior. It does not approve stale-authority windows, recovery guarantees, retention, quotas or public release policy. + +## Invariants + +- A `ResourcePrincipal` is resolved by the server. A character, corporation or location ID supplied by a browser, assistant or job payload is a selector and never authorization. +- `grantRevision` (what scopes/resources are authorized) is distinct from `credentialVersion` (which encrypted token/credential generation is current). A revision change invalidates affected jobs, cursors, caches and private reports. +- `DatasetCoverage` distinguishes an atomic snapshot `generation` from an append dataset `checkpoint` and coverage window. `fetchedAt` is the local observation time. `upstreamEffectiveAt` is nullable because ESI often supplies no effective timestamp. +- A dataset read is authorized only when its principal is selected and user-owned, its `authorizationStatus` is active, and its `grantRevision` and `authorizationEpoch` exactly match the selected resource. Ready cached data from an earlier authority epoch is stale and cannot satisfy a gate. +- Snapshot data must stage and validate before publishing a generation. Append data is idempotent by source identity and reports finite coverage/gaps rather than pretending to be an unlimited atomic history. +- `PublicationFence` is attached to both success and failure publication. User jobs require a non-empty resource fence; system/reference/public-feed jobs require an empty one. Corporation-owned jobs require every fenced resource to be the exact corporation principal (no foreign-resource mixing). It covers every selected resource with grant, credential and monotonic authorization-epoch fences; a worker cannot publish after its grant, credential, generation or quarantine fence is stale. This is a contract for later implementation, not an implementation in T02. +- `JobAuthority` is explicit and duplicated job-level principal/revision fields are avoided; the fence is the authority source. A user-owned job cannot promote itself to `system`; system/reference/public-feed jobs have no user dataset authority. +- A `ConsentBinding` binds one OAuth attempt to one initiating session, intent, capability and a non-empty list of per-resource requested/approved-preservation scopes. Accepted callbacks require a pending, unexpired binding; grants echo the exact bound request, are cardinality-matched, and are validated for finite counters, required scopes, server allowlists, grant revision and authorization epoch before persistence. Callback failure with an unknown consumed-code outcome returns `recovery_required`; it must not blindly replay a code. Exact orphan cleanup and recovery UX remain an operator decision. +- Transfer, revocation and quarantine are distinct authorization states. The contract carries an unresolved `StaleAuthorityObservation.maximumWindowMs`; no stale grace period is invented here. +- An inventory cursor is opaque to the caller but carries a server-side pin of selected resources, authorization revisions, epochs, snapshot generations, reference version, price version and expiry plus continuation offset/last-item state. Server state marks cursors `active` or `exhausted`; every sort uses canonical `item_id` tie-breaking; an `after_end` result always has `nextCursor: null`. Possession of a cursor/report ID is not authorization. +- Inventory summaries use `scope: all_matching_rows`, never the visible page. An empty `after_end` page is a valid terminal response and may retain a non-empty selected summary; `nextCursor: null` and `pageState: 'after_end'` make this explicit. An empty initial page means no matching rows. Clients must not substitute page totals. +- Wallet results contain labelled `wallet_balance` components. They do not expose a combined wealth total or imply journal, transaction, order or profit coverage; each component pins credential version and wallet coverage. +- `Asset.isSingleton` is optional at the legacy boundary; query rows must normalize absence to `isSingleton: null`, never infer singleton state from quantity. T02 only freezes this contract; retaining the ESI fact during ingestion remains a downstream T06/T07 gate. +- A cursor token is opaque. Its unsigned pin is stored server-side and includes server-derived caller identity, query fingerprint, selected resources, revisions, epochs and input versions; every use revalidates that pin and current authorization. Possessing or editing a token cannot authorize or retarget a cursor, and no cursor-signing crypto is selected here. +- Capability consent separates required scopes from the explicit server allowlist and approved-existing scopes preserved during an upgrade. Arbitrary requested scopes are rejected; declarative metadata does not itself replace runtime authorization checks. +- Reports carry one dataset pin per selected resource/dataset, including coverage, authorization revision, authorization epoch and fetch/effective times; compatibility maps are indexes, not the authority. Ready-result provenance requires complete snapshot/append pins, while partial results use explicit partial states. + +## Frozen request/result surfaces + +`InventoryQuery` is the first query surface: resolved resource selection, validated filters (character/type/name/category/group/quantity/location/system/region/blueprint state), stable sort, bounded page size and a pinned cursor. `InventoryQueryResult` has explicit ready, partial, stale, authorization-required and unavailable states. + +`ReferenceReaderPort` mirrors T03’s indexed `ReferenceReader`: type search, category/group, region/constellation/system and station methods use its bounded selection/record shapes. `pin()` returns a `ReferenceReaderHandle` with immutable `pinnedVersion` and explicit close ownership, so multiple calls share one artifact. `LocationReader` is only a narrow adapter over that pinned reader and synthesizes explicit `kind: 'unknown'` facts for empty ID lookups; it is not a competing reference implementation. Private structure names/evidence are not public reference facts. + +`WalletBalanceResult` is opt-in and resource-scoped. Each component carries exact `Isk`, local fetch time, nullable upstream effective time, coverage and authorization revision. Partial character coverage is visible. + +Public `market_data` and `reference_data` gates accept only active system/public provenance; an active character or corporation dataset cannot satisfy another user’s public-data requirement. + +`ReportProvenance` pins report inputs, authorization revisions, reference/price versions, fetch/effective times and assumptions. Complete reports require the exact selected-resource × required-dataset pin set; each pin echoes its selected resource and complete coverage. It is intentionally smaller than a copied inventory and contains no credentials, cookies or provider payloads. + +## Approved money policy boundary + +The baseline remains unchanged: existing `Isk` values are integer hundredths serialized as decimal text; market-price validation remains non-negative; numeric quantities remain non-negative safe integers and bigint quantities remain supported. The currently verified ESI market-price converter accepts the unsigned JSON-number decimal subset, permits exponents in `[-1000, 1000]`, and rounds non-negative ties half-up. Its legacy regex may accept a terminal newline; the chosen strict boundary below intentionally tightens that edge rather than claiming zero grammar differences. That baseline is retained as a separate policy; negative market estimates remain invalid. + +The approved shared signed extension accepts **ASCII decimal text only**, with strict full-input matching against: + +```text +^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$ +``` + +This is the chosen JSON-number grammar for the decimal boundary: no trimming, whitespace, leading `+`, leading-zero integer, `.5`, `1.`, separators, `NaN`, `Infinity`, or non-ASCII digits. The raw input is capped at **8192 characters**, checked before regex or parsing; accepted ASCII input has the same character and UTF-8 byte length. Strict matching must explicitly reject a final newline because JavaScript `$` can otherwise accept one. + +After syntax validation, count all mantissa digits (excluding sign and decimal point) and reject more than **4096 coefficient digits**. Inspect the exponent lexically and boundedly before converting it or allocating powers/BigInts. The **cents shift after scaling** (`centsShift = exponent + 2 - fractionDigits`) must be within `[-1000, 1000]`; any excess is rejected, never truncated. For example, `1e998` has shift `1000` and is allowed, while `1e999` has shift `1001` and is rejected. `1.` followed by 1002 zeros has shift `-1000` and is allowed under the coefficient/raw-input limits. Normalize to integer hundredths using fixed **half-away-from-zero** rounding and canonicalize negative zero to `0`. There is no selectable rounding-mode API. Examples: `1.005` → `101`, `-1.005` → `-101` cents. + +T02 records this policy only; T04 owns parser/formatter implementation. Newly rejected over-cap, over-coefficient, over-exponent/shift, whitespace, non-ASCII and otherwise invalid inputs are intentional compatibility changes at the new signed boundary. Strict final-newline rejection also tightens the legacy ESI regex edge; preserve the established unsigned non-negative validation and half-up rounding semantics, plus existing quantity behavior. + +## Explicit decision gates + +1. **Callback/recovery:** exact behavior after a provider code may have been consumed is unresolved; use recovery/new-binding states and never blind replay. +2. **Stale authority:** maximum stale window and remote-revocation retry/deletion deadline are unresolved; quarantine/local fail-closed behavior needs the owning auth/worker decision. +3. **Retention:** generation/checkpoint/report/export retention and GC are unresolved; valid pins must be retained, but no duration or quota is selected here. +4. **Public admission/quotas:** no public limits or signup policy are selected by these contracts. +5. **Money implementation compatibility:** the signed policy is approved and fixed; T04 must preserve the exact grammar, bounds, reject-before-allocation ordering, half-away rounding, negative-zero canonicalization, and baseline unsigned ESI validation/half-up behavior. +6. **Reference source coverage:** T03 must verify station source coverage; the reader contract does not claim stations exist. + +## Compatibility and migration + +T02 adds type-only contracts, declarative capability metadata, synthetic fixtures and tests. It does not add Convex tables, indexes, migrations, OAuth behavior, workers, schema changes or production routes. Existing `Character`, `Asset`, `AssetReport`, capability gates and repository ports remain source-compatible. `AssetReport.provenance` is optional until downstream services can pin it. diff --git a/fixtures/contracts/t02.ts b/fixtures/contracts/t02.ts new file mode 100644 index 0000000..39cc481 --- /dev/null +++ b/fixtures/contracts/t02.ts @@ -0,0 +1,246 @@ +import type { + ConsentBinding, + DatasetJob, + DatasetSyncState, + EntityResolution, + InventoryCursor, + InventoryCursorState, + InventoryQuery, + InventoryRow, + InventoryQueryResult, + ResourceAuthorization, + SignedMoneyNormalizationPolicy, + WalletBalanceResult, +} from '../../src/lib/domain/model'; + +/** Synthetic, offline-only values used to exercise contract shape and pinning. */ +export const syntheticPrincipal = { kind: 'character', id: '90000000000000001', ownerUserId: 'fixture-user-a' } as const; +export const syntheticResource = { + principal: syntheticPrincipal, + requiredScopes: ['esi-assets.read_assets.v1'], + authorizationRevision: 7, + authorizationEpoch: 1, +} as const; + +export const datasetState: DatasetSyncState = { + principal: syntheticPrincipal, + dataset: 'assets', + status: 'ready', + authorizationStatus: 'active', + authorizationEpoch: 1, + grantRevision: 7, + credentialVersion: 3, + coverage: { kind: 'snapshot', generation: 'assets-g7', complete: true }, + fetchedAt: 1_700_000_100_000, + upstreamEffectiveAt: null, +}; + +export const appendDatasetState: DatasetSyncState = { + principal: { kind: 'character', id: '90000000000000001', ownerUserId: 'fixture-user-a' }, + dataset: 'wallet_journal', + status: 'partial', + authorizationStatus: 'active', + authorizationEpoch: 1, + grantRevision: 4, + credentialVersion: 2, + coverage: { + kind: 'append', + checkpoint: 'journal-from-90000000000000001', + sourceIdentity: 'esi:character-wallet-journal', + windowStart: 1_699_900_000_000, + windowEnd: 1_700_000_100_000, + gaps: [{ from: null, to: 'journal-from-90000000000000001', reason: 'older history unavailable upstream' }], + complete: false, + }, + fetchedAt: 1_700_000_100_000, + upstreamEffectiveAt: null, +}; + +export const singletonRows: readonly Pick[] = [ + { isSingleton: true }, + { isSingleton: false }, + { isSingleton: null }, +]; + +export const ambiguousType: EntityResolution<{ id: number; name: string }> = { + status: 'ambiguous', + candidates: [{ id: 34, name: 'Tritanium' }, { id: 35, name: 'Pyerite' }], +}; + +export const authorization: ResourceAuthorization = { + principal: syntheticPrincipal, + userId: 'fixture-user-a', + status: 'active', + grantRevision: 7, + credentialVersion: 3, + grantedScopes: ['esi-assets.read_assets.v1'], + observedAt: 1_700_000_100_000, + staleAuthority: { state: 'current', observedAt: 1_700_000_100_000, maximumWindowMs: null }, + authorizationEpoch: 1, +}; + +export const consent: ConsentBinding = { + bindingId: 'consent-fixture-1', + sessionId: 'session-fixture-1', + userId: 'fixture-user-a', + intent: 'upgrade_dataset', + capability: 'inventory_query', + resourceRequests: [{ + resource: syntheticResource, + requestedScopes: ['esi-assets.read_assets.v1'], + approvedExistingScopes: [], + }], + createdAt: 1_700_000_000_000, + expiresAt: 1_700_000_300_000, + state: 'pending', +}; + +export const systemJob: DatasetJob = { + id: 'job-reference-fixture-1', + owner: { kind: 'system', service: 'reference' }, + authority: 'system', + dataset: 'reference_data', + status: 'queued', + attempts: 0, + dueAt: 1_700_000_200_000, + leaseExpiresAt: null, + publicationFence: { + jobId: 'job-reference-fixture-1', + principal: { kind: 'system', id: 'reference-public' }, + dataset: 'reference_data', + resources: [], + coverageKind: 'snapshot', + generation: 'reference-g1', + checkpoint: null, + }, +}; + +export const inventoryCursor: InventoryCursor = { token: 'cursor-fixture-opaque' }; +export const inventoryCursorState: InventoryCursorState = { + token: inventoryCursor.token, + status: 'active', + position: { offset: 0, lastItemId: null }, + pin: { + callerUserId: 'fixture-user-a', + queryFingerprint: 'sha256:fixture-query', + selectedResources: [syntheticResource], + authorizationRevisions: { 'character:90000000000000001': 7 }, + authorizationEpochs: { 'character:90000000000000001': 1 }, + snapshotGenerations: { assets: 'assets-g7' }, + referenceVersion: 'reference-g1', + priceVersion: 'prices-g1', + expiresAt: 1_700_000_300_000, + }, +}; + +export const inventoryQuery: InventoryQuery = { + callerUserId: 'fixture-user-a', + selectedResources: [syntheticResource], + authorizationRevisions: { 'character:90000000000000001': 7 }, + authorizationEpochs: { 'character:90000000000000001': 1 }, + filters: { typeIds: [34], minQuantity: '2', blueprintState: 'all' }, + sort: { field: 'name', direction: 'ascending', tieBreaker: 'item_id' }, + pageSize: 50, + cursor: inventoryCursor, +}; + +export const inventoryResult: InventoryQueryResult = { + status: 'ready', + rows: [], + summary: { matchingRows: 0, matchingQuantity: '0', estimatedValue: '0', scope: 'all_matching_rows' }, + nextCursor: null, + pageState: 'initial', + provenance: { + reportId: 'report-fixture-1', + generatedAt: 1_700_000_200_000, + fetchedAt: { assets: 1_700_000_100_000 }, + upstreamEffectiveAt: { assets: null }, + input: { + selectedResources: [syntheticResource], + requiredDatasetKinds: ['assets'], + pinSet: 'exact_selected_resource_dataset_set', + datasetPins: [{ + selectedResource: syntheticResource, + principal: syntheticPrincipal, + dataset: 'assets', + coverage: { kind: 'snapshot', generation: 'assets-g7', complete: true }, + authorizationRevision: 7, + authorizationEpoch: 1, + fetchedAt: 1_700_000_100_000, + upstreamEffectiveAt: null, + }], + authorizationRevisions: { 'character:90000000000000001': 7 }, + authorizationEpochs: { 'character:90000000000000001': 1 }, + snapshotGenerations: { assets: 'assets-g7' }, + appendCheckpoints: {}, + referenceVersion: 'reference-g1', + priceVersion: 'prices-g1', + }, + sourceSkew: false, + assumptions: [], + }, +}; + +export const afterEndInventoryCursorState: InventoryCursorState = { + ...inventoryCursorState, + status: 'exhausted', + position: { offset: 2, lastItemId: '90000000000000042' }, +}; + +export const afterEndInventoryResult: InventoryQueryResult = { + status: 'ready', + rows: [], + summary: { matchingRows: 2, matchingQuantity: '3', estimatedValue: '100', scope: 'all_matching_rows' }, + nextCursor: null, + pageState: 'after_end', + provenance: inventoryResult.provenance, +}; + +export const walletResult: WalletBalanceResult = { + status: 'ready', + components: [{ + component: 'wallet_balance', + principal: syntheticPrincipal, + amount: '-125', + fetchedAt: 1_700_000_100_000, + upstreamEffectiveAt: null, + coverage: { kind: 'snapshot', generation: 'wallet-g2', complete: true }, + authorizationRevision: 4, + credentialVersion: 2, + }], + provenance: { + ...inventoryResult.provenance, + input: { + ...inventoryResult.provenance.input, + requiredDatasetKinds: ['wallet_balance'], + pinSet: 'exact_selected_resource_dataset_set', + datasetPins: [{ + selectedResource: syntheticResource, + principal: syntheticPrincipal, + dataset: 'wallet_balance', + coverage: { kind: 'snapshot', generation: 'wallet-g2', complete: true }, + authorizationRevision: 4, + authorizationEpoch: 1, + fetchedAt: 1_700_000_100_000, + upstreamEffectiveAt: null, + }], + authorizationRevisions: { 'character:90000000000000001': 4 }, + authorizationEpochs: { 'character:90000000000000001': 1 }, + snapshotGenerations: { wallet_balance: 'wallet-g2' }, + }, + }, +}; + +/** Synthetic fixture for the approved, fixed signed-decimal policy. */ +export const approvedMoneyPolicy: SignedMoneyNormalizationPolicy = { + input: 'decimal_text', + scale: 2, + rounding: 'half_away_from_zero', + grammar: 'json_number_ascii_decimal', + maxRawInputCharacters: 8192, + maxCoefficientDigits: 4096, + exponentRange: [-1000, 1000], + centsShiftRange: [-1000, 1000], + rejectWhitespace: true, + canonicalizeNegativeZero: true, +}; diff --git a/src/lib/domain/capabilities.ts b/src/lib/domain/capabilities.ts index b945f80..5b6aadb 100644 --- a/src/lib/domain/capabilities.ts +++ b/src/lib/domain/capabilities.ts @@ -2,8 +2,18 @@ import type { Action, Capability, CapabilityGate, + ConsentBinding, + ConsentCallbackResult, + CompleteReportInputPin, + ConsentResourceRequest, + DatasetJob, + DatasetKind, + DatasetSyncState, MissingRequirement, Requirement, + ResourceAuthorization, + ResourcePrincipal, + SelectedResource, UserId, } from './model'; @@ -12,12 +22,238 @@ export const CAPABILITY_REQUIREMENTS: Readonly> = { + asset_search: { + capability: 'asset_search', datasets: ['assets'], requiredScopes: [ESI_ASSETS_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'], + }, + asset_valuation: { + capability: 'asset_valuation', datasets: ['assets', 'market_data'], requiredScopes: [ESI_ASSETS_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'], + }, + manufacturing_opportunities: { + capability: 'manufacturing_opportunities', + datasets: ['assets', 'blueprints', 'skills', 'market_data'], + requiredScopes: [ESI_ASSETS_SCOPE, ESI_BLUEPRINTS_SCOPE, ESI_SKILLS_SCOPE, ESI_SKILL_QUEUE_SCOPE], + supportsResourceSelection: true, + allowedPrincipalKinds: ['character'], + }, + inventory_query: { + capability: 'inventory_query', datasets: ['assets', 'reference_data'], requiredScopes: [ESI_ASSETS_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'], + }, + wallet_balance: { + capability: 'wallet_balance', datasets: ['wallet_balance'], requiredScopes: [ESI_WALLET_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'], + }, +}; + +export const SERVER_SCOPE_ALLOWLIST: readonly string[] = [ + ESI_ASSETS_SCOPE, ESI_WALLET_SCOPE, ESI_BLUEPRINTS_SCOPE, ESI_SKILLS_SCOPE, ESI_SKILL_QUEUE_SCOPE, +]; +const SYSTEM_JOB_DATASETS: Readonly> = { + reference: ['reference_data'], + public_feed: ['market_data'], + maintenance: ['assets', 'blueprints', 'skills', 'market_data', 'reference_data', 'wallet_balance', 'wallet_journal', 'wallet_transactions'], +}; + +export type CompleteReportPinValidation = + | { valid: true } + | { valid: false; reason: 'missing_pin' | 'unexpected_pin' | 'duplicate_pin' | 'principal_mismatch' | 'revision_mismatch' | 'incomplete_coverage' }; + +export type DatasetJobFenceValidation = + | { valid: true } + | { valid: false; reason: 'job_mismatch' | 'empty_user_fence' | 'system_resource_fence' | 'owner_mismatch' }; + +export type ConsentCallbackValidation = + | { valid: true } + | { valid: false; reason: 'binding_mismatch' | 'resource_mismatch' | 'scope_mismatch' | 'revision_mismatch' | 'duplicate_resource' }; + +export type ResourceCapabilityScopeValidation = CapabilityScopeValidation & { + principal: ResourcePrincipal; +}; +export type CapabilityScopeValidation = { + valid: boolean; + requiredScopes: readonly string[]; + allowlistedScopes: readonly string[]; + preservedScopes: readonly string[]; + missingScopes: readonly string[]; + unexpectedScopes: readonly string[]; +}; + +/** + * Validate a consent request against required capability scopes plus an + * explicitly server-approved set of existing grants. Callers cannot turn an + * arbitrary requested scope into a preserved grant. + */ +export function validateCapabilityScopes( + capability: Capability, + requestedScopes: readonly string[], + approvedExistingScopes: readonly string[] = [], +): CapabilityScopeValidation { + return validateScopeLists(CAPABILITY_DEFINITIONS[capability].requiredScopes, requestedScopes, approvedExistingScopes); +} + +function validateScopeLists( + requiredScopes: readonly string[], + requestedScopes: readonly string[], + approvedExistingScopes: readonly string[], +): CapabilityScopeValidation { + const serverAllowlist = new Set(SERVER_SCOPE_ALLOWLIST); + const preservedScopes = approvedExistingScopes.filter((scope) => serverAllowlist.has(scope)); + const allowlistedScopes = [...new Set([...requiredScopes, ...preservedScopes])]; + const allowlisted = new Set(allowlistedScopes); + const missingScopes = requiredScopes.filter((scope) => !requestedScopes.includes(scope)); + const unexpectedScopes = requestedScopes.filter((scope) => !allowlisted.has(scope) || !serverAllowlist.has(scope)); + return { + valid: missingScopes.length === 0 && unexpectedScopes.length === 0, + requiredScopes: [...requiredScopes], + allowlistedScopes, + preservedScopes: preservedScopes.filter((scope) => requestedScopes.includes(scope)), + missingScopes, + unexpectedScopes, + }; +} + +/** Keyed wrapper used by consent flows; the approved scopes belong to this resource. */ +export function validateResourceCapabilityScopes( + capability: Capability, + request: ConsentResourceRequest, +): ResourceCapabilityScopeValidation { + const requiredScopes = [...new Set([...CAPABILITY_DEFINITIONS[capability].requiredScopes, ...request.resource.requiredScopes])]; + const validated = validateScopeLists(requiredScopes, request.requestedScopes, request.approvedExistingScopes); + return { principal: request.resource.principal, ...validated }; +} + +/** Validate callback correspondence before any credential or grant is persisted. */ +export function validateConsentCallback(binding: ConsentBinding, callback: ConsentCallbackResult, now = Date.now()): ConsentCallbackValidation { + if (callback.bindingId !== binding.bindingId) return { valid: false, reason: 'binding_mismatch' }; + if (callback.status === 'recovery_required' || callback.status === 'denied') return { valid: true }; + if (binding.state !== 'pending' || !Number.isFinite(now) || !Number.isFinite(binding.createdAt) || !Number.isFinite(binding.expiresAt) + || binding.expiresAt <= binding.createdAt || now < binding.createdAt || now >= binding.expiresAt) return { valid: false, reason: 'binding_mismatch' }; + if (callback.resourceGrants.length !== binding.resourceRequests.length) return { valid: false, reason: 'resource_mismatch' }; + + const seen = new Set(); + for (const grant of callback.resourceGrants) { + const key = principalFingerprint(grant.request.resource.principal); + if (seen.has(key)) return { valid: false, reason: 'duplicate_resource' }; + seen.add(key); + const bound = binding.resourceRequests.find((request) => samePrincipal(request.resource.principal, grant.request.resource.principal)); + if (!bound || !sameResourceRequest(bound, grant.request)) return { valid: false, reason: 'resource_mismatch' }; + const scopes = validateResourceCapabilityScopes(binding.capability, { ...grant.request, requestedScopes: grant.grantedScopes }); + if (!scopes.valid) return { valid: false, reason: 'scope_mismatch' }; + if (!validCounter(bound.resource.authorizationEpoch) || !validCounter(bound.resource.authorizationRevision) + || !validCounter(grant.authorizationEpoch) || !validCounter(grant.grantRevision) || !validCounter(grant.credentialVersion) + || grant.authorizationEpoch !== bound.resource.authorizationEpoch || grant.grantRevision < bound.resource.authorizationRevision) { + return { valid: false, reason: 'revision_mismatch' }; + } + } + return seen.size === binding.resourceRequests.length ? { valid: true } : { valid: false, reason: 'resource_mismatch' }; +} + +function sameResourceRequest(left: ConsentResourceRequest, right: ConsentResourceRequest): boolean { + return samePrincipal(left.resource.principal, right.resource.principal) + && left.resource.authorizationRevision === right.resource.authorizationRevision + && left.resource.authorizationEpoch === right.resource.authorizationEpoch + && sameStrings(left.resource.requiredScopes, right.resource.requiredScopes) + && sameStrings(left.requestedScopes, right.requestedScopes) + && sameStrings(left.approvedExistingScopes, right.approvedExistingScopes); +} + +function validCounter(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0; +} + +function samePrincipal(left: ResourcePrincipal, right: ResourcePrincipal): boolean { + if (left.kind !== right.kind || left.id !== right.id) return false; + if (left.kind === 'character' || left.kind === 'corporation') { + return right.kind === left.kind && right.ownerUserId === left.ownerUserId; + } + return true; +} + +function principalFingerprint(principal: ResourcePrincipal): string { + return JSON.stringify(principal); +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value) => right.includes(value)); +} + +/** Validate the exact selected-resource × required-dataset pin set. */ +export function validateCompleteReportPins(input: CompleteReportInputPin): CompleteReportPinValidation { + if (input.pinSet !== 'exact_selected_resource_dataset_set') return { valid: false, reason: 'unexpected_pin' }; + const expected = new Set(input.selectedResources.flatMap((resource) => input.requiredDatasetKinds.map((dataset) => `${principalFingerprint(resource.principal)}|${dataset}`))); + const seen = new Set(); + for (const pin of input.datasetPins) { + const key = `${principalFingerprint(pin.selectedResource.principal)}|${pin.dataset}`; + if (!samePrincipal(pin.principal, pin.selectedResource.principal)) return { valid: false, reason: 'principal_mismatch' }; + if (!sameSelectedResource(input.selectedResources, pin.selectedResource) + || pin.authorizationRevision !== pin.selectedResource.authorizationRevision + || pin.authorizationEpoch !== pin.selectedResource.authorizationEpoch) return { valid: false, reason: 'revision_mismatch' }; + if (!expected.has(key)) return { valid: false, reason: 'unexpected_pin' }; + if (seen.has(key)) return { valid: false, reason: 'duplicate_pin' }; + seen.add(key); + if (!pin.coverage.complete) return { valid: false, reason: 'incomplete_coverage' }; + } + return seen.size === expected.size ? { valid: true } : { valid: false, reason: 'missing_pin' }; +} + +function sameSelectedResource(resources: readonly SelectedResource[], candidate: SelectedResource): boolean { + return resources.some((resource) => samePrincipal(resource.principal, candidate.principal) + && resource.authorizationRevision === candidate.authorizationRevision + && resource.authorizationEpoch === candidate.authorizationEpoch + && sameStrings(resource.requiredScopes, candidate.requiredScopes)); +} + +/** Validate duplicated job/fence identity before a worker can publish. */ +export function validateDatasetJobFence(job: DatasetJob): DatasetJobFenceValidation { + const fence = job.publicationFence; + if (fence.jobId !== job.id || fence.dataset !== job.dataset) return { valid: false, reason: 'job_mismatch' }; + if (job.authority === 'system') { + const service = 'service' in job.owner ? job.owner.service : null; + const allowedDatasets = service === null ? [] : SYSTEM_JOB_DATASETS[service]; + return fence.resources.length === 0 && fence.principal.kind === 'system' && allowedDatasets.includes(job.dataset) + ? { valid: true } + : { valid: false, reason: 'system_resource_fence' }; + } + if (fence.resources.length === 0 || fence.principal.kind === 'system') return { valid: false, reason: 'empty_user_fence' }; + const owner = job.owner as { kind: 'user'; userId: UserId } | { kind: 'corporation'; principal: ResourcePrincipal }; + if (owner.kind === 'user') { + return principalBelongsToUser(fence.principal, owner.userId) + && fence.resources.every((resource) => principalBelongsToUser(resource.principal, owner.userId)) + ? { valid: true } + : { valid: false, reason: 'owner_mismatch' }; + } + if (owner.kind === 'corporation') { + return samePrincipal(fence.principal, owner.principal) && fence.resources.every((resource) => samePrincipal(resource.principal, owner.principal)) + ? { valid: true } + : { valid: false, reason: 'owner_mismatch' }; + } + return { valid: false, reason: 'owner_mismatch' }; +} export const CONNECT_ACTION: Action = { kind: 'connect_character', @@ -58,6 +294,8 @@ export function requirementsMissing( /** * Gate a capability using only deterministic application state. In particular, * this function never delegates an authorization decision to an AI model. + * This compatibility helper accepts precomputed missing requirements; callers + * at an authorization boundary should use gateCapabilityWithAuthorization. */ export function gateCapability( capability: Capability, @@ -70,6 +308,119 @@ export function gateCapability( return { status: 'available' }; } +export type CapabilityAuthorizationContext = { + userId: UserId | null; + /** Server-resolved grant records, one per selected resource; never global user scopes. */ + resourceAuthorizations: readonly ResourceAuthorization[]; + selectedResources: readonly SelectedResource[]; + datasets: readonly DatasetSyncState[]; +}; + +const REQUIREMENT_DATASET: Partial> = { + assets: 'assets', + wallet_balance: 'wallet_balance', + market_data: 'market_data', + reference_data: 'reference_data', + blueprints: 'blueprints', + skills: 'skills', +}; +const SCOPE_REQUIREMENT: Readonly> = { + [ESI_ASSETS_SCOPE]: 'assets', + [ESI_WALLET_SCOPE]: 'wallet_balance', + [ESI_BLUEPRINTS_SCOPE]: 'blueprints', + [ESI_SKILLS_SCOPE]: 'skills', + [ESI_SKILL_QUEUE_SCOPE]: 'skills', +}; + +function principalBelongsToUser(principal: ResourcePrincipal, userId: UserId): boolean { + if (principal.kind === 'user') return principal.id === userId; + if (principal.kind === 'character' || principal.kind === 'corporation') return principal.ownerUserId === userId; + return false; +} + +function addMissing(missing: MissingRequirement[], entry: MissingRequirement): void { + if (!missing.some((current) => current.requirement === entry.requirement && current.reason === entry.reason && current.characterId === entry.characterId)) { + missing.push(entry); + } +} + +/** + * Authoritative contract gate: derives missing scopes/resources/dataset state + * instead of treating a non-empty user ID as authorization. + */ +export function gateCapabilityWithAuthorization( + capability: Capability, + context: CapabilityAuthorizationContext, + actions: readonly Action[] = [], +): CapabilityGate { + if (typeof context.userId !== 'string' || context.userId.length === 0) return authenticationRequired(capability); + + const userId = context.userId; + const definition = CAPABILITY_DEFINITIONS[capability]; + const missing: MissingRequirement[] = []; + const selectedKeys = new Set(); + for (const resource of context.selectedResources) { + selectedKeys.add(principalFingerprint(resource.principal)); + const authorization = context.resourceAuthorizations.find((entry) => samePrincipal(entry.principal, resource.principal)); + if (!authorization || authorization.userId !== userId || authorization.status !== 'active') { + addMissing(missing, { requirement: CAPABILITY_REQUIREMENTS[capability][0]!, reason: 'authorization' }); + continue; + } + if (authorization.grantRevision !== resource.authorizationRevision || authorization.authorizationEpoch !== resource.authorizationEpoch) { + addMissing(missing, { requirement: CAPABILITY_REQUIREMENTS[capability][0]!, reason: 'stale' }); + } + if (!definition.allowedPrincipalKinds.includes(resource.principal.kind)) { + addMissing(missing, { requirement: CAPABILITY_REQUIREMENTS[capability][0]!, reason: 'authorization' }); + continue; + } + if (!principalBelongsToUser(resource.principal, userId)) { + addMissing(missing, { requirement: CAPABILITY_REQUIREMENTS[capability][0]!, reason: 'authorization' }); + } + for (const scope of [...definition.requiredScopes, ...resource.requiredScopes]) { + if (!authorization.grantedScopes.includes(scope)) { + addMissing(missing, { requirement: SCOPE_REQUIREMENT[scope] ?? CAPABILITY_REQUIREMENTS[capability][0]!, reason: 'authorization' }); + } + } + } + + if (definition.supportsResourceSelection && context.selectedResources.length === 0 && definition.requiredScopes.length > 0) { + addMissing(missing, { requirement: CAPABILITY_REQUIREMENTS[capability][0]!, reason: 'authorization' }); + } + + for (const requirement of CAPABILITY_REQUIREMENTS[capability]) { + const dataset = REQUIREMENT_DATASET[requirement]; + if (!dataset) continue; + const allStates = context.datasets.filter((state) => state.dataset === dataset); + // Public reference/market state may be system-owned; private datasets must + // match one of the already-validated selected principals exactly. + const isPublicDataset = dataset === 'reference_data' || dataset === 'market_data'; + const states = isPublicDataset + ? allStates.filter((state) => state.authorizationStatus === 'active' && state.principal.kind === 'system') + : allStates.filter((state) => { + const selected = context.selectedResources.find((resource) => samePrincipal(resource.principal, state.principal)); + return selected !== undefined + && state.authorizationStatus === 'active' + && state.grantRevision === selected.authorizationRevision + && state.authorizationEpoch === selected.authorizationEpoch + && principalBelongsToUser(state.principal, userId); + }); + const selectedStateKeys = new Set(states.map((state) => principalFingerprint(state.principal))); + const missingSelectedState = !isPublicDataset && [...selectedKeys].some((key) => !selectedStateKeys.has(key)); + if (states.length === 0 || missingSelectedState) { + addMissing(missing, { requirement, reason: 'synchronization' }); + continue; + } + if (states.some((state) => state.status === 'stale')) { + addMissing(missing, { requirement, reason: 'stale' }); + } else if (states.some((state) => state.status !== 'ready')) { + addMissing(missing, { requirement, reason: 'synchronization' }); + } + } + + if (missing.length > 0) return requirementsMissing(capability, missing, actions); + return { status: 'available' }; +} + /** * Manufacturing is intentionally a visible, typed not-yet-implemented gate. * Skills and blueprints are separate requirements so a caller cannot mistake diff --git a/src/lib/domain/model.ts b/src/lib/domain/model.ts index 22ebcd8..d1256c8 100644 --- a/src/lib/domain/model.ts +++ b/src/lib/domain/model.ts @@ -15,9 +15,18 @@ export type Asset = { locationId: string; locationType: string; locationFlag: string; + /** ESI is_singleton; absent on legacy rows and therefore unknown. */ + isSingleton?: boolean; isBlueprintCopy?: boolean; }; -export type InventoryType = { id: number; name: string; categoryId: number; volume?: number }; +export type InventoryType = { + id: number; + name: string; + categoryId: number; + /** Legacy reference rows may omit group identity until refreshed. */ + groupId?: number; + volume?: number; +}; /** Integer cents (0.01 ISK), serialized as decimal text. No floating-point accumulation. */ export type Isk = string; export type ValuationMethod = 'reference_estimate' | 'immediate_liquidation' | 'expected_sell_order' | 'replacement'; @@ -39,8 +48,8 @@ export interface ReferenceRepository { export interface MarketEstimates { estimates(typeIds: number[]): Promise; } -export type Requirement = 'assets' | 'blueprints' | 'skills' | 'market_data'; -export type Capability = 'asset_search' | 'asset_valuation' | 'manufacturing_opportunities'; +export type Requirement = 'assets' | 'blueprints' | 'skills' | 'market_data' | 'wallet_balance' | 'reference_data'; +export type Capability = 'asset_search' | 'asset_valuation' | 'manufacturing_opportunities' | 'inventory_query' | 'wallet_balance'; export type Action = { kind: 'connect_character' | 'synchronize'; label: string; href: string }; export type MissingRequirement = { requirement: Requirement; characterId?: number; reason: 'authorization' | 'synchronization' | 'stale' | 'not_implemented' }; export type CapabilityGate = @@ -56,6 +65,8 @@ export type AssetReport = { referenceVersion: string; prices: Omit; warnings: string[]; + /** Added for pinned downstream reports; optional for legacy asset consumers. */ + provenance?: ReportProvenance; }; export type AssetOperation = | Exclude @@ -92,3 +103,420 @@ export interface CapitalRouter { export interface OrderBook { buyOrders(typeId: number, locationId: string): Promise<{ orderId: string; price: Isk; remaining: number; minimumVolume: number; range: string }[]>; } + +/** + * Cross-feature contracts for dataset authorization and publication. These are + * data-only ports: persistence, OAuth callbacks, workers and schema migrations + * deliberately remain in their owning milestones. + */ +export type DecimalId = string; +export type DatasetKind = + | 'assets' + | 'wallet_balance' + | 'wallet_journal' + | 'wallet_transactions' + | 'market_data' + | 'reference_data' + | 'blueprints' + | 'skills'; +/** These are semantically distinct counters even when their numeric values coincide. */ +export type GrantRevision = number; +export type AuthorizationRevision = GrantRevision; +export type AuthorizationEpoch = number; +export type CredentialVersion = number; +export type SnapshotGeneration = string; +export type AppendCheckpoint = string; +export type ReferenceVersion = string; +export type PriceVersion = string; + +/** A principal is an authorization subject, never a browser-provided selector. */ +export type ResourcePrincipal = + | { kind: 'user'; id: UserId } + | { kind: 'character'; id: DecimalId; ownerUserId: UserId } + | { kind: 'corporation'; id: DecimalId; ownerUserId: UserId } + | { kind: 'system'; id: string }; + +export type SelectedResource = { + principal: ResourcePrincipal; + requiredScopes: readonly string[]; + authorizationRevision: AuthorizationRevision; + authorizationEpoch: AuthorizationEpoch; +}; + +/** Snapshot and append datasets cannot be represented by one freshness field. */ +export type AppendCoverageGap = { + from: AppendCheckpoint | null; + to: AppendCheckpoint | null; + reason: string; +}; +export type DatasetCoverage = + | { kind: 'snapshot'; generation: SnapshotGeneration; complete: true } + | { kind: 'snapshot'; generation: null; complete: false } + | { + kind: 'append'; + checkpoint: AppendCheckpoint | null; + sourceIdentity: string; + windowStart: number | null; + windowEnd: number | null; + gaps: readonly AppendCoverageGap[]; + complete: boolean; + }; + +export type DatasetSyncStatus = + | 'never' + | 'queued' + | 'syncing' + | 'ready' + | 'partial' + | 'stale' + | 'blocked' + | 'error' + | 'quarantined'; + +/** A ready state cannot claim incomplete or missing coverage. */ +export type CompleteDatasetCoverage = + | { kind: 'snapshot'; generation: SnapshotGeneration; complete: true } + | { kind: 'append'; checkpoint: AppendCheckpoint; sourceIdentity: string; windowStart: number; windowEnd: number; gaps: readonly []; complete: true }; + +/** Upstream effective time is null when ESI does not provide one. */ +type DatasetSyncStateBase = { + principal: ResourcePrincipal; + dataset: DatasetKind; + /** Current authority snapshot; ready data from another revision/epoch is not readable. */ + authorizationStatus: AuthorizationStatus; + authorizationEpoch: AuthorizationEpoch; + grantRevision: GrantRevision; + credentialVersion: CredentialVersion | null; + fetchedAt: number | null; + upstreamEffectiveAt: number | null; + failure?: { category: string; retryable: boolean }; +}; +export type DatasetSyncState = + | (DatasetSyncStateBase & { status: 'ready'; coverage: CompleteDatasetCoverage }) + | (DatasetSyncStateBase & { status: Exclude; coverage: DatasetCoverage }); + +export type AuthorizationStatus = 'active' | 'revocation_pending' | 'quarantined' | 'revoked' | 'transferred'; + +/** + * A missing upstream revocation/transfer observation is intentionally not + * collapsed into a chosen stale-authority grace period. Operators must approve + * that window before a worker can promise one. + */ +export type StaleAuthorityObservation = { + state: 'current' | 'stale' | 'unknown'; + observedAt: number | null; + maximumWindowMs: number | null; +}; + +export type ResourceAuthorization = { + principal: ResourcePrincipal; + userId: UserId; + status: AuthorizationStatus; + grantRevision: GrantRevision; + credentialVersion: CredentialVersion | null; + grantedScopes: readonly string[]; + observedAt: number; + staleAuthority: StaleAuthorityObservation; + authorizationEpoch: AuthorizationEpoch; +}; + +export type ConsentIntent = 'initial_login' | 'link_character' | 'upgrade_dataset' | 'reconnect'; + +/** Scopes and revisions are keyed to one selected resource, never one global user list. */ +export type ConsentResourceRequest = { + resource: SelectedResource; + requestedScopes: readonly string[]; + approvedExistingScopes: readonly string[]; +}; +export type ConsentResourceGrant = { + /** Echoes the exact bound request; the callback cannot return an unbound principal. */ + request: ConsentResourceRequest; + grantedScopes: readonly string[]; + grantRevision: GrantRevision; + credentialVersion: CredentialVersion; + authorizationEpoch: AuthorizationEpoch; +}; +/** One OAuth/session binding is consumed at most once and carries its scope intent. */ +export type ConsentBinding = { + bindingId: string; + sessionId: string; + userId: UserId | null; + intent: ConsentIntent; + capability: Capability; + resourceRequests: readonly [ConsentResourceRequest, ...ConsentResourceRequest[]]; + createdAt: number; + expiresAt: number; + state: 'pending' | 'consumed' | 'failed' | 'expired'; +}; + +export type ConsentCallbackResult = + | { status: 'accepted'; bindingId: string; resourceGrants: readonly [ConsentResourceGrant, ...ConsentResourceGrant[]] } + | { status: 'denied'; bindingId: string; resourceGrants: readonly [] } + | { status: 'recovery_required'; bindingId: string; codeOutcome: 'unknown' | 'consumed'; retry: 'recover' | 'new_binding' }; + +/** Refresh CAS and OAuth replay are separate concerns; unknown rotation is actionable. */ +export type CredentialRefreshResult = + | { status: 'updated'; credentialVersion: CredentialVersion; grantRevision: GrantRevision } + | { status: 'unchanged'; credentialVersion: CredentialVersion; grantRevision: GrantRevision } + | { status: 'uncertain'; credentialVersion: CredentialVersion; grantRevision: GrantRevision; action: 'reauthorize' | 'recover' }; + +export type EntityResolution = + | { status: 'resolved'; value: T } + | { status: 'ambiguous'; candidates: readonly T[] } + | { status: 'unknown'; reason: string }; + +export type JobOwner = + | { kind: 'user'; userId: UserId } + | { kind: 'system'; service: 'reference' | 'public_feed' | 'maintenance' } + | { kind: 'corporation'; principal: Extract }; + +export type JobAuthority = 'user' | 'system'; +export type SyncJobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'quarantined'; +export type PublicationOutcome = 'success' | 'failure'; +export type PublicationResourceFence = SelectedResource & { + /** Monotonic revocation/quarantine epoch, distinct from grant and credential versions. */ + credentialVersion: CredentialVersion | null; +}; + +/** A publication fence binds all selected resources and both write outcomes. */ +type NonEmptyReadonly = readonly [T, ...T[]]; +type PublicationFenceBase = { + jobId: string; + /** Job principal is retained for routing; resources are the authorization fence. */ + principal: ResourcePrincipal; + resources: readonly PublicationResourceFence[]; + dataset: DatasetKind; +}; +export type PublicationFence = + | (PublicationFenceBase & { + coverageKind: 'snapshot'; + generation: SnapshotGeneration; + checkpoint: null; + }) + | (PublicationFenceBase & { + coverageKind: 'append'; + generation: null; + checkpoint: AppendCheckpoint | null; + }); + +type UserPublicationPrincipal = Exclude; +export type UserPublicationFence = PublicationFence & { principal: UserPublicationPrincipal; resources: NonEmptyReadonly }; +export type SystemPublicationFence = PublicationFence & { principal: Extract; resources: readonly [] }; +type DatasetJobBase = { + id: string; + dataset: DatasetKind; + status: SyncJobStatus; + attempts: number; + dueAt: number; + leaseExpiresAt: number | null; +}; +/** Authority is discriminated with ownership; callers cannot promote a job by changing a flag. */ +export type DatasetJob = DatasetJobBase & + ( + | { owner: Extract; authority: 'user'; publicationFence: UserPublicationFence } + | { owner: Extract; authority: 'user'; publicationFence: UserPublicationFence } + | { owner: Extract; authority: 'system'; publicationFence: SystemPublicationFence } + ); + +export type PublicationAttempt = { + fence: PublicationFence; + outcome: PublicationOutcome; + observedAt: number; + accepted: boolean; + rejectionReason?: 'stale_generation' | 'stale_grant' | 'stale_credential' | 'stale_authorization_epoch' | 'authorization_revoked' | 'quarantined'; +}; + +export type ReportDatasetPin = { + /** The selected resource this dataset pin belongs to; principal is retained as a compatibility index. */ + selectedResource: SelectedResource; + principal: ResourcePrincipal; + dataset: DatasetKind; + coverage: DatasetCoverage; + authorizationRevision: AuthorizationRevision; + authorizationEpoch: AuthorizationEpoch; + fetchedAt: number | null; + upstreamEffectiveAt: number | null; +}; +export type ReportInputPin = { + selectedResources: readonly SelectedResource[]; + requiredDatasetKinds: readonly [DatasetKind, ...DatasetKind[]]; + /** One pin per selected resource/dataset; maps below are compatibility indexes. */ + datasetPins: readonly ReportDatasetPin[]; + authorizationRevisions: Readonly>; + authorizationEpochs: Readonly>; + snapshotGenerations: Readonly>; + appendCheckpoints: Readonly>; + referenceVersion: ReferenceVersion | null; + priceVersion: PriceVersion | null; +}; + +export type ReportProvenance = { + reportId: string; + generatedAt: number; + fetchedAt: Readonly>; + upstreamEffectiveAt: Readonly>; + input: ReportInputPin; + sourceSkew: boolean; + assumptions: readonly string[]; +}; +export type CompleteReportDatasetPin = ReportDatasetPin & { coverage: CompleteDatasetCoverage }; +export type CompleteReportInputPin = Omit & { + /** Set identity is validated against selectedResources × requiredDatasetKinds. */ + pinSet: 'exact_selected_resource_dataset_set'; + datasetPins: readonly [CompleteReportDatasetPin, ...CompleteReportDatasetPin[]]; +}; +export type CompleteReportProvenance = Omit & { input: CompleteReportInputPin }; + +export type InventoryBlueprintState = 'all' | 'original' | 'copy' | 'unknown'; +export type InventorySortField = 'character' | 'name' | 'quantity' | 'location' | 'estimated_value'; +export type InventorySortDirection = 'ascending' | 'descending'; +export type InventoryFilters = { + characterIds?: readonly DecimalId[]; + typeIds?: readonly number[]; + customName?: string; + categoryIds?: readonly number[]; + groupIds?: readonly number[]; + minQuantity?: string; + maxQuantity?: string; + locationIds?: readonly DecimalId[]; + systemIds?: readonly number[]; + regionIds?: readonly number[]; + blueprintState?: InventoryBlueprintState; +}; +/** Item ID is the canonical unique tie-breaker for every inventory ordering. */ +export type InventorySort = { field: InventorySortField; direction: InventorySortDirection; tieBreaker: 'item_id' }; + +/** Cursor tokens are opaque; the unsigned pin is retained server-side and revalidated on use. */ +export type InventoryCursorPin = { + callerUserId: UserId; + queryFingerprint: string; + selectedResources: readonly SelectedResource[]; + authorizationRevisions: Readonly>; + authorizationEpochs: Readonly>; + snapshotGenerations: Readonly>; + referenceVersion: ReferenceVersion | null; + priceVersion: PriceVersion | null; + expiresAt: number; +}; +export type InventoryCursorPosition = { offset: number; lastItemId: DecimalId | null }; +export type InventoryCursor = { token: string }; +export type InventoryCursorState = { token: string; pin: InventoryCursorPin; status: 'active' | 'exhausted'; position: InventoryCursorPosition }; +export type InventoryQuery = { + /** Resolved from the verified session; never accepted as caller authority. */ + callerUserId: UserId; + /** Resolved by the authorization service from caller-owned resources. */ + selectedResources: readonly SelectedResource[]; + authorizationRevisions: Readonly>; + authorizationEpochs: Readonly>; + filters: InventoryFilters; + sort: InventorySort; + pageSize: number; + cursor: InventoryCursor | null; +}; + +export type InventoryRow = Omit & { + name: string; + categoryId: number | null; + groupId: number | null; + customName: string | null; + systemId: number | null; + regionId: number | null; + isSingleton: boolean | null; + estimatedValue: Isk | null; +}; +export type InventorySummary = { + matchingRows: number; + matchingQuantity: string; + estimatedValue: Isk | null; + scope: 'all_matching_rows'; +}; +export type InventoryPageState = 'initial' | 'continuation' | 'after_end'; +export type InventoryQueryResult = + | { status: 'ready'; rows: readonly InventoryRow[]; summary: InventorySummary; nextCursor: InventoryCursor | null; pageState: 'initial' | 'continuation'; provenance: CompleteReportProvenance } + | { status: 'ready'; rows: readonly InventoryRow[]; summary: InventorySummary; nextCursor: null; pageState: 'after_end'; provenance: CompleteReportProvenance } + | { status: 'partial' | 'stale' | 'authorization_required' | 'unavailable'; rows: readonly InventoryRow[]; summary: InventorySummary | null; reason: string; provenance?: ReportProvenance }; + +export type ReferenceCategoryRecord = { id: number; name: string; published?: boolean }; +export type ReferenceGroupRecord = { id: number; categoryId: number; name: string; published?: boolean }; +export type ReferenceTypeRecord = { id: number; name: string; categoryId: number; groupId: number; published?: boolean; volume?: number }; +export type ReferenceRegionRecord = { id: number; name: string | null }; +export type ReferenceConstellationRecord = { id: number; name: string | null; regionId: number | null }; +export type ReferenceSystemRecord = { + id: number; name: string; constellationId: number; regionId: number | null; + x: number; y: number; z: number; securityStatus: number; securityClass: string | null; +}; +export type ReferenceStationRecord = { + id: number; systemId: number; systemName: string | null; regionId: number | null; + name: string | null; typeId: number | null; ownerId: number | null; + x: number | null; y: number | null; z: number | null; +}; +export type ReferenceSearchOptions = { query: string; categoryId?: number; groupId?: number; published?: boolean; limit?: number; offset?: number }; +export type ReferenceListOptions = { ids?: number[]; query?: string; limit?: number; offset?: number }; +export type ReferenceIdSelection = number[] | ReferenceListOptions | undefined; +export type ReferenceGroupSelection = number[] | (ReferenceListOptions & { categoryId?: number }) | undefined; +export type ReferenceSystemSelection = number[] | (ReferenceListOptions & { constellationId?: number; regionId?: number }) | undefined; +export type ReferenceStationSelection = number[] | (ReferenceListOptions & { systemId?: number }) | undefined; + +/** Structural mirror of T03's indexed reader; records stay immutable/version-pinned. */ +export interface ReferenceReader extends ReferenceRepository { + searchTypes(options: ReferenceSearchOptions): Promise; + searchTypesByName(query: string, options?: Omit): Promise; + categories(selection?: ReferenceIdSelection): Promise; + groups(selection?: ReferenceGroupSelection): Promise; + regions(selection?: ReferenceIdSelection): Promise; + constellations(selection?: ReferenceIdSelection): Promise; + systems(selection?: ReferenceSystemSelection): Promise; + stations(selection?: ReferenceStationSelection): Promise; +} +export interface ReferenceReaderHandle extends ReferenceReader { + readonly pinnedVersion: ReferenceVersion; + close(): void; +} +/** Main reader owns a manifest-pinned handle; callers use one handle across calls. */ +export interface ReferenceReaderPort extends ReferenceReader { + pin(): ReferenceReaderHandle; +} +export type LocationFact = { + kind: 'region' | 'system' | 'station' | 'unknown'; + id: DecimalId; + name: string | null; + parentId: DecimalId | null; + referenceVersion: ReferenceVersion; +}; +/** Narrow adapter over a pinned T03 reader; unknown IDs remain explicit. */ +export interface LocationReader { + location(id: DecimalId): Promise; +} + +export type WalletBalanceComponent = { + component: 'wallet_balance'; + principal: ResourcePrincipal; + amount: Isk; + fetchedAt: number; + upstreamEffectiveAt: number | null; + coverage: DatasetCoverage; + authorizationRevision: AuthorizationRevision; + credentialVersion: CredentialVersion | null; +}; +export type WalletBalanceResult = + | { status: 'ready'; components: readonly WalletBalanceComponent[]; provenance: CompleteReportProvenance } + | { status: 'partial'; components: readonly WalletBalanceComponent[]; missing: readonly ResourcePrincipal[]; provenance: ReportProvenance } + | { status: 'blocked' | 'unavailable'; reason: string }; + +/** + * Approved signed-decimal policy contract. This is intentionally a fixed + * policy, not a selectable rounding-mode API. T04 owns the parser; T02 only + * records the boundary and resource limits. + */ +export type SignedMoneyNormalizationPolicy = { + input: 'decimal_text'; + scale: 2; + rounding: 'half_away_from_zero'; + grammar: 'json_number_ascii_decimal'; + maxRawInputCharacters: 8192; + maxCoefficientDigits: 4096; + exponentRange: readonly [-1000, 1000]; + centsShiftRange: readonly [-1000, 1000]; + rejectWhitespace: true; + canonicalizeNegativeZero: true; +}; diff --git a/tests/contracts.test.ts b/tests/contracts.test.ts new file mode 100644 index 0000000..f8eb7b6 --- /dev/null +++ b/tests/contracts.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from 'bun:test'; +import { + CAPABILITY_DEFINITIONS, + CAPABILITY_REQUIREMENTS, + ESI_ASSETS_SCOPE, + ESI_WALLET_SCOPE, + gateCapabilityWithAuthorization, + validateCapabilityScopes, + validateResourceCapabilityScopes, + validateConsentCallback, + validateDatasetJobFence, + validateCompleteReportPins, +} from '../src/lib/domain/capabilities'; +import { afterEndInventoryCursorState, afterEndInventoryResult, ambiguousType, appendDatasetState, authorization, consent, datasetState, inventoryCursor, inventoryCursorState, inventoryQuery, inventoryResult, approvedMoneyPolicy, singletonRows, syntheticResource, systemJob, walletResult } from '../fixtures/contracts/t02'; + +describe('T02 typed contract fixtures', () => { + test('separates snapshot generations from append checkpoints and unknown upstream time', () => { + expect(datasetState.coverage).toEqual({ kind: 'snapshot', generation: 'assets-g7', complete: true }); + expect(datasetState.fetchedAt).toBeGreaterThan(0); + expect(datasetState.upstreamEffectiveAt).toBeNull(); + expect(appendDatasetState.coverage.kind).toBe('append'); + if (appendDatasetState.coverage.kind === 'append') { + expect(appendDatasetState.coverage.checkpoint).toBe('journal-from-90000000000000001'); + } + expect(typeof authorization.grantRevision).toBe('number'); + expect(typeof authorization.credentialVersion).toBe('number'); + }); + + test('binds consent to one session, capability, scopes and selected resources', () => { + expect(consent.state).toBe('pending'); + expect(consent.sessionId).toBe('session-fixture-1'); + expect(consent.resourceRequests[0]?.resource.authorizationRevision).toBe(7); + expect(consent.resourceRequests[0]?.requestedScopes).toEqual([ESI_ASSETS_SCOPE]); + }); + + test('keeps system job authority distinct from user resources', () => { + expect(systemJob.authority).toBe('system'); + expect(systemJob.owner).toEqual({ kind: 'system', service: 'reference' }); + expect(systemJob.publicationFence.resources).toHaveLength(0); + expect(validateDatasetJobFence(systemJob)).toEqual({ valid: true }); + }); + + test('pins inventory cursors and reports to authorized resources and all matching rows', () => { + expect(inventoryQuery.cursor).toBe(inventoryCursor); + expect(inventoryCursorState.token).toBe(inventoryCursor.token); + expect(inventoryCursorState.status).toBe('active'); + expect(inventoryCursorState.position).toEqual({ offset: 0, lastItemId: null }); + expect(inventoryQuery.sort.tieBreaker).toBe('item_id'); + expect(inventoryQuery.authorizationRevisions['character:90000000000000001']).toBe(7); + expect(inventoryCursorState.pin.callerUserId).toBe('fixture-user-a'); + expect(inventoryCursorState.pin.authorizationEpochs['character:90000000000000001']).toBe(1); + expect(inventoryResult.status).toBe('ready'); + if (inventoryResult.status === 'ready') { + expect(inventoryResult.summary.scope).toBe('all_matching_rows'); + expect(inventoryResult.pageState).toBe('initial'); + expect(inventoryResult.provenance.input.snapshotGenerations.assets).toBe('assets-g7'); + expect(inventoryResult.provenance.input.referenceVersion).toBe('reference-g1'); + expect(validateCompleteReportPins(inventoryResult.provenance.input)).toEqual({ valid: true }); + expect(validateCompleteReportPins({ ...inventoryResult.provenance.input, datasetPins: [{ ...inventoryResult.provenance.input.datasetPins[0]!, authorizationRevision: 8 }] })).toEqual({ valid: false, reason: 'revision_mismatch' }); + } + }); + + test('after-end pages keep all-matching summaries and terminate the cursor', () => { + expect(afterEndInventoryResult.status).toBe('ready'); + if (afterEndInventoryResult.status === 'ready') { + expect(afterEndInventoryResult.pageState).toBe('after_end'); + expect(afterEndInventoryResult.rows).toHaveLength(0); + expect(afterEndInventoryResult.summary.matchingRows).toBe(2); + expect(afterEndInventoryResult.nextCursor).toBeNull(); + expect(afterEndInventoryCursorState.status).toBe('exhausted'); + expect(afterEndInventoryCursorState.position.lastItemId).toBe('90000000000000042'); + } + }); + + test('singleton facts preserve true, false and legacy unknown states', () => { + expect(singletonRows.map((row) => row.isSingleton)).toEqual([true, false, null]); + }); + + test('ambiguous entity matches remain explicit instead of selecting by name', () => { + expect(ambiguousType.status).toBe('ambiguous'); + if (ambiguousType.status === 'ambiguous') expect(ambiguousType.candidates).toHaveLength(2); + }); + + test('wallet is a separately labelled component and preserves signed cents', () => { + expect(walletResult.status).toBe('ready'); + if (walletResult.status === 'ready') { + expect(walletResult.components[0]?.component).toBe('wallet_balance'); + expect(walletResult.components[0]?.amount).toBe('-125'); + expect(walletResult.components[0]?.credentialVersion).toBe(2); + expect(walletResult.provenance.input.datasetPins[0]?.dataset).toBe('wallet_balance'); + } + }); + + test('capability metadata is allowlisted and wallet consent is opt-in', () => { + expect(CAPABILITY_REQUIREMENTS.wallet_balance).toEqual(['wallet_balance']); + expect(CAPABILITY_DEFINITIONS.wallet_balance.requiredScopes).toEqual([ESI_WALLET_SCOPE]); + expect(CAPABILITY_DEFINITIONS.inventory_query.requiredScopes).toEqual([ESI_ASSETS_SCOPE]); + expect(validateCapabilityScopes('wallet_balance', [ESI_WALLET_SCOPE]).valid).toBe(true); + expect(validateCapabilityScopes('wallet_balance', []).missingScopes).toEqual([ESI_WALLET_SCOPE]); + expect(validateCapabilityScopes('wallet_balance', [ESI_WALLET_SCOPE, ESI_ASSETS_SCOPE], [ESI_ASSETS_SCOPE]).preservedScopes).toEqual([ESI_ASSETS_SCOPE]); + expect(validateResourceCapabilityScopes('wallet_balance', consent.resourceRequests[0]!).principal).toEqual(syntheticResource.principal); + expect(validateResourceCapabilityScopes('inventory_query', { + ...consent.resourceRequests[0]!, + resource: { ...syntheticResource, requiredScopes: [ESI_WALLET_SCOPE] }, + }).missingScopes).toEqual([ESI_WALLET_SCOPE]); + const accepted = { + status: 'accepted', + bindingId: consent.bindingId, + resourceGrants: [{ + request: consent.resourceRequests[0]!, + grantedScopes: [ESI_ASSETS_SCOPE], + grantRevision: 7, + credentialVersion: 3, + authorizationEpoch: 1, + }], + } as const; + expect(validateConsentCallback(consent, accepted, 1_700_000_100_000)).toEqual({ valid: true }); + expect(validateConsentCallback(consent, { + ...accepted, + resourceGrants: [{ ...accepted.resourceGrants[0]!, request: { ...accepted.resourceGrants[0]!.request, resource: { ...syntheticResource, principal: { kind: 'character', id: '90000000000000002', ownerUserId: 'fixture-user-a' } } } }], + }, 1_700_000_100_000)).toEqual({ valid: false, reason: 'resource_mismatch' }); + expect(validateConsentCallback(consent, accepted, consent.expiresAt)).toEqual({ valid: false, reason: 'binding_mismatch' }); + expect(validateConsentCallback(consent, { ...accepted, resourceGrants: [{ ...accepted.resourceGrants[0]!, grantRevision: Number.NaN }] }, 1_700_000_100_000)).toEqual({ valid: false, reason: 'revision_mismatch' }); + expect(validateCapabilityScopes('wallet_balance', ['unallowlisted.scope']).valid).toBe(false); + expect(gateCapabilityWithAuthorization('wallet_balance', { + userId: 'fixture-user-a', resourceAuthorizations: [], selectedResources: [], datasets: [], + }).status).toBe('requirements_missing'); + expect(gateCapabilityWithAuthorization('wallet_balance', { + userId: 'fixture-user-a', resourceAuthorizations: [{ + ...authorization, principal: { kind: 'corporation', id: '99000000000000001', ownerUserId: 'fixture-user-a' }, grantedScopes: [ESI_WALLET_SCOPE], + }], selectedResources: [{ + principal: { kind: 'corporation', id: '99000000000000001', ownerUserId: 'fixture-user-a' }, + requiredScopes: [ESI_WALLET_SCOPE], authorizationRevision: 1, authorizationEpoch: 1, + }], datasets: [], + }).status).toBe('requirements_missing'); + expect(gateCapabilityWithAuthorization('inventory_query', { + userId: 'fixture-user-a', resourceAuthorizations: [authorization], selectedResources: [ + { principal: { kind: 'character', id: '90000000000000001', ownerUserId: 'fixture-user-a' }, requiredScopes: [ESI_ASSETS_SCOPE], authorizationRevision: 7, authorizationEpoch: 1 }, + ], datasets: [datasetState, { ...datasetState, principal: { kind: 'system', id: 'reference-public' }, dataset: 'reference_data' }], + }).status).toBe('available'); + expect(gateCapabilityWithAuthorization('inventory_query', { + userId: 'fixture-user-a', resourceAuthorizations: [authorization], selectedResources: [syntheticResource], datasets: [ + datasetState, + { ...datasetState, dataset: 'reference_data' }, + ], + }).status).toBe('requirements_missing'); + expect(gateCapabilityWithAuthorization('inventory_query', { + userId: 'fixture-user-a', resourceAuthorizations: [authorization], selectedResources: [syntheticResource], datasets: [ + { ...datasetState, authorizationEpoch: 2 }, + { ...datasetState, principal: { kind: 'system', id: 'reference-public' }, dataset: 'reference_data' }, + ], + }).status).toBe('requirements_missing'); + expect(gateCapabilityWithAuthorization('inventory_query', { + userId: 'fixture-user-a', resourceAuthorizations: [authorization], selectedResources: [ + syntheticResource, + { principal: { kind: 'character', id: '90000000000000002', ownerUserId: 'fixture-user-a' }, requiredScopes: [ESI_ASSETS_SCOPE], authorizationRevision: 7, authorizationEpoch: 1 }, + ], datasets: [datasetState, { ...datasetState, principal: { kind: 'system', id: 'reference-public' }, dataset: 'reference_data' }], + }).status).toBe('requirements_missing'); + expect(gateCapabilityWithAuthorization('inventory_query', { + userId: 'fixture-user-a', resourceAuthorizations: [ + authorization, + { ...authorization, principal: { kind: 'character', id: '90000000000000002', ownerUserId: 'fixture-user-a' }, grantedScopes: [] }, + ], selectedResources: [ + syntheticResource, + { principal: { kind: 'character', id: '90000000000000002', ownerUserId: 'fixture-user-a' }, requiredScopes: [ESI_ASSETS_SCOPE], authorizationRevision: 7, authorizationEpoch: 1 }, + ], datasets: [datasetState, { ...datasetState, principal: { kind: 'character', id: '90000000000000002', ownerUserId: 'fixture-user-a' }, dataset: 'assets' }, { ...datasetState, principal: { kind: 'system', id: 'reference-public' }, dataset: 'reference_data' }], + }).status).toBe('requirements_missing'); + }); + + test('approved money policy fixes grammar, bounds and signed rounding', () => { + expect(approvedMoneyPolicy.input).toBe('decimal_text'); + expect(approvedMoneyPolicy.rounding).toBe('half_away_from_zero'); + expect(approvedMoneyPolicy.grammar).toBe('json_number_ascii_decimal'); + expect(approvedMoneyPolicy.maxRawInputCharacters).toBe(8192); + expect(approvedMoneyPolicy.maxCoefficientDigits).toBe(4096); + expect(approvedMoneyPolicy.exponentRange).toEqual([-1000, 1000]); + expect(approvedMoneyPolicy.centsShiftRange).toEqual([-1000, 1000]); + expect(approvedMoneyPolicy.canonicalizeNegativeZero).toBe(true); + expect(authorization.staleAuthority.maximumWindowMs).toBeNull(); + }); +});