@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.
@@ -0,0 +1,224 @@
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 { readApplicationId, readVerificationCaseId, } from "./ids.js";
61
+ import { readBoolean, readClosedRecord, readString, refuseValue, } from "./parse.js";
62
+ /**
63
+ * A versioned identifier: lower case, separated by hyphens or dots.
64
+ *
65
+ * Dots are admitted so that `passport-1.2` is expressible; upper case and
66
+ * whitespace are not, because these values appear in stored records and in
67
+ * comparisons, and two spellings of one route is one route nobody can query.
68
+ */
69
+ const VERSIONED_IDENTIFIER = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/;
70
+ /** An `EvidenceRoute` from a wire value, or a refusal. */
71
+ export const parseEvidenceRoute = (value, field = "evidenceRoute") => {
72
+ if (!VERSIONED_IDENTIFIER.test(value)) {
73
+ refuseValue(field, "must be a lower-case versioned identifier separated by hyphens or dots");
74
+ }
75
+ return value;
76
+ };
77
+ /** An `EvidencePolicyVersion` from a wire value, or a refusal. */
78
+ export const parseEvidencePolicyVersion = (value, field = "evidencePolicyVersion") => {
79
+ if (!VERSIONED_IDENTIFIER.test(value)) {
80
+ refuseValue(field, "must be a lower-case versioned identifier separated by hyphens or dots");
81
+ }
82
+ return value;
83
+ };
84
+ /** Every outcome, in §8.6's order. */
85
+ export const VERIFICATION_OUTCOMES = Object.freeze(["pass", "refer", "fail"]);
86
+ /** A `VerificationOutcome` from a wire value, or a refusal. */
87
+ export const parseVerificationOutcome = (value, field = "outcome") => {
88
+ if (typeof value !== "string" ||
89
+ !VERIFICATION_OUTCOMES.includes(value)) {
90
+ refuseValue(field, "must be one of pass, refer or fail (§8.6)");
91
+ }
92
+ return value;
93
+ };
94
+ /**
95
+ * The strongest method this evidence supports, at runtime, or `null` for evidence
96
+ * that supports none.
97
+ *
98
+ * `null` is not a failure and must not be rendered as one: it is the honest answer
99
+ * for a case that examined nothing yet. A caller holding `null` has nothing to
100
+ * assert, which is §8.2's position exactly.
101
+ */
102
+ export const methodSupportedByEvidence = (evidence) => {
103
+ if (!evidence.identityDocumentChecked) {
104
+ return null;
105
+ }
106
+ if (evidence.biometricCaptured &&
107
+ evidence.biometricComparedToDocumentPortrait) {
108
+ return "document-and-biometric";
109
+ }
110
+ return "document";
111
+ };
112
+ /**
113
+ * Whether `method` is honest about `evidence` — the refusable form of §8.2.
114
+ *
115
+ * Evidence may exceed what a method asserts; it may never fall short of it. A
116
+ * `dev-bypass` case is honest only when nothing was examined: a bypass that claims
117
+ * a document was checked is an overstatement in the other direction, and the one a
118
+ * migration would most like to make.
119
+ */
120
+ export const evidenceSupportsMethod = (method, evidence) => {
121
+ if (method === "dev-bypass") {
122
+ return (!evidence.identityDocumentChecked &&
123
+ !evidence.biometricCaptured &&
124
+ !evidence.biometricComparedToDocumentPortrait);
125
+ }
126
+ const supported = methodSupportedByEvidence(evidence);
127
+ if (supported === null) {
128
+ return false;
129
+ }
130
+ return method === "document" ? true : supported === "document-and-biometric";
131
+ };
132
+ const CASE_FIELDS = Object.freeze([
133
+ "verificationCaseId",
134
+ "applicationId",
135
+ "outcome",
136
+ "evidenceRoute",
137
+ "evidencePolicyVersion",
138
+ "facts",
139
+ "method",
140
+ "providerCaseReference",
141
+ ]);
142
+ /** Fields that would make a supplier's number the decision. §8.2, §8.8 and §15 all refuse them. */
143
+ const SCORE_FIELDS = Object.freeze([
144
+ "score",
145
+ "providerScore",
146
+ "confidence",
147
+ "matchScore",
148
+ "threshold",
149
+ "similarity",
150
+ ]);
151
+ const FACT_FIELDS = Object.freeze([
152
+ "identityDocumentChecked",
153
+ "biometricCaptured",
154
+ "biometricComparedToDocumentPortrait",
155
+ "documentAuthenticityEstablished",
156
+ "presentationAttackCheckPerformed",
157
+ ]);
158
+ const METHOD_MEMBERS = Object.freeze([
159
+ "dev-bypass",
160
+ "document",
161
+ "document-and-biometric",
162
+ ]);
163
+ /** The evidence facts from a wire value, or a refusal. */
164
+ export const parseIdentityEvidenceFacts = (value, field = "facts") => {
165
+ const record = readClosedRecord(value, field, [
166
+ ...FACT_FIELDS,
167
+ ...SCORE_FIELDS,
168
+ ]);
169
+ for (const key of Object.keys(record)) {
170
+ if (SCORE_FIELDS.includes(key)) {
171
+ refuseValue(`${field}.${key}`, "is a provider figure and not an evidence fact. A bare provider score is never the decision (§8.2, §15)");
172
+ }
173
+ }
174
+ return {
175
+ identityDocumentChecked: readBoolean(record, field, "identityDocumentChecked"),
176
+ biometricCaptured: readBoolean(record, field, "biometricCaptured"),
177
+ biometricComparedToDocumentPortrait: readBoolean(record, field, "biometricComparedToDocumentPortrait"),
178
+ documentAuthenticityEstablished: readBoolean(record, field, "documentAuthenticityEstablished"),
179
+ presentationAttackCheckPerformed: readBoolean(record, field, "presentationAttackCheckPerformed"),
180
+ };
181
+ };
182
+ /**
183
+ * A `VerificationCaseSummary` from a wire value, or a refusal.
184
+ *
185
+ * The refusal that matters is the last one: a `method` the facts do not bear is
186
+ * refused, naming the method the facts actually support. That is §8.2's "A passport
187
+ * MRZ read plus an unmatched selfie is not a document-and-biometric decision",
188
+ * enforced where a provider's response enters this pillar.
189
+ */
190
+ export const parseVerificationCaseSummary = (value, field = "verificationCase") => {
191
+ const record = readClosedRecord(value, field, [
192
+ ...CASE_FIELDS,
193
+ ...SCORE_FIELDS,
194
+ ]);
195
+ for (const key of Object.keys(record)) {
196
+ if (SCORE_FIELDS.includes(key)) {
197
+ refuseValue(`${field}.${key}`, "is a provider figure and not a decision. This pillar records its own versioned decision from evidence facts (§8.2, §8.8, §15)");
198
+ }
199
+ }
200
+ const facts = parseIdentityEvidenceFacts(record["facts"], `${field}.facts`);
201
+ const method = readString(record, field, "method");
202
+ if (!METHOD_MEMBERS.includes(method)) {
203
+ refuseValue(`${field}.method`, "must be a verification method of the Cayso assurance profile");
204
+ }
205
+ if (!evidenceSupportsMethod(method, facts)) {
206
+ const supported = methodSupportedByEvidence(facts);
207
+ refuseValue(`${field}.method`, supported === null
208
+ ? "claims more than the evidence facts support. These facts support no method at all (§8.2)"
209
+ : `claims more than the evidence facts support. These facts support ${supported} (§8.2)`);
210
+ }
211
+ const providerCaseReference = record["providerCaseReference"];
212
+ return {
213
+ verificationCaseId: readVerificationCaseId(record, field, "verificationCaseId"),
214
+ applicationId: readApplicationId(record, field, "applicationId"),
215
+ outcome: parseVerificationOutcome(record["outcome"], `${field}.outcome`),
216
+ evidenceRoute: parseEvidenceRoute(readString(record, field, "evidenceRoute"), `${field}.evidenceRoute`),
217
+ evidencePolicyVersion: parseEvidencePolicyVersion(readString(record, field, "evidencePolicyVersion"), `${field}.evidencePolicyVersion`),
218
+ facts,
219
+ method: method,
220
+ providerCaseReference: providerCaseReference === null
221
+ ? null
222
+ : readString(record, field, "providerCaseReference"),
223
+ };
224
+ };
package/dist/ids.d.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The identifiers of §9 and §15, each branded and each parsed.
3
+ *
4
+ * The canon's identifier rules are almost all rules about CONFUSION — which opaque
5
+ * string may stand in for which other opaque string — and they are the rules this
6
+ * pillar has already lost twice. CONTEXT.md states three of them outright: a Citizen
7
+ * ID "is the only identifier that may appear as an assertion subject"; a Piper
8
+ * Platform subject "identifies a local account… is never evidence of which human is
9
+ * present and is never the source of a Citizen ID"; a Handle "is never a Citizen ID
10
+ * and never appears in Cayso Platform's ledger identity".
11
+ *
12
+ * Every one of those is a string. Nothing structural tells them apart, so the brand
13
+ * of `brand.ts` does the telling: they are mutually unassignable, and a function that
14
+ * asks for a `CitizenId` cannot be handed a `PiperPlatformSubject` however the value
15
+ * was obtained. That is the half a compiler holds.
16
+ *
17
+ * The half a compiler cannot hold is a value that arrived over a wire, which is what
18
+ * each `parse…` function is for.
19
+ *
20
+ * WHAT THE PARSERS DELIBERATELY DO NOT DO
21
+ *
22
+ * They do not pin a generated format. §12.2 limits this package to "wire types,
23
+ * closed error/status unions and a thin client only", and how a Citizen ID is
24
+ * MINTED is the schema's business — P1-3's — not the contract's. What the contract
25
+ * can honestly say is the shape an opaque identifier has on the wire: printable,
26
+ * unpunctuated, URL-safe, and therefore incapable of being an email address or a
27
+ * qualified name. `OPAQUE_IDENTIFIER` admits UUID, ULID, base64url and every other
28
+ * format P1-3 might choose, and admits none of the values CONTEXT.md says an
29
+ * identifier is not.
30
+ *
31
+ * They also carry no length bound. A bound would be a figure, and this pillar's rule
32
+ * is that a figure belongs in §12.5's register with its evidence and is read from
33
+ * configuration. The bound that actually matters to a service is the request-size
34
+ * limit, which §12.1 puts on the slice that exposes an endpoint (P1-8).
35
+ */
36
+ import type { Branded } from "./brand.js";
37
+ /**
38
+ * The Community email domains of §9: `percayso.me` for Citizens, `percayso.biz` for
39
+ * Organisations. Both are confirmed and owned by Percayso.
40
+ *
41
+ * Organisation addresses are not phase-one work — §9 says so — and the domain is
42
+ * listed anyway because the union is the contract. A closed union that omitted a
43
+ * domain the canon confirms would be a union the Organisation slice has to break.
44
+ */
45
+ export declare const COMMUNITY_EMAIL_DOMAINS: readonly ["percayso.me", "percayso.biz"];
46
+ /** One of the two Community email domains. */
47
+ export type CommunityEmailDomain = (typeof COMMUNITY_EMAIL_DOMAINS)[number];
48
+ /**
49
+ * Identity Platform's stable, opaque identifier for a Citizen.
50
+ *
51
+ * The only identifier that may appear as an assertion subject (CONTEXT.md, and
52
+ * `assertion.ts` is where that is made unexpressible rather than merely stated).
53
+ */
54
+ export type CitizenId = Branded<"CitizenId">;
55
+ /** The identifier of one person's attempt to become a Citizen (§5.2). */
56
+ export type ApplicationId = Branded<"ApplicationId">;
57
+ /** The identifier of the record of what evidence was examined, and what was concluded. */
58
+ export type VerificationCaseId = Branded<"VerificationCaseId">;
59
+ /**
60
+ * The identifier of one registered installation of Piper Platform (§15 `deployments`).
61
+ *
62
+ * Registration "gives accountability, throttling and revocation. It does NOT admit an
63
+ * issuer, prove the code is unmodified, or establish which human is present"
64
+ * (CONTEXT.md, **Deployment**). Its separateness from `CitizenId` is the type-level
65
+ * form of §14.4's "possession of any deployment credential is insufficient without
66
+ * independently verifiable Citizen proof".
67
+ */
68
+ export type DeploymentId = Branded<"DeploymentId">;
69
+ /**
70
+ * The identifier of a local user inside one Piper Platform deployment.
71
+ *
72
+ * It identifies a local account and nothing else. §7.1: Identity Platform derives the
73
+ * Citizen subject from its own authenticated session, and "no path, query, body,
74
+ * header, cookie or installation registration may replace it". This type exists
75
+ * PRECISELY so that the replacement cannot be written: it is not assignable to
76
+ * `CitizenId`, and this package publishes no function that converts one to the other.
77
+ */
78
+ export type PiperPlatformSubject = Branded<"PiperPlatformSubject">;
79
+ /** A Citizen's unique, chosen, public pseudonym, in the canonical form of §9.1. */
80
+ export type CanonicalHandle = Branded<"CanonicalHandle">;
81
+ /**
82
+ * The submitted form of a Handle, stored separately from the canonical one (§9.1).
83
+ *
84
+ * Separate types rather than one, because §9.1 requires separate storage and the
85
+ * reason is operational: an algorithm upgrade "never silently changes an existing
86
+ * Handle", which is only checkable if the two forms were never the same field.
87
+ */
88
+ export type DisplayHandle = Branded<"DisplayHandle">;
89
+ /**
90
+ * The address assigned to a Citizen under a Community domain, as its OWN identifier.
91
+ *
92
+ * §9.2: the initial local part is the canonical Handle, and it is then stored
93
+ * separately — "changing the Handle does not automatically change the email address".
94
+ * A distinct brand is what stops a Handle being written into this field by a
95
+ * conversion somebody thought was obviously safe.
96
+ */
97
+ export type CommunityEmailAddress = Branded<"CommunityEmailAddress">;
98
+ /**
99
+ * An opaque reference tying one exchange to its audit record (§15 `audit_events`).
100
+ *
101
+ * It is carried on a refusal, and it is the ONLY thing carried on one that varies —
102
+ * see `refusal.ts` for why that list is as short as it is.
103
+ */
104
+ export type CorrelationReference = Branded<"CorrelationReference">;
105
+ /** Decode a Citizen ID, or refuse. */
106
+ export declare const parseCitizenId: (value: string, field?: string) => CitizenId;
107
+ /** Decode an Application identifier, or refuse. */
108
+ export declare const parseApplicationId: (value: string, field?: string) => ApplicationId;
109
+ /** Decode a Verification Case identifier, or refuse. */
110
+ export declare const parseVerificationCaseId: (value: string, field?: string) => VerificationCaseId;
111
+ /** Decode a Deployment identifier, or refuse. */
112
+ export declare const parseDeploymentId: (value: string, field?: string) => DeploymentId;
113
+ /**
114
+ * Decode a Piper Platform subject, or refuse.
115
+ *
116
+ * Structurally identical to `parseCitizenId` and deliberately kept as its own
117
+ * function. Two parsers that produce unassignable types is the point: a caller that
118
+ * has a local subject and needs a Citizen ID has to go and get one from a Percayso ID
119
+ * session, which is §7.1's rule, and there is nowhere in this package to shortcut it.
120
+ */
121
+ export declare const parsePiperPlatformSubject: (value: string, field?: string) => PiperPlatformSubject;
122
+ /** Decode a correlation reference, or refuse. */
123
+ export declare const parseCorrelationReference: (value: string, field?: string) => CorrelationReference;
124
+ /**
125
+ * Decode a canonical Handle, or refuse.
126
+ *
127
+ * Refuses anything that is not already canonical — an upper-case letter, a leading or
128
+ * trailing hyphen, consecutive hyphens, or any character outside §9.1's alphabet.
129
+ * CANONICALISING is not done here and must not be: NFKC, case folding and the TR39
130
+ * skeleton are P1-12's, and a second implementation of them is how two spellings of
131
+ * the same Handle end up in two tables.
132
+ */
133
+ export declare const parseCanonicalHandle: (value: string, field?: string) => CanonicalHandle;
134
+ /**
135
+ * Decode a Community email address, or refuse.
136
+ *
137
+ * The local part is checked for the same alphabet as a canonical Handle because §9.2
138
+ * says the initial local part IS one. It is not checked for being any PARTICULAR
139
+ * Handle: the address is its own identifier from the moment it is assigned, and a
140
+ * parser that insisted the two still matched would refuse every renamed Handle in the
141
+ * pillar — the exact breakage §9.2 exists to prevent.
142
+ */
143
+ export declare const parseCommunityEmailAddress: (value: string, field?: string) => CommunityEmailAddress;
144
+ /**
145
+ * Decode a display Handle, or refuse.
146
+ *
147
+ * Only emptiness and surrounding whitespace are refused. The submitted form is
148
+ * deliberately permissive — §9.1 stores it precisely BECAUSE it is not the canonical
149
+ * form — and every rule that narrows it lives with the canonicalisation algorithm in
150
+ * P1-12.
151
+ */
152
+ export declare const parseDisplayHandle: (value: string, field?: string) => DisplayHandle;
153
+ /** Read and decode a Citizen ID from a record field. */
154
+ export declare const readCitizenId: (record: Readonly<Record<string, unknown>>, field: string, key: string) => CitizenId;
155
+ /** Read and decode an Application identifier from a record field. */
156
+ export declare const readApplicationId: (record: Readonly<Record<string, unknown>>, field: string, key: string) => ApplicationId;
157
+ /** Read and decode a Verification Case identifier from a record field. */
158
+ export declare const readVerificationCaseId: (record: Readonly<Record<string, unknown>>, field: string, key: string) => VerificationCaseId;
package/dist/ids.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The identifiers of §9 and §15, each branded and each parsed.
3
+ *
4
+ * The canon's identifier rules are almost all rules about CONFUSION — which opaque
5
+ * string may stand in for which other opaque string — and they are the rules this
6
+ * pillar has already lost twice. CONTEXT.md states three of them outright: a Citizen
7
+ * ID "is the only identifier that may appear as an assertion subject"; a Piper
8
+ * Platform subject "identifies a local account… is never evidence of which human is
9
+ * present and is never the source of a Citizen ID"; a Handle "is never a Citizen ID
10
+ * and never appears in Cayso Platform's ledger identity".
11
+ *
12
+ * Every one of those is a string. Nothing structural tells them apart, so the brand
13
+ * of `brand.ts` does the telling: they are mutually unassignable, and a function that
14
+ * asks for a `CitizenId` cannot be handed a `PiperPlatformSubject` however the value
15
+ * was obtained. That is the half a compiler holds.
16
+ *
17
+ * The half a compiler cannot hold is a value that arrived over a wire, which is what
18
+ * each `parse…` function is for.
19
+ *
20
+ * WHAT THE PARSERS DELIBERATELY DO NOT DO
21
+ *
22
+ * They do not pin a generated format. §12.2 limits this package to "wire types,
23
+ * closed error/status unions and a thin client only", and how a Citizen ID is
24
+ * MINTED is the schema's business — P1-3's — not the contract's. What the contract
25
+ * can honestly say is the shape an opaque identifier has on the wire: printable,
26
+ * unpunctuated, URL-safe, and therefore incapable of being an email address or a
27
+ * qualified name. `OPAQUE_IDENTIFIER` admits UUID, ULID, base64url and every other
28
+ * format P1-3 might choose, and admits none of the values CONTEXT.md says an
29
+ * identifier is not.
30
+ *
31
+ * They also carry no length bound. A bound would be a figure, and this pillar's rule
32
+ * is that a figure belongs in §12.5's register with its evidence and is read from
33
+ * configuration. The bound that actually matters to a service is the request-size
34
+ * limit, which §12.1 puts on the slice that exposes an endpoint (P1-8).
35
+ */
36
+ import { readString, refuseValue } from "./parse.js";
37
+ /**
38
+ * The shape of an opaque identifier on the wire.
39
+ *
40
+ * URL-safe and unpunctuated, which is what makes it an identifier rather than a
41
+ * value with structure somebody might read. It excludes `@`, so no email address can
42
+ * ever be decoded as a Citizen ID; it excludes whitespace and `.`, `/` and `:`, so no
43
+ * name, address or qualified reference can either.
44
+ */
45
+ const OPAQUE_IDENTIFIER = /^[A-Za-z0-9_-]+$/;
46
+ /**
47
+ * The canonical Handle alphabet of §9.1: `a-z`, `0-9` and `-`, starting and ending
48
+ * alphanumeric, with no consecutive hyphens.
49
+ *
50
+ * §9.1's LENGTH BOUNDS ARE NOT RESTATED HERE. They are canon figures, the canon is
51
+ * the one place a figure is written down, and the algorithm they belong to — NFKC,
52
+ * case folding, the TR39 confusable skeleton, the reserved set and the versioned
53
+ * policy that governs all of it — is slice P1-12's, not this package's. What this
54
+ * parser is for is narrower and worth having on its own: it refuses a value that is
55
+ * NOT ALREADY CANONICAL, so a display form can never be decoded into a field that
56
+ * means the canonical one.
57
+ */
58
+ const CANONICAL_HANDLE = /^[a-z0-9](?:-?[a-z0-9])*$/;
59
+ /**
60
+ * The Community email domains of §9: `percayso.me` for Citizens, `percayso.biz` for
61
+ * Organisations. Both are confirmed and owned by Percayso.
62
+ *
63
+ * Organisation addresses are not phase-one work — §9 says so — and the domain is
64
+ * listed anyway because the union is the contract. A closed union that omitted a
65
+ * domain the canon confirms would be a union the Organisation slice has to break.
66
+ */
67
+ export const COMMUNITY_EMAIL_DOMAINS = Object.freeze([
68
+ "percayso.me",
69
+ "percayso.biz",
70
+ ]);
71
+ const parseOpaque = (value, field) => {
72
+ if (!OPAQUE_IDENTIFIER.test(value)) {
73
+ refuseValue(field, "must be an opaque identifier: unreserved URL characters only, and therefore never an address, a name or a qualified reference");
74
+ }
75
+ return value;
76
+ };
77
+ /** Decode a Citizen ID, or refuse. */
78
+ export const parseCitizenId = (value, field = "citizenId") => parseOpaque(value, field);
79
+ /** Decode an Application identifier, or refuse. */
80
+ export const parseApplicationId = (value, field = "applicationId") => parseOpaque(value, field);
81
+ /** Decode a Verification Case identifier, or refuse. */
82
+ export const parseVerificationCaseId = (value, field = "verificationCaseId") => parseOpaque(value, field);
83
+ /** Decode a Deployment identifier, or refuse. */
84
+ export const parseDeploymentId = (value, field = "deploymentId") => parseOpaque(value, field);
85
+ /**
86
+ * Decode a Piper Platform subject, or refuse.
87
+ *
88
+ * Structurally identical to `parseCitizenId` and deliberately kept as its own
89
+ * function. Two parsers that produce unassignable types is the point: a caller that
90
+ * has a local subject and needs a Citizen ID has to go and get one from a Percayso ID
91
+ * session, which is §7.1's rule, and there is nowhere in this package to shortcut it.
92
+ */
93
+ export const parsePiperPlatformSubject = (value, field = "piperPlatformSubject") => parseOpaque(value, field);
94
+ /** Decode a correlation reference, or refuse. */
95
+ export const parseCorrelationReference = (value, field = "correlationReference") => parseOpaque(value, field);
96
+ /**
97
+ * Decode a canonical Handle, or refuse.
98
+ *
99
+ * Refuses anything that is not already canonical — an upper-case letter, a leading or
100
+ * trailing hyphen, consecutive hyphens, or any character outside §9.1's alphabet.
101
+ * CANONICALISING is not done here and must not be: NFKC, case folding and the TR39
102
+ * skeleton are P1-12's, and a second implementation of them is how two spellings of
103
+ * the same Handle end up in two tables.
104
+ */
105
+ export const parseCanonicalHandle = (value, field = "canonicalHandle") => {
106
+ if (!CANONICAL_HANDLE.test(value)) {
107
+ refuseValue(field, "must already be canonical per §9.1: lower-case ASCII letters, digits and single interior hyphens, starting and ending alphanumeric. Canonicalisation belongs to P1-12 and is not performed here");
108
+ }
109
+ return value;
110
+ };
111
+ /**
112
+ * Decode a Community email address, or refuse.
113
+ *
114
+ * The local part is checked for the same alphabet as a canonical Handle because §9.2
115
+ * says the initial local part IS one. It is not checked for being any PARTICULAR
116
+ * Handle: the address is its own identifier from the moment it is assigned, and a
117
+ * parser that insisted the two still matched would refuse every renamed Handle in the
118
+ * pillar — the exact breakage §9.2 exists to prevent.
119
+ */
120
+ export const parseCommunityEmailAddress = (value, field = "communityEmailAddress") => {
121
+ const at = value.indexOf("@");
122
+ if (at === -1 || value.indexOf("@", at + 1) !== -1) {
123
+ refuseValue(field, "must contain exactly one @");
124
+ }
125
+ const local = value.slice(0, at);
126
+ const domain = value.slice(at + 1);
127
+ if (!CANONICAL_HANDLE.test(local)) {
128
+ refuseValue(`${field}.localPart`, "must use §9.1's canonical alphabet, which is the alphabet the address was first assigned from");
129
+ }
130
+ if (!COMMUNITY_EMAIL_DOMAINS.includes(domain)) {
131
+ refuseValue(`${field}.domain`, `must be one of the ${COMMUNITY_EMAIL_DOMAINS.length} Community domains of §9`);
132
+ }
133
+ return value;
134
+ };
135
+ /**
136
+ * Decode a display Handle, or refuse.
137
+ *
138
+ * Only emptiness and surrounding whitespace are refused. The submitted form is
139
+ * deliberately permissive — §9.1 stores it precisely BECAUSE it is not the canonical
140
+ * form — and every rule that narrows it lives with the canonicalisation algorithm in
141
+ * P1-12.
142
+ */
143
+ export const parseDisplayHandle = (value, field = "displayHandle") => {
144
+ if (value.trim() === "" || value.trim() !== value) {
145
+ refuseValue(field, "must be non-empty and carry no surrounding whitespace");
146
+ }
147
+ return value;
148
+ };
149
+ /** Read and decode a Citizen ID from a record field. */
150
+ export const readCitizenId = (record, field, key) => parseCitizenId(readString(record, field, key), `${field}.${key}`);
151
+ /** Read and decode an Application identifier from a record field. */
152
+ export const readApplicationId = (record, field, key) => parseApplicationId(readString(record, field, key), `${field}.${key}`);
153
+ /** Read and decode a Verification Case identifier from a record field. */
154
+ export const readVerificationCaseId = (record, field, key) => parseVerificationCaseId(readString(record, field, key), `${field}.${key}`);
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `@percayso/identity-contracts` — the identity vocabulary this pillar publishes.
3
+ *
4
+ * WHAT IS HERE. The identifiers of §9 and §15, the closed lifecycle states of §5.2
5
+ * and §11.3, the closed refusal union, the evidence vocabulary of §8.2 and §8.3, and
6
+ * the assertion request and issuance shapes. Every one of them is a WIRE type with a
7
+ * decoder beside it. Where the canon states an invariant, it is written twice: once
8
+ * as a type that cannot express the violation, and once as a value the decoder
9
+ * refuses.
10
+ *
11
+ * WHAT IS DELIBERATELY NOT HERE. No server, no policy engine, no verifier, no
12
+ * database access and no figures. The thin client ships from the separate `./client`
13
+ * entry point so that a consumer wanting only the types does not pay for it, and so
14
+ * that the packaging test can prove the type surface stands alone.
15
+ *
16
+ * NOTHING RUNS ON IMPORT. Importing this module defines types, freezes a handful of
17
+ * tables and nothing else: no connection, no environment read, no clock. The rule is
18
+ * this repository's and the sibling's alike, and it is what makes a contract package
19
+ * safe to depend on from anywhere.
20
+ *
21
+ * NO RUNTIME DEPENDENCY. `@cayso/contracts` appears here only in type position.
22
+ * Assertion, assurance and operation types are IMPORTED from it and never restated,
23
+ * and the package manifest records it as a peer dependency, exact-pinned; the
24
+ * package README explains why that shape rather than either of the other two. The
25
+ * built JavaScript names it nowhere, and `packaging.test.ts` asserts so against the
26
+ * real tarball.
27
+ */
28
+ export type { Branded } from "./brand.js";
29
+ export { ContractParseError, readArray, readBoolean, readClosedRecord, readMember, readObject, readString, refuseValue, } from "./parse.js";
30
+ export { COMMUNITY_EMAIL_DOMAINS, parseApplicationId, parseCanonicalHandle, parseCitizenId, parseCommunityEmailAddress, parseCorrelationReference, parseDeploymentId, parseDisplayHandle, parsePiperPlatformSubject, parseVerificationCaseId, readApplicationId, readCitizenId, readVerificationCaseId, } from "./ids.js";
31
+ export type { ApplicationId, CanonicalHandle, CitizenId, CommunityEmailAddress, CommunityEmailDomain, CorrelationReference, DeploymentId, DisplayHandle, PiperPlatformSubject, VerificationCaseId, } from "./ids.js";
32
+ export { APPLICATION_STATES, APPLICATION_TRANSITIONS, isApplicationTransitionPermitted, isRetryable, isTerminalApplicationState, parseApplicationState, parseApplicationSummary, parseApprovedApplication, } from "./application.js";
33
+ export type { ApplicationState, ApplicationSummary, ApprovedApplication, } from "./application.js";
34
+ export { CITIZEN_STATUSES, parseCitizenCreation, parseCitizenNames, parseCitizenRecord, parseCitizenStatus, } from "./citizen.js";
35
+ export type { CitizenCreation, CitizenNames, CitizenRecord, CitizenStatus, Personhood, } from "./citizen.js";
36
+ export { PROVISIONING_STATES, PROVISIONING_TRANSITIONS, isProvisioningTransitionPermitted, parseCaysoConfirmation, parseProvisioningCoordination, } from "./provisioning.js";
37
+ export type { CaysoConfirmation, ProvisioningCoordination, ProvisioningState, } from "./provisioning.js";
38
+ export { VERIFICATION_OUTCOMES, evidenceSupportsMethod, methodSupportedByEvidence, parseEvidencePolicyVersion, parseEvidenceRoute, parseIdentityEvidenceFacts, parseVerificationCaseSummary, parseVerificationOutcome, } from "./evidence.js";
39
+ export type { EvidencePolicyVersion, EvidenceRoute, IdentityEvidenceFacts, MethodSupportedBy, VerificationCaseSummary, VerificationOutcome, } from "./evidence.js";
40
+ export { REFUSAL_CODES, isAppealable, parseRefusal } from "./refusal.js";
41
+ export type { ContractResult, Refusal, RefusalCode } from "./refusal.js";
42
+ export { ASSERTION_OPERATIONS, parseAssertionIssuanceRecord, parseAssertionRequest, } from "./assertion.js";
43
+ export type { AssertionIssuanceRecord, AssertionOperation, AssertionRequest, AssertionScope, IssuanceOutcome, } from "./assertion.js";
package/dist/index.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `@percayso/identity-contracts` — the identity vocabulary this pillar publishes.
3
+ *
4
+ * WHAT IS HERE. The identifiers of §9 and §15, the closed lifecycle states of §5.2
5
+ * and §11.3, the closed refusal union, the evidence vocabulary of §8.2 and §8.3, and
6
+ * the assertion request and issuance shapes. Every one of them is a WIRE type with a
7
+ * decoder beside it. Where the canon states an invariant, it is written twice: once
8
+ * as a type that cannot express the violation, and once as a value the decoder
9
+ * refuses.
10
+ *
11
+ * WHAT IS DELIBERATELY NOT HERE. No server, no policy engine, no verifier, no
12
+ * database access and no figures. The thin client ships from the separate `./client`
13
+ * entry point so that a consumer wanting only the types does not pay for it, and so
14
+ * that the packaging test can prove the type surface stands alone.
15
+ *
16
+ * NOTHING RUNS ON IMPORT. Importing this module defines types, freezes a handful of
17
+ * tables and nothing else: no connection, no environment read, no clock. The rule is
18
+ * this repository's and the sibling's alike, and it is what makes a contract package
19
+ * safe to depend on from anywhere.
20
+ *
21
+ * NO RUNTIME DEPENDENCY. `@cayso/contracts` appears here only in type position.
22
+ * Assertion, assurance and operation types are IMPORTED from it and never restated,
23
+ * and the package manifest records it as a peer dependency, exact-pinned; the
24
+ * package README explains why that shape rather than either of the other two. The
25
+ * built JavaScript names it nowhere, and `packaging.test.ts` asserts so against the
26
+ * real tarball.
27
+ */
28
+ export { ContractParseError, readArray, readBoolean, readClosedRecord, readMember, readObject, readString, refuseValue, } from "./parse.js";
29
+ export { COMMUNITY_EMAIL_DOMAINS, parseApplicationId, parseCanonicalHandle, parseCitizenId, parseCommunityEmailAddress, parseCorrelationReference, parseDeploymentId, parseDisplayHandle, parsePiperPlatformSubject, parseVerificationCaseId, readApplicationId, readCitizenId, readVerificationCaseId, } from "./ids.js";
30
+ export { APPLICATION_STATES, APPLICATION_TRANSITIONS, isApplicationTransitionPermitted, isRetryable, isTerminalApplicationState, parseApplicationState, parseApplicationSummary, parseApprovedApplication, } from "./application.js";
31
+ export { CITIZEN_STATUSES, parseCitizenCreation, parseCitizenNames, parseCitizenRecord, parseCitizenStatus, } from "./citizen.js";
32
+ export { PROVISIONING_STATES, PROVISIONING_TRANSITIONS, isProvisioningTransitionPermitted, parseCaysoConfirmation, parseProvisioningCoordination, } from "./provisioning.js";
33
+ export { VERIFICATION_OUTCOMES, evidenceSupportsMethod, methodSupportedByEvidence, parseEvidencePolicyVersion, parseEvidenceRoute, parseIdentityEvidenceFacts, parseVerificationCaseSummary, parseVerificationOutcome, } from "./evidence.js";
34
+ export { REFUSAL_CODES, isAppealable, parseRefusal } from "./refusal.js";
35
+ export { ASSERTION_OPERATIONS, parseAssertionIssuanceRecord, parseAssertionRequest, } from "./assertion.js";