Harden contract helper validators

This commit is contained in:
jb
2026-09-19 10:07:35 -04:00
parent 614bbf9290
commit 373eb60d0b
3 changed files with 84 additions and 8 deletions
+2 -2
View File
@@ -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
+66 -6
View File
@@ -11,6 +11,7 @@ import type {
DatasetSyncState,
InventoryCursorLookup,
InventoryCursorState,
InventoryQueryResult,
MissingRequirement,
Requirement,
ResourceAuthorization,
@@ -83,11 +84,14 @@ const SYSTEM_JOB_DATASETS: Readonly<Record<'reference' | 'public_feed' | 'mainte
export type InventoryCursorValidation =
| { valid: true }
| { valid: false; reason: 'token_mismatch' | 'expired' | 'exhausted' | 'caller_mismatch' | 'query_mismatch' | 'resource_mismatch' | 'authorization_mismatch' | 'input_version_mismatch' };
| { valid: false; reason: 'token_mismatch' | 'expired' | 'exhausted' | 'caller_mismatch' | 'query_mismatch' | 'resource_mismatch' | 'authorization_mismatch' | 'input_version_mismatch' | 'malformed_state' };
export type InventoryPageValidation =
| { valid: true }
| { valid: false; reason: 'invalid_page_state' | 'terminal_cursor' | 'empty_page_summary' };
export type CompleteReportPinValidation =
| { valid: true }
| { valid: false; reason: 'missing_pin' | 'unexpected_pin' | 'duplicate_pin' | 'principal_mismatch' | 'revision_mismatch' | 'incomplete_coverage' };
| { valid: false; reason: 'missing_pin' | 'unexpected_pin' | 'duplicate_pin' | 'duplicate_resource' | 'principal_mismatch' | 'revision_mismatch' | 'incomplete_coverage' };
export type DatasetJobFenceValidation =
| { valid: true }
@@ -107,6 +111,7 @@ export type CapabilityScopeValidation = {
preservedScopes: readonly string[];
missingScopes: readonly string[];
unexpectedScopes: readonly string[];
duplicateScopes: readonly string[];
};
/**
@@ -128,18 +133,20 @@ function validateScopeLists(
approvedExistingScopes: readonly string[],
): CapabilityScopeValidation {
const serverAllowlist = new Set(SERVER_SCOPE_ALLOWLIST);
const duplicateScopes = [...new Set([...findDuplicates(requestedScopes), ...findDuplicates(approvedExistingScopes)])];
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,
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<string>();
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<string>();
const duplicates = new Set<string>();
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<T extends string | number | null>(left: Readonly<Record<stri
/** 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' };
if (hasDuplicatePrincipals(input.selectedResources.map((resource) => 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<string>();
for (const pin of input.datasetPins) {
+16
View File
@@ -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<typeof validateInventoryPageResult>[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' } } } }],