@suveren/gateway 0.19.5 → 0.20.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.
Files changed (46) hide show
  1. package/content/integrations/crm.json +1 -1
  2. package/content/integrations/deploy-github.json +1 -1
  3. package/content/integrations/erp.json +1 -1
  4. package/content/integrations/mail.json +1 -1
  5. package/content/integrations/records.json +1 -1
  6. package/dist/control-plane/index.mjs +21 -13
  7. package/dist/mcp-server/{chunk-ZRRZFGRL.mjs → chunk-APIQIEUA.mjs} +39 -27
  8. package/dist/mcp-server/http.mjs +308 -97
  9. package/dist/mcp-server/report-verify-cli.mjs +1 -1
  10. package/dist/ui/assets/index-1gCMlbLl.js +116 -0
  11. package/dist/ui/index.html +1 -1
  12. package/node_modules/@hap/core/README.md +92 -29
  13. package/node_modules/@hap/core/dist/index.d.mts +614 -593
  14. package/node_modules/@hap/core/dist/index.d.ts +614 -593
  15. package/node_modules/@hap/core/dist/index.js +774 -749
  16. package/node_modules/@hap/core/dist/index.mjs +744 -724
  17. package/node_modules/@hap/core/package.json +1 -1
  18. package/node_modules/@hap/core/src/base64url.ts +39 -0
  19. package/node_modules/@hap/core/src/commitment-modes.ts +0 -21
  20. package/node_modules/@hap/core/src/errors.ts +76 -0
  21. package/node_modules/@hap/core/src/frame.ts +47 -178
  22. package/node_modules/@hap/core/src/gatekeeper.ts +219 -600
  23. package/node_modules/@hap/core/src/identity.ts +9 -12
  24. package/node_modules/@hap/core/src/index.ts +8 -4
  25. package/node_modules/@hap/core/src/mandate.ts +177 -166
  26. package/node_modules/@hap/core/src/owner-signature.ts +282 -0
  27. package/node_modules/@hap/core/src/profile.ts +234 -0
  28. package/node_modules/@hap/core/src/ticket.ts +148 -0
  29. package/node_modules/@hap/core/src/types.ts +191 -344
  30. package/node_modules/@hap/core/src/versions.ts +47 -0
  31. package/package.json +2 -2
  32. package/profiles/calendar/0.6.profile.json +191 -0
  33. package/profiles/charge/0.6.profile.json +211 -0
  34. package/profiles/customers/0.9.profile.json +182 -0
  35. package/profiles/delegation/0.3.profile.json +118 -0
  36. package/profiles/deploy/0.11.profile.json +177 -0
  37. package/profiles/email/0.8.profile.json +226 -0
  38. package/profiles/index.json +12 -1
  39. package/profiles/publish/0.6.profile.json +187 -0
  40. package/profiles/purchase/0.7.profile.json +235 -0
  41. package/profiles/records/0.6.profile.json +118 -0
  42. package/profiles/reporting/0.3.profile.json +98 -0
  43. package/profiles/sales/0.4.profile.json +291 -0
  44. package/dist/ui/assets/index-Du68zJhH.js +0 -116
  45. package/node_modules/@hap/core/src/attestation.ts +0 -173
  46. package/node_modules/@hap/core/src/receipt.ts +0 -74
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Owner Signatures (v0.7) — the `HAP-mandate-projection` object, the
3
+ * `HAP-approval` object, and their verification.
4
+ *
5
+ * Renamed from `mandate.ts` (protocol.md → *Migration from v0.6*: the
6
+ * owner-signed projection's `typ` moves from `"HAP-mandate"` to
7
+ * `"HAP-mandate-projection"`, disambiguating it from the AS-signed mandate
8
+ * itself, which now legitimately owns the name `"HAP-mandate"`).
9
+ *
10
+ * The human signs BEFORE the AS does, so they cannot sign the finished
11
+ * mandate (`mandate_id`/`issued_at` do not exist yet). They sign a mandate
12
+ * PROJECTION: a canonical object every field of which is known at approval
13
+ * time and reconstructible from the finished mandate — so a verifier
14
+ * rebuilds it from the mandate it already holds and checks the signature
15
+ * with the key carried in the owner's DID. No side channel, no second
16
+ * fetch, no key directory. See protocol.md → "Owner Signatures".
17
+ *
18
+ * Verification here requires NO trust in the AS. What it cannot do is tell
19
+ * the verifier WHOSE key signed — confirming the DID belongs to the
20
+ * expected person is the out-of-band step (protocol.md → "Verification
21
+ * procedure" step 5), and it is the honest cost of cold verification.
22
+ */
23
+
24
+ import * as ed from '@noble/ed25519';
25
+ import type { Mandate, MandatePayload, MandateOwnerEntry, OwnerSignatureBinding, ProfileOwnerSignatureFloor } from './types';
26
+ import { canonicalize } from './canonicalize';
27
+ import { decodeDidKey } from './did-key';
28
+ import { toBase64Url, fromBase64Url } from './base64url';
29
+ import type { HapErrorCode } from './errors';
30
+
31
+ /** The object the owner signs — a canonical projection of the mandate.
32
+ * Field absence is defined, not incidental (protocol.md → *The signed
33
+ * object: HAP-mandate-projection*): each conditional field is included
34
+ * exactly when the mandate carries it and omitted otherwise. */
35
+ export interface MandateProjection {
36
+ typ: 'HAP-mandate-projection';
37
+ /** Projection/canonicalization version. A verifier MUST pin it. */
38
+ version: '0.7';
39
+ profile_id: string;
40
+ /** Covered deliberately: the owner signs the profile's BYTES, not only its id. */
41
+ profile_hash: string;
42
+ /** The signing owner's own DID. MUST equal the carrying mandate's `mandate_owners[0].did`. */
43
+ owner_did: string;
44
+ bounds_hash: string;
45
+ scope_hash: string;
46
+ execution_context_hash: string;
47
+ gate_content_hashes: Record<string, string>;
48
+ /** Included iff the mandate carries one — binds ciphertext AND the
49
+ * frozen approver set, the swap this mechanism exists to stop. */
50
+ intent_disclosure_hash?: string;
51
+ commitment_mode: string;
52
+ /** Included together, iff `commitment_mode === "review_above_cap"`. */
53
+ above_cap_caps?: Record<string, number>;
54
+ above_cap_approvers?: string[];
55
+ /** Included iff the mandate narrows the profile's disclose_fields. */
56
+ disclose_fields?: string[];
57
+ /** The replay defence: the human signs how long the authority lives. */
58
+ expires_at: number;
59
+ nonce: string;
60
+ }
61
+
62
+ /** Per-action approval, signed by the owner in `review` mode. Signing a
63
+ * `reject` matters as much as a `commit`: a rejection the AS can discard is
64
+ * a rejection that never happened. */
65
+ export interface ApprovalObject {
66
+ typ: 'HAP-approval';
67
+ version: '0.7';
68
+ proposal_id: string;
69
+ /** Renamed from `attestation_id` in v0.7. */
70
+ mandate_id: string;
71
+ decision: 'commit' | 'reject';
72
+ /** What was approved: the ticket's contentHash where the profile binds
73
+ * content; otherwise sha256 over the JCS of the proposal's argument set. */
74
+ content_hash: string;
75
+ decided_at: number;
76
+ nonce: string;
77
+ }
78
+
79
+ /** Renamed from `MandateError` in v0.7 — carries a canonical {@link HapErrorCode}. */
80
+ export class OwnerSignatureError extends Error {
81
+ constructor(public code: HapErrorCode, message: string) {
82
+ super(`${code}: ${message}`);
83
+ this.name = 'OwnerSignatureError';
84
+ }
85
+ }
86
+
87
+ /** Decode a signature, refusing non-base64url input with THIS artifact's code
88
+ * — decoding sits outside the `.catch(() => false)` of the verify call, so
89
+ * without this a malformed owner or approval signature escaped with no code. */
90
+ function decodeOrThrow(signature: string, code: HapErrorCode): Uint8Array {
91
+ try {
92
+ return fromBase64Url(signature);
93
+ } catch (err) {
94
+ throw new OwnerSignatureError(code, (err as Error).message);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Rebuild the projection a given `mandate_owners` entry signed, from the
100
+ * mandate's own signed fields. Field absence is defined, not incidental:
101
+ * `intent_disclosure_hash`, `above_cap_caps`/`above_cap_approvers`, and
102
+ * `disclose_fields` are included iff the mandate carries them.
103
+ */
104
+ export function buildMandateProjection(
105
+ payload: MandatePayload,
106
+ entry: Pick<MandateOwnerEntry, 'did' | 'nonce'>,
107
+ ): MandateProjection {
108
+ const {
109
+ profile_id, profile_hash, bounds_hash, scope_hash, execution_context_hash,
110
+ gate_content_hashes, commitment_mode, expires_at,
111
+ } = payload;
112
+ if (!profile_hash || !bounds_hash || !scope_hash || !commitment_mode) {
113
+ throw new OwnerSignatureError(
114
+ 'MALFORMED_MANDATE',
115
+ 'mandate projection requires profile_hash, bounds_hash, scope_hash and commitment_mode',
116
+ );
117
+ }
118
+ const projection: MandateProjection = {
119
+ typ: 'HAP-mandate-projection',
120
+ version: '0.7',
121
+ profile_id,
122
+ profile_hash,
123
+ owner_did: entry.did,
124
+ bounds_hash,
125
+ scope_hash,
126
+ execution_context_hash,
127
+ gate_content_hashes,
128
+ commitment_mode,
129
+ expires_at,
130
+ nonce: entry.nonce!,
131
+ };
132
+ if (payload.intent_disclosure_hash !== undefined) {
133
+ projection.intent_disclosure_hash = payload.intent_disclosure_hash;
134
+ }
135
+ if (payload.commitment_mode === 'review_above_cap') {
136
+ projection.above_cap_caps = payload.above_cap_caps;
137
+ projection.above_cap_approvers = payload.above_cap_approvers;
138
+ }
139
+ if (payload.disclose_fields !== undefined) {
140
+ projection.disclose_fields = payload.disclose_fields;
141
+ }
142
+ return projection;
143
+ }
144
+
145
+ /** The exact bytes an owner signs: RFC 8785 (JCS) canonical UTF-8. Renamed
146
+ * from `mandateSigningBytes` in v0.7. */
147
+ export function projectionSigningBytes(projection: MandateProjection): Uint8Array {
148
+ return new TextEncoder().encode(canonicalize(projection));
149
+ }
150
+
151
+ /** The exact bytes an owner signs for a per-action approval. */
152
+ export function approvalSigningBytes(approval: ApprovalObject): Uint8Array {
153
+ return new TextEncoder().encode(canonicalize(approval));
154
+ }
155
+
156
+ /**
157
+ * Sign a mandate projection with a raw Ed25519 private key — the `raw`
158
+ * binding: tests and CI only, no custody claim. WebAuthn (`webauthn`) and
159
+ * wallet (`eudi`) bindings sign the same bytes through their own custody;
160
+ * they are implemented by the platforms that hold those keys, not here.
161
+ */
162
+ export async function signMandateProjection(
163
+ projection: MandateProjection,
164
+ privateKey: Uint8Array,
165
+ ): Promise<string> {
166
+ return toBase64Url(await ed.signAsync(projectionSigningBytes(projection), privateKey));
167
+ }
168
+
169
+ /** Sign an approval object with a raw Ed25519 private key (`raw` binding). */
170
+ export async function signApproval(approval: ApprovalObject, privateKey: Uint8Array): Promise<string> {
171
+ return toBase64Url(await ed.signAsync(approvalSigningBytes(approval), privateKey));
172
+ }
173
+
174
+ /**
175
+ * Verify ONE `mandate_owners` entry against the mandate that carries it.
176
+ *
177
+ * Steps (protocol.md → "Verification procedure", step 4 of 6): the entry
178
+ * names this mandate's sole owner, its signing DID is key-bearing,
179
+ * projection reconstruction, Ed25519 over JCS bytes. The DID is
180
+ * authoritative over `alg` — a disagreement is `OWNER_SIGNATURE_INVALID`,
181
+ * never a fallback to the claimed algorithm.
182
+ *
183
+ * What this deliberately does NOT verify: that the DID belongs to the
184
+ * person the verifier expects (out-of-band, step 5) and the AS's own
185
+ * signature over the mandate (`verifyMandateSignature`, step 1).
186
+ */
187
+ export async function verifyOwnerSignature(mandate: Mandate, entry: MandateOwnerEntry): Promise<void> {
188
+ const owners = mandate.payload.mandate_owners ?? [];
189
+ if (!owners.some((o) => o.did === entry.did)) {
190
+ throw new OwnerSignatureError('OWNER_SIGNATURE_INVALID', `signing DID ${entry.did} is not this mandate's mandate_owners`);
191
+ }
192
+ if (entry.alg !== undefined && entry.alg !== 'EdDSA') {
193
+ // The only key type a did:key carries in this protocol version is Ed25519;
194
+ // an entry claiming otherwise disagrees with its own DID.
195
+ throw new OwnerSignatureError('OWNER_SIGNATURE_INVALID', `alg ${entry.alg} disagrees with the DID's key type (DID is authoritative)`);
196
+ }
197
+ if (!entry.signature || !entry.nonce) {
198
+ throw new OwnerSignatureError('OWNER_SIGNATURE_INVALID', `mandate_owners entry for ${entry.did} carries no signature to verify`);
199
+ }
200
+
201
+ let publicKey: Uint8Array;
202
+ try {
203
+ publicKey = decodeDidKey(entry.did);
204
+ } catch (err) {
205
+ throw new OwnerSignatureError('OWNER_SIGNATURE_INVALID', (err as Error).message);
206
+ }
207
+
208
+ const projection = buildMandateProjection(mandate.payload, entry);
209
+ const ok = await ed
210
+ .verifyAsync(decodeOrThrow(entry.signature, 'OWNER_SIGNATURE_INVALID'), projectionSigningBytes(projection), publicKey)
211
+ .catch(() => false);
212
+ if (!ok) {
213
+ throw new OwnerSignatureError('OWNER_SIGNATURE_INVALID', `owner signature by ${entry.did} does not verify`);
214
+ }
215
+ }
216
+
217
+ /** Verify every co-signing `mandate_owners` entry a mandate carries (i.e.
218
+ * every entry that carries a signature). Resolves to the verified entries;
219
+ * a mandate with none resolves to `[]` — nothing to check, nothing claimed. */
220
+ export async function verifyOwnerSignatures(mandate: Mandate): Promise<MandateOwnerEntry[]> {
221
+ const entries = (mandate.payload.mandate_owners ?? []).filter((e) => e.signature !== undefined);
222
+ for (const entry of entries) {
223
+ await verifyOwnerSignature(mandate, entry);
224
+ }
225
+ return entries;
226
+ }
227
+
228
+ /** Verify an approval signature against a signer's key-bearing DID. */
229
+ export async function verifyApproval(approval: ApprovalObject, signature: string, signerDid: string): Promise<void> {
230
+ let publicKey: Uint8Array;
231
+ try {
232
+ publicKey = decodeDidKey(signerDid);
233
+ } catch (err) {
234
+ throw new OwnerSignatureError('OWNER_SIGNATURE_INVALID', (err as Error).message);
235
+ }
236
+ const ok = await ed.verifyAsync(decodeOrThrow(signature, 'APPROVAL_SIGNATURE_INVALID'), approvalSigningBytes(approval), publicKey).catch(() => false);
237
+ if (!ok) {
238
+ throw new OwnerSignatureError('APPROVAL_SIGNATURE_INVALID', `approval signature by ${signerDid} does not verify`);
239
+ }
240
+ }
241
+
242
+ /** Signature-assurance ranking for `OwnerSignatureBinding`, weakest first —
243
+ * protocol.md → *Owner Signatures* → "binding" table ordering (raw < webauthn < eudi). */
244
+ const BINDING_RANK: Record<OwnerSignatureBinding, number> = { raw: 0, webauthn: 1, eudi: 2 };
245
+
246
+ /**
247
+ * Enforce a profile's `ownerSignature` floor (protocol.md → *Owner
248
+ * Signatures* → "Where the requirement lives" — the profile-floor tier,
249
+ * checkable without trusting the AS: `profile_id`/`profile_hash` are in the
250
+ * signed payload AND in the owner-signed projection).
251
+ *
252
+ * An unknown `binding` value is treated as rank 0 (weakest) rather than
253
+ * rejected outright — "an AS MUST NOT reject a mandate_owners entry solely
254
+ * because it does not recognize its binding value" extends naturally to a
255
+ * verifier ranking it, since crediting an unrecognized binding with
256
+ * unearned strength would be the more dangerous failure mode.
257
+ *
258
+ * @throws OwnerSignatureError `OWNER_SIGNATURE_REQUIRED` when the floor is
259
+ * unmet — "MUST fail, not warn" (protocol.md, same section).
260
+ */
261
+ export function checkOwnerSignatureRequirement(
262
+ payload: MandatePayload,
263
+ floor: ProfileOwnerSignatureFloor | undefined,
264
+ ): void {
265
+ if (!floor?.required) return;
266
+ const entry = payload.mandate_owners[0];
267
+ const signed = entry?.signature !== undefined && entry?.binding !== undefined;
268
+ if (!signed) {
269
+ throw new OwnerSignatureError(
270
+ 'OWNER_SIGNATURE_REQUIRED',
271
+ 'this profile requires an owner signature and mandate_owners[0] carries none',
272
+ );
273
+ }
274
+ const minRank = floor.minBinding ? BINDING_RANK[floor.minBinding] : 0;
275
+ const haveRank = BINDING_RANK[entry!.binding as OwnerSignatureBinding] ?? 0;
276
+ if (haveRank < minRank) {
277
+ throw new OwnerSignatureError(
278
+ 'OWNER_SIGNATURE_REQUIRED',
279
+ `this profile requires binding >= "${floor.minBinding}", mandate_owners[0] carries "${entry!.binding}"`,
280
+ );
281
+ }
282
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Profile hashing and authoring-time validation (new in v0.7).
3
+ *
4
+ * `computeProfileHash` is the AS-side and Gatekeeper-side half of
5
+ * `profile_hash` (protocol.md → *Profile hash*): "sha256 of the RFC 8785
6
+ * (JCS) serialization of the profile JSON" — over the PARSED document, never
7
+ * file bytes, so two parties who provisioned the same profile through
8
+ * different channels (a git checkout, an npm tarball, a copy with a
9
+ * trailing newline) always agree.
10
+ *
11
+ * `validateProfile` folds together the authoring-time checks that used to
12
+ * live in three places (`frame.ts`'s `validateBoundsRequiredFor`,
13
+ * `commitment-modes.ts`'s `validateCommitmentModes`, and nothing at all for
14
+ * the v0.7 structural rules) into one function returning the canonical
15
+ * `PROFILE_INVALID` shape an AS can emit directly. Like its predecessors,
16
+ * it is NOT wired into `registerProfile` — nothing in hap-core validates
17
+ * profile JSON at load time; call it explicitly from an authoring tool, a
18
+ * hap-profiles CI check, or a test.
19
+ */
20
+ import { createHash } from 'crypto';
21
+ import { canonicalize } from './canonicalize';
22
+ import { allowedCommitmentModes, SIGNABLE_COMMITMENT_MODES } from './commitment-modes';
23
+ import type { AgentProfile, BoundType } from './types';
24
+ import { HapError, type HapErrorCode } from './errors';
25
+
26
+ /** One authoring-time finding. `code` is always a canonical v0.7 code — in
27
+ * practice always `PROFILE_INVALID`, since that is the only mandate-request
28
+ * refusal the protocol attributes to "the profile fails validation"
29
+ * (protocol.md → Error Codes). */
30
+ export interface ProfileValidationError {
31
+ code: HapErrorCode;
32
+ field?: string;
33
+ message: string;
34
+ }
35
+
36
+ /**
37
+ * `profile_hash` — `"sha256:" + sha256(JCS(parsed))`, over the PARSED
38
+ * profile document. Formatting, key order, and a trailing newline on disk
39
+ * never change it (protocol.md → *Profile hash*; conformance vector set
40
+ * `profile-hash.json`).
41
+ */
42
+ export function computeProfileHash(parsed: unknown): string {
43
+ const hash = createHash('sha256').update(canonicalize(parsed), 'utf8').digest('hex');
44
+ return `sha256:${hash}`;
45
+ }
46
+
47
+ /** Keys retired by earlier versions. A profile authored against an old
48
+ * vocabulary fails loudly here instead of silently losing the field's
49
+ * effect (protocol.md → *Migration from v0.6*, "Signed-payload, profile, and
50
+ * wire renames"). */
51
+ const RETIRED_PROFILE_KEYS: Record<string, string> = {
52
+ contextSchema: 'scopeSchema',
53
+ ownerMandate: 'ownerSignature',
54
+ receipt_lookup: 'ticket_lookup',
55
+ frameSchema: '(removed — v0.3 execution frames are retired)',
56
+ };
57
+
58
+ /**
59
+ * Authoring-time structural validation (protocol.md → *Bounds Schema*,
60
+ * *Owner Signatures* → "Where the requirement lives", *Migration from
61
+ * v0.6*). Returns `[]` for a clean profile; never throws.
62
+ */
63
+ export function validateProfile(profile: AgentProfile): ProfileValidationError[] {
64
+ const errors: ProfileValidationError[] = [];
65
+ const p = profile as AgentProfile & Record<string, unknown>;
66
+
67
+ // ── Retired keys (v0.7 rename table) ────────────────────────────────────
68
+ for (const [retired, current] of Object.entries(RETIRED_PROFILE_KEYS)) {
69
+ if (retired in p) {
70
+ errors.push({
71
+ code: 'PROFILE_INVALID',
72
+ field: retired,
73
+ message: `"${retired}" is retired in v0.7 — use "${current}" instead.`,
74
+ });
75
+ }
76
+ }
77
+ if (Array.isArray(p.requiredGates) && (p.requiredGates as unknown[]).includes('decision_owner')) {
78
+ errors.push({
79
+ code: 'PROFILE_INVALID',
80
+ field: 'requiredGates',
81
+ message: '"decision_owner" is retired in v0.7 — the required gate is "mandate_owner".',
82
+ });
83
+ }
84
+
85
+ // ── Bounds schema ────────────────────────────────────────────────────────
86
+ const boundsSchema = profile.boundsSchema;
87
+ if (boundsSchema) {
88
+ const actionTypes = boundsSchema.actionTypes ? [...boundsSchema.actionTypes] : undefined;
89
+
90
+ for (const [fieldName, fieldDef] of Object.entries(boundsSchema.fields)) {
91
+ if (fieldName === 'profile') continue;
92
+
93
+ // Bounds Schema rule 6: no action-routing arrays. v0.4 retired execution
94
+ // paths; routing is the job of actionType + the tool-gating manifest.
95
+ const routing = ['path', 'paths'].filter((k) => k in (fieldDef as object));
96
+ if (routing.length > 0) {
97
+ errors.push({
98
+ code: 'PROFILE_INVALID',
99
+ field: fieldName,
100
+ message: `Bounds field "${fieldName}" declares ${routing.map((k) => `"${k}"`).join(' and ')} — action-routing arrays are forbidden (Bounds Schema rule 6); use actionType and appliesTo.`,
101
+ });
102
+ }
103
+
104
+ if (!fieldDef.boundType) {
105
+ errors.push({
106
+ code: 'PROFILE_INVALID',
107
+ field: fieldName,
108
+ message: `Bounds field "${fieldName}" has no boundType — enforcement semantics undefined (Bounds Schema rule 2).`,
109
+ });
110
+ continue;
111
+ }
112
+ const bt = fieldDef.boundType as BoundType & { requiredFor?: unknown };
113
+
114
+ // appliesTo on a per_transaction or enum bound (new in v0.7).
115
+ if (fieldDef.appliesTo && (bt.kind === 'per_transaction' || bt.kind === 'enum')) {
116
+ errors.push({
117
+ code: 'PROFILE_INVALID',
118
+ field: fieldName,
119
+ message: `Bounds field "${fieldName}": appliesTo MUST NOT be declared on a ${bt.kind} bound (Bounds Schema rule 7).`,
120
+ });
121
+ }
122
+
123
+ // cumulative_count on a v0.7+ profile MUST declare appliesTo.
124
+ if (bt.kind === 'cumulative_count' && !fieldDef.appliesTo?.length) {
125
+ errors.push({
126
+ code: 'PROFILE_INVALID',
127
+ field: fieldName,
128
+ message: `Bounds field "${fieldName}": a cumulative_count bound on a v0.7+ profile MUST declare appliesTo (Bounds Schema rule 7) — absence would silently throttle every action type.`,
129
+ });
130
+ }
131
+
132
+ // appliesTo members MUST be drawn from the actionTypes registry.
133
+ if (fieldDef.appliesTo && actionTypes) {
134
+ for (const at of fieldDef.appliesTo) {
135
+ if (!actionTypes.includes(at)) {
136
+ errors.push({
137
+ code: 'PROFILE_INVALID',
138
+ field: fieldName,
139
+ message: `Bounds field "${fieldName}": appliesTo names "${at}", which is not in boundsSchema.actionTypes [${actionTypes.join(', ')}].`,
140
+ });
141
+ }
142
+ }
143
+ }
144
+
145
+ // requiredFor is only meaningful on a per_transaction bound — every
146
+ // enforcement point ignores it on any other kind.
147
+ if (bt.kind !== 'per_transaction' && (bt as { requiredFor?: unknown }).requiredFor !== undefined) {
148
+ errors.push({
149
+ code: 'PROFILE_INVALID',
150
+ field: fieldName,
151
+ message: `Bounds field "${fieldName}": requiredFor is only meaningful on a per_transaction bound (found on kind "${bt.kind}") — every enforcement point ignores it there. Remove it.`,
152
+ });
153
+ }
154
+
155
+ // per_transaction.requiredFor members MUST also be registered.
156
+ if (bt.kind === 'per_transaction' && Array.isArray(bt.requiredFor) && actionTypes) {
157
+ for (const at of bt.requiredFor as string[]) {
158
+ if (!actionTypes.includes(at)) {
159
+ errors.push({
160
+ code: 'PROFILE_INVALID',
161
+ field: fieldName,
162
+ message: `Bounds field "${fieldName}": requiredFor names "${at}", which is not in boundsSchema.actionTypes [${actionTypes.join(', ')}].`,
163
+ });
164
+ }
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ // ── Commitment modes ─────────────────────────────────────────────────────
171
+ const declaredModes = (p as { commitment_modes?: unknown }).commitment_modes;
172
+ if (declaredModes !== undefined) {
173
+ if (!Array.isArray(declaredModes)) {
174
+ errors.push({ code: 'PROFILE_INVALID', field: 'commitment_modes', message: 'commitment_modes must be a list of modes.' });
175
+ } else {
176
+ if (declaredModes.length === 0) {
177
+ errors.push({
178
+ code: 'PROFILE_INVALID',
179
+ field: 'commitment_modes',
180
+ message: 'commitment_modes is empty — no mandate could ever be signed under this profile.',
181
+ });
182
+ }
183
+ const seen = new Set<unknown>();
184
+ for (const m of declaredModes) {
185
+ if (!(SIGNABLE_COMMITMENT_MODES as readonly string[]).includes(m as string)) {
186
+ errors.push({
187
+ code: 'PROFILE_INVALID',
188
+ field: 'commitment_modes',
189
+ message: `commitment_modes: "${String(m)}" is not a mode a signer can choose (${SIGNABLE_COMMITMENT_MODES.join(', ')}).`,
190
+ });
191
+ }
192
+ if (seen.has(m)) {
193
+ errors.push({ code: 'PROFILE_INVALID', field: 'commitment_modes', message: `commitment_modes: "${String(m)}" appears twice.` });
194
+ }
195
+ seen.add(m);
196
+ }
197
+ }
198
+ }
199
+ // allowedCommitmentModes is the fail-closed reader; an always-empty result
200
+ // for a profile that DID declare modes means the declaration is unusable.
201
+ if (Array.isArray(declaredModes) && declaredModes.length > 0 && allowedCommitmentModes(profile).length === 0) {
202
+ errors.push({
203
+ code: 'PROFILE_INVALID',
204
+ field: 'commitment_modes',
205
+ message: 'commitment_modes declares no mode this profile can actually use.',
206
+ });
207
+ }
208
+
209
+ return errors;
210
+ }
211
+
212
+ /**
213
+ * A mandate's `disclose_fields` MUST be a subset of the profile's own list
214
+ * (protocol.md → *Mandate Payload*, conditional field `disclose_fields`: "a
215
+ * subset of the profile's `disclose_fields` list"). A mandate may narrow
216
+ * disclosure, never widen it.
217
+ *
218
+ * @throws Error prefixed `MALFORMED_MANDATE:` when it is not a subset.
219
+ */
220
+ export function checkDiscloseSubset(
221
+ mandateDiscloseFields: readonly string[] | undefined,
222
+ profileDiscloseFields: readonly string[] | undefined,
223
+ ): void {
224
+ if (!mandateDiscloseFields || mandateDiscloseFields.length === 0) return;
225
+ const allowed = new Set(profileDiscloseFields ?? []);
226
+ const widened = mandateDiscloseFields.filter((f) => !allowed.has(f));
227
+ if (widened.length > 0) {
228
+ throw new HapError(
229
+ 'MALFORMED_MANDATE',
230
+ `disclose_fields [${widened.join(', ')}] is not a subset of the profile's ` +
231
+ `disclose_fields [${[...allowed].join(', ')}] — a mandate may narrow disclosure, never widen it.`,
232
+ );
233
+ }
234
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Mandate tickets (v0.7) — the wire type, signing, and holder-side
3
+ * verification. Renamed from `receipt.ts` (protocol.md → *Mandate Tickets*:
4
+ * "v0.4 introduced the object under the name execution receipt; v0.7 renames
5
+ * it because the tense was wrong — a receipt follows payment, and the ticket
6
+ * must come first").
7
+ *
8
+ * Ticket payloads are camelCase on the wire (unlike the snake_case mandate
9
+ * payload) — a shipped inconsistency the spec documents rather than breaks
10
+ * (protocol.md → *Migration from v0.6*, "Unchanged on purpose").
11
+ */
12
+
13
+ import { HapError } from './errors';
14
+ import * as ed from '@noble/ed25519';
15
+ import type { ContentBinding, Subject } from './types';
16
+ import { canonicalize } from './canonicalize';
17
+ import { toBase64Url, fromBase64Url } from './base64url';
18
+ import { decodeDidKey } from './did-key';
19
+
20
+ /** Signed mandate ticket — protocol.md → *Ticket Payload Schema*. */
21
+ export interface TicketPayload {
22
+ id: string;
23
+ /** The mandate this ticket was issued under (new in v0.7). Audit and
24
+ * chain-of-custody only — `boundsHash` remains the AS's lookup key. */
25
+ mandateId: string;
26
+ groupId: string | null;
27
+ userId?: string;
28
+ boundsHash: string;
29
+ profileId: string;
30
+ /** Downstream tool name. Audit metadata only — never a dispatch key. */
31
+ action: string;
32
+ /** Semantic category — drives cumulative bucketing and bounds dispatch. */
33
+ actionType: string;
34
+ executionContext: Record<string, unknown>;
35
+ cumulativeState?: Record<string, unknown>;
36
+ /** The mandate's plaintext bounds object, exactly as the AS enforced them
37
+ * for this ticket (new in v0.7 — required). */
38
+ limits: Record<string, unknown>;
39
+ /** Protocol version of the ticket — the version of the mandate it was
40
+ * issued under (new in v0.7). */
41
+ version: string;
42
+ /** The AS DID, as in the mandate (new in v0.7). */
43
+ issuer: string;
44
+ timestamp: number;
45
+ /** Hash of the bound content, computed by the Gatekeeper, copied
46
+ * verbatim by the AS. The AS never sees the content. */
47
+ contentHash?: string;
48
+ /** The binding declaration echoed into the signed ticket, so a
49
+ * verifier knows exactly what `contentHash` covers. */
50
+ contentBinding?: Pick<ContentBinding, 'version' | 'kind' | 'fields' | 'required_fields' | 'appliesTo'>;
51
+ /** Disclosed subset of the mandate's identity overlay. */
52
+ subjects?: Subject[];
53
+ /** Review path: the proposal this ticket executed. */
54
+ proposalId?: string;
55
+ /** Review path: the owner's HAP-approval signature for the executed
56
+ * proposal, as the object itself — never a hash of it, which a holder
57
+ * could check only by asking the AS. */
58
+ approvalSignature?: { alg: 'EdDSA' | 'ES256'; signature: string; nonce: string; decided_at: number };
59
+ /** Ed25519 over the JCS-canonical payload (this field excluded), base64url. */
60
+ signature: string;
61
+ }
62
+
63
+ /**
64
+ * Sign a ticket payload with the Authority Server's Ed25519 private key —
65
+ * the same key used for mandates.
66
+ */
67
+ export async function signTicket(
68
+ payload: Omit<TicketPayload, 'signature'>,
69
+ privateKey: Uint8Array,
70
+ ): Promise<TicketPayload> {
71
+ const signature = toBase64Url(await ed.signAsync(new TextEncoder().encode(canonicalize(payload)), privateKey));
72
+ return { ...payload, signature };
73
+ }
74
+
75
+ /**
76
+ * Verify a ticket's AS signature: strip `signature`, JCS-canonicalize the
77
+ * rest, Ed25519-verify against the key resolved from `issuer` — never
78
+ * against a key offered alongside the ticket (protocol.md → *Ticket
79
+ * Verification* step 1). This is holder-side verification — it needs the
80
+ * COMPLETE ticket; a redacted public view cannot be re-verified this way
81
+ * (see {@link publicTicketView}).
82
+ *
83
+ * Content is checked separately: where `contentHash` is present, recompute
84
+ * it from the held artifact via the content-binding module and compare.
85
+ *
86
+ * @param opts.trustedIssuers When supplied, an `issuer` not in this list is
87
+ * rejected before any cryptography runs.
88
+ * @throws Error prefixed `INVALID_SIGNATURE:` when verification fails.
89
+ */
90
+ export async function verifyTicketSignature(
91
+ ticket: TicketPayload,
92
+ opts?: { trustedIssuers?: readonly string[] },
93
+ ): Promise<void> {
94
+ const { signature, ...unsigned } = ticket;
95
+ if (!signature) {
96
+ throw new HapError('INVALID_SIGNATURE', 'ticket carries no signature');
97
+ }
98
+ if (opts?.trustedIssuers && !opts.trustedIssuers.includes(ticket.issuer)) {
99
+ throw new HapError('INVALID_SIGNATURE', `issuer ${JSON.stringify(ticket.issuer)} is not a trusted Authority Server`);
100
+ }
101
+
102
+ let publicKey: Uint8Array;
103
+ try {
104
+ publicKey = decodeDidKey(ticket.issuer);
105
+ } catch (err) {
106
+ throw new HapError('INVALID_SIGNATURE', `cannot resolve a key from issuer ${JSON.stringify(ticket.issuer)}: ${(err as Error).message}`);
107
+ }
108
+
109
+ const bytes = new TextEncoder().encode(canonicalize(unsigned));
110
+ let sigBytes: Uint8Array;
111
+ try {
112
+ sigBytes = fromBase64Url(signature);
113
+ } catch (err) {
114
+ throw new HapError('INVALID_SIGNATURE', `${(err as Error).message}`);
115
+ }
116
+
117
+ const ok = await ed.verifyAsync(sigBytes, bytes, publicKey).catch(() => false);
118
+ if (!ok) {
119
+ throw new HapError('INVALID_SIGNATURE', 'ticket signature verification failed');
120
+ }
121
+ }
122
+
123
+ /**
124
+ * A redacted public view of a ticket (new in v0.7 — protocol.md → *Ticket
125
+ * Disclosure Is Declared*, *Ticket Verification*). Hides `userId`,
126
+ * `cumulativeState`, and `limits` unconditionally, and narrows
127
+ * `executionContext` to exactly the keys named by `governingDiscloseFields`
128
+ * (the mandate's `disclose_fields` where present, otherwise the profile's).
129
+ *
130
+ * Disclose NOTHING by default: an empty or absent `governingDiscloseFields`
131
+ * strips `executionContext` entirely. "The holder is the verifier" — this
132
+ * view is NOT independently re-verifiable against `signature` (it is missing
133
+ * signed fields); a "signature valid" indicator on a page showing only this
134
+ * view is the AS re-verifying its own signature, and implementations MUST
135
+ * NOT present it as independently verified.
136
+ */
137
+ export function publicTicketView(
138
+ ticket: TicketPayload,
139
+ governingDiscloseFields: readonly string[] = [],
140
+ ): Omit<TicketPayload, 'userId' | 'cumulativeState' | 'limits'> {
141
+ const { userId: _userId, cumulativeState: _cumulativeState, limits: _limits, executionContext, ...rest } = ticket;
142
+ const allowed = new Set(governingDiscloseFields);
143
+ const disclosedExecutionContext: Record<string, unknown> = {};
144
+ for (const [key, value] of Object.entries(executionContext)) {
145
+ if (allowed.has(key)) disclosedExecutionContext[key] = value;
146
+ }
147
+ return { ...rest, executionContext: disclosedExecutionContext };
148
+ }