@better-update/cli 0.42.1 → 0.44.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/dist/index.mjs CHANGED
@@ -23,7 +23,7 @@ import os, { tmpdir } from "node:os";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import { maxBy, uniqBy } from "es-toolkit";
25
25
  import forge from "node-forge";
26
- import { AndroidConfig } from "@expo/config-plugins";
26
+ import configPlugins from "@expo/config-plugins";
27
27
  import plistMod from "@expo/plist";
28
28
  import { ExpoRunFormatter } from "@expo/xcpretty";
29
29
  import { Buffer as Buffer$1 } from "node:buffer";
@@ -35,7 +35,7 @@ var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
35
35
 
36
36
  //#endregion
37
37
  //#region package.json
38
- var version = "0.42.1";
38
+ var version = "0.44.0";
39
39
 
40
40
  //#endregion
41
41
  //#region src/lib/interactive-mode.ts
@@ -133,6 +133,148 @@ const UploadHeaders = Schema.Record({
133
133
  value: Schema.String
134
134
  });
135
135
 
136
+ //#endregion
137
+ //#region ../../packages/api/src/domain/user-encryption-key.ts
138
+ /**
139
+ * A recipient key's role. A `device` key is user-owned (works across the user's
140
+ * orgs); `recovery` (offline break-glass) and `machine` (CI) keys are org-owned
141
+ * and have no `userId`.
142
+ */
143
+ const EncryptionKeyKind = Schema.Literal("device", "recovery", "machine");
144
+ /** An age recipient string (`age1...`) — a public key safe for the server to hold. */
145
+ const AgeRecipient = Schema.String.pipe(Schema.minLength(1), Schema.startsWith("age1")).annotations({ description: "age recipient public key (age1...)" });
146
+ /** An SSH-style key fingerprint (`SHA256:...`) shown for out-of-band verification. */
147
+ const KeyFingerprint = Schema.String.pipe(Schema.startsWith("SHA256:"), Schema.minLength(8)).annotations({ description: "SSH-style key fingerprint (SHA256:...)" });
148
+ /**
149
+ * A registered public recipient. Private keys never leave the owner's machine;
150
+ * the server only ever holds the public half.
151
+ */
152
+ var UserEncryptionKey = class extends Schema.Class("UserEncryptionKey")({
153
+ id: Id,
154
+ userId: Schema.NullOr(Id),
155
+ organizationId: Schema.NullOr(Id),
156
+ kind: EncryptionKeyKind,
157
+ publicKey: AgeRecipient,
158
+ label: Name120,
159
+ fingerprint: KeyFingerprint,
160
+ createdAt: DateTimeString,
161
+ lastUsedAt: Schema.NullOr(DateTimeString),
162
+ revokedAt: Schema.NullOr(DateTimeString)
163
+ }) {};
164
+ /** Register a new public recipient (device on first use, or a CI/recovery key). */
165
+ const RegisterEncryptionKeyBody = Schema.Struct({
166
+ kind: EncryptionKeyKind,
167
+ publicKey: AgeRecipient,
168
+ label: Name120,
169
+ fingerprint: KeyFingerprint
170
+ });
171
+
172
+ //#endregion
173
+ //#region ../../packages/api/src/domain/account-key.ts
174
+ /**
175
+ * Argon2id cost parameters carried in an account-key escrow header. The browser
176
+ * re-derives the KEK with these to open the escrow, so they travel with the blob
177
+ * (an enrollment can use heavier params without a format change).
178
+ */
179
+ const AccountKeyKdfParams = Schema.Struct({
180
+ time: Schema.Number.pipe(Schema.int(), Schema.positive()),
181
+ memory: Schema.Number.pipe(Schema.int(), Schema.positive()),
182
+ parallelism: Schema.Number.pipe(Schema.int(), Schema.positive())
183
+ });
184
+ /** A base64 Ed25519 public key — reserved for the deferred signed-roster integrity layer. */
185
+ const Ed25519PublicKey = Schema.String.pipe(Schema.minLength(1)).annotations({ description: "base64-encoded Ed25519 public key (reserved for signed-roster integrity)" });
186
+ /**
187
+ * A per-user account key's PUBLIC view (no escrow ciphertext). The age public key
188
+ * is the env-vault recipient the browser unwraps with; the escrow that holds the
189
+ * private halves is served only by the gated {@link AccountKeyEscrow} endpoint.
190
+ */
191
+ var AccountKey = class extends Schema.Class("AccountKey")({
192
+ id: Id,
193
+ userId: Id,
194
+ agePublicKey: AgeRecipient,
195
+ ed25519PublicKey: Ed25519PublicKey,
196
+ fingerprint: KeyFingerprint,
197
+ createdAt: DateTimeString,
198
+ lastUsedAt: Schema.NullOr(DateTimeString),
199
+ revokedAt: Schema.NullOr(DateTimeString)
200
+ }) {};
201
+ /**
202
+ * The full passphrase-sealed escrow for the caller — everything the browser needs
203
+ * to open it locally ({@link AccountKeyKdfParams} + salt + ciphertext). The server
204
+ * stores it opaquely and can never open it. Served by the `getMe` endpoint, which
205
+ * is gated on `vaultAccess:read`; a 2FA step-up for browser sessions is REQUIRED
206
+ * before any web consumer ships but is not yet implemented (P4). The contents stay
207
+ * passphrase-sealed regardless. `version`/`kdf`/`cipher` are the fixed v1 envelope
208
+ * constants, echoed so the browser can rebuild the crypto envelope.
209
+ */
210
+ var AccountKeyEscrow = class extends Schema.Class("AccountKeyEscrow")({
211
+ id: Id,
212
+ version: Schema.Literal(1),
213
+ agePublicKey: AgeRecipient,
214
+ ed25519PublicKey: Ed25519PublicKey,
215
+ fingerprint: KeyFingerprint,
216
+ kdf: Schema.Literal("argon2id"),
217
+ kdfParams: AccountKeyKdfParams,
218
+ salt: Schema.String.pipe(Schema.minLength(1)),
219
+ cipher: Schema.Literal("xchacha20poly1305"),
220
+ escrowCt: Schema.String.pipe(Schema.minLength(1)),
221
+ createdAt: DateTimeString
222
+ }) {};
223
+ /**
224
+ * Register the caller's account key. The CLI generates the keypair and seals the
225
+ * private halves under the passphrase BEFORE calling this — the server only ever
226
+ * receives the public keys + the opaque escrow ciphertext + its KDF header.
227
+ */
228
+ const RegisterAccountKeyBody = Schema.Struct({
229
+ agePublicKey: AgeRecipient,
230
+ ed25519PublicKey: Ed25519PublicKey,
231
+ fingerprint: KeyFingerprint,
232
+ kdfParams: AccountKeyKdfParams,
233
+ salt: Schema.String.pipe(Schema.minLength(1)),
234
+ escrowCt: Schema.String.pipe(Schema.minLength(1))
235
+ });
236
+ /**
237
+ * The org's members' live account keys (public view). The env-vault cutover and
238
+ * rotation fetch it to enumerate the account-key recipients and resolve each
239
+ * `account_keys.id` to the `age` recipient the new env key is wrapped to.
240
+ */
241
+ const AccountKeyList = Schema.Struct({ items: Schema.Array(AccountKey) });
242
+ /**
243
+ * Re-seal the caller's account-key escrow under a new passphrase (the CLI
244
+ * `passphrase change` flow). The keypair itself is unchanged — only the
245
+ * passphrase-derived seal — so every env-vault wrap to it stays valid.
246
+ */
247
+ const ResealAccountKeyBody = Schema.Struct({
248
+ kdfParams: AccountKeyKdfParams,
249
+ salt: Schema.String.pipe(Schema.minLength(1)),
250
+ escrowCt: Schema.String.pipe(Schema.minLength(1))
251
+ });
252
+
253
+ //#endregion
254
+ //#region ../../packages/api/src/domain/errors.ts
255
+ var BadRequest = class extends Schema.TaggedError()("BadRequest", { message: Schema.String }, HttpApiSchema.annotations({ status: 400 })) {};
256
+ var Conflict = class extends Schema.TaggedError()("Conflict", { message: Schema.String }, HttpApiSchema.annotations({ status: 409 })) {};
257
+ var NotAcceptable = class extends Schema.TaggedError()("NotAcceptable", { message: Schema.String }, HttpApiSchema.annotations({ status: 406 })) {};
258
+
259
+ //#endregion
260
+ //#region ../../packages/api/src/groups/account-keys.ts
261
+ var AccountKeysGroup = class extends HttpApiGroup.make("accountKeys").add(HttpApiEndpoint.post("register", "/api/account-keys").setPayload(RegisterAccountKeyBody).addSuccess(AccountKey, { status: 201 }).annotateContext(OpenApi.annotations({
262
+ title: "Register account key",
263
+ description: "Register the caller's per-user account key — the env-vault recipient the browser unwraps with. The CLI seals the private halves under the passphrase first; the server stores the escrow opaquely."
264
+ }))).add(HttpApiEndpoint.get("list", "/api/account-keys").addSuccess(AccountKeyList).annotateContext(OpenApi.annotations({
265
+ title: "List org account keys",
266
+ description: "List the live account keys of the org's members (public view, admin) — the env-vault cutover/rotate uses it to enumerate the account-key recipients and resolve each id to its age recipient."
267
+ }))).add(HttpApiEndpoint.patch("reseal", "/api/account-keys/me").setPayload(ResealAccountKeyBody).addSuccess(AccountKey).annotateContext(OpenApi.annotations({
268
+ title: "Re-seal account-key escrow",
269
+ description: "Re-seal the caller's account-key escrow under a new passphrase (the CLI `passphrase change` flow). The keypair is unchanged, so every env-vault wrap to it stays valid."
270
+ }))).add(HttpApiEndpoint.get("getMe", "/api/account-keys/me").addSuccess(AccountKeyEscrow).annotateContext(OpenApi.annotations({
271
+ title: "Get my account-key escrow",
272
+ description: "Return the caller's passphrase-sealed account-key escrow for local unlock. The contents stay passphrase-sealed regardless of caller. Browser (cookie) callers must first complete a WebAuthn step-up via /api/web-vault/step-up; CLI bearer callers are exempt."
273
+ }))).addError(NotFound).addError(Conflict).addError(BadRequest).addError(Forbidden).annotateContext(OpenApi.annotations({
274
+ title: "Account Keys",
275
+ description: "Per-user account keys for browser-side env-vault access"
276
+ })) {};
277
+
136
278
  //#endregion
137
279
  //#region ../../packages/api/src/domain/admin.ts
138
280
  /**
@@ -270,12 +412,6 @@ var AndroidApplicationIdentifier = class extends Schema.Class("AndroidApplicatio
270
412
  const CreateAndroidApplicationIdentifierBody = Schema.Struct({ packageName: AndroidPackageName });
271
413
  const DeleteAndroidApplicationIdentifierResult = DeletedResult;
272
414
 
273
- //#endregion
274
- //#region ../../packages/api/src/domain/errors.ts
275
- var BadRequest = class extends Schema.TaggedError()("BadRequest", { message: Schema.String }, HttpApiSchema.annotations({ status: 400 })) {};
276
- var Conflict = class extends Schema.TaggedError()("Conflict", { message: Schema.String }, HttpApiSchema.annotations({ status: 409 })) {};
277
- var NotAcceptable = class extends Schema.TaggedError()("NotAcceptable", { message: Schema.String }, HttpApiSchema.annotations({ status: 406 })) {};
278
-
279
415
  //#endregion
280
416
  //#region ../../packages/api/src/groups/android-application-identifiers.ts
281
417
  const projectIdParam$6 = HttpApiSchema.param("projectId", Schema.String);
@@ -364,7 +500,17 @@ var OrgVault = class extends Schema.Class("OrgVault")({
364
500
  */
365
501
  rotationPending: Schema.Boolean,
366
502
  rotationPendingSince: Schema.NullOr(DateTimeString),
367
- rotationPendingReason: Schema.NullOr(Schema.String)
503
+ rotationPendingReason: Schema.NullOr(Schema.String),
504
+ /**
505
+ * Env-vault (EV) state from the two-vault split. `envVaultCutoverAt` is `null`
506
+ * until the org forks its env values into a separate key; while `null`,
507
+ * `envVaultVersion` is unused and env stays part of the credentials vault.
508
+ */
509
+ envVaultVersion: VaultVersion,
510
+ envRotationPending: Schema.Boolean,
511
+ envRotationPendingSince: Schema.NullOr(DateTimeString),
512
+ envRotationPendingReason: Schema.NullOr(Schema.String),
513
+ envVaultCutoverAt: Schema.NullOr(DateTimeString)
368
514
  }) {};
369
515
  /**
370
516
  * One wrap of the org vault key to a recipient's public key — an `age` blob the
@@ -403,10 +549,32 @@ const VaultWrapInput = Schema.Struct({
403
549
  wrappedKey: Schema.String.pipe(Schema.minLength(1))
404
550
  });
405
551
  /**
406
- * Bootstrap the org vault on the first upload: the initial wrap rows, which must
407
- * include the uploader's own recipient and the offline recovery recipient.
552
+ * An env-vault recipient kind. Superset of the credentials-vault recipient kinds
553
+ * plus `account` — the per-user account key the browser unwraps the env vault
554
+ * with. `recipientId` references `user_encryption_keys.id` for the first three and
555
+ * `account_keys.id` for `account` (polymorphic; see migration 0071). Defined here
556
+ * (alongside `VaultWrapInput`) rather than in `env-vault.ts` so `BootstrapVaultBody`
557
+ * can carry env wraps without a circular import (`env-vault.ts` → `org-vault.ts`).
408
558
  */
409
- const BootstrapVaultBody = Schema.Struct({ wraps: Schema.Array(VaultWrapInput).pipe(Schema.minItems(1)) });
559
+ const EnvVaultRecipientKind = Schema.Literal("device", "recovery", "machine", "account");
560
+ /** One recipient's env-vault wrap row in a bootstrap / cutover / grant / rotate submission (age blob, base64). */
561
+ const EnvVaultWrapInput = Schema.Struct({
562
+ recipientKind: EnvVaultRecipientKind,
563
+ recipientId: Id,
564
+ wrappedKey: Schema.String.pipe(Schema.minLength(1))
565
+ });
566
+ /**
567
+ * Bootstrap the org vault: the initial credential-vault wrap rows AND the initial
568
+ * env-vault wrap rows, each of which must include the uploader's own recipient and
569
+ * the offline recovery recipient. Orgs are "born forked" — the env vault is set up
570
+ * at bootstrap (server stamps the cutover + env version), so a separate
571
+ * `env-vault migrate` step is never needed. `envWraps` is required: a client that
572
+ * cannot produce them is too old to bootstrap.
573
+ */
574
+ const BootstrapVaultBody = Schema.Struct({
575
+ wraps: Schema.Array(VaultWrapInput).pipe(Schema.minItems(1)),
576
+ envWraps: Schema.Array(EnvVaultWrapInput).pipe(Schema.minItems(1))
577
+ });
410
578
  /**
411
579
  * Add a single wrap row at the current vault version (grant another user, or
412
580
  * self-link your own device). Authz is enforced server-side; the wrap itself is
@@ -1650,10 +1818,18 @@ const EnvVarListScope = Schema.Literal("all", "project", "global");
1650
1818
  * AAD `credentialId` when sealing; the envelope fields are the opaque ciphertext,
1651
1819
  * wrapped DEK, and vault version. The server stores these and never decrypts —
1652
1820
  * env var values are end-to-end encrypted, like credentials.
1821
+ *
1822
+ * `vaultKind` names which vault the DEK was sealed under. It is OPTIONAL for
1823
+ * back-compat: a pre-split CLI omits it (the server treats absence as
1824
+ * `"credentials"`). Once an org cuts over to a separate env vault, the server
1825
+ * requires `"env"` here — without it a credentials-keyed blob from an un-upgraded
1826
+ * (or racing) CLI would otherwise be silently stored into an env-vault row and be
1827
+ * permanently undecryptable. See `assertEnvVaultWriteAllowed`.
1653
1828
  */
1654
1829
  const EnvVarValueEnvelope = Schema.Struct({
1655
1830
  id: Id,
1656
- ...encryptedEnvelopeFields
1831
+ ...encryptedEnvelopeFields,
1832
+ vaultKind: Schema.optional(Schema.Literal("credentials", "env"))
1657
1833
  });
1658
1834
  /**
1659
1835
  * Env var metadata. The value is **not** here — it lives encrypted in the
@@ -1751,6 +1927,9 @@ var EnvVarsGroup = class extends HttpApiGroup.make("env-vars").add(HttpApiEndpoi
1751
1927
  }))).add(HttpApiEndpoint.get("get")`/api/env-vars/${idParam}`.addSuccess(EnvVar).annotateContext(OpenApi.annotations({
1752
1928
  title: "Get environment variable",
1753
1929
  description: "Get an environment variable's metadata by ID (no value)"
1930
+ }))).add(HttpApiEndpoint.get("getValue")`/api/env-vars/${idParam}/value`.addSuccess(EnvVarValueEnvelope).annotateContext(OpenApi.annotations({
1931
+ title: "Get sealed env-var value",
1932
+ description: "Return the active value's sealed envelope (ciphertext, wrapped DEK, vault version) for client-side decryption in the browser env-vault. Browser (cookie) callers must first complete a WebAuthn step-up; CLI bearer callers use the bulk export instead."
1754
1933
  }))).add(HttpApiEndpoint.patch("update")`/api/env-vars/${idParam}`.setPayload(UpdateEnvVarBody).addSuccess(EnvVar).annotateContext(OpenApi.annotations({
1755
1934
  title: "Update environment variable",
1756
1935
  description: "Change the value (a new sealed revision) and/or the visibility tier. The environment is immutable."
@@ -1777,6 +1956,105 @@ var EnvVarsGroup = class extends HttpApiGroup.make("env-vars").add(HttpApiEndpoi
1777
1956
  description: "Manage end-to-end encrypted, versioned environment variables for project builds"
1778
1957
  })) {};
1779
1958
 
1959
+ //#endregion
1960
+ //#region ../../packages/api/src/domain/env-vault.ts
1961
+ /** One wrap of the env-vault key to a recipient (an opaque `age` blob). */
1962
+ var OrgEnvVaultKeyWrap = class extends Schema.Class("OrgEnvVaultKeyWrap")({
1963
+ organizationId: Id,
1964
+ envVaultVersion: VaultVersion,
1965
+ recipientKind: EnvVaultRecipientKind,
1966
+ recipientId: Id,
1967
+ wrappedKey: Schema.String,
1968
+ createdAt: DateTimeString
1969
+ }) {};
1970
+ /** The wrapped env-vault key for the calling recipient — fetched, then unwrapped client-side. */
1971
+ const RecipientEnvVaultKey = Schema.Struct({
1972
+ envVaultVersion: VaultVersion,
1973
+ wrappedKey: Schema.String
1974
+ });
1975
+ /** A recipient currently holding the env-vault key (kind + id + when wrapped). */
1976
+ const EnvVaultRecipientRef = Schema.Struct({
1977
+ recipientKind: EnvVaultRecipientKind,
1978
+ recipientId: Id,
1979
+ createdAt: DateTimeString
1980
+ });
1981
+ /** Every recipient holding the env-vault key at the current version. */
1982
+ const EnvVaultRecipients = Schema.Struct({
1983
+ envVaultVersion: VaultVersion,
1984
+ recipients: Schema.Array(EnvVaultRecipientRef)
1985
+ });
1986
+ /** Add a single env wrap at the current env version (grant or self-link). */
1987
+ const AddEnvVaultWrapBody = Schema.Struct({
1988
+ envVaultVersion: VaultVersion,
1989
+ wrap: EnvVaultWrapInput
1990
+ });
1991
+ /** One env-var revision's DEK re-wrapped under the env-vault key (cutover/rotation). */
1992
+ const EnvVaultDekUpdate = Schema.Struct({
1993
+ credentialId: Id,
1994
+ wrappedDek: WrappedDek
1995
+ });
1996
+ /** One env-var revision's currently-stored wrapped DEK + version (the rotation source). */
1997
+ const EnvVaultDekRef = Schema.Struct({
1998
+ credentialId: Id,
1999
+ wrappedDek: WrappedDek,
2000
+ vaultVersion: VaultVersion
2001
+ });
2002
+ /** Every wrapped env DEK + the current env-vault version (the rotation source set). */
2003
+ const EnvVaultCredentialDeks = Schema.Struct({
2004
+ envVaultVersion: VaultVersion,
2005
+ deks: Schema.Array(EnvVaultDekRef)
2006
+ });
2007
+ /**
2008
+ * The one-shot cutover: fork the org's env values into a separate env vault. The
2009
+ * client generates the env key, wraps it to every recipient (device/recovery/
2010
+ * machine + each member's account key), and re-keys every env DEK from the
2011
+ * credentials key to the env key. Must include an offline recovery recipient and
2012
+ * re-key every env-var revision.
2013
+ */
2014
+ const CutoverEnvVaultBody = Schema.Struct({
2015
+ wraps: Schema.Array(EnvVaultWrapInput).pipe(Schema.minItems(1)),
2016
+ envDeks: Schema.Array(EnvVaultDekUpdate)
2017
+ });
2018
+ /**
2019
+ * Rotate (or revoke) the env-vault key. The client generates a new key at
2020
+ * `fromVersion + 1`, re-wraps every env DEK under it, and re-wraps the new key to
2021
+ * the surviving recipients. Applied atomically with compare-and-swap on
2022
+ * `fromVersion`; must re-key every env-var revision.
2023
+ */
2024
+ const RotateEnvVaultBody = Schema.Struct({
2025
+ fromVersion: VaultVersion,
2026
+ wraps: Schema.Array(EnvVaultWrapInput).pipe(Schema.minItems(1)),
2027
+ envDeks: Schema.Array(EnvVaultDekUpdate)
2028
+ });
2029
+
2030
+ //#endregion
2031
+ //#region ../../packages/api/src/groups/env-vault.ts
2032
+ /** `:recipientKind` / `:recipientId` path params for a polymorphic env recipient. */
2033
+ const recipientKindParam = HttpApiSchema.param("recipientKind", EnvVaultRecipientKind);
2034
+ const recipientIdParam = HttpApiSchema.param("recipientId", Id);
2035
+ var EnvVaultGroup = class extends HttpApiGroup.make("envVault").add(HttpApiEndpoint.post("cutover", "/api/env-vault/cutover").setPayload(CutoverEnvVaultBody).addSuccess(OrgVault).annotateContext(OpenApi.annotations({
2036
+ title: "Cut over to the env vault",
2037
+ description: "One-shot fork of the org's env values into a separate env vault: wrap the new env key to every recipient and re-key every env DEK in place. Idempotent (compare-and-swap on the cutover sentinel)."
2038
+ }))).add(HttpApiEndpoint.get("listWraps", "/api/env-vault/wraps").addSuccess(EnvVaultRecipients).annotateContext(OpenApi.annotations({
2039
+ title: "List env-vault recipients",
2040
+ description: "List the recipients holding the env-vault key at the current version"
2041
+ }))).add(HttpApiEndpoint.post("addWrap", "/api/env-vault/wraps").setPayload(AddEnvVaultWrapBody).addSuccess(OrgEnvVaultKeyWrap, { status: 201 }).annotateContext(OpenApi.annotations({
2042
+ title: "Add env-vault wrap",
2043
+ description: "Wrap the env-vault key to a recipient — granting a member's account key (admin) or self-linking your own device/account key"
2044
+ }))).add(HttpApiEndpoint.get("getWrap")`/api/env-vault/wraps/${recipientKindParam}/${recipientIdParam}`.addSuccess(RecipientEnvVaultKey).annotateContext(OpenApi.annotations({
2045
+ title: "Get env-vault wrap",
2046
+ description: "Fetch the wrapped env-vault key for a recipient to unwrap locally"
2047
+ }))).add(HttpApiEndpoint.get("listCredentialDeks", "/api/env-vault/credential-deks").addSuccess(EnvVaultCredentialDeks).annotateContext(OpenApi.annotations({
2048
+ title: "List wrapped env DEKs",
2049
+ description: "Every wrapped env DEK + the current env-vault version — fetched to re-wrap under a new key during a rotation"
2050
+ }))).add(HttpApiEndpoint.post("rotate", "/api/env-vault/rotate").setPayload(RotateEnvVaultBody).addSuccess(OrgVault).annotateContext(OpenApi.annotations({
2051
+ title: "Rotate env-vault key",
2052
+ description: "Revoke or rotate (admin): bump the env-vault version, re-wrap every env DEK, and re-wrap the new key to the surviving recipients — applied atomically with compare-and-swap"
2053
+ }))).addError(NotFound).addError(Conflict).addError(BadRequest).addError(Forbidden).annotateContext(OpenApi.annotations({
2054
+ title: "Env Vault",
2055
+ description: "Manage the organization's separate end-to-end encrypted env-vault key wraps"
2056
+ })) {};
2057
+
1780
2058
  //#endregion
1781
2059
  //#region ../../packages/api/src/groups/environments.ts
1782
2060
  /** `:name` path parameter — the environment name (built-in or user-defined). */
@@ -2670,42 +2948,6 @@ var UpdatesGroup = class extends HttpApiGroup.make("updates").add(HttpApiEndpoin
2670
2948
  description: "Update publishing, deletion, republish, and per-update rollout endpoints"
2671
2949
  })) {};
2672
2950
 
2673
- //#endregion
2674
- //#region ../../packages/api/src/domain/user-encryption-key.ts
2675
- /**
2676
- * A recipient key's role. A `device` key is user-owned (works across the user's
2677
- * orgs); `recovery` (offline break-glass) and `machine` (CI) keys are org-owned
2678
- * and have no `userId`.
2679
- */
2680
- const EncryptionKeyKind = Schema.Literal("device", "recovery", "machine");
2681
- /** An age recipient string (`age1...`) — a public key safe for the server to hold. */
2682
- const AgeRecipient = Schema.String.pipe(Schema.minLength(1), Schema.startsWith("age1")).annotations({ description: "age recipient public key (age1...)" });
2683
- /** An SSH-style key fingerprint (`SHA256:...`) shown for out-of-band verification. */
2684
- const KeyFingerprint = Schema.String.pipe(Schema.startsWith("SHA256:"), Schema.minLength(8)).annotations({ description: "SSH-style key fingerprint (SHA256:...)" });
2685
- /**
2686
- * A registered public recipient. Private keys never leave the owner's machine;
2687
- * the server only ever holds the public half.
2688
- */
2689
- var UserEncryptionKey = class extends Schema.Class("UserEncryptionKey")({
2690
- id: Id,
2691
- userId: Schema.NullOr(Id),
2692
- organizationId: Schema.NullOr(Id),
2693
- kind: EncryptionKeyKind,
2694
- publicKey: AgeRecipient,
2695
- label: Name120,
2696
- fingerprint: KeyFingerprint,
2697
- createdAt: DateTimeString,
2698
- lastUsedAt: Schema.NullOr(DateTimeString),
2699
- revokedAt: Schema.NullOr(DateTimeString)
2700
- }) {};
2701
- /** Register a new public recipient (device on first use, or a CI/recovery key). */
2702
- const RegisterEncryptionKeyBody = Schema.Struct({
2703
- kind: EncryptionKeyKind,
2704
- publicKey: AgeRecipient,
2705
- label: Name120,
2706
- fingerprint: KeyFingerprint
2707
- });
2708
-
2709
2951
  //#endregion
2710
2952
  //#region ../../packages/api/src/groups/user-encryption-keys.ts
2711
2953
  var UserEncryptionKeysGroup = class extends HttpApiGroup.make("userEncryptionKeys").add(HttpApiEndpoint.get("list", "/api/encryption-keys").addSuccess(Schema.Struct({ items: Schema.Array(UserEncryptionKey) })).annotateContext(OpenApi.annotations({
@@ -2719,6 +2961,44 @@ var UserEncryptionKeysGroup = class extends HttpApiGroup.make("userEncryptionKey
2719
2961
  description: "Register and list end-to-end encryption recipient public keys"
2720
2962
  })) {};
2721
2963
 
2964
+ //#endregion
2965
+ //#region ../../packages/api/src/domain/web-vault.ts
2966
+ /**
2967
+ * A WebAuthn authentication assertion, JSON-stringified by the browser
2968
+ * (`@better-auth/passkey/client` drives `navigator.credentials.get()`), carried
2969
+ * as a single string so the wire payload stays a plain object with no opaque
2970
+ * `unknown` fields. The step-up handler parses it and hands it to better-auth's
2971
+ * `verifyPasskeyAuthentication`; the exact inner shape is the plugin's contract,
2972
+ * not ours.
2973
+ */
2974
+ const PasskeyStepUpBody = Schema.Struct({ assertionJson: Schema.String });
2975
+ /**
2976
+ * Result of a successful step-up: the ISO instant the server recorded, which the
2977
+ * env-vault gate now treats as the start of the step-up TTL window for this
2978
+ * session.
2979
+ */
2980
+ const PasskeyStepUpResult = Schema.Struct({ verifiedAt: Schema.String });
2981
+
2982
+ //#endregion
2983
+ //#region ../../packages/api/src/groups/web-vault.ts
2984
+ /**
2985
+ * Web-vault step-up: the WebAuthn re-authentication a browser session performs
2986
+ * before it may read/write env values (the "2FA mandatory before web env access"
2987
+ * rule, spec §P4). The browser fetches a challenge from better-auth's
2988
+ * `generate-authenticate-options`, runs the passkey ceremony, then POSTs the
2989
+ * assertion here; the server verifies it via the passkey plugin and records a
2990
+ * fresh step-up for THIS session. The env-vault write gate
2991
+ * (assert-web-env-step-up) consults that record. CLI/CI (bearer) callers never
2992
+ * need this — they are exempt from the gate.
2993
+ */
2994
+ var WebVaultGroup = class extends HttpApiGroup.make("webVault").add(HttpApiEndpoint.post("stepUp", "/api/web-vault/step-up").setPayload(PasskeyStepUpBody).addSuccess(PasskeyStepUpResult).annotateContext(OpenApi.annotations({
2995
+ title: "WebAuthn step-up",
2996
+ description: "Verify a fresh passkey assertion for the current browser session and record the step-up. Required before browser env-value reads/writes; cookie transport only."
2997
+ }))).addError(BadRequest).addError(Forbidden).annotateContext(OpenApi.annotations({
2998
+ title: "Web Vault",
2999
+ description: "WebAuthn step-up for browser env-vault access"
3000
+ })) {};
3001
+
2722
3002
  //#endregion
2723
3003
  //#region ../../packages/api/src/domain/webhook.ts
2724
3004
  const WebhookEventName = Schema.Literal("update.published", "build.completed");
@@ -2775,7 +3055,7 @@ var WebhooksGroup = class extends HttpApiGroup.make("webhooks").add(HttpApiEndpo
2775
3055
 
2776
3056
  //#endregion
2777
3057
  //#region ../../packages/api/src/api.ts
2778
- var ManagementApi = class extends HttpApi.make("management-api").add(ProjectsGroup).add(BranchesGroup).add(ChannelsGroup).add(EnvironmentsGroup).add(UpdatesGroup).add(AssetsGroup).add(AnalyticsGroup).add(BuildsGroup).add(RuntimesGroup).add(EnvVarsGroup).add(FingerprintsGroup).add(AuditLogsGroup).add(DevicesGroup).add(AppleTeamsGroup).add(AppleDistributionCertificatesGroup).add(ApplePushKeysGroup).add(ApplePushCertificatesGroup).add(ApplePayCertificatesGroup).add(ApplePassTypeCertificatesGroup).add(AscApiKeysGroup).add(AppleProvisioningProfilesGroup).add(GoogleServiceAccountKeysGroup).add(IosBundleConfigurationsGroup).add(IosAppMetadataGroup).add(SubmissionsGroup).add(AndroidApplicationIdentifiersGroup).add(AndroidUploadKeystoresGroup).add(AndroidBuildCredentialsGroup).add(BuildCredentialsGroup).add(UserEncryptionKeysGroup).add(OrgVaultGroup).add(MeGroup).add(WebhooksGroup).add(PoliciesGroup).add(GroupsGroup).add(PolicyAttachmentsGroup).add(ApiKeysGroup).add(InvitationsGroup).add(MembersGroup).add(OrganizationGroup).add(AdminGroup).middleware(Authentication).annotateContext(OpenApi.annotations({
3058
+ var ManagementApi = class extends HttpApi.make("management-api").add(ProjectsGroup).add(BranchesGroup).add(ChannelsGroup).add(EnvironmentsGroup).add(UpdatesGroup).add(AssetsGroup).add(AnalyticsGroup).add(BuildsGroup).add(RuntimesGroup).add(EnvVarsGroup).add(FingerprintsGroup).add(AuditLogsGroup).add(DevicesGroup).add(AppleTeamsGroup).add(AppleDistributionCertificatesGroup).add(ApplePushKeysGroup).add(ApplePushCertificatesGroup).add(ApplePayCertificatesGroup).add(ApplePassTypeCertificatesGroup).add(AscApiKeysGroup).add(AppleProvisioningProfilesGroup).add(GoogleServiceAccountKeysGroup).add(IosBundleConfigurationsGroup).add(IosAppMetadataGroup).add(SubmissionsGroup).add(AndroidApplicationIdentifiersGroup).add(AndroidUploadKeystoresGroup).add(AndroidBuildCredentialsGroup).add(BuildCredentialsGroup).add(UserEncryptionKeysGroup).add(OrgVaultGroup).add(AccountKeysGroup).add(EnvVaultGroup).add(WebVaultGroup).add(MeGroup).add(WebhooksGroup).add(PoliciesGroup).add(GroupsGroup).add(PolicyAttachmentsGroup).add(ApiKeysGroup).add(InvitationsGroup).add(MembersGroup).add(OrganizationGroup).add(AdminGroup).middleware(Authentication).annotateContext(OpenApi.annotations({
2779
3059
  title: "Better Update Management API",
2780
3060
  version: "1.0.0",
2781
3061
  description: "Management API for OTA update publishing, deployment, and analytics"
@@ -3731,8 +4011,16 @@ const VAULT_CACHE_TTL_MS = 900 * 1e3;
3731
4011
  /** Bounds for a user-chosen TTL (`credentials unlock --duration`). */
3732
4012
  const VAULT_CACHE_TTL_MIN_MS = 60 * 1e3;
3733
4013
  const VAULT_CACHE_TTL_MAX_MS = 1440 * 60 * 1e3;
3734
- /** Keychain service name; the account is the recipient's public key. */
4014
+ /** Keychain service name; the account is the recipient's public key (per {@link cacheAccount}). */
3735
4015
  const KEYCHAIN_SERVICE = "better-update-vault";
4016
+ /**
4017
+ * The keychain account a recipient's cached key is stored under, namespaced by
4018
+ * vault kind so the credentials and env vaults cache independently. The
4019
+ * credentials vault keeps the bare public key — byte-identical to entries written
4020
+ * before the two-vault split, so an upgrade keeps any live unlock — while the env
4021
+ * vault is prefixed.
4022
+ */
4023
+ const cacheAccount = (publicKey, vaultKind = "credentials") => vaultKind === "env" ? `env:${publicKey}` : publicKey;
3736
4024
  const isCachedVaultEntry = (value) => isRecord$1(value) && typeof value["vaultKey"] === "string" && typeof value["vaultVersion"] === "number" && typeof value["keyId"] === "string" && typeof value["exp"] === "number";
3737
4025
  /** Serialize an unlocked vault into a keychain blob, stamping a TTL from `now`. */
3738
4026
  const encodeCacheEntry = (vault, now, ttlMs = VAULT_CACHE_TTL_MS) => JSON.stringify({
@@ -3765,28 +4053,30 @@ const VaultCacheLive = Layer.effect(VaultCache, Effect.gen(function* () {
3765
4053
  const flag = yield* runtime.getEnv("BETTER_UPDATE_NO_CACHE");
3766
4054
  return flag !== void 0 && flag.length > 0 && flag !== "0" && flag !== "false";
3767
4055
  });
3768
- const readRaw = (publicKey) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, publicKey).getPassword()).pipe(Effect.orElseSucceed(() => null));
3769
- const writeRaw = (publicKey, blob) => Effect.try(() => {
3770
- new Entry(KEYCHAIN_SERVICE, publicKey).setPassword(blob);
4056
+ const readRaw = (account) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, account).getPassword()).pipe(Effect.orElseSucceed(() => null));
4057
+ const writeRaw = (account, blob) => Effect.try(() => {
4058
+ new Entry(KEYCHAIN_SERVICE, account).setPassword(blob);
3771
4059
  }).pipe(Effect.ignore);
3772
- const deleteRaw = (publicKey) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, publicKey).deletePassword()).pipe(Effect.ignore);
4060
+ const deleteRaw = (account) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, account).deletePassword()).pipe(Effect.ignore);
3773
4061
  return {
3774
- get: (publicKey) => Effect.gen(function* () {
4062
+ get: (publicKey, vaultKind) => Effect.gen(function* () {
3775
4063
  if (yield* cacheDisabled) return;
3776
- const raw = yield* readRaw(publicKey);
4064
+ const account = cacheAccount(publicKey, vaultKind);
4065
+ const raw = yield* readRaw(account);
3777
4066
  if (raw === null) return;
3778
4067
  const decoded = decodeCacheEntry(raw, yield* Clock.currentTimeMillis);
3779
4068
  if (decoded === void 0) {
3780
- yield* deleteRaw(publicKey);
4069
+ yield* deleteRaw(account);
3781
4070
  return;
3782
4071
  }
3783
4072
  return decoded;
3784
4073
  }),
3785
- set: (publicKey, vault, ttlMs) => Effect.gen(function* () {
4074
+ set: (publicKey, vault, opts) => Effect.gen(function* () {
3786
4075
  if (yield* cacheDisabled) return;
3787
- yield* writeRaw(publicKey, encodeCacheEntry(vault, yield* Clock.currentTimeMillis, ttlMs));
4076
+ const now = yield* Clock.currentTimeMillis;
4077
+ yield* writeRaw(cacheAccount(publicKey, opts?.vaultKind), encodeCacheEntry(vault, now, opts?.ttlMs));
3788
4078
  }),
3789
- clear: (publicKey) => deleteRaw(publicKey)
4079
+ clear: (publicKey, vaultKind) => deleteRaw(cacheAccount(publicKey, vaultKind))
3790
4080
  };
3791
4081
  }));
3792
4082
 
@@ -5764,7 +6054,7 @@ const listCommand$9 = defineCommand({
5764
6054
  ]), "No branches found.");
5765
6055
  }))
5766
6056
  });
5767
- const createCommand$5 = defineCommand({
6057
+ const createCommand$6 = defineCommand({
5768
6058
  meta: {
5769
6059
  name: "create",
5770
6060
  description: "Create a branch"
@@ -5872,7 +6162,7 @@ const branchesCommand = defineCommand({
5872
6162
  subCommands: {
5873
6163
  list: listCommand$9,
5874
6164
  view: viewCommand$3,
5875
- create: createCommand$5,
6165
+ create: createCommand$6,
5876
6166
  rename: renameCommand$2,
5877
6167
  delete: deleteCommand$7
5878
6168
  }
@@ -9007,7 +9297,7 @@ const sha512 = /* @__PURE__ */ createHasher$1(() => new _SHA512(), /* @__PURE__
9007
9297
  //#endregion
9008
9298
  //#region ../../packages/credentials-crypto/src/aead.ts
9009
9299
  const aead = managedNonce(xchacha20poly1305);
9010
- const textEncoder$2 = new TextEncoder();
9300
+ const textEncoder$3 = new TextEncoder();
9011
9301
  const KEY_BYTES$1 = 32;
9012
9302
  const LENGTH_PREFIX_BYTES = 4;
9013
9303
  /** A fresh 32-byte symmetric key — an org vault key or a per-credential DEK. */
@@ -9029,7 +9319,7 @@ const aeadDecrypt = (key, sealed, aad) => aead(key, aad).decrypt(sealed);
9029
9319
  * ever encode to the same bytes (prevents cross-binding confusion).
9030
9320
  */
9031
9321
  const encodeAad = (domain, parts) => {
9032
- const segments = [domain, ...parts.map(String)].map((text) => textEncoder$2.encode(text));
9322
+ const segments = [domain, ...parts.map(String)].map((text) => textEncoder$3.encode(text));
9033
9323
  const size = segments.reduce((total, seg) => total + LENGTH_PREFIX_BYTES + seg.length, 0);
9034
9324
  const out = new Uint8Array(size);
9035
9325
  const view = new DataView(out.buffer);
@@ -9041,7 +9331,7 @@ const encodeAad = (domain, parts) => {
9041
9331
  return out;
9042
9332
  };
9043
9333
  /** SSH-style fingerprint of an age recipient string: `SHA256:<base64, no padding>`. */
9044
- const fingerprint = (recipient) => `SHA256:${toBase64(sha256$1(textEncoder$2.encode(recipient))).replace(/=+$/u, "")}`;
9334
+ const fingerprint = (recipient) => `SHA256:${toBase64(sha256$1(textEncoder$3.encode(recipient))).replace(/=+$/u, "")}`;
9045
9335
 
9046
9336
  //#endregion
9047
9337
  //#region ../../node_modules/.bun/@noble+hashes@2.2.0/node_modules/@noble/hashes/_blake.js
@@ -17823,6 +18113,191 @@ var PrimeEdwardsPoint = class {
17823
18113
  return this;
17824
18114
  }
17825
18115
  };
18116
+ /**
18117
+ * Initializes EdDSA signatures over given Edwards curve.
18118
+ * @param Point - Edwards point constructor.
18119
+ * @param cHash - Hash function.
18120
+ * @param eddsaOpts - Optional signature helpers. See {@link EdDSAOpts}.
18121
+ * @returns EdDSA helper namespace.
18122
+ * @throws If the hash function, options, or derived point operations are invalid. {@link Error}
18123
+ * @example
18124
+ * Initializes EdDSA signatures over given Edwards curve.
18125
+ *
18126
+ * ```ts
18127
+ * import { eddsa } from '@noble/curves/abstract/edwards.js';
18128
+ * import { jubjub } from '@noble/curves/misc.js';
18129
+ * import { sha512 } from '@noble/hashes/sha2.js';
18130
+ * const sigs = eddsa(jubjub.Point, sha512);
18131
+ * const { secretKey, publicKey } = sigs.keygen();
18132
+ * const msg = new TextEncoder().encode('hello noble');
18133
+ * const sig = sigs.sign(msg, secretKey);
18134
+ * const isValid = sigs.verify(sig, msg, publicKey);
18135
+ * ```
18136
+ */
18137
+ function eddsa(Point, cHash, eddsaOpts = {}) {
18138
+ if (typeof cHash !== "function") throw new Error("\"hash\" function param is required");
18139
+ const hash = cHash;
18140
+ const opts = eddsaOpts;
18141
+ validateObject(opts, {}, {
18142
+ adjustScalarBytes: "function",
18143
+ randomBytes: "function",
18144
+ domain: "function",
18145
+ prehash: "function",
18146
+ zip215: "boolean",
18147
+ mapToCurve: "function"
18148
+ });
18149
+ const { prehash } = opts;
18150
+ const { BASE, Fp, Fn } = Point;
18151
+ const outputLen = hash.outputLen;
18152
+ const expectedLen = 2 * Fp.BYTES;
18153
+ if (outputLen !== void 0) {
18154
+ asafenumber(outputLen, "hash.outputLen");
18155
+ if (outputLen !== expectedLen) throw new Error(`hash.outputLen must be ${expectedLen}, got ${outputLen}`);
18156
+ }
18157
+ const randomBytes = opts.randomBytes === void 0 ? randomBytes$1 : opts.randomBytes;
18158
+ const adjustScalarBytes = opts.adjustScalarBytes === void 0 ? (bytes) => bytes : opts.adjustScalarBytes;
18159
+ const domain = opts.domain === void 0 ? (data, ctx, phflag) => {
18160
+ abool(phflag, "phflag");
18161
+ if (ctx.length || phflag) throw new Error("Contexts/pre-hash are not supported");
18162
+ return data;
18163
+ } : opts.domain;
18164
+ function modN_LE(hash) {
18165
+ return Fn.create(bytesToNumberLE(hash));
18166
+ }
18167
+ function getPrivateScalar(key) {
18168
+ const len = lengths.secretKey;
18169
+ abytes(key, lengths.secretKey, "secretKey");
18170
+ const hashed = abytes(hash(key), 2 * len, "hashedSecretKey");
18171
+ const head = adjustScalarBytes(hashed.slice(0, len));
18172
+ return {
18173
+ head,
18174
+ prefix: hashed.slice(len, 2 * len),
18175
+ scalar: modN_LE(head)
18176
+ };
18177
+ }
18178
+ /** Convenience method that creates public key from scalar. RFC8032 5.1.5
18179
+ * Also exposes the derived scalar/prefix tuple and point form reused by sign().
18180
+ */
18181
+ function getExtendedPublicKey(secretKey) {
18182
+ const { head, prefix, scalar } = getPrivateScalar(secretKey);
18183
+ const point = BASE.multiply(scalar);
18184
+ return {
18185
+ head,
18186
+ prefix,
18187
+ scalar,
18188
+ point,
18189
+ pointBytes: point.toBytes()
18190
+ };
18191
+ }
18192
+ /** Calculates EdDSA pub key. RFC8032 5.1.5. */
18193
+ function getPublicKey(secretKey) {
18194
+ return getExtendedPublicKey(secretKey).pointBytes;
18195
+ }
18196
+ function hashDomainToScalar(context = Uint8Array.of(), ...msgs) {
18197
+ return modN_LE(hash(domain(concatBytes(...msgs), abytes(context, void 0, "context"), !!prehash)));
18198
+ }
18199
+ /** Signs message with secret key. RFC8032 5.1.6 */
18200
+ function sign(msg, secretKey, options = {}) {
18201
+ msg = abytes(msg, void 0, "message");
18202
+ if (prehash) msg = prehash(msg);
18203
+ const { prefix, scalar, pointBytes } = getExtendedPublicKey(secretKey);
18204
+ const r = hashDomainToScalar(options.context, prefix, msg);
18205
+ const R = BASE.multiply(r).toBytes();
18206
+ const k = hashDomainToScalar(options.context, R, pointBytes, msg);
18207
+ const s = Fn.create(r + k * scalar);
18208
+ if (!Fn.isValid(s)) throw new Error("sign failed: invalid s");
18209
+ return abytes(concatBytes(R, Fn.toBytes(s)), lengths.signature, "result");
18210
+ }
18211
+ const verifyOpts = { zip215: opts.zip215 };
18212
+ /**
18213
+ * Verifies EdDSA signature against message and public key. RFC 8032 §§5.1.7 and 5.2.7.
18214
+ * A cofactored verification equation is checked.
18215
+ */
18216
+ function verify(sig, msg, publicKey, options = verifyOpts) {
18217
+ const { context } = options;
18218
+ const zip215 = options.zip215 === void 0 ? !!verifyOpts.zip215 : options.zip215;
18219
+ const len = lengths.signature;
18220
+ sig = abytes(sig, len, "signature");
18221
+ msg = abytes(msg, void 0, "message");
18222
+ publicKey = abytes(publicKey, lengths.publicKey, "publicKey");
18223
+ if (zip215 !== void 0) abool(zip215, "zip215");
18224
+ if (prehash) msg = prehash(msg);
18225
+ const mid = len / 2;
18226
+ const r = sig.subarray(0, mid);
18227
+ const s = bytesToNumberLE(sig.subarray(mid, len));
18228
+ let A, R, SB;
18229
+ try {
18230
+ A = Point.fromBytes(publicKey, zip215);
18231
+ R = Point.fromBytes(r, zip215);
18232
+ SB = BASE.multiplyUnsafe(s);
18233
+ } catch (error) {
18234
+ return false;
18235
+ }
18236
+ if (!zip215 && A.isSmallOrder()) return false;
18237
+ const k = hashDomainToScalar(context, r, publicKey, msg);
18238
+ return R.add(A.multiplyUnsafe(k)).subtract(SB).clearCofactor().is0();
18239
+ }
18240
+ const _size = Fp.BYTES;
18241
+ const lengths = {
18242
+ secretKey: _size,
18243
+ publicKey: _size,
18244
+ signature: 2 * _size,
18245
+ seed: _size
18246
+ };
18247
+ function randomSecretKey(seed) {
18248
+ seed = seed === void 0 ? randomBytes(lengths.seed) : seed;
18249
+ return abytes(seed, lengths.seed, "seed");
18250
+ }
18251
+ function isValidSecretKey(key) {
18252
+ return isBytes(key) && key.length === lengths.secretKey;
18253
+ }
18254
+ function isValidPublicKey(key, zip215) {
18255
+ try {
18256
+ return !!Point.fromBytes(key, zip215 === void 0 ? verifyOpts.zip215 : zip215);
18257
+ } catch (error) {
18258
+ return false;
18259
+ }
18260
+ }
18261
+ const utils = {
18262
+ getExtendedPublicKey,
18263
+ randomSecretKey,
18264
+ isValidSecretKey,
18265
+ isValidPublicKey,
18266
+ /**
18267
+ * Converts ed public key to x public key. Uses formula:
18268
+ * - ed25519:
18269
+ * - `(u, v) = ((1+y)/(1-y), sqrt(-486664)*u/x)`
18270
+ * - `(x, y) = (sqrt(-486664)*u/v, (u-1)/(u+1))`
18271
+ * - ed448:
18272
+ * - `(u, v) = ((y-1)/(y+1), sqrt(156324)*u/x)`
18273
+ * - `(x, y) = (sqrt(156324)*u/v, (1+u)/(1-u))`
18274
+ */
18275
+ toMontgomery(publicKey) {
18276
+ const { y } = Point.fromBytes(publicKey);
18277
+ const size = lengths.publicKey;
18278
+ const is25519 = size === 32;
18279
+ if (!is25519 && size !== 57) throw new Error("only defined for 25519 and 448");
18280
+ const u = is25519 ? Fp.div(_1n$2 + y, _1n$2 - y) : Fp.div(y - _1n$2, y + _1n$2);
18281
+ return Fp.toBytes(u);
18282
+ },
18283
+ toMontgomerySecret(secretKey) {
18284
+ const size = lengths.secretKey;
18285
+ abytes(secretKey, size);
18286
+ return adjustScalarBytes(hash(secretKey.subarray(0, size))).subarray(0, size);
18287
+ }
18288
+ };
18289
+ Object.freeze(lengths);
18290
+ Object.freeze(utils);
18291
+ return Object.freeze({
18292
+ keygen: createKeygen(randomSecretKey, getPublicKey),
18293
+ getPublicKey,
18294
+ sign,
18295
+ verify,
18296
+ utils,
18297
+ Point,
18298
+ lengths
18299
+ });
18300
+ }
17826
18301
 
17827
18302
  //#endregion
17828
18303
  //#region ../../node_modules/.bun/@noble+curves@2.2.0/node_modules/@noble/curves/abstract/montgomery.js
@@ -18033,6 +18508,31 @@ function uvRatio(u, v) {
18033
18508
  const ed25519_Point = /* @__PURE__ */ edwards(ed25519_CURVE, { uvRatio });
18034
18509
  const Fp = /* @__PURE__ */ (() => ed25519_Point.Fp)();
18035
18510
  const Fn = /* @__PURE__ */ (() => ed25519_Point.Fn)();
18511
+ function ed(opts) {
18512
+ return eddsa(ed25519_Point, sha512, Object.assign({
18513
+ adjustScalarBytes,
18514
+ zip215: true
18515
+ }, opts));
18516
+ }
18517
+ /**
18518
+ * ed25519 curve with EdDSA signatures.
18519
+ * Seeded `keygen(seed)` / `utils.randomSecretKey(seed)` reuse the provided
18520
+ * 32-byte seed buffer instead of copying it.
18521
+ * @example
18522
+ * Generate one Ed25519 keypair, sign a message, and verify it.
18523
+ *
18524
+ * ```js
18525
+ * import { ed25519 } from '@noble/curves/ed25519.js';
18526
+ * const { secretKey, publicKey } = ed25519.keygen();
18527
+ * // const publicKey = ed25519.getPublicKey(secretKey);
18528
+ * const msg = new TextEncoder().encode('hello noble');
18529
+ * const sig = ed25519.sign(msg, secretKey);
18530
+ * const isValid = ed25519.verify(sig, msg, publicKey); // ZIP215
18531
+ * // RFC8032 / FIPS 186-5
18532
+ * const isValid2 = ed25519.verify(sig, msg, publicKey, { zip215: false });
18533
+ * ```
18534
+ */
18535
+ const ed25519 = /* @__PURE__ */ ed({});
18036
18536
  /**
18037
18537
  * ECDH using curve25519 aka x25519.
18038
18538
  * `getSharedSecret()` rejects low-order peer inputs by default, and seeded
@@ -19113,8 +19613,9 @@ function isCryptoKey(key) {
19113
19613
 
19114
19614
  //#endregion
19115
19615
  //#region ../../packages/credentials-crypto/src/identity.ts
19116
- const textEncoder$1 = new TextEncoder();
19117
- const textDecoder$1 = new TextDecoder();
19616
+ const textEncoder$2 = new TextEncoder();
19617
+ const textDecoder$2 = new TextDecoder();
19618
+ /** Salt length for the passphrase KDF (shared by the identity + account-key seals). */
19118
19619
  const SALT_BYTES = 16;
19119
19620
  const KEY_BYTES = 32;
19120
19621
  /** OWASP-recommended Argon2id defaults (~64 MiB), tuned for ~250–500 ms. */
@@ -19135,6 +19636,7 @@ const generateIdentity = async () => {
19135
19636
  };
19136
19637
  /** Derive the age recipient (`age1...`) from an identity private key. */
19137
19638
  const deriveRecipient = async (privateKey) => identityToRecipient(privateKey);
19639
+ /** Argon2id(passphrase, salt) → 32-byte KEK. Shared by the identity + account-key seals. */
19138
19640
  const deriveKek = (passphrase, salt, params) => {
19139
19641
  return argon2id(passphrase, salt, {
19140
19642
  t: params.time,
@@ -19155,8 +19657,8 @@ const sealIdentity = async (args) => {
19155
19657
  const publicKey = await identityToRecipient(args.privateKey);
19156
19658
  const kdfParams = args.kdfParams ?? DEFAULT_ARGON2_PARAMS;
19157
19659
  const fp = fingerprint(publicKey);
19158
- const salt = randomBytes$5(SALT_BYTES);
19159
- const ct = aeadEncrypt(deriveKek(args.passphrase, salt, kdfParams), textEncoder$1.encode(args.privateKey), sealAad({
19660
+ const salt = randomBytes$5(16);
19661
+ const ct = aeadEncrypt(deriveKek(args.passphrase, salt, kdfParams), textEncoder$2.encode(args.privateKey), sealAad({
19160
19662
  publicKey,
19161
19663
  fingerprint: fp,
19162
19664
  kdfParams
@@ -19181,7 +19683,7 @@ const sealIdentity = async (args) => {
19181
19683
  const openIdentity = async (args) => {
19182
19684
  const { file } = args;
19183
19685
  const kek = deriveKek(args.passphrase, fromBase64(file.salt), file.kdfParams);
19184
- const privateKey = textDecoder$1.decode(aeadDecrypt(kek, fromBase64(file.ct), sealAad(file)));
19686
+ const privateKey = textDecoder$2.decode(aeadDecrypt(kek, fromBase64(file.ct), sealAad(file)));
19185
19687
  const publicKey = await identityToRecipient(privateKey);
19186
19688
  return {
19187
19689
  privateKey,
@@ -19211,7 +19713,12 @@ const unwrapVaultKey = async (args) => {
19211
19713
  decrypter.addIdentity(args.privateKey);
19212
19714
  return decrypter.decrypt(args.wrapped);
19213
19715
  };
19214
- const dekAad = (binding) => encodeAad("better-update/dek", [
19716
+ const dekAad = (binding) => binding.vaultKind === "env" ? encodeAad("better-update/dek", [
19717
+ binding.orgId,
19718
+ binding.credentialId,
19719
+ binding.vaultVersion,
19720
+ "env"
19721
+ ]) : encodeAad("better-update/dek", [
19215
19722
  binding.orgId,
19216
19723
  binding.credentialId,
19217
19724
  binding.vaultVersion
@@ -19227,8 +19734,8 @@ const unwrapDek = (args) => aeadDecrypt(args.vaultKey, args.wrappedDek, dekAad(a
19227
19734
 
19228
19735
  //#endregion
19229
19736
  //#region ../../packages/credentials-crypto/src/credential.ts
19230
- const textEncoder = new TextEncoder();
19231
- const textDecoder = new TextDecoder();
19737
+ const textEncoder$1 = new TextEncoder();
19738
+ const textDecoder$1 = new TextDecoder();
19232
19739
  const blobAad = (binding) => encodeAad("better-update/credential", [
19233
19740
  binding.orgId,
19234
19741
  binding.credentialId,
@@ -19236,7 +19743,7 @@ const blobAad = (binding) => encodeAad("better-update/credential", [
19236
19743
  binding.schemaVersion
19237
19744
  ]);
19238
19745
  /** Encrypt a credential payload with its DEK, binding its identity as AAD. */
19239
- const sealCredential = (args) => aeadEncrypt(args.dek, textEncoder.encode(JSON.stringify(args.payload)), blobAad(args.payload));
19746
+ const sealCredential = (args) => aeadEncrypt(args.dek, textEncoder$1.encode(JSON.stringify(args.payload)), blobAad(args.payload));
19240
19747
  /**
19241
19748
  * Decrypt a credential blob with its DEK. The AAD binds the blob to `expect`, so
19242
19749
  * a blob for another (org, credential, type, schema) fails the tag instead of
@@ -19246,7 +19753,99 @@ const sealCredential = (args) => aeadEncrypt(args.dek, textEncoder.encode(JSON.s
19246
19753
  */
19247
19754
  const openCredential = (args) => {
19248
19755
  const plaintext = aeadDecrypt(args.dek, args.ciphertext, blobAad(args.expect));
19249
- return JSON.parse(textDecoder.decode(plaintext));
19756
+ return JSON.parse(textDecoder$1.decode(plaintext));
19757
+ };
19758
+
19759
+ //#endregion
19760
+ //#region ../../packages/credentials-crypto/src/account-key.ts
19761
+ const textEncoder = new TextEncoder();
19762
+ const textDecoder = new TextDecoder();
19763
+ /**
19764
+ * Argon2id cost for the ACCOUNT escrow. Deliberately heavier than the on-disk
19765
+ * identity default (`DEFAULT_ARGON2_PARAMS`, ~64 MiB): the escrow blob is stored
19766
+ * server-side, so it is more exposed than a local `identity.json` and warrants a
19767
+ * costlier KDF. ~128 MiB. The params live in the envelope, so an enrollment can be
19768
+ * re-tuned without a format change — validate pure-JS Argon2id perf in the browser
19769
+ * before raising this. See docs/specs/build/11-two-vault-split-and-web-env-crud.md §3.2.
19770
+ */
19771
+ const ACCOUNT_ARGON2_PARAMS = {
19772
+ time: 3,
19773
+ memory: 131072,
19774
+ parallelism: 1
19775
+ };
19776
+ /** Narrow a decrypted escrow payload to {@link SealedAccountSecret} without an unsafe cast. */
19777
+ const isSealedAccountSecret = (value) => typeof value === "object" && value !== null && "agePrivateKey" in value && typeof value.agePrivateKey === "string" && "ed25519PrivateKey" in value && typeof value.ed25519PrivateKey === "string";
19778
+ /** Generate a fresh account keypair: an age X25519 key plus an Ed25519 signing key. */
19779
+ const generateAccountKey = async () => {
19780
+ const agePrivateKey = await generateIdentity$1();
19781
+ const agePublicKey = await identityToRecipient(agePrivateKey);
19782
+ const ed = ed25519.keygen();
19783
+ return {
19784
+ agePrivateKey,
19785
+ agePublicKey,
19786
+ ed25519PrivateKey: toBase64(ed.secretKey),
19787
+ ed25519PublicKey: toBase64(ed.publicKey),
19788
+ fingerprint: fingerprint(agePublicKey)
19789
+ };
19790
+ };
19791
+ const escrowAad = (header) => encodeAad("better-update/account-key", [
19792
+ header.agePublicKey,
19793
+ header.ed25519PublicKey,
19794
+ header.fingerprint,
19795
+ header.kdfParams.time,
19796
+ header.kdfParams.memory,
19797
+ header.kdfParams.parallelism
19798
+ ]);
19799
+ /** Seal an account keypair into its escrow envelope with a passphrase. */
19800
+ const sealAccountKey = (args) => {
19801
+ const kdfParams = args.kdfParams ?? ACCOUNT_ARGON2_PARAMS;
19802
+ const salt = randomBytes$5(16);
19803
+ const kek = deriveKek(args.passphrase, salt, kdfParams);
19804
+ const header = {
19805
+ agePublicKey: args.material.agePublicKey,
19806
+ ed25519PublicKey: args.material.ed25519PublicKey,
19807
+ fingerprint: args.material.fingerprint,
19808
+ kdfParams
19809
+ };
19810
+ const secret = {
19811
+ agePrivateKey: args.material.agePrivateKey,
19812
+ ed25519PrivateKey: args.material.ed25519PrivateKey
19813
+ };
19814
+ const ct = aeadEncrypt(kek, textEncoder.encode(JSON.stringify(secret)), escrowAad(header));
19815
+ return {
19816
+ version: 1,
19817
+ agePublicKey: header.agePublicKey,
19818
+ ed25519PublicKey: header.ed25519PublicKey,
19819
+ fingerprint: header.fingerprint,
19820
+ kdf: "argon2id",
19821
+ kdfParams,
19822
+ salt: toBase64(salt),
19823
+ cipher: "xchacha20poly1305",
19824
+ ct: toBase64(ct)
19825
+ };
19826
+ };
19827
+ /**
19828
+ * Open an account escrow envelope. Throws (propagated AEAD failure) on a wrong
19829
+ * passphrase or a tampered envelope — the seal binds both public keys, the
19830
+ * fingerprint, and the KDF params as AAD. The returned public halves are
19831
+ * **re-derived from the decrypted private keys**, so they always match the keys
19832
+ * they unlock (mirrors `openIdentity`).
19833
+ */
19834
+ const openAccountKey = async (args) => {
19835
+ const { envelope } = args;
19836
+ const plaintext = aeadDecrypt(deriveKek(args.passphrase, fromBase64(envelope.salt), envelope.kdfParams), fromBase64(envelope.ct), escrowAad(envelope));
19837
+ const parsed = JSON.parse(textDecoder.decode(plaintext));
19838
+ if (!isSealedAccountSecret(parsed)) throw new Error("Account escrow payload has an unexpected shape.");
19839
+ const secret = parsed;
19840
+ const agePublicKey = await identityToRecipient(secret.agePrivateKey);
19841
+ const ed25519PublicKey = toBase64(ed25519.getPublicKey(fromBase64(secret.ed25519PrivateKey)));
19842
+ return {
19843
+ agePrivateKey: secret.agePrivateKey,
19844
+ agePublicKey,
19845
+ ed25519PrivateKey: secret.ed25519PrivateKey,
19846
+ ed25519PublicKey,
19847
+ fingerprint: fingerprint(agePublicKey)
19848
+ };
19250
19849
  };
19251
19850
 
19252
19851
  //#endregion
@@ -19423,7 +20022,7 @@ const unlockVaultKeyInteractive = (api, options) => Effect.gen(function* () {
19423
20022
  const cached = yield* cache.get(recipient.publicKey);
19424
20023
  if (cached !== void 0) return cached.vault;
19425
20024
  const vault = yield* unlockVaultKey(api, yield* promptPassword("Passphrase to unlock this device's identity:"));
19426
- yield* cache.set(recipient.publicKey, vault, options?.cacheTtlMs);
20025
+ yield* cache.set(recipient.publicKey, vault, { ttlMs: options?.cacheTtlMs });
19427
20026
  return vault;
19428
20027
  }).pipe(Effect.provide(VaultCacheLive));
19429
20028
  /**
@@ -19484,7 +20083,8 @@ const getActiveOrgId = (api) => Effect.gen(function* () {
19484
20083
  const openVaultSessionInteractive = (api) => Effect.gen(function* () {
19485
20084
  return {
19486
20085
  orgId: yield* getActiveOrgId(api),
19487
- vault: yield* unlockVaultKeyInteractive(api)
20086
+ vault: yield* unlockVaultKeyInteractive(api),
20087
+ vaultKind: "credentials"
19488
20088
  };
19489
20089
  });
19490
20090
  /** Reshape a sealed envelope into the `{ id, …opaque fields }` an upload body carries. */
@@ -19500,7 +20100,7 @@ const toUploadEnvelope = (envelope) => ({
19500
20100
  * `(org, credentialId, type, schemaVersion)` so the server cannot mix envelopes.
19501
20101
  */
19502
20102
  const sealForUpload = (args) => Effect.gen(function* () {
19503
- const { orgId, vault } = args.session;
20103
+ const { orgId, vault, vaultKind } = args.session;
19504
20104
  const credentialId = crypto.randomUUID();
19505
20105
  const dek = generateDek();
19506
20106
  const payload = {
@@ -19523,7 +20123,8 @@ const sealForUpload = (args) => Effect.gen(function* () {
19523
20123
  binding: {
19524
20124
  orgId,
19525
20125
  credentialId,
19526
- vaultVersion: vault.vaultVersion
20126
+ vaultVersion: vault.vaultVersion,
20127
+ vaultKind
19527
20128
  }
19528
20129
  })
19529
20130
  }),
@@ -19542,7 +20143,7 @@ const sealForUpload = (args) => Effect.gen(function* () {
19542
20143
  * also serves the build-resolve flow whose result carries the id out-of-band.
19543
20144
  */
19544
20145
  const openEnvelope = (args) => Effect.gen(function* () {
19545
- const { orgId, vault } = args.session;
20146
+ const { orgId, vault, vaultKind } = args.session;
19546
20147
  const dek = yield* Effect.try({
19547
20148
  try: () => unwrapDek({
19548
20149
  wrappedDek: fromBase64(args.envelope.wrappedDek),
@@ -19550,7 +20151,8 @@ const openEnvelope = (args) => Effect.gen(function* () {
19550
20151
  binding: {
19551
20152
  orgId,
19552
20153
  credentialId: args.credentialId,
19553
- vaultVersion: args.envelope.vaultVersion
20154
+ vaultVersion: args.envelope.vaultVersion,
20155
+ vaultKind
19554
20156
  }
19555
20157
  }),
19556
20158
  catch: () => new IdentityError({ message: "Could not unwrap this credential's key — vault access may have been rotated or revoked." })
@@ -19602,6 +20204,84 @@ const openFromDownload = (args) => {
19602
20204
  });
19603
20205
  };
19604
20206
 
20207
+ //#endregion
20208
+ //#region src/application/env-vault-access.ts
20209
+ /**
20210
+ * Guidance when this device holds no env-vault wrap. Post-cutover the env vault is
20211
+ * a SEPARATE key from the credentials vault, so even a device that can read
20212
+ * credentials may not be an env recipient yet — it self-links by enrolling an
20213
+ * account key, or an admin re-runs the env-vault migration / rotation to include
20214
+ * it.
20215
+ */
20216
+ const ENV_VAULT_NOT_RECIPIENT_GUIDANCE = "This device isn't an env-vault recipient. Run `better-update credentials account create` to enroll, or ask an admin to re-run `better-update credentials env-vault rotate` to include it.";
20217
+ /**
20218
+ * Unlock the ENV-vault key for this device via its env wrap (post-cutover). The
20219
+ * env vault holds a DIFFERENT key from the credentials vault — wrapped to the same
20220
+ * device/recovery/machine recipients PLUS per-user account keys — so this mirrors
20221
+ * {@link unlockVaultKey} but reads the polymorphic `org_env_vault_key_wraps` row
20222
+ * keyed by this device's `(recipientKind, keyId)`.
20223
+ */
20224
+ const unlockEnvVaultKey = (api, passphrase) => Effect.gen(function* () {
20225
+ const recipient = yield* activeRecipient;
20226
+ const privateKey = yield* unlockActivePrivateKey(passphrase);
20227
+ const { items } = yield* api.userEncryptionKeys.list();
20228
+ const own = items.find((key) => key.publicKey === recipient.publicKey);
20229
+ if (!own) return yield* new IdentityError({ message: "This device's encryption key is not registered. Run `better-update credentials identity register` first." });
20230
+ const wrap = yield* api.envVault.getWrap({ path: {
20231
+ recipientKind: recipientKind(recipient.source),
20232
+ recipientId: own.id
20233
+ } }).pipe(Effect.catchTag("NotFound", () => new IdentityError({ message: ENV_VAULT_NOT_RECIPIENT_GUIDANCE })));
20234
+ return {
20235
+ vaultKey: yield* Effect.tryPromise({
20236
+ try: async () => unwrapVaultKey({
20237
+ wrapped: fromBase64(wrap.wrappedKey),
20238
+ privateKey
20239
+ }),
20240
+ catch: () => new IdentityError({ message: "This device could not unwrap the env-vault key — its env access may have been revoked or rotated. Re-enroll or ask an admin to re-grant access." })
20241
+ }),
20242
+ vaultVersion: wrap.envVaultVersion,
20243
+ keyId: own.id
20244
+ };
20245
+ });
20246
+ /**
20247
+ * Cache-aware env-vault unlock, mirroring {@link unlockVaultKeyInteractive} but on
20248
+ * the `"env"` cache namespace so the credentials and env vaults cache (and lock)
20249
+ * independently. CI's `BETTER_UPDATE_IDENTITY` key is never cached.
20250
+ */
20251
+ const unlockEnvVaultKeyInteractive = (api) => Effect.gen(function* () {
20252
+ const recipient = yield* activeRecipient;
20253
+ if (recipient.source !== "file") return yield* unlockEnvVaultKey(api, void 0);
20254
+ const cache = yield* VaultCache;
20255
+ const cached = yield* cache.get(recipient.publicKey, "env");
20256
+ if (cached !== void 0) return cached.vault;
20257
+ const vault = yield* unlockEnvVaultKey(api, yield* promptPassword("Passphrase to unlock this device's identity:"));
20258
+ yield* cache.set(recipient.publicKey, vault, { vaultKind: "env" });
20259
+ return vault;
20260
+ }).pipe(Effect.provide(VaultCacheLive));
20261
+ /** Forget this device's cached env-vault key — called after an env rotation re-keys it. */
20262
+ const forgetCachedEnvVaultKey = Effect.gen(function* () {
20263
+ const recipient = yield* activeRecipient;
20264
+ yield* (yield* VaultCache).clear(recipient.publicKey, "env");
20265
+ }).pipe(Effect.provide(VaultCacheLive));
20266
+ /**
20267
+ * Resolve the vault session env VALUES are sealed under, branched on the org's
20268
+ * cutover state. Pre-cutover (or no vault yet) env lives in the CREDENTIALS vault —
20269
+ * `openVaultSessionInteractive` returns a `"credentials"`-kind session, byte-for-
20270
+ * byte the pre-split behaviour. Once the org has cut over, env lives in its own
20271
+ * vault: unlock the env key and return an `"env"`-kind session so seal/open bind
20272
+ * the DEK to the env vault. Every env command (`set/get/pull/push/export/import`)
20273
+ * goes through this, so the cutover is transparent to them.
20274
+ */
20275
+ const openEnvVaultSessionInteractive = (api) => Effect.gen(function* () {
20276
+ const vault = yield* api.orgVault.get().pipe(Effect.catchTag("NotFound", () => Effect.succeed(null)));
20277
+ if ((vault === null ? null : vault.envVaultCutoverAt) === null) return yield* openVaultSessionInteractive(api);
20278
+ return {
20279
+ orgId: yield* getActiveOrgId(api),
20280
+ vault: yield* unlockEnvVaultKeyInteractive(api),
20281
+ vaultKind: "env"
20282
+ };
20283
+ });
20284
+
19605
20285
  //#endregion
19606
20286
  //#region src/lib/credential-secret.ts
19607
20287
  /**
@@ -19643,7 +20323,7 @@ const exportDecryptedEnvVars = (api, projectId, environment) => Effect.gen(funct
19643
20323
  environment
19644
20324
  } }).pipe(Effect.mapError((cause) => new EnvExportError({ message: `Failed to export environment variables for "${environment}": ${String(cause)}` })));
19645
20325
  if (result.items.length === 0) return [];
19646
- return yield* decryptEnvVars(yield* openVaultSessionInteractive(api).pipe(Effect.mapError((cause) => new EnvExportError({ message: `Could not unlock the credential vault to decrypt environment variables: ${cause.message}` }))), result.items);
20326
+ return yield* decryptEnvVars(yield* openEnvVaultSessionInteractive(api).pipe(Effect.mapError((cause) => new EnvExportError({ message: `Could not unlock the credential vault to decrypt environment variables: ${cause.message}` }))), result.items);
19647
20327
  });
19648
20328
  /**
19649
20329
  * Pull + decrypt environment variables flattened into a key/value map for
@@ -19662,7 +20342,7 @@ const pullEnvVars = (api, { projectId, environment }) => Effect.gen(function* ()
19662
20342
  * vault once; the server stores only the sealed envelopes (never the plaintext).
19663
20343
  */
19664
20344
  const uploadEnvVars = (api, params) => Effect.gen(function* () {
19665
- const session = yield* openVaultSessionInteractive(api);
20345
+ const session = yield* openEnvVaultSessionInteractive(api);
19666
20346
  const pairs = params.environments.flatMap((environment) => params.entries.map((entry) => ({
19667
20347
  ...entry,
19668
20348
  environment
@@ -19683,7 +20363,8 @@ const uploadEnvVars = (api, params) => Effect.gen(function* () {
19683
20363
  id: envelope.id,
19684
20364
  ciphertext: envelope.ciphertext,
19685
20365
  wrappedDek: envelope.wrappedDek,
19686
- vaultVersion: envelope.vaultVersion
20366
+ vaultVersion: envelope.vaultVersion,
20367
+ vaultKind: session.vaultKind
19687
20368
  }
19688
20369
  })), { concurrency: 8 });
19689
20370
  return yield* api["env-vars"].bulkImport({ payload: {
@@ -21850,6 +22531,17 @@ const parseCert = (certDerBytes) => {
21850
22531
  return forge.pki.certificateFromAsn1(asn1);
21851
22532
  };
21852
22533
  const generatePassword = () => forge.util.encode64(forge.random.getBytesSync(16));
22534
+ /**
22535
+ * Normalize an Apple certificate serial number for comparison.
22536
+ *
22537
+ * The serial read from a certificate's X.509 DER (via node-forge) keeps any
22538
+ * leading zero byte (e.g. `02C4E489…`), whereas the App Store Connect API
22539
+ * reports the same serial with leading zeros stripped (e.g. `2C4E489…`).
22540
+ * Comparing the two representations verbatim makes a present certificate look
22541
+ * absent ("not present on Apple Developer Portal"), so normalize both sides:
22542
+ * uppercase and drop leading zeros.
22543
+ */
22544
+ const normalizeAppleSerial = (serial) => serial.toUpperCase().replace(/^0+/u, "");
21853
22545
  const extractCertMetadata = (cert) => Effect.gen(function* () {
21854
22546
  const appleTeamId = extractTeamId$1(cert);
21855
22547
  if (appleTeamId === null) return yield* new CertParseError({ message: "Could not extract Apple team identifier from certificate subject" });
@@ -22089,9 +22781,9 @@ const revokeLocalDistributionCertificate = (api, input) => Effect.gen(function*
22089
22781
  issuerId: creds.issuerId,
22090
22782
  p8Pem: creds.p8Pem
22091
22783
  };
22092
- const targetSerial = local.serialNumber.toUpperCase();
22784
+ const targetSerial = normalizeAppleSerial(local.serialNumber);
22093
22785
  const matching = yield* Effect.all([listCertificates(ascCreds, { certificateType: "IOS_DISTRIBUTION" }), listCertificates(ascCreds, { certificateType: "IOS_DEVELOPMENT" })], { concurrency: 2 }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")));
22094
- const ascMatch = [...matching[0], ...matching[1]].find((entry) => entry.serialNumber.toUpperCase() === targetSerial);
22786
+ const ascMatch = [...matching[0], ...matching[1]].find((entry) => normalizeAppleSerial(entry.serialNumber) === targetSerial);
22095
22787
  let revokedOnApple = false;
22096
22788
  if (ascMatch !== void 0) {
22097
22789
  yield* deleteCertificate(ascCreds, ascMatch.id).pipe(Effect.mapError(wrapAscError("apple-revoke-certificate")));
@@ -22118,7 +22810,9 @@ const listAppleCertificates = (api, input) => Effect.gen(function* () {
22118
22810
  }, compact({ certificateType: input.certificateType })).pipe(Effect.mapError(wrapAscError("apple-list-certificates")));
22119
22811
  });
22120
22812
  const resolveCertAscId = (creds, serialNumber, certificateType) => Effect.gen(function* () {
22121
- const match = (yield* listCertificates(creds, { certificateType }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")))).find((entry) => entry.serialNumber.toUpperCase() === serialNumber);
22813
+ const certs = yield* listCertificates(creds, { certificateType }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")));
22814
+ const target = normalizeAppleSerial(serialNumber);
22815
+ const match = certs.find((entry) => normalizeAppleSerial(entry.serialNumber) === target);
22122
22816
  if (match === void 0) return yield* new GenerateFailedError({
22123
22817
  step: "match-apple-certificate",
22124
22818
  message: `Distribution certificate ${serialNumber} not present on Apple Developer Portal; upload or re-generate it`
@@ -22682,6 +23376,7 @@ const parsePlist = (data) => data.subarray(0, 8).equals(BPLIST_MAGIC) ? parsePli
22682
23376
 
22683
23377
  //#endregion
22684
23378
  //#region src/lib/update-channel-native.ts
23379
+ const { AndroidConfig } = configPlugins;
22685
23380
  /**
22686
23381
  * EAS parity: after `expo prebuild`, bake the build profile's `channel` into
22687
23382
  * the generated native projects as the `expo-channel-name` request header —
@@ -23093,8 +23788,8 @@ const findOrCreateBundleId = (ctx, bundleIdentifier) => Effect.gen(function* ()
23093
23788
  });
23094
23789
  const findAscCertificateId = (ctx, serialNumber, certificateType) => Effect.gen(function* () {
23095
23790
  const certs = yield* wrap("apple-list-certificates", async () => AppleUtils.Certificate.getAsync(ctx, { query: { filter: { certificateType } } }));
23096
- const upper = serialNumber.toUpperCase();
23097
- const match = certs.find((entry) => entry.attributes.serialNumber.toUpperCase() === upper);
23791
+ const target = normalizeAppleSerial(serialNumber);
23792
+ const match = certs.find((entry) => normalizeAppleSerial(entry.attributes.serialNumber) === target);
23098
23793
  if (match === void 0) return yield* new AppleIdGenerateFailedError({
23099
23794
  step: "match-apple-certificate",
23100
23795
  message: `Distribution certificate ${serialNumber} not present on Apple Developer Portal; upload or re-generate it`
@@ -26172,7 +26867,7 @@ const resolveNamedResourceId$1 = (params) => resolveNamedResourceId$2(params, (m
26172
26867
 
26173
26868
  //#endregion
26174
26869
  //#region src/commands/channels/create.ts
26175
- const createCommand$4 = defineCommand({
26870
+ const createCommand$5 = defineCommand({
26176
26871
  meta: {
26177
26872
  name: "create",
26178
26873
  description: "Create a channel"
@@ -26390,7 +27085,7 @@ const completeCommand$1 = defineCommand({
26390
27085
 
26391
27086
  //#endregion
26392
27087
  //#region src/commands/channels/rollout/create.ts
26393
- const createCommand$3 = defineCommand({
27088
+ const createCommand$4 = defineCommand({
26394
27089
  meta: {
26395
27090
  name: "create",
26396
27091
  description: "Start a branch rollout on a channel"
@@ -26510,7 +27205,7 @@ const rolloutCommand$1 = defineCommand({
26510
27205
  description: "Manage channel branch rollouts"
26511
27206
  },
26512
27207
  subCommands: {
26513
- create: createCommand$3,
27208
+ create: createCommand$4,
26514
27209
  update: updateCommand$3,
26515
27210
  complete: completeCommand$1,
26516
27211
  revert: revertCommand$2
@@ -26624,7 +27319,7 @@ const channelsCommand = defineCommand({
26624
27319
  subCommands: {
26625
27320
  list: listCommand$7,
26626
27321
  view: viewCommand$2,
26627
- create: createCommand$4,
27322
+ create: createCommand$5,
26628
27323
  update: updateCommand$2,
26629
27324
  pause: pauseCommand,
26630
27325
  resume: resumeCommand,
@@ -28223,7 +28918,8 @@ const rotateVaultTo = (args) => Effect.gen(function* () {
28223
28918
  binding: {
28224
28919
  orgId,
28225
28920
  credentialId: dek.credentialId,
28226
- vaultVersion: dek.vaultVersion
28921
+ vaultVersion: dek.vaultVersion,
28922
+ vaultKind: "credentials"
28227
28923
  }
28228
28924
  });
28229
28925
  return {
@@ -28235,7 +28931,8 @@ const rotateVaultTo = (args) => Effect.gen(function* () {
28235
28931
  binding: {
28236
28932
  orgId,
28237
28933
  credentialId: dek.credentialId,
28238
- vaultVersion: newVersion
28934
+ vaultVersion: newVersion,
28935
+ vaultKind: "credentials"
28239
28936
  }
28240
28937
  }))
28241
28938
  };
@@ -28381,7 +29078,7 @@ const grantCommand = defineCommand({
28381
29078
  }), { json: "value" })
28382
29079
  });
28383
29080
  const confirmRecipients = (recipients, skip) => Effect.forEach(recipients, (recipient) => confirmFingerprint(recipient, skip), { discard: true });
28384
- const rotateCommand = defineCommand({
29081
+ const rotateCommand$1 = defineCommand({
28385
29082
  meta: {
28386
29083
  name: "rotate",
28387
29084
  description: "Rotate the vault key, re-wrapping every credential to the same recipients (admin)"
@@ -28541,7 +29238,7 @@ const accessCommand = defineCommand({
28541
29238
  subCommands: {
28542
29239
  list: listCommand$6,
28543
29240
  grant: grantCommand,
28544
- rotate: rotateCommand,
29241
+ rotate: rotateCommand$1,
28545
29242
  revoke: revokeCommand$1,
28546
29243
  recover: recoverCommand,
28547
29244
  recovery: recoveryCommand
@@ -28549,6 +29246,251 @@ const accessCommand = defineCommand({
28549
29246
  default: "list"
28550
29247
  });
28551
29248
 
29249
+ //#endregion
29250
+ //#region src/application/passphrase-change.ts
29251
+ /** Rebuild the {@link AccountKeyEnvelope} from the server escrow view (escrowCt → ct). */
29252
+ const escrowToEnvelope = (escrow) => ({
29253
+ version: escrow.version,
29254
+ agePublicKey: escrow.agePublicKey,
29255
+ ed25519PublicKey: escrow.ed25519PublicKey,
29256
+ fingerprint: escrow.fingerprint,
29257
+ kdf: escrow.kdf,
29258
+ kdfParams: escrow.kdfParams,
29259
+ salt: escrow.salt,
29260
+ cipher: escrow.cipher,
29261
+ ct: escrow.escrowCt
29262
+ });
29263
+ /**
29264
+ * Re-seal the caller's account-key escrow under `newPassphrase`. BEST-EFFORT and
29265
+ * total: it never fails the surrounding flow — it reports an {@link
29266
+ * AccountResealOutcome} so the caller can warn. This matters because the account
29267
+ * escrow is a single per-USER secret shared across every device, while device
29268
+ * identities are per-device: another device may already have moved the escrow to a
29269
+ * different passphrase, so opening it with THIS device's old passphrase can
29270
+ * legitimately fail (`passphrase-mismatch`) without blocking the device change.
29271
+ */
29272
+ const resealAccountKey = (api, oldPassphrase, newPassphrase) => Effect.gen(function* () {
29273
+ const escrowResult = yield* api.accountKeys.getMe().pipe(Effect.either);
29274
+ if (Either.isLeft(escrowResult)) return escrowResult.left._tag === "NotFound" ? "absent" : "error";
29275
+ const materialResult = yield* Effect.tryPromise(async () => openAccountKey({
29276
+ envelope: escrowToEnvelope(escrowResult.right),
29277
+ passphrase: oldPassphrase
29278
+ })).pipe(Effect.either);
29279
+ if (Either.isLeft(materialResult)) return "passphrase-mismatch";
29280
+ const next = sealAccountKey({
29281
+ material: materialResult.right,
29282
+ passphrase: newPassphrase
29283
+ });
29284
+ const resealResult = yield* api.accountKeys.reseal({ payload: {
29285
+ kdfParams: next.kdfParams,
29286
+ salt: next.salt,
29287
+ escrowCt: next.ct
29288
+ } }).pipe(Effect.either);
29289
+ return Either.isLeft(resealResult) ? "error" : "resealed";
29290
+ });
29291
+ /**
29292
+ * Change the device passphrase. The local device identity is the PRIMARY, and is
29293
+ * saved FIRST: it is purely local and authoritative for this device, so on success
29294
+ * the device is on the new passphrase regardless of what happens to the shared
29295
+ * account escrow. Ordering is deliberate — if the local save fails, nothing was
29296
+ * mutated (the server escrow is untouched); the account escrow re-seal then runs
29297
+ * best-effort and its {@link AccountResealOutcome} is returned for the caller to
29298
+ * surface, so a network/passphrase issue degrades to a warning, never a hard block
29299
+ * or a silent split. The vault keys are unchanged, so cached unlocks and every
29300
+ * wrap stay valid.
29301
+ */
29302
+ const changePassphrase = (api, params) => Effect.gen(function* () {
29303
+ const store = yield* IdentityStore;
29304
+ const file = yield* loadIdentityFileOrFail;
29305
+ const identity = yield* Effect.tryPromise({
29306
+ try: async () => openIdentity({
29307
+ file,
29308
+ passphrase: params.oldPassphrase
29309
+ }),
29310
+ catch: () => new IdentityError({ message: "Wrong current passphrase — could not unlock this device's identity." })
29311
+ });
29312
+ const nextFile = yield* Effect.promise(async () => sealIdentity({
29313
+ privateKey: identity.privateKey,
29314
+ passphrase: params.newPassphrase
29315
+ }));
29316
+ yield* store.save(nextFile);
29317
+ return { account: yield* resealAccountKey(api, params.oldPassphrase, params.newPassphrase) };
29318
+ });
29319
+
29320
+ //#endregion
29321
+ //#region src/commands/credentials/account.ts
29322
+ /** `true` once the org has cut over to its separate env vault. */
29323
+ const orgHasCutOver = (api) => api.orgVault.get().pipe(Effect.map((vault) => vault.envVaultCutoverAt !== null), Effect.catchTag("NotFound", () => Effect.succeed(false)));
29324
+ /** The caller's live account key (public escrow view), or `null` if not enrolled. */
29325
+ const findOwnAccountKey = (api) => api.accountKeys.getMe().pipe(Effect.catchTag("NotFound", () => Effect.succeed(null)));
29326
+ /**
29327
+ * Wrap the env-vault key to the caller's OWN account key (self-link). Unlocks the
29328
+ * env vault via this device's env wrap, so it works for any member whose device is
29329
+ * already an env recipient — the account key inherits env access without an admin.
29330
+ * Requires the org to have cut over (there is no env vault before that).
29331
+ */
29332
+ const linkAccountKeyToEnv = (api, params) => Effect.gen(function* () {
29333
+ const ev = yield* unlockEnvVaultKeyInteractive(api);
29334
+ const wrapped = yield* Effect.promise(async () => wrapVaultKey({
29335
+ vaultKey: ev.vaultKey,
29336
+ recipient: params.agePublicKey
29337
+ }));
29338
+ yield* api.envVault.addWrap({ payload: {
29339
+ envVaultVersion: ev.vaultVersion,
29340
+ wrap: {
29341
+ recipientKind: "account",
29342
+ recipientId: params.accountKeyId,
29343
+ wrappedKey: toBase64(wrapped)
29344
+ }
29345
+ } });
29346
+ });
29347
+ /**
29348
+ * Prompt for — and verify — the device passphrase, so the account escrow is sealed
29349
+ * under the SAME passphrase as the device identity (the "one passphrase" promise:
29350
+ * a later `passphrase change` re-seals both). Verifying via `openIdentity` also
29351
+ * stops a typo from minting an escrow no one can open.
29352
+ */
29353
+ const promptVerifiedDevicePassphrase = Effect.gen(function* () {
29354
+ const file = yield* loadIdentityFileOrFail;
29355
+ const passphrase = yield* promptPassword("Passphrase for this device's identity (the account key uses the same one):");
29356
+ yield* Effect.tryPromise({
29357
+ try: async () => openIdentity({
29358
+ file,
29359
+ passphrase
29360
+ }),
29361
+ catch: () => new IdentityError({ message: "Wrong passphrase — could not unlock this device's identity." })
29362
+ });
29363
+ return passphrase;
29364
+ });
29365
+ const createCommand$3 = defineCommand({
29366
+ meta: {
29367
+ name: "create",
29368
+ description: "Enroll this user's account key — the env-vault recipient that unlocks env values from the browser"
29369
+ },
29370
+ run: async () => runEffect(Effect.gen(function* () {
29371
+ const api = yield* apiClient;
29372
+ if ((yield* findOwnAccountKey(api)) !== null) return yield* new IdentityError({ message: "An account key is already enrolled for this user. Use `better-update credentials passphrase change` to re-seal it, or `better-update credentials account link` to (re)grant it env access." });
29373
+ const passphrase = yield* promptVerifiedDevicePassphrase;
29374
+ const envelope = sealAccountKey({
29375
+ material: yield* Effect.promise(async () => generateAccountKey()),
29376
+ passphrase
29377
+ });
29378
+ const registered = yield* api.accountKeys.register({ payload: {
29379
+ agePublicKey: envelope.agePublicKey,
29380
+ ed25519PublicKey: envelope.ed25519PublicKey,
29381
+ fingerprint: envelope.fingerprint,
29382
+ kdfParams: envelope.kdfParams,
29383
+ salt: envelope.salt,
29384
+ escrowCt: envelope.ct
29385
+ } });
29386
+ const cutOver = yield* orgHasCutOver(api);
29387
+ if (cutOver) yield* linkAccountKeyToEnv(api, {
29388
+ accountKeyId: registered.id,
29389
+ agePublicKey: registered.agePublicKey
29390
+ });
29391
+ yield* printKeyValue([["Account key fingerprint", registered.fingerprint], ["Env access", cutOver ? "granted (self-linked)" : "pending — vault not initialized"]]);
29392
+ yield* printHuman(cutOver ? "Account key enrolled. You can now unlock env values from the browser after a 2FA step-up." : "Account key enrolled. It gains env access once the org vault is initialized (`better-update credentials identity init`).");
29393
+ return {
29394
+ fingerprint: registered.fingerprint,
29395
+ envAccess: cutOver
29396
+ };
29397
+ }), { json: "value" })
29398
+ });
29399
+ const linkCommand$1 = defineCommand({
29400
+ meta: {
29401
+ name: "link",
29402
+ description: "Grant your already-enrolled account key access to the env vault (after a rotation)"
29403
+ },
29404
+ run: async () => runEffect(Effect.gen(function* () {
29405
+ const api = yield* apiClient;
29406
+ const own = yield* findOwnAccountKey(api);
29407
+ if (own === null) return yield* new IdentityError({ message: "No account key enrolled. Run `better-update credentials account create` first." });
29408
+ if (!(yield* orgHasCutOver(api))) return yield* new IdentityError({ message: "This organization's vault is not initialized. Run `better-update credentials identity init` first." });
29409
+ yield* linkAccountKeyToEnv(api, {
29410
+ accountKeyId: own.id,
29411
+ agePublicKey: own.agePublicKey
29412
+ });
29413
+ yield* printHuman(`Linked account key ${own.fingerprint} to the env vault.`);
29414
+ return {
29415
+ linked: true,
29416
+ fingerprint: own.fingerprint
29417
+ };
29418
+ }), { json: "value" })
29419
+ });
29420
+ const promptNewAccountPassphrase = Effect.gen(function* () {
29421
+ const first = yield* promptPassword("New passphrase (use this device's passphrase to keep them in sync):");
29422
+ if (first.length === 0) return yield* new IdentityError({ message: "Passphrase must not be empty." });
29423
+ if (first !== (yield* promptPassword("Confirm new passphrase:"))) return yield* new IdentityError({ message: "Passphrases did not match." });
29424
+ return first;
29425
+ });
29426
+ const resealCommand = defineCommand({
29427
+ meta: {
29428
+ name: "reseal",
29429
+ description: "Re-seal your account key under a new passphrase — repair after a passphrase change on another device, or a reseal that failed mid-change"
29430
+ },
29431
+ run: async () => runEffect(Effect.gen(function* () {
29432
+ const api = yield* apiClient;
29433
+ const own = yield* findOwnAccountKey(api);
29434
+ if (own === null) return yield* new IdentityError({ message: "No account key enrolled. Run `better-update credentials account create` first." });
29435
+ const current = yield* promptPassword("Current account-key passphrase:");
29436
+ const sealed = sealAccountKey({
29437
+ material: yield* Effect.tryPromise({
29438
+ try: async () => openAccountKey({
29439
+ envelope: escrowToEnvelope(own),
29440
+ passphrase: current
29441
+ }),
29442
+ catch: () => new IdentityError({ message: "Wrong current account-key passphrase." })
29443
+ }),
29444
+ passphrase: yield* promptNewAccountPassphrase
29445
+ });
29446
+ yield* api.accountKeys.reseal({ payload: {
29447
+ kdfParams: sealed.kdfParams,
29448
+ salt: sealed.salt,
29449
+ escrowCt: sealed.ct
29450
+ } });
29451
+ yield* printHuman(`Re-sealed account key ${own.fingerprint} under the new passphrase.`);
29452
+ return {
29453
+ resealed: true,
29454
+ fingerprint: own.fingerprint
29455
+ };
29456
+ }), { json: "value" })
29457
+ });
29458
+ const showCommand$1 = defineCommand({
29459
+ meta: {
29460
+ name: "show",
29461
+ description: "Show this user's enrolled account key (fingerprint + status)"
29462
+ },
29463
+ run: async () => runEffect(Effect.gen(function* () {
29464
+ const own = yield* findOwnAccountKey(yield* apiClient);
29465
+ if (own === null) {
29466
+ yield* printHuman("No account key enrolled for this user. Run `better-update credentials account create` to enroll one.");
29467
+ return { enrolled: false };
29468
+ }
29469
+ yield* printKeyValue([
29470
+ ["Account key fingerprint", own.fingerprint],
29471
+ ["Age recipient (public)", own.agePublicKey],
29472
+ ["Enrolled at", own.createdAt]
29473
+ ]);
29474
+ return {
29475
+ enrolled: true,
29476
+ fingerprint: own.fingerprint
29477
+ };
29478
+ }), { json: "value" })
29479
+ });
29480
+ const accountCommand = defineCommand({
29481
+ meta: {
29482
+ name: "account",
29483
+ description: "Manage your per-user account key for browser-side env-vault access"
29484
+ },
29485
+ subCommands: {
29486
+ create: createCommand$3,
29487
+ link: linkCommand$1,
29488
+ reseal: resealCommand,
29489
+ show: showCommand$1
29490
+ },
29491
+ default: "show"
29492
+ });
29493
+
28552
29494
  //#endregion
28553
29495
  //#region src/commands/credentials/configure.ts
28554
29496
  /**
@@ -29247,6 +30189,170 @@ const downloadCommand = defineCommand({
29247
30189
  }), { json: "value" })
29248
30190
  });
29249
30191
 
30192
+ //#endregion
30193
+ //#region src/application/env-vault-rekey.ts
30194
+ /**
30195
+ * Re-wrap a single env DEK from one vault key to another, rebinding it to the
30196
+ * destination vault version + kind. Pure crypto — throws (propagated AEAD failure)
30197
+ * if the source key/binding is wrong. Shared by the cutover (credentials→env) and
30198
+ * the env rotation (env→env).
30199
+ */
30200
+ const rekeyEnvDek = (args) => {
30201
+ const raw = unwrapDek({
30202
+ wrappedDek: fromBase64(args.wrappedDek),
30203
+ vaultKey: args.from,
30204
+ binding: {
30205
+ orgId: args.orgId,
30206
+ credentialId: args.credentialId,
30207
+ vaultVersion: args.fromVersion,
30208
+ vaultKind: args.fromKind
30209
+ }
30210
+ });
30211
+ return {
30212
+ credentialId: args.credentialId,
30213
+ wrappedDek: toBase64(wrapDek({
30214
+ dek: raw,
30215
+ vaultKey: args.to,
30216
+ binding: {
30217
+ orgId: args.orgId,
30218
+ credentialId: args.credentialId,
30219
+ vaultVersion: args.toVersion,
30220
+ vaultKind: args.toKind
30221
+ }
30222
+ }))
30223
+ };
30224
+ };
30225
+ /** Seal the env key to each recipient, producing the wrap rows for a cutover / rotation. */
30226
+ const wrapEnvKeyToRecipients = (evKey, recipients) => Effect.forEach(recipients, (entry) => Effect.promise(async () => ({
30227
+ recipientKind: entry.recipientKind,
30228
+ recipientId: entry.recipientId,
30229
+ wrappedKey: toBase64(await wrapVaultKey({
30230
+ vaultKey: evKey,
30231
+ recipient: entry.recipient
30232
+ }))
30233
+ })), { concurrency: "unbounded" });
30234
+
30235
+ //#endregion
30236
+ //#region src/application/env-vault-rotation.ts
30237
+ /**
30238
+ * Resolve the env vault's CURRENT recipients to the age recipients the new key is
30239
+ * wrapped to. The recipient set comes from `envVault.listWraps` (a member removal
30240
+ * already dropped the departing user's wraps server-side), and each id is resolved
30241
+ * to its public key: device/recovery/machine via `userEncryptionKeys`, account via
30242
+ * `accountKeys`. An id that no longer resolves (revoked) is dropped — the server
30243
+ * still enforces that a recovery recipient remains.
30244
+ */
30245
+ const rewrapSurvivingRecipients = (api, evKey) => Effect.gen(function* () {
30246
+ const { recipients } = yield* api.envVault.listWraps();
30247
+ const [{ items: keys }, { items: accounts }] = yield* Effect.all([api.userEncryptionKeys.list(), api.accountKeys.list()]);
30248
+ const keyById = new Map(keys.map((key) => [key.id, key.publicKey]));
30249
+ const accountById = new Map(accounts.map((account) => [account.id, account.agePublicKey]));
30250
+ return yield* wrapEnvKeyToRecipients(evKey, recipients.flatMap((wrap) => {
30251
+ const recipient = wrap.recipientKind === "account" ? accountById.get(wrap.recipientId) : keyById.get(wrap.recipientId);
30252
+ return recipient === void 0 ? [] : [{
30253
+ recipientKind: wrap.recipientKind,
30254
+ recipientId: wrap.recipientId,
30255
+ recipient
30256
+ }];
30257
+ }));
30258
+ });
30259
+ /** Re-key every env DEK under the new env key, bumping it to the next env version. */
30260
+ const rekeyEnvDeksForRotation = (api, params) => Effect.gen(function* () {
30261
+ const { deks } = yield* api.envVault.listCredentialDeks();
30262
+ return yield* Effect.forEach(deks, (dek) => Effect.try({
30263
+ try: () => rekeyEnvDek({
30264
+ orgId: params.orgId,
30265
+ credentialId: dek.credentialId,
30266
+ wrappedDek: dek.wrappedDek,
30267
+ from: params.fromKey,
30268
+ fromVersion: dek.vaultVersion,
30269
+ fromKind: "env",
30270
+ to: params.toKey,
30271
+ toVersion: params.toVersion,
30272
+ toKind: "env"
30273
+ }),
30274
+ catch: () => new IdentityError({ message: "Failed to re-key an env value during rotation — re-unlock the env vault and retry." })
30275
+ }), { concurrency: "unbounded" });
30276
+ });
30277
+ /**
30278
+ * Rotate the env vault key: generate a new key at version+1, re-wrap it to the
30279
+ * current recipients, re-key every env DEK, and submit atomically (the server
30280
+ * compare-and-swaps on the env version). Clears the env rotation-pending flag a
30281
+ * member removal raised. Drops the now-stale cached env key afterwards.
30282
+ */
30283
+ const rotateEnvVault = (api) => Effect.gen(function* () {
30284
+ const orgId = yield* getActiveOrgId(api);
30285
+ if ((yield* api.orgVault.get().pipe(Effect.catchTag("NotFound", () => new IdentityError({ message: "This organization has no credential vault yet." })))).envVaultCutoverAt === null) return yield* new IdentityError({ message: "This organization's env vault is not initialized." });
30286
+ const current = yield* unlockEnvVaultKeyInteractive(api);
30287
+ const toVersion = current.vaultVersion + 1;
30288
+ const newEvKey = generateVaultKey();
30289
+ const wraps = yield* rewrapSurvivingRecipients(api, newEvKey);
30290
+ const envDeks = yield* rekeyEnvDeksForRotation(api, {
30291
+ orgId,
30292
+ fromKey: current.vaultKey,
30293
+ toKey: newEvKey,
30294
+ toVersion
30295
+ });
30296
+ const rotated = yield* api.envVault.rotate({ payload: {
30297
+ fromVersion: current.vaultVersion,
30298
+ wraps,
30299
+ envDeks
30300
+ } });
30301
+ yield* forgetCachedEnvVaultKey;
30302
+ return rotated;
30303
+ });
30304
+
30305
+ //#endregion
30306
+ //#region src/commands/credentials/env-vault.ts
30307
+ const rotateCommand = defineCommand({
30308
+ meta: {
30309
+ name: "rotate",
30310
+ description: "Rotate the env-vault key, re-wrapping to the current recipients — clears a pending flag after a member is removed (admin)"
30311
+ },
30312
+ run: async () => runEffect(Effect.gen(function* () {
30313
+ const vault = yield* rotateEnvVault(yield* apiClient);
30314
+ yield* printHuman(`Rotated the env vault to version ${String(vault.envVaultVersion)}.`);
30315
+ return { envVaultVersion: vault.envVaultVersion };
30316
+ }), { json: "value" })
30317
+ });
30318
+ const statusCommand$2 = defineCommand({
30319
+ meta: {
30320
+ name: "status",
30321
+ description: "Show whether the org has cut over to a separate env vault, and its version/state"
30322
+ },
30323
+ run: async () => runEffect(Effect.gen(function* () {
30324
+ const vault = yield* (yield* apiClient).orgVault.get().pipe(Effect.catchTag("NotFound", () => Effect.succeed(null)));
30325
+ if (vault === null) {
30326
+ yield* printHuman("This organization has no credential vault yet.");
30327
+ return { vaultExists: false };
30328
+ }
30329
+ const cutOver = vault.envVaultCutoverAt !== null;
30330
+ yield* printKeyValue([
30331
+ ["Env vault", cutOver ? "separate (migrated)" : "shared with credentials vault"],
30332
+ ["Env vault version", cutOver ? String(vault.envVaultVersion) : "—"],
30333
+ ["Env rotation pending", vault.envRotationPending ? "yes" : "no"]
30334
+ ]);
30335
+ if (vault.envRotationPending) yield* printHuman("⚠ Env rotation pending — run `better-update credentials env-vault rotate` to re-key and restore env access.");
30336
+ return {
30337
+ vaultExists: true,
30338
+ cutOver,
30339
+ envVaultVersion: cutOver ? vault.envVaultVersion : null,
30340
+ envRotationPending: vault.envRotationPending
30341
+ };
30342
+ }), { json: "value" })
30343
+ });
30344
+ const envVaultCommand = defineCommand({
30345
+ meta: {
30346
+ name: "env-vault",
30347
+ description: "Manage the organization's env-vault (rotate, status)"
30348
+ },
30349
+ subCommands: {
30350
+ rotate: rotateCommand,
30351
+ status: statusCommand$2
30352
+ },
30353
+ default: "status"
30354
+ });
30355
+
29250
30356
  //#endregion
29251
30357
  //#region src/lib/credentials-generator-merchant.ts
29252
30358
  /**
@@ -29761,13 +30867,16 @@ const RECOVERY_LABEL = "Offline recovery key";
29761
30867
  /**
29762
30868
  * Bootstrap the org vault on first use: generate the org vault key locally, mint
29763
30869
  * an offline recovery recipient, wrap the vault key to BOTH the caller's device
29764
- * and the recovery key, and POST the two initial wrap rows. The server requires
29765
- * the bootstrap to include a `recovery` recipient (break-glass), so both wraps go
29766
- * up together. Returns the unlocked vault key plus the recovery private key for a
29767
- * one-time, offline-only display.
30870
+ * and the recovery key, and POST the initial wrap rows. The org is "born forked" —
30871
+ * a second, INDEPENDENT env-vault key is generated and wrapped to the same device +
30872
+ * recovery recipients in the same call, so the env vault is the default and
30873
+ * `env-vault migrate` is never needed. The server requires both a `recovery`
30874
+ * recipient (break-glass) and the env wraps. Returns the unlocked vault key plus
30875
+ * the recovery private key for a one-time, offline-only display.
29768
30876
  */
29769
30877
  const bootstrapVault = (args) => Effect.gen(function* () {
29770
30878
  const vaultKey = generateVaultKey();
30879
+ const envVaultKey = generateVaultKey();
29771
30880
  const recovery = yield* Effect.promise(async () => generateIdentity());
29772
30881
  const recoveryKey = yield* args.api.userEncryptionKeys.register({ payload: {
29773
30882
  kind: "recovery",
@@ -29782,15 +30891,27 @@ const bootstrapVault = (args) => Effect.gen(function* () {
29782
30891
  vaultKey,
29783
30892
  recipient: recovery.publicKey
29784
30893
  }))]);
30894
+ const envWraps = yield* wrapEnvKeyToRecipients(envVaultKey, [{
30895
+ recipientKind: "device",
30896
+ recipientId: args.deviceKeyId,
30897
+ recipient: args.deviceRecipient
30898
+ }, {
30899
+ recipientKind: "recovery",
30900
+ recipientId: recoveryKey.id,
30901
+ recipient: recovery.publicKey
30902
+ }]);
29785
30903
  return {
29786
30904
  vaultKey,
29787
- vaultVersion: (yield* args.api.orgVault.bootstrap({ payload: { wraps: [{
29788
- userEncryptionKeyId: args.deviceKeyId,
29789
- wrappedKey: toBase64(deviceWrap)
29790
- }, {
29791
- userEncryptionKeyId: recoveryKey.id,
29792
- wrappedKey: toBase64(recoveryWrap)
29793
- }] } })).vaultVersion,
30905
+ vaultVersion: (yield* args.api.orgVault.bootstrap({ payload: {
30906
+ wraps: [{
30907
+ userEncryptionKeyId: args.deviceKeyId,
30908
+ wrappedKey: toBase64(deviceWrap)
30909
+ }, {
30910
+ userEncryptionKeyId: recoveryKey.id,
30911
+ wrappedKey: toBase64(recoveryWrap)
30912
+ }],
30913
+ envWraps
30914
+ } })).vaultVersion,
29794
30915
  keyId: args.deviceKeyId,
29795
30916
  recoveryPrivateKey: recovery.privateKey,
29796
30917
  recoveryFingerprint: recovery.fingerprint
@@ -29803,7 +30924,7 @@ const resolveLabel = (flag) => Effect.gen(function* () {
29803
30924
  if (flag && flag.trim().length > 0) return flag.trim();
29804
30925
  return yield* promptText("Label for this device key", { defaultValue: yield* (yield* CliRuntime).userName });
29805
30926
  });
29806
- const promptNewPassphrase = Effect.gen(function* () {
30927
+ const promptNewPassphrase$1 = Effect.gen(function* () {
29807
30928
  const first = yield* promptPassword("Choose a passphrase to protect this device key:");
29808
30929
  if (first.length === 0) return yield* new IdentityError({ message: "Passphrase must not be empty." });
29809
30930
  if (first !== (yield* promptPassword("Confirm passphrase:"))) return yield* new IdentityError({ message: "Passphrases did not match." });
@@ -29825,7 +30946,7 @@ const createCommand$2 = defineCommand({
29825
30946
  } },
29826
30947
  run: async ({ args }) => runEffect(Effect.gen(function* () {
29827
30948
  const label = yield* resolveLabel(args.label);
29828
- const identity = yield* createLocalIdentity(yield* promptNewPassphrase);
30949
+ const identity = yield* createLocalIdentity(yield* promptNewPassphrase$1);
29829
30950
  const api = yield* apiClient;
29830
30951
  yield* printRecipient(yield* registerRecipient(api, {
29831
30952
  kind: "device",
@@ -29958,6 +31079,47 @@ const listCommand$4 = defineCommand({
29958
31079
  }))
29959
31080
  });
29960
31081
 
31082
+ //#endregion
31083
+ //#region src/commands/credentials/passphrase.ts
31084
+ /** Human result for each account-escrow outcome of a device passphrase change. */
31085
+ const ACCOUNT_OUTCOME_MESSAGE = {
31086
+ resealed: "Passphrase changed — this device's identity and your account key were both re-sealed.",
31087
+ absent: "Passphrase changed — this device's identity was re-sealed (no account key enrolled).",
31088
+ "passphrase-mismatch": "Passphrase changed for this device — but your account key is sealed under a DIFFERENT passphrase (likely changed on another device) and was left unchanged. Run `better-update credentials account reseal` to bring it onto this passphrase.",
31089
+ error: "Passphrase changed for this device — but your account key could not be re-sealed (the server was unreachable). Run `better-update credentials account reseal` to retry."
31090
+ };
31091
+ const promptNewPassphrase = Effect.gen(function* () {
31092
+ const first = yield* promptPassword("Choose a new passphrase:");
31093
+ if (first.length === 0) return yield* new IdentityError({ message: "Passphrase must not be empty." });
31094
+ if (first !== (yield* promptPassword("Confirm new passphrase:"))) return yield* new IdentityError({ message: "Passphrases did not match." });
31095
+ return first;
31096
+ });
31097
+ const changeCommand = defineCommand({
31098
+ meta: {
31099
+ name: "change",
31100
+ description: "Change this device's passphrase, re-sealing the device identity and (if enrolled) your account key under it"
31101
+ },
31102
+ run: async () => runEffect(Effect.gen(function* () {
31103
+ const { account } = yield* changePassphrase(yield* apiClient, {
31104
+ oldPassphrase: yield* promptPassword("Current passphrase:"),
31105
+ newPassphrase: yield* promptNewPassphrase
31106
+ });
31107
+ yield* printHuman(ACCOUNT_OUTCOME_MESSAGE[account]);
31108
+ return {
31109
+ changed: true,
31110
+ account
31111
+ };
31112
+ }), { json: "value" })
31113
+ });
31114
+ const passphraseCommand = defineCommand({
31115
+ meta: {
31116
+ name: "passphrase",
31117
+ description: "Manage this device's identity passphrase"
31118
+ },
31119
+ subCommands: { change: changeCommand },
31120
+ default: "change"
31121
+ });
31122
+
29961
31123
  //#endregion
29962
31124
  //#region src/commands/credentials/regenerate-profile.ts
29963
31125
  const REGENERATE_EXIT_EXTRAS = {
@@ -31323,6 +32485,9 @@ const credentialsCommand = defineCommand({
31323
32485
  manager: managerCommand,
31324
32486
  identity: identityCommand,
31325
32487
  access: accessCommand,
32488
+ account: accountCommand,
32489
+ "env-vault": envVaultCommand,
32490
+ passphrase: passphraseCommand,
31326
32491
  device: deviceCommand,
31327
32492
  unlock: unlockCommand,
31328
32493
  lock: lockCommand,
@@ -32614,8 +33779,9 @@ const updateCommand$1 = defineCommand({
32614
33779
  payload: compact({ visibility })
32615
33780
  });
32616
33781
  else {
33782
+ const session = yield* openEnvVaultSessionInteractive(api);
32617
33783
  const envelope = yield* sealForUpload({
32618
- session: yield* openVaultSessionInteractive(api),
33784
+ session,
32619
33785
  credentialType: "envVarValue",
32620
33786
  metadata: {
32621
33787
  key,
@@ -32630,7 +33796,8 @@ const updateCommand$1 = defineCommand({
32630
33796
  id: envelope.id,
32631
33797
  ciphertext: envelope.ciphertext,
32632
33798
  wrappedDek: envelope.wrappedDek,
32633
- vaultVersion: envelope.vaultVersion
33799
+ vaultVersion: envelope.vaultVersion,
33800
+ vaultKind: session.vaultKind
32634
33801
  },
32635
33802
  ...compact({ visibility })
32636
33803
  }