@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,116 @@
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 { parseCorrelationReference } from "./ids.js";
42
+ import { readClosedRecord, readString, refuseValue } from "./parse.js";
43
+ /** Every refusal code. A consumer that must render them all reads this. */
44
+ export const REFUSAL_CODES = Object.freeze([
45
+ "unauthenticated",
46
+ "step_up_required",
47
+ "not_permitted",
48
+ "state_not_permitted",
49
+ "input_not_acceptable",
50
+ "handle_unavailable",
51
+ "document_already_bound",
52
+ "evidence_insufficient",
53
+ "onboarding_result_not_redeemable",
54
+ "rate_limited",
55
+ "temporarily_unavailable",
56
+ ]);
57
+ /**
58
+ * The refusals a person may appeal, per §8.6's route.
59
+ *
60
+ * `document_already_bound` is here because §14.4 names it: "Reuse of a document
61
+ * identifier already bound to an active Citizen is refused, and the refusal is
62
+ * appealable", and §16's R7 says why — a false duplicate refusal permanently
63
+ * excludes a legitimate person if the appeal cannot override the control itself.
64
+ * `evidence_insufficient` is here because an appeal is reconsideration of a
65
+ * decision about evidence.
66
+ *
67
+ * The others are not appealable and saying so is not a diminishment: appealing
68
+ * `rate_limited` is waiting, and appealing `unauthenticated` is signing in. An
69
+ * Application REJECTION is a lifecycle state (§5.2's `rejected --> under_appeal`)
70
+ * rather than a refusal code, and its appeal route is that transition.
71
+ */
72
+ const APPEALABLE_REFUSALS = Object.freeze([
73
+ "document_already_bound",
74
+ "evidence_insufficient",
75
+ ]);
76
+ /** Whether §8.6's appeal route applies to this refusal. */
77
+ export const isAppealable = (code) => APPEALABLE_REFUSALS.includes(code);
78
+ const REFUSAL_FIELDS = Object.freeze([
79
+ "refusal",
80
+ "correlationReference",
81
+ ]);
82
+ /**
83
+ * Fields that would turn a refusal into a disclosure. Refused by name so the
84
+ * message can say why, rather than reporting an unrecognised key.
85
+ */
86
+ const DISCLOSING_FIELDS = Object.freeze([
87
+ "message",
88
+ "detail",
89
+ "details",
90
+ "reason",
91
+ "internalReason",
92
+ "providerReason",
93
+ "riskSignal",
94
+ "riskSignals",
95
+ "stack",
96
+ ]);
97
+ /** A `Refusal` from a wire value, or a refusal of its own. */
98
+ export const parseRefusal = (value, field = "refusal") => {
99
+ const record = readClosedRecord(value, field, [
100
+ ...REFUSAL_FIELDS,
101
+ ...DISCLOSING_FIELDS,
102
+ ]);
103
+ for (const key of Object.keys(record)) {
104
+ if (DISCLOSING_FIELDS.includes(key)) {
105
+ refuseValue(`${field}.${key}`, "carries free text or an internal signal. A refusal is a code and a correlation reference; the text is the consumer's to render, accessibly (§14.4)");
106
+ }
107
+ }
108
+ const code = readString(record, field, "refusal");
109
+ if (!REFUSAL_CODES.includes(code)) {
110
+ refuseValue(`${field}.refusal`, `must be one of the ${REFUSAL_CODES.length} refusal codes of this contract`);
111
+ }
112
+ return {
113
+ refusal: code,
114
+ correlationReference: parseCorrelationReference(readString(record, field, "correlationReference"), `${field}.correlationReference`),
115
+ };
116
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@percayso/identity-contracts",
3
+ "version": "0.1.0",
4
+ "description": "Percayso ID wire types: identifiers, closed lifecycle states, the closed refusal union, the evidence vocabulary and a thin client.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Percayso-Community/identity.platform.git",
9
+ "directory": "packages/identity-contracts"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./client": {
21
+ "types": "./dist/client.d.ts",
22
+ "default": "./dist/client.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "LICENSE",
28
+ "README.md"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "registry": "https://registry.npmjs.org"
33
+ },
34
+ "scripts": {
35
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
36
+ "lint": "eslint .",
37
+ "typecheck": "tsc -p tsconfig.json",
38
+ "test": "vitest run"
39
+ },
40
+ "peerDependencies": {
41
+ "@cayso/contracts": "0.6.1"
42
+ },
43
+ "devDependencies": {
44
+ "@cayso/contracts": "0.6.1",
45
+ "typescript": "6.0.3",
46
+ "vitest": "5.0.0"
47
+ }
48
+ }