run402 4.69.8 → 4.70.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 (40) hide show
  1. package/README.md +3 -1
  2. package/gitvault-surface.json +5 -1
  3. package/lib/command-manifest.mjs +2 -0
  4. package/lib/gitvault-capabilities.mjs +6 -0
  5. package/lib/harness-context.mjs +50 -0
  6. package/lib/messages.mjs +83 -1
  7. package/lib/repos.mjs +313 -9
  8. package/lib/rooms-context.mjs +23 -13
  9. package/package.json +1 -1
  10. package/sdk/dist/errors.d.ts +1 -1
  11. package/sdk/dist/errors.d.ts.map +1 -1
  12. package/sdk/dist/errors.js.map +1 -1
  13. package/sdk/dist/namespaces/gitvault.d.ts +223 -0
  14. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  15. package/sdk/dist/namespaces/gitvault.js +609 -2
  16. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  17. package/sdk/dist/namespaces/rooms.d.ts +20 -1
  18. package/sdk/dist/namespaces/rooms.d.ts.map +1 -1
  19. package/sdk/dist/namespaces/rooms.js +93 -0
  20. package/sdk/dist/namespaces/rooms.js.map +1 -1
  21. package/sdk/dist/namespaces/rooms.types.d.ts +73 -0
  22. package/sdk/dist/namespaces/rooms.types.d.ts.map +1 -1
  23. package/sdk/dist/node/gitvault-address.d.ts +1 -0
  24. package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
  25. package/sdk/dist/node/gitvault-address.js +5 -1
  26. package/sdk/dist/node/gitvault-address.js.map +1 -1
  27. package/sdk/dist/node/gitvault-handoff.d.ts +109 -31
  28. package/sdk/dist/node/gitvault-handoff.d.ts.map +1 -1
  29. package/sdk/dist/node/gitvault-handoff.js +264 -137
  30. package/sdk/dist/node/gitvault-handoff.js.map +1 -1
  31. package/sdk/dist/node/gitvault-keystore.d.ts +2 -2
  32. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
  33. package/sdk/dist/node/gitvault-restore.d.ts +19 -0
  34. package/sdk/dist/node/gitvault-restore.d.ts.map +1 -1
  35. package/sdk/dist/node/gitvault-restore.js +59 -2
  36. package/sdk/dist/node/gitvault-restore.js.map +1 -1
  37. package/sdk/dist/node/index.d.ts +4 -4
  38. package/sdk/dist/node/index.d.ts.map +1 -1
  39. package/sdk/dist/node/index.js +9 -5
  40. package/sdk/dist/node/index.js.map +1 -1
@@ -1,17 +1,26 @@
1
1
  /**
2
- * gitvault — the Handoff Key (kygit-handoff design D3/D6/D10).
2
+ * gitvault — the Handoff Key and the Invite Key, one kind-parameterized
3
+ * module (kygit-handoff design D3/D6/D10; kygit-invite design D3).
3
4
  *
4
- * A Handoff Key is a bearer bridge, never a K_repo derivation of its own:
5
- * `kgh1_<base64url(handoff_id[16] || master_secret[32])>` (69 chars). Two
6
- * HKDF-SHA-256 derivations off `master_secret` (salt = the 16 raw
7
- * `handoff_id` bytes) produce `auth_secret` (what the gateway hashes and
8
- * compares) and `wrap_key` (what seals/opens the small envelope carrying
9
- * the vault's epoch key `k_e` directly the gateway never sees either).
5
+ * Both keys are bearer bridges, never a K_repo derivation of their own:
6
+ * `kgh1_<base64url(handoff_id[16] || master_secret[32])>` /
7
+ * `kgi1_<base64url(invite_id[16] || master_secret[32])>` (69 chars each).
8
+ * Two HKDF-SHA-256 derivations off `master_secret` (salt = the 16 raw id
9
+ * bytes) produce `auth_secret` (what the gateway hashes and compares) and
10
+ * `wrap_key` (what seals/opens the small envelope carrying the vault's
11
+ * epoch key `k_e` directly — the gateway never sees either). The HKDF info
12
+ * strings and the auth-hash label are DOMAIN-SEPARATED by kind
13
+ * (`kygit/<kind>/auth/v1`, `kygit/<kind>/wrap/v1`,
14
+ * `kygit/<kind>/auth-hash/v1`) so a handoff-derived secret can never verify
15
+ * as an invite secret nor the reverse, even for a row whose id both parsers
16
+ * would recover.
10
17
  *
11
- * The prefix is a REGISTRY (design D9 rule 4): `kgh1_` is the first row.
12
- * `parseHandoffKey` refuses any other recognized-shape-but-wrong-kind
13
- * prefix BY NAME, pointing at its own verb, rather than misreading it as a
14
- * malformed handoff key.
18
+ * The prefix is a REGISTRY (design D9 rule 4 / kygit-invite D3): `kgh1_` is
19
+ * the first row, `kgi1_` the second. `parseClaimKey` refuses any other
20
+ * recognized-shape-but-wrong-kind prefix BY NAME, pointing at its own verb,
21
+ * rather than misreading it as a malformed key of the kind it expected.
22
+ * `parseHandoffKey`/`parseInviteKey` are thin kind-bound aliases over the
23
+ * one parser — every existing handoff export and test stays byte-identical.
15
24
  *
16
25
  * Reuses ONLY existing primitives — `jcs`/HKDF/HMAC/SHA-256 from
17
26
  * `@noble/hashes`, the SAME `_gitvaultAeadBackend()` XChaCha20-Poly1305
@@ -20,7 +29,10 @@
20
29
  * object frame (design D3: "the sealed envelope lives in the gateway ROW,
21
30
  * never in the vault object store — the frozen r402s/v0 protocol is
22
31
  * untouched"), so it gets its own tiny header/AAD rather than reusing
23
- * `frameAad`'s object-store-shaped seven fields.
32
+ * `frameAad`'s object-store-shaped seven fields. The invite envelope is a
33
+ * DIFFERENT, deliberately simpler format again than the `KGH1` handoff-
34
+ * bridge frame is from `r402s/v0` — same idea, one level down: `KGI1` is its
35
+ * own frame magic, never reused across kinds.
24
36
  */
25
37
  import { hkdf } from "@noble/hashes/hkdf.js";
26
38
  import { sha256 } from "@noble/hashes/sha2.js";
@@ -32,11 +44,20 @@ import { _gitvaultAeadBackend, fromBase64url, randomBytes, toBase64url, bytesToH
32
44
  function fail(code, message, context, details) {
33
45
  throw new LocalError(message, context, { code, details });
34
46
  }
35
- /** `kgh1_` is the first row. A future `kgi1_` (KyGit Invite) is a SIBLING row here, never a branch on this one's parser. */
36
- export const HANDOFF_KEY_PREFIXES = [{ prefix: "kgh1_", kind: "handoff", verb: "resume" }];
37
- const HANDOFF_ID_BYTES = 16;
47
+ /**
48
+ * `kgh1_` is the first row (handoff); `kgi1_` is the second (invite,
49
+ * kygit-invite design D3). A future third kind is a SIBLING row here, never
50
+ * a branch on either existing one's parser.
51
+ */
52
+ export const HANDOFF_KEY_PREFIXES = [
53
+ { prefix: "kgh1_", kind: "handoff", verb: "resume", errorPrefix: "HANDOFF", envelopeKind: "kygit-handoff-envelope-v2", noteSchema: "kygit.handoff-note.v1", frameMagic: "KGH1" },
54
+ { prefix: "kgi1_", kind: "invite", verb: "join", errorPrefix: "INVITE", envelopeKind: "kygit-invite-envelope-v2", noteSchema: "kygit.invite-note.v1", frameMagic: "KGI1" },
55
+ ];
56
+ const HANDOFF_ENTRY = HANDOFF_KEY_PREFIXES[0];
57
+ const INVITE_ENTRY = HANDOFF_KEY_PREFIXES[1];
58
+ const CLAIM_ID_BYTES = 16;
38
59
  const MASTER_SECRET_BYTES = 32;
39
- const HANDOFF_KEY_BODY_BYTES = HANDOFF_ID_BYTES + MASTER_SECRET_BYTES;
60
+ const CLAIM_KEY_BODY_BYTES = CLAIM_ID_BYTES + MASTER_SECRET_BYTES;
40
61
  function bytesToUuid(bytes) {
41
62
  const h = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
42
63
  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
@@ -51,6 +72,14 @@ export function uuidToBytes(uuid, field = "handoff_id") {
51
72
  out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
52
73
  return out;
53
74
  }
75
+ /** Assemble a printed claim key from a fresh 32-byte master secret and the gateway-minted claim id, for `entry`'s kind. */
76
+ function assembleClaimKey(entry, claimId, masterSecret) {
77
+ if (masterSecret.length !== MASTER_SECRET_BYTES)
78
+ fail(`${entry.errorPrefix}_KEY_INVALID`, "master_secret must be 32 bytes", `assembling ${entry.kind} key`);
79
+ const idBytes = uuidToBytes(claimId, `${entry.kind}_id`);
80
+ const body = concatBytes(idBytes, masterSecret);
81
+ return { key: `${entry.prefix}${toBase64url(body)}`, id_bytes: idBytes, master_secret: masterSecret };
82
+ }
54
83
  /**
55
84
  * Assemble the printed key from a fresh 32-byte master secret and the
56
85
  * gateway-minted `handoff_id`. The gateway never sees `master_secret` — the
@@ -58,64 +87,86 @@ export function uuidToBytes(uuid, field = "handoff_id") {
58
87
  * ONLY that.
59
88
  */
60
89
  export function assembleHandoffKey(handoffId, masterSecret = randomBytes(MASTER_SECRET_BYTES)) {
61
- if (masterSecret.length !== MASTER_SECRET_BYTES)
62
- fail("HANDOFF_KEY_INVALID", "master_secret must be 32 bytes", "assembling handoff key");
63
- const idBytes = uuidToBytes(handoffId);
64
- const body = concatBytes(idBytes, masterSecret);
65
- return { key: `kgh1_${toBase64url(body)}`, handoff_id_bytes: idBytes, master_secret: masterSecret };
90
+ const { key, id_bytes, master_secret } = assembleClaimKey(HANDOFF_ENTRY, handoffId, masterSecret);
91
+ return { key, handoff_id_bytes: id_bytes, master_secret };
92
+ }
93
+ /** The invite-kind sibling of {@link assembleHandoffKey} (kygit-invite design D3). */
94
+ export function assembleInviteKey(inviteId, masterSecret = randomBytes(MASTER_SECRET_BYTES)) {
95
+ const { key, id_bytes, master_secret } = assembleClaimKey(INVITE_ENTRY, inviteId, masterSecret);
96
+ return { key, invite_id_bytes: id_bytes, master_secret };
66
97
  }
67
98
  /**
68
- * Parse ANY `kg**_` handoff-family key by its prefix registry. A recognized
69
- * prefix of a DIFFERENT kind (a future `kgi1_` invite key handed to
70
- * `resume`) refuses BY NAME pointing at its own verb never misread as a
71
- * malformed handoff key (design D9 rule 4).
99
+ * Parse ANY `kg**_` claim-family key by its prefix registry against the
100
+ * `expectedKind` the calling verb accepts. A recognized prefix of a
101
+ * DIFFERENT kind (a `kgi1_` invite key handed to `resume`, or a `kgh1_`
102
+ * handoff key handed to `join`) refuses BY NAME pointing at its own verb —
103
+ * never misread as a malformed key of the expected kind, and never
104
+ * contacting the gateway (design D9 rule 4 / kygit-invite design D3).
72
105
  */
73
- export function parseHandoffKey(raw) {
106
+ export function parseClaimKey(raw, expectedKind) {
107
+ const expectedEntry = expectedKind === "handoff" ? HANDOFF_ENTRY : INVITE_ENTRY;
74
108
  const trimmed = raw.trim();
75
109
  const entry = HANDOFF_KEY_PREFIXES.find((e) => trimmed.startsWith(e.prefix));
76
110
  if (!entry) {
77
- // A recognized SIBLING prefix (future kind) would be matched here once
78
- // it joins the registry; today only `kgh1_` is registered, so any
79
- // non-matching prefix is a plain invalid key.
80
- fail("HANDOFF_KEY_INVALID", "not a recognized kygit key (expected a key starting kgh1_)", "parsing handoff key");
111
+ fail(`${expectedEntry.errorPrefix}_KEY_INVALID`, `not a recognized kygit key (expected a key starting ${expectedEntry.prefix})`, "parsing claim key");
81
112
  }
82
- if (entry.kind !== "handoff") {
83
- fail("HANDOFF_KEY_WRONG_KIND", `this key is a ${entry.kind} key — use \`kygit ${entry.verb}\` instead of resume`, "parsing handoff key", { kind: entry.kind, verb: entry.verb });
113
+ if (entry.kind !== expectedKind) {
114
+ fail(`${expectedEntry.errorPrefix}_KEY_WRONG_KIND`, `this key is a ${entry.kind} key — use \`kygit ${entry.verb}\` instead of ${expectedEntry.verb}`, "parsing claim key", { kind: entry.kind, verb: entry.verb });
84
115
  }
85
116
  const body = trimmed.slice(entry.prefix.length);
86
117
  let bytes;
87
118
  try {
88
- bytes = fromBase64url(body, "handoff key body");
119
+ bytes = fromBase64url(body, `${entry.kind} key body`);
89
120
  }
90
121
  catch {
91
- fail("HANDOFF_KEY_INVALID", "the key body is not valid base64url", "parsing handoff key");
122
+ fail(`${entry.errorPrefix}_KEY_INVALID`, "the key body is not valid base64url", "parsing claim key");
92
123
  }
93
- if (bytes.length !== HANDOFF_KEY_BODY_BYTES) {
94
- fail("HANDOFF_KEY_INVALID", `the key decodes to ${bytes.length} bytes; expected ${HANDOFF_KEY_BODY_BYTES}`, "parsing handoff key");
124
+ if (bytes.length !== CLAIM_KEY_BODY_BYTES) {
125
+ fail(`${entry.errorPrefix}_KEY_INVALID`, `the key decodes to ${bytes.length} bytes; expected ${CLAIM_KEY_BODY_BYTES}`, "parsing claim key");
95
126
  }
96
- const idBytes = bytes.subarray(0, HANDOFF_ID_BYTES);
97
- const masterSecret = bytes.subarray(HANDOFF_ID_BYTES);
98
- return { kind: "handoff", handoff_id_bytes: idBytes, handoff_id: bytesToUuid(idBytes), master_secret: masterSecret };
127
+ const idBytes = bytes.subarray(0, CLAIM_ID_BYTES);
128
+ const masterSecret = bytes.subarray(CLAIM_ID_BYTES);
129
+ return { kind: entry.kind, id_bytes: idBytes, id: bytesToUuid(idBytes), master_secret: masterSecret };
99
130
  }
100
- // ─── HKDF derivations (design D3) ────────────────────────────────────────────
101
- const HANDOFF_AUTH_INFO = "kygit/handoff/auth/v1";
102
- const HANDOFF_WRAP_INFO = "kygit/handoff/wrap/v1";
103
- const HANDOFF_AUTH_HASH_LABEL = "kygit/handoff/auth-hash/v1";
104
- /** `HKDF-SHA256(ikm=master_secret, salt=handoff_id[16], info=…)` per D3, for both `auth_secret` and `wrap_key`. */
105
- export function deriveHandoffSecrets(handoffIdBytes, masterSecret) {
106
- const authSecret = hkdf(sha256, masterSecret, handoffIdBytes, utf8ToBytes(HANDOFF_AUTH_INFO), 32);
107
- const wrapKey = hkdf(sha256, masterSecret, handoffIdBytes, utf8ToBytes(HANDOFF_WRAP_INFO), 32);
131
+ /**
132
+ * Parse ANY `kg**_` handoff-family key by its prefix registry. A recognized
133
+ * prefix of a DIFFERENT kind (an invite key handed to `resume`) refuses BY
134
+ * NAME pointing at its own verb — never misread as a malformed handoff key
135
+ * (design D9 rule 4). Kind-bound alias of {@link parseClaimKey}.
136
+ */
137
+ export function parseHandoffKey(raw) {
138
+ const parsed = parseClaimKey(raw, "handoff");
139
+ return { kind: "handoff", handoff_id_bytes: parsed.id_bytes, handoff_id: parsed.id, master_secret: parsed.master_secret };
140
+ }
141
+ /** The invite-kind sibling of {@link parseHandoffKey} (kygit-invite design D3). */
142
+ export function parseInviteKey(raw) {
143
+ const parsed = parseClaimKey(raw, "invite");
144
+ return { kind: "invite", invite_id_bytes: parsed.id_bytes, invite_id: parsed.id, master_secret: parsed.master_secret };
145
+ }
146
+ /** `HKDF-SHA256(ikm=master_secret, salt=id[16], info=…)` per D3, domain-separated by kind, for both `auth_secret` and `wrap_key`. */
147
+ function deriveClaimSecrets(kind, idBytes, masterSecret) {
148
+ const authInfo = `kygit/${kind}/auth/v1`;
149
+ const wrapInfo = `kygit/${kind}/wrap/v1`;
150
+ const authHashLabel = `kygit/${kind}/auth-hash/v1`;
151
+ const authSecret = hkdf(sha256, masterSecret, idBytes, utf8ToBytes(authInfo), 32);
152
+ const wrapKey = hkdf(sha256, masterSecret, idBytes, utf8ToBytes(wrapInfo), 32);
153
+ const authHash = sha256(concatBytes(utf8ToBytes(authHashLabel), authSecret));
108
154
  // ONE SHA-256 over (label ‖ auth_secret), hex-encoded — exactly what the
109
155
  // gateway recomputes at claim (`computeAuthHash`). Through 4.68.1 this line
110
156
  // ran the digest through `sha256Hex` (which hashes its input again), so the
111
157
  // stored value was sha256(sha256(label ‖ secret)) and NO key minted by any
112
- // published client could ever verify the mint/claim agreement test above
113
- // compared two calls of this same function and could not see it. The
114
- // cross-side vector in gitvault-handoff.test.ts now recomputes the
115
- // gateway's hash independently.
116
- const authHash = sha256(concatBytes(utf8ToBytes(HANDOFF_AUTH_HASH_LABEL), authSecret));
158
+ // published client could ever verify. The cross-side vector in
159
+ // gitvault-handoff.test.ts now recomputes the gateway's hash independently.
117
160
  return { auth_secret: authSecret, wrap_key: wrapKey, auth_hash_hex: bytesToHex(authHash) };
118
161
  }
162
+ /** `HKDF-SHA256(ikm=master_secret, salt=handoff_id[16], info=kygit/handoff/…)` per D3, for both `auth_secret` and `wrap_key`. */
163
+ export function deriveHandoffSecrets(handoffIdBytes, masterSecret) {
164
+ return deriveClaimSecrets("handoff", handoffIdBytes, masterSecret);
165
+ }
166
+ /** The invite-kind sibling of {@link deriveHandoffSecrets}, domain-separated under `kygit/invite/…` info strings (kygit-invite design D3) — an invite secret never verifies as a handoff hash, or the reverse. */
167
+ export function deriveInviteSecrets(inviteIdBytes, masterSecret) {
168
+ return deriveClaimSecrets("invite", inviteIdBytes, masterSecret);
169
+ }
119
170
  // ─── Writer-admission grant + acceptance (gitvault-multi-writer D4/§4.17) ───
120
171
  //
121
172
  // The bearer-completable path for `add_writer_key{authorization.kind:
@@ -131,17 +182,27 @@ export function deriveHandoffSecrets(handoffIdBytes, masterSecret) {
131
182
  // JCS(statement)` — that text is stale; the shipped gateway code calls its
132
183
  // own `signaturePreimage("handoff-writer-accept/v1", acceptance.statement)`,
133
184
  // the identical convention used here).
134
- const HANDOFF_WRITER_ADMISSION_INFO = "kygit/handoff/writer-admission/v1";
135
185
  /**
136
- * Third HKDF output of the Handoff Key's `master_secret` (design D4,
137
- * protocol §4.17): the one-use admission Ed25519 seed. Derived directly
138
- * from `master_secret` deliberately NEVER from `auth_secret` or
139
- * `wrap_key` — so the gateway, which receives `auth_secret` at claim, stays
140
- * computationally unable to derive this seed and manufacture a different
141
- * claimant completion.
186
+ * Third HKDF output of a claim key's `master_secret` (design D4, protocol
187
+ * §4.17; kind-parameterized by kygit-invite design D3): the one-use
188
+ * admission Ed25519 seed. Derived directly from `master_secret`
189
+ * deliberately NEVER from `auth_secret` or `wrap_key` — so the gateway,
190
+ * which receives `auth_secret` at claim, stays computationally unable to
191
+ * derive this seed and manufacture a different claimant completion. The info
192
+ * string is domain-separated by kind (`kygit/<kind>/writer-admission/v1`),
193
+ * so an invite's admission seed never matches a handoff's for the same id
194
+ * and master secret.
142
195
  */
196
+ export function deriveClaimWriterAdmissionSeed(kind, idBytes, masterSecret) {
197
+ return hkdf(sha256, masterSecret, idBytes, utf8ToBytes(`kygit/${kind}/writer-admission/v1`), 32);
198
+ }
199
+ /** Handoff-bound alias of {@link deriveClaimWriterAdmissionSeed} — `kygit/handoff/writer-admission/v1`. */
143
200
  export function deriveWriterAdmissionSeed(handoffIdBytes, masterSecret) {
144
- return hkdf(sha256, masterSecret, handoffIdBytes, utf8ToBytes(HANDOFF_WRITER_ADMISSION_INFO), 32);
201
+ return deriveClaimWriterAdmissionSeed("handoff", handoffIdBytes, masterSecret);
202
+ }
203
+ /** The invite-kind sibling of {@link deriveWriterAdmissionSeed} — `kygit/invite/writer-admission/v1` (kygit-invite design D3). */
204
+ export function deriveInviteWriterAdmissionSeed(inviteIdBytes, masterSecret) {
205
+ return deriveClaimWriterAdmissionSeed("invite", inviteIdBytes, masterSecret);
145
206
  }
146
207
  const WRITER_MINTED_ROLES = ["owner", "admin", "developer", "billing", "viewer"];
147
208
  /** Mint side (D4): builds + signs `writer_admission_grant`. The gateway's own mint-time validation (grantor active + self-consistent, field equality) is server-side; this function only builds a well-formed, correctly-signed object. */
@@ -251,20 +312,19 @@ export function verifyWriterAcceptance(acceptance, admissionPubkeyRaw) {
251
312
  const preimage = signaturePreimage(HANDOFF_WRITER_ACCEPT_PREIMAGE_DOMAIN, acceptance.statement);
252
313
  return ed25519VerifyStrict(acceptanceSig, preimage, admissionPubkeyRaw) && ed25519VerifyStrict(possessionSig, preimage, signingPubkey);
253
314
  }
254
- /** Frame tag for this envelope format — DISTINCT from `r402s/v0`'s `"R402S0"` object frame (design D3: never stored as a vault object). */
315
+ /** Frame tag for the handoff envelope format — DISTINCT from `r402s/v0`'s `"R402S0"` object frame (design D3: never stored as a vault object). The LEGACY v1 tag: nothing seals it any more, but a pre-gitvault-multi-writer handoff row still carries one and {@link openHandoffEnvelope} still opens it. */
255
316
  export const HANDOFF_ENVELOPE_KIND = "kygit-handoff-envelope-v1";
256
- const HANDOFF_FRAME_MAGIC = "KGH1";
257
- const HANDOFF_FRAME_MAGIC_BYTES = 4;
258
- const HANDOFF_FRAME_VERSION_BYTE = 0x01;
259
- const HANDOFF_NONCE_BYTES = 24;
260
- const HANDOFF_FRAME_HEADER_BYTES = HANDOFF_FRAME_MAGIC_BYTES + 1 + HANDOFF_NONCE_BYTES;
261
- function handoffAeadSeal(key32, nonce24, aad, plaintext) {
317
+ const FRAME_MAGIC_BYTES = 4;
318
+ const FRAME_VERSION_BYTE = 0x01;
319
+ const CLAIM_NONCE_BYTES = 24;
320
+ const CLAIM_FRAME_HEADER_BYTES = FRAME_MAGIC_BYTES + 1 + CLAIM_NONCE_BYTES;
321
+ function claimAeadSeal(key32, nonce24, aad, plaintext) {
262
322
  const backend = _gitvaultAeadBackend();
263
323
  if (backend)
264
324
  return backend.seal(key32, nonce24, aad, plaintext);
265
325
  return xchacha20poly1305(key32, nonce24, aad).encrypt(plaintext);
266
326
  }
267
- function handoffAeadOpen(key32, nonce24, aad, ctAndTag) {
327
+ function claimAeadOpen(key32, nonce24, aad, ctAndTag) {
268
328
  try {
269
329
  const backend = _gitvaultAeadBackend();
270
330
  if (backend)
@@ -275,9 +335,9 @@ function handoffAeadOpen(key32, nonce24, aad, ctAndTag) {
275
335
  return null;
276
336
  }
277
337
  }
278
- /** AAD = `handoff_id[16] ‖ kind` (design D3) — `kind` is this envelope format's own tag, UTF-8. */
279
- function handoffEnvelopeAad(handoffIdBytes, envelopeKind) {
280
- return concatBytes(handoffIdBytes, utf8ToBytes(envelopeKind));
338
+ /** AAD = `id[16] ‖ envelopeKind` (design D3) — `envelopeKind` is this envelope format's own tag, UTF-8. */
339
+ function claimEnvelopeAad(idBytes, envelopeKind) {
340
+ return concatBytes(idBytes, utf8ToBytes(envelopeKind));
281
341
  }
282
342
  /**
283
343
  * `sealed_envelope` on the wire is STANDARD base64 — openapi spells it
@@ -285,11 +345,14 @@ function handoffEnvelopeAad(handoffIdBytes, envelopeKind) {
285
345
  * gateway echoes the stored bytes with `Buffer#toString("base64")` (`+`,
286
346
  * `/`, `=` padding). Decoding it with the canonical-base64url-only
287
347
  * `fromBase64url`, which refuses every `+`/`/`/`=`, takes a 200 from the
288
- * gateway and dies client-side with HANDOFF_ENVELOPE_INVALID. This decoder
348
+ * gateway and dies client-side with `*_ENVELOPE_INVALID`. This decoder
289
349
  * reads the documented form and, for tolerance, the base64url form other
290
- * clients mint; padding is optional either way. Anything
291
- * outside the two alphabets is a refusal (`null` — the caller raises the
292
- * typed HANDOFF_ENVELOPE_INVALID), never a silent partial decode.
350
+ * clients mint; padding is optional either way. Anything outside the two
351
+ * alphabets is a refusal (`null` — the caller raises the typed
352
+ * `*_ENVELOPE_INVALID`), never a silent partial decode. Shared by both
353
+ * claim kinds: the invite routes encode exactly as the handoff routes do,
354
+ * byte for byte, rather than "fixing" the alphabet on one side only
355
+ * (kygit-invite design D3's cross-side trap).
293
356
  */
294
357
  function decodeSealedEnvelope(value) {
295
358
  const trimmed = value.trim();
@@ -305,91 +368,107 @@ function decodeSealedEnvelope(value) {
305
368
  return null;
306
369
  }
307
370
  }
308
- // Plain JSON, NOT the r402s/v0 no-JSON-numbers JCS profile: this envelope is
309
- // never a vault object and only this SDK ever encodes/decodes it, so it
310
- // needs deterministic round-tripping, not cross-implementation
311
- // byte-canonical determinism (design D3's "the frozen protocol is
312
- // untouched" reusing the object-frame profile here would be exactly the
313
- // kind of accidental protocol coupling that note warns against). Shared by
314
- // every envelope kind (v1, v2, …) so the frame byte-layout lives in exactly
315
- // one place a v1/v2 divergence here would be invisible to either side
316
- // until a cross-version open failed.
317
- function sealHandoffEnvelopeFrame(handoffIdBytes, wrapKey, payload, envelopeKind, nonce) {
371
+ /**
372
+ * Seal `payload` under `wrap_key` for `entry`'s kind, tagged `envelopeKind`.
373
+ * Returns the standard-base64 wire form (openapi `format: byte`) + the tag.
374
+ *
375
+ * Plain JSON, NOT the r402s/v0 no-JSON-numbers JCS profile: this envelope is
376
+ * never a vault object and only this SDK ever encodes/decodes it, so it
377
+ * needs deterministic round-tripping, not cross-implementation
378
+ * byte-canonical determinism (design D3's "the frozen protocol is
379
+ * untouched" — reusing the object-frame profile here would be exactly the
380
+ * kind of accidental protocol coupling that note warns against). Shared by
381
+ * every claim kind and every envelope version (v1, v2, …) so the frame
382
+ * byte-layout lives in exactly one place — a kind or version divergence here
383
+ * would be invisible to either side until a cross-open failed.
384
+ */
385
+ function sealClaimEnvelope(entry, idBytes, wrapKey, payload, envelopeKind, nonce) {
318
386
  const plaintext = utf8ToBytes(JSON.stringify(payload));
319
- const n = nonce ?? randomBytes(HANDOFF_NONCE_BYTES);
320
- if (n.length !== HANDOFF_NONCE_BYTES)
321
- fail("HANDOFF_ENVELOPE_INVALID", "nonce must be 24 bytes", "sealing handoff envelope");
322
- const aad = handoffEnvelopeAad(handoffIdBytes, envelopeKind);
323
- const ct = handoffAeadSeal(wrapKey, n, aad, plaintext);
324
- const frame = concatBytes(utf8ToBytes(HANDOFF_FRAME_MAGIC), new Uint8Array([HANDOFF_FRAME_VERSION_BYTE]), n, ct);
387
+ const n = nonce ?? randomBytes(CLAIM_NONCE_BYTES);
388
+ if (n.length !== CLAIM_NONCE_BYTES)
389
+ fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "nonce must be 24 bytes", `sealing ${entry.kind} envelope`);
390
+ const aad = claimEnvelopeAad(idBytes, envelopeKind);
391
+ const ct = claimAeadSeal(wrapKey, n, aad, plaintext);
392
+ const frame = concatBytes(utf8ToBytes(entry.frameMagic), new Uint8Array([FRAME_VERSION_BYTE]), n, ct);
325
393
  return { sealed_envelope: base64.encode(frame), envelope_kind: envelopeKind };
326
394
  }
327
- /** Seal the handoff payload under `wrap_key`. Returns the standard-base64 wire form (openapi `format: byte`) + its declared kind tag. */
328
- export function sealHandoffEnvelope(handoffIdBytes, wrapKey, payload, nonce) {
329
- return sealHandoffEnvelopeFrame(handoffIdBytes, wrapKey, payload, HANDOFF_ENVELOPE_KIND, nonce);
330
- }
331
395
  /**
332
- * Decodes, header-checks, AEAD-opens, and JSON-parses a sealed envelope,
333
- * returning the RAW parsed payload with no shape validation — shared by
334
- * every envelope kind (mirrors {@link sealHandoffEnvelopeFrame} on the seal
335
- * side) so the frame byte-layout lives in exactly one place. Each envelope
336
- * kind's own `open*` function applies its OWN shape check on the result;
337
- * this function deliberately does not, since a shape check IS the version
338
- * discriminator (a v1 opener must reject a `v:2` payload, not silently
339
- * accept it — that is what makes `v` meaningful at all).
396
+ * Decodes, header-checks, AEAD-opens, and JSON-parses a sealed envelope for
397
+ * `entry`'s kind, returning the RAW parsed payload with no shape validation
398
+ * (mirrors {@link sealClaimEnvelope} on the seal side) so the frame
399
+ * byte-layout lives in exactly one place. Each envelope kind/version's own
400
+ * `open*` function applies its OWN shape check on the result; this function
401
+ * deliberately does not, since a shape check IS the version discriminator (a
402
+ * v1 opener must reject a `v:2` payload, not silently accept it — that is
403
+ * what makes `v` meaningful at all). Throws `${errorPrefix}_ENVELOPE_INVALID`
404
+ * on any header mismatch and `${errorPrefix}_AEAD_AUTH_FAILURE` on a bad
405
+ * key/AAD/ciphertext.
340
406
  */
341
- function openHandoffEnvelopeFrame(handoffIdBytes, wrapKey, sealedEnvelope, envelopeKind) {
407
+ function openClaimEnvelope(entry, idBytes, wrapKey, sealedEnvelope, envelopeKind) {
342
408
  const frame = decodeSealedEnvelope(sealedEnvelope);
343
409
  if (frame === null) {
344
- fail("HANDOFF_ENVELOPE_INVALID", "sealed_envelope is not valid base64", "opening handoff envelope");
410
+ fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "sealed_envelope is not valid base64", `opening ${entry.kind} envelope`);
345
411
  }
346
- if (frame.length < HANDOFF_FRAME_HEADER_BYTES + 16) {
347
- fail("HANDOFF_ENVELOPE_INVALID", "sealed_envelope is shorter than header + AEAD tag", "opening handoff envelope");
412
+ if (frame.length < CLAIM_FRAME_HEADER_BYTES + 16) {
413
+ fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "sealed_envelope is shorter than header + AEAD tag", `opening ${entry.kind} envelope`);
348
414
  }
349
- const magic = new TextDecoder().decode(frame.subarray(0, HANDOFF_FRAME_MAGIC_BYTES));
350
- if (magic !== HANDOFF_FRAME_MAGIC || frame[HANDOFF_FRAME_MAGIC_BYTES] !== HANDOFF_FRAME_VERSION_BYTE) {
351
- fail("HANDOFF_ENVELOPE_INVALID", "sealed_envelope header magic/version mismatch", "opening handoff envelope");
415
+ const magic = new TextDecoder().decode(frame.subarray(0, FRAME_MAGIC_BYTES));
416
+ if (magic !== entry.frameMagic || frame[FRAME_MAGIC_BYTES] !== FRAME_VERSION_BYTE) {
417
+ fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "sealed_envelope header magic/version mismatch", `opening ${entry.kind} envelope`);
352
418
  }
353
- const nonce = frame.subarray(HANDOFF_FRAME_MAGIC_BYTES + 1, HANDOFF_FRAME_HEADER_BYTES);
354
- const ct = frame.subarray(HANDOFF_FRAME_HEADER_BYTES);
355
- const aad = handoffEnvelopeAad(handoffIdBytes, envelopeKind);
356
- const opened = handoffAeadOpen(wrapKey, nonce, aad, ct);
419
+ const nonce = frame.subarray(FRAME_MAGIC_BYTES + 1, CLAIM_FRAME_HEADER_BYTES);
420
+ const ct = frame.subarray(CLAIM_FRAME_HEADER_BYTES);
421
+ const aad = claimEnvelopeAad(idBytes, envelopeKind);
422
+ const opened = claimAeadOpen(wrapKey, nonce, aad, ct);
357
423
  if (opened === null)
358
- fail("HANDOFF_AEAD_AUTH_FAILURE", "the sealed envelope failed AEAD authentication under this key", "opening handoff envelope");
424
+ fail(`${entry.errorPrefix}_AEAD_AUTH_FAILURE`, "the sealed envelope failed AEAD authentication under this key", `opening ${entry.kind} envelope`);
359
425
  try {
360
426
  return JSON.parse(new TextDecoder().decode(opened));
361
427
  }
362
428
  catch {
363
- fail("HANDOFF_ENVELOPE_INVALID", "opened envelope is not valid JSON", "opening handoff envelope");
429
+ fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "opened envelope is not valid JSON", `opening ${entry.kind} envelope`);
364
430
  }
365
431
  }
432
+ /** Seal the handoff payload under `wrap_key` as a LEGACY v1 envelope. Returns the standard-base64 wire form (openapi `format: byte`) + its declared kind tag. Nothing in the shipped flow calls this any more — {@link sealHandoffEnvelopeV2} is what `handoff` seals — but the v1 shape stays openable and sealable for the conformance vectors. */
433
+ export function sealHandoffEnvelope(handoffIdBytes, wrapKey, payload, nonce) {
434
+ return sealClaimEnvelope(HANDOFF_ENTRY, handoffIdBytes, wrapKey, payload, HANDOFF_ENVELOPE_KIND, nonce);
435
+ }
366
436
  /** Open a sealed handoff envelope under `wrap_key` (standard base64 as the claim response carries it, or base64url). Throws `HANDOFF_ENVELOPE_INVALID` on any header mismatch, a `v:2` (or otherwise non-v1) payload shape, and `HANDOFF_AEAD_AUTH_FAILURE` on a bad key/AAD/ciphertext. */
367
437
  export function openHandoffEnvelope(handoffIdBytes, wrapKey, sealedEnvelope, envelopeKind = HANDOFF_ENVELOPE_KIND) {
368
- const payload = openHandoffEnvelopeFrame(handoffIdBytes, wrapKey, sealedEnvelope, envelopeKind);
369
- const p = payload;
438
+ const p = openClaimEnvelope(HANDOFF_ENTRY, handoffIdBytes, wrapKey, sealedEnvelope, envelopeKind);
370
439
  if (p.v !== 1 || p.kind !== "handoff" || typeof p.repo_id !== "string" || typeof p.epoch !== "string" || typeof p.k_e_hex !== "string" || !p.checkpoint || p.note_schema !== "kygit.handoff-note.v1") {
371
440
  fail("HANDOFF_ENVELOPE_INVALID", "opened envelope does not match the kygit.handoff-note.v1 payload shape", "opening handoff envelope");
372
441
  }
373
442
  return p;
374
443
  }
375
- // ─── Envelope v2 (gitvault-multi-writer D4/§4.17) ────────────────────────────
376
- /**
377
- * `kygit-handoff-envelope-v2` adds `writer_admission_grant_sha256` to the
378
- * v1 payload — design D4's "no hash cycle: grant first, then seal": the
379
- * minter builds + signs {@link WriterAdmissionGrant} FIRST, hashes its
380
- * stored bytes SECOND, and only THEN seals this envelope carrying that
381
- * hash. The claimant cross-checks it against the grant the gateway
382
- * independently returns at claim, an integrity binding entirely
383
- * independent of anything the gateway could tamper with (the envelope's
384
- * AEAD authenticity comes from `wrap_key`, which the gateway never holds).
385
- */
444
+ // ─── Envelope v2 (gitvault-multi-writer D4/§4.17; kygit-invite design D3) ───
445
+ //
446
+ // `writer_admission_grant_sha256` is what makes v2 v2 — design D4's "no hash
447
+ // cycle: grant first, then seal": the minter builds + signs
448
+ // {@link WriterAdmissionGrant} FIRST, hashes its stored bytes SECOND, and
449
+ // only THEN seals this envelope carrying that hash. The claimant
450
+ // cross-checks it against the grant the gateway independently returns at
451
+ // claim, an integrity binding entirely independent of anything the gateway
452
+ // could tamper with (the envelope's AEAD authenticity comes from `wrap_key`,
453
+ // which the gateway never holds). BOTH claim kinds seal v2 — an invite has
454
+ // no v1 at all, and a handoff's v1 survives only as a legacy opener.
455
+ /** The v2 envelope kind the handoff mint seals. */
386
456
  export const HANDOFF_ENVELOPE_V2_KIND = "kygit-handoff-envelope-v2";
387
- /** Seal a v2 envelope identical framing to {@link sealHandoffEnvelope}, tagged `kygit-handoff-envelope-v2` so its AAD (and therefore its ciphertext) is never confusable with a v1 envelope of the same handoff. */
457
+ /** The v2 envelope kind the invite mint seals (kygit-invite design D3) its own tag, never confusable with the handoff one even for the same id. */
458
+ export const INVITE_ENVELOPE_V2_KIND = "kygit-invite-envelope-v2";
459
+ /** Seal a v2 handoff envelope — identical framing to {@link sealHandoffEnvelope}, tagged `kygit-handoff-envelope-v2` so its AAD (and therefore its ciphertext) is never confusable with a v1 envelope of the same handoff. */
388
460
  export function sealHandoffEnvelopeV2(handoffIdBytes, wrapKey, payload, nonce) {
389
461
  if (!GITVAULT_SHA256_RE.test(payload.writer_admission_grant_sha256)) {
390
462
  fail("VALIDATION_FAILED", "writer_admission_grant_sha256 must be 64 lowercase hex", "sealing handoff envelope v2", { field: "writer_admission_grant_sha256" });
391
463
  }
392
- return sealHandoffEnvelopeFrame(handoffIdBytes, wrapKey, payload, HANDOFF_ENVELOPE_V2_KIND, nonce);
464
+ return sealClaimEnvelope(HANDOFF_ENTRY, handoffIdBytes, wrapKey, payload, HANDOFF_ENVELOPE_V2_KIND, nonce);
465
+ }
466
+ /** The invite-kind sibling of {@link sealHandoffEnvelopeV2} — the ONLY invite envelope there is (kygit-invite design D3). */
467
+ export function sealInviteEnvelope(inviteIdBytes, wrapKey, payload, nonce) {
468
+ if (!GITVAULT_SHA256_RE.test(payload.writer_admission_grant_sha256)) {
469
+ fail("VALIDATION_FAILED", "writer_admission_grant_sha256 must be 64 lowercase hex", "sealing invite envelope", { field: "writer_admission_grant_sha256" });
470
+ }
471
+ return sealClaimEnvelope(INVITE_ENTRY, inviteIdBytes, wrapKey, payload, INVITE_ENVELOPE_V2_KIND, nonce);
393
472
  }
394
473
  /**
395
474
  * Open a v2 envelope for a writer-activation flow. Refuses a v1 (or any
@@ -398,7 +477,7 @@ export function sealHandoffEnvelopeV2(handoffIdBytes, wrapKey, payload, nonce) {
398
477
  * writer-activation-aware `resume` needs `writer_admission_grant_sha256` to
399
478
  * cross-check the returned grant, and a pre-rev-47 v1 envelope structurally
400
479
  * has none. Shares ONLY the frame/AEAD-opening step with
401
- * {@link openHandoffEnvelope} ({@link openHandoffEnvelopeFrame}) — NOT that
480
+ * {@link openHandoffEnvelope} ({@link openClaimEnvelope}) — NOT that
402
481
  * function itself, since its `v !== 1` shape check would (correctly) refuse
403
482
  * a v2 payload; this function applies its own `v !== 2` shape check
404
483
  * instead. Callers that only need K_repo/checkpoint delivery, with no
@@ -410,8 +489,7 @@ export function openHandoffEnvelopeV2(handoffIdBytes, wrapKey, sealedEnvelope, e
410
489
  if (envelopeKind !== HANDOFF_ENVELOPE_V2_KIND) {
411
490
  fail("HANDOFF_ENVELOPE_UNSUPPORTED", `this handoff's envelope is ${envelopeKind ? `\`${envelopeKind}\`` : "of an unrecognized kind"}, not \`${HANDOFF_ENVELOPE_V2_KIND}\` — writer activation needs the v2 envelope's writer_admission_grant_sha256, which a pre-gitvault-multi-writer handoff never carries`, "opening handoff envelope for writer activation", { received: envelopeKind, required: HANDOFF_ENVELOPE_V2_KIND });
412
491
  }
413
- const payload = openHandoffEnvelopeFrame(handoffIdBytes, wrapKey, sealedEnvelope, envelopeKind);
414
- const p = payload;
492
+ const p = openClaimEnvelope(HANDOFF_ENTRY, handoffIdBytes, wrapKey, sealedEnvelope, envelopeKind);
415
493
  if (p.v !== 2 ||
416
494
  p.kind !== "handoff" ||
417
495
  typeof p.repo_id !== "string" ||
@@ -425,12 +503,37 @@ export function openHandoffEnvelopeV2(handoffIdBytes, wrapKey, sealedEnvelope, e
425
503
  }
426
504
  return p;
427
505
  }
506
+ /** The invite-kind sibling of {@link openHandoffEnvelopeV2} — throws `INVITE_ENVELOPE_UNSUPPORTED` / `INVITE_ENVELOPE_INVALID` / `INVITE_AEAD_AUTH_FAILURE` (kygit-invite design D3). */
507
+ export function openInviteEnvelope(inviteIdBytes, wrapKey, sealedEnvelope, envelopeKind = INVITE_ENVELOPE_V2_KIND) {
508
+ if (envelopeKind !== INVITE_ENVELOPE_V2_KIND) {
509
+ fail("INVITE_ENVELOPE_UNSUPPORTED", `this invite's envelope is ${envelopeKind ? `\`${envelopeKind}\`` : "of an unrecognized kind"}, not \`${INVITE_ENVELOPE_V2_KIND}\``, "opening invite envelope", { received: envelopeKind, required: INVITE_ENVELOPE_V2_KIND });
510
+ }
511
+ const p = openClaimEnvelope(INVITE_ENTRY, inviteIdBytes, wrapKey, sealedEnvelope, envelopeKind);
512
+ if (p.v !== 2 ||
513
+ p.kind !== "invite" ||
514
+ typeof p.repo_id !== "string" ||
515
+ typeof p.epoch !== "string" ||
516
+ typeof p.k_e_hex !== "string" ||
517
+ !p.checkpoint ||
518
+ p.note_schema !== "kygit.invite-note.v1" ||
519
+ typeof p.writer_admission_grant_sha256 !== "string" ||
520
+ !GITVAULT_SHA256_RE.test(p.writer_admission_grant_sha256)) {
521
+ fail("INVITE_ENVELOPE_INVALID", "opened envelope does not match the v2 kygit.invite-note.v1 payload shape", "opening invite envelope");
522
+ }
523
+ return p;
524
+ }
428
525
  export function isKygitHandoffNote(value) {
429
526
  if (!value || typeof value !== "object")
430
527
  return false;
431
528
  const v = value;
432
529
  return v.schema === "kygit.handoff-note.v1" && typeof v.summary === "string" && typeof v.created_at === "string" && !!v.from && !!v.capture;
433
530
  }
531
+ export function isKygitInviteNote(value) {
532
+ if (!value || typeof value !== "object")
533
+ return false;
534
+ const v = value;
535
+ return v.schema === "kygit.invite-note.v1" && typeof v.summary === "string" && typeof v.created_at === "string" && !!v.from && !!v.capture;
536
+ }
434
537
  // ─── Client-side secret scan (design D10 — no override flag) ────────────────
435
538
  const HANDOFF_NOTE_SECRET_PREFIXES = [
436
539
  /\bsk-[A-Za-z0-9]/,
@@ -441,6 +544,10 @@ const HANDOFF_NOTE_SECRET_PREFIXES = [
441
544
  /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
442
545
  /-----BEGIN [A-Z ]*CERTIFICATE-----/,
443
546
  /\bkgh1_[A-Za-z0-9_-]/,
547
+ // kygit-invite design D3: the invite key joins the scan's known-prefix
548
+ // list so a pasted kgi1_ reads as a bare secret in EITHER note kind, not
549
+ // just its own.
550
+ /\bkgi1_[A-Za-z0-9_-]/,
444
551
  ];
445
552
  /** Shannon entropy in bits/char over `s`. */
446
553
  function shannonEntropy(s) {
@@ -465,7 +572,7 @@ function hasHighEntropyToken(text) {
465
572
  }
466
573
  return null;
467
574
  }
468
- /** Every string leaf in the note, `path` = dotted field name for the refusal. */
575
+ /** Every string leaf in a claim note, `path` = dotted field name for the refusal. */
469
576
  function* noteStringLeaves(note) {
470
577
  yield { path: "summary", text: note.summary };
471
578
  for (const [group, arr] of [
@@ -491,9 +598,10 @@ function* noteStringLeaves(note) {
491
598
  /**
492
599
  * Refuse a note that carries a bare secret — no override flag exists
493
600
  * (design D10): a note is read by another agent, and secrets have the
494
- * secrets API. `null` when the note is clean.
601
+ * secrets API. `null` when the note is clean. Shared implementation for
602
+ * every claim-note kind.
495
603
  */
496
- export function scanHandoffNoteForSecrets(note) {
604
+ function scanNoteForSecrets(note) {
497
605
  for (const { path, text } of noteStringLeaves(note)) {
498
606
  for (const re of HANDOFF_NOTE_SECRET_PREFIXES) {
499
607
  if (re.test(text))
@@ -505,6 +613,18 @@ export function scanHandoffNoteForSecrets(note) {
505
613
  }
506
614
  return null;
507
615
  }
616
+ /**
617
+ * Refuse a note that carries a bare secret — no override flag exists
618
+ * (design D10): a note is read by another agent, and secrets have the
619
+ * secrets API. `null` when the note is clean.
620
+ */
621
+ export function scanHandoffNoteForSecrets(note) {
622
+ return scanNoteForSecrets(note);
623
+ }
624
+ /** The invite-kind sibling of {@link scanHandoffNoteForSecrets} (kygit-invite design D3). */
625
+ export function scanInviteNoteForSecrets(note) {
626
+ return scanNoteForSecrets(note);
627
+ }
508
628
  /** Throws `HANDOFF_NOTE_CONTAINS_SECRET` naming the field — call before the handoff commit is written. */
509
629
  export function assertHandoffNoteHasNoSecret(note) {
510
630
  const finding = scanHandoffNoteForSecrets(note);
@@ -512,4 +632,11 @@ export function assertHandoffNoteHasNoSecret(note) {
512
632
  fail("HANDOFF_NOTE_CONTAINS_SECRET", `the handoff note's \`${finding.field}\` ${finding.reason} — edit the note; secrets belong in the secrets API, never in a note another agent will read`, "scanning handoff note", { field: finding.field });
513
633
  }
514
634
  }
635
+ /** Throws `INVITE_NOTE_CONTAINS_SECRET` naming the field — call before the invite commit is written (kygit-invite design D3). */
636
+ export function assertInviteNoteHasNoSecret(note) {
637
+ const finding = scanInviteNoteForSecrets(note);
638
+ if (finding) {
639
+ fail("INVITE_NOTE_CONTAINS_SECRET", `the invite note's \`${finding.field}\` ${finding.reason} — edit the note; secrets belong in the secrets API, never in a note another agent will read`, "scanning invite note", { field: finding.field });
640
+ }
641
+ }
515
642
  //# sourceMappingURL=gitvault-handoff.js.map