Harden contract helper validators
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' } } } }],
|
||||
|
||||
Reference in New Issue
Block a user