diff --git a/docs/design-records/t02-contracts.md b/docs/design-records/t02-contracts.md index 91a7872..d61db1a 100644 --- a/docs/design-records/t02-contracts.md +++ b/docs/design-records/t02-contracts.md @@ -13,12 +13,12 @@ - `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. +- 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, nonnegative safe offsets and canonical decimal last IDs; every lookup rejects duplicate principals, changed caller/query/revisions/input versions, malformed state or expired pins. An `after_end` result always has `nextCursor: null`; an empty initial page must summarize zero matches. 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. +- Capability consent separates required scopes from the explicit server allowlist and approved-existing scopes preserved during an upgrade. Arbitrary or duplicate requested/preserved 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 diff --git a/src/lib/domain/capabilities.ts b/src/lib/domain/capabilities.ts index db63456..384e359 100644 --- a/src/lib/domain/capabilities.ts +++ b/src/lib/domain/capabilities.ts @@ -11,6 +11,7 @@ import type { DatasetSyncState, InventoryCursorLookup, InventoryCursorState, + InventoryQueryResult, MissingRequirement, Requirement, ResourceAuthorization, @@ -83,11 +84,14 @@ const SYSTEM_JOB_DATASETS: Readonly 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, + valid: missingScopes.length === 0 && unexpectedScopes.length === 0 && duplicateScopes.length === 0, requiredScopes: [...requiredScopes], allowlistedScopes, preservedScopes: preservedScopes.filter((scope) => requestedScopes.includes(scope)), missingScopes, unexpectedScopes, + duplicateScopes, }; } @@ -160,6 +167,7 @@ export function validateConsentCallback(binding: ConsentBinding, callback: Conse 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' }; + if (hasDuplicatePrincipals(binding.resourceRequests.map((request) => request.resource.principal))) return { valid: false, reason: 'duplicate_resource' }; const seen = new Set(); for (const grant of callback.resourceGrants) { @@ -201,20 +209,46 @@ function samePrincipal(left: ResourcePrincipal, right: ResourcePrincipal): boole } function principalFingerprint(principal: ResourcePrincipal): string { - return JSON.stringify(principal); + switch (principal.kind) { + case 'user': return `user\u0000${principal.id}`; + case 'character': return `character\u0000${principal.id}\u0000${principal.ownerUserId}`; + case 'corporation': return `corporation\u0000${principal.id}\u0000${principal.ownerUserId}`; + case 'system': return `system\u0000${principal.id}`; + } +} + +function findDuplicates(values: readonly string[]): string[] { + const seen = new Set(); + const duplicates = new Set(); + for (const value of values) { + if (seen.has(value)) duplicates.add(value); + seen.add(value); + } + return [...duplicates]; +} + +function hasDuplicatePrincipals(principals: readonly ResourcePrincipal[]): boolean { + return findDuplicates(principals.map(principalFingerprint)).length > 0; } function sameStrings(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && left.every((value) => right.includes(value)); + if (left.length !== right.length) return false; + const sortedLeft = [...left].sort(); + const sortedRight = [...right].sort(); + return sortedLeft.every((value, index) => value === sortedRight[index]); } /** Validate every server-resolved value before consuming an opaque cursor. */ export function validateInventoryCursorUse(state: InventoryCursorState, lookup: InventoryCursorLookup): InventoryCursorValidation { - if (state.token !== lookup.token) return { valid: false, reason: 'token_mismatch' }; + if (typeof state.token !== 'string' || state.token.length === 0 || state.token !== lookup.token) return { valid: false, reason: 'token_mismatch' }; + if (state.status !== 'active' && state.status !== 'exhausted') return { valid: false, reason: 'malformed_state' }; + if (!validCursorPosition(state.position)) return { valid: false, reason: 'malformed_state' }; if (state.status === 'exhausted') return { valid: false, reason: 'exhausted' }; if (!Number.isFinite(lookup.now) || !Number.isFinite(state.pin.expiresAt) || lookup.now >= state.pin.expiresAt) return { valid: false, reason: 'expired' }; if (state.pin.callerUserId !== lookup.callerUserId) return { valid: false, reason: 'caller_mismatch' }; if (state.pin.queryFingerprint !== lookup.queryFingerprint) return { valid: false, reason: 'query_mismatch' }; + if (hasDuplicatePrincipals(state.pin.selectedResources.map((resource) => resource.principal)) + || hasDuplicatePrincipals(lookup.selectedResources.map((resource) => resource.principal))) return { valid: false, reason: 'resource_mismatch' }; if (!sameSelectedResourceList(state.pin.selectedResources, lookup.selectedResources)) return { valid: false, reason: 'resource_mismatch' }; if (!sameRecord(state.pin.authorizationRevisions, lookup.authorizationRevisions) || !sameRecord(state.pin.authorizationEpochs, lookup.authorizationEpochs)) return { valid: false, reason: 'authorization_mismatch' }; @@ -223,6 +257,30 @@ export function validateInventoryCursorUse(state: InventoryCursorState, lookup: return { valid: true }; } +/** Enforce the page discriminants without implementing page retrieval. */ +export function validateInventoryPageResult(result: InventoryQueryResult): InventoryPageValidation { + if (result.status !== 'ready') return { valid: true }; + if (result.pageState === 'after_end') { + return result.rows.length === 0 && result.nextCursor === null + ? { valid: true } + : { valid: false, reason: 'terminal_cursor' }; + } + if (result.pageState === 'initial' && result.rows.length === 0 + && (result.summary.matchingRows !== 0 || result.summary.matchingQuantity !== '0' || (result.summary.estimatedValue !== null && result.summary.estimatedValue !== '0'))) { + return { valid: false, reason: 'empty_page_summary' }; + } + if (result.pageState === 'continuation' && result.rows.length === 0) return { valid: false, reason: 'invalid_page_state' }; + return { valid: true }; +} + +function validCursorPosition(position: unknown): boolean { + if (!position || typeof position !== 'object') return false; + const candidate = position as { offset?: unknown; lastItemId?: unknown }; + if (!Number.isSafeInteger(candidate.offset) || (candidate.offset as number) < 0) return false; + if (candidate.offset === 0) return candidate.lastItemId === null; + return typeof candidate.lastItemId === 'string' && /^(0|[1-9][0-9]*)$/.test(candidate.lastItemId); +} + function sameSelectedResourceList(left: readonly SelectedResource[], right: readonly SelectedResource[]): boolean { return left.length === right.length && left.every((resource) => right.some((candidate) => sameSelectedResource([resource], candidate))); } @@ -236,6 +294,8 @@ function sameRecord(left: Readonly resource.principal)) + || findDuplicates(input.requiredDatasetKinds).length > 0) return { valid: false, reason: 'duplicate_resource' }; 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) { diff --git a/tests/contracts.test.ts b/tests/contracts.test.ts index 9956e66..def8704 100644 --- a/tests/contracts.test.ts +++ b/tests/contracts.test.ts @@ -11,6 +11,7 @@ import { validateDatasetJobFence, validateCompleteReportPins, validateInventoryCursorUse, + validateInventoryPageResult, } from '../src/lib/domain/capabilities'; import { absentLocation, afterEndInventoryCursorState, afterEndInventoryResult, ambiguousType, appendDatasetState, authorization, borrowedInventoryCursorLookup, changedQueryInventoryCursorLookup, changedRevisionInventoryCursorLookup, consent, datasetState, inventoryCursor, inventoryCursorLookup, inventoryCursorState, inventoryQuery, inventoryResult, approvedMoneyPolicy, singletonRows, systemListedStationPlaceholder, syntheticResource, systemJob, verifiedNamelessStation, walletResult } from '../fixtures/contracts/t02'; @@ -53,6 +54,10 @@ describe('T02 typed contract fixtures', () => { expect(validateInventoryCursorUse(inventoryCursorState, borrowedInventoryCursorLookup)).toEqual({ valid: false, reason: 'caller_mismatch' }); expect(validateInventoryCursorUse(inventoryCursorState, changedQueryInventoryCursorLookup)).toEqual({ valid: false, reason: 'query_mismatch' }); expect(validateInventoryCursorUse(inventoryCursorState, changedRevisionInventoryCursorLookup)).toEqual({ valid: false, reason: 'authorization_mismatch' }); + expect(validateInventoryCursorUse(inventoryCursorState, { ...inventoryCursorLookup, selectedResources: [{ ...syntheticResource, principal: { kind: 'character', ownerUserId: 'fixture-user-b', id: syntheticResource.principal.id } }] })).toEqual({ valid: false, reason: 'resource_mismatch' }); + expect(validateInventoryCursorUse(inventoryCursorState, { ...inventoryCursorLookup, selectedResources: [syntheticResource, syntheticResource] })).toEqual({ valid: false, reason: 'resource_mismatch' }); + expect(validateInventoryCursorUse({ ...inventoryCursorState, position: { offset: -1, lastItemId: null } } as unknown as typeof inventoryCursorState, inventoryCursorLookup)).toEqual({ valid: false, reason: 'malformed_state' }); + expect(validateInventoryCursorUse({ ...inventoryCursorState, position: { offset: 1, lastItemId: '01' } }, inventoryCursorLookup)).toEqual({ valid: false, reason: 'malformed_state' }); expect(inventoryCursorState.pin.authorizationEpochs['character:90000000000000001']).toBe(1); expect(inventoryResult.status).toBe('ready'); if (inventoryResult.status === 'ready') { @@ -60,7 +65,10 @@ describe('T02 typed contract fixtures', () => { expect(inventoryResult.pageState).toBe('initial'); expect(inventoryResult.provenance.input.snapshotGenerations.assets).toBe('assets-g7'); expect(inventoryResult.provenance.input.referenceVersion).toBe('reference-g1'); + expect(validateInventoryPageResult(inventoryResult)).toEqual({ valid: true }); expect(validateCompleteReportPins(inventoryResult.provenance.input)).toEqual({ valid: true }); + expect(validateCompleteReportPins({ ...inventoryResult.provenance.input, selectedResources: [syntheticResource, syntheticResource] })).toEqual({ valid: false, reason: 'duplicate_resource' }); + expect(validateCompleteReportPins({ ...inventoryResult.provenance.input, datasetPins: [inventoryResult.provenance.input.datasetPins[0]!, inventoryResult.provenance.input.datasetPins[0]!] })).toEqual({ valid: false, reason: 'duplicate_pin' }); expect(validateCompleteReportPins({ ...inventoryResult.provenance.input, datasetPins: [{ ...inventoryResult.provenance.input.datasetPins[0]!, authorizationRevision: 8 }] })).toEqual({ valid: false, reason: 'revision_mismatch' }); } }); @@ -74,6 +82,12 @@ describe('T02 typed contract fixtures', () => { expect(afterEndInventoryResult.nextCursor).toBeNull(); expect(afterEndInventoryCursorState.status).toBe('exhausted'); expect(afterEndInventoryCursorState.position.lastItemId).toBe('90000000000000042'); + expect(validateInventoryPageResult(afterEndInventoryResult)).toEqual({ valid: true }); + expect(validateInventoryPageResult({ + status: 'ready', rows: [], summary: { matchingRows: 2, matchingQuantity: '0', estimatedValue: null, scope: 'all_matching_rows' }, + nextCursor: null, pageState: 'initial', provenance: inventoryResult.provenance!, + } as unknown as Parameters[0])).toEqual({ valid: false, reason: 'empty_page_summary' }); + expect(validateInventoryPageResult({ ...afterEndInventoryResult, nextCursor: inventoryCursor } as unknown as typeof afterEndInventoryResult)).toEqual({ valid: false, reason: 'terminal_cursor' }); } }); @@ -112,6 +126,7 @@ describe('T02 typed contract fixtures', () => { 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', [ESI_WALLET_SCOPE, ESI_WALLET_SCOPE]).duplicateScopes).toEqual([ESI_WALLET_SCOPE]); 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); @@ -131,6 +146,7 @@ describe('T02 typed contract fixtures', () => { }], } 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: { ownerUserId: 'fixture-user-a', id: syntheticResource.principal.id, kind: 'character' } } } }] }, 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' } } } }],