@aroha-sdk/credentials 1.0.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/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@aroha-sdk/credentials",
3
+ "version": "1.0.0",
4
+ "description": "Human credentials, RBAC, and credential registry for the Aroha Protocol",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "clean": "rm -rf dist",
17
+ "test": "vitest run"
18
+ },
19
+ "dependencies": {
20
+ "@aroha-sdk/core": "^1.0.0",
21
+ "@noble/ed25519": "^2.1.0",
22
+ "@noble/hashes": "^1.4.0",
23
+ "uuid": "^10.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "typescript": "^5.4.0",
27
+ "vitest": "^1.6.0"
28
+ }
29
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as ed from "@noble/ed25519";
3
+ import { sha512 } from "@noble/hashes/sha512";
4
+ import {
5
+ issueHumanCredential,
6
+ verifyHumanCredential,
7
+ serializeCredential,
8
+ deserializeCredential,
9
+ } from "./credentials.js";
10
+ import { ArohaRole } from "./types.js";
11
+
12
+ ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
13
+
14
+ async function makeIssuer() {
15
+ const priv = ed.utils.randomPrivateKey();
16
+ const pub = await ed.getPublicKeyAsync(priv);
17
+ return { priv, pub, did: "did:aroha:issuer" };
18
+ }
19
+
20
+ describe("issueHumanCredential", () => {
21
+ it("issues a credential with correct fields", async () => {
22
+ const issuer = await makeIssuer();
23
+ const cred = await issueHumanCredential(
24
+ "user-123",
25
+ [ArohaRole.HumanUser],
26
+ issuer.did,
27
+ issuer.priv,
28
+ 3_600_000,
29
+ "user@example.com"
30
+ );
31
+
32
+ expect(cred.userId).toBe("user-123");
33
+ expect(cred.roles).toContain(ArohaRole.HumanUser);
34
+ expect(cred.issuerDID).toBe(issuer.did);
35
+ expect(cred.email).toBe("user@example.com");
36
+ expect(cred.signature).toBeTruthy();
37
+ expect(new Date(cred.expiresAt) > new Date()).toBe(true);
38
+ });
39
+ });
40
+
41
+ describe("verifyHumanCredential", () => {
42
+ it("verifies a valid credential token", async () => {
43
+ const issuer = await makeIssuer();
44
+ const cred = await issueHumanCredential("user-456", [ArohaRole.HumanAdmin], issuer.did, issuer.priv);
45
+ const token = serializeCredential(cred);
46
+
47
+ const result = await verifyHumanCredential(token, issuer.pub);
48
+ expect(result.valid).toBe(true);
49
+ expect(result.credential?.userId).toBe("user-456");
50
+ });
51
+
52
+ it("rejects a token signed by a different key", async () => {
53
+ const issuer = await makeIssuer();
54
+ const otherIssuer = await makeIssuer();
55
+ const cred = await issueHumanCredential("user-789", [ArohaRole.HumanUser], issuer.did, issuer.priv);
56
+ const token = serializeCredential(cred);
57
+
58
+ const result = await verifyHumanCredential(token, otherIssuer.pub);
59
+ expect(result.valid).toBe(false);
60
+ expect(result.reason).toMatch(/invalid/i);
61
+ });
62
+
63
+ it("rejects a malformed token", async () => {
64
+ const issuer = await makeIssuer();
65
+ const result = await verifyHumanCredential("not-valid-base64url!!!", issuer.pub);
66
+ expect(result.valid).toBe(false);
67
+ expect(result.reason).toMatch(/malformed/i);
68
+ });
69
+
70
+ it("rejects an expired credential", async () => {
71
+ const issuer = await makeIssuer();
72
+ const cred = await issueHumanCredential("user-exp", [ArohaRole.HumanUser], issuer.did, issuer.priv, -1000);
73
+ const token = serializeCredential(cred);
74
+
75
+ const result = await verifyHumanCredential(token, issuer.pub);
76
+ expect(result.valid).toBe(false);
77
+ expect(result.reason).toMatch(/expired/i);
78
+ });
79
+ });
80
+
81
+ describe("serializeCredential / deserializeCredential", () => {
82
+ it("round-trips a credential", async () => {
83
+ const issuer = await makeIssuer();
84
+ const cred = await issueHumanCredential("round-trip", [ArohaRole.HumanReadonly], issuer.did, issuer.priv);
85
+ const token = serializeCredential(cred);
86
+ const decoded = deserializeCredential(token);
87
+ expect(decoded).toEqual(cred);
88
+ });
89
+
90
+ it("returns null for garbage input", () => {
91
+ expect(deserializeCredential("!!!")).toBeNull();
92
+ });
93
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Aroha Protocol — Human Credential System (Layer 1 extension)
3
+ *
4
+ * Human users are not DID-bearing agents. They authenticate to their Personal
5
+ * Agent (out of band — password, OAuth, passkey, etc.), which then issues them
6
+ * a short-lived signed credential. That credential travels in the body of any
7
+ * Aroha message the user triggers, letting provider agents verify who initiated
8
+ * the request and what they are allowed to do.
9
+ *
10
+ * The credential is signed by the issuing agent's Ed25519 private key using
11
+ * the same canonicalization rules as Aroha envelope proofs.
12
+ */
13
+
14
+ import * as ed from "@noble/ed25519";
15
+ import { sha512 } from "@noble/hashes/sha512";
16
+ import { v4 as uuidv4 } from "uuid";
17
+ import { type ArohaRole, type HumanCredential } from "./types.js";
18
+
19
+ ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
20
+
21
+ // Re-export so consumers can import from this module
22
+ export type { HumanCredential } from "./types.js";
23
+
24
+ export interface CredentialVerificationResult {
25
+ valid: boolean;
26
+ credential?: HumanCredential;
27
+ reason?: string;
28
+ }
29
+
30
+ // ─── Issue ────────────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Issue a signed HumanCredential.
34
+ *
35
+ * @param userId Application user identifier
36
+ * @param roles Roles to grant (e.g. ["human:user"])
37
+ * @param issuerDID DID of the issuing agent (the Personal Agent)
38
+ * @param privateKey Ed25519 private key of the issuing agent (32 bytes)
39
+ * @param ttlMs Time-to-live in milliseconds (default: 1 hour)
40
+ * @param email Optional email for display purposes
41
+ */
42
+ export async function issueHumanCredential(
43
+ userId: string,
44
+ roles: ArohaRole[],
45
+ issuerDID: string,
46
+ privateKey: Uint8Array,
47
+ ttlMs = 3_600_000,
48
+ email?: string
49
+ ): Promise<HumanCredential> {
50
+ const now = new Date();
51
+ const credential: Omit<HumanCredential, "signature"> = {
52
+ credentialId: uuidv4(),
53
+ userId,
54
+ roles,
55
+ issuedAt: now.toISOString(),
56
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
57
+ issuerDID,
58
+ ...(email ? { email } : {}),
59
+ };
60
+
61
+ const canonical = canonicalizeCredential(credential);
62
+ const bytes = new TextEncoder().encode(canonical);
63
+ const sig = await ed.signAsync(bytes, privateKey);
64
+
65
+ return {
66
+ ...credential,
67
+ signature: Buffer.from(sig).toString("base64url"),
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Serialize a HumanCredential to a compact base64url token suitable for
73
+ * embedding in a Aroha message body as `credentialToken`.
74
+ */
75
+ export function serializeCredential(cred: HumanCredential): string {
76
+ return Buffer.from(JSON.stringify(cred)).toString("base64url");
77
+ }
78
+
79
+ /**
80
+ * Deserialize a credentialToken back to a HumanCredential object.
81
+ * Returns null if the token is malformed.
82
+ */
83
+ export function deserializeCredential(token: string): HumanCredential | null {
84
+ try {
85
+ return JSON.parse(Buffer.from(token, "base64url").toString("utf8")) as HumanCredential;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ // ─── Verify ───────────────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Verify a HumanCredential.
95
+ *
96
+ * Checks:
97
+ * 1. Token is parseable
98
+ * 2. Expiry has not passed
99
+ * 3. Ed25519 signature is valid against the issuer's public key
100
+ *
101
+ * @param token The base64url credentialToken from the message body
102
+ * @param issuerPublicKey Ed25519 public key of the issuer DID (32 bytes)
103
+ */
104
+ export async function verifyHumanCredential(
105
+ token: string,
106
+ issuerPublicKey: Uint8Array
107
+ ): Promise<CredentialVerificationResult> {
108
+ const cred = deserializeCredential(token);
109
+ if (!cred) {
110
+ return { valid: false, reason: "Credential token is malformed" };
111
+ }
112
+
113
+ if (new Date(cred.expiresAt) < new Date()) {
114
+ return { valid: false, reason: `Credential expired at ${cred.expiresAt}` };
115
+ }
116
+
117
+ const { signature, ...body } = cred;
118
+ const canonical = canonicalizeCredential(body);
119
+ const bytes = new TextEncoder().encode(canonical);
120
+
121
+ try {
122
+ const sig = Buffer.from(signature, "base64url");
123
+ const ok = await ed.verifyAsync(sig, bytes, issuerPublicKey);
124
+ if (!ok) return { valid: false, reason: "Credential signature invalid" };
125
+ return { valid: true, credential: cred };
126
+ } catch {
127
+ return { valid: false, reason: "Credential signature verification threw an error" };
128
+ }
129
+ }
130
+
131
+ // ─── Canonicalization ─────────────────────────────────────────────────────────
132
+
133
+ /** Canonical JSON for signing — excludes `signature`, sorts keys. */
134
+ function canonicalizeCredential(cred: Omit<HumanCredential, "signature">): string {
135
+ return JSON.stringify(sortKeysDeep(cred));
136
+ }
137
+
138
+ function sortKeysDeep(value: unknown): unknown {
139
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
140
+ if (value !== null && typeof value === "object") {
141
+ const sorted: Record<string, unknown> = {};
142
+ for (const key of Object.keys(value as object).sort()) {
143
+ sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
144
+ }
145
+ return sorted;
146
+ }
147
+ return value;
148
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./types.js";
2
+ export * from "./credentials.js";
3
+ export * from "./rbac.js";
4
+ export * from "./registry.js";
5
+ export * from "./middleware.js";
6
+ export * from "./mandate.js";
@@ -0,0 +1,293 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as ed from "@noble/ed25519";
3
+ import { sha512 } from "@noble/hashes/sha512";
4
+ import {
5
+ issueIntentMandate,
6
+ attenuateToCart,
7
+ attenuateToPayment,
8
+ verifyMandate,
9
+ type SignedMandate,
10
+ } from "./mandate.js";
11
+ import type { SpendingConstraints } from "@aroha-sdk/core";
12
+
13
+ ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
14
+
15
+ async function makeKey() {
16
+ const priv = ed.utils.randomPrivateKey();
17
+ const pub = await ed.getPublicKeyAsync(priv);
18
+ return { priv, pub };
19
+ }
20
+
21
+ const BASE: SpendingConstraints = {
22
+ spendLimitUsd: 500,
23
+ sessionLimitUsd: 200,
24
+ requireHumanApprovalAboveUsd: 100,
25
+ allowedMerchants: ["merchant-a", "merchant-b", "merchant-c"],
26
+ };
27
+
28
+ // ─── issueIntentMandate ───────────────────────────────────────────────────────
29
+
30
+ describe("issueIntentMandate", () => {
31
+ it("issues a mandate with correct fields", async () => {
32
+ const k = await makeKey();
33
+ const m = await issueIntentMandate("did:aroha:grantor", "did:aroha:grantee", BASE, k.priv);
34
+ expect(m.mandate.mandateTier).toBe("intent");
35
+ expect(m.mandate.grantor).toBe("did:aroha:grantor");
36
+ expect(m.mandate.grantee).toBe("did:aroha:grantee");
37
+ expect(m.mandate.parentMandateId).toBeNull();
38
+ expect(m.mandate.mandateId).toBeTruthy();
39
+ expect(new Date(m.mandate.expiresAt) > new Date()).toBe(true);
40
+ expect(m.token).toBeTruthy();
41
+ expect(m.signature).toBeTruthy();
42
+ });
43
+
44
+ it("respects custom ttlMs", async () => {
45
+ const k = await makeKey();
46
+ const before = Date.now();
47
+ const m = await issueIntentMandate("did:aroha:g", "did:aroha:e", BASE, k.priv, 10_000);
48
+ const expMs = new Date(m.mandate.expiresAt).getTime();
49
+ expect(expMs - before).toBeGreaterThan(9_000);
50
+ expect(expMs - before).toBeLessThan(11_000);
51
+ });
52
+ });
53
+
54
+ // ─── verifyMandate ────────────────────────────────────────────────────────────
55
+
56
+ describe("verifyMandate", () => {
57
+ it("accepts a valid token", async () => {
58
+ const k = await makeKey();
59
+ const m = await issueIntentMandate("did:aroha:g", "did:aroha:e", BASE, k.priv);
60
+ const r = await verifyMandate(m.token, k.pub);
61
+ expect(r.valid).toBe(true);
62
+ expect(r.mandate?.mandateId).toBe(m.mandate.mandateId);
63
+ });
64
+
65
+ it("rejects a token signed by a different key", async () => {
66
+ const k = await makeKey();
67
+ const other = await makeKey();
68
+ const m = await issueIntentMandate("did:aroha:g", "did:aroha:e", BASE, k.priv);
69
+ const r = await verifyMandate(m.token, other.pub);
70
+ expect(r.valid).toBe(false);
71
+ expect(r.reason).toMatch(/signature/i);
72
+ });
73
+
74
+ it("rejects an expired mandate", async () => {
75
+ const k = await makeKey();
76
+ const m = await issueIntentMandate("did:aroha:g", "did:aroha:e", BASE, k.priv, -1);
77
+ const r = await verifyMandate(m.token, k.pub);
78
+ expect(r.valid).toBe(false);
79
+ expect(r.reason).toMatch(/expired/i);
80
+ });
81
+
82
+ it("rejects grantee mismatch", async () => {
83
+ const k = await makeKey();
84
+ const m = await issueIntentMandate("did:aroha:g", "did:aroha:e", BASE, k.priv);
85
+ const r = await verifyMandate(m.token, k.pub, "did:aroha:someone-else");
86
+ expect(r.valid).toBe(false);
87
+ expect(r.reason).toMatch(/grantee/i);
88
+ });
89
+
90
+ it("rejects malformed token", async () => {
91
+ const k = await makeKey();
92
+ const r = await verifyMandate("not!!valid!!base64url", k.pub);
93
+ expect(r.valid).toBe(false);
94
+ expect(r.reason).toMatch(/malformed/i);
95
+ });
96
+ });
97
+
98
+ // ─── attenuateToCart — narrowing enforcement ──────────────────────────────────
99
+
100
+ describe("attenuateToCart narrowing", () => {
101
+ async function intent(): Promise<[SignedMandate, Uint8Array, Uint8Array]> {
102
+ const k = await makeKey();
103
+ const m = await issueIntentMandate("did:aroha:user", "did:aroha:agent", BASE, k.priv);
104
+ return [m, k.priv, k.pub];
105
+ }
106
+
107
+ it("allows valid narrowing", async () => {
108
+ const [parent, agentKey] = await intent();
109
+ const narrow: SpendingConstraints = {
110
+ spendLimitUsd: 100,
111
+ sessionLimitUsd: 50,
112
+ requireHumanApprovalAboveUsd: 50,
113
+ allowedMerchants: ["merchant-a"],
114
+ };
115
+ await expect(
116
+ attenuateToCart(parent, "did:aroha:provider", narrow, agentKey)
117
+ ).resolves.toBeDefined();
118
+ });
119
+
120
+ it("rejects spendLimitUsd widening", async () => {
121
+ const [parent, agentKey] = await intent();
122
+ await expect(
123
+ attenuateToCart(parent, "did:aroha:p", { spendLimitUsd: 1000 }, agentKey)
124
+ ).rejects.toThrow(/spendLimit/i);
125
+ });
126
+
127
+ it("rejects sessionLimitUsd widening", async () => {
128
+ const [parent, agentKey] = await intent();
129
+ await expect(
130
+ attenuateToCart(parent, "did:aroha:p", { spendLimitUsd: 100, sessionLimitUsd: 999 }, agentKey)
131
+ ).rejects.toThrow(/sessionLimit/i);
132
+ });
133
+
134
+ it("rejects requireHumanApprovalAboveUsd widening", async () => {
135
+ const [parent, agentKey] = await intent();
136
+ await expect(
137
+ attenuateToCart(parent, "did:aroha:p", { spendLimitUsd: 100, requireHumanApprovalAboveUsd: 999 }, agentKey)
138
+ ).rejects.toThrow(/approval threshold/i);
139
+ });
140
+
141
+ it("rejects allowedMerchants with unlisted merchant", async () => {
142
+ const [parent, agentKey] = await intent();
143
+ await expect(
144
+ attenuateToCart(parent, "did:aroha:p", {
145
+ spendLimitUsd: 100,
146
+ allowedMerchants: ["merchant-a", "merchant-unknown"],
147
+ }, agentKey)
148
+ ).rejects.toThrow(/allowedMerchants/i);
149
+ });
150
+
151
+ it("rejects empty allowedMerchants when parent restricts", async () => {
152
+ const [parent, agentKey] = await intent();
153
+ await expect(
154
+ attenuateToCart(parent, "did:aroha:p", {
155
+ spendLimitUsd: 100,
156
+ allowedMerchants: [],
157
+ }, agentKey)
158
+ ).rejects.toThrow(/allowedMerchants/i);
159
+ });
160
+
161
+ it("rejects missing allowedMerchants when parent restricts", async () => {
162
+ const [parent, agentKey] = await intent();
163
+ await expect(
164
+ attenuateToCart(parent, "did:aroha:p", { spendLimitUsd: 100 }, agentKey)
165
+ ).rejects.toThrow(/allowedMerchants/i);
166
+ });
167
+ });
168
+
169
+ // ─── attenuateToPayment — narrowing enforcement ───────────────────────────────
170
+
171
+ describe("attenuateToPayment narrowing", () => {
172
+ it("rejects spendLimitUsd widening from cart", async () => {
173
+ const intentKey = await makeKey();
174
+ const cartKey = await makeKey();
175
+ const intent = await issueIntentMandate("did:aroha:user", "did:aroha:agent", BASE, intentKey.priv);
176
+ const cart = await attenuateToCart(intent, "did:aroha:provider", {
177
+ spendLimitUsd: 100, allowedMerchants: ["merchant-a"],
178
+ }, intentKey.priv);
179
+ await expect(
180
+ attenuateToPayment(cart, "did:aroha:processor", { spendLimitUsd: 999 }, cartKey.priv)
181
+ ).rejects.toThrow(/spendLimit/i);
182
+ });
183
+ });
184
+
185
+ // ─── Full chain: intent → cart → payment ─────────────────────────────────────
186
+
187
+ describe("full delegation chain", () => {
188
+ it("intent → cart → payment verifies end-to-end", async () => {
189
+ const userKey = await makeKey();
190
+ const agentKey = await makeKey();
191
+ const providerKey = await makeKey();
192
+
193
+ const intent = await issueIntentMandate(
194
+ "did:aroha:user", "did:aroha:agent", BASE, userKey.priv
195
+ );
196
+ const cart = await attenuateToCart(intent, "did:aroha:provider", {
197
+ spendLimitUsd: 100,
198
+ allowedMerchants: ["merchant-a"],
199
+ }, userKey.priv);
200
+ const payment = await attenuateToPayment(cart, "did:aroha:processor", {
201
+ spendLimitUsd: 42,
202
+ allowedMerchants: ["merchant-a"],
203
+ }, userKey.priv);
204
+
205
+ expect(payment.mandate.mandateTier).toBe("payment");
206
+ expect(payment.mandate.parentMandateId).toBe(cart.mandate.mandateId);
207
+ expect(payment.mandate.constraints.spendLimitUsd).toBe(42);
208
+
209
+ const r = await verifyMandate(payment.token, userKey.pub, "did:aroha:processor");
210
+ expect(r.valid).toBe(true);
211
+ });
212
+ });
213
+
214
+ describe("assertNarrowing — merchantCategory", () => {
215
+ it("allows child to keep same merchantCategory", async () => {
216
+ const k = await makeKey();
217
+ const parent = await issueIntentMandate("did:aroha:g", "did:aroha:e", {
218
+ spendLimitUsd: 500,
219
+ merchantCategory: "travel",
220
+ }, k.priv);
221
+ await expect(
222
+ attenuateToCart(parent, "did:aroha:p", { spendLimitUsd: 100, merchantCategory: "travel" }, k.priv)
223
+ ).resolves.toBeDefined();
224
+ });
225
+
226
+ it("rejects child setting merchantCategory to null when parent restricts it", async () => {
227
+ const k = await makeKey();
228
+ const parent = await issueIntentMandate("did:aroha:g", "did:aroha:e", {
229
+ spendLimitUsd: 500,
230
+ merchantCategory: "travel",
231
+ }, k.priv);
232
+ await expect(
233
+ attenuateToCart(parent, "did:aroha:p", { spendLimitUsd: 100, merchantCategory: null }, k.priv)
234
+ ).rejects.toThrow(/merchantCategory/i);
235
+ });
236
+
237
+ it("rejects child setting different merchantCategory", async () => {
238
+ const k = await makeKey();
239
+ const parent = await issueIntentMandate("did:aroha:g", "did:aroha:e", {
240
+ spendLimitUsd: 500,
241
+ merchantCategory: "travel",
242
+ }, k.priv);
243
+ await expect(
244
+ attenuateToCart(parent, "did:aroha:p", { spendLimitUsd: 100, merchantCategory: "food" }, k.priv)
245
+ ).rejects.toThrow(/merchantCategory/i);
246
+ });
247
+ });
248
+
249
+ describe("assertNarrowing — validUntil / validFrom", () => {
250
+ const future = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString();
251
+
252
+ it("rejects child validUntil beyond parent validUntil", async () => {
253
+ const k = await makeKey();
254
+ const parent = await issueIntentMandate("did:aroha:g", "did:aroha:e", {
255
+ spendLimitUsd: 500,
256
+ validUntil: future(3_600_000), // 1 hour
257
+ }, k.priv);
258
+ await expect(
259
+ attenuateToCart(parent, "did:aroha:p", {
260
+ spendLimitUsd: 100,
261
+ validUntil: future(7_200_000), // 2 hours — wider
262
+ }, k.priv)
263
+ ).rejects.toThrow(/validUntil/i);
264
+ });
265
+
266
+ it("allows child validUntil within parent window", async () => {
267
+ const k = await makeKey();
268
+ const parent = await issueIntentMandate("did:aroha:g", "did:aroha:e", {
269
+ spendLimitUsd: 500,
270
+ validUntil: future(3_600_000),
271
+ }, k.priv);
272
+ await expect(
273
+ attenuateToCart(parent, "did:aroha:p", {
274
+ spendLimitUsd: 100,
275
+ validUntil: future(1_800_000), // 30 min — narrower
276
+ }, k.priv)
277
+ ).resolves.toBeDefined();
278
+ });
279
+
280
+ it("rejects child validFrom before parent validFrom", async () => {
281
+ const k = await makeKey();
282
+ const parent = await issueIntentMandate("did:aroha:g", "did:aroha:e", {
283
+ spendLimitUsd: 500,
284
+ validFrom: future(3_600_000),
285
+ }, k.priv);
286
+ await expect(
287
+ attenuateToCart(parent, "did:aroha:p", {
288
+ spendLimitUsd: 100,
289
+ validFrom: future(1_800_000), // starts earlier than parent — wider
290
+ }, k.priv)
291
+ ).rejects.toThrow(/validFrom/i);
292
+ });
293
+ });