@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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assertion vocabulary: who may be the subject, what a request may ask for, and
|
|
3
|
+
* what an issuance record may keep afterwards.
|
|
4
|
+
*
|
|
5
|
+
* CONTEXT.md states the rule this file exists to hold: "The Citizen ID is the only
|
|
6
|
+
* identifier that may appear as an assertion subject." §8.7 lists the structural
|
|
7
|
+
* tests phase one relies on instead of the phase-two binding table, and two of them
|
|
8
|
+
* are about this type — "no Piper subject or deployment identifier appears in the
|
|
9
|
+
* Citizen-creation input" and "no assertion derives `sub` from a Piper identifier".
|
|
10
|
+
*
|
|
11
|
+
* ENFORCED TWICE.
|
|
12
|
+
*
|
|
13
|
+
* `AssertionRequest.subject` is a `CitizenId`. A `PiperPlatformSubject` is a
|
|
14
|
+
* different brand and is not assignable to it — in either direction, with no
|
|
15
|
+
* conversion function anywhere in this package — so an issuer that tried to pass one
|
|
16
|
+
* through does not compile. `parseAssertionRequest` is the runtime twin: the subject
|
|
17
|
+
* arrives as a string like everything else on a wire, and only `readCitizenId`
|
|
18
|
+
* accepts it.
|
|
19
|
+
*
|
|
20
|
+
* `DeploymentId` is present and carries nothing. §14.4: "Possession of any
|
|
21
|
+
* deployment credential is insufficient without independently verifiable Citizen
|
|
22
|
+
* proof", and Appendix B rejects "Piper deployment class as an authorisation basis"
|
|
23
|
+
* outright. It is recorded because §15's `deployments` row wants registration,
|
|
24
|
+
* admission and revocation as separate concepts — not because it authorises
|
|
25
|
+
* anything. Nothing in this package reads it, and no function here takes one where a
|
|
26
|
+
* subject is expected.
|
|
27
|
+
*
|
|
28
|
+
* SHORT-LIVED, SINGLE-USE, OPERATION-SCOPED — AND WHICH OF THE THREE THIS PACKAGE
|
|
29
|
+
* CAN HOLD.
|
|
30
|
+
*
|
|
31
|
+
* Operation-scoped it holds completely: `scope` is a non-empty tuple type, so an
|
|
32
|
+
* empty scope is not expressible, and the decoder refuses an empty array. An empty
|
|
33
|
+
* scope authorises nothing, and a caller who sends one has almost certainly built
|
|
34
|
+
* the list dynamically and lost it.
|
|
35
|
+
*
|
|
36
|
+
* Short-lived it holds partially and says so. `expiresAt` must be after `issuedAt`,
|
|
37
|
+
* which is checkable here; the CEILING on that interval is
|
|
38
|
+
* `MAX_ASSERTION_LIFETIME_SECONDS`, a runtime export of `@cayso/contracts` that this
|
|
39
|
+
* package may not value-import — it holds no runtime dependency, and the coupling
|
|
40
|
+
* scan's rule C enforces that mechanically. Restating the figure here would be
|
|
41
|
+
* exactly what the canon forbids: a number copied out of its one home, going stale
|
|
42
|
+
* in a published package that every consumer compiles against. The issuer takes
|
|
43
|
+
* that value dependency and enforces the ceiling. This package refuses the nonsense
|
|
44
|
+
* and leaves the figure where it lives.
|
|
45
|
+
*
|
|
46
|
+
* Single-use it does not hold at all. Replay refusal needs recorded state — §15's
|
|
47
|
+
* `assertion_issuance` row, with its unique `jti` or one-way representation — and
|
|
48
|
+
* this package has no store and no clock. The vocabulary is here; the enforcement is
|
|
49
|
+
* the issuer's and the verifier's.
|
|
50
|
+
*/
|
|
51
|
+
import type { AssertionClaims, ContractOperation, Millis } from "@cayso/contracts";
|
|
52
|
+
import { type CitizenId, type DeploymentId } from "./ids.js";
|
|
53
|
+
/**
|
|
54
|
+
* The operations an assertion may be scoped to.
|
|
55
|
+
*
|
|
56
|
+
* Cayso's `ContractOperation`, imported and not restated: the scope of an assertion
|
|
57
|
+
* this pillar mints is read by Cayso's gate, so a second vocabulary would be two
|
|
58
|
+
* lists that disagree the first time one of them grows. `assertion.test.ts` asserts
|
|
59
|
+
* this package's runtime list is exactly Cayso's `CONTRACT_OPERATIONS`.
|
|
60
|
+
*/
|
|
61
|
+
export type AssertionOperation = ContractOperation;
|
|
62
|
+
/**
|
|
63
|
+
* The operations, as a value this package can check a wire scope against.
|
|
64
|
+
*
|
|
65
|
+
* `CONTRACT_OPERATIONS` is a runtime export of `@cayso/contracts` and this package
|
|
66
|
+
* may not value-import it (coupling scan rule C). The list is therefore written once
|
|
67
|
+
* here and pinned to Cayso's by a test, in both directions, so an operation added or
|
|
68
|
+
* removed upstream fails this package's test run rather than passing through it.
|
|
69
|
+
*/
|
|
70
|
+
export declare const ASSERTION_OPERATIONS: readonly AssertionOperation[];
|
|
71
|
+
/** A non-empty scope. The type cannot express the empty list; the decoder refuses it. */
|
|
72
|
+
export type AssertionScope = readonly [
|
|
73
|
+
AssertionOperation,
|
|
74
|
+
...AssertionOperation[]
|
|
75
|
+
];
|
|
76
|
+
/**
|
|
77
|
+
* What an issuer is asked for.
|
|
78
|
+
*
|
|
79
|
+
* `subject` is a `CitizenId` and nothing else. `audience` is the literal `"cayso"`,
|
|
80
|
+
* matching `AssertionClaims.aud` in `@cayso/contracts` — a request that could name
|
|
81
|
+
* its own audience is a request that could have an assertion minted for a relying
|
|
82
|
+
* party nobody registered, and §14.4 requires that a modified downloaded Instance
|
|
83
|
+
* "cannot … change its audience, redirect URI, operation or onboarding target".
|
|
84
|
+
*/
|
|
85
|
+
export interface AssertionRequest {
|
|
86
|
+
readonly subject: CitizenId;
|
|
87
|
+
readonly audience: AssertionClaims["aud"];
|
|
88
|
+
readonly scope: AssertionScope;
|
|
89
|
+
/**
|
|
90
|
+
* The installation presenting the request, or `null`. Recorded, never trusted:
|
|
91
|
+
* it grants nothing, narrows nothing and substitutes for no part of Citizen proof.
|
|
92
|
+
*/
|
|
93
|
+
readonly deploymentId: DeploymentId | null;
|
|
94
|
+
}
|
|
95
|
+
/** An `AssertionRequest` from a wire value, or a refusal. */
|
|
96
|
+
export declare const parseAssertionRequest: (value: unknown, field?: string) => AssertionRequest;
|
|
97
|
+
/** Whether issuance happened. A closed pair; there is no third answer and no "partial". */
|
|
98
|
+
export type IssuanceOutcome = "issued" | "refused";
|
|
99
|
+
/**
|
|
100
|
+
* What §15's `assertion_issuance` row keeps, and nothing from its "must not hold"
|
|
101
|
+
* column.
|
|
102
|
+
*
|
|
103
|
+
* There is no field for the encoded assertion, its signature, a legal identity value
|
|
104
|
+
* or a deployment secret — §15 forbids all four, and §14.4's disclosure criterion
|
|
105
|
+
* repeats it for logs, traces and analytics. `jtiDigest` is §15's "Unique `jti` or a
|
|
106
|
+
* one-way representation": a digest is enough to refuse a replay and is not enough
|
|
107
|
+
* to reconstruct anything.
|
|
108
|
+
*
|
|
109
|
+
* `safeReasonCode` is a `RefusalCode`-shaped idea deliberately typed as the closed
|
|
110
|
+
* refusal union's member set rather than free text — see `refusal.ts` for why a
|
|
111
|
+
* reason that can hold prose eventually holds a provider's payload.
|
|
112
|
+
*/
|
|
113
|
+
export interface AssertionIssuanceRecord {
|
|
114
|
+
readonly jtiDigest: string;
|
|
115
|
+
readonly subject: CitizenId;
|
|
116
|
+
readonly scope: AssertionScope;
|
|
117
|
+
readonly audience: AssertionClaims["aud"];
|
|
118
|
+
/** The `kid` of the key that signed it. An identifier, never the key. */
|
|
119
|
+
readonly signingKeyId: string;
|
|
120
|
+
readonly issuedAt: Millis;
|
|
121
|
+
readonly expiresAt: Millis;
|
|
122
|
+
readonly outcome: IssuanceOutcome;
|
|
123
|
+
}
|
|
124
|
+
/** An `AssertionIssuanceRecord` from a wire value, or a refusal. */
|
|
125
|
+
export declare const parseAssertionIssuanceRecord: (value: unknown, field?: string) => AssertionIssuanceRecord;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assertion vocabulary: who may be the subject, what a request may ask for, and
|
|
3
|
+
* what an issuance record may keep afterwards.
|
|
4
|
+
*
|
|
5
|
+
* CONTEXT.md states the rule this file exists to hold: "The Citizen ID is the only
|
|
6
|
+
* identifier that may appear as an assertion subject." §8.7 lists the structural
|
|
7
|
+
* tests phase one relies on instead of the phase-two binding table, and two of them
|
|
8
|
+
* are about this type — "no Piper subject or deployment identifier appears in the
|
|
9
|
+
* Citizen-creation input" and "no assertion derives `sub` from a Piper identifier".
|
|
10
|
+
*
|
|
11
|
+
* ENFORCED TWICE.
|
|
12
|
+
*
|
|
13
|
+
* `AssertionRequest.subject` is a `CitizenId`. A `PiperPlatformSubject` is a
|
|
14
|
+
* different brand and is not assignable to it — in either direction, with no
|
|
15
|
+
* conversion function anywhere in this package — so an issuer that tried to pass one
|
|
16
|
+
* through does not compile. `parseAssertionRequest` is the runtime twin: the subject
|
|
17
|
+
* arrives as a string like everything else on a wire, and only `readCitizenId`
|
|
18
|
+
* accepts it.
|
|
19
|
+
*
|
|
20
|
+
* `DeploymentId` is present and carries nothing. §14.4: "Possession of any
|
|
21
|
+
* deployment credential is insufficient without independently verifiable Citizen
|
|
22
|
+
* proof", and Appendix B rejects "Piper deployment class as an authorisation basis"
|
|
23
|
+
* outright. It is recorded because §15's `deployments` row wants registration,
|
|
24
|
+
* admission and revocation as separate concepts — not because it authorises
|
|
25
|
+
* anything. Nothing in this package reads it, and no function here takes one where a
|
|
26
|
+
* subject is expected.
|
|
27
|
+
*
|
|
28
|
+
* SHORT-LIVED, SINGLE-USE, OPERATION-SCOPED — AND WHICH OF THE THREE THIS PACKAGE
|
|
29
|
+
* CAN HOLD.
|
|
30
|
+
*
|
|
31
|
+
* Operation-scoped it holds completely: `scope` is a non-empty tuple type, so an
|
|
32
|
+
* empty scope is not expressible, and the decoder refuses an empty array. An empty
|
|
33
|
+
* scope authorises nothing, and a caller who sends one has almost certainly built
|
|
34
|
+
* the list dynamically and lost it.
|
|
35
|
+
*
|
|
36
|
+
* Short-lived it holds partially and says so. `expiresAt` must be after `issuedAt`,
|
|
37
|
+
* which is checkable here; the CEILING on that interval is
|
|
38
|
+
* `MAX_ASSERTION_LIFETIME_SECONDS`, a runtime export of `@cayso/contracts` that this
|
|
39
|
+
* package may not value-import — it holds no runtime dependency, and the coupling
|
|
40
|
+
* scan's rule C enforces that mechanically. Restating the figure here would be
|
|
41
|
+
* exactly what the canon forbids: a number copied out of its one home, going stale
|
|
42
|
+
* in a published package that every consumer compiles against. The issuer takes
|
|
43
|
+
* that value dependency and enforces the ceiling. This package refuses the nonsense
|
|
44
|
+
* and leaves the figure where it lives.
|
|
45
|
+
*
|
|
46
|
+
* Single-use it does not hold at all. Replay refusal needs recorded state — §15's
|
|
47
|
+
* `assertion_issuance` row, with its unique `jti` or one-way representation — and
|
|
48
|
+
* this package has no store and no clock. The vocabulary is here; the enforcement is
|
|
49
|
+
* the issuer's and the verifier's.
|
|
50
|
+
*/
|
|
51
|
+
import { parseDeploymentId, readCitizenId, } from "./ids.js";
|
|
52
|
+
import { readArray, readClosedRecord, readString, refuseValue, } from "./parse.js";
|
|
53
|
+
/**
|
|
54
|
+
* The operations, as a value this package can check a wire scope against.
|
|
55
|
+
*
|
|
56
|
+
* `CONTRACT_OPERATIONS` is a runtime export of `@cayso/contracts` and this package
|
|
57
|
+
* may not value-import it (coupling scan rule C). The list is therefore written once
|
|
58
|
+
* here and pinned to Cayso's by a test, in both directions, so an operation added or
|
|
59
|
+
* removed upstream fails this package's test run rather than passing through it.
|
|
60
|
+
*/
|
|
61
|
+
export const ASSERTION_OPERATIONS = Object.freeze([
|
|
62
|
+
"read-own-wallet",
|
|
63
|
+
"read-purchase-result",
|
|
64
|
+
"authorise-purchase",
|
|
65
|
+
"cancel-purchase",
|
|
66
|
+
"provision-citizenship",
|
|
67
|
+
"read-own-citizenship",
|
|
68
|
+
]);
|
|
69
|
+
/**
|
|
70
|
+
* A scope that is known to be non-empty, or a refusal.
|
|
71
|
+
*
|
|
72
|
+
* The check and the type agree by construction: the first element is read out and
|
|
73
|
+
* refused when it is absent, so the tuple is built from a value the compiler has
|
|
74
|
+
* seen rather than asserted into existence by a cast.
|
|
75
|
+
*/
|
|
76
|
+
const asScope = (operations, field) => {
|
|
77
|
+
const [first, ...rest] = operations;
|
|
78
|
+
if (first === undefined) {
|
|
79
|
+
refuseValue(field, "must name at least one operation. An empty scope authorises nothing");
|
|
80
|
+
}
|
|
81
|
+
return [first, ...rest];
|
|
82
|
+
};
|
|
83
|
+
const REQUEST_FIELDS = Object.freeze([
|
|
84
|
+
"subject",
|
|
85
|
+
"audience",
|
|
86
|
+
"scope",
|
|
87
|
+
"deploymentId",
|
|
88
|
+
]);
|
|
89
|
+
/**
|
|
90
|
+
* Field names by which a caller might try to supply the subject from somewhere
|
|
91
|
+
* other than an established Percayso ID session.
|
|
92
|
+
*
|
|
93
|
+
* §14.4: "No browser, Piper API or installation registration can substitute a
|
|
94
|
+
* Citizen through path, query, body, header or cookie." A request carrying one of
|
|
95
|
+
* these is refused by name, because the caller needs to know the substitution was
|
|
96
|
+
* seen and rejected rather than ignored. The `PiperPlatformSubject` type in `ids.ts`
|
|
97
|
+
* exists precisely so that such a value has somewhere to live that is NOT here.
|
|
98
|
+
*/
|
|
99
|
+
const SUBJECT_SUBSTITUTION_FIELDS = Object.freeze([
|
|
100
|
+
"piperSubject",
|
|
101
|
+
"piperPlatformSubject",
|
|
102
|
+
"localSubject",
|
|
103
|
+
"sub",
|
|
104
|
+
"onBehalfOf",
|
|
105
|
+
"actAs",
|
|
106
|
+
"impersonate",
|
|
107
|
+
]);
|
|
108
|
+
/** An `AssertionRequest` from a wire value, or a refusal. */
|
|
109
|
+
export const parseAssertionRequest = (value, field = "assertionRequest") => {
|
|
110
|
+
const record = readClosedRecord(value, field, [
|
|
111
|
+
...REQUEST_FIELDS,
|
|
112
|
+
...SUBJECT_SUBSTITUTION_FIELDS,
|
|
113
|
+
]);
|
|
114
|
+
for (const key of Object.keys(record)) {
|
|
115
|
+
if (SUBJECT_SUBSTITUTION_FIELDS.includes(key)) {
|
|
116
|
+
refuseValue(`${field}.${key}`, "cannot supply the subject. The Citizen ID is the only identifier that may appear as an assertion subject, and it comes from this pillar's own session (§8.7, §14.4)");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const audience = readString(record, field, "audience");
|
|
120
|
+
if (audience !== "cayso") {
|
|
121
|
+
refuseValue(`${field}.audience`, "must be cayso. A caller does not choose the audience an assertion is minted for (§14.4)");
|
|
122
|
+
}
|
|
123
|
+
const scope = readArray(record, field, "scope", (entry, elementField) => {
|
|
124
|
+
if (typeof entry !== "string" ||
|
|
125
|
+
!ASSERTION_OPERATIONS.includes(entry)) {
|
|
126
|
+
refuseValue(elementField, "must be an operation of the Cayso contract");
|
|
127
|
+
}
|
|
128
|
+
return entry;
|
|
129
|
+
});
|
|
130
|
+
const deploymentId = record["deploymentId"];
|
|
131
|
+
return {
|
|
132
|
+
subject: readCitizenId(record, field, "subject"),
|
|
133
|
+
audience: "cayso",
|
|
134
|
+
scope: asScope(scope, `${field}.scope`),
|
|
135
|
+
deploymentId: deploymentId === null
|
|
136
|
+
? null
|
|
137
|
+
: parseDeploymentId(readString(record, field, "deploymentId"), `${field}.deploymentId`),
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
const ISSUANCE_FIELDS = Object.freeze([
|
|
141
|
+
"jtiDigest",
|
|
142
|
+
"subject",
|
|
143
|
+
"scope",
|
|
144
|
+
"audience",
|
|
145
|
+
"signingKeyId",
|
|
146
|
+
"issuedAt",
|
|
147
|
+
"expiresAt",
|
|
148
|
+
"outcome",
|
|
149
|
+
]);
|
|
150
|
+
/**
|
|
151
|
+
* Field names carrying signing material or the encoded assertion itself.
|
|
152
|
+
*
|
|
153
|
+
* These spellings appear here as names this contract REFUSES. They are not this
|
|
154
|
+
* pillar's vocabulary for an Assertion — CONTEXT.md's banned-word table settles that
|
|
155
|
+
* — and a record that could carry one would put it in every audit export.
|
|
156
|
+
*
|
|
157
|
+
* THE LIST DELIBERATELY STOPS SHORT OF NAMING A PER-DEPLOYMENT SECRET. §14.4 is not
|
|
158
|
+
* that such a field must be refused; it is that no such thing exists — a Piper
|
|
159
|
+
* Platform deployment is a public client and holds no secret. Writing the name here
|
|
160
|
+
* to refuse it would put the term into the repository, where the credential scan
|
|
161
|
+
* finds it and is right to. A wire value carrying one is refused anyway, by
|
|
162
|
+
* `readClosedRecord`, as a field this contract type does not have.
|
|
163
|
+
*/
|
|
164
|
+
const FORBIDDEN_MATERIAL_FIELDS = Object.freeze([
|
|
165
|
+
"assertion",
|
|
166
|
+
"encoded",
|
|
167
|
+
"signature",
|
|
168
|
+
"privateKey",
|
|
169
|
+
"signingKey",
|
|
170
|
+
"legalName",
|
|
171
|
+
"dateOfBirth",
|
|
172
|
+
]);
|
|
173
|
+
const readMillis = (record, field, key) => {
|
|
174
|
+
const value = record[key];
|
|
175
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
176
|
+
refuseValue(`${field}.${key}`, "must be a non-negative integer count of milliseconds");
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
};
|
|
180
|
+
/** An `AssertionIssuanceRecord` from a wire value, or a refusal. */
|
|
181
|
+
export const parseAssertionIssuanceRecord = (value, field = "issuance") => {
|
|
182
|
+
const record = readClosedRecord(value, field, [
|
|
183
|
+
...ISSUANCE_FIELDS,
|
|
184
|
+
...FORBIDDEN_MATERIAL_FIELDS,
|
|
185
|
+
]);
|
|
186
|
+
for (const key of Object.keys(record)) {
|
|
187
|
+
if (FORBIDDEN_MATERIAL_FIELDS.includes(key)) {
|
|
188
|
+
refuseValue(`${field}.${key}`, "is signing material, an encoded credential or a legal identity value. An issuance record holds none of them (§14.4, §15)");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const audience = readString(record, field, "audience");
|
|
192
|
+
if (audience !== "cayso") {
|
|
193
|
+
refuseValue(`${field}.audience`, "must be cayso");
|
|
194
|
+
}
|
|
195
|
+
const scope = readArray(record, field, "scope", (entry, elementField) => {
|
|
196
|
+
if (typeof entry !== "string" ||
|
|
197
|
+
!ASSERTION_OPERATIONS.includes(entry)) {
|
|
198
|
+
refuseValue(elementField, "must be an operation of the Cayso contract");
|
|
199
|
+
}
|
|
200
|
+
return entry;
|
|
201
|
+
});
|
|
202
|
+
const issuedAt = readMillis(record, field, "issuedAt");
|
|
203
|
+
const expiresAt = readMillis(record, field, "expiresAt");
|
|
204
|
+
if (expiresAt <= issuedAt) {
|
|
205
|
+
refuseValue(`${field}.expiresAt`, "must be after issuedAt. How much after is bounded by the Cayso contract's published maximum lifetime, which the issuer enforces and this package does not restate");
|
|
206
|
+
}
|
|
207
|
+
const outcome = readString(record, field, "outcome");
|
|
208
|
+
if (outcome !== "issued" && outcome !== "refused") {
|
|
209
|
+
refuseValue(`${field}.outcome`, "must be issued or refused");
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
jtiDigest: readString(record, field, "jtiDigest"),
|
|
213
|
+
subject: readCitizenId(record, field, "subject"),
|
|
214
|
+
scope: asScope(scope, `${field}.scope`),
|
|
215
|
+
audience: "cayso",
|
|
216
|
+
signingKeyId: readString(record, field, "signingKeyId"),
|
|
217
|
+
issuedAt,
|
|
218
|
+
expiresAt,
|
|
219
|
+
outcome: outcome,
|
|
220
|
+
};
|
|
221
|
+
};
|
package/dist/brand.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The brand, which is how this package says that two strings are not each other.
|
|
3
|
+
*
|
|
4
|
+
* Several of the canon's hardest invariants are statements about WHICH identifier
|
|
5
|
+
* may appear WHERE, and every one of those identifiers is a string on the wire. A
|
|
6
|
+
* Citizen ID, a Piper Platform subject and a canonical Handle are all opaque text;
|
|
7
|
+
* nothing structural distinguishes them, so nothing structural can stop one being
|
|
8
|
+
* passed where another belongs. CONTEXT.md's rule — "the Citizen ID is the only
|
|
9
|
+
* identifier that may appear as an assertion subject" — would then be a sentence in
|
|
10
|
+
* a document rather than something a compiler can hold.
|
|
11
|
+
*
|
|
12
|
+
* A brand fixes that half. `CitizenId` and `PiperPlatformSubject` are both `string`
|
|
13
|
+
* at runtime and are mutually unassignable at compile time, so a function that asks
|
|
14
|
+
* for one cannot be handed the other, and there is no widening that quietly works.
|
|
15
|
+
*
|
|
16
|
+
* It is only half, deliberately. A brand is erased, so it says nothing about a value
|
|
17
|
+
* that arrived over a wire — which is why every branded type in `ids.ts` also has a
|
|
18
|
+
* parser, and why the parser is the layer a reviewer should read second.
|
|
19
|
+
*/
|
|
20
|
+
declare const brand: unique symbol;
|
|
21
|
+
/**
|
|
22
|
+
* An opaque string that is only itself.
|
|
23
|
+
*
|
|
24
|
+
* `Name` never appears at runtime and is never read; it exists so that two brands
|
|
25
|
+
* declared with different names are different types.
|
|
26
|
+
*/
|
|
27
|
+
export type Branded<Name extends string> = string & {
|
|
28
|
+
readonly [brand]: Name;
|
|
29
|
+
};
|
|
30
|
+
export {};
|
package/dist/brand.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The brand, which is how this package says that two strings are not each other.
|
|
3
|
+
*
|
|
4
|
+
* Several of the canon's hardest invariants are statements about WHICH identifier
|
|
5
|
+
* may appear WHERE, and every one of those identifiers is a string on the wire. A
|
|
6
|
+
* Citizen ID, a Piper Platform subject and a canonical Handle are all opaque text;
|
|
7
|
+
* nothing structural distinguishes them, so nothing structural can stop one being
|
|
8
|
+
* passed where another belongs. CONTEXT.md's rule — "the Citizen ID is the only
|
|
9
|
+
* identifier that may appear as an assertion subject" — would then be a sentence in
|
|
10
|
+
* a document rather than something a compiler can hold.
|
|
11
|
+
*
|
|
12
|
+
* A brand fixes that half. `CitizenId` and `PiperPlatformSubject` are both `string`
|
|
13
|
+
* at runtime and are mutually unassignable at compile time, so a function that asks
|
|
14
|
+
* for one cannot be handed the other, and there is no widening that quietly works.
|
|
15
|
+
*
|
|
16
|
+
* It is only half, deliberately. A brand is erased, so it says nothing about a value
|
|
17
|
+
* that arrived over a wire — which is why every branded type in `ids.ts` also has a
|
|
18
|
+
* parser, and why the parser is the layer a reviewer should read second.
|
|
19
|
+
*/
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
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 type { Millis, VerificationMethod } from "@cayso/contracts";
|
|
37
|
+
import { type ApplicationSummary, type ApprovedApplication } from "./application.js";
|
|
38
|
+
import { type CitizenId, type CommunityEmailAddress, type DisplayHandle, type VerificationCaseId } from "./ids.js";
|
|
39
|
+
/**
|
|
40
|
+
* The Citizen statuses of §5.2 — three, and the third one's meaning is not settled.
|
|
41
|
+
*
|
|
42
|
+
* `closed` is carried because the canon carries it, and deliberately without any
|
|
43
|
+
* statement of what it entails: the meaning and duration of `closed` is a
|
|
44
|
+
* Community-wide question that has not been settled. Until it is, closure is
|
|
45
|
+
* reversible by policy — so nothing in this package treats `closed` as terminal, and
|
|
46
|
+
* no predicate here says it is.
|
|
47
|
+
*/
|
|
48
|
+
export type CitizenStatus = "active" | "suspended" | "closed";
|
|
49
|
+
/** Every Citizen status, in §5.2's order. */
|
|
50
|
+
export declare const CITIZEN_STATUSES: readonly CitizenStatus[];
|
|
51
|
+
/**
|
|
52
|
+
* A Citizen status from a wire value, or a refusal.
|
|
53
|
+
*
|
|
54
|
+
* `pending` gets its own refusal rather than falling through the general one. The
|
|
55
|
+
* general message would say a value was outside a closed union, which is true and
|
|
56
|
+
* unhelpful; this one says where pending belongs, because a caller sending it has
|
|
57
|
+
* almost certainly read an Application state and written it into a Citizen field.
|
|
58
|
+
*/
|
|
59
|
+
export declare const parseCitizenStatus: (value: unknown, field?: string) => CitizenStatus;
|
|
60
|
+
/**
|
|
61
|
+
* The Citizen record of §15, and nothing its "must not hold" column forbids.
|
|
62
|
+
*
|
|
63
|
+
* `assuranceProfileVersion` is a `string` because that is what
|
|
64
|
+
* `AssertionClaims.assuranceProfileVersion` is in `@cayso/contracts` — narrowing it
|
|
65
|
+
* to a union here would be this package inventing a rule about another pillar's
|
|
66
|
+
* field, and the one profile version that exists today is Cayso's value to publish,
|
|
67
|
+
* not ours to copy. `verificationMethod` is Cayso's `VerificationMethod` for the
|
|
68
|
+
* same reason, imported rather than restated: two closed unions describing one
|
|
69
|
+
* vocabulary diverge on the first addition, and the one that diverges silently is
|
|
70
|
+
* the one nobody is looking at.
|
|
71
|
+
*/
|
|
72
|
+
export interface CitizenRecord {
|
|
73
|
+
readonly citizenId: CitizenId;
|
|
74
|
+
readonly status: CitizenStatus;
|
|
75
|
+
/** The verification case that justified approval. §15 requires the Citizen to be able to name it. */
|
|
76
|
+
readonly approvedVerificationCaseId: VerificationCaseId;
|
|
77
|
+
/** The assurance profile the approval was assessed under. Cayso's field, Cayso's vocabulary. */
|
|
78
|
+
readonly assuranceProfileVersion: string;
|
|
79
|
+
/** How the human was verified. Never widened, never inferred from the Citizen's age or activity. */
|
|
80
|
+
readonly verificationMethod: VerificationMethod;
|
|
81
|
+
readonly createdAt: Millis;
|
|
82
|
+
}
|
|
83
|
+
/** A `CitizenRecord` from a wire value, or a refusal. */
|
|
84
|
+
export declare const parseCitizenRecord: (value: unknown, field?: string) => CitizenRecord;
|
|
85
|
+
/**
|
|
86
|
+
* What a Citizen is called, as distinct from what a Citizen IS.
|
|
87
|
+
*
|
|
88
|
+
* §14.4: "A Handle or email address never appears as a Cayso ledger identity and is
|
|
89
|
+
* never the stable Citizen ID." The three identifiers here are three different
|
|
90
|
+
* branded types, so the one that may be an assertion subject cannot be substituted
|
|
91
|
+
* by either of the others, in either direction, at any call site — and the Handle
|
|
92
|
+
* and the address are mutually unassignable too, which stops the other likely slip.
|
|
93
|
+
*
|
|
94
|
+
* They are separate fields rather than one identity blob because §15 keeps them in
|
|
95
|
+
* separate tables with their own state and change history: a Handle is renameable,
|
|
96
|
+
* and the address is its **own** identifier with its own lifecycle. A consumer that
|
|
97
|
+
* cached this record cached a name, not a Citizen.
|
|
98
|
+
*/
|
|
99
|
+
export interface CitizenNames {
|
|
100
|
+
readonly citizenId: CitizenId;
|
|
101
|
+
readonly handle: DisplayHandle;
|
|
102
|
+
readonly communityEmailAddress: CommunityEmailAddress;
|
|
103
|
+
}
|
|
104
|
+
/** A `CitizenNames` from a wire value, or a refusal. */
|
|
105
|
+
export declare const parseCitizenNames: (value: unknown, field?: string) => CitizenNames;
|
|
106
|
+
/**
|
|
107
|
+
* The only shape from which a Citizen record may be created.
|
|
108
|
+
*
|
|
109
|
+
* The `approvedApplication` field is required and its type cannot be satisfied by an
|
|
110
|
+
* Application in any other state, which is §14.4's "No route, script, production
|
|
111
|
+
* fixture or flag creates a Citizen outside an Application" made structural. A
|
|
112
|
+
* fixture that wants a Citizen must produce an approved Application first; there is
|
|
113
|
+
* no shorter path in this package, and adding one would be visible as a new exported
|
|
114
|
+
* type rather than as a flag.
|
|
115
|
+
*
|
|
116
|
+
* It deliberately carries no status. A newly created Citizen is `active` — §5.2
|
|
117
|
+
* leaves no other reading, since `suspended` and `closed` are consequences of later
|
|
118
|
+
* decisions — and letting a caller choose would let a creation path produce a
|
|
119
|
+
* Citizen who was born suspended.
|
|
120
|
+
*/
|
|
121
|
+
export interface CitizenCreation {
|
|
122
|
+
readonly approvedApplication: ApprovedApplication;
|
|
123
|
+
readonly citizenId: CitizenId;
|
|
124
|
+
readonly assuranceProfileVersion: string;
|
|
125
|
+
readonly verificationMethod: VerificationMethod;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A `CitizenCreation` from a wire value, or a refusal.
|
|
129
|
+
*
|
|
130
|
+
* The refusal a caller is most likely to meet here is the `ContractParseError`
|
|
131
|
+
* `parseApprovedApplication` raises: `approvedApplication.state: must be approved`.
|
|
132
|
+
*/
|
|
133
|
+
export declare const parseCitizenCreation: (value: unknown, field?: string) => CitizenCreation;
|
|
134
|
+
/**
|
|
135
|
+
* Where a person stands, as one discriminated union rather than three flags.
|
|
136
|
+
*
|
|
137
|
+
* §5.1 draws exactly this: `Visitor ──application created──▶ Applicant ──approval──▶
|
|
138
|
+
* Citizen`. Modelling it as a union is what makes "an Applicant is not a partial
|
|
139
|
+
* Citizen" hold at compile time — the `applicant` branch has no Citizen field to
|
|
140
|
+
* leave null, the `citizen` branch has no Application state to inspect, and no value
|
|
141
|
+
* can be in two branches at once. Three optional fields on one object would have
|
|
142
|
+
* permitted all eight combinations, including the partial Citizen §5.1 says does not
|
|
143
|
+
* exist.
|
|
144
|
+
*
|
|
145
|
+
* The `visitor` branch carries nothing at all, deliberately: a Visitor is a person
|
|
146
|
+
* this pillar knows nothing about, and a Visitor identifier would be the first step
|
|
147
|
+
* towards knowing something it has no basis to record.
|
|
148
|
+
*/
|
|
149
|
+
export type Personhood = {
|
|
150
|
+
readonly stage: "visitor";
|
|
151
|
+
} | {
|
|
152
|
+
readonly stage: "applicant";
|
|
153
|
+
readonly application: ApplicationSummary;
|
|
154
|
+
} | {
|
|
155
|
+
readonly stage: "citizen";
|
|
156
|
+
readonly citizen: CitizenRecord;
|
|
157
|
+
readonly names: CitizenNames;
|
|
158
|
+
};
|