@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/parse.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decoding half of this package's invariants.
|
|
3
|
+
*
|
|
4
|
+
* Every rule here has a twin in the type system, and neither is sufficient alone. A
|
|
5
|
+
* type cannot police a value that arrived as JSON from a caller this pillar does not
|
|
6
|
+
* control — §1.2 is explicit that a locally operated Piper Platform deployment and
|
|
7
|
+
* everything shipped with it are under the operator's control, so a wire value is an
|
|
8
|
+
* assertion by a stranger until something checks it. A parser, equally, cannot stop
|
|
9
|
+
* a programmer inside this repository writing the wrong identifier into the right
|
|
10
|
+
* field. So the canon's invariants are written twice: once as a shape that cannot be
|
|
11
|
+
* expressed, and once as a value that is refused.
|
|
12
|
+
*
|
|
13
|
+
* TWO PROPERTIES OF THE REFUSALS HERE ARE LOAD-BEARING.
|
|
14
|
+
*
|
|
15
|
+
* **A refusal names the field and the rule, never the value.** §14.4's disclosure
|
|
16
|
+
* criterion forbids a document field, a legal identity value or a raw image reaching
|
|
17
|
+
* logs or traces, and a parse error is a string that ends up in both. Echoing the
|
|
18
|
+
* offending input is the most natural thing in the world to write and would put the
|
|
19
|
+
* exact values this pillar exists to protect into every stack trace. `contract.test.ts`
|
|
20
|
+
* asserts it: a refusal never contains what it refused.
|
|
21
|
+
*
|
|
22
|
+
* **A refusal is not the wire refusal.** `ContractParseError` is thrown at a
|
|
23
|
+
* boundary inside a consumer's own process. What a caller receives is the closed,
|
|
24
|
+
* non-enumerating `Refusal` of `refusal.ts`, which carries a code and nothing else.
|
|
25
|
+
* The two must never be joined up: a parse message is detailed because it is private,
|
|
26
|
+
* and it stops being safe the moment it is returned.
|
|
27
|
+
*/
|
|
28
|
+
/** Raised when a wire value does not satisfy the contract. Never caught to proceed. */
|
|
29
|
+
export declare class ContractParseError extends Error {
|
|
30
|
+
readonly name = "ContractParseError";
|
|
31
|
+
/** The field that failed, in dotted form, so a caller can say where without saying what. */
|
|
32
|
+
readonly field: string;
|
|
33
|
+
constructor(field: string, rule: string);
|
|
34
|
+
}
|
|
35
|
+
/** Refuse a value. The message states the rule; it never restates the input. */
|
|
36
|
+
export declare const refuseValue: (field: string, rule: string) => never;
|
|
37
|
+
/**
|
|
38
|
+
* A plain object, or a refusal.
|
|
39
|
+
*
|
|
40
|
+
* Arrays and `null` are refused rather than coerced. `null` is an object to
|
|
41
|
+
* `typeof` and an array is an object to everything, and a decoder that accepted
|
|
42
|
+
* either would accept a shape its caller never meant to send.
|
|
43
|
+
*/
|
|
44
|
+
export declare const readObject: (value: unknown, field: string) => Readonly<Record<string, unknown>>;
|
|
45
|
+
/**
|
|
46
|
+
* A plain object whose keys are all permitted, or a refusal.
|
|
47
|
+
*
|
|
48
|
+
* UNKNOWN KEYS ARE REFUSED, and that is a security property rather than a tidiness
|
|
49
|
+
* one. Several of the canon's "must not hold" rules in §15 are about a field's
|
|
50
|
+
* ABSENCE — `cayso_provisioning` must not hold a balance, `verification_cases` must
|
|
51
|
+
* not hold a bare provider score as the decision, `audit_events` must not hold raw
|
|
52
|
+
* evidence. A decoder that ignored what it did not recognise would let every one of
|
|
53
|
+
* those arrive, be carried through a consumer untyped, and be written back out. A
|
|
54
|
+
* closed record is how "must not hold" survives a round trip.
|
|
55
|
+
*/
|
|
56
|
+
export declare const readClosedRecord: (value: unknown, field: string, permitted: readonly string[]) => Readonly<Record<string, unknown>>;
|
|
57
|
+
/** A required string field, or a refusal. */
|
|
58
|
+
export declare const readString: (record: Readonly<Record<string, unknown>>, field: string, key: string) => string;
|
|
59
|
+
/** A required member of a closed union, or a refusal. */
|
|
60
|
+
export declare const readMember: <T extends string>(record: Readonly<Record<string, unknown>>, field: string, key: string, members: readonly T[]) => T;
|
|
61
|
+
/** A required boolean field, or a refusal. */
|
|
62
|
+
export declare const readBoolean: (record: Readonly<Record<string, unknown>>, field: string, key: string) => boolean;
|
|
63
|
+
/**
|
|
64
|
+
* A required array field, or a refusal. The element decoder runs over every entry, so
|
|
65
|
+
* a list is as closed as the values in it.
|
|
66
|
+
*/
|
|
67
|
+
export declare const readArray: <T>(record: Readonly<Record<string, unknown>>, field: string, key: string, element: (value: unknown, elementField: string) => T) => readonly T[];
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decoding half of this package's invariants.
|
|
3
|
+
*
|
|
4
|
+
* Every rule here has a twin in the type system, and neither is sufficient alone. A
|
|
5
|
+
* type cannot police a value that arrived as JSON from a caller this pillar does not
|
|
6
|
+
* control — §1.2 is explicit that a locally operated Piper Platform deployment and
|
|
7
|
+
* everything shipped with it are under the operator's control, so a wire value is an
|
|
8
|
+
* assertion by a stranger until something checks it. A parser, equally, cannot stop
|
|
9
|
+
* a programmer inside this repository writing the wrong identifier into the right
|
|
10
|
+
* field. So the canon's invariants are written twice: once as a shape that cannot be
|
|
11
|
+
* expressed, and once as a value that is refused.
|
|
12
|
+
*
|
|
13
|
+
* TWO PROPERTIES OF THE REFUSALS HERE ARE LOAD-BEARING.
|
|
14
|
+
*
|
|
15
|
+
* **A refusal names the field and the rule, never the value.** §14.4's disclosure
|
|
16
|
+
* criterion forbids a document field, a legal identity value or a raw image reaching
|
|
17
|
+
* logs or traces, and a parse error is a string that ends up in both. Echoing the
|
|
18
|
+
* offending input is the most natural thing in the world to write and would put the
|
|
19
|
+
* exact values this pillar exists to protect into every stack trace. `contract.test.ts`
|
|
20
|
+
* asserts it: a refusal never contains what it refused.
|
|
21
|
+
*
|
|
22
|
+
* **A refusal is not the wire refusal.** `ContractParseError` is thrown at a
|
|
23
|
+
* boundary inside a consumer's own process. What a caller receives is the closed,
|
|
24
|
+
* non-enumerating `Refusal` of `refusal.ts`, which carries a code and nothing else.
|
|
25
|
+
* The two must never be joined up: a parse message is detailed because it is private,
|
|
26
|
+
* and it stops being safe the moment it is returned.
|
|
27
|
+
*/
|
|
28
|
+
/** Raised when a wire value does not satisfy the contract. Never caught to proceed. */
|
|
29
|
+
export class ContractParseError extends Error {
|
|
30
|
+
name = "ContractParseError";
|
|
31
|
+
/** The field that failed, in dotted form, so a caller can say where without saying what. */
|
|
32
|
+
field;
|
|
33
|
+
constructor(field, rule) {
|
|
34
|
+
super(`${field}: ${rule}`);
|
|
35
|
+
this.field = field;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Refuse a value. The message states the rule; it never restates the input. */
|
|
39
|
+
export const refuseValue = (field, rule) => {
|
|
40
|
+
throw new ContractParseError(field, rule);
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* A plain object, or a refusal.
|
|
44
|
+
*
|
|
45
|
+
* Arrays and `null` are refused rather than coerced. `null` is an object to
|
|
46
|
+
* `typeof` and an array is an object to everything, and a decoder that accepted
|
|
47
|
+
* either would accept a shape its caller never meant to send.
|
|
48
|
+
*/
|
|
49
|
+
export const readObject = (value, field) => {
|
|
50
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
51
|
+
refuseValue(field, "must be an object");
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* A plain object whose keys are all permitted, or a refusal.
|
|
57
|
+
*
|
|
58
|
+
* UNKNOWN KEYS ARE REFUSED, and that is a security property rather than a tidiness
|
|
59
|
+
* one. Several of the canon's "must not hold" rules in §15 are about a field's
|
|
60
|
+
* ABSENCE — `cayso_provisioning` must not hold a balance, `verification_cases` must
|
|
61
|
+
* not hold a bare provider score as the decision, `audit_events` must not hold raw
|
|
62
|
+
* evidence. A decoder that ignored what it did not recognise would let every one of
|
|
63
|
+
* those arrive, be carried through a consumer untyped, and be written back out. A
|
|
64
|
+
* closed record is how "must not hold" survives a round trip.
|
|
65
|
+
*/
|
|
66
|
+
export const readClosedRecord = (value, field, permitted) => {
|
|
67
|
+
const record = readObject(value, field);
|
|
68
|
+
for (const key of Object.keys(record)) {
|
|
69
|
+
if (!permitted.includes(key)) {
|
|
70
|
+
refuseValue(`${field}.${key}`, "is not a field of this contract type. The contract is closed: an unrecognised field is refused rather than carried");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return record;
|
|
74
|
+
};
|
|
75
|
+
/** A required string field, or a refusal. */
|
|
76
|
+
export const readString = (record, field, key) => {
|
|
77
|
+
const value = record[key];
|
|
78
|
+
if (typeof value !== "string") {
|
|
79
|
+
refuseValue(`${field}.${key}`, "must be a string");
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
};
|
|
83
|
+
/** A required member of a closed union, or a refusal. */
|
|
84
|
+
export const readMember = (record, field, key, members) => {
|
|
85
|
+
const value = readString(record, field, key);
|
|
86
|
+
if (!members.includes(value)) {
|
|
87
|
+
refuseValue(`${field}.${key}`, `must be one of the ${members.length} members of this closed union`);
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
};
|
|
91
|
+
/** A required boolean field, or a refusal. */
|
|
92
|
+
export const readBoolean = (record, field, key) => {
|
|
93
|
+
const value = record[key];
|
|
94
|
+
if (typeof value !== "boolean") {
|
|
95
|
+
refuseValue(`${field}.${key}`, "must be a boolean");
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* A required array field, or a refusal. The element decoder runs over every entry, so
|
|
101
|
+
* a list is as closed as the values in it.
|
|
102
|
+
*/
|
|
103
|
+
export const readArray = (record, field, key, element) => {
|
|
104
|
+
const value = record[key];
|
|
105
|
+
if (!Array.isArray(value)) {
|
|
106
|
+
refuseValue(`${field}.${key}`, "must be an array");
|
|
107
|
+
}
|
|
108
|
+
return value.map((entry, index) => element(entry, `${field}.${key}[${index}]`));
|
|
109
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provisioning coordination: what this pillar believes about a Citizen's Cayso
|
|
3
|
+
* provisioning, and the several things it refuses to claim.
|
|
4
|
+
*
|
|
5
|
+
* §11.3 gives the five coordination states and then constrains what they mean, in a
|
|
6
|
+
* sentence that decides most of this file: "**This state is operational belief,
|
|
7
|
+
* never the economic fact.** It may decide what the next session attempts. It may
|
|
8
|
+
* never be cited as proof that a grant exists."
|
|
9
|
+
*
|
|
10
|
+
* HOW THAT IS ENFORCED TWICE.
|
|
11
|
+
*
|
|
12
|
+
* There is no balance field, and there is no predicate that answers "does this
|
|
13
|
+
* Citizen have a grant". The union below can express "we have not confirmed", "we do
|
|
14
|
+
* not know" and "Cayso told us it was done, and here is what it said" — and it
|
|
15
|
+
* cannot express a figure this pillar arrived at on its own, because there is
|
|
16
|
+
* nowhere to put one. That is the type half. The decoder half is
|
|
17
|
+
* `readClosedRecord`, which refuses an unrecognised field rather than dropping it:
|
|
18
|
+
* a caller who sends a balance is told the contract has no such field, instead of
|
|
19
|
+
* having it silently discarded and adding it to the type next week to stop the
|
|
20
|
+
* discarding. §15's `cayso_provisioning` row forbids "A current balance; any use as
|
|
21
|
+
* proof a grant exists", and both halves are needed — a type cannot refuse JSON and
|
|
22
|
+
* a decoder cannot stop a programmer.
|
|
23
|
+
*
|
|
24
|
+
* `confirmed` carries a `CaysoConfirmation`, whose `source` is the literal
|
|
25
|
+
* `"cayso-response"`. Nothing else is assignable to it, so a confirmed coordination
|
|
26
|
+
* record cannot be assembled from a Reviewer's belief or from an operator's screen.
|
|
27
|
+
* §11.3: "No operator 'mark grant received' action may bypass a response or
|
|
28
|
+
* side-effect-free read from Cayso, and reconciliation may observe Cayso state but
|
|
29
|
+
* never create or adjust a balance." The witness type is that rule; nothing in this
|
|
30
|
+
* package mints one from anything but a parsed Cayso response.
|
|
31
|
+
*
|
|
32
|
+
* THE FIELDS THAT ARE ABSENT BECAUSE A DECISION IS OPEN. A deferred provisioning
|
|
33
|
+
* outbox — `next_attempt` and lease fields — is permitted only once Cayso authorises
|
|
34
|
+
* one, and it has not. So those fields are not here, and
|
|
35
|
+
* `parseProvisioningCoordination` refuses them by name rather than by the general
|
|
36
|
+
* unknown-field rule: a caller sending `nextAttemptAt` is building a scheduled retry
|
|
37
|
+
* path that does not exist yet, and deserves to be told so rather than have the field
|
|
38
|
+
* quietly ignored. **No worker, no lease, no scheduled retry path** until Cayso
|
|
39
|
+
* authorises one."
|
|
40
|
+
*
|
|
41
|
+
* The extension seam §11.3 asks for is therefore a schema and issue-level seam, not
|
|
42
|
+
* a set of dormant fields in a published contract. A field a consumer can already
|
|
43
|
+
* send is not a seam; it is the feature, shipped early, without the amendment.
|
|
44
|
+
*/
|
|
45
|
+
import type { Cents, Millis } from "@cayso/contracts";
|
|
46
|
+
import { type CitizenId } from "./ids.js";
|
|
47
|
+
/**
|
|
48
|
+
* The coordination states of §11.3's table, one member each.
|
|
49
|
+
*
|
|
50
|
+
* `confirmation_unknown` is the one worth reading twice: it is not a failure and not
|
|
51
|
+
* a success, and a consumer that renders it as either is the reason it exists. §11.3
|
|
52
|
+
* describes exactly what happens next — the next authenticated session "first
|
|
53
|
+
* performs the side-effect-free citizenship read, then repeats the idempotent
|
|
54
|
+
* operation if necessary".
|
|
55
|
+
*/
|
|
56
|
+
export type ProvisioningState = "awaiting_citizen_action" | "attempting" | "confirmation_unknown" | "confirmed" | "permanent_failure";
|
|
57
|
+
/** Every coordination state, in §11.3's order. */
|
|
58
|
+
export declare const PROVISIONING_STATES: readonly ProvisioningState[];
|
|
59
|
+
/**
|
|
60
|
+
* The edges §11.3 describes, and no others.
|
|
61
|
+
*
|
|
62
|
+
* `confirmation_unknown --> attempting` is the recovery §11.3 specifies and the only
|
|
63
|
+
* loop here; it is entered by a Citizen's next authenticated session, never by a
|
|
64
|
+
* timer, because there is no worker to hold one. `permanent_failure` has no outgoing
|
|
65
|
+
* edge: §11.3 defines it as a refusal "requiring intervention or a policy change",
|
|
66
|
+
* and what such an intervention is permitted to do is not decided, so this package
|
|
67
|
+
* draws no edge rather than inventing the one that looks safe.
|
|
68
|
+
*/
|
|
69
|
+
export declare const PROVISIONING_TRANSITIONS: Readonly<Record<ProvisioningState, readonly ProvisioningState[]>>;
|
|
70
|
+
/** Whether §11.3 draws this edge. */
|
|
71
|
+
export declare const isProvisioningTransitionPermitted: (from: ProvisioningState, to: ProvisioningState) => boolean;
|
|
72
|
+
/**
|
|
73
|
+
* What Cayso said, recorded as coordination evidence and nothing more.
|
|
74
|
+
*
|
|
75
|
+
* §15's `cayso_provisioning` row permits the confirmed references — `wallet_id`,
|
|
76
|
+
* `grant_movement_id`, amount and confirmation time — and forbids "any use as proof
|
|
77
|
+
* a grant exists". Both halves are true at once, and the distinction is the reason
|
|
78
|
+
* `source` exists: these figures are a record of a message from the pillar that owns
|
|
79
|
+
* the ledger, not a statement this pillar is entitled to make. A consumer that needs
|
|
80
|
+
* to know what a Citizen holds asks Cayso, whose side-effect-free citizenship read
|
|
81
|
+
* exists for the purpose.
|
|
82
|
+
*
|
|
83
|
+
* `source` is the literal `"cayso-response"`, so there is no second way to build
|
|
84
|
+
* one. It is the same device `ApprovedApplication` uses on the Citizen side: a
|
|
85
|
+
* witness type that widening cannot produce.
|
|
86
|
+
*/
|
|
87
|
+
export interface CaysoConfirmation {
|
|
88
|
+
readonly source: "cayso-response";
|
|
89
|
+
/** Cayso's wallet reference, as returned. Opaque here; this pillar never interprets it. */
|
|
90
|
+
readonly walletId: string;
|
|
91
|
+
/** Cayso's movement reference for the sign-up grant, as returned. */
|
|
92
|
+
readonly grantMovementId: string;
|
|
93
|
+
/** The amount Cayso reported granting. Cayso's figure, in Cayso's units, never recomputed here. */
|
|
94
|
+
readonly grantedCents: Cents;
|
|
95
|
+
readonly confirmedAt: Millis;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* One Citizen's coordination row, as a discriminated union over the state.
|
|
99
|
+
*
|
|
100
|
+
* The union is what stops a half-filled record: only the `confirmed` branch has a
|
|
101
|
+
* `CaysoConfirmation`, so an unconfirmed row cannot carry one, and a confirmed row
|
|
102
|
+
* cannot lack one. A single flat record with optional fields would have permitted
|
|
103
|
+
* both — "confirmed with nothing to show for it" is precisely the shape an operator
|
|
104
|
+
* marking a row by hand would produce.
|
|
105
|
+
*
|
|
106
|
+
* `attemptCount`, `lastAttemptAt` and `lastSafeError` come from §15 and are on every
|
|
107
|
+
* branch because they describe this pillar's own behaviour rather than Cayso's.
|
|
108
|
+
* `lastSafeError` is SAFE: a short, non-enumerating reason fit to be stored and
|
|
109
|
+
* shown, never a provider payload — §14.4 requires a Citizen to see "actionable,
|
|
110
|
+
* accessible status and refusal text without exposure of internal risk signals".
|
|
111
|
+
*/
|
|
112
|
+
export type ProvisioningCoordination = {
|
|
113
|
+
readonly citizenId: CitizenId;
|
|
114
|
+
readonly attemptCount: number;
|
|
115
|
+
readonly lastAttemptAt: Millis | null;
|
|
116
|
+
readonly lastSafeError: string | null;
|
|
117
|
+
} & ({
|
|
118
|
+
readonly state: Exclude<ProvisioningState, "confirmed">;
|
|
119
|
+
readonly confirmation?: undefined;
|
|
120
|
+
} | {
|
|
121
|
+
readonly state: "confirmed";
|
|
122
|
+
readonly confirmation: CaysoConfirmation;
|
|
123
|
+
});
|
|
124
|
+
/**
|
|
125
|
+
* A `CaysoConfirmation` from a wire value, or a refusal.
|
|
126
|
+
*
|
|
127
|
+
* `source` must be the exact string `"cayso-response"`. A caller with a different
|
|
128
|
+
* provenance for these figures has an operator's belief, which §11.3 refuses.
|
|
129
|
+
*/
|
|
130
|
+
export declare const parseCaysoConfirmation: (value: unknown, field?: string) => CaysoConfirmation;
|
|
131
|
+
/**
|
|
132
|
+
* A `ProvisioningCoordination` from a wire value, or a refusal.
|
|
133
|
+
*
|
|
134
|
+
* Three refusals here are deliberate and named: the fields of a deferred provisioning
|
|
135
|
+
* outbox, which is not part of this contract; the economic fields, which belong to
|
|
136
|
+
* Cayso's ledger and never to a coordination row; and a `confirmed` state carrying no
|
|
137
|
+
* confirmation, which is the wire form of an operator marking a row done.
|
|
138
|
+
*/
|
|
139
|
+
export declare const parseProvisioningCoordination: (value: unknown, field?: string) => ProvisioningCoordination;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provisioning coordination: what this pillar believes about a Citizen's Cayso
|
|
3
|
+
* provisioning, and the several things it refuses to claim.
|
|
4
|
+
*
|
|
5
|
+
* §11.3 gives the five coordination states and then constrains what they mean, in a
|
|
6
|
+
* sentence that decides most of this file: "**This state is operational belief,
|
|
7
|
+
* never the economic fact.** It may decide what the next session attempts. It may
|
|
8
|
+
* never be cited as proof that a grant exists."
|
|
9
|
+
*
|
|
10
|
+
* HOW THAT IS ENFORCED TWICE.
|
|
11
|
+
*
|
|
12
|
+
* There is no balance field, and there is no predicate that answers "does this
|
|
13
|
+
* Citizen have a grant". The union below can express "we have not confirmed", "we do
|
|
14
|
+
* not know" and "Cayso told us it was done, and here is what it said" — and it
|
|
15
|
+
* cannot express a figure this pillar arrived at on its own, because there is
|
|
16
|
+
* nowhere to put one. That is the type half. The decoder half is
|
|
17
|
+
* `readClosedRecord`, which refuses an unrecognised field rather than dropping it:
|
|
18
|
+
* a caller who sends a balance is told the contract has no such field, instead of
|
|
19
|
+
* having it silently discarded and adding it to the type next week to stop the
|
|
20
|
+
* discarding. §15's `cayso_provisioning` row forbids "A current balance; any use as
|
|
21
|
+
* proof a grant exists", and both halves are needed — a type cannot refuse JSON and
|
|
22
|
+
* a decoder cannot stop a programmer.
|
|
23
|
+
*
|
|
24
|
+
* `confirmed` carries a `CaysoConfirmation`, whose `source` is the literal
|
|
25
|
+
* `"cayso-response"`. Nothing else is assignable to it, so a confirmed coordination
|
|
26
|
+
* record cannot be assembled from a Reviewer's belief or from an operator's screen.
|
|
27
|
+
* §11.3: "No operator 'mark grant received' action may bypass a response or
|
|
28
|
+
* side-effect-free read from Cayso, and reconciliation may observe Cayso state but
|
|
29
|
+
* never create or adjust a balance." The witness type is that rule; nothing in this
|
|
30
|
+
* package mints one from anything but a parsed Cayso response.
|
|
31
|
+
*
|
|
32
|
+
* THE FIELDS THAT ARE ABSENT BECAUSE A DECISION IS OPEN. A deferred provisioning
|
|
33
|
+
* outbox — `next_attempt` and lease fields — is permitted only once Cayso authorises
|
|
34
|
+
* one, and it has not. So those fields are not here, and
|
|
35
|
+
* `parseProvisioningCoordination` refuses them by name rather than by the general
|
|
36
|
+
* unknown-field rule: a caller sending `nextAttemptAt` is building a scheduled retry
|
|
37
|
+
* path that does not exist yet, and deserves to be told so rather than have the field
|
|
38
|
+
* quietly ignored. **No worker, no lease, no scheduled retry path** until Cayso
|
|
39
|
+
* authorises one."
|
|
40
|
+
*
|
|
41
|
+
* The extension seam §11.3 asks for is therefore a schema and issue-level seam, not
|
|
42
|
+
* a set of dormant fields in a published contract. A field a consumer can already
|
|
43
|
+
* send is not a seam; it is the feature, shipped early, without the amendment.
|
|
44
|
+
*/
|
|
45
|
+
import { readCitizenId } from "./ids.js";
|
|
46
|
+
import { readClosedRecord, readString, refuseValue } from "./parse.js";
|
|
47
|
+
/** Every coordination state, in §11.3's order. */
|
|
48
|
+
export const PROVISIONING_STATES = Object.freeze([
|
|
49
|
+
"awaiting_citizen_action",
|
|
50
|
+
"attempting",
|
|
51
|
+
"confirmation_unknown",
|
|
52
|
+
"confirmed",
|
|
53
|
+
"permanent_failure",
|
|
54
|
+
]);
|
|
55
|
+
/**
|
|
56
|
+
* The edges §11.3 describes, and no others.
|
|
57
|
+
*
|
|
58
|
+
* `confirmation_unknown --> attempting` is the recovery §11.3 specifies and the only
|
|
59
|
+
* loop here; it is entered by a Citizen's next authenticated session, never by a
|
|
60
|
+
* timer, because there is no worker to hold one. `permanent_failure` has no outgoing
|
|
61
|
+
* edge: §11.3 defines it as a refusal "requiring intervention or a policy change",
|
|
62
|
+
* and what such an intervention is permitted to do is not decided, so this package
|
|
63
|
+
* draws no edge rather than inventing the one that looks safe.
|
|
64
|
+
*/
|
|
65
|
+
export const PROVISIONING_TRANSITIONS = Object.freeze({
|
|
66
|
+
awaiting_citizen_action: Object.freeze(["attempting"]),
|
|
67
|
+
attempting: Object.freeze([
|
|
68
|
+
"confirmed",
|
|
69
|
+
"confirmation_unknown",
|
|
70
|
+
"permanent_failure",
|
|
71
|
+
]),
|
|
72
|
+
confirmation_unknown: Object.freeze([
|
|
73
|
+
"attempting",
|
|
74
|
+
"confirmed",
|
|
75
|
+
"permanent_failure",
|
|
76
|
+
]),
|
|
77
|
+
confirmed: Object.freeze([]),
|
|
78
|
+
permanent_failure: Object.freeze([]),
|
|
79
|
+
});
|
|
80
|
+
/** Whether §11.3 draws this edge. */
|
|
81
|
+
export const isProvisioningTransitionPermitted = (from, to) => PROVISIONING_TRANSITIONS[from].includes(to);
|
|
82
|
+
const COORDINATION_FIELDS = Object.freeze([
|
|
83
|
+
"citizenId",
|
|
84
|
+
"state",
|
|
85
|
+
"attemptCount",
|
|
86
|
+
"lastAttemptAt",
|
|
87
|
+
"lastSafeError",
|
|
88
|
+
"confirmation",
|
|
89
|
+
]);
|
|
90
|
+
/**
|
|
91
|
+
* Fields that name the deferred outbox, refused with what would have to be agreed
|
|
92
|
+
* before any of them could mean anything.
|
|
93
|
+
*/
|
|
94
|
+
const DEFERRED_OUTBOX_FIELDS = Object.freeze([
|
|
95
|
+
"nextAttempt",
|
|
96
|
+
"nextAttemptAt",
|
|
97
|
+
"lease",
|
|
98
|
+
"leaseExpiresAt",
|
|
99
|
+
"leasedBy",
|
|
100
|
+
"lockedUntil",
|
|
101
|
+
]);
|
|
102
|
+
/** Fields that would make coordination state into an economic claim. §15 forbids them outright. */
|
|
103
|
+
const ECONOMIC_FIELDS = Object.freeze([
|
|
104
|
+
"balanceCents",
|
|
105
|
+
"currentBalance",
|
|
106
|
+
"walletBalance",
|
|
107
|
+
"restrictedCents",
|
|
108
|
+
"unrestrictedCents",
|
|
109
|
+
]);
|
|
110
|
+
const readCount = (record, field, key) => {
|
|
111
|
+
const value = record[key];
|
|
112
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
113
|
+
refuseValue(`${field}.${key}`, "must be a non-negative integer");
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
};
|
|
117
|
+
const readMillis = (record, field, key) => {
|
|
118
|
+
const value = record[key];
|
|
119
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
120
|
+
refuseValue(`${field}.${key}`, "must be a non-negative integer count of milliseconds");
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
};
|
|
124
|
+
const readNullableMillis = (record, field, key) => {
|
|
125
|
+
const value = record[key];
|
|
126
|
+
if (value === null) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
130
|
+
refuseValue(`${field}.${key}`, "must be null or a non-negative integer count of milliseconds");
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
};
|
|
134
|
+
const CONFIRMATION_FIELDS = Object.freeze([
|
|
135
|
+
"source",
|
|
136
|
+
"walletId",
|
|
137
|
+
"grantMovementId",
|
|
138
|
+
"grantedCents",
|
|
139
|
+
"confirmedAt",
|
|
140
|
+
]);
|
|
141
|
+
/**
|
|
142
|
+
* A `CaysoConfirmation` from a wire value, or a refusal.
|
|
143
|
+
*
|
|
144
|
+
* `source` must be the exact string `"cayso-response"`. A caller with a different
|
|
145
|
+
* provenance for these figures has an operator's belief, which §11.3 refuses.
|
|
146
|
+
*/
|
|
147
|
+
export const parseCaysoConfirmation = (value, field = "confirmation") => {
|
|
148
|
+
const record = readClosedRecord(value, field, CONFIRMATION_FIELDS);
|
|
149
|
+
const source = readString(record, field, "source");
|
|
150
|
+
if (source !== "cayso-response") {
|
|
151
|
+
refuseValue(`${field}.source`, "must be cayso-response. Confirmation comes from a Cayso response or a side-effect-free read, never from an operator action (§11.3)");
|
|
152
|
+
}
|
|
153
|
+
const grantedCents = record["grantedCents"];
|
|
154
|
+
if (typeof grantedCents !== "number" ||
|
|
155
|
+
!Number.isSafeInteger(grantedCents) ||
|
|
156
|
+
grantedCents < 0) {
|
|
157
|
+
refuseValue(`${field}.grantedCents`, "must be a non-negative integer, exactly as Cayso reported it");
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
source: "cayso-response",
|
|
161
|
+
walletId: readString(record, field, "walletId"),
|
|
162
|
+
grantMovementId: readString(record, field, "grantMovementId"),
|
|
163
|
+
grantedCents: grantedCents,
|
|
164
|
+
confirmedAt: readMillis(record, field, "confirmedAt"),
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* The closed-record read for a coordination row, with the two refusals that have to
|
|
169
|
+
* name their reason before the general unknown-field rule reaches them.
|
|
170
|
+
*/
|
|
171
|
+
const readCoordinationRecord = (value, field) => {
|
|
172
|
+
const record = readClosedRecord(value, field, [
|
|
173
|
+
...COORDINATION_FIELDS,
|
|
174
|
+
...DEFERRED_OUTBOX_FIELDS,
|
|
175
|
+
...ECONOMIC_FIELDS,
|
|
176
|
+
]);
|
|
177
|
+
for (const key of Object.keys(record)) {
|
|
178
|
+
if (DEFERRED_OUTBOX_FIELDS.includes(key)) {
|
|
179
|
+
refuseValue(`${field}.${key}`, "belongs to a deferred provisioning outbox. There is no worker, no lease and no scheduled retry path, and there will be none until Cayso authorises one (§11.3, §15)");
|
|
180
|
+
}
|
|
181
|
+
if (ECONOMIC_FIELDS.includes(key)) {
|
|
182
|
+
refuseValue(`${field}.${key}`, "is an economic fact and not coordination state. This row may never be cited as proof that a grant exists (§11.3, §15)");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return record;
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* A `ProvisioningCoordination` from a wire value, or a refusal.
|
|
189
|
+
*
|
|
190
|
+
* Three refusals here are deliberate and named: the fields of a deferred provisioning
|
|
191
|
+
* outbox, which is not part of this contract; the economic fields, which belong to
|
|
192
|
+
* Cayso's ledger and never to a coordination row; and a `confirmed` state carrying no
|
|
193
|
+
* confirmation, which is the wire form of an operator marking a row done.
|
|
194
|
+
*/
|
|
195
|
+
export const parseProvisioningCoordination = (value, field = "coordination") => {
|
|
196
|
+
const record = readCoordinationRecord(value, field);
|
|
197
|
+
const state = readString(record, field, "state");
|
|
198
|
+
if (!PROVISIONING_STATES.includes(state)) {
|
|
199
|
+
refuseValue(`${field}.state`, `must be one of the ${PROVISIONING_STATES.length} coordination states of §11.3`);
|
|
200
|
+
}
|
|
201
|
+
const common = {
|
|
202
|
+
citizenId: readCitizenId(record, field, "citizenId"),
|
|
203
|
+
attemptCount: readCount(record, field, "attemptCount"),
|
|
204
|
+
lastAttemptAt: readNullableMillis(record, field, "lastAttemptAt"),
|
|
205
|
+
lastSafeError: record["lastSafeError"] === null
|
|
206
|
+
? null
|
|
207
|
+
: readString(record, field, "lastSafeError"),
|
|
208
|
+
};
|
|
209
|
+
if (state === "confirmed") {
|
|
210
|
+
return {
|
|
211
|
+
...common,
|
|
212
|
+
state: "confirmed",
|
|
213
|
+
confirmation: parseCaysoConfirmation(record["confirmation"], `${field}.confirmation`),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (record["confirmation"] !== undefined) {
|
|
217
|
+
refuseValue(`${field}.confirmation`, "is permitted only in the confirmed state. Cayso's references are recorded when Cayso returns them and not before (§11.3)");
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
...common,
|
|
221
|
+
state: state,
|
|
222
|
+
};
|
|
223
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The closed refusal union: what a caller is told when this pillar says no, and
|
|
3
|
+
* everything it is deliberately not told.
|
|
4
|
+
*
|
|
5
|
+
* §14.4 sets both halves of the requirement, and they pull against each other. "A
|
|
6
|
+
* Citizen sees actionable, accessible status and refusal text without exposure of
|
|
7
|
+
* internal risk signals." Actionable, so a refusal cannot be an opaque failure;
|
|
8
|
+
* without internal risk signals, so it cannot be the reason. A closed union of codes
|
|
9
|
+
* satisfies both: the code says what the caller may do next, and the consumer
|
|
10
|
+
* renders the text — accessibly, in the Citizen's language, at the right reading
|
|
11
|
+
* level — because a wire string can be none of those things.
|
|
12
|
+
*
|
|
13
|
+
* ENFORCED TWICE, AND THE SECOND HALF IS THE INTERESTING ONE.
|
|
14
|
+
*
|
|
15
|
+
* `Refusal` has no message field, no detail field and no reason field. There is
|
|
16
|
+
* nowhere to put prose, so no handler can attach a provider's explanation to a
|
|
17
|
+
* refusal on its way out; that is the type half, and it is the half that survives a
|
|
18
|
+
* hurried afternoon. `parseRefusal` is the decoder half and refuses those field
|
|
19
|
+
* names explicitly rather than by the general unknown-key rule, because a caller
|
|
20
|
+
* sending `reason` has a provider payload in their hand and needs to be told why it
|
|
21
|
+
* stops here.
|
|
22
|
+
*
|
|
23
|
+
* WHY THE UNION DOES NOT ENUMERATE.
|
|
24
|
+
*
|
|
25
|
+
* Several members cover more than one underlying cause on purpose. `not_permitted`
|
|
26
|
+
* answers both "this is not yours" and "this does not exist", because two codes
|
|
27
|
+
* would make the refusal an existence oracle for anything addressed by identifier.
|
|
28
|
+
* `onboarding_result_not_redeemable` covers every one of §14.4's five cases — a
|
|
29
|
+
* second redemption, another client, expiry, an altered Citizen, a different
|
|
30
|
+
* transaction verifier — as one member, because telling an attacker WHICH of the
|
|
31
|
+
* five they tripped is how they work out what to change. The union cannot express
|
|
32
|
+
* the distinction, which is stronger than a convention that it should not be
|
|
33
|
+
* exposed.
|
|
34
|
+
*
|
|
35
|
+
* A REFUSAL IS NOT A PARSE ERROR. `ContractParseError` in `parse.ts` is detailed
|
|
36
|
+
* because it is private to a consumer's own process. A `Refusal` crosses a network
|
|
37
|
+
* boundary and carries a code and a correlation reference, and nothing else. Joining
|
|
38
|
+
* them up — returning a parse message to a caller — would leak exactly what §14.4's
|
|
39
|
+
* disclosure criterion protects.
|
|
40
|
+
*/
|
|
41
|
+
import { type CorrelationReference } from "./ids.js";
|
|
42
|
+
/**
|
|
43
|
+
* Why this pillar refused, in terms a caller can act on.
|
|
44
|
+
*
|
|
45
|
+
* Every member answers "what now" rather than "what happened inside". A member is
|
|
46
|
+
* added by amending this union, which is a breaking change to a published contract
|
|
47
|
+
* and therefore a decision somebody takes on purpose — not a new string that appears
|
|
48
|
+
* in a log one morning.
|
|
49
|
+
*/
|
|
50
|
+
export type RefusalCode =
|
|
51
|
+
/** No Percayso ID session is established. Sign in, then retry. */
|
|
52
|
+
"unauthenticated"
|
|
53
|
+
/** The session is real but not fresh or strong enough for this operation. Re-authenticate. */
|
|
54
|
+
| "step_up_required"
|
|
55
|
+
/**
|
|
56
|
+
* The caller may not do this. Deliberately also the answer when the target does
|
|
57
|
+
* not exist: a distinct "not found" would let anyone enumerate Citizens,
|
|
58
|
+
* Applications and cases by identifier.
|
|
59
|
+
*/
|
|
60
|
+
| "not_permitted"
|
|
61
|
+
/** The record is not in a state where this is possible. See §5.2 and §11.3 for the diagrams. */
|
|
62
|
+
| "state_not_permitted"
|
|
63
|
+
/** The request did not satisfy the contract. Nothing about the caller is implied. */
|
|
64
|
+
| "input_not_acceptable"
|
|
65
|
+
/** That Handle is taken or reserved. §14.4: a collision returns to selection. */
|
|
66
|
+
| "handle_unavailable"
|
|
67
|
+
/**
|
|
68
|
+
* The identity document is already bound to an active Citizen (§8.5). §14.4
|
|
69
|
+
* requires this refusal to be appealable, which is why it is its own member: a
|
|
70
|
+
* refusal a person cannot recognise is a refusal they cannot appeal.
|
|
71
|
+
*/
|
|
72
|
+
| "document_already_bound"
|
|
73
|
+
/** The evidence does not support what was asked for (§8.2). Never a provider's figure. */
|
|
74
|
+
| "evidence_insufficient"
|
|
75
|
+
/**
|
|
76
|
+
* The onboarding result cannot be redeemed. One member for all five of §14.4's
|
|
77
|
+
* cases, on purpose — see this file's docblock.
|
|
78
|
+
*/
|
|
79
|
+
| "onboarding_result_not_redeemable"
|
|
80
|
+
/** A declared limit was reached (§12.5). The limits themselves are the exposing slice's. */
|
|
81
|
+
| "rate_limited"
|
|
82
|
+
/**
|
|
83
|
+
* The operation cannot be performed right now and may succeed later. §11.4:
|
|
84
|
+
* issuance refuses on signing failure, with no unsigned, stub or stale-key
|
|
85
|
+
* fallback, and absence of signing configuration fails readiness.
|
|
86
|
+
*/
|
|
87
|
+
| "temporarily_unavailable";
|
|
88
|
+
/** Every refusal code. A consumer that must render them all reads this. */
|
|
89
|
+
export declare const REFUSAL_CODES: readonly RefusalCode[];
|
|
90
|
+
/** Whether §8.6's appeal route applies to this refusal. */
|
|
91
|
+
export declare const isAppealable: (code: RefusalCode) => boolean;
|
|
92
|
+
/**
|
|
93
|
+
* What crosses the wire when this pillar refuses.
|
|
94
|
+
*
|
|
95
|
+
* Two fields, and the second one is what makes the first one supportable. A Citizen
|
|
96
|
+
* who cannot be told why can still be helped: they quote the correlation reference,
|
|
97
|
+
* and a support path with the right authority reads the audited record behind it.
|
|
98
|
+
* §15's `audit_events` row carries that same reference, which is how the opaque code
|
|
99
|
+
* a caller sees and the full account a Reviewer sees are the same event.
|
|
100
|
+
*/
|
|
101
|
+
export interface Refusal {
|
|
102
|
+
readonly refusal: RefusalCode;
|
|
103
|
+
readonly correlationReference: CorrelationReference;
|
|
104
|
+
}
|
|
105
|
+
/** A `Refusal` from a wire value, or a refusal of its own. */
|
|
106
|
+
export declare const parseRefusal: (value: unknown, field?: string) => Refusal;
|
|
107
|
+
/**
|
|
108
|
+
* What every operation in this contract returns: a value, or a refusal.
|
|
109
|
+
*
|
|
110
|
+
* A discriminated union rather than a thrown error, because a refusal is an ordinary
|
|
111
|
+
* outcome of asking this pillar a question and an exception is not. A caller must
|
|
112
|
+
* switch on `outcome`, so there is no path where a refusal is ignored by a `catch`
|
|
113
|
+
* nobody wrote — and the refused branch has no value field, so there is no
|
|
114
|
+
* half-populated result to read out of it.
|
|
115
|
+
*/
|
|
116
|
+
export type ContractResult<T> = {
|
|
117
|
+
readonly outcome: "ok";
|
|
118
|
+
readonly value: T;
|
|
119
|
+
} | {
|
|
120
|
+
readonly outcome: "refused";
|
|
121
|
+
readonly refusal: Refusal;
|
|
122
|
+
};
|