@better-update/cli 0.42.0 → 0.43.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.0";
38
+ var version = "0.43.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
@@ -1650,10 +1796,18 @@ const EnvVarListScope = Schema.Literal("all", "project", "global");
1650
1796
  * AAD `credentialId` when sealing; the envelope fields are the opaque ciphertext,
1651
1797
  * wrapped DEK, and vault version. The server stores these and never decrypts —
1652
1798
  * env var values are end-to-end encrypted, like credentials.
1799
+ *
1800
+ * `vaultKind` names which vault the DEK was sealed under. It is OPTIONAL for
1801
+ * back-compat: a pre-split CLI omits it (the server treats absence as
1802
+ * `"credentials"`). Once an org cuts over to a separate env vault, the server
1803
+ * requires `"env"` here — without it a credentials-keyed blob from an un-upgraded
1804
+ * (or racing) CLI would otherwise be silently stored into an env-vault row and be
1805
+ * permanently undecryptable. See `assertEnvVaultWriteAllowed`.
1653
1806
  */
1654
1807
  const EnvVarValueEnvelope = Schema.Struct({
1655
1808
  id: Id,
1656
- ...encryptedEnvelopeFields
1809
+ ...encryptedEnvelopeFields,
1810
+ vaultKind: Schema.optional(Schema.Literal("credentials", "env"))
1657
1811
  });
1658
1812
  /**
1659
1813
  * Env var metadata. The value is **not** here — it lives encrypted in the
@@ -1751,6 +1905,9 @@ var EnvVarsGroup = class extends HttpApiGroup.make("env-vars").add(HttpApiEndpoi
1751
1905
  }))).add(HttpApiEndpoint.get("get")`/api/env-vars/${idParam}`.addSuccess(EnvVar).annotateContext(OpenApi.annotations({
1752
1906
  title: "Get environment variable",
1753
1907
  description: "Get an environment variable's metadata by ID (no value)"
1908
+ }))).add(HttpApiEndpoint.get("getValue")`/api/env-vars/${idParam}/value`.addSuccess(EnvVarValueEnvelope).annotateContext(OpenApi.annotations({
1909
+ title: "Get sealed env-var value",
1910
+ 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
1911
  }))).add(HttpApiEndpoint.patch("update")`/api/env-vars/${idParam}`.setPayload(UpdateEnvVarBody).addSuccess(EnvVar).annotateContext(OpenApi.annotations({
1755
1912
  title: "Update environment variable",
1756
1913
  description: "Change the value (a new sealed revision) and/or the visibility tier. The environment is immutable."
@@ -1777,6 +1934,118 @@ var EnvVarsGroup = class extends HttpApiGroup.make("env-vars").add(HttpApiEndpoi
1777
1934
  description: "Manage end-to-end encrypted, versioned environment variables for project builds"
1778
1935
  })) {};
1779
1936
 
1937
+ //#endregion
1938
+ //#region ../../packages/api/src/domain/env-vault.ts
1939
+ /**
1940
+ * An env-vault recipient kind. Superset of the credentials-vault recipient kinds
1941
+ * plus `account` — the per-user account key the browser unwraps the env vault
1942
+ * with. `recipientId` references `user_encryption_keys.id` for the first three and
1943
+ * `account_keys.id` for `account` (polymorphic; see migration 0071).
1944
+ */
1945
+ const EnvVaultRecipientKind = Schema.Literal("device", "recovery", "machine", "account");
1946
+ /** One wrap of the env-vault key to a recipient (an opaque `age` blob). */
1947
+ var OrgEnvVaultKeyWrap = class extends Schema.Class("OrgEnvVaultKeyWrap")({
1948
+ organizationId: Id,
1949
+ envVaultVersion: VaultVersion,
1950
+ recipientKind: EnvVaultRecipientKind,
1951
+ recipientId: Id,
1952
+ wrappedKey: Schema.String,
1953
+ createdAt: DateTimeString
1954
+ }) {};
1955
+ /** The wrapped env-vault key for the calling recipient — fetched, then unwrapped client-side. */
1956
+ const RecipientEnvVaultKey = Schema.Struct({
1957
+ envVaultVersion: VaultVersion,
1958
+ wrappedKey: Schema.String
1959
+ });
1960
+ /** A recipient currently holding the env-vault key (kind + id + when wrapped). */
1961
+ const EnvVaultRecipientRef = Schema.Struct({
1962
+ recipientKind: EnvVaultRecipientKind,
1963
+ recipientId: Id,
1964
+ createdAt: DateTimeString
1965
+ });
1966
+ /** Every recipient holding the env-vault key at the current version. */
1967
+ const EnvVaultRecipients = Schema.Struct({
1968
+ envVaultVersion: VaultVersion,
1969
+ recipients: Schema.Array(EnvVaultRecipientRef)
1970
+ });
1971
+ /** One recipient's wrap row in a cutover / grant / rotate submission (age blob, base64). */
1972
+ const EnvVaultWrapInput = Schema.Struct({
1973
+ recipientKind: EnvVaultRecipientKind,
1974
+ recipientId: Id,
1975
+ wrappedKey: Schema.String.pipe(Schema.minLength(1))
1976
+ });
1977
+ /** Add a single env wrap at the current env version (grant or self-link). */
1978
+ const AddEnvVaultWrapBody = Schema.Struct({
1979
+ envVaultVersion: VaultVersion,
1980
+ wrap: EnvVaultWrapInput
1981
+ });
1982
+ /** One env-var revision's DEK re-wrapped under the env-vault key (cutover/rotation). */
1983
+ const EnvVaultDekUpdate = Schema.Struct({
1984
+ credentialId: Id,
1985
+ wrappedDek: WrappedDek
1986
+ });
1987
+ /** One env-var revision's currently-stored wrapped DEK + version (the rotation source). */
1988
+ const EnvVaultDekRef = Schema.Struct({
1989
+ credentialId: Id,
1990
+ wrappedDek: WrappedDek,
1991
+ vaultVersion: VaultVersion
1992
+ });
1993
+ /** Every wrapped env DEK + the current env-vault version (the rotation source set). */
1994
+ const EnvVaultCredentialDeks = Schema.Struct({
1995
+ envVaultVersion: VaultVersion,
1996
+ deks: Schema.Array(EnvVaultDekRef)
1997
+ });
1998
+ /**
1999
+ * The one-shot cutover: fork the org's env values into a separate env vault. The
2000
+ * client generates the env key, wraps it to every recipient (device/recovery/
2001
+ * machine + each member's account key), and re-keys every env DEK from the
2002
+ * credentials key to the env key. Must include an offline recovery recipient and
2003
+ * re-key every env-var revision.
2004
+ */
2005
+ const CutoverEnvVaultBody = Schema.Struct({
2006
+ wraps: Schema.Array(EnvVaultWrapInput).pipe(Schema.minItems(1)),
2007
+ envDeks: Schema.Array(EnvVaultDekUpdate)
2008
+ });
2009
+ /**
2010
+ * Rotate (or revoke) the env-vault key. The client generates a new key at
2011
+ * `fromVersion + 1`, re-wraps every env DEK under it, and re-wraps the new key to
2012
+ * the surviving recipients. Applied atomically with compare-and-swap on
2013
+ * `fromVersion`; must re-key every env-var revision.
2014
+ */
2015
+ const RotateEnvVaultBody = Schema.Struct({
2016
+ fromVersion: VaultVersion,
2017
+ wraps: Schema.Array(EnvVaultWrapInput).pipe(Schema.minItems(1)),
2018
+ envDeks: Schema.Array(EnvVaultDekUpdate)
2019
+ });
2020
+
2021
+ //#endregion
2022
+ //#region ../../packages/api/src/groups/env-vault.ts
2023
+ /** `:recipientKind` / `:recipientId` path params for a polymorphic env recipient. */
2024
+ const recipientKindParam = HttpApiSchema.param("recipientKind", EnvVaultRecipientKind);
2025
+ const recipientIdParam = HttpApiSchema.param("recipientId", Id);
2026
+ var EnvVaultGroup = class extends HttpApiGroup.make("envVault").add(HttpApiEndpoint.post("cutover", "/api/env-vault/cutover").setPayload(CutoverEnvVaultBody).addSuccess(OrgVault).annotateContext(OpenApi.annotations({
2027
+ title: "Cut over to the env vault",
2028
+ 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)."
2029
+ }))).add(HttpApiEndpoint.get("listWraps", "/api/env-vault/wraps").addSuccess(EnvVaultRecipients).annotateContext(OpenApi.annotations({
2030
+ title: "List env-vault recipients",
2031
+ description: "List the recipients holding the env-vault key at the current version"
2032
+ }))).add(HttpApiEndpoint.post("addWrap", "/api/env-vault/wraps").setPayload(AddEnvVaultWrapBody).addSuccess(OrgEnvVaultKeyWrap, { status: 201 }).annotateContext(OpenApi.annotations({
2033
+ title: "Add env-vault wrap",
2034
+ description: "Wrap the env-vault key to a recipient — granting a member's account key (admin) or self-linking your own device/account key"
2035
+ }))).add(HttpApiEndpoint.get("getWrap")`/api/env-vault/wraps/${recipientKindParam}/${recipientIdParam}`.addSuccess(RecipientEnvVaultKey).annotateContext(OpenApi.annotations({
2036
+ title: "Get env-vault wrap",
2037
+ description: "Fetch the wrapped env-vault key for a recipient to unwrap locally"
2038
+ }))).add(HttpApiEndpoint.get("listCredentialDeks", "/api/env-vault/credential-deks").addSuccess(EnvVaultCredentialDeks).annotateContext(OpenApi.annotations({
2039
+ title: "List wrapped env DEKs",
2040
+ description: "Every wrapped env DEK + the current env-vault version — fetched to re-wrap under a new key during a rotation"
2041
+ }))).add(HttpApiEndpoint.post("rotate", "/api/env-vault/rotate").setPayload(RotateEnvVaultBody).addSuccess(OrgVault).annotateContext(OpenApi.annotations({
2042
+ title: "Rotate env-vault key",
2043
+ 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"
2044
+ }))).addError(NotFound).addError(Conflict).addError(BadRequest).addError(Forbidden).annotateContext(OpenApi.annotations({
2045
+ title: "Env Vault",
2046
+ description: "Manage the organization's separate end-to-end encrypted env-vault key wraps"
2047
+ })) {};
2048
+
1780
2049
  //#endregion
1781
2050
  //#region ../../packages/api/src/groups/environments.ts
1782
2051
  /** `:name` path parameter — the environment name (built-in or user-defined). */
@@ -2670,42 +2939,6 @@ var UpdatesGroup = class extends HttpApiGroup.make("updates").add(HttpApiEndpoin
2670
2939
  description: "Update publishing, deletion, republish, and per-update rollout endpoints"
2671
2940
  })) {};
2672
2941
 
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
2942
  //#endregion
2710
2943
  //#region ../../packages/api/src/groups/user-encryption-keys.ts
2711
2944
  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 +2952,44 @@ var UserEncryptionKeysGroup = class extends HttpApiGroup.make("userEncryptionKey
2719
2952
  description: "Register and list end-to-end encryption recipient public keys"
2720
2953
  })) {};
2721
2954
 
2955
+ //#endregion
2956
+ //#region ../../packages/api/src/domain/web-vault.ts
2957
+ /**
2958
+ * A WebAuthn authentication assertion, JSON-stringified by the browser
2959
+ * (`@better-auth/passkey/client` drives `navigator.credentials.get()`), carried
2960
+ * as a single string so the wire payload stays a plain object with no opaque
2961
+ * `unknown` fields. The step-up handler parses it and hands it to better-auth's
2962
+ * `verifyPasskeyAuthentication`; the exact inner shape is the plugin's contract,
2963
+ * not ours.
2964
+ */
2965
+ const PasskeyStepUpBody = Schema.Struct({ assertionJson: Schema.String });
2966
+ /**
2967
+ * Result of a successful step-up: the ISO instant the server recorded, which the
2968
+ * env-vault gate now treats as the start of the step-up TTL window for this
2969
+ * session.
2970
+ */
2971
+ const PasskeyStepUpResult = Schema.Struct({ verifiedAt: Schema.String });
2972
+
2973
+ //#endregion
2974
+ //#region ../../packages/api/src/groups/web-vault.ts
2975
+ /**
2976
+ * Web-vault step-up: the WebAuthn re-authentication a browser session performs
2977
+ * before it may read/write env values (the "2FA mandatory before web env access"
2978
+ * rule, spec §P4). The browser fetches a challenge from better-auth's
2979
+ * `generate-authenticate-options`, runs the passkey ceremony, then POSTs the
2980
+ * assertion here; the server verifies it via the passkey plugin and records a
2981
+ * fresh step-up for THIS session. The env-vault write gate
2982
+ * (assert-web-env-step-up) consults that record. CLI/CI (bearer) callers never
2983
+ * need this — they are exempt from the gate.
2984
+ */
2985
+ var WebVaultGroup = class extends HttpApiGroup.make("webVault").add(HttpApiEndpoint.post("stepUp", "/api/web-vault/step-up").setPayload(PasskeyStepUpBody).addSuccess(PasskeyStepUpResult).annotateContext(OpenApi.annotations({
2986
+ title: "WebAuthn step-up",
2987
+ 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."
2988
+ }))).addError(BadRequest).addError(Forbidden).annotateContext(OpenApi.annotations({
2989
+ title: "Web Vault",
2990
+ description: "WebAuthn step-up for browser env-vault access"
2991
+ })) {};
2992
+
2722
2993
  //#endregion
2723
2994
  //#region ../../packages/api/src/domain/webhook.ts
2724
2995
  const WebhookEventName = Schema.Literal("update.published", "build.completed");
@@ -2775,7 +3046,7 @@ var WebhooksGroup = class extends HttpApiGroup.make("webhooks").add(HttpApiEndpo
2775
3046
 
2776
3047
  //#endregion
2777
3048
  //#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({
3049
+ 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
3050
  title: "Better Update Management API",
2780
3051
  version: "1.0.0",
2781
3052
  description: "Management API for OTA update publishing, deployment, and analytics"
@@ -3731,8 +4002,16 @@ const VAULT_CACHE_TTL_MS = 900 * 1e3;
3731
4002
  /** Bounds for a user-chosen TTL (`credentials unlock --duration`). */
3732
4003
  const VAULT_CACHE_TTL_MIN_MS = 60 * 1e3;
3733
4004
  const VAULT_CACHE_TTL_MAX_MS = 1440 * 60 * 1e3;
3734
- /** Keychain service name; the account is the recipient's public key. */
4005
+ /** Keychain service name; the account is the recipient's public key (per {@link cacheAccount}). */
3735
4006
  const KEYCHAIN_SERVICE = "better-update-vault";
4007
+ /**
4008
+ * The keychain account a recipient's cached key is stored under, namespaced by
4009
+ * vault kind so the credentials and env vaults cache independently. The
4010
+ * credentials vault keeps the bare public key — byte-identical to entries written
4011
+ * before the two-vault split, so an upgrade keeps any live unlock — while the env
4012
+ * vault is prefixed.
4013
+ */
4014
+ const cacheAccount = (publicKey, vaultKind = "credentials") => vaultKind === "env" ? `env:${publicKey}` : publicKey;
3736
4015
  const isCachedVaultEntry = (value) => isRecord$1(value) && typeof value["vaultKey"] === "string" && typeof value["vaultVersion"] === "number" && typeof value["keyId"] === "string" && typeof value["exp"] === "number";
3737
4016
  /** Serialize an unlocked vault into a keychain blob, stamping a TTL from `now`. */
3738
4017
  const encodeCacheEntry = (vault, now, ttlMs = VAULT_CACHE_TTL_MS) => JSON.stringify({
@@ -3765,28 +4044,30 @@ const VaultCacheLive = Layer.effect(VaultCache, Effect.gen(function* () {
3765
4044
  const flag = yield* runtime.getEnv("BETTER_UPDATE_NO_CACHE");
3766
4045
  return flag !== void 0 && flag.length > 0 && flag !== "0" && flag !== "false";
3767
4046
  });
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);
4047
+ const readRaw = (account) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, account).getPassword()).pipe(Effect.orElseSucceed(() => null));
4048
+ const writeRaw = (account, blob) => Effect.try(() => {
4049
+ new Entry(KEYCHAIN_SERVICE, account).setPassword(blob);
3771
4050
  }).pipe(Effect.ignore);
3772
- const deleteRaw = (publicKey) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, publicKey).deletePassword()).pipe(Effect.ignore);
4051
+ const deleteRaw = (account) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, account).deletePassword()).pipe(Effect.ignore);
3773
4052
  return {
3774
- get: (publicKey) => Effect.gen(function* () {
4053
+ get: (publicKey, vaultKind) => Effect.gen(function* () {
3775
4054
  if (yield* cacheDisabled) return;
3776
- const raw = yield* readRaw(publicKey);
4055
+ const account = cacheAccount(publicKey, vaultKind);
4056
+ const raw = yield* readRaw(account);
3777
4057
  if (raw === null) return;
3778
4058
  const decoded = decodeCacheEntry(raw, yield* Clock.currentTimeMillis);
3779
4059
  if (decoded === void 0) {
3780
- yield* deleteRaw(publicKey);
4060
+ yield* deleteRaw(account);
3781
4061
  return;
3782
4062
  }
3783
4063
  return decoded;
3784
4064
  }),
3785
- set: (publicKey, vault, ttlMs) => Effect.gen(function* () {
4065
+ set: (publicKey, vault, opts) => Effect.gen(function* () {
3786
4066
  if (yield* cacheDisabled) return;
3787
- yield* writeRaw(publicKey, encodeCacheEntry(vault, yield* Clock.currentTimeMillis, ttlMs));
4067
+ const now = yield* Clock.currentTimeMillis;
4068
+ yield* writeRaw(cacheAccount(publicKey, opts?.vaultKind), encodeCacheEntry(vault, now, opts?.ttlMs));
3788
4069
  }),
3789
- clear: (publicKey) => deleteRaw(publicKey)
4070
+ clear: (publicKey, vaultKind) => deleteRaw(cacheAccount(publicKey, vaultKind))
3790
4071
  };
3791
4072
  }));
3792
4073
 
@@ -5764,7 +6045,7 @@ const listCommand$9 = defineCommand({
5764
6045
  ]), "No branches found.");
5765
6046
  }))
5766
6047
  });
5767
- const createCommand$5 = defineCommand({
6048
+ const createCommand$6 = defineCommand({
5768
6049
  meta: {
5769
6050
  name: "create",
5770
6051
  description: "Create a branch"
@@ -5872,7 +6153,7 @@ const branchesCommand = defineCommand({
5872
6153
  subCommands: {
5873
6154
  list: listCommand$9,
5874
6155
  view: viewCommand$3,
5875
- create: createCommand$5,
6156
+ create: createCommand$6,
5876
6157
  rename: renameCommand$2,
5877
6158
  delete: deleteCommand$7
5878
6159
  }
@@ -9007,7 +9288,7 @@ const sha512 = /* @__PURE__ */ createHasher$1(() => new _SHA512(), /* @__PURE__
9007
9288
  //#endregion
9008
9289
  //#region ../../packages/credentials-crypto/src/aead.ts
9009
9290
  const aead = managedNonce(xchacha20poly1305);
9010
- const textEncoder$2 = new TextEncoder();
9291
+ const textEncoder$3 = new TextEncoder();
9011
9292
  const KEY_BYTES$1 = 32;
9012
9293
  const LENGTH_PREFIX_BYTES = 4;
9013
9294
  /** A fresh 32-byte symmetric key — an org vault key or a per-credential DEK. */
@@ -9029,7 +9310,7 @@ const aeadDecrypt = (key, sealed, aad) => aead(key, aad).decrypt(sealed);
9029
9310
  * ever encode to the same bytes (prevents cross-binding confusion).
9030
9311
  */
9031
9312
  const encodeAad = (domain, parts) => {
9032
- const segments = [domain, ...parts.map(String)].map((text) => textEncoder$2.encode(text));
9313
+ const segments = [domain, ...parts.map(String)].map((text) => textEncoder$3.encode(text));
9033
9314
  const size = segments.reduce((total, seg) => total + LENGTH_PREFIX_BYTES + seg.length, 0);
9034
9315
  const out = new Uint8Array(size);
9035
9316
  const view = new DataView(out.buffer);
@@ -9041,7 +9322,7 @@ const encodeAad = (domain, parts) => {
9041
9322
  return out;
9042
9323
  };
9043
9324
  /** 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, "")}`;
9325
+ const fingerprint = (recipient) => `SHA256:${toBase64(sha256$1(textEncoder$3.encode(recipient))).replace(/=+$/u, "")}`;
9045
9326
 
9046
9327
  //#endregion
9047
9328
  //#region ../../node_modules/.bun/@noble+hashes@2.2.0/node_modules/@noble/hashes/_blake.js
@@ -17823,6 +18104,191 @@ var PrimeEdwardsPoint = class {
17823
18104
  return this;
17824
18105
  }
17825
18106
  };
18107
+ /**
18108
+ * Initializes EdDSA signatures over given Edwards curve.
18109
+ * @param Point - Edwards point constructor.
18110
+ * @param cHash - Hash function.
18111
+ * @param eddsaOpts - Optional signature helpers. See {@link EdDSAOpts}.
18112
+ * @returns EdDSA helper namespace.
18113
+ * @throws If the hash function, options, or derived point operations are invalid. {@link Error}
18114
+ * @example
18115
+ * Initializes EdDSA signatures over given Edwards curve.
18116
+ *
18117
+ * ```ts
18118
+ * import { eddsa } from '@noble/curves/abstract/edwards.js';
18119
+ * import { jubjub } from '@noble/curves/misc.js';
18120
+ * import { sha512 } from '@noble/hashes/sha2.js';
18121
+ * const sigs = eddsa(jubjub.Point, sha512);
18122
+ * const { secretKey, publicKey } = sigs.keygen();
18123
+ * const msg = new TextEncoder().encode('hello noble');
18124
+ * const sig = sigs.sign(msg, secretKey);
18125
+ * const isValid = sigs.verify(sig, msg, publicKey);
18126
+ * ```
18127
+ */
18128
+ function eddsa(Point, cHash, eddsaOpts = {}) {
18129
+ if (typeof cHash !== "function") throw new Error("\"hash\" function param is required");
18130
+ const hash = cHash;
18131
+ const opts = eddsaOpts;
18132
+ validateObject(opts, {}, {
18133
+ adjustScalarBytes: "function",
18134
+ randomBytes: "function",
18135
+ domain: "function",
18136
+ prehash: "function",
18137
+ zip215: "boolean",
18138
+ mapToCurve: "function"
18139
+ });
18140
+ const { prehash } = opts;
18141
+ const { BASE, Fp, Fn } = Point;
18142
+ const outputLen = hash.outputLen;
18143
+ const expectedLen = 2 * Fp.BYTES;
18144
+ if (outputLen !== void 0) {
18145
+ asafenumber(outputLen, "hash.outputLen");
18146
+ if (outputLen !== expectedLen) throw new Error(`hash.outputLen must be ${expectedLen}, got ${outputLen}`);
18147
+ }
18148
+ const randomBytes = opts.randomBytes === void 0 ? randomBytes$1 : opts.randomBytes;
18149
+ const adjustScalarBytes = opts.adjustScalarBytes === void 0 ? (bytes) => bytes : opts.adjustScalarBytes;
18150
+ const domain = opts.domain === void 0 ? (data, ctx, phflag) => {
18151
+ abool(phflag, "phflag");
18152
+ if (ctx.length || phflag) throw new Error("Contexts/pre-hash are not supported");
18153
+ return data;
18154
+ } : opts.domain;
18155
+ function modN_LE(hash) {
18156
+ return Fn.create(bytesToNumberLE(hash));
18157
+ }
18158
+ function getPrivateScalar(key) {
18159
+ const len = lengths.secretKey;
18160
+ abytes(key, lengths.secretKey, "secretKey");
18161
+ const hashed = abytes(hash(key), 2 * len, "hashedSecretKey");
18162
+ const head = adjustScalarBytes(hashed.slice(0, len));
18163
+ return {
18164
+ head,
18165
+ prefix: hashed.slice(len, 2 * len),
18166
+ scalar: modN_LE(head)
18167
+ };
18168
+ }
18169
+ /** Convenience method that creates public key from scalar. RFC8032 5.1.5
18170
+ * Also exposes the derived scalar/prefix tuple and point form reused by sign().
18171
+ */
18172
+ function getExtendedPublicKey(secretKey) {
18173
+ const { head, prefix, scalar } = getPrivateScalar(secretKey);
18174
+ const point = BASE.multiply(scalar);
18175
+ return {
18176
+ head,
18177
+ prefix,
18178
+ scalar,
18179
+ point,
18180
+ pointBytes: point.toBytes()
18181
+ };
18182
+ }
18183
+ /** Calculates EdDSA pub key. RFC8032 5.1.5. */
18184
+ function getPublicKey(secretKey) {
18185
+ return getExtendedPublicKey(secretKey).pointBytes;
18186
+ }
18187
+ function hashDomainToScalar(context = Uint8Array.of(), ...msgs) {
18188
+ return modN_LE(hash(domain(concatBytes(...msgs), abytes(context, void 0, "context"), !!prehash)));
18189
+ }
18190
+ /** Signs message with secret key. RFC8032 5.1.6 */
18191
+ function sign(msg, secretKey, options = {}) {
18192
+ msg = abytes(msg, void 0, "message");
18193
+ if (prehash) msg = prehash(msg);
18194
+ const { prefix, scalar, pointBytes } = getExtendedPublicKey(secretKey);
18195
+ const r = hashDomainToScalar(options.context, prefix, msg);
18196
+ const R = BASE.multiply(r).toBytes();
18197
+ const k = hashDomainToScalar(options.context, R, pointBytes, msg);
18198
+ const s = Fn.create(r + k * scalar);
18199
+ if (!Fn.isValid(s)) throw new Error("sign failed: invalid s");
18200
+ return abytes(concatBytes(R, Fn.toBytes(s)), lengths.signature, "result");
18201
+ }
18202
+ const verifyOpts = { zip215: opts.zip215 };
18203
+ /**
18204
+ * Verifies EdDSA signature against message and public key. RFC 8032 §§5.1.7 and 5.2.7.
18205
+ * A cofactored verification equation is checked.
18206
+ */
18207
+ function verify(sig, msg, publicKey, options = verifyOpts) {
18208
+ const { context } = options;
18209
+ const zip215 = options.zip215 === void 0 ? !!verifyOpts.zip215 : options.zip215;
18210
+ const len = lengths.signature;
18211
+ sig = abytes(sig, len, "signature");
18212
+ msg = abytes(msg, void 0, "message");
18213
+ publicKey = abytes(publicKey, lengths.publicKey, "publicKey");
18214
+ if (zip215 !== void 0) abool(zip215, "zip215");
18215
+ if (prehash) msg = prehash(msg);
18216
+ const mid = len / 2;
18217
+ const r = sig.subarray(0, mid);
18218
+ const s = bytesToNumberLE(sig.subarray(mid, len));
18219
+ let A, R, SB;
18220
+ try {
18221
+ A = Point.fromBytes(publicKey, zip215);
18222
+ R = Point.fromBytes(r, zip215);
18223
+ SB = BASE.multiplyUnsafe(s);
18224
+ } catch (error) {
18225
+ return false;
18226
+ }
18227
+ if (!zip215 && A.isSmallOrder()) return false;
18228
+ const k = hashDomainToScalar(context, r, publicKey, msg);
18229
+ return R.add(A.multiplyUnsafe(k)).subtract(SB).clearCofactor().is0();
18230
+ }
18231
+ const _size = Fp.BYTES;
18232
+ const lengths = {
18233
+ secretKey: _size,
18234
+ publicKey: _size,
18235
+ signature: 2 * _size,
18236
+ seed: _size
18237
+ };
18238
+ function randomSecretKey(seed) {
18239
+ seed = seed === void 0 ? randomBytes(lengths.seed) : seed;
18240
+ return abytes(seed, lengths.seed, "seed");
18241
+ }
18242
+ function isValidSecretKey(key) {
18243
+ return isBytes(key) && key.length === lengths.secretKey;
18244
+ }
18245
+ function isValidPublicKey(key, zip215) {
18246
+ try {
18247
+ return !!Point.fromBytes(key, zip215 === void 0 ? verifyOpts.zip215 : zip215);
18248
+ } catch (error) {
18249
+ return false;
18250
+ }
18251
+ }
18252
+ const utils = {
18253
+ getExtendedPublicKey,
18254
+ randomSecretKey,
18255
+ isValidSecretKey,
18256
+ isValidPublicKey,
18257
+ /**
18258
+ * Converts ed public key to x public key. Uses formula:
18259
+ * - ed25519:
18260
+ * - `(u, v) = ((1+y)/(1-y), sqrt(-486664)*u/x)`
18261
+ * - `(x, y) = (sqrt(-486664)*u/v, (u-1)/(u+1))`
18262
+ * - ed448:
18263
+ * - `(u, v) = ((y-1)/(y+1), sqrt(156324)*u/x)`
18264
+ * - `(x, y) = (sqrt(156324)*u/v, (1+u)/(1-u))`
18265
+ */
18266
+ toMontgomery(publicKey) {
18267
+ const { y } = Point.fromBytes(publicKey);
18268
+ const size = lengths.publicKey;
18269
+ const is25519 = size === 32;
18270
+ if (!is25519 && size !== 57) throw new Error("only defined for 25519 and 448");
18271
+ const u = is25519 ? Fp.div(_1n$2 + y, _1n$2 - y) : Fp.div(y - _1n$2, y + _1n$2);
18272
+ return Fp.toBytes(u);
18273
+ },
18274
+ toMontgomerySecret(secretKey) {
18275
+ const size = lengths.secretKey;
18276
+ abytes(secretKey, size);
18277
+ return adjustScalarBytes(hash(secretKey.subarray(0, size))).subarray(0, size);
18278
+ }
18279
+ };
18280
+ Object.freeze(lengths);
18281
+ Object.freeze(utils);
18282
+ return Object.freeze({
18283
+ keygen: createKeygen(randomSecretKey, getPublicKey),
18284
+ getPublicKey,
18285
+ sign,
18286
+ verify,
18287
+ utils,
18288
+ Point,
18289
+ lengths
18290
+ });
18291
+ }
17826
18292
 
17827
18293
  //#endregion
17828
18294
  //#region ../../node_modules/.bun/@noble+curves@2.2.0/node_modules/@noble/curves/abstract/montgomery.js
@@ -18033,6 +18499,31 @@ function uvRatio(u, v) {
18033
18499
  const ed25519_Point = /* @__PURE__ */ edwards(ed25519_CURVE, { uvRatio });
18034
18500
  const Fp = /* @__PURE__ */ (() => ed25519_Point.Fp)();
18035
18501
  const Fn = /* @__PURE__ */ (() => ed25519_Point.Fn)();
18502
+ function ed(opts) {
18503
+ return eddsa(ed25519_Point, sha512, Object.assign({
18504
+ adjustScalarBytes,
18505
+ zip215: true
18506
+ }, opts));
18507
+ }
18508
+ /**
18509
+ * ed25519 curve with EdDSA signatures.
18510
+ * Seeded `keygen(seed)` / `utils.randomSecretKey(seed)` reuse the provided
18511
+ * 32-byte seed buffer instead of copying it.
18512
+ * @example
18513
+ * Generate one Ed25519 keypair, sign a message, and verify it.
18514
+ *
18515
+ * ```js
18516
+ * import { ed25519 } from '@noble/curves/ed25519.js';
18517
+ * const { secretKey, publicKey } = ed25519.keygen();
18518
+ * // const publicKey = ed25519.getPublicKey(secretKey);
18519
+ * const msg = new TextEncoder().encode('hello noble');
18520
+ * const sig = ed25519.sign(msg, secretKey);
18521
+ * const isValid = ed25519.verify(sig, msg, publicKey); // ZIP215
18522
+ * // RFC8032 / FIPS 186-5
18523
+ * const isValid2 = ed25519.verify(sig, msg, publicKey, { zip215: false });
18524
+ * ```
18525
+ */
18526
+ const ed25519 = /* @__PURE__ */ ed({});
18036
18527
  /**
18037
18528
  * ECDH using curve25519 aka x25519.
18038
18529
  * `getSharedSecret()` rejects low-order peer inputs by default, and seeded
@@ -19113,8 +19604,9 @@ function isCryptoKey(key) {
19113
19604
 
19114
19605
  //#endregion
19115
19606
  //#region ../../packages/credentials-crypto/src/identity.ts
19116
- const textEncoder$1 = new TextEncoder();
19117
- const textDecoder$1 = new TextDecoder();
19607
+ const textEncoder$2 = new TextEncoder();
19608
+ const textDecoder$2 = new TextDecoder();
19609
+ /** Salt length for the passphrase KDF (shared by the identity + account-key seals). */
19118
19610
  const SALT_BYTES = 16;
19119
19611
  const KEY_BYTES = 32;
19120
19612
  /** OWASP-recommended Argon2id defaults (~64 MiB), tuned for ~250–500 ms. */
@@ -19135,6 +19627,7 @@ const generateIdentity = async () => {
19135
19627
  };
19136
19628
  /** Derive the age recipient (`age1...`) from an identity private key. */
19137
19629
  const deriveRecipient = async (privateKey) => identityToRecipient(privateKey);
19630
+ /** Argon2id(passphrase, salt) → 32-byte KEK. Shared by the identity + account-key seals. */
19138
19631
  const deriveKek = (passphrase, salt, params) => {
19139
19632
  return argon2id(passphrase, salt, {
19140
19633
  t: params.time,
@@ -19155,8 +19648,8 @@ const sealIdentity = async (args) => {
19155
19648
  const publicKey = await identityToRecipient(args.privateKey);
19156
19649
  const kdfParams = args.kdfParams ?? DEFAULT_ARGON2_PARAMS;
19157
19650
  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({
19651
+ const salt = randomBytes$5(16);
19652
+ const ct = aeadEncrypt(deriveKek(args.passphrase, salt, kdfParams), textEncoder$2.encode(args.privateKey), sealAad({
19160
19653
  publicKey,
19161
19654
  fingerprint: fp,
19162
19655
  kdfParams
@@ -19181,7 +19674,7 @@ const sealIdentity = async (args) => {
19181
19674
  const openIdentity = async (args) => {
19182
19675
  const { file } = args;
19183
19676
  const kek = deriveKek(args.passphrase, fromBase64(file.salt), file.kdfParams);
19184
- const privateKey = textDecoder$1.decode(aeadDecrypt(kek, fromBase64(file.ct), sealAad(file)));
19677
+ const privateKey = textDecoder$2.decode(aeadDecrypt(kek, fromBase64(file.ct), sealAad(file)));
19185
19678
  const publicKey = await identityToRecipient(privateKey);
19186
19679
  return {
19187
19680
  privateKey,
@@ -19211,7 +19704,12 @@ const unwrapVaultKey = async (args) => {
19211
19704
  decrypter.addIdentity(args.privateKey);
19212
19705
  return decrypter.decrypt(args.wrapped);
19213
19706
  };
19214
- const dekAad = (binding) => encodeAad("better-update/dek", [
19707
+ const dekAad = (binding) => binding.vaultKind === "env" ? encodeAad("better-update/dek", [
19708
+ binding.orgId,
19709
+ binding.credentialId,
19710
+ binding.vaultVersion,
19711
+ "env"
19712
+ ]) : encodeAad("better-update/dek", [
19215
19713
  binding.orgId,
19216
19714
  binding.credentialId,
19217
19715
  binding.vaultVersion
@@ -19227,8 +19725,8 @@ const unwrapDek = (args) => aeadDecrypt(args.vaultKey, args.wrappedDek, dekAad(a
19227
19725
 
19228
19726
  //#endregion
19229
19727
  //#region ../../packages/credentials-crypto/src/credential.ts
19230
- const textEncoder = new TextEncoder();
19231
- const textDecoder = new TextDecoder();
19728
+ const textEncoder$1 = new TextEncoder();
19729
+ const textDecoder$1 = new TextDecoder();
19232
19730
  const blobAad = (binding) => encodeAad("better-update/credential", [
19233
19731
  binding.orgId,
19234
19732
  binding.credentialId,
@@ -19236,7 +19734,7 @@ const blobAad = (binding) => encodeAad("better-update/credential", [
19236
19734
  binding.schemaVersion
19237
19735
  ]);
19238
19736
  /** 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));
19737
+ const sealCredential = (args) => aeadEncrypt(args.dek, textEncoder$1.encode(JSON.stringify(args.payload)), blobAad(args.payload));
19240
19738
  /**
19241
19739
  * Decrypt a credential blob with its DEK. The AAD binds the blob to `expect`, so
19242
19740
  * a blob for another (org, credential, type, schema) fails the tag instead of
@@ -19246,7 +19744,99 @@ const sealCredential = (args) => aeadEncrypt(args.dek, textEncoder.encode(JSON.s
19246
19744
  */
19247
19745
  const openCredential = (args) => {
19248
19746
  const plaintext = aeadDecrypt(args.dek, args.ciphertext, blobAad(args.expect));
19249
- return JSON.parse(textDecoder.decode(plaintext));
19747
+ return JSON.parse(textDecoder$1.decode(plaintext));
19748
+ };
19749
+
19750
+ //#endregion
19751
+ //#region ../../packages/credentials-crypto/src/account-key.ts
19752
+ const textEncoder = new TextEncoder();
19753
+ const textDecoder = new TextDecoder();
19754
+ /**
19755
+ * Argon2id cost for the ACCOUNT escrow. Deliberately heavier than the on-disk
19756
+ * identity default (`DEFAULT_ARGON2_PARAMS`, ~64 MiB): the escrow blob is stored
19757
+ * server-side, so it is more exposed than a local `identity.json` and warrants a
19758
+ * costlier KDF. ~128 MiB. The params live in the envelope, so an enrollment can be
19759
+ * re-tuned without a format change — validate pure-JS Argon2id perf in the browser
19760
+ * before raising this. See docs/specs/build/11-two-vault-split-and-web-env-crud.md §3.2.
19761
+ */
19762
+ const ACCOUNT_ARGON2_PARAMS = {
19763
+ time: 3,
19764
+ memory: 131072,
19765
+ parallelism: 1
19766
+ };
19767
+ /** Narrow a decrypted escrow payload to {@link SealedAccountSecret} without an unsafe cast. */
19768
+ const isSealedAccountSecret = (value) => typeof value === "object" && value !== null && "agePrivateKey" in value && typeof value.agePrivateKey === "string" && "ed25519PrivateKey" in value && typeof value.ed25519PrivateKey === "string";
19769
+ /** Generate a fresh account keypair: an age X25519 key plus an Ed25519 signing key. */
19770
+ const generateAccountKey = async () => {
19771
+ const agePrivateKey = await generateIdentity$1();
19772
+ const agePublicKey = await identityToRecipient(agePrivateKey);
19773
+ const ed = ed25519.keygen();
19774
+ return {
19775
+ agePrivateKey,
19776
+ agePublicKey,
19777
+ ed25519PrivateKey: toBase64(ed.secretKey),
19778
+ ed25519PublicKey: toBase64(ed.publicKey),
19779
+ fingerprint: fingerprint(agePublicKey)
19780
+ };
19781
+ };
19782
+ const escrowAad = (header) => encodeAad("better-update/account-key", [
19783
+ header.agePublicKey,
19784
+ header.ed25519PublicKey,
19785
+ header.fingerprint,
19786
+ header.kdfParams.time,
19787
+ header.kdfParams.memory,
19788
+ header.kdfParams.parallelism
19789
+ ]);
19790
+ /** Seal an account keypair into its escrow envelope with a passphrase. */
19791
+ const sealAccountKey = (args) => {
19792
+ const kdfParams = args.kdfParams ?? ACCOUNT_ARGON2_PARAMS;
19793
+ const salt = randomBytes$5(16);
19794
+ const kek = deriveKek(args.passphrase, salt, kdfParams);
19795
+ const header = {
19796
+ agePublicKey: args.material.agePublicKey,
19797
+ ed25519PublicKey: args.material.ed25519PublicKey,
19798
+ fingerprint: args.material.fingerprint,
19799
+ kdfParams
19800
+ };
19801
+ const secret = {
19802
+ agePrivateKey: args.material.agePrivateKey,
19803
+ ed25519PrivateKey: args.material.ed25519PrivateKey
19804
+ };
19805
+ const ct = aeadEncrypt(kek, textEncoder.encode(JSON.stringify(secret)), escrowAad(header));
19806
+ return {
19807
+ version: 1,
19808
+ agePublicKey: header.agePublicKey,
19809
+ ed25519PublicKey: header.ed25519PublicKey,
19810
+ fingerprint: header.fingerprint,
19811
+ kdf: "argon2id",
19812
+ kdfParams,
19813
+ salt: toBase64(salt),
19814
+ cipher: "xchacha20poly1305",
19815
+ ct: toBase64(ct)
19816
+ };
19817
+ };
19818
+ /**
19819
+ * Open an account escrow envelope. Throws (propagated AEAD failure) on a wrong
19820
+ * passphrase or a tampered envelope — the seal binds both public keys, the
19821
+ * fingerprint, and the KDF params as AAD. The returned public halves are
19822
+ * **re-derived from the decrypted private keys**, so they always match the keys
19823
+ * they unlock (mirrors `openIdentity`).
19824
+ */
19825
+ const openAccountKey = async (args) => {
19826
+ const { envelope } = args;
19827
+ const plaintext = aeadDecrypt(deriveKek(args.passphrase, fromBase64(envelope.salt), envelope.kdfParams), fromBase64(envelope.ct), escrowAad(envelope));
19828
+ const parsed = JSON.parse(textDecoder.decode(plaintext));
19829
+ if (!isSealedAccountSecret(parsed)) throw new Error("Account escrow payload has an unexpected shape.");
19830
+ const secret = parsed;
19831
+ const agePublicKey = await identityToRecipient(secret.agePrivateKey);
19832
+ const ed25519PublicKey = toBase64(ed25519.getPublicKey(fromBase64(secret.ed25519PrivateKey)));
19833
+ return {
19834
+ agePrivateKey: secret.agePrivateKey,
19835
+ agePublicKey,
19836
+ ed25519PrivateKey: secret.ed25519PrivateKey,
19837
+ ed25519PublicKey,
19838
+ fingerprint: fingerprint(agePublicKey)
19839
+ };
19250
19840
  };
19251
19841
 
19252
19842
  //#endregion
@@ -19423,7 +20013,7 @@ const unlockVaultKeyInteractive = (api, options) => Effect.gen(function* () {
19423
20013
  const cached = yield* cache.get(recipient.publicKey);
19424
20014
  if (cached !== void 0) return cached.vault;
19425
20015
  const vault = yield* unlockVaultKey(api, yield* promptPassword("Passphrase to unlock this device's identity:"));
19426
- yield* cache.set(recipient.publicKey, vault, options?.cacheTtlMs);
20016
+ yield* cache.set(recipient.publicKey, vault, { ttlMs: options?.cacheTtlMs });
19427
20017
  return vault;
19428
20018
  }).pipe(Effect.provide(VaultCacheLive));
19429
20019
  /**
@@ -19484,7 +20074,8 @@ const getActiveOrgId = (api) => Effect.gen(function* () {
19484
20074
  const openVaultSessionInteractive = (api) => Effect.gen(function* () {
19485
20075
  return {
19486
20076
  orgId: yield* getActiveOrgId(api),
19487
- vault: yield* unlockVaultKeyInteractive(api)
20077
+ vault: yield* unlockVaultKeyInteractive(api),
20078
+ vaultKind: "credentials"
19488
20079
  };
19489
20080
  });
19490
20081
  /** Reshape a sealed envelope into the `{ id, …opaque fields }` an upload body carries. */
@@ -19500,7 +20091,7 @@ const toUploadEnvelope = (envelope) => ({
19500
20091
  * `(org, credentialId, type, schemaVersion)` so the server cannot mix envelopes.
19501
20092
  */
19502
20093
  const sealForUpload = (args) => Effect.gen(function* () {
19503
- const { orgId, vault } = args.session;
20094
+ const { orgId, vault, vaultKind } = args.session;
19504
20095
  const credentialId = crypto.randomUUID();
19505
20096
  const dek = generateDek();
19506
20097
  const payload = {
@@ -19523,7 +20114,8 @@ const sealForUpload = (args) => Effect.gen(function* () {
19523
20114
  binding: {
19524
20115
  orgId,
19525
20116
  credentialId,
19526
- vaultVersion: vault.vaultVersion
20117
+ vaultVersion: vault.vaultVersion,
20118
+ vaultKind
19527
20119
  }
19528
20120
  })
19529
20121
  }),
@@ -19542,7 +20134,7 @@ const sealForUpload = (args) => Effect.gen(function* () {
19542
20134
  * also serves the build-resolve flow whose result carries the id out-of-band.
19543
20135
  */
19544
20136
  const openEnvelope = (args) => Effect.gen(function* () {
19545
- const { orgId, vault } = args.session;
20137
+ const { orgId, vault, vaultKind } = args.session;
19546
20138
  const dek = yield* Effect.try({
19547
20139
  try: () => unwrapDek({
19548
20140
  wrappedDek: fromBase64(args.envelope.wrappedDek),
@@ -19550,7 +20142,8 @@ const openEnvelope = (args) => Effect.gen(function* () {
19550
20142
  binding: {
19551
20143
  orgId,
19552
20144
  credentialId: args.credentialId,
19553
- vaultVersion: args.envelope.vaultVersion
20145
+ vaultVersion: args.envelope.vaultVersion,
20146
+ vaultKind
19554
20147
  }
19555
20148
  }),
19556
20149
  catch: () => new IdentityError({ message: "Could not unwrap this credential's key — vault access may have been rotated or revoked." })
@@ -19602,6 +20195,84 @@ const openFromDownload = (args) => {
19602
20195
  });
19603
20196
  };
19604
20197
 
20198
+ //#endregion
20199
+ //#region src/application/env-vault-access.ts
20200
+ /**
20201
+ * Guidance when this device holds no env-vault wrap. Post-cutover the env vault is
20202
+ * a SEPARATE key from the credentials vault, so even a device that can read
20203
+ * credentials may not be an env recipient yet — it self-links by enrolling an
20204
+ * account key, or an admin re-runs the env-vault migration / rotation to include
20205
+ * it.
20206
+ */
20207
+ 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.";
20208
+ /**
20209
+ * Unlock the ENV-vault key for this device via its env wrap (post-cutover). The
20210
+ * env vault holds a DIFFERENT key from the credentials vault — wrapped to the same
20211
+ * device/recovery/machine recipients PLUS per-user account keys — so this mirrors
20212
+ * {@link unlockVaultKey} but reads the polymorphic `org_env_vault_key_wraps` row
20213
+ * keyed by this device's `(recipientKind, keyId)`.
20214
+ */
20215
+ const unlockEnvVaultKey = (api, passphrase) => Effect.gen(function* () {
20216
+ const recipient = yield* activeRecipient;
20217
+ const privateKey = yield* unlockActivePrivateKey(passphrase);
20218
+ const { items } = yield* api.userEncryptionKeys.list();
20219
+ const own = items.find((key) => key.publicKey === recipient.publicKey);
20220
+ if (!own) return yield* new IdentityError({ message: "This device's encryption key is not registered. Run `better-update credentials identity register` first." });
20221
+ const wrap = yield* api.envVault.getWrap({ path: {
20222
+ recipientKind: recipientKind(recipient.source),
20223
+ recipientId: own.id
20224
+ } }).pipe(Effect.catchTag("NotFound", () => new IdentityError({ message: ENV_VAULT_NOT_RECIPIENT_GUIDANCE })));
20225
+ return {
20226
+ vaultKey: yield* Effect.tryPromise({
20227
+ try: async () => unwrapVaultKey({
20228
+ wrapped: fromBase64(wrap.wrappedKey),
20229
+ privateKey
20230
+ }),
20231
+ 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." })
20232
+ }),
20233
+ vaultVersion: wrap.envVaultVersion,
20234
+ keyId: own.id
20235
+ };
20236
+ });
20237
+ /**
20238
+ * Cache-aware env-vault unlock, mirroring {@link unlockVaultKeyInteractive} but on
20239
+ * the `"env"` cache namespace so the credentials and env vaults cache (and lock)
20240
+ * independently. CI's `BETTER_UPDATE_IDENTITY` key is never cached.
20241
+ */
20242
+ const unlockEnvVaultKeyInteractive = (api) => Effect.gen(function* () {
20243
+ const recipient = yield* activeRecipient;
20244
+ if (recipient.source !== "file") return yield* unlockEnvVaultKey(api, void 0);
20245
+ const cache = yield* VaultCache;
20246
+ const cached = yield* cache.get(recipient.publicKey, "env");
20247
+ if (cached !== void 0) return cached.vault;
20248
+ const vault = yield* unlockEnvVaultKey(api, yield* promptPassword("Passphrase to unlock this device's identity:"));
20249
+ yield* cache.set(recipient.publicKey, vault, { vaultKind: "env" });
20250
+ return vault;
20251
+ }).pipe(Effect.provide(VaultCacheLive));
20252
+ /** Forget this device's cached env-vault key — called after an env rotation re-keys it. */
20253
+ const forgetCachedEnvVaultKey = Effect.gen(function* () {
20254
+ const recipient = yield* activeRecipient;
20255
+ yield* (yield* VaultCache).clear(recipient.publicKey, "env");
20256
+ }).pipe(Effect.provide(VaultCacheLive));
20257
+ /**
20258
+ * Resolve the vault session env VALUES are sealed under, branched on the org's
20259
+ * cutover state. Pre-cutover (or no vault yet) env lives in the CREDENTIALS vault —
20260
+ * `openVaultSessionInteractive` returns a `"credentials"`-kind session, byte-for-
20261
+ * byte the pre-split behaviour. Once the org has cut over, env lives in its own
20262
+ * vault: unlock the env key and return an `"env"`-kind session so seal/open bind
20263
+ * the DEK to the env vault. Every env command (`set/get/pull/push/export/import`)
20264
+ * goes through this, so the cutover is transparent to them.
20265
+ */
20266
+ const openEnvVaultSessionInteractive = (api) => Effect.gen(function* () {
20267
+ const vault = yield* api.orgVault.get().pipe(Effect.catchTag("NotFound", () => Effect.succeed(null)));
20268
+ if ((vault === null ? null : vault.envVaultCutoverAt) === null) return yield* openVaultSessionInteractive(api);
20269
+ return {
20270
+ orgId: yield* getActiveOrgId(api),
20271
+ vault: yield* unlockEnvVaultKeyInteractive(api),
20272
+ vaultKind: "env"
20273
+ };
20274
+ });
20275
+
19605
20276
  //#endregion
19606
20277
  //#region src/lib/credential-secret.ts
19607
20278
  /**
@@ -19643,7 +20314,7 @@ const exportDecryptedEnvVars = (api, projectId, environment) => Effect.gen(funct
19643
20314
  environment
19644
20315
  } }).pipe(Effect.mapError((cause) => new EnvExportError({ message: `Failed to export environment variables for "${environment}": ${String(cause)}` })));
19645
20316
  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);
20317
+ 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
20318
  });
19648
20319
  /**
19649
20320
  * Pull + decrypt environment variables flattened into a key/value map for
@@ -19662,7 +20333,7 @@ const pullEnvVars = (api, { projectId, environment }) => Effect.gen(function* ()
19662
20333
  * vault once; the server stores only the sealed envelopes (never the plaintext).
19663
20334
  */
19664
20335
  const uploadEnvVars = (api, params) => Effect.gen(function* () {
19665
- const session = yield* openVaultSessionInteractive(api);
20336
+ const session = yield* openEnvVaultSessionInteractive(api);
19666
20337
  const pairs = params.environments.flatMap((environment) => params.entries.map((entry) => ({
19667
20338
  ...entry,
19668
20339
  environment
@@ -19683,7 +20354,8 @@ const uploadEnvVars = (api, params) => Effect.gen(function* () {
19683
20354
  id: envelope.id,
19684
20355
  ciphertext: envelope.ciphertext,
19685
20356
  wrappedDek: envelope.wrappedDek,
19686
- vaultVersion: envelope.vaultVersion
20357
+ vaultVersion: envelope.vaultVersion,
20358
+ vaultKind: session.vaultKind
19687
20359
  }
19688
20360
  })), { concurrency: 8 });
19689
20361
  return yield* api["env-vars"].bulkImport({ payload: {
@@ -21850,6 +22522,17 @@ const parseCert = (certDerBytes) => {
21850
22522
  return forge.pki.certificateFromAsn1(asn1);
21851
22523
  };
21852
22524
  const generatePassword = () => forge.util.encode64(forge.random.getBytesSync(16));
22525
+ /**
22526
+ * Normalize an Apple certificate serial number for comparison.
22527
+ *
22528
+ * The serial read from a certificate's X.509 DER (via node-forge) keeps any
22529
+ * leading zero byte (e.g. `02C4E489…`), whereas the App Store Connect API
22530
+ * reports the same serial with leading zeros stripped (e.g. `2C4E489…`).
22531
+ * Comparing the two representations verbatim makes a present certificate look
22532
+ * absent ("not present on Apple Developer Portal"), so normalize both sides:
22533
+ * uppercase and drop leading zeros.
22534
+ */
22535
+ const normalizeAppleSerial = (serial) => serial.toUpperCase().replace(/^0+/u, "");
21853
22536
  const extractCertMetadata = (cert) => Effect.gen(function* () {
21854
22537
  const appleTeamId = extractTeamId$1(cert);
21855
22538
  if (appleTeamId === null) return yield* new CertParseError({ message: "Could not extract Apple team identifier from certificate subject" });
@@ -22089,9 +22772,9 @@ const revokeLocalDistributionCertificate = (api, input) => Effect.gen(function*
22089
22772
  issuerId: creds.issuerId,
22090
22773
  p8Pem: creds.p8Pem
22091
22774
  };
22092
- const targetSerial = local.serialNumber.toUpperCase();
22775
+ const targetSerial = normalizeAppleSerial(local.serialNumber);
22093
22776
  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);
22777
+ const ascMatch = [...matching[0], ...matching[1]].find((entry) => normalizeAppleSerial(entry.serialNumber) === targetSerial);
22095
22778
  let revokedOnApple = false;
22096
22779
  if (ascMatch !== void 0) {
22097
22780
  yield* deleteCertificate(ascCreds, ascMatch.id).pipe(Effect.mapError(wrapAscError("apple-revoke-certificate")));
@@ -22118,7 +22801,9 @@ const listAppleCertificates = (api, input) => Effect.gen(function* () {
22118
22801
  }, compact({ certificateType: input.certificateType })).pipe(Effect.mapError(wrapAscError("apple-list-certificates")));
22119
22802
  });
22120
22803
  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);
22804
+ const certs = yield* listCertificates(creds, { certificateType }).pipe(Effect.mapError(wrapAscError("apple-list-certificates")));
22805
+ const target = normalizeAppleSerial(serialNumber);
22806
+ const match = certs.find((entry) => normalizeAppleSerial(entry.serialNumber) === target);
22122
22807
  if (match === void 0) return yield* new GenerateFailedError({
22123
22808
  step: "match-apple-certificate",
22124
22809
  message: `Distribution certificate ${serialNumber} not present on Apple Developer Portal; upload or re-generate it`
@@ -22682,6 +23367,7 @@ const parsePlist = (data) => data.subarray(0, 8).equals(BPLIST_MAGIC) ? parsePli
22682
23367
 
22683
23368
  //#endregion
22684
23369
  //#region src/lib/update-channel-native.ts
23370
+ const { AndroidConfig } = configPlugins;
22685
23371
  /**
22686
23372
  * EAS parity: after `expo prebuild`, bake the build profile's `channel` into
22687
23373
  * the generated native projects as the `expo-channel-name` request header —
@@ -23093,8 +23779,8 @@ const findOrCreateBundleId = (ctx, bundleIdentifier) => Effect.gen(function* ()
23093
23779
  });
23094
23780
  const findAscCertificateId = (ctx, serialNumber, certificateType) => Effect.gen(function* () {
23095
23781
  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);
23782
+ const target = normalizeAppleSerial(serialNumber);
23783
+ const match = certs.find((entry) => normalizeAppleSerial(entry.attributes.serialNumber) === target);
23098
23784
  if (match === void 0) return yield* new AppleIdGenerateFailedError({
23099
23785
  step: "match-apple-certificate",
23100
23786
  message: `Distribution certificate ${serialNumber} not present on Apple Developer Portal; upload or re-generate it`
@@ -24533,6 +25219,7 @@ const resolveIosStrategy = (profile, projectType) => {
24533
25219
 
24534
25220
  //#endregion
24535
25221
  //#region src/lib/gradle-config.ts
25222
+ const isValidAndroidPackageName = Schema.is(AndroidPackageName);
24536
25223
  /**
24537
25224
  * Parse Groovy `build.gradle` to extract key Android config values.
24538
25225
  * Returns `undefined` if:
@@ -24579,12 +25266,27 @@ const parseVersionCode = (raw) => {
24579
25266
  const extractGradleConfig = (parsed) => {
24580
25267
  const defaultConfig = asRecord(asRecord(parsed["android"])?.["defaultConfig"]);
24581
25268
  return compact({
24582
- applicationId: typeof defaultConfig?.["applicationId"] === "string" ? unquote(defaultConfig["applicationId"]) : void 0,
25269
+ applicationId: extractApplicationId(defaultConfig?.["applicationId"]),
24583
25270
  versionCode: parseVersionCode(defaultConfig?.["versionCode"]),
24584
25271
  versionName: typeof defaultConfig?.["versionName"] === "string" ? unquote(defaultConfig["versionName"]) : void 0
24585
25272
  });
24586
25273
  };
24587
25274
  const unquote = (input) => input.startsWith("\"") && input.endsWith("\"") ? input.slice(1, -1) : input;
25275
+ /**
25276
+ * Extract a usable Android `applicationId` from the parsed Gradle value.
25277
+ *
25278
+ * react-native-config and similar setups make the id dynamic and env-driven
25279
+ * (e.g. `applicationId project.env.get("APP_ID")`, or a `def` variable). The
25280
+ * Groovy parser surfaces the raw expression text (`project.env.get("APP_ID")`,
25281
+ * `appId`, …), which is not a real package name. Treat anything that isn't a
25282
+ * valid reverse-domain package as unresolved so callers fall back to the
25283
+ * Expo/eas config value instead of building with — or validating — junk.
25284
+ */
25285
+ const extractApplicationId = (raw) => {
25286
+ if (typeof raw !== "string") return;
25287
+ const value = unquote(raw);
25288
+ return isValidAndroidPackageName(value) ? value : void 0;
25289
+ };
24588
25290
 
24589
25291
  //#endregion
24590
25292
  //#region src/application/platform-build.ts
@@ -26156,7 +26858,7 @@ const resolveNamedResourceId$1 = (params) => resolveNamedResourceId$2(params, (m
26156
26858
 
26157
26859
  //#endregion
26158
26860
  //#region src/commands/channels/create.ts
26159
- const createCommand$4 = defineCommand({
26861
+ const createCommand$5 = defineCommand({
26160
26862
  meta: {
26161
26863
  name: "create",
26162
26864
  description: "Create a channel"
@@ -26374,7 +27076,7 @@ const completeCommand$1 = defineCommand({
26374
27076
 
26375
27077
  //#endregion
26376
27078
  //#region src/commands/channels/rollout/create.ts
26377
- const createCommand$3 = defineCommand({
27079
+ const createCommand$4 = defineCommand({
26378
27080
  meta: {
26379
27081
  name: "create",
26380
27082
  description: "Start a branch rollout on a channel"
@@ -26494,7 +27196,7 @@ const rolloutCommand$1 = defineCommand({
26494
27196
  description: "Manage channel branch rollouts"
26495
27197
  },
26496
27198
  subCommands: {
26497
- create: createCommand$3,
27199
+ create: createCommand$4,
26498
27200
  update: updateCommand$3,
26499
27201
  complete: completeCommand$1,
26500
27202
  revert: revertCommand$2
@@ -26608,7 +27310,7 @@ const channelsCommand = defineCommand({
26608
27310
  subCommands: {
26609
27311
  list: listCommand$7,
26610
27312
  view: viewCommand$2,
26611
- create: createCommand$4,
27313
+ create: createCommand$5,
26612
27314
  update: updateCommand$2,
26613
27315
  pause: pauseCommand,
26614
27316
  resume: resumeCommand,
@@ -28207,7 +28909,8 @@ const rotateVaultTo = (args) => Effect.gen(function* () {
28207
28909
  binding: {
28208
28910
  orgId,
28209
28911
  credentialId: dek.credentialId,
28210
- vaultVersion: dek.vaultVersion
28912
+ vaultVersion: dek.vaultVersion,
28913
+ vaultKind: "credentials"
28211
28914
  }
28212
28915
  });
28213
28916
  return {
@@ -28219,7 +28922,8 @@ const rotateVaultTo = (args) => Effect.gen(function* () {
28219
28922
  binding: {
28220
28923
  orgId,
28221
28924
  credentialId: dek.credentialId,
28222
- vaultVersion: newVersion
28925
+ vaultVersion: newVersion,
28926
+ vaultKind: "credentials"
28223
28927
  }
28224
28928
  }))
28225
28929
  };
@@ -28365,7 +29069,7 @@ const grantCommand = defineCommand({
28365
29069
  }), { json: "value" })
28366
29070
  });
28367
29071
  const confirmRecipients = (recipients, skip) => Effect.forEach(recipients, (recipient) => confirmFingerprint(recipient, skip), { discard: true });
28368
- const rotateCommand = defineCommand({
29072
+ const rotateCommand$1 = defineCommand({
28369
29073
  meta: {
28370
29074
  name: "rotate",
28371
29075
  description: "Rotate the vault key, re-wrapping every credential to the same recipients (admin)"
@@ -28525,7 +29229,7 @@ const accessCommand = defineCommand({
28525
29229
  subCommands: {
28526
29230
  list: listCommand$6,
28527
29231
  grant: grantCommand,
28528
- rotate: rotateCommand,
29232
+ rotate: rotateCommand$1,
28529
29233
  revoke: revokeCommand$1,
28530
29234
  recover: recoverCommand,
28531
29235
  recovery: recoveryCommand
@@ -28533,6 +29237,251 @@ const accessCommand = defineCommand({
28533
29237
  default: "list"
28534
29238
  });
28535
29239
 
29240
+ //#endregion
29241
+ //#region src/application/passphrase-change.ts
29242
+ /** Rebuild the {@link AccountKeyEnvelope} from the server escrow view (escrowCt → ct). */
29243
+ const escrowToEnvelope = (escrow) => ({
29244
+ version: escrow.version,
29245
+ agePublicKey: escrow.agePublicKey,
29246
+ ed25519PublicKey: escrow.ed25519PublicKey,
29247
+ fingerprint: escrow.fingerprint,
29248
+ kdf: escrow.kdf,
29249
+ kdfParams: escrow.kdfParams,
29250
+ salt: escrow.salt,
29251
+ cipher: escrow.cipher,
29252
+ ct: escrow.escrowCt
29253
+ });
29254
+ /**
29255
+ * Re-seal the caller's account-key escrow under `newPassphrase`. BEST-EFFORT and
29256
+ * total: it never fails the surrounding flow — it reports an {@link
29257
+ * AccountResealOutcome} so the caller can warn. This matters because the account
29258
+ * escrow is a single per-USER secret shared across every device, while device
29259
+ * identities are per-device: another device may already have moved the escrow to a
29260
+ * different passphrase, so opening it with THIS device's old passphrase can
29261
+ * legitimately fail (`passphrase-mismatch`) without blocking the device change.
29262
+ */
29263
+ const resealAccountKey = (api, oldPassphrase, newPassphrase) => Effect.gen(function* () {
29264
+ const escrowResult = yield* api.accountKeys.getMe().pipe(Effect.either);
29265
+ if (Either.isLeft(escrowResult)) return escrowResult.left._tag === "NotFound" ? "absent" : "error";
29266
+ const materialResult = yield* Effect.tryPromise(async () => openAccountKey({
29267
+ envelope: escrowToEnvelope(escrowResult.right),
29268
+ passphrase: oldPassphrase
29269
+ })).pipe(Effect.either);
29270
+ if (Either.isLeft(materialResult)) return "passphrase-mismatch";
29271
+ const next = sealAccountKey({
29272
+ material: materialResult.right,
29273
+ passphrase: newPassphrase
29274
+ });
29275
+ const resealResult = yield* api.accountKeys.reseal({ payload: {
29276
+ kdfParams: next.kdfParams,
29277
+ salt: next.salt,
29278
+ escrowCt: next.ct
29279
+ } }).pipe(Effect.either);
29280
+ return Either.isLeft(resealResult) ? "error" : "resealed";
29281
+ });
29282
+ /**
29283
+ * Change the device passphrase. The local device identity is the PRIMARY, and is
29284
+ * saved FIRST: it is purely local and authoritative for this device, so on success
29285
+ * the device is on the new passphrase regardless of what happens to the shared
29286
+ * account escrow. Ordering is deliberate — if the local save fails, nothing was
29287
+ * mutated (the server escrow is untouched); the account escrow re-seal then runs
29288
+ * best-effort and its {@link AccountResealOutcome} is returned for the caller to
29289
+ * surface, so a network/passphrase issue degrades to a warning, never a hard block
29290
+ * or a silent split. The vault keys are unchanged, so cached unlocks and every
29291
+ * wrap stay valid.
29292
+ */
29293
+ const changePassphrase = (api, params) => Effect.gen(function* () {
29294
+ const store = yield* IdentityStore;
29295
+ const file = yield* loadIdentityFileOrFail;
29296
+ const identity = yield* Effect.tryPromise({
29297
+ try: async () => openIdentity({
29298
+ file,
29299
+ passphrase: params.oldPassphrase
29300
+ }),
29301
+ catch: () => new IdentityError({ message: "Wrong current passphrase — could not unlock this device's identity." })
29302
+ });
29303
+ const nextFile = yield* Effect.promise(async () => sealIdentity({
29304
+ privateKey: identity.privateKey,
29305
+ passphrase: params.newPassphrase
29306
+ }));
29307
+ yield* store.save(nextFile);
29308
+ return { account: yield* resealAccountKey(api, params.oldPassphrase, params.newPassphrase) };
29309
+ });
29310
+
29311
+ //#endregion
29312
+ //#region src/commands/credentials/account.ts
29313
+ /** `true` once the org has cut over to its separate env vault. */
29314
+ const orgHasCutOver = (api) => api.orgVault.get().pipe(Effect.map((vault) => vault.envVaultCutoverAt !== null), Effect.catchTag("NotFound", () => Effect.succeed(false)));
29315
+ /** The caller's live account key (public escrow view), or `null` if not enrolled. */
29316
+ const findOwnAccountKey = (api) => api.accountKeys.getMe().pipe(Effect.catchTag("NotFound", () => Effect.succeed(null)));
29317
+ /**
29318
+ * Wrap the env-vault key to the caller's OWN account key (self-link). Unlocks the
29319
+ * env vault via this device's env wrap, so it works for any member whose device is
29320
+ * already an env recipient — the account key inherits env access without an admin.
29321
+ * Requires the org to have cut over (there is no env vault before that).
29322
+ */
29323
+ const linkAccountKeyToEnv = (api, params) => Effect.gen(function* () {
29324
+ const ev = yield* unlockEnvVaultKeyInteractive(api);
29325
+ const wrapped = yield* Effect.promise(async () => wrapVaultKey({
29326
+ vaultKey: ev.vaultKey,
29327
+ recipient: params.agePublicKey
29328
+ }));
29329
+ yield* api.envVault.addWrap({ payload: {
29330
+ envVaultVersion: ev.vaultVersion,
29331
+ wrap: {
29332
+ recipientKind: "account",
29333
+ recipientId: params.accountKeyId,
29334
+ wrappedKey: toBase64(wrapped)
29335
+ }
29336
+ } });
29337
+ });
29338
+ /**
29339
+ * Prompt for — and verify — the device passphrase, so the account escrow is sealed
29340
+ * under the SAME passphrase as the device identity (the "one passphrase" promise:
29341
+ * a later `passphrase change` re-seals both). Verifying via `openIdentity` also
29342
+ * stops a typo from minting an escrow no one can open.
29343
+ */
29344
+ const promptVerifiedDevicePassphrase = Effect.gen(function* () {
29345
+ const file = yield* loadIdentityFileOrFail;
29346
+ const passphrase = yield* promptPassword("Passphrase for this device's identity (the account key uses the same one):");
29347
+ yield* Effect.tryPromise({
29348
+ try: async () => openIdentity({
29349
+ file,
29350
+ passphrase
29351
+ }),
29352
+ catch: () => new IdentityError({ message: "Wrong passphrase — could not unlock this device's identity." })
29353
+ });
29354
+ return passphrase;
29355
+ });
29356
+ const createCommand$3 = defineCommand({
29357
+ meta: {
29358
+ name: "create",
29359
+ description: "Enroll this user's account key — the env-vault recipient that unlocks env values from the browser"
29360
+ },
29361
+ run: async () => runEffect(Effect.gen(function* () {
29362
+ const api = yield* apiClient;
29363
+ 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." });
29364
+ const passphrase = yield* promptVerifiedDevicePassphrase;
29365
+ const envelope = sealAccountKey({
29366
+ material: yield* Effect.promise(async () => generateAccountKey()),
29367
+ passphrase
29368
+ });
29369
+ const registered = yield* api.accountKeys.register({ payload: {
29370
+ agePublicKey: envelope.agePublicKey,
29371
+ ed25519PublicKey: envelope.ed25519PublicKey,
29372
+ fingerprint: envelope.fingerprint,
29373
+ kdfParams: envelope.kdfParams,
29374
+ salt: envelope.salt,
29375
+ escrowCt: envelope.ct
29376
+ } });
29377
+ const cutOver = yield* orgHasCutOver(api);
29378
+ if (cutOver) yield* linkAccountKeyToEnv(api, {
29379
+ accountKeyId: registered.id,
29380
+ agePublicKey: registered.agePublicKey
29381
+ });
29382
+ yield* printKeyValue([["Account key fingerprint", registered.fingerprint], ["Env access", cutOver ? "granted (self-linked)" : "pending env-vault migration"]]);
29383
+ 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 an admin runs `better-update credentials env-vault migrate`.");
29384
+ return {
29385
+ fingerprint: registered.fingerprint,
29386
+ envAccess: cutOver
29387
+ };
29388
+ }), { json: "value" })
29389
+ });
29390
+ const linkCommand$1 = defineCommand({
29391
+ meta: {
29392
+ name: "link",
29393
+ description: "Grant your already-enrolled account key access to the env vault (after a rotation)"
29394
+ },
29395
+ run: async () => runEffect(Effect.gen(function* () {
29396
+ const api = yield* apiClient;
29397
+ const own = yield* findOwnAccountKey(api);
29398
+ if (own === null) return yield* new IdentityError({ message: "No account key enrolled. Run `better-update credentials account create` first." });
29399
+ if (!(yield* orgHasCutOver(api))) return yield* new IdentityError({ message: "This organization has no env vault yet. An admin must run `better-update credentials env-vault migrate` first." });
29400
+ yield* linkAccountKeyToEnv(api, {
29401
+ accountKeyId: own.id,
29402
+ agePublicKey: own.agePublicKey
29403
+ });
29404
+ yield* printHuman(`Linked account key ${own.fingerprint} to the env vault.`);
29405
+ return {
29406
+ linked: true,
29407
+ fingerprint: own.fingerprint
29408
+ };
29409
+ }), { json: "value" })
29410
+ });
29411
+ const promptNewAccountPassphrase = Effect.gen(function* () {
29412
+ const first = yield* promptPassword("New passphrase (use this device's passphrase to keep them in sync):");
29413
+ if (first.length === 0) return yield* new IdentityError({ message: "Passphrase must not be empty." });
29414
+ if (first !== (yield* promptPassword("Confirm new passphrase:"))) return yield* new IdentityError({ message: "Passphrases did not match." });
29415
+ return first;
29416
+ });
29417
+ const resealCommand = defineCommand({
29418
+ meta: {
29419
+ name: "reseal",
29420
+ 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"
29421
+ },
29422
+ run: async () => runEffect(Effect.gen(function* () {
29423
+ const api = yield* apiClient;
29424
+ const own = yield* findOwnAccountKey(api);
29425
+ if (own === null) return yield* new IdentityError({ message: "No account key enrolled. Run `better-update credentials account create` first." });
29426
+ const current = yield* promptPassword("Current account-key passphrase:");
29427
+ const sealed = sealAccountKey({
29428
+ material: yield* Effect.tryPromise({
29429
+ try: async () => openAccountKey({
29430
+ envelope: escrowToEnvelope(own),
29431
+ passphrase: current
29432
+ }),
29433
+ catch: () => new IdentityError({ message: "Wrong current account-key passphrase." })
29434
+ }),
29435
+ passphrase: yield* promptNewAccountPassphrase
29436
+ });
29437
+ yield* api.accountKeys.reseal({ payload: {
29438
+ kdfParams: sealed.kdfParams,
29439
+ salt: sealed.salt,
29440
+ escrowCt: sealed.ct
29441
+ } });
29442
+ yield* printHuman(`Re-sealed account key ${own.fingerprint} under the new passphrase.`);
29443
+ return {
29444
+ resealed: true,
29445
+ fingerprint: own.fingerprint
29446
+ };
29447
+ }), { json: "value" })
29448
+ });
29449
+ const showCommand$1 = defineCommand({
29450
+ meta: {
29451
+ name: "show",
29452
+ description: "Show this user's enrolled account key (fingerprint + status)"
29453
+ },
29454
+ run: async () => runEffect(Effect.gen(function* () {
29455
+ const own = yield* findOwnAccountKey(yield* apiClient);
29456
+ if (own === null) {
29457
+ yield* printHuman("No account key enrolled for this user. Run `better-update credentials account create` to enroll one.");
29458
+ return { enrolled: false };
29459
+ }
29460
+ yield* printKeyValue([
29461
+ ["Account key fingerprint", own.fingerprint],
29462
+ ["Age recipient (public)", own.agePublicKey],
29463
+ ["Enrolled at", own.createdAt]
29464
+ ]);
29465
+ return {
29466
+ enrolled: true,
29467
+ fingerprint: own.fingerprint
29468
+ };
29469
+ }), { json: "value" })
29470
+ });
29471
+ const accountCommand = defineCommand({
29472
+ meta: {
29473
+ name: "account",
29474
+ description: "Manage your per-user account key for browser-side env-vault access"
29475
+ },
29476
+ subCommands: {
29477
+ create: createCommand$3,
29478
+ link: linkCommand$1,
29479
+ reseal: resealCommand,
29480
+ show: showCommand$1
29481
+ },
29482
+ default: "show"
29483
+ });
29484
+
28536
29485
  //#endregion
28537
29486
  //#region src/commands/credentials/configure.ts
28538
29487
  /**
@@ -29231,6 +30180,268 @@ const downloadCommand = defineCommand({
29231
30180
  }), { json: "value" })
29232
30181
  });
29233
30182
 
30183
+ //#endregion
30184
+ //#region src/application/env-vault-rekey.ts
30185
+ /**
30186
+ * Re-wrap a single env DEK from one vault key to another, rebinding it to the
30187
+ * destination vault version + kind. Pure crypto — throws (propagated AEAD failure)
30188
+ * if the source key/binding is wrong. Shared by the cutover (credentials→env) and
30189
+ * the env rotation (env→env).
30190
+ */
30191
+ const rekeyEnvDek = (args) => {
30192
+ const raw = unwrapDek({
30193
+ wrappedDek: fromBase64(args.wrappedDek),
30194
+ vaultKey: args.from,
30195
+ binding: {
30196
+ orgId: args.orgId,
30197
+ credentialId: args.credentialId,
30198
+ vaultVersion: args.fromVersion,
30199
+ vaultKind: args.fromKind
30200
+ }
30201
+ });
30202
+ return {
30203
+ credentialId: args.credentialId,
30204
+ wrappedDek: toBase64(wrapDek({
30205
+ dek: raw,
30206
+ vaultKey: args.to,
30207
+ binding: {
30208
+ orgId: args.orgId,
30209
+ credentialId: args.credentialId,
30210
+ vaultVersion: args.toVersion,
30211
+ vaultKind: args.toKind
30212
+ }
30213
+ }))
30214
+ };
30215
+ };
30216
+ /** Seal the env key to each recipient, producing the wrap rows for a cutover / rotation. */
30217
+ const wrapEnvKeyToRecipients = (evKey, recipients) => Effect.forEach(recipients, (entry) => Effect.promise(async () => ({
30218
+ recipientKind: entry.recipientKind,
30219
+ recipientId: entry.recipientId,
30220
+ wrappedKey: toBase64(await wrapVaultKey({
30221
+ vaultKey: evKey,
30222
+ recipient: entry.recipient
30223
+ }))
30224
+ })), { concurrency: "unbounded" });
30225
+
30226
+ //#endregion
30227
+ //#region src/application/env-vault-cutover.ts
30228
+ /**
30229
+ * Every recipient the env key is wrapped to at cutover: the credentials vault's
30230
+ * device/recovery/machine recipients (so upgraded CLIs keep env access via their
30231
+ * device key) PLUS every member's account key (so the browser can unlock env). New
30232
+ * account keys enrolled later self-link via `credentials account create`.
30233
+ */
30234
+ const collectEnvRecipients = (api) => Effect.gen(function* () {
30235
+ const cvRecipients = yield* currentRecipients(api);
30236
+ const { items: accounts } = yield* api.accountKeys.list();
30237
+ return [...cvRecipients.map((key) => ({
30238
+ recipientKind: key.kind,
30239
+ recipientId: key.id,
30240
+ recipient: key.publicKey
30241
+ })), ...accounts.map((account) => ({
30242
+ recipientKind: "account",
30243
+ recipientId: account.id,
30244
+ recipient: account.agePublicKey
30245
+ }))];
30246
+ });
30247
+ /**
30248
+ * Re-key every env DEK from the credentials vault key to the new env key. Env
30249
+ * values live in the credentials vault until cutover, so the source set comes from
30250
+ * `orgVault.listCredentialDeks` filtered to env rows (`credentialType` ===
30251
+ * `envVarValue`); each is unwrapped under the credentials key and re-wrapped under
30252
+ * the env key at version 1.
30253
+ */
30254
+ const rekeyEnvDeksToEnvVault = (api, params) => Effect.gen(function* () {
30255
+ const { deks } = yield* api.orgVault.listCredentialDeks();
30256
+ return yield* Effect.forEach(deks.filter((dek) => dek.credentialType === "envVarValue"), (dek) => Effect.try({
30257
+ try: () => rekeyEnvDek({
30258
+ orgId: params.orgId,
30259
+ credentialId: dek.credentialId,
30260
+ wrappedDek: dek.wrappedDek,
30261
+ from: params.cvKey,
30262
+ fromVersion: dek.vaultVersion,
30263
+ fromKind: "credentials",
30264
+ to: params.evKey,
30265
+ toVersion: 1,
30266
+ toKind: "env"
30267
+ }),
30268
+ catch: () => new IdentityError({ message: "Failed to re-key an env value during the migration — re-unlock the vault and retry." })
30269
+ }), { concurrency: "unbounded" });
30270
+ });
30271
+ /**
30272
+ * One-shot cutover: fork the org's env values into a separate env vault. Generate
30273
+ * a fresh env key, wrap it to every recipient, re-key every env DEK from the
30274
+ * credentials key to the env key, and submit it all atomically. The server
30275
+ * compare-and-swaps on the cutover sentinel, so a re-run after a partial failure is
30276
+ * safe (a second cutover is rejected `Conflict`). The credentials vault — and
30277
+ * every signing credential — is untouched.
30278
+ */
30279
+ const cutoverEnvVault = (api) => Effect.gen(function* () {
30280
+ const orgId = yield* getActiveOrgId(api);
30281
+ if ((yield* api.orgVault.get().pipe(Effect.catchTag("NotFound", () => new IdentityError({ message: "This organization has no credential vault yet. Run `better-update credentials identity init` first." })))).envVaultCutoverAt !== null) return yield* new IdentityError({ message: "This organization's env vault is already migrated." });
30282
+ const current = yield* unlockVaultKeyInteractive(api);
30283
+ const evKey = generateVaultKey();
30284
+ const wraps = yield* wrapEnvKeyToRecipients(evKey, yield* collectEnvRecipients(api));
30285
+ const envDeks = yield* rekeyEnvDeksToEnvVault(api, {
30286
+ orgId,
30287
+ cvKey: current.vaultKey,
30288
+ evKey
30289
+ });
30290
+ return yield* api.envVault.cutover({ payload: {
30291
+ wraps,
30292
+ envDeks
30293
+ } });
30294
+ });
30295
+
30296
+ //#endregion
30297
+ //#region src/application/env-vault-rotation.ts
30298
+ /**
30299
+ * Resolve the env vault's CURRENT recipients to the age recipients the new key is
30300
+ * wrapped to. The recipient set comes from `envVault.listWraps` (a member removal
30301
+ * already dropped the departing user's wraps server-side), and each id is resolved
30302
+ * to its public key: device/recovery/machine via `userEncryptionKeys`, account via
30303
+ * `accountKeys`. An id that no longer resolves (revoked) is dropped — the server
30304
+ * still enforces that a recovery recipient remains.
30305
+ */
30306
+ const rewrapSurvivingRecipients = (api, evKey) => Effect.gen(function* () {
30307
+ const { recipients } = yield* api.envVault.listWraps();
30308
+ const [{ items: keys }, { items: accounts }] = yield* Effect.all([api.userEncryptionKeys.list(), api.accountKeys.list()]);
30309
+ const keyById = new Map(keys.map((key) => [key.id, key.publicKey]));
30310
+ const accountById = new Map(accounts.map((account) => [account.id, account.agePublicKey]));
30311
+ return yield* wrapEnvKeyToRecipients(evKey, recipients.flatMap((wrap) => {
30312
+ const recipient = wrap.recipientKind === "account" ? accountById.get(wrap.recipientId) : keyById.get(wrap.recipientId);
30313
+ return recipient === void 0 ? [] : [{
30314
+ recipientKind: wrap.recipientKind,
30315
+ recipientId: wrap.recipientId,
30316
+ recipient
30317
+ }];
30318
+ }));
30319
+ });
30320
+ /** Re-key every env DEK under the new env key, bumping it to the next env version. */
30321
+ const rekeyEnvDeksForRotation = (api, params) => Effect.gen(function* () {
30322
+ const { deks } = yield* api.envVault.listCredentialDeks();
30323
+ return yield* Effect.forEach(deks, (dek) => Effect.try({
30324
+ try: () => rekeyEnvDek({
30325
+ orgId: params.orgId,
30326
+ credentialId: dek.credentialId,
30327
+ wrappedDek: dek.wrappedDek,
30328
+ from: params.fromKey,
30329
+ fromVersion: dek.vaultVersion,
30330
+ fromKind: "env",
30331
+ to: params.toKey,
30332
+ toVersion: params.toVersion,
30333
+ toKind: "env"
30334
+ }),
30335
+ catch: () => new IdentityError({ message: "Failed to re-key an env value during rotation — re-unlock the env vault and retry." })
30336
+ }), { concurrency: "unbounded" });
30337
+ });
30338
+ /**
30339
+ * Rotate the env vault key: generate a new key at version+1, re-wrap it to the
30340
+ * current recipients, re-key every env DEK, and submit atomically (the server
30341
+ * compare-and-swaps on the env version). Clears the env rotation-pending flag a
30342
+ * member removal raised. Drops the now-stale cached env key afterwards.
30343
+ */
30344
+ const rotateEnvVault = (api) => Effect.gen(function* () {
30345
+ const orgId = yield* getActiveOrgId(api);
30346
+ 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 has no env vault yet — run `better-update credentials env-vault migrate` first." });
30347
+ const current = yield* unlockEnvVaultKeyInteractive(api);
30348
+ const toVersion = current.vaultVersion + 1;
30349
+ const newEvKey = generateVaultKey();
30350
+ const wraps = yield* rewrapSurvivingRecipients(api, newEvKey);
30351
+ const envDeks = yield* rekeyEnvDeksForRotation(api, {
30352
+ orgId,
30353
+ fromKey: current.vaultKey,
30354
+ toKey: newEvKey,
30355
+ toVersion
30356
+ });
30357
+ const rotated = yield* api.envVault.rotate({ payload: {
30358
+ fromVersion: current.vaultVersion,
30359
+ wraps,
30360
+ envDeks
30361
+ } });
30362
+ yield* forgetCachedEnvVaultKey;
30363
+ return rotated;
30364
+ });
30365
+
30366
+ //#endregion
30367
+ //#region src/commands/credentials/env-vault.ts
30368
+ const migrateCommand = defineCommand({
30369
+ meta: {
30370
+ name: "migrate",
30371
+ description: "Fork env values into a separate env vault so they can be unlocked from the browser (one-time, admin)"
30372
+ },
30373
+ args: { yes: {
30374
+ type: "boolean",
30375
+ description: "Skip the confirmation prompt"
30376
+ } },
30377
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
30378
+ const api = yield* apiClient;
30379
+ yield* printHuman("Migrating splits env values into their own end-to-end-encrypted vault, wrapped to every device and account key.");
30380
+ yield* printHuman("⚠ This is one-shot per organization. Until they upgrade, older CLIs can no longer READ env values (credentials are unaffected).");
30381
+ yield* printHuman("⚠ Avoid env-var writes (set/push/import/update) elsewhere while this runs — a value written mid-migration may need re-setting afterwards.");
30382
+ if (!(args.yes === true || (yield* promptConfirm("Migrate this organization's env values to a separate vault now?", { initialValue: false })))) {
30383
+ yield* printHuman("Aborted — no changes made.");
30384
+ return { migrated: false };
30385
+ }
30386
+ const vault = yield* cutoverEnvVault(api);
30387
+ yield* printHuman(`✓ Env vault created (version ${String(vault.envVaultVersion)}).`);
30388
+ yield* printHuman("Members enroll browser access with `better-update credentials account create`.");
30389
+ return {
30390
+ migrated: true,
30391
+ envVaultVersion: vault.envVaultVersion
30392
+ };
30393
+ }), { json: "value" })
30394
+ });
30395
+ const rotateCommand = defineCommand({
30396
+ meta: {
30397
+ name: "rotate",
30398
+ description: "Rotate the env-vault key, re-wrapping to the current recipients — clears a pending flag after a member is removed (admin)"
30399
+ },
30400
+ run: async () => runEffect(Effect.gen(function* () {
30401
+ const vault = yield* rotateEnvVault(yield* apiClient);
30402
+ yield* printHuman(`Rotated the env vault to version ${String(vault.envVaultVersion)}.`);
30403
+ return { envVaultVersion: vault.envVaultVersion };
30404
+ }), { json: "value" })
30405
+ });
30406
+ const statusCommand$2 = defineCommand({
30407
+ meta: {
30408
+ name: "status",
30409
+ description: "Show whether the org has cut over to a separate env vault, and its version/state"
30410
+ },
30411
+ run: async () => runEffect(Effect.gen(function* () {
30412
+ const vault = yield* (yield* apiClient).orgVault.get().pipe(Effect.catchTag("NotFound", () => Effect.succeed(null)));
30413
+ if (vault === null) {
30414
+ yield* printHuman("This organization has no credential vault yet.");
30415
+ return { vaultExists: false };
30416
+ }
30417
+ const cutOver = vault.envVaultCutoverAt !== null;
30418
+ yield* printKeyValue([
30419
+ ["Env vault", cutOver ? "separate (migrated)" : "shared with credentials vault"],
30420
+ ["Env vault version", cutOver ? String(vault.envVaultVersion) : "—"],
30421
+ ["Env rotation pending", vault.envRotationPending ? "yes" : "no"]
30422
+ ]);
30423
+ if (vault.envRotationPending) yield* printHuman("⚠ Env rotation pending — run `better-update credentials env-vault rotate` to re-key and restore env access.");
30424
+ return {
30425
+ vaultExists: true,
30426
+ cutOver,
30427
+ envVaultVersion: cutOver ? vault.envVaultVersion : null,
30428
+ envRotationPending: vault.envRotationPending
30429
+ };
30430
+ }), { json: "value" })
30431
+ });
30432
+ const envVaultCommand = defineCommand({
30433
+ meta: {
30434
+ name: "env-vault",
30435
+ description: "Manage the organization's separate env-vault (migrate, rotate, status)"
30436
+ },
30437
+ subCommands: {
30438
+ migrate: migrateCommand,
30439
+ rotate: rotateCommand,
30440
+ status: statusCommand$2
30441
+ },
30442
+ default: "status"
30443
+ });
30444
+
29234
30445
  //#endregion
29235
30446
  //#region src/lib/credentials-generator-merchant.ts
29236
30447
  /**
@@ -29787,7 +30998,7 @@ const resolveLabel = (flag) => Effect.gen(function* () {
29787
30998
  if (flag && flag.trim().length > 0) return flag.trim();
29788
30999
  return yield* promptText("Label for this device key", { defaultValue: yield* (yield* CliRuntime).userName });
29789
31000
  });
29790
- const promptNewPassphrase = Effect.gen(function* () {
31001
+ const promptNewPassphrase$1 = Effect.gen(function* () {
29791
31002
  const first = yield* promptPassword("Choose a passphrase to protect this device key:");
29792
31003
  if (first.length === 0) return yield* new IdentityError({ message: "Passphrase must not be empty." });
29793
31004
  if (first !== (yield* promptPassword("Confirm passphrase:"))) return yield* new IdentityError({ message: "Passphrases did not match." });
@@ -29809,7 +31020,7 @@ const createCommand$2 = defineCommand({
29809
31020
  } },
29810
31021
  run: async ({ args }) => runEffect(Effect.gen(function* () {
29811
31022
  const label = yield* resolveLabel(args.label);
29812
- const identity = yield* createLocalIdentity(yield* promptNewPassphrase);
31023
+ const identity = yield* createLocalIdentity(yield* promptNewPassphrase$1);
29813
31024
  const api = yield* apiClient;
29814
31025
  yield* printRecipient(yield* registerRecipient(api, {
29815
31026
  kind: "device",
@@ -29942,6 +31153,47 @@ const listCommand$4 = defineCommand({
29942
31153
  }))
29943
31154
  });
29944
31155
 
31156
+ //#endregion
31157
+ //#region src/commands/credentials/passphrase.ts
31158
+ /** Human result for each account-escrow outcome of a device passphrase change. */
31159
+ const ACCOUNT_OUTCOME_MESSAGE = {
31160
+ resealed: "Passphrase changed — this device's identity and your account key were both re-sealed.",
31161
+ absent: "Passphrase changed — this device's identity was re-sealed (no account key enrolled).",
31162
+ "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.",
31163
+ 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."
31164
+ };
31165
+ const promptNewPassphrase = Effect.gen(function* () {
31166
+ const first = yield* promptPassword("Choose a new passphrase:");
31167
+ if (first.length === 0) return yield* new IdentityError({ message: "Passphrase must not be empty." });
31168
+ if (first !== (yield* promptPassword("Confirm new passphrase:"))) return yield* new IdentityError({ message: "Passphrases did not match." });
31169
+ return first;
31170
+ });
31171
+ const changeCommand = defineCommand({
31172
+ meta: {
31173
+ name: "change",
31174
+ description: "Change this device's passphrase, re-sealing the device identity and (if enrolled) your account key under it"
31175
+ },
31176
+ run: async () => runEffect(Effect.gen(function* () {
31177
+ const { account } = yield* changePassphrase(yield* apiClient, {
31178
+ oldPassphrase: yield* promptPassword("Current passphrase:"),
31179
+ newPassphrase: yield* promptNewPassphrase
31180
+ });
31181
+ yield* printHuman(ACCOUNT_OUTCOME_MESSAGE[account]);
31182
+ return {
31183
+ changed: true,
31184
+ account
31185
+ };
31186
+ }), { json: "value" })
31187
+ });
31188
+ const passphraseCommand = defineCommand({
31189
+ meta: {
31190
+ name: "passphrase",
31191
+ description: "Manage this device's identity passphrase"
31192
+ },
31193
+ subCommands: { change: changeCommand },
31194
+ default: "change"
31195
+ });
31196
+
29945
31197
  //#endregion
29946
31198
  //#region src/commands/credentials/regenerate-profile.ts
29947
31199
  const REGENERATE_EXIT_EXTRAS = {
@@ -31307,6 +32559,9 @@ const credentialsCommand = defineCommand({
31307
32559
  manager: managerCommand,
31308
32560
  identity: identityCommand,
31309
32561
  access: accessCommand,
32562
+ account: accountCommand,
32563
+ "env-vault": envVaultCommand,
32564
+ passphrase: passphraseCommand,
31310
32565
  device: deviceCommand,
31311
32566
  unlock: unlockCommand,
31312
32567
  lock: lockCommand,
@@ -32598,8 +33853,9 @@ const updateCommand$1 = defineCommand({
32598
33853
  payload: compact({ visibility })
32599
33854
  });
32600
33855
  else {
33856
+ const session = yield* openEnvVaultSessionInteractive(api);
32601
33857
  const envelope = yield* sealForUpload({
32602
- session: yield* openVaultSessionInteractive(api),
33858
+ session,
32603
33859
  credentialType: "envVarValue",
32604
33860
  metadata: {
32605
33861
  key,
@@ -32614,7 +33870,8 @@ const updateCommand$1 = defineCommand({
32614
33870
  id: envelope.id,
32615
33871
  ciphertext: envelope.ciphertext,
32616
33872
  wrappedDek: envelope.wrappedDek,
32617
- vaultVersion: envelope.vaultVersion
33873
+ vaultVersion: envelope.vaultVersion,
33874
+ vaultKind: session.vaultKind
32618
33875
  },
32619
33876
  ...compact({ visibility })
32620
33877
  }