Add dataset and identity contract foundations
This commit is contained in:
@@ -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.
|
||||||
@@ -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<InventoryRow, 'isSingleton'>[] = [
|
||||||
|
{ 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,
|
||||||
|
};
|
||||||
@@ -2,8 +2,18 @@ import type {
|
|||||||
Action,
|
Action,
|
||||||
Capability,
|
Capability,
|
||||||
CapabilityGate,
|
CapabilityGate,
|
||||||
|
ConsentBinding,
|
||||||
|
ConsentCallbackResult,
|
||||||
|
CompleteReportInputPin,
|
||||||
|
ConsentResourceRequest,
|
||||||
|
DatasetJob,
|
||||||
|
DatasetKind,
|
||||||
|
DatasetSyncState,
|
||||||
MissingRequirement,
|
MissingRequirement,
|
||||||
Requirement,
|
Requirement,
|
||||||
|
ResourceAuthorization,
|
||||||
|
ResourcePrincipal,
|
||||||
|
SelectedResource,
|
||||||
UserId,
|
UserId,
|
||||||
} from './model';
|
} from './model';
|
||||||
|
|
||||||
@@ -12,12 +22,238 @@ export const CAPABILITY_REQUIREMENTS: Readonly<Record<Capability, readonly Requi
|
|||||||
asset_search: ['assets'],
|
asset_search: ['assets'],
|
||||||
asset_valuation: ['assets', 'market_data'],
|
asset_valuation: ['assets', 'market_data'],
|
||||||
manufacturing_opportunities: ['assets', 'blueprints', 'skills', 'market_data'],
|
manufacturing_opportunities: ['assets', 'blueprints', 'skills', 'market_data'],
|
||||||
|
inventory_query: ['assets', 'reference_data'],
|
||||||
|
wallet_balance: ['wallet_balance'],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Assets scope verified against the 2026-08-18 ESI OpenAPI contract. */
|
/** Assets scope verified against the 2026-08-18 ESI OpenAPI contract. */
|
||||||
export const ESI_ASSETS_SCOPE = 'esi-assets.read_assets.v1';
|
export const ESI_ASSETS_SCOPE = 'esi-assets.read_assets.v1';
|
||||||
|
export const ESI_WALLET_SCOPE = 'esi-wallet.read_character_wallet.v1';
|
||||||
|
export const ESI_BLUEPRINTS_SCOPE = 'esi-characters.read_blueprints.v1';
|
||||||
|
export const ESI_SKILLS_SCOPE = 'esi-skills.read_skills.v1';
|
||||||
|
export const ESI_SKILL_QUEUE_SCOPE = 'esi-skills.read_skillqueue.v1';
|
||||||
export const ASSETS_SCOPE = ESI_ASSETS_SCOPE;
|
export const ASSETS_SCOPE = ESI_ASSETS_SCOPE;
|
||||||
export const REQUIRED_ASSETS_SCOPE = ESI_ASSETS_SCOPE;
|
export const REQUIRED_ASSETS_SCOPE = ESI_ASSETS_SCOPE;
|
||||||
|
export const WALLET_BALANCE_SCOPE = ESI_WALLET_SCOPE;
|
||||||
|
|
||||||
|
export type CapabilityDefinition = {
|
||||||
|
capability: Capability;
|
||||||
|
datasets: readonly DatasetKind[];
|
||||||
|
requiredScopes: readonly string[];
|
||||||
|
supportsResourceSelection: boolean;
|
||||||
|
allowedPrincipalKinds: readonly ResourcePrincipal['kind'][];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The allowlisted capability surface is declarative. Consumers may use this to
|
||||||
|
* construct consent requests, but it does not itself authorize a principal.
|
||||||
|
*/
|
||||||
|
export const CAPABILITY_DEFINITIONS: Readonly<Record<Capability, CapabilityDefinition>> = {
|
||||||
|
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<Record<'reference' | 'public_feed' | 'maintenance', readonly DatasetKind[]>> = {
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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 = {
|
export const CONNECT_ACTION: Action = {
|
||||||
kind: 'connect_character',
|
kind: 'connect_character',
|
||||||
@@ -58,6 +294,8 @@ export function requirementsMissing(
|
|||||||
/**
|
/**
|
||||||
* Gate a capability using only deterministic application state. In particular,
|
* Gate a capability using only deterministic application state. In particular,
|
||||||
* this function never delegates an authorization decision to an AI model.
|
* 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(
|
export function gateCapability(
|
||||||
capability: Capability,
|
capability: Capability,
|
||||||
@@ -70,6 +308,119 @@ export function gateCapability(
|
|||||||
return { status: 'available' };
|
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<Record<Requirement, DatasetKind>> = {
|
||||||
|
assets: 'assets',
|
||||||
|
wallet_balance: 'wallet_balance',
|
||||||
|
market_data: 'market_data',
|
||||||
|
reference_data: 'reference_data',
|
||||||
|
blueprints: 'blueprints',
|
||||||
|
skills: 'skills',
|
||||||
|
};
|
||||||
|
const SCOPE_REQUIREMENT: Readonly<Record<string, Requirement>> = {
|
||||||
|
[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<string>();
|
||||||
|
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.
|
* Manufacturing is intentionally a visible, typed not-yet-implemented gate.
|
||||||
* Skills and blueprints are separate requirements so a caller cannot mistake
|
* Skills and blueprints are separate requirements so a caller cannot mistake
|
||||||
|
|||||||
+431
-3
@@ -15,9 +15,18 @@ export type Asset = {
|
|||||||
locationId: string;
|
locationId: string;
|
||||||
locationType: string;
|
locationType: string;
|
||||||
locationFlag: string;
|
locationFlag: string;
|
||||||
|
/** ESI is_singleton; absent on legacy rows and therefore unknown. */
|
||||||
|
isSingleton?: boolean;
|
||||||
isBlueprintCopy?: 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. */
|
/** Integer cents (0.01 ISK), serialized as decimal text. No floating-point accumulation. */
|
||||||
export type Isk = string;
|
export type Isk = string;
|
||||||
export type ValuationMethod = 'reference_estimate' | 'immediate_liquidation' | 'expected_sell_order' | 'replacement';
|
export type ValuationMethod = 'reference_estimate' | 'immediate_liquidation' | 'expected_sell_order' | 'replacement';
|
||||||
@@ -39,8 +48,8 @@ export interface ReferenceRepository {
|
|||||||
export interface MarketEstimates {
|
export interface MarketEstimates {
|
||||||
estimates(typeIds: number[]): Promise<PriceSnapshot>;
|
estimates(typeIds: number[]): Promise<PriceSnapshot>;
|
||||||
}
|
}
|
||||||
export type Requirement = 'assets' | 'blueprints' | 'skills' | 'market_data';
|
export type Requirement = 'assets' | 'blueprints' | 'skills' | 'market_data' | 'wallet_balance' | 'reference_data';
|
||||||
export type Capability = 'asset_search' | 'asset_valuation' | 'manufacturing_opportunities';
|
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 Action = { kind: 'connect_character' | 'synchronize'; label: string; href: string };
|
||||||
export type MissingRequirement = { requirement: Requirement; characterId?: number; reason: 'authorization' | 'synchronization' | 'stale' | 'not_implemented' };
|
export type MissingRequirement = { requirement: Requirement; characterId?: number; reason: 'authorization' | 'synchronization' | 'stale' | 'not_implemented' };
|
||||||
export type CapabilityGate =
|
export type CapabilityGate =
|
||||||
@@ -56,6 +65,8 @@ export type AssetReport = {
|
|||||||
referenceVersion: string;
|
referenceVersion: string;
|
||||||
prices: Omit<PriceSnapshot, 'prices'>;
|
prices: Omit<PriceSnapshot, 'prices'>;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
|
/** Added for pinned downstream reports; optional for legacy asset consumers. */
|
||||||
|
provenance?: ReportProvenance;
|
||||||
};
|
};
|
||||||
export type AssetOperation =
|
export type AssetOperation =
|
||||||
| Exclude<CapabilityGate, { status: 'available' }>
|
| Exclude<CapabilityGate, { status: 'available' }>
|
||||||
@@ -92,3 +103,420 @@ export interface CapitalRouter {
|
|||||||
export interface OrderBook {
|
export interface OrderBook {
|
||||||
buyOrders(typeId: number, locationId: string): Promise<{ orderId: string; price: Isk; remaining: number; minimumVolume: number; range: string }[]>;
|
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<DatasetSyncStatus, 'ready'>; 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<T> =
|
||||||
|
| { 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<ResourcePrincipal, { kind: 'corporation' }> };
|
||||||
|
|
||||||
|
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<T> = 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<ResourcePrincipal, { kind: 'system' }>;
|
||||||
|
export type UserPublicationFence = PublicationFence & { principal: UserPublicationPrincipal; resources: NonEmptyReadonly<PublicationResourceFence> };
|
||||||
|
export type SystemPublicationFence = PublicationFence & { principal: Extract<ResourcePrincipal, { kind: 'system' }>; 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<JobOwner, { kind: 'user' }>; authority: 'user'; publicationFence: UserPublicationFence }
|
||||||
|
| { owner: Extract<JobOwner, { kind: 'corporation' }>; authority: 'user'; publicationFence: UserPublicationFence }
|
||||||
|
| { owner: Extract<JobOwner, { kind: 'system' }>; 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<Record<string, AuthorizationRevision>>;
|
||||||
|
authorizationEpochs: Readonly<Record<string, AuthorizationEpoch>>;
|
||||||
|
snapshotGenerations: Readonly<Record<string, SnapshotGeneration>>;
|
||||||
|
appendCheckpoints: Readonly<Record<string, AppendCheckpoint>>;
|
||||||
|
referenceVersion: ReferenceVersion | null;
|
||||||
|
priceVersion: PriceVersion | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportProvenance = {
|
||||||
|
reportId: string;
|
||||||
|
generatedAt: number;
|
||||||
|
fetchedAt: Readonly<Record<string, number | null>>;
|
||||||
|
upstreamEffectiveAt: Readonly<Record<string, number | null>>;
|
||||||
|
input: ReportInputPin;
|
||||||
|
sourceSkew: boolean;
|
||||||
|
assumptions: readonly string[];
|
||||||
|
};
|
||||||
|
export type CompleteReportDatasetPin = ReportDatasetPin & { coverage: CompleteDatasetCoverage };
|
||||||
|
export type CompleteReportInputPin = Omit<ReportInputPin, 'datasetPins'> & {
|
||||||
|
/** Set identity is validated against selectedResources × requiredDatasetKinds. */
|
||||||
|
pinSet: 'exact_selected_resource_dataset_set';
|
||||||
|
datasetPins: readonly [CompleteReportDatasetPin, ...CompleteReportDatasetPin[]];
|
||||||
|
};
|
||||||
|
export type CompleteReportProvenance = Omit<ReportProvenance, 'input'> & { 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<Record<string, AuthorizationRevision>>;
|
||||||
|
authorizationEpochs: Readonly<Record<string, AuthorizationEpoch>>;
|
||||||
|
snapshotGenerations: Readonly<Record<string, SnapshotGeneration>>;
|
||||||
|
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<Record<string, AuthorizationRevision>>;
|
||||||
|
authorizationEpochs: Readonly<Record<string, AuthorizationEpoch>>;
|
||||||
|
filters: InventoryFilters;
|
||||||
|
sort: InventorySort;
|
||||||
|
pageSize: number;
|
||||||
|
cursor: InventoryCursor | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InventoryRow = Omit<Asset, 'isSingleton'> & {
|
||||||
|
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<ReferenceTypeRecord[]>;
|
||||||
|
searchTypesByName(query: string, options?: Omit<ReferenceSearchOptions, 'query'>): Promise<ReferenceTypeRecord[]>;
|
||||||
|
categories(selection?: ReferenceIdSelection): Promise<ReferenceCategoryRecord[]>;
|
||||||
|
groups(selection?: ReferenceGroupSelection): Promise<ReferenceGroupRecord[]>;
|
||||||
|
regions(selection?: ReferenceIdSelection): Promise<ReferenceRegionRecord[]>;
|
||||||
|
constellations(selection?: ReferenceIdSelection): Promise<ReferenceConstellationRecord[]>;
|
||||||
|
systems(selection?: ReferenceSystemSelection): Promise<ReferenceSystemRecord[]>;
|
||||||
|
stations(selection?: ReferenceStationSelection): Promise<ReferenceStationRecord[]>;
|
||||||
|
}
|
||||||
|
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<LocationFact>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user