@cef-ai/vault-sdk 3.3.0 → 4.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.
Files changed (43) hide show
  1. package/dist/client.d.ts +26 -2
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +40 -3
  4. package/dist/client.js.map +1 -1
  5. package/dist/core/canonical.d.ts +17 -1
  6. package/dist/core/canonical.d.ts.map +1 -1
  7. package/dist/core/canonical.js +26 -1
  8. package/dist/core/canonical.js.map +1 -1
  9. package/dist/core/endpoints.d.ts +5 -4
  10. package/dist/core/endpoints.d.ts.map +1 -1
  11. package/dist/core/endpoints.js +13 -4
  12. package/dist/core/endpoints.js.map +1 -1
  13. package/dist/core/grant-doc.d.ts +477 -0
  14. package/dist/core/grant-doc.d.ts.map +1 -0
  15. package/dist/core/grant-doc.js +768 -0
  16. package/dist/core/grant-doc.js.map +1 -0
  17. package/dist/core/types.d.ts +181 -49
  18. package/dist/core/types.d.ts.map +1 -1
  19. package/dist/fluent/vault.d.ts +6 -0
  20. package/dist/fluent/vault.d.ts.map +1 -1
  21. package/dist/fluent/vault.js +7 -0
  22. package/dist/fluent/vault.js.map +1 -1
  23. package/dist/index.d.ts +4 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +5 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal/memberships.d.ts +360 -0
  28. package/dist/internal/memberships.d.ts.map +1 -0
  29. package/dist/internal/memberships.js +403 -0
  30. package/dist/internal/memberships.js.map +1 -0
  31. package/dist/internal/vaults.d.ts +22 -0
  32. package/dist/internal/vaults.d.ts.map +1 -1
  33. package/dist/internal/vaults.js +11 -0
  34. package/dist/internal/vaults.js.map +1 -1
  35. package/dist/internal-exports.d.ts +2 -1
  36. package/dist/internal-exports.d.ts.map +1 -1
  37. package/dist/internal-exports.js +1 -1
  38. package/dist/internal-exports.js.map +1 -1
  39. package/package.json +2 -2
  40. package/dist/internal/orgs.d.ts +0 -59
  41. package/dist/internal/orgs.d.ts.map +0 -1
  42. package/dist/internal/orgs.js +0 -80
  43. package/dist/internal/orgs.js.map +0 -1
@@ -0,0 +1,768 @@
1
+ // src/core/grant-doc.ts
2
+ //
3
+ // The canonical grant document — the bytes a vault owner signs to say "this
4
+ // person holds this role and these scopes on this vault" — and the check that
5
+ // a served signature is theirs.
6
+ //
7
+ // This is the TypeScript mirror of ddc-node's
8
+ // `pkg/compute/vault/grantsig.go` — the implementation moved there from
9
+ // `internal/compute/vault/authz` when the orchestrator became its second
10
+ // reader, and `authz` is now a delegation shim, so the `authz.*` names used
11
+ // throughout these comments all forward to that file. Its whole reason to
12
+ // exist is
13
+ // that `GET /api/v1/vaults/:vaultId/roster/signed` is only worth serving if a
14
+ // party that is NOT vault-api can check it. If every consumer had to
15
+ // re-implement base64 → ed25519 → schema check by hand, most would check the
16
+ // signature and then read the response's own convenience fields, which is a
17
+ // decorative signature: verifying one thing and enforcing another.
18
+ //
19
+ // # Read role and scopes out of the DOCUMENT, never off the response
20
+ //
21
+ // `SignedGrant.role` and `SignedGrant.scopes` are a RENDERING of `doc` that
22
+ // vault-api provides for display. They are deliberately typed as plain
23
+ // strings here, not as `MemberRole`/`ScopeGrants`, so that feeding them
24
+ // straight into code that decides access is awkward rather than natural.
25
+ // `verifySignedRoster` returns `Grant`s decoded from the verified bytes; those
26
+ // are the values to act on.
27
+ //
28
+ // # The document is verified as received, never re-encoded
29
+ //
30
+ // `verifyGrantSig` checks the signature over the exact bytes it was handed.
31
+ // It does NOT rebuild them with `canonicalGrantBytes` and compare: a verifier
32
+ // that re-encodes is checking bytes it just composed itself, and it would
33
+ // start rejecting perfectly valid documents the moment this encoder and the
34
+ // server's drifted by a space. Go's `DecodeGrantDoc` is a reader for the same
35
+ // reason. `canonicalGrantBytes` exists for the SIGNING side (whoever mints a
36
+ // grant must produce bytes vault-api will accept) and for tests.
37
+ //
38
+ // # Mirroring Go's encoder exactly, in the two places it is not obvious
39
+ //
40
+ // 1. `encoding/json` escapes `<`, `>` and `&` as the six-character escapes
41
+ // `\u003c`, `\u003e`, `\u0026`
42
+ // (`SetEscapeHTML` defaults to true), plus U+2028/U+2029. `JSON.stringify`
43
+ // escapes none of them. A scope named `a<b` would otherwise produce
44
+ // different bytes on the two sides and a signature that never verifies.
45
+ // 2. Go sorts scope names as raw UTF-8 byte strings, which is code-point
46
+ // order. JavaScript's `<` compares UTF-16 code units, which disagrees for
47
+ // astral characters: `"😀" < "A"` is true in UTF-16 and false by code
48
+ // point. `compareCodePoints` below sorts the way Go does.
49
+ //
50
+ // 3. `issuedAt` and `expiresAt` are STRINGS carried verbatim, never `Date`s.
51
+ // Go's `encoding/json` would trim trailing zeros off a re-marshalled
52
+ // fraction (`…:56.100Z` → `…:56.1Z`), so neither side may re-format what
53
+ // the other signed. Whatever string the signer chose is what goes in the
54
+ // bytes.
55
+ //
56
+ // 4. `expiresAt` IS ALWAYS PRESENT, and "no expiry" is the EMPTY STRING
57
+ // (`NO_GRANT_EXPIRY`) rather than a dropped key or a `null`. One document
58
+ // shape, always: omitting the field would make a never-expires grant
59
+ // byte-identical to a document minted before the field existed, so every
60
+ // signature already stored would verify as a brand-new unlimited grant.
61
+ // See `NO_GRANT_EXPIRY`.
62
+ //
63
+ // Both are pinned by golden vectors produced by running the real Go function
64
+ // (see `grant-doc.test.ts`), not by reading its source and guessing.
65
+ //
66
+ // # A GRANT DOCUMENT IS AUTHORITY ONLY WHEN YOU FETCHED IT YOURSELF
67
+ //
68
+ // This mirrors `pkg/compute/vault/grantsig.go`'s rule, and it is the reason
69
+ // `verifySignedRoster` takes a whole roster rather than a loose grant.
70
+ //
71
+ // A verified signature proves the vault owner once signed these bytes. It does
72
+ // NOT prove the grant still stands: revocation deletes the membership row and
73
+ // cannot reach back and unsign a document somebody already saved. `issuedAt`
74
+ // does not change that — a timestamp bounds how OLD a document is, never
75
+ // whether it was WITHDRAWN.
76
+ //
77
+ // So: fetch the signed roster yourself, verify it, and act on what it
78
+ // contains. Absence from a freshly fetched roster is what revocation looks
79
+ // like, and it is the only signal that carries it. Accepting a document a
80
+ // caller HANDS you and authorising them on it is wrong in a way that passes
81
+ // every test — a revoked member's saved grant verifies forever — and checking
82
+ // that its `issuedAt` is recent does not fix it, it only narrows the window.
83
+ import * as ed from "@noble/ed25519";
84
+ // The `./blake2` SUBPATH, not the package root. Both work at runtime, but
85
+ // 14.0.3's root barrel does not re-export either blake2 name to
86
+ // TypeScript (`encodeAddress` and the rest come through fine), so the
87
+ // root import compiles nowhere. `./blake2` is a declared export subpath.
88
+ import { blake2AsHex } from "@polkadot/util-crypto/blake2";
89
+ import { decodeBase64, hexToBytes, strip0xHex } from "./canonical.js";
90
+ const enc = new TextEncoder();
91
+ const dec = new TextDecoder();
92
+ /**
93
+ * The schema string a CEILING-LESS grant carries inside its signed bytes
94
+ * (`authz.GrantSchemaV1`). Still emitted, still the common case: a grant that
95
+ * sets no privacy ceiling is encoded at v1, byte for byte as it always was,
96
+ * so every signature minted before v2 existed goes on verifying.
97
+ */
98
+ export const GRANT_SCHEMA = "cere.vault.grant.v1";
99
+ /**
100
+ * The schema string a ceiling-carrying grant carries (`authz.GrantSchemaV2`).
101
+ *
102
+ * A document is v2 if and only if it carries a privacy ceiling. There is no
103
+ * v2 spelling of "no ceiling" — that is v1, and giving it two spellings is
104
+ * the ambiguity `NO_GRANT_EXPIRY`'s comment spends a paragraph refusing.
105
+ *
106
+ * The version is a FUNCTION of the ceiling rather than an unconditional bump:
107
+ * bumping it always would change the bytes of every grant, including the
108
+ * overwhelming majority that sets no ceiling, which is a flag day on every
109
+ * stored signature. See `canonicalGrantBytesWithCeiling`.
110
+ */
111
+ export const GRANT_SCHEMA_V2 = "cere.vault.grant.v2";
112
+ /**
113
+ * The ONE spelling of "no privacy ceiling was ever signed for this
114
+ * membership" — and it is NOT a synonym for `"public"`.
115
+ *
116
+ * It means NOTHING VISIBLE, and that is a decision on the record: a caller
117
+ * whose ceiling is this sees no memory-graph row at all, because a ceiling
118
+ * nobody signed is not a ceiling. Picking `"internal"` as the default would
119
+ * invent an authorisation the vault owner never attested on the axis whose
120
+ * whole job is disclosure — the same ruling `NO_GRANT_EXPIRY` makes one field
121
+ * earlier, except that there the failure direction WIDENS an unsigned grant
122
+ * and here it NARROWS to zero, which is why failing closed is free.
123
+ *
124
+ * Selects {@link GRANT_SCHEMA} in the encoder: a grant carrying this is a v1
125
+ * document, byte for byte.
126
+ */
127
+ export const NO_PRIVACY_CEILING = "";
128
+ /**
129
+ * Every ceiling a grant may carry, least to most sensitive — Go's
130
+ * `validPrivacyCeilings`. {@link NO_PRIVACY_CEILING} is deliberately ABSENT:
131
+ * it is a legal decoded state (the document is v1) but never a value a caller
132
+ * may SET, and admitting `""` at the writer would give the state two
133
+ * spellings.
134
+ */
135
+ export const PRIVACY_CEILINGS = [
136
+ "public",
137
+ "internal",
138
+ "private",
139
+ "restricted",
140
+ ];
141
+ /** Is `c` a ceiling a v2 grant may carry? The WRITER-side guard. */
142
+ export function isValidPrivacyCeiling(c) {
143
+ return PRIVACY_CEILINGS.includes(c);
144
+ }
145
+ /** Renders the valid ceilings for an error, as Go's `PrivacyCeilingsList`. */
146
+ function privacyCeilingsList() {
147
+ const quoted = PRIVACY_CEILINGS.map((c) => `"${c}"`);
148
+ return quoted.slice(0, -1).join(", ") + " or " + quoted[quoted.length - 1];
149
+ }
150
+ /** Tags a string as a privacy ceiling. The parameter type is the literal
151
+ * union, so a misspelling is a compile error and an as-yet-unknown ceiling
152
+ * from a newer peer has to be cast deliberately. */
153
+ export function privacyCeiling(c) {
154
+ return c;
155
+ }
156
+ /**
157
+ * The header a membership write carries the vault owner's grant signature in
158
+ * (`authz.GrantSignatureHeader`), hex, with or without an `0x` prefix.
159
+ *
160
+ * A HEADER OF ITS OWN, not the wallet auth's `X-Signature`, because the two
161
+ * cover different bytes: `X-Signature` covers the request BODY and
162
+ * authenticates the CALLER (who may be an admin acting for the owner), while
163
+ * this covers the canonical grant document and attests the OWNER's
164
+ * authorisation of that exact grant. The body of `PUT .../members` is
165
+ * `{"role":…,"timestamp":…}` — the vault id and the member's key are URL
166
+ * parameters and never enter the signed bytes — so one owner-signed
167
+ * `{"role":"admin",…}` would otherwise verify identically for every member of
168
+ * every vault that owner holds.
169
+ */
170
+ export const GRANT_SIGNATURE_HEADER = "X-Grant-Signature";
171
+ /**
172
+ * The header carrying the `issuedAt` the signed document was minted with
173
+ * (`pkgvault.GrantIssuedAtHeader`).
174
+ *
175
+ * vault-api does not RECEIVE the grant document, it REBUILDS it — from the URL
176
+ * parameters, the body, and the other half read back from its store — and then
177
+ * checks your signature over what it built. Every field must therefore be
178
+ * something the server can reproduce, and a mint time is the one thing it
179
+ * cannot: a server-chosen `now` would never equal yours. So it travels beside
180
+ * the signature.
181
+ *
182
+ * NOT the body's `timestamp`, even though every mutation body already carries
183
+ * one and the wallet middleware already bounds its skew. The signer and the
184
+ * sender are different parties: the route admits an admin, but the document
185
+ * must be signed by the VAULT's wallet, so an admin presents a grant their
186
+ * owner signed out of band. Binding the mint time to the request body would
187
+ * make the owner predict the admin's exact timestamp string, and the skew
188
+ * window would expire their pre-signed grant in five minutes.
189
+ */
190
+ export const GRANT_ISSUED_AT_HEADER = "X-Grant-Issued-At";
191
+ /**
192
+ * `auth.CanonicalPubKeyHex`, reproduced: `0x`-prefixed lowercase hex, or the
193
+ * input UNCHANGED when it is not hex at all.
194
+ *
195
+ * THE SERVER APPLIES THIS TO THE URL PARAMETER BEFORE IT BUILDS THE DOCUMENT
196
+ * (`memberPubkeyParam`), so a signer that skips it produces a perfectly valid
197
+ * signature over bytes the server never derives — the failure that presents
198
+ * as a key problem rather than a spelling one. Callers have always been
199
+ * allowed to pass a bare or upper-case key (the store canonicalises on the
200
+ * way in), so signing their exact spelling would break the writes that work
201
+ * today.
202
+ *
203
+ * A non-hex value passes through untouched, exactly as Go's does: a malformed
204
+ * key must reach the server as the caller wrote it and be refused there,
205
+ * rather than be silently rewritten into a different key here.
206
+ */
207
+ export function canonicalPubkeyHex(s) {
208
+ // `normalizeHexKey`: TrimPrefix "0x" THEN TrimPrefix "0X" — one each, in
209
+ // that order — then lower-case. Reproduced literally rather than as one
210
+ // regex so the two implementations can be read side by side.
211
+ let bare = s.startsWith("0x") ? s.slice(2) : s;
212
+ if (bare.startsWith("0X"))
213
+ bare = bare.slice(2);
214
+ bare = bare.toLowerCase();
215
+ // `bare == ""` and a `hex.DecodeString` failure — which includes an
216
+ // ODD-LENGTH string — both return the input unchanged.
217
+ if (bare === "" || bare.length % 2 !== 0 || !/^[0-9a-f]+$/.test(bare))
218
+ return s;
219
+ return `0x${bare}`;
220
+ }
221
+ /**
222
+ * The vault id a wallet key owns — `pkgvault.IDFor`, reproduced.
223
+ *
224
+ * `"v-"` + blake2b-128 of the key's CANONICAL HEX SPELLING, as 32 lowercase
225
+ * hex characters.
226
+ *
227
+ * # This is a one-way function, and that is the whole point
228
+ *
229
+ * It runs pubkey -> id. It does NOT run backwards, and no amount of holding
230
+ * an id recovers the key that made it: blake2b-128 is a hash. What a caller
231
+ * holding only a vault id CAN do is the inverse QUESTION rather than the
232
+ * inverse function — take a candidate key and ask whether it derives that id.
233
+ * That single check is what turns `verifySignedRoster` from a consistency
234
+ * test into a verification, because the id came from outside the response
235
+ * and the key inside it now has to match something the attacker cannot
236
+ * choose.
237
+ *
238
+ * # Two ways to get this wrong, both of which produce a plausible id
239
+ *
240
+ * 1. The key is hashed as its hex TEXT (Go writes `[]byte(walletPubkeyHex)`),
241
+ * never as the 32 bytes that text denotes. `@polkadot`'s `blake2AsHex`
242
+ * decodes a `0x`-prefixed STRING as hex, so it must be handed encoded
243
+ * bytes — passing the spelling straight in silently hashes the wrong
244
+ * input.
245
+ * 2. Because the text is the input, the SPELLING is part of it: `deadbeef`,
246
+ * `0xdeadbeef` and `0xDEADBEEF` are three different vault ids in Go.
247
+ * Callers have always been allowed any of those spellings, so the
248
+ * canonical form is applied first — the same rule the server derives with.
249
+ *
250
+ * Both are pinned by golden vectors taken from the running Go function.
251
+ */
252
+ export function vaultIdFor(walletPubkeyHex) {
253
+ const canonical = canonicalPubkeyHex(walletPubkeyHex);
254
+ // `enc.encode` first — see (1) above; `blake2AsHex` returns `0x`-prefixed
255
+ // hex where Go's `hex.EncodeToString` does not, hence the slice.
256
+ return `v-${blake2AsHex(enc.encode(canonical), 128).slice(2)}`;
257
+ }
258
+ /**
259
+ * The ONE spelling of "this membership does not expire": the empty string,
260
+ * always emitted, never an absent key and never `null`.
261
+ *
262
+ * A FIXED SENTINEL so the canonical document has exactly one shape. Two shapes
263
+ * is how a signature comes to verify against bytes the other side never
264
+ * builds, and here conditional omission would be worse than ambiguous: a
265
+ * document that DROPPED `expiresAt` when there is no expiry is byte-identical
266
+ * to a document minted before the field existed, so every signature already
267
+ * stored would verify as a brand-new never-expires grant. Emitting `""` makes
268
+ * those older bytes unbuildable, which is the version gate this change would
269
+ * otherwise have to spend a new {@link GRANT_SCHEMA} string on.
270
+ *
271
+ * The empty string also cannot be mistaken for a moment — no RFC 3339 spelling
272
+ * is empty — so, unlike a far-future sentinel instant, nothing downstream can
273
+ * parse it into a date and silently act on the year 9999.
274
+ */
275
+ export const NO_GRANT_EXPIRY = "";
276
+ /**
277
+ * Tags a timestamp string as the grant's expiry.
278
+ *
279
+ * It does NOT validate: the string is carried into the signed bytes verbatim
280
+ * and neither side parses or re-formats it, so a check here would be a second
281
+ * opinion about a value the server rebuilds from what you sent. vault-api DOES
282
+ * validate — `PUT …/members` 400s an `expiresAt` that is not RFC 3339, because
283
+ * that value also has to reach a `timestamptz` column — so a malformed expiry
284
+ * is refused at the one place that can refuse it usefully.
285
+ *
286
+ * Pass the SAME string you put in the request body. Formatting a `Date` twice
287
+ * is how the two copies drift by a trimmed fractional zero.
288
+ */
289
+ export function grantExpiry(rfc3339) {
290
+ return rfc3339;
291
+ }
292
+ /**
293
+ * A signed grant that could not be trusted: the signature does not verify,
294
+ * the document does not decode, its schema is unknown, or it describes a
295
+ * different membership from the one it was served under.
296
+ *
297
+ * THROWN by {@link verifyGrant}, where one grant IS the whole question and a
298
+ * failure must stop the caller. REPORTED (and the entry skipped) by
299
+ * {@link verifySignedRoster}, where the same failure on one entry used to
300
+ * fail the whole roster for every other member — see that function's doc
301
+ * comment for the outage that ruling turned out to be. `memberPubkey` names
302
+ * the entry so an operator can find it.
303
+ */
304
+ export class GrantVerificationError extends Error {
305
+ memberPubkey;
306
+ constructor(message,
307
+ /**
308
+ * The member the failure is about, or `""` when it is about the ENVELOPE
309
+ * — a missing anchor, a roster for the wrong vault, a malformed shape —
310
+ * which belongs to no single entry.
311
+ */
312
+ memberPubkey = "") {
313
+ super(message);
314
+ this.memberPubkey = memberPubkey;
315
+ this.name = "GrantVerificationError";
316
+ }
317
+ }
318
+ /**
319
+ * The exact bytes a CEILING-LESS grant signature covers — v1, byte for byte
320
+ * as `authz.CanonicalGrantBytes` has always emitted them. Delegates to
321
+ * {@link canonicalGrantBytesWithCeiling}; see that function for the full
322
+ * contract, and this file's header for why the version is a function of the
323
+ * ceiling rather than an unconditional bump.
324
+ */
325
+ export function canonicalGrantBytes(vaultId, memberPubkey, role, scopes, issuedAt, expiresAt) {
326
+ return canonicalGrantBytesWithCeiling(vaultId, memberPubkey, role, scopes, issuedAt, expiresAt, NO_PRIVACY_CEILING);
327
+ }
328
+ /**
329
+ * The encoder, mirroring `authz.CanonicalGrantBytesWithCeiling`. Emits
330
+ * {@link GRANT_SCHEMA} when the grant sets no privacy ceiling and
331
+ * {@link GRANT_SCHEMA_V2} when it sets one.
332
+ *
333
+ * Every bound value is inside the output: a document proves ONE member's
334
+ * standing on ONE vault and nothing else. `scopes` is emitted as an ARRAY
335
+ * sorted by name — not as an object — because the ordering is then this
336
+ * function's own step and can be tested, where a JSON object would have the
337
+ * encoder sort the keys and a stability test would pass whatever this code
338
+ * did.
339
+ *
340
+ * An incomplete grant throws rather than producing shorter bytes: a document
341
+ * naming no vault or no member would verify perfectly and authorise nobody in
342
+ * particular. `issuedAt` is required on the same grounds — an empty one is a
343
+ * document with no mint time that verifies exactly as well as one with a mint
344
+ * time.
345
+ *
346
+ * `privacyCeiling` is emitted LAST, after `expiresAt`, and ONLY when set —
347
+ * `omitempty` in Go, an append here — so a ceiling-less grant is
348
+ * byte-identical to a v1 document and every signature already stored goes on
349
+ * matching what this encoder rebuilds.
350
+ *
351
+ * The ceiling is REFUSED, never coerced: an unrecognised value throws, because
352
+ * a document that verifies perfectly and confers a ceiling nobody named is,
353
+ * on the disclosure axis, the one output this function must never hand back.
354
+ * {@link NO_PRIVACY_CEILING} is the single exception and is not a value: it
355
+ * selects v1.
356
+ *
357
+ * `issuedAt` has NO DEFAULT on purpose: a `new Date().toISOString()` fallback
358
+ * would be a hidden clock inside the encoder, and a caller who forgot to pass
359
+ * one would silently sign a different moment from the one they sent. `scopes`
360
+ * loses its `= {}` default with it — a default that can never be used, because
361
+ * a required parameter follows, reads as an option and is not one. `expiresAt`
362
+ * has none for the sharper version of the same reason: its default would be
363
+ * "never expires", the one wrong answer that looks right.
364
+ *
365
+ * `expiresAt` is the ONE parameter with no emptiness check, because `""` is
366
+ * {@link NO_GRANT_EXPIRY} — a legal, common, deliberate value rather than a
367
+ * missing one. `ceiling` shares the exemption for the same reason.
368
+ */
369
+ export function canonicalGrantBytesWithCeiling(vaultId, memberPubkey, role, scopes, issuedAt, expiresAt, privacyCeiling) {
370
+ if (vaultId === "")
371
+ throw new Error("canonical grant: vaultId is required");
372
+ if (memberPubkey === "")
373
+ throw new Error("canonical grant: memberPubkey is required");
374
+ if (role === "")
375
+ throw new Error("canonical grant: role is required");
376
+ if (issuedAt === "")
377
+ throw new Error("canonical grant: issuedAt is required");
378
+ // The version gate, and the writer-side refusal, in one place: anything
379
+ // that is neither "no ceiling" nor a recognised class stops here.
380
+ let schema = GRANT_SCHEMA;
381
+ if (privacyCeiling !== NO_PRIVACY_CEILING) {
382
+ if (!isValidPrivacyCeiling(privacyCeiling)) {
383
+ throw new Error(`canonical grant: privacyCeiling ${JSON.stringify(privacyCeiling)} must be ${privacyCeilingsList()}`);
384
+ }
385
+ schema = GRANT_SCHEMA_V2;
386
+ }
387
+ const names = Object.keys(scopes).sort(compareCodePoints);
388
+ const parts = names.map((name) => `{"name":${goString(name)},"level":${goString(scopes[name])}}`);
389
+ // Hand-assembled rather than `JSON.stringify`d because the field ORDER is
390
+ // the wire contract (Go emits struct fields in declaration order) and
391
+ // because `goString` has to do the HTML escaping `JSON.stringify` will not.
392
+ const doc = `{"schema":${goString(schema)},` +
393
+ `"vaultId":${goString(vaultId)},` +
394
+ `"memberPubkey":${goString(memberPubkey)},` +
395
+ `"role":${goString(role)},` +
396
+ `"scopes":[${parts.join(",")}],` +
397
+ `"issuedAt":${goString(issuedAt)},` +
398
+ `"expiresAt":${goString(expiresAt)}` +
399
+ // omitempty: the ceiling key is present in a v2 document ONLY. Appending
400
+ // it unconditionally would give v1 a second spelling and break every
401
+ // stored signature's rebuild.
402
+ (privacyCeiling === NO_PRIVACY_CEILING ? "" : `,"privacyCeiling":${goString(privacyCeiling)}`) +
403
+ "}";
404
+ return enc.encode(doc);
405
+ }
406
+ /**
407
+ * Reads a canonical grant document back into its parts, mirroring
408
+ * `authz.DecodeGrantDoc`. Never re-encodes; the bytes stay the authority.
409
+ *
410
+ * Accepts BOTH current schemas — {@link GRANT_SCHEMA} and
411
+ * {@link GRANT_SCHEMA_V2} — with the field that distinguishes them checked in
412
+ * both directions (a v1 document carrying a `privacyCeiling` is a forgery
413
+ * shape; a v2 document without a recognised one has given "no ceiling" a
414
+ * second spelling). Throws on any other schema and on the same three required
415
+ * fields the encoder refuses to omit.
416
+ */
417
+ export function decodeGrantDoc(doc) {
418
+ if (doc.length === 0)
419
+ throw new Error("decode grant: empty document");
420
+ let raw;
421
+ try {
422
+ raw = JSON.parse(dec.decode(doc));
423
+ }
424
+ catch (err) {
425
+ throw new Error(`decode grant: ${err instanceof Error ? err.message : String(err)}`);
426
+ }
427
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
428
+ throw new Error("decode grant: document is not a JSON object");
429
+ }
430
+ const obj = raw;
431
+ const schema = stringField(obj, "schema");
432
+ // THE VERSION GATE, AND IT IS TWO-WAY — mirroring `DecodeGrantDoc`'s switch.
433
+ //
434
+ // It used to be one equality against one constant. Two schema strings are
435
+ // current, and the field that distinguishes them must be checked in BOTH
436
+ // directions, because a JSON reader ignores unknown keys by default:
437
+ //
438
+ // - a v1 document carrying a privacyCeiling key would otherwise decode
439
+ // as a clean v1 grant with the field silently dropped. That is a
440
+ // forgery shape — the owner attested v1 semantics and somebody
441
+ // appended a field — and dropping it quietly is how a reader comes to
442
+ // disagree with the bytes it verified.
443
+ // - a v2 document with no usable ceiling would otherwise decode to
444
+ // NO_PRIVACY_CEILING, giving "no ceiling" a second spelling under a
445
+ // schema string that promises one.
446
+ //
447
+ // Refusing both is what keeps the schema string an honest description of
448
+ // the bytes rather than a label. Note the emptiness (not presence) check
449
+ // on v1: Go unmarshals `"privacyCeiling":""` into the empty string and
450
+ // treats it as absent, so a v1 document with an EMPTY ceiling key decodes
451
+ // as v1 here too — matching the bytes Go accepts, not a stricter rule.
452
+ const rawCeiling = stringField(obj, "privacyCeiling", true);
453
+ if (schema === GRANT_SCHEMA) {
454
+ if (rawCeiling !== "") {
455
+ throw new Error(`decode grant: schema ${JSON.stringify(schema)} carries a privacyCeiling; that is ${JSON.stringify(GRANT_SCHEMA_V2)}`);
456
+ }
457
+ }
458
+ else if (schema === GRANT_SCHEMA_V2) {
459
+ if (!isValidPrivacyCeiling(rawCeiling)) {
460
+ throw new Error(`decode grant: schema ${JSON.stringify(schema)} requires a privacyCeiling of ${privacyCeilingsList()}, got ${JSON.stringify(rawCeiling)}`);
461
+ }
462
+ }
463
+ else {
464
+ throw new Error(`decode grant: schema ${JSON.stringify(schema)} is not ${JSON.stringify(GRANT_SCHEMA)} or ${JSON.stringify(GRANT_SCHEMA_V2)}`);
465
+ }
466
+ const vaultId = stringField(obj, "vaultId");
467
+ const memberPubkey = stringField(obj, "memberPubkey");
468
+ const role = stringField(obj, "role");
469
+ if (vaultId === "" || memberPubkey === "" || role === "") {
470
+ throw new Error("decode grant: vaultId, memberPubkey and role are all required");
471
+ }
472
+ // `scopes` is an array in the document and a map here, exactly as Go's
473
+ // decoder does it: the array exists so the ENCODER's ordering is a testable
474
+ // step, and a decoder has no ordering left to prove — every consumer of a
475
+ // decoded grant asks "what level on scope X", which is a map question.
476
+ const rawScopes = obj.scopes;
477
+ if (!Array.isArray(rawScopes)) {
478
+ throw new Error("decode grant: scopes must be an array");
479
+ }
480
+ const scopes = {};
481
+ for (const entry of rawScopes) {
482
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
483
+ throw new Error("decode grant: a scope entry is not an object");
484
+ }
485
+ const name = stringField(entry, "name");
486
+ if (name === "")
487
+ throw new Error("decode grant: a scope with no name");
488
+ scopes[name] = stringField(entry, "level");
489
+ }
490
+ // LAST, because the fields are checked in WIRE ORDER and `issuedAt` is the
491
+ // last field of the document. Required exactly as Go's decoder requires it:
492
+ // a document predating the field is REFUSED rather than passed on as a
493
+ // grant with no mint time.
494
+ const issuedAt = stringField(obj, "issuedAt");
495
+ if (issuedAt === "")
496
+ throw new Error("decode grant: issuedAt is required");
497
+ // PRESENCE, not emptiness, and the asymmetry with `issuedAt` above is the
498
+ // point: `""` is NO_GRANT_EXPIRY, a legal value most documents carry, while
499
+ // an ABSENT key is a document minted before the field existed — one whose
500
+ // signer attested a standing and never its duration. `stringField` already
501
+ // refuses absent ("got nothing") and `null` ("got object"), which is exactly
502
+ // what Go's `raw.ExpiresAt == nil` refuses; an `=== ""` guard here would make
503
+ // "this membership never expires" undecodable.
504
+ const expiresAt = stringField(obj, "expiresAt");
505
+ // v1 decodes to NO_PRIVACY_CEILING — the version gate above has already
506
+ // proved the key was absent-or-empty for v1 and valid for v2.
507
+ const ceiling = (schema === GRANT_SCHEMA_V2 ? rawCeiling : "");
508
+ return { schema, vaultId, memberPubkey, role, scopes, issuedAt, expiresAt, privacyCeiling: ceiling };
509
+ }
510
+ /**
511
+ * Is `sigHex` `orgWalletPubkeyHex`'s ed25519 signature over `doc`?
512
+ *
513
+ * A boolean, not a throw, because nothing here can be undecided: both inputs
514
+ * are in hand and the check is arithmetic. Malformed inputs (a key or
515
+ * signature of the wrong length, non-hex) are `false` rather than an
516
+ * exception, mirroring `authz.VerifyGrantSig` — the caller that needs to tell
517
+ * "nothing was sent" from "it does not verify" looks at the inputs itself.
518
+ *
519
+ * Hex is accepted with or without an `0x` prefix, in either case, because
520
+ * wallets and SDKs disagree about both.
521
+ */
522
+ export async function verifyGrantSig(orgWalletPubkeyHex, doc, sigHex) {
523
+ if (doc.length === 0 || sigHex === "")
524
+ return false;
525
+ let pub;
526
+ let sig;
527
+ try {
528
+ pub = hexToBytes(strip0xHex(orgWalletPubkeyHex).toLowerCase());
529
+ sig = hexToBytes(strip0xHex(sigHex).toLowerCase());
530
+ }
531
+ catch {
532
+ return false;
533
+ }
534
+ if (pub.length !== 32 || sig.length !== 64)
535
+ return false;
536
+ try {
537
+ return await ed.verifyAsync(sig, doc, pub);
538
+ }
539
+ catch {
540
+ // @noble throws on a point that is not on the curve; that is a failed
541
+ // verification, not a caller error.
542
+ return false;
543
+ }
544
+ }
545
+ /**
546
+ * Verifies EVERY grant in a signed roster against the vault owner's key and
547
+ * returns the grants decoded from the verified bytes.
548
+ *
549
+ * This is the whole point of the signed roster: after this call you hold
550
+ * values that came out of bytes the vault owner signed, and you can throw the
551
+ * response away. Enforce on the returned `Grant`s — never on
552
+ * `SignedGrant.role` / `SignedGrant.scopes`, which are vault-api's rendering
553
+ * of the same document and are not covered by anything you checked.
554
+ *
555
+ * Three bindings are checked per entry, and the last two matter as much as
556
+ * the signature: the document must carry a schema this SDK understands, it
557
+ * must name the vault the roster is FOR, and it must name the member it was
558
+ * served UNDER. A document that verifies but describes a different membership
559
+ * is a valid signature over somebody else's grant — the substitution the
560
+ * canonical bytes bind all four values to prevent.
561
+ *
562
+ * # One unverifiable entry degrades the ENTRY, not the roster
563
+ *
564
+ * An entry that fails any of its checks is skipped and reported to
565
+ * {@link RosterVerifyOptions.onUnverifiableGrant} (when given) rather than
566
+ * thrown, and the remaining entries are returned. This is deliberate, and it
567
+ * is the second half of the condition ddc-node's `allowV2GrantMint` hold
568
+ * names for lifting: the loop this replaces threw on the FIRST bad entry, so
569
+ * one grant a consumer could not read — a ceiling-carrying v2 under 4.0.0, a
570
+ * future v3, a corrupt row — made the WHOLE roster fail verification for
571
+ * every member of that vault. An outage with a bystander list that long is
572
+ * not fail-closed, it is fail-everyone-else's.
573
+ *
574
+ * The envelope is still fail-closed: a roster naming the wrong vault, a
575
+ * signing key that does not derive the vault, or a malformed roster shape
576
+ * throws `GrantVerificationError` and NOTHING is returned — until the anchor
577
+ * holds, every entry is attacker-supplied, and there is no per-entry question
578
+ * to degrade to.
579
+ *
580
+ * SILENT SHORTENING IS THE RESIDUAL RISK, and the callback is the mitigation:
581
+ * a caller that ignores `onUnverifiableGrant` sees a shorter roster that
582
+ * looks complete. Treat a reported member as UNPROVEN — absence from the
583
+ * returned list means "no verified standing", which for authorization means
584
+ * deny, not "not in the vault".
585
+ *
586
+ * # `expectedVaultId` is the trust anchor, and it is why this is a
587
+ * # verification rather than a consistency check
588
+ *
589
+ * PASS THE ID YOU ASKED FOR — the same one handed to
590
+ * `sdk.memberships.signedRoster()`. It is required, and the requirement is
591
+ * the fix for the hole it closes:
592
+ *
593
+ * Every other value used here arrives INSIDE the response. Given only a
594
+ * roster, "verify it" can mean no more than "does this agree with itself?" —
595
+ * and it always does, because whoever composed it chose every field. Anyone
596
+ * able to answer on this address could sign a roster with a key of their own,
597
+ * name that key as `orgWalletPubkey`, and watch it verify.
598
+ *
599
+ * One externally-held value breaks the circle. `vaultIdFor` is a one-way
600
+ * derivation, so a key that derives `expectedVaultId` cannot be picked; it
601
+ * can only be the vault's actual owner. The two checks are therefore:
602
+ *
603
+ * 1. the roster must be FOR the vault asked about (`roster.vaultId`), which
604
+ * is the binding the two separate calls otherwise leave open — a
605
+ * response about somebody else's vault used to verify perfectly; and
606
+ * 2. `orgWalletPubkey` must DERIVE that vault, which pins the signing key
607
+ * to an identity the response did not get to choose.
608
+ *
609
+ * Both run BEFORE any signature is checked, mirroring
610
+ * `RosterAuthority.verifyRoster` in ddc-node: until the anchor holds, every
611
+ * byte of the envelope is attacker-supplied, including the key the
612
+ * signatures would be checked against.
613
+ *
614
+ * A caller who holds the OWNER KEY rather than the id needs nothing extra —
615
+ * `vaultIdFor(ownerKey)` is the id. That direction works; the reverse does
616
+ * not exist.
617
+ *
618
+ * This anchors the roster to a vault, which is all a hash can do. It does not
619
+ * tell you the id itself is the vault you meant: if `expectedVaultId` was
620
+ * itself learned from an untrusted party, the result is trust in THAT party's
621
+ * choice of vault. Anchor it to something you already knew.
622
+ */
623
+ export async function verifySignedRoster(roster, expectedVaultId, options = {}) {
624
+ if (typeof expectedVaultId !== "string" || expectedVaultId === "") {
625
+ throw new GrantVerificationError("expectedVaultId is required: verifying a roster against the vault id it " +
626
+ "carries only proves the response agrees with itself");
627
+ }
628
+ // The envelope is checked as SHAPE before anything is read out of it. A
629
+ // `grants: null` used to reach the `for` loop and surface as a bare
630
+ // TypeError — an error the caller cannot distinguish from a bug in their
631
+ // own code, on a path whose entire job is to report untrustworthy input.
632
+ if (typeof roster !== "object" || roster === null) {
633
+ throw new GrantVerificationError(`signed roster is not an object (got ${typeOf(roster)})`);
634
+ }
635
+ if (typeof roster.vaultId !== "string" || roster.vaultId === "") {
636
+ throw new GrantVerificationError("signed roster carries no vaultId");
637
+ }
638
+ if (typeof roster.orgWalletPubkey !== "string" || roster.orgWalletPubkey === "") {
639
+ throw new GrantVerificationError("signed roster carries no orgWalletPubkey");
640
+ }
641
+ if (!Array.isArray(roster.grants)) {
642
+ throw new GrantVerificationError(`signed roster's grants is not an array (got ${typeOf(roster.grants)})`);
643
+ }
644
+ // THE ANCHOR, both halves, before a single signature is checked.
645
+ if (roster.vaultId !== expectedVaultId) {
646
+ throw new GrantVerificationError(`signed roster names vault ${roster.vaultId}, asked about ${expectedVaultId}`);
647
+ }
648
+ if (vaultIdFor(roster.orgWalletPubkey) !== expectedVaultId) {
649
+ throw new GrantVerificationError(`orgWalletPubkey ${roster.orgWalletPubkey} does not derive vault ${expectedVaultId}`);
650
+ }
651
+ const out = [];
652
+ for (const grant of roster.grants) {
653
+ // A non-object entry is an ENTRY-level malformation — it belongs to no
654
+ // member in particular, but it is one row's bytes that are wrong, not the
655
+ // envelope's, so it degrades like any other unverifiable entry rather
656
+ // than poisoning the roster.
657
+ if (typeof grant !== "object" || grant === null) {
658
+ const err = new GrantVerificationError(`signed roster holds a grant that is not an object`);
659
+ options.onUnverifiableGrant?.(err);
660
+ continue;
661
+ }
662
+ try {
663
+ out.push(await verifyGrant(expectedVaultId, roster.orgWalletPubkey, grant));
664
+ }
665
+ catch (err) {
666
+ if (!(err instanceof GrantVerificationError))
667
+ throw err;
668
+ // DEGRADE THE ENTRY, NOT THE ROSTER — see the doc comment above for why
669
+ // this replaced the first-bad-entry throw, and what a caller owes the
670
+ // members reported here.
671
+ options.onUnverifiableGrant?.(err);
672
+ }
673
+ }
674
+ return out;
675
+ }
676
+ /** `typeof`, but naming `null` and arrays, for a message a caller can act on. */
677
+ function typeOf(v) {
678
+ if (v === null)
679
+ return "null";
680
+ return Array.isArray(v) ? "an array" : typeof v;
681
+ }
682
+ /**
683
+ * One entry of `verifySignedRoster`, exposed for a caller holding a single
684
+ * grant (a member proving their own standing, say) rather than a whole
685
+ * roster. Same bindings, same fail-closed throw.
686
+ *
687
+ * `vaultId` and `orgWalletPubkey` must CORRESPOND — the same anchor
688
+ * `verifySignedRoster` applies, checked here too because both arguments are
689
+ * the caller's and the natural way to fill them is straight off a response,
690
+ * which is exactly the unanchored call this check refuses.
691
+ */
692
+ export async function verifyGrant(vaultId, orgWalletPubkey, grant) {
693
+ if (vaultIdFor(orgWalletPubkey) !== vaultId) {
694
+ throw new GrantVerificationError(`orgWalletPubkey ${orgWalletPubkey} does not derive vault ${vaultId}`, grant.memberPubkey);
695
+ }
696
+ let doc;
697
+ try {
698
+ doc = decodeBase64(grant.doc);
699
+ }
700
+ catch (err) {
701
+ throw new GrantVerificationError(`grant document is not valid base64: ${err instanceof Error ? err.message : String(err)}`, grant.memberPubkey);
702
+ }
703
+ if (!(await verifyGrantSig(orgWalletPubkey, doc, grant.sig))) {
704
+ throw new GrantVerificationError(`grant document is not signed by the vault owner (${orgWalletPubkey})`, grant.memberPubkey);
705
+ }
706
+ let decoded;
707
+ try {
708
+ decoded = decodeGrantDoc(doc);
709
+ }
710
+ catch (err) {
711
+ throw new GrantVerificationError(err instanceof Error ? err.message : String(err), grant.memberPubkey);
712
+ }
713
+ if (decoded.vaultId !== vaultId || decoded.memberPubkey !== grant.memberPubkey) {
714
+ throw new GrantVerificationError(`grant document describes a different membership: it names ` +
715
+ `${decoded.memberPubkey} on ${decoded.vaultId}, and was served as ` +
716
+ `${grant.memberPubkey} on ${vaultId}`, grant.memberPubkey);
717
+ }
718
+ return decoded;
719
+ }
720
+ /**
721
+ * A required string field, or a typed failure naming it. With `absentOk`, an
722
+ * ABSENT key reads as `""` — for `privacyCeiling`, whose absence is v1's
723
+ * whole spelling and whose empty-string presence Go's decoder treats the
724
+ * same way. A present-but-non-string value still fails either way.
725
+ */
726
+ function stringField(obj, key, absentOk = false) {
727
+ const v = obj[key];
728
+ if (v === undefined && absentOk)
729
+ return "";
730
+ if (typeof v !== "string") {
731
+ throw new Error(`decode grant: ${key} must be a string, got ${v === undefined ? "nothing" : typeof v}`);
732
+ }
733
+ return v;
734
+ }
735
+ /**
736
+ * `encoding/json`'s string encoding, which `JSON.stringify` does not
737
+ * reproduce: `<`, `>`, `&` and U+2028/U+2029 are escaped. Everything else is
738
+ * left to `JSON.stringify`, so the two agree on quotes, control characters
739
+ * and lone surrogates.
740
+ */
741
+ function goString(s) {
742
+ return JSON.stringify(s)
743
+ .replace(/</g, "\\u003c")
744
+ .replace(/>/g, "\\u003e")
745
+ .replace(/&/g, "\\u0026")
746
+ .replace(/\u2028/g, "\\u2028")
747
+ .replace(/\u2029/g, "\\u2029");
748
+ }
749
+ /**
750
+ * Sorts the way Go sorts strings — by UTF-8 bytes, which is code-point order.
751
+ * JavaScript's `<` compares UTF-16 code units and disagrees for astral
752
+ * characters, so `[...]sort()` would order a scope named "😀" (U+1F600)
753
+ * BEFORE one named "A" (U+FF21) while Go puts it after, and the signature
754
+ * over the resulting bytes would never verify.
755
+ */
756
+ function compareCodePoints(a, b) {
757
+ const ai = Array.from(a);
758
+ const bi = Array.from(b);
759
+ const n = Math.min(ai.length, bi.length);
760
+ for (let i = 0; i < n; i++) {
761
+ const x = ai[i].codePointAt(0);
762
+ const y = bi[i].codePointAt(0);
763
+ if (x !== y)
764
+ return x - y;
765
+ }
766
+ return ai.length - bi.length;
767
+ }
768
+ //# sourceMappingURL=grant-doc.js.map