@percayso/identity-contracts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/application.d.ts +156 -0
- package/dist/application.js +170 -0
- package/dist/assertion.d.ts +125 -0
- package/dist/assertion.js +221 -0
- package/dist/brand.d.ts +30 -0
- package/dist/brand.js +20 -0
- package/dist/citizen.d.ts +158 -0
- package/dist/citizen.js +144 -0
- package/dist/client.d.ts +140 -0
- package/dist/client.js +154 -0
- package/dist/evidence.d.ts +202 -0
- package/dist/evidence.js +224 -0
- package/dist/ids.d.ts +158 -0
- package/dist/ids.js +154 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +35 -0
- package/dist/parse.d.ts +67 -0
- package/dist/parse.js +109 -0
- package/dist/provisioning.d.ts +139 -0
- package/dist/provisioning.js +223 -0
- package/dist/refusal.d.ts +122 -0
- package/dist/refusal.js +116 -0
- package/package.json +48 -0
package/dist/citizen.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Citizen: the three statuses of §5.2, what a Citizen record may carry, and the
|
|
3
|
+
* witness required before one can exist at all.
|
|
4
|
+
*
|
|
5
|
+
* THERE IS NO `pending` CITIZEN, and this file is where that sentence stops being
|
|
6
|
+
* prose. §5.2: "Citizen states are `active`, `suspended` and `closed`. **There is no
|
|
7
|
+
* `pending` Citizen** — pending belongs to an Application." §15's `citizens` row
|
|
8
|
+
* says the same from the other side, forbidding a generic pending state on
|
|
9
|
+
* `applications` too. So the union omits it — a `switch` over `CitizenStatus` that
|
|
10
|
+
* adds a `pending` arm does not compile — and `parseCitizenStatus` refuses the
|
|
11
|
+
* string by name, with a message that says where pending actually belongs. The
|
|
12
|
+
* type cannot express it and the decoder will not accept it; either alone would
|
|
13
|
+
* leave the other half open.
|
|
14
|
+
*
|
|
15
|
+
* THE SECOND INVARIANT IS THAT A CITIZEN CANNOT BE CREATED WITHOUT AN APPROVAL.
|
|
16
|
+
* §14.4: "No route, script, production fixture or flag creates a Citizen outside an
|
|
17
|
+
* Application." `CitizenCreation` requires an `ApprovedApplication`, whose `state`
|
|
18
|
+
* is the literal `"approved"` — so there is no Citizen-shaped value a caller can
|
|
19
|
+
* assemble from an Application in any other state, and no optional field that could
|
|
20
|
+
* be left out. `parseCitizenCreation` refuses the same thing at runtime.
|
|
21
|
+
*
|
|
22
|
+
* AND THE THIRD IS THAT A CITIZEN RECORD HOLDS NO MONEY. §15's `citizens` row must
|
|
23
|
+
* not hold "any balance, wallet balance or workspace role", and §11.3 is blunter:
|
|
24
|
+
* coordination state "is operational belief, never the economic fact". There is no
|
|
25
|
+
* balance field here, no wallet field and no role field, and `readClosedRecord`
|
|
26
|
+
* refuses a caller who sends one rather than dropping it silently — a dropped field
|
|
27
|
+
* is a field somebody adds to the type next week to stop it being dropped.
|
|
28
|
+
*
|
|
29
|
+
* WHAT IS ABSENT BY DESIGN. A Citizen record does not say whether a Citizen Space
|
|
30
|
+
* exists. §5.2: "An approved Citizen whose Citizen Space has not yet been created is
|
|
31
|
+
* an ordinary `active` Citizen with outstanding onboarding coordination. They are
|
|
32
|
+
* not a lesser kind of Citizen." A flag here would create the lesser kind, so the
|
|
33
|
+
* coordination state lives in `provisioning.ts` where it cannot be mistaken for a
|
|
34
|
+
* property of the person.
|
|
35
|
+
*/
|
|
36
|
+
import { parseApprovedApplication, } from "./application.js";
|
|
37
|
+
import { parseCommunityEmailAddress, parseDisplayHandle, readCitizenId, readVerificationCaseId, } from "./ids.js";
|
|
38
|
+
import { readClosedRecord, readString, refuseValue } from "./parse.js";
|
|
39
|
+
/** Every Citizen status, in §5.2's order. */
|
|
40
|
+
export const CITIZEN_STATUSES = Object.freeze([
|
|
41
|
+
"active",
|
|
42
|
+
"suspended",
|
|
43
|
+
"closed",
|
|
44
|
+
]);
|
|
45
|
+
/**
|
|
46
|
+
* A Citizen status from a wire value, or a refusal.
|
|
47
|
+
*
|
|
48
|
+
* `pending` gets its own refusal rather than falling through the general one. The
|
|
49
|
+
* general message would say a value was outside a closed union, which is true and
|
|
50
|
+
* unhelpful; this one says where pending belongs, because a caller sending it has
|
|
51
|
+
* almost certainly read an Application state and written it into a Citizen field.
|
|
52
|
+
*/
|
|
53
|
+
export const parseCitizenStatus = (value, field = "status") => {
|
|
54
|
+
if (value === "pending") {
|
|
55
|
+
refuseValue(field, "must not be pending. There is no pending Citizen — pending belongs to an Application (§5.2)");
|
|
56
|
+
}
|
|
57
|
+
if (typeof value !== "string" ||
|
|
58
|
+
!CITIZEN_STATUSES.includes(value)) {
|
|
59
|
+
refuseValue(field, `must be one of the ${CITIZEN_STATUSES.length} Citizen statuses of §5.2`);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
};
|
|
63
|
+
const CITIZEN_RECORD_FIELDS = Object.freeze([
|
|
64
|
+
"citizenId",
|
|
65
|
+
"status",
|
|
66
|
+
"approvedVerificationCaseId",
|
|
67
|
+
"assuranceProfileVersion",
|
|
68
|
+
"verificationMethod",
|
|
69
|
+
"createdAt",
|
|
70
|
+
]);
|
|
71
|
+
/**
|
|
72
|
+
* Cayso's verification methods, as a value this package can check against.
|
|
73
|
+
*
|
|
74
|
+
* `VERIFICATION_METHODS` is a runtime export of `@cayso/contracts`, and this package
|
|
75
|
+
* may not take a value import on it: the coupling scan's rule C forbids a bare
|
|
76
|
+
* specifier in a value position here, which is how a contract package stays free of
|
|
77
|
+
* runtime dependencies. So the members are listed once, in one place, and
|
|
78
|
+
* `citizen.test.ts` asserts the list is exactly Cayso's by checking assignability in
|
|
79
|
+
* both directions — a member added or removed upstream fails this package's
|
|
80
|
+
* typecheck rather than passing through it. That test is the seam; this array is not
|
|
81
|
+
* a second vocabulary.
|
|
82
|
+
*/
|
|
83
|
+
const VERIFICATION_METHOD_MEMBERS = Object.freeze(["dev-bypass", "document", "document-and-biometric"]);
|
|
84
|
+
const readVerificationMethod = (record, field, key) => {
|
|
85
|
+
const value = readString(record, field, key);
|
|
86
|
+
if (!VERIFICATION_METHOD_MEMBERS.includes(value)) {
|
|
87
|
+
refuseValue(`${field}.${key}`, "must be a verification method of the Cayso assurance profile");
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
};
|
|
91
|
+
const readMillis = (record, field, key) => {
|
|
92
|
+
const value = record[key];
|
|
93
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
94
|
+
refuseValue(`${field}.${key}`, "must be a non-negative integer count of milliseconds");
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
};
|
|
98
|
+
/** A `CitizenRecord` from a wire value, or a refusal. */
|
|
99
|
+
export const parseCitizenRecord = (value, field = "citizen") => {
|
|
100
|
+
const record = readClosedRecord(value, field, CITIZEN_RECORD_FIELDS);
|
|
101
|
+
return {
|
|
102
|
+
citizenId: readCitizenId(record, field, "citizenId"),
|
|
103
|
+
status: parseCitizenStatus(record["status"], `${field}.status`),
|
|
104
|
+
approvedVerificationCaseId: readVerificationCaseId(record, field, "approvedVerificationCaseId"),
|
|
105
|
+
assuranceProfileVersion: readString(record, field, "assuranceProfileVersion"),
|
|
106
|
+
verificationMethod: readVerificationMethod(record, field, "verificationMethod"),
|
|
107
|
+
createdAt: readMillis(record, field, "createdAt"),
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
const CITIZEN_NAMES_FIELDS = Object.freeze([
|
|
111
|
+
"citizenId",
|
|
112
|
+
"handle",
|
|
113
|
+
"communityEmailAddress",
|
|
114
|
+
]);
|
|
115
|
+
/** A `CitizenNames` from a wire value, or a refusal. */
|
|
116
|
+
export const parseCitizenNames = (value, field = "citizenNames") => {
|
|
117
|
+
const record = readClosedRecord(value, field, CITIZEN_NAMES_FIELDS);
|
|
118
|
+
return {
|
|
119
|
+
citizenId: readCitizenId(record, field, "citizenId"),
|
|
120
|
+
handle: parseDisplayHandle(readString(record, field, "handle"), `${field}.handle`),
|
|
121
|
+
communityEmailAddress: parseCommunityEmailAddress(readString(record, field, "communityEmailAddress"), `${field}.communityEmailAddress`),
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
const CITIZEN_CREATION_FIELDS = Object.freeze([
|
|
125
|
+
"approvedApplication",
|
|
126
|
+
"citizenId",
|
|
127
|
+
"assuranceProfileVersion",
|
|
128
|
+
"verificationMethod",
|
|
129
|
+
]);
|
|
130
|
+
/**
|
|
131
|
+
* A `CitizenCreation` from a wire value, or a refusal.
|
|
132
|
+
*
|
|
133
|
+
* The refusal a caller is most likely to meet here is the `ContractParseError`
|
|
134
|
+
* `parseApprovedApplication` raises: `approvedApplication.state: must be approved`.
|
|
135
|
+
*/
|
|
136
|
+
export const parseCitizenCreation = (value, field = "citizenCreation") => {
|
|
137
|
+
const record = readClosedRecord(value, field, CITIZEN_CREATION_FIELDS);
|
|
138
|
+
return {
|
|
139
|
+
approvedApplication: parseApprovedApplication(record["approvedApplication"], `${field}.approvedApplication`),
|
|
140
|
+
citizenId: readCitizenId(record, field, "citizenId"),
|
|
141
|
+
assuranceProfileVersion: readString(record, field, "assuranceProfileVersion"),
|
|
142
|
+
verificationMethod: readVerificationMethod(record, field, "verificationMethod"),
|
|
143
|
+
};
|
|
144
|
+
};
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A thin client: the routes this contract describes, a decoder on every response,
|
|
3
|
+
* and nothing else.
|
|
4
|
+
*
|
|
5
|
+
* THIN IS A CONSTRAINT, NOT A DESCRIPTION. This package publishes wire types, the
|
|
6
|
+
* closed unions and a client. It contains no server, no policy engine and no
|
|
7
|
+
* verifier, and this file is where that boundary is most easily lost: a client that
|
|
8
|
+
* retried, cached, refreshed a session or decided what a refusal meant would be
|
|
9
|
+
* making decisions the pillar has not taken yet, inside a package every consumer
|
|
10
|
+
* compiles against.
|
|
11
|
+
*
|
|
12
|
+
* SO IT READS AND NEVER WRITES. There is no method here that creates an Application,
|
|
13
|
+
* submits evidence, approves anything or asks for an assertion. The Application API
|
|
14
|
+
* and the approval transaction do not exist yet, and a client method posting to a
|
|
15
|
+
* route nobody has implemented is worse than no method at all: it looks like a
|
|
16
|
+
* commitment, and the first consumer to call it discovers the shape by trial.
|
|
17
|
+
*
|
|
18
|
+
* WHY THE ROUTE TABLE IS HERE AT ALL. A path is part of a wire contract, and the
|
|
19
|
+
* sibling precedent publishes one for the same reason: a consumer hand-writing a URL
|
|
20
|
+
* is a consumer with a private copy of the contract. Publishing it makes a
|
|
21
|
+
* disagreement between this package and the slice that serves these routes a
|
|
22
|
+
* VERSION BUMP somebody has to make, rather than a 404 somebody debugs.
|
|
23
|
+
*
|
|
24
|
+
* NO SIDE EFFECTS ON IMPORT. Importing this module defines functions and freezes one
|
|
25
|
+
* table. It opens no connection, reads no environment variable and touches no clock.
|
|
26
|
+
*
|
|
27
|
+
* THIS SLICE EXPOSES NO ENDPOINT. §12.5's obligation — declared dimensions, figures
|
|
28
|
+
* backed by load and abuse evidence — falls on the slice that makes an endpoint
|
|
29
|
+
* externally reachable. This package is the caller's side of a conversation with a
|
|
30
|
+
* server that does not exist yet, so it declares no limit policy, and the slice
|
|
31
|
+
* that serves these routes declares one before it exposes them.
|
|
32
|
+
*/
|
|
33
|
+
import { type ApplicationSummary } from "./application.js";
|
|
34
|
+
import { type CitizenNames, type CitizenRecord } from "./citizen.js";
|
|
35
|
+
import { type VerificationCaseSummary } from "./evidence.js";
|
|
36
|
+
import type { ApplicationId, VerificationCaseId } from "./ids.js";
|
|
37
|
+
import { type ProvisioningCoordination } from "./provisioning.js";
|
|
38
|
+
import { type ContractResult } from "./refusal.js";
|
|
39
|
+
/**
|
|
40
|
+
* The transport, described structurally so that this package depends on no runtime
|
|
41
|
+
* and no ambient library.
|
|
42
|
+
*
|
|
43
|
+
* `globalThis.fetch` satisfies it, and so does a test double of about ten lines.
|
|
44
|
+
* The shape is deliberately the smallest subset the client uses: naming the platform
|
|
45
|
+
* `Request`, `Response` or `AbortSignal` types would put `@types/node` or the DOM
|
|
46
|
+
* library into this package's published declarations, and a contract package that
|
|
47
|
+
* makes a consumer's typecheck depend on ITS ambient types is not dependency-free in
|
|
48
|
+
* any sense a consumer would recognise.
|
|
49
|
+
*/
|
|
50
|
+
export interface HttpRequestInit {
|
|
51
|
+
readonly method: string;
|
|
52
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
53
|
+
}
|
|
54
|
+
/** The part of a response this client reads. */
|
|
55
|
+
export interface HttpResponse {
|
|
56
|
+
readonly ok: boolean;
|
|
57
|
+
readonly status: number;
|
|
58
|
+
json(): Promise<unknown>;
|
|
59
|
+
}
|
|
60
|
+
/** Anything that can perform a request. `globalThis.fetch` is assignable to it. */
|
|
61
|
+
export type FetchLike = (url: string, init: HttpRequestInit) => Promise<HttpResponse>;
|
|
62
|
+
/**
|
|
63
|
+
* How the caller carries its established session.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately a hook rather than a scheme. Whether a consumer presents a Percayso
|
|
66
|
+
* ID session cookie, an authorisation-code result or something else is the sign-in
|
|
67
|
+
* protocol's business, and a header format invented here would become the de facto
|
|
68
|
+
* answer before that is settled. It returns headers, so a consumer can carry
|
|
69
|
+
* whatever it has without this package naming it.
|
|
70
|
+
*/
|
|
71
|
+
export type Authorise = () => Promise<Readonly<Record<string, string>>>;
|
|
72
|
+
export interface IdentityClientOptions {
|
|
73
|
+
/** Where the Identity Platform is. No default: a contract package does not know where you deployed. */
|
|
74
|
+
readonly baseUrl: string;
|
|
75
|
+
readonly authorise?: Authorise;
|
|
76
|
+
readonly fetchImpl?: FetchLike;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The routes this contract describes.
|
|
80
|
+
*
|
|
81
|
+
* Read-only, every one of them. `GET` is stated explicitly rather than assumed,
|
|
82
|
+
* because §11.3 turns on the existence of a "side-effect-free" read and a table that
|
|
83
|
+
* left the method implicit would be one refactor away from losing the distinction.
|
|
84
|
+
*/
|
|
85
|
+
export declare const ROUTES: Readonly<{
|
|
86
|
+
application: Readonly<{
|
|
87
|
+
method: "GET";
|
|
88
|
+
path: "/api/applications/:id";
|
|
89
|
+
}>;
|
|
90
|
+
ownCitizen: Readonly<{
|
|
91
|
+
method: "GET";
|
|
92
|
+
path: "/api/citizens/me";
|
|
93
|
+
}>;
|
|
94
|
+
ownCitizenNames: Readonly<{
|
|
95
|
+
method: "GET";
|
|
96
|
+
path: "/api/citizens/me/names";
|
|
97
|
+
}>;
|
|
98
|
+
verificationCase: Readonly<{
|
|
99
|
+
method: "GET";
|
|
100
|
+
path: "/api/verification-cases/:id";
|
|
101
|
+
}>;
|
|
102
|
+
ownProvisioningCoordination: Readonly<{
|
|
103
|
+
method: "GET";
|
|
104
|
+
path: "/api/citizens/me/provisioning-coordination";
|
|
105
|
+
}>;
|
|
106
|
+
}>;
|
|
107
|
+
/**
|
|
108
|
+
* Raised when a response is not something this contract can read at all.
|
|
109
|
+
*
|
|
110
|
+
* Distinct from a `Refusal`, which is this pillar saying no in a way the contract
|
|
111
|
+
* describes, and distinct from a `ContractParseError`, which is a well-formed
|
|
112
|
+
* response whose contents are wrong. This one means the conversation failed: a
|
|
113
|
+
* gateway answered, or the body was not JSON. A caller cannot map it to a refusal
|
|
114
|
+
* code without inventing one.
|
|
115
|
+
*/
|
|
116
|
+
export declare class ContractTransportError extends Error {
|
|
117
|
+
readonly name = "ContractTransportError";
|
|
118
|
+
readonly status: number;
|
|
119
|
+
constructor(status: number, rule: string);
|
|
120
|
+
}
|
|
121
|
+
/** What this client can do. Five reads, no writes — see this file's docblock. */
|
|
122
|
+
export interface IdentityClient {
|
|
123
|
+
getApplication(applicationId: ApplicationId): Promise<ContractResult<ApplicationSummary>>;
|
|
124
|
+
getOwnCitizen(): Promise<ContractResult<CitizenRecord>>;
|
|
125
|
+
getOwnCitizenNames(): Promise<ContractResult<CitizenNames>>;
|
|
126
|
+
getVerificationCase(verificationCaseId: VerificationCaseId): Promise<ContractResult<VerificationCaseSummary>>;
|
|
127
|
+
getOwnProvisioningCoordination(): Promise<ContractResult<ProvisioningCoordination>>;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Create a client.
|
|
131
|
+
*
|
|
132
|
+
* Every method returns a `ContractResult`: an `ok` branch carrying a decoded value,
|
|
133
|
+
* or a `refused` branch carrying the closed `Refusal`. A refusal is not thrown,
|
|
134
|
+
* because a refusal is an ordinary answer and an exception is how an ordinary answer
|
|
135
|
+
* gets swallowed by a `catch` written for something else.
|
|
136
|
+
*
|
|
137
|
+
* Nothing is retried. A retry policy is a decision about idempotency and about the
|
|
138
|
+
* limits of §12.5, and neither is settled for routes nobody serves yet.
|
|
139
|
+
*/
|
|
140
|
+
export declare const createIdentityClient: (options: IdentityClientOptions) => IdentityClient;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A thin client: the routes this contract describes, a decoder on every response,
|
|
3
|
+
* and nothing else.
|
|
4
|
+
*
|
|
5
|
+
* THIN IS A CONSTRAINT, NOT A DESCRIPTION. This package publishes wire types, the
|
|
6
|
+
* closed unions and a client. It contains no server, no policy engine and no
|
|
7
|
+
* verifier, and this file is where that boundary is most easily lost: a client that
|
|
8
|
+
* retried, cached, refreshed a session or decided what a refusal meant would be
|
|
9
|
+
* making decisions the pillar has not taken yet, inside a package every consumer
|
|
10
|
+
* compiles against.
|
|
11
|
+
*
|
|
12
|
+
* SO IT READS AND NEVER WRITES. There is no method here that creates an Application,
|
|
13
|
+
* submits evidence, approves anything or asks for an assertion. The Application API
|
|
14
|
+
* and the approval transaction do not exist yet, and a client method posting to a
|
|
15
|
+
* route nobody has implemented is worse than no method at all: it looks like a
|
|
16
|
+
* commitment, and the first consumer to call it discovers the shape by trial.
|
|
17
|
+
*
|
|
18
|
+
* WHY THE ROUTE TABLE IS HERE AT ALL. A path is part of a wire contract, and the
|
|
19
|
+
* sibling precedent publishes one for the same reason: a consumer hand-writing a URL
|
|
20
|
+
* is a consumer with a private copy of the contract. Publishing it makes a
|
|
21
|
+
* disagreement between this package and the slice that serves these routes a
|
|
22
|
+
* VERSION BUMP somebody has to make, rather than a 404 somebody debugs.
|
|
23
|
+
*
|
|
24
|
+
* NO SIDE EFFECTS ON IMPORT. Importing this module defines functions and freezes one
|
|
25
|
+
* table. It opens no connection, reads no environment variable and touches no clock.
|
|
26
|
+
*
|
|
27
|
+
* THIS SLICE EXPOSES NO ENDPOINT. §12.5's obligation — declared dimensions, figures
|
|
28
|
+
* backed by load and abuse evidence — falls on the slice that makes an endpoint
|
|
29
|
+
* externally reachable. This package is the caller's side of a conversation with a
|
|
30
|
+
* server that does not exist yet, so it declares no limit policy, and the slice
|
|
31
|
+
* that serves these routes declares one before it exposes them.
|
|
32
|
+
*/
|
|
33
|
+
import { parseApplicationSummary, } from "./application.js";
|
|
34
|
+
import { parseCitizenNames, parseCitizenRecord, } from "./citizen.js";
|
|
35
|
+
import { parseVerificationCaseSummary, } from "./evidence.js";
|
|
36
|
+
import { ContractParseError } from "./parse.js";
|
|
37
|
+
import { parseProvisioningCoordination, } from "./provisioning.js";
|
|
38
|
+
import { parseRefusal } from "./refusal.js";
|
|
39
|
+
/**
|
|
40
|
+
* The routes this contract describes.
|
|
41
|
+
*
|
|
42
|
+
* Read-only, every one of them. `GET` is stated explicitly rather than assumed,
|
|
43
|
+
* because §11.3 turns on the existence of a "side-effect-free" read and a table that
|
|
44
|
+
* left the method implicit would be one refactor away from losing the distinction.
|
|
45
|
+
*/
|
|
46
|
+
export const ROUTES = Object.freeze({
|
|
47
|
+
application: Object.freeze({ method: "GET", path: "/api/applications/:id" }),
|
|
48
|
+
ownCitizen: Object.freeze({ method: "GET", path: "/api/citizens/me" }),
|
|
49
|
+
ownCitizenNames: Object.freeze({
|
|
50
|
+
method: "GET",
|
|
51
|
+
path: "/api/citizens/me/names",
|
|
52
|
+
}),
|
|
53
|
+
verificationCase: Object.freeze({
|
|
54
|
+
method: "GET",
|
|
55
|
+
path: "/api/verification-cases/:id",
|
|
56
|
+
}),
|
|
57
|
+
ownProvisioningCoordination: Object.freeze({
|
|
58
|
+
method: "GET",
|
|
59
|
+
path: "/api/citizens/me/provisioning-coordination",
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
/**
|
|
63
|
+
* Raised when a response is not something this contract can read at all.
|
|
64
|
+
*
|
|
65
|
+
* Distinct from a `Refusal`, which is this pillar saying no in a way the contract
|
|
66
|
+
* describes, and distinct from a `ContractParseError`, which is a well-formed
|
|
67
|
+
* response whose contents are wrong. This one means the conversation failed: a
|
|
68
|
+
* gateway answered, or the body was not JSON. A caller cannot map it to a refusal
|
|
69
|
+
* code without inventing one.
|
|
70
|
+
*/
|
|
71
|
+
export class ContractTransportError extends Error {
|
|
72
|
+
name = "ContractTransportError";
|
|
73
|
+
status;
|
|
74
|
+
constructor(status, rule) {
|
|
75
|
+
super(`transport: ${rule}`);
|
|
76
|
+
this.status = status;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const trimTrailingSlash = (value) => value.endsWith("/") ? value.slice(0, -1) : value;
|
|
80
|
+
/**
|
|
81
|
+
* Build a URL from a route and at most one identifier.
|
|
82
|
+
*
|
|
83
|
+
* No escaping is performed and none is needed, which is a property of the
|
|
84
|
+
* identifiers rather than of this function: every branded identifier in `ids.ts` is
|
|
85
|
+
* parsed against an alphabet that excludes `/`, `?`, `#`, `:` and whitespace, so a
|
|
86
|
+
* value that reached here cannot introduce a path segment, a query or a fragment.
|
|
87
|
+
* The type system is what guarantees it arrived through a parser — this function
|
|
88
|
+
* accepts a branded identifier, and a bare `string` is not assignable to one.
|
|
89
|
+
*/
|
|
90
|
+
const urlFor = (baseUrl, route, identifier) => {
|
|
91
|
+
const path = identifier === undefined
|
|
92
|
+
? route.path
|
|
93
|
+
: route.path.replace(":id", identifier);
|
|
94
|
+
return `${trimTrailingSlash(baseUrl)}${path}`;
|
|
95
|
+
};
|
|
96
|
+
const resolveFetch = (options) => {
|
|
97
|
+
const provided = options.fetchImpl;
|
|
98
|
+
if (provided !== undefined) {
|
|
99
|
+
return provided;
|
|
100
|
+
}
|
|
101
|
+
const ambient = globalThis.fetch;
|
|
102
|
+
if (ambient === undefined) {
|
|
103
|
+
throw new ContractTransportError(0, "no fetch implementation. Pass fetchImpl when the runtime has no global fetch");
|
|
104
|
+
}
|
|
105
|
+
return ambient;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Create a client.
|
|
109
|
+
*
|
|
110
|
+
* Every method returns a `ContractResult`: an `ok` branch carrying a decoded value,
|
|
111
|
+
* or a `refused` branch carrying the closed `Refusal`. A refusal is not thrown,
|
|
112
|
+
* because a refusal is an ordinary answer and an exception is how an ordinary answer
|
|
113
|
+
* gets swallowed by a `catch` written for something else.
|
|
114
|
+
*
|
|
115
|
+
* Nothing is retried. A retry policy is a decision about idempotency and about the
|
|
116
|
+
* limits of §12.5, and neither is settled for routes nobody serves yet.
|
|
117
|
+
*/
|
|
118
|
+
export const createIdentityClient = (options) => {
|
|
119
|
+
const performFetch = resolveFetch(options);
|
|
120
|
+
const { baseUrl, authorise } = options;
|
|
121
|
+
const read = async (route, decode, field, identifier) => {
|
|
122
|
+
const headers = authorise === undefined ? {} : await authorise();
|
|
123
|
+
const response = await performFetch(urlFor(baseUrl, route, identifier), {
|
|
124
|
+
method: route.method,
|
|
125
|
+
headers: { accept: "application/json", ...headers },
|
|
126
|
+
});
|
|
127
|
+
let body;
|
|
128
|
+
try {
|
|
129
|
+
body = await response.json();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
throw new ContractTransportError(response.status, "response body was not JSON");
|
|
133
|
+
}
|
|
134
|
+
if (response.ok) {
|
|
135
|
+
return { outcome: "ok", value: decode(body, field) };
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return { outcome: "refused", refusal: parseRefusal(body, "refusal") };
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (error instanceof ContractParseError) {
|
|
142
|
+
throw new ContractTransportError(response.status, "response was neither a result nor a refusal this contract describes");
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
getApplication: (applicationId) => read(ROUTES.application, parseApplicationSummary, "application", applicationId),
|
|
149
|
+
getOwnCitizen: () => read(ROUTES.ownCitizen, parseCitizenRecord, "citizen"),
|
|
150
|
+
getOwnCitizenNames: () => read(ROUTES.ownCitizenNames, parseCitizenNames, "citizenNames"),
|
|
151
|
+
getVerificationCase: (verificationCaseId) => read(ROUTES.verificationCase, parseVerificationCaseSummary, "verificationCase", verificationCaseId),
|
|
152
|
+
getOwnProvisioningCoordination: () => read(ROUTES.ownProvisioningCoordination, parseProvisioningCoordination, "coordination"),
|
|
153
|
+
};
|
|
154
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The evidence vocabulary: facts, never scores, and a method that cannot exceed
|
|
3
|
+
* what its facts support.
|
|
4
|
+
*
|
|
5
|
+
* §8.2 is the whole of this file in two sentences. "The verification provider must
|
|
6
|
+
* return **evidence facts and reason codes**, not an unqualified score or Boolean."
|
|
7
|
+
* And: "**Evidence is never overstated.** A passport MRZ read plus an unmatched
|
|
8
|
+
* selfie is not a document-and-biometric decision, and no provider's unqualified
|
|
9
|
+
* 'pass' may be recorded as one."
|
|
10
|
+
*
|
|
11
|
+
* ENFORCED TWICE.
|
|
12
|
+
*
|
|
13
|
+
* The compile-time half is `MethodSupportedBy`, a type-level function from evidence
|
|
14
|
+
* to the strongest method that evidence honestly supports. Code that knows its
|
|
15
|
+
* evidence as literals — a fixture, a test, a stub profile, the issuer assembling a
|
|
16
|
+
* claim it is about to mint — writes `MethodSupportedBy<typeof facts>` and gets a
|
|
17
|
+
* compile error rather than an overclaim. `document-and-biometric` is unreachable
|
|
18
|
+
* from evidence whose `biometricComparedToDocumentPortrait` is `false`, and there is
|
|
19
|
+
* no widening that gets there.
|
|
20
|
+
*
|
|
21
|
+
* The runtime half is `parseVerificationCaseSummary`, because a type can say nothing
|
|
22
|
+
* about a provider's JSON. It recomputes the supported method from the facts on the
|
|
23
|
+
* wire and refuses a `method` stronger than they bear — the exact case §8.2 names.
|
|
24
|
+
*
|
|
25
|
+
* WHY THE SUPPORT RULE IS WRITTEN HERE AND NOT IMPORTED.
|
|
26
|
+
*
|
|
27
|
+
* `@cayso/contracts` exports `methodForEvidence`, which answers the same question,
|
|
28
|
+
* and this package deliberately cannot use it: it holds no runtime dependency, and
|
|
29
|
+
* the coupling scan's rule C makes that mechanical by refusing a bare specifier in a
|
|
30
|
+
* value position. Two implementations of one rule is normally how they diverge, so
|
|
31
|
+
* the divergence is closed where it can be closed — `evidence.test.ts` imports
|
|
32
|
+
* Cayso's function (a test file, outside the coupling scan, using the exact-pinned
|
|
33
|
+
* development dependency) and asserts the two agree across every combination of the
|
|
34
|
+
* three facts. An upstream change to the rule fails this package's test run rather
|
|
35
|
+
* than passing silently into a consumer.
|
|
36
|
+
*
|
|
37
|
+
* The authority still belongs upstream. A verifier or issuer taking a real decision
|
|
38
|
+
* uses Cayso's function, and that is where the value dependency is taken. What lives
|
|
39
|
+
* here refuses an overstatement at a contract boundary; it does not grant an
|
|
40
|
+
* assurance.
|
|
41
|
+
*
|
|
42
|
+
* NO ROUTE IS HARD-CODED, AND THE STUB'S IDENTIFIER IS NOT IN THIS PACKAGE.
|
|
43
|
+
*
|
|
44
|
+
* §8.3 requires a "route-neutral, versioned evidence model" and warns that
|
|
45
|
+
* "hard-coding the first document reader into policy is how it becomes permanent by
|
|
46
|
+
* accident". So `EvidenceRoute` and `EvidencePolicyVersion` are opaque versioned
|
|
47
|
+
* identifiers rather than closed unions: phase one is passport-only, and a published
|
|
48
|
+
* union with one member would make that the contract every consumer compiled
|
|
49
|
+
* against. Adding a route is "a **new** evidence-policy version" (§8.3), which is a
|
|
50
|
+
* value, not a type change.
|
|
51
|
+
*
|
|
52
|
+
* The stub's route and policy version are deliberately absent for a stronger reason.
|
|
53
|
+
* §8.4 requires that "the production bundle contains neither the stub implementation
|
|
54
|
+
* nor its selection identifier". A member named for the stub in a contract package
|
|
55
|
+
* every production surface imports would place that selection identifier in every
|
|
56
|
+
* one of them. The stub names its own route, in its own package, excluded at build
|
|
57
|
+
* time — which is §8.4's "decisive control is build-time exclusion, not
|
|
58
|
+
* configuration".
|
|
59
|
+
*/
|
|
60
|
+
import type { VerificationEvidence, VerificationMethod } from "@cayso/contracts";
|
|
61
|
+
import type { Branded } from "./brand.js";
|
|
62
|
+
import { type ApplicationId, type VerificationCaseId } from "./ids.js";
|
|
63
|
+
/**
|
|
64
|
+
* Which evidence route produced a case — §8.3's `evidence_route`.
|
|
65
|
+
*
|
|
66
|
+
* Opaque, so that adding a driving licence, a national identity card, an ePassport
|
|
67
|
+
* NFC route or a staff-assisted route is a new value rather than a breaking change
|
|
68
|
+
* to every consumer of this package.
|
|
69
|
+
*/
|
|
70
|
+
export type EvidenceRoute = Branded<"EvidenceRoute">;
|
|
71
|
+
/**
|
|
72
|
+
* Which version of the evidence policy decided a case — §8.3's
|
|
73
|
+
* `evidence_policy_version`.
|
|
74
|
+
*
|
|
75
|
+
* §8.3: adding a route is "a **new** evidence-policy version — never a migration
|
|
76
|
+
* that changes the meaning of past approvals". A stored case therefore carries the
|
|
77
|
+
* version that judged it, for ever, and this pillar never reinterprets an old case
|
|
78
|
+
* under a new policy.
|
|
79
|
+
*/
|
|
80
|
+
export type EvidencePolicyVersion = Branded<"EvidencePolicyVersion">;
|
|
81
|
+
/** An `EvidenceRoute` from a wire value, or a refusal. */
|
|
82
|
+
export declare const parseEvidenceRoute: (value: string, field?: string) => EvidenceRoute;
|
|
83
|
+
/** An `EvidencePolicyVersion` from a wire value, or a refusal. */
|
|
84
|
+
export declare const parseEvidencePolicyVersion: (value: string, field?: string) => EvidencePolicyVersion;
|
|
85
|
+
/**
|
|
86
|
+
* What §8.6's table calls an outcome: `pass`, `refer` or `fail`.
|
|
87
|
+
*
|
|
88
|
+
* Three members, and the middle one is the reason the union is closed. §8.6: "A
|
|
89
|
+
* referral is **not** an automated rejection, so it does not inherently require two
|
|
90
|
+
* reviewers." A Boolean would have had to file `refer` under one of the others, and
|
|
91
|
+
* whichever it chose would have moved the authority required to overturn it. §8.4
|
|
92
|
+
* requires the stub to return all three deterministically, which is the same three
|
|
93
|
+
* and not a development-only set.
|
|
94
|
+
*/
|
|
95
|
+
export type VerificationOutcome = "pass" | "refer" | "fail";
|
|
96
|
+
/** Every outcome, in §8.6's order. */
|
|
97
|
+
export declare const VERIFICATION_OUTCOMES: readonly VerificationOutcome[];
|
|
98
|
+
/** A `VerificationOutcome` from a wire value, or a refusal. */
|
|
99
|
+
export declare const parseVerificationOutcome: (value: unknown, field?: string) => VerificationOutcome;
|
|
100
|
+
/**
|
|
101
|
+
* The evidence facts of one case: Cayso's three, and the two §8.1 adds.
|
|
102
|
+
*
|
|
103
|
+
* It EXTENDS `VerificationEvidence` rather than redeclaring its fields, so a value
|
|
104
|
+
* of this type is accepted anywhere Cayso's is expected and the two can never
|
|
105
|
+
* disagree about what `biometricComparedToDocumentPortrait` means. §8.2's
|
|
106
|
+
* "**Provider vocabulary stops at the adapter**" is the rule that makes this the
|
|
107
|
+
* right shape: whatever a supplier returns is normalised into these booleans by the
|
|
108
|
+
* adapter, and no provider's own vocabulary reaches a stored case or a consumer.
|
|
109
|
+
*
|
|
110
|
+
* The two additions are facts §8.1 lists as production requirements and Cayso's
|
|
111
|
+
* profile does not model, because they are this pillar's business to establish
|
|
112
|
+
* rather than Cayso's to judge. Neither strengthens the method that evidence
|
|
113
|
+
* supports — `MethodSupportedBy` ignores both, deliberately, because the method
|
|
114
|
+
* vocabulary is Cayso's and this package does not get to widen what one of its
|
|
115
|
+
* members asserts. They are recorded so that a case can say what was and was not
|
|
116
|
+
* done, which is what §8.1's table is for.
|
|
117
|
+
*/
|
|
118
|
+
export interface IdentityEvidenceFacts extends VerificationEvidence {
|
|
119
|
+
/**
|
|
120
|
+
* Document authenticity was established by a provider or model check, or by
|
|
121
|
+
* cryptographic chip validation. §8.1: an MRZ consistency check alone is **not**
|
|
122
|
+
* document authenticity.
|
|
123
|
+
*/
|
|
124
|
+
readonly documentAuthenticityEstablished: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* A presentation-attack (liveness) check was performed and recorded. §8.6: a
|
|
127
|
+
* person comparing two photographs is **not** automatically a liveness check.
|
|
128
|
+
*/
|
|
129
|
+
readonly presentationAttackCheckPerformed: boolean;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The strongest method this evidence supports, at the type level.
|
|
133
|
+
*
|
|
134
|
+
* Written against `Extract<VerificationMethod, …>` rather than against bare string
|
|
135
|
+
* literals: if either method were renamed or removed upstream, these branches would
|
|
136
|
+
* collapse to `never` and `evidence.test.ts` would fail, rather than this package
|
|
137
|
+
* quietly continuing to speak a vocabulary Cayso no longer has.
|
|
138
|
+
*
|
|
139
|
+
* `dev-bypass` is never produced. It is not a conclusion evidence supports; it is
|
|
140
|
+
* the marker of a verification that examined nothing (§8.4: "`dev-bypass` proves
|
|
141
|
+
* nothing and never migrates as verified evidence").
|
|
142
|
+
*/
|
|
143
|
+
export type MethodSupportedBy<E extends VerificationEvidence> = E extends {
|
|
144
|
+
readonly identityDocumentChecked: true;
|
|
145
|
+
} ? E extends {
|
|
146
|
+
readonly biometricCaptured: true;
|
|
147
|
+
readonly biometricComparedToDocumentPortrait: true;
|
|
148
|
+
} ? Extract<VerificationMethod, "document-and-biometric"> : Extract<VerificationMethod, "document"> : never;
|
|
149
|
+
/**
|
|
150
|
+
* The strongest method this evidence supports, at runtime, or `null` for evidence
|
|
151
|
+
* that supports none.
|
|
152
|
+
*
|
|
153
|
+
* `null` is not a failure and must not be rendered as one: it is the honest answer
|
|
154
|
+
* for a case that examined nothing yet. A caller holding `null` has nothing to
|
|
155
|
+
* assert, which is §8.2's position exactly.
|
|
156
|
+
*/
|
|
157
|
+
export declare const methodSupportedByEvidence: (evidence: VerificationEvidence) => VerificationMethod | null;
|
|
158
|
+
/**
|
|
159
|
+
* Whether `method` is honest about `evidence` — the refusable form of §8.2.
|
|
160
|
+
*
|
|
161
|
+
* Evidence may exceed what a method asserts; it may never fall short of it. A
|
|
162
|
+
* `dev-bypass` case is honest only when nothing was examined: a bypass that claims
|
|
163
|
+
* a document was checked is an overstatement in the other direction, and the one a
|
|
164
|
+
* migration would most like to make.
|
|
165
|
+
*/
|
|
166
|
+
export declare const evidenceSupportsMethod: (method: VerificationMethod, evidence: VerificationEvidence) => boolean;
|
|
167
|
+
/**
|
|
168
|
+
* What a verification case says, and nothing §15 forbids it to say.
|
|
169
|
+
*
|
|
170
|
+
* `verification_cases` must not hold "A bare provider score as the decision", so
|
|
171
|
+
* there is no score field, no confidence field and no threshold field; the decision
|
|
172
|
+
* is this pillar's, taken from the facts, under a named policy version. §8.8 makes
|
|
173
|
+
* the same point about suppliers: "**Disqualifying: 'score only'**". A provider's
|
|
174
|
+
* own case reference is carried, because a case must be traceable to the supplier
|
|
175
|
+
* that produced it, and it is opaque here.
|
|
176
|
+
*/
|
|
177
|
+
export interface VerificationCaseSummary {
|
|
178
|
+
readonly verificationCaseId: VerificationCaseId;
|
|
179
|
+
readonly applicationId: ApplicationId;
|
|
180
|
+
readonly outcome: VerificationOutcome;
|
|
181
|
+
readonly evidenceRoute: EvidenceRoute;
|
|
182
|
+
readonly evidencePolicyVersion: EvidencePolicyVersion;
|
|
183
|
+
readonly facts: IdentityEvidenceFacts;
|
|
184
|
+
/**
|
|
185
|
+
* The method these facts support and the decision was recorded under. Refused by
|
|
186
|
+
* `parseVerificationCaseSummary` when the facts do not bear it.
|
|
187
|
+
*/
|
|
188
|
+
readonly method: VerificationMethod;
|
|
189
|
+
/** The supplier's own reference for this case, or `null`. Opaque; never interpreted here. */
|
|
190
|
+
readonly providerCaseReference: string | null;
|
|
191
|
+
}
|
|
192
|
+
/** The evidence facts from a wire value, or a refusal. */
|
|
193
|
+
export declare const parseIdentityEvidenceFacts: (value: unknown, field?: string) => IdentityEvidenceFacts;
|
|
194
|
+
/**
|
|
195
|
+
* A `VerificationCaseSummary` from a wire value, or a refusal.
|
|
196
|
+
*
|
|
197
|
+
* The refusal that matters is the last one: a `method` the facts do not bear is
|
|
198
|
+
* refused, naming the method the facts actually support. That is §8.2's "A passport
|
|
199
|
+
* MRZ read plus an unmatched selfie is not a document-and-biometric decision",
|
|
200
|
+
* enforced where a provider's response enters this pillar.
|
|
201
|
+
*/
|
|
202
|
+
export declare const parseVerificationCaseSummary: (value: unknown, field?: string) => VerificationCaseSummary;
|