@seekrit/cli 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1056 -56
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -28,6 +28,12 @@ const ENTITLEMENT_KEYS = Object.keys({
28
28
  description: "Vault-style short-lived database and cloud credentials.",
29
29
  default: true
30
30
  },
31
+ "feature.rotation": {
32
+ kind: "feature",
33
+ label: "Secret rotation",
34
+ description: "Managed, scheduled rotation of stored credentials.",
35
+ default: true
36
+ },
31
37
  "feature.log_sink": {
32
38
  kind: "feature",
33
39
  label: "Audit log export (SIEM)",
@@ -106,6 +112,12 @@ const ENTITLEMENT_KEYS = Object.keys({
106
112
  description: "Maximum registered third-party sync destinations.",
107
113
  default: null
108
114
  },
115
+ "rotation.policies.max": {
116
+ kind: "limit",
117
+ label: "Rotation policies",
118
+ description: "Maximum secrets with managed rotation configured.",
119
+ default: null
120
+ },
109
121
  members: {
110
122
  kind: "metered",
111
123
  label: "Members",
@@ -754,7 +766,7 @@ const connectionSchema = z.object({
754
766
  database: z.string().min(1)
755
767
  });
756
768
  /** A `{{name}}`/`{{verifier}}`/`{{valid_until}}` templated SQL statement. */
757
- const statementSchema = z.string().min(1).max(4e3);
769
+ const statementSchema$1 = z.string().min(1).max(4e3);
758
770
  /** A bare SQL identifier (schema name) — no quotes/whitespace/semicolons. */
759
771
  const identifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,62}$/, "must be an identifier");
760
772
  const postgresTargetConfigSchema = z.object({
@@ -764,20 +776,20 @@ const postgresTargetConfigSchema = z.object({
764
776
  schema: identifierSchema.optional(),
765
777
  connection: connectionSchema,
766
778
  provisionerUrl: z.url().optional(),
767
- createStatements: z.array(statementSchema).max(16).optional(),
768
- revokeStatements: z.array(statementSchema).max(16).optional()
779
+ createStatements: z.array(statementSchema$1).max(16).optional(),
780
+ revokeStatements: z.array(statementSchema$1).max(16).optional()
769
781
  });
770
782
  /** A MySQL account host part (`'name'@'<host>'`) — no quotes/whitespace. */
771
- const mysqlHostSchema = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
783
+ const mysqlHostSchema$1 = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
772
784
  const mysqlTargetConfigSchema = z.object({
773
785
  provider: z.literal("mysql"),
774
786
  executor: executorModeSchema,
775
787
  accessLevel: mysqlAccessLevelSchema.optional(),
776
788
  connection: connectionSchema,
777
- userHost: mysqlHostSchema.optional(),
789
+ userHost: mysqlHostSchema$1.optional(),
778
790
  provisionerUrl: z.url().optional(),
779
- createStatements: z.array(statementSchema).max(16).optional(),
780
- revokeStatements: z.array(statementSchema).max(16).optional()
791
+ createStatements: z.array(statementSchema$1).max(16).optional(),
792
+ revokeStatements: z.array(statementSchema$1).max(16).optional()
781
793
  });
782
794
  const redisConnectionSchema = z.object({
783
795
  host: z.string().min(1),
@@ -791,8 +803,8 @@ const redisTargetConfigSchema = z.object({
791
803
  accessLevel: redisAccessLevelSchema.optional(),
792
804
  connection: redisConnectionSchema,
793
805
  provisionerUrl: z.url().optional(),
794
- createStatements: z.array(statementSchema).max(16).optional(),
795
- revokeStatements: z.array(statementSchema).max(16).optional()
806
+ createStatements: z.array(statementSchema$1).max(16).optional(),
807
+ revokeStatements: z.array(statementSchema$1).max(16).optional()
796
808
  });
797
809
  const sshTargetConfigSchema = z.object({
798
810
  provider: z.literal("ssh"),
@@ -1122,6 +1134,11 @@ const AUDIT_ACTIONS = [
1122
1134
  "secret.deleted",
1123
1135
  "secret.read",
1124
1136
  "secret.restored",
1137
+ "secret.rotation_configured",
1138
+ "secret.rotation_updated",
1139
+ "secret.rotation_disabled",
1140
+ "secret.rotated",
1141
+ "secret.rotation_failed",
1125
1142
  "token.created",
1126
1143
  "token.revoked",
1127
1144
  "token.deleted",
@@ -1147,6 +1164,9 @@ const AUDIT_ACTIONS = [
1147
1164
  "entitlement.override_cleared",
1148
1165
  "billing.checkout_started",
1149
1166
  "billing.portal_opened",
1167
+ "promo.created",
1168
+ "promo.updated",
1169
+ "promo.redeemed",
1150
1170
  "kms.key_created",
1151
1171
  "kms.key_granted",
1152
1172
  "kms.key_revoked",
@@ -1187,7 +1207,8 @@ const NOTIFICATION_TYPES = [
1187
1207
  "token_expiring",
1188
1208
  "lease_expired",
1189
1209
  "sync_failed",
1190
- "honey_token_tripped"
1210
+ "honey_token_tripped",
1211
+ "secret_rotation_failed"
1191
1212
  ];
1192
1213
  /** UI-facing copy + default for each notification type. */
1193
1214
  const NOTIFICATION_TYPE_META = {
@@ -1240,6 +1261,11 @@ const NOTIFICATION_TYPE_META = {
1240
1261
  label: "Honey token tripped",
1241
1262
  description: "Someone tried to use one of your organization's decoy credentials (throttled per token). Nothing legitimate holds one, so every trip is worth reading.",
1242
1263
  defaultEnabled: true
1264
+ },
1265
+ secret_rotation_failed: {
1266
+ label: "Secret rotation failed",
1267
+ description: "A scheduled secret rotation could not complete — the stored value may be out of step with the system it authenticates to.",
1268
+ defaultEnabled: true
1243
1269
  }
1244
1270
  };
1245
1271
  //#endregion
@@ -1467,6 +1493,25 @@ z.object({
1467
1493
  note: z.string().max(500).nullish(),
1468
1494
  expiresAt: z.iso.datetime().nullish()
1469
1495
  });
1496
+ z.object({
1497
+ code: z.string().min(4).max(40),
1498
+ family: planFamilySchema,
1499
+ version: z.number().int().positive().optional(),
1500
+ durationDays: z.number().int().positive().max(3650).nullish(),
1501
+ maxRedemptions: z.number().int().positive().nullish(),
1502
+ startsAt: z.iso.datetime().nullish(),
1503
+ endsAt: z.iso.datetime().nullish(),
1504
+ note: z.string().max(500).nullish()
1505
+ });
1506
+ z.object({
1507
+ maxRedemptions: z.number().int().positive().nullish(),
1508
+ startsAt: z.iso.datetime().nullish(),
1509
+ endsAt: z.iso.datetime().nullish(),
1510
+ note: z.string().max(500).nullish(),
1511
+ /** Kill switch. Disabling stops new redemptions; live grants are untouched. */
1512
+ disabled: z.boolean().optional()
1513
+ });
1514
+ z.object({ code: z.string().min(1).max(40) });
1470
1515
  z.object({ family: planFamilySchema });
1471
1516
  z.object({
1472
1517
  sessionId: z.string().regex(/^skc_[0-9A-Za-z]+$/),
@@ -1484,6 +1529,99 @@ z.object({
1484
1529
  action: z.string().optional(),
1485
1530
  resourceType: z.string().optional()
1486
1531
  });
1532
+ z.enum([
1533
+ "generated",
1534
+ "postgres",
1535
+ "mysql",
1536
+ "redis"
1537
+ ]);
1538
+ z.enum([
1539
+ "active",
1540
+ "paused",
1541
+ "failed"
1542
+ ]);
1543
+ /** Statuses an admin may set directly (`failed` is only reached by the sweep). */
1544
+ const settableRotationStatusSchema = z.enum(["active", "paused"]);
1545
+ const rotationAlphabetSchema = z.enum([
1546
+ "alphanumeric",
1547
+ "hex",
1548
+ "base64url",
1549
+ "printable"
1550
+ ]);
1551
+ const rotationIntervalSchema = z.number().int().min(300).max(3600 * 24 * 365);
1552
+ /**
1553
+ * A database user name we are willing to *re-key*. Deliberately more permissive
1554
+ * than the lease providers' name schemas — those name accounts seekrit creates,
1555
+ * whereas this names an account the customer's DBA created years ago, which may
1556
+ * be mixed-case or contain dots or dashes.
1557
+ *
1558
+ * It stays injection-safe for every place it is interpolated: a double-quoted
1559
+ * Postgres identifier, a single-quoted MySQL literal, and a bare Redis command
1560
+ * token. The charset excludes both quote characters, backslash, whitespace, and
1561
+ * `;`, so there is no way out of the surrounding quoting, and no whitespace to
1562
+ * split one Redis argument into two.
1563
+ */
1564
+ const rotationUsernameSchema = z.string().regex(/^[A-Za-z0-9_$.-]{1,63}$/, "must be 1–63 chars of letters, digits, underscore, dollar, dot or dash");
1565
+ /** A `{{name}}`/`{{host}}`/`{{verifier}}` templated statement or command line. */
1566
+ const statementSchema = z.string().min(1).max(4e3);
1567
+ const passwordLengthSchema = z.number().int().min(16).max(256);
1568
+ /** A MySQL account host part (`'name'@'<host>'`) — no quotes/whitespace. */
1569
+ const mysqlHostSchema = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
1570
+ const generatedRotationConfigSchema = z.object({
1571
+ kind: z.literal("generated"),
1572
+ length: passwordLengthSchema.optional(),
1573
+ alphabet: rotationAlphabetSchema.optional()
1574
+ });
1575
+ const postgresRotationConfigSchema = z.object({
1576
+ kind: z.literal("postgres"),
1577
+ username: rotationUsernameSchema,
1578
+ passwordLength: passwordLengthSchema.optional(),
1579
+ statements: z.array(statementSchema).max(16).optional()
1580
+ });
1581
+ const mysqlRotationConfigSchema = z.object({
1582
+ kind: z.literal("mysql"),
1583
+ username: rotationUsernameSchema,
1584
+ userHost: mysqlHostSchema.optional(),
1585
+ passwordLength: passwordLengthSchema.optional(),
1586
+ statements: z.array(statementSchema).max(16).optional()
1587
+ });
1588
+ const redisRotationConfigSchema = z.object({
1589
+ kind: z.literal("redis"),
1590
+ username: rotationUsernameSchema,
1591
+ passwordLength: passwordLengthSchema.optional(),
1592
+ statements: z.array(statementSchema).max(16).optional()
1593
+ });
1594
+ const rotationConfigSchema = z.discriminatedUnion("kind", [
1595
+ generatedRotationConfigSchema,
1596
+ postgresRotationConfigSchema,
1597
+ mysqlRotationConfigSchema,
1598
+ redisRotationConfigSchema
1599
+ ]);
1600
+ z.object({
1601
+ environmentId: z.string().min(1),
1602
+ /** The secret whose value rotates. It must already exist. */
1603
+ secretName: secretNameSchema,
1604
+ config: rotationConfigSchema,
1605
+ intervalSeconds: rotationIntervalSchema,
1606
+ /**
1607
+ * The registered lease target supplying the connection, executor mode, and
1608
+ * wrapped admin credential. Required for every kind but `generated`.
1609
+ */
1610
+ targetId: z.string().min(1).optional(),
1611
+ /** Environment DEK wrapped to the rotator public key (`wd1.` blob). */
1612
+ wrappedDek: z.string().min(1).optional(),
1613
+ /** Rotate once immediately instead of waiting for the first interval. */
1614
+ rotateNow: z.boolean().optional()
1615
+ });
1616
+ z.object({
1617
+ intervalSeconds: rotationIntervalSchema.optional(),
1618
+ config: rotationConfigSchema.optional(),
1619
+ /**
1620
+ * `paused` stops the sweep; `active` resumes it and clears the failure
1621
+ * streak, which is also how a `failed` policy is recovered.
1622
+ */
1623
+ status: settableRotationStatusSchema.optional()
1624
+ }).refine((v) => v.intervalSeconds !== void 0 || v.config !== void 0 || v.status !== void 0, "provide at least one of intervalSeconds, config, or status");
1487
1625
  //#endregion
1488
1626
  //#region ../../packages/core/src/sync.ts
1489
1627
  /**
@@ -1525,7 +1663,11 @@ const SYNC_PROVIDER_KINDS = [
1525
1663
  "fly",
1526
1664
  "northflank",
1527
1665
  "digitalocean",
1528
- "heroku"
1666
+ "heroku",
1667
+ "netlify",
1668
+ "bunnyshell",
1669
+ "github-actions",
1670
+ "gcp-secret-manager"
1529
1671
  ];
1530
1672
  z.enum(SYNC_PROVIDER_KINDS);
1531
1673
  /**
@@ -1711,6 +1853,101 @@ const digitalOceanConnectionConfigSchema = z.object({ provider: z.literal("digit
1711
1853
  * not.
1712
1854
  */
1713
1855
  const herokuConnectionConfigSchema = z.object({ provider: z.literal("heroku") });
1856
+ /**
1857
+ * Netlify team scope — the one thing a Netlify token cannot tell us itself.
1858
+ *
1859
+ * Every environment variable endpoint is account-scoped
1860
+ * (`/accounts/{account_id}/env`), and a personal access token belongs to a
1861
+ * *user*, who may sit in several teams. So unlike Fly's or Heroku's, this
1862
+ * config is not empty: the token says who you are, and this says which team's
1863
+ * variables to write.
1864
+ *
1865
+ * Netlify treats the team's id and its slug as interchangeable wherever
1866
+ * `{account_id}` appears, so both are accepted. The slug is the one an operator
1867
+ * can find without an API call — it is in the dashboard URL
1868
+ * (`app.netlify.com/teams/<slug>`) and under Team settings → General.
1869
+ */
1870
+ const netlifyConnectionConfigSchema = z.object({
1871
+ provider: z.literal("netlify"),
1872
+ /** Netlify team slug (`acme`) or account id — `{account_id}` accepts either. */
1873
+ accountId: z.string().trim().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "must be a Netlify team slug or account ID — no slashes or spaces")
1874
+ });
1875
+ /**
1876
+ * Bunnyshell account scope — empty, as Fly's, Heroku's, and Northflank's are.
1877
+ *
1878
+ * A Bunnyshell access token (from `environments.bunnyshell.com/access-token`)
1879
+ * belongs to a *user* and carries their access to every organization they are
1880
+ * in, exactly as a Heroku token does. Unlike Netlify's, that does not force an
1881
+ * organization onto the connection, because nothing here is addressed *through*
1882
+ * one: both variable collections name their parent by an opaque, globally
1883
+ * unique id (`environment` or `project`), so the token plus the destination's
1884
+ * id is the whole address. The API offers an `organization` filter, but it
1885
+ * narrows a listing — it is not part of an address.
1886
+ */
1887
+ const bunnyshellConnectionConfigSchema = z.object({ provider: z.literal("bunnyshell") });
1888
+ /**
1889
+ * GitHub account scope — empty for github.com, which is the whole point.
1890
+ *
1891
+ * A GitHub token addresses everything by `{owner}/{repo}` or `{org}`, and those
1892
+ * are the destination's business, so there is no account half to state the way
1893
+ * Cloudflare and Netlify need one. `baseUrl` is the single exception, and it is
1894
+ * not an account scope at all: it names a **GitHub Enterprise Server** install,
1895
+ * whose API lives on the customer's own host rather than on `api.github.com`.
1896
+ *
1897
+ * Left unset for github.com and for Enterprise Cloud (which is `api.github.com`
1898
+ * with a different plan behind it). Set only for a self-hosted GHES appliance,
1899
+ * where the REST API is at `https://<host>/api/v3`.
1900
+ */
1901
+ const githubActionsConnectionConfigSchema = z.object({
1902
+ provider: z.literal("github-actions"),
1903
+ /**
1904
+ * GitHub Enterprise Server API root, e.g. `https://github.acme.com/api/v3`.
1905
+ * Omit for github.com. Must be `https:` — this URL carries the token.
1906
+ */
1907
+ baseUrl: z.string().trim().max(300).refine((value) => {
1908
+ let parsed;
1909
+ try {
1910
+ parsed = new URL(value);
1911
+ } catch {
1912
+ return false;
1913
+ }
1914
+ return parsed.protocol === "https:" && !parsed.username && !parsed.password;
1915
+ }, "must be an https:// URL — the GitHub Enterprise Server API root, e.g. https://github.acme.com/api/v3").optional()
1916
+ });
1917
+ /**
1918
+ * A Google Cloud project, as `projects/{project}` accepts one: either the
1919
+ * project **ID** (`acme-prod`, 6–30 characters, what the console shows) or the
1920
+ * project **number** (all digits). Both are accepted because both work, and
1921
+ * the id is the one an operator can read off their own dashboard.
1922
+ *
1923
+ * Validated by shape for the reason Cloudflare's account id is: every Secret
1924
+ * Manager URL is built from this string, and a typo would otherwise surface as
1925
+ * a 403 from Google hours later inside an alarm, with nobody watching.
1926
+ */
1927
+ const gcpProjectSchema = z.string().trim().refine((value) => /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(value) || /^\d{1,20}$/.test(value), "must be a Google Cloud project ID (e.g. acme-prod) or project number");
1928
+ /**
1929
+ * Google Cloud project scope — which project's Secret Manager to write.
1930
+ *
1931
+ * The service account is *not* here, unlike AWS's access key id: a GCP
1932
+ * credential is a key JSON that names its own `client_email`, so the identity
1933
+ * arrives with the credential the way a Vercel token's does. What the
1934
+ * credential cannot say is which project to write, because a service account
1935
+ * can be granted access to secrets in projects other than its own — so that is
1936
+ * this field, exactly as Cloudflare's account id is.
1937
+ *
1938
+ * One project per connection. Syncing an environment into two projects means
1939
+ * two connections, which also keeps their key grants separate.
1940
+ *
1941
+ * Global secrets only: v1 addresses `secretmanager.googleapis.com`, not the
1942
+ * per-location `secretmanager.<location>.rep.googleapis.com` endpoints that
1943
+ * regional secrets live behind. Data residency is expressed instead through the
1944
+ * destination's user-managed replication.
1945
+ */
1946
+ const gcpSecretManagerConnectionConfigSchema = z.object({
1947
+ provider: z.literal("gcp-secret-manager"),
1948
+ /** Project ID (`acme-prod`) or project number. */
1949
+ projectId: gcpProjectSchema
1950
+ });
1714
1951
  const syncConnectionConfigSchema = z.discriminatedUnion("provider", [
1715
1952
  vercelConnectionConfigSchema,
1716
1953
  cloudflareWorkersConnectionConfigSchema,
@@ -1723,7 +1960,11 @@ const syncConnectionConfigSchema = z.discriminatedUnion("provider", [
1723
1960
  flyConnectionConfigSchema,
1724
1961
  northflankConnectionConfigSchema,
1725
1962
  digitalOceanConnectionConfigSchema,
1726
- herokuConnectionConfigSchema
1963
+ herokuConnectionConfigSchema,
1964
+ netlifyConnectionConfigSchema,
1965
+ bunnyshellConnectionConfigSchema,
1966
+ githubActionsConnectionConfigSchema,
1967
+ gcpSecretManagerConnectionConfigSchema
1727
1968
  ]);
1728
1969
  /** Vercel's three deployment targets. A binding writes to one or more. */
1729
1970
  const VERCEL_TARGETS = [
@@ -2078,6 +2319,362 @@ const herokuDestinationSchema = z.object({
2078
2319
  /** App name as `heroku apps` prints it, or the app's UUID. */
2079
2320
  app: herokuAppSchema
2080
2321
  });
2322
+ /**
2323
+ * The deploy contexts a Netlify value can be set for.
2324
+ *
2325
+ * These are Netlify's own, minus two. `all` is missing deliberately: Netlify
2326
+ * requires a **secret** value to be set against explicit contexts, and its
2327
+ * `setEnvVarValue` endpoint is reported to fail outright on `context: "all"` —
2328
+ * so the union offers only contexts that work under both. Naming the contexts
2329
+ * you mean is what you want here anyway; a binding already exists to map one
2330
+ * seekrit environment onto one deploy context. `dev-server` (Preview Server) is
2331
+ * left out for want of anyone asking.
2332
+ *
2333
+ * `branch` is the odd one: it needs a branch name alongside it, which the
2334
+ * destination carries as {@link netlifyDestinationSchema}'s `branch`.
2335
+ */
2336
+ const NETLIFY_CONTEXTS = [
2337
+ "production",
2338
+ "deploy-preview",
2339
+ "branch-deploy",
2340
+ "branch",
2341
+ "dev"
2342
+ ];
2343
+ /**
2344
+ * A Netlify site, by its **API ID** — the UUID under Project configuration →
2345
+ * General → Project information.
2346
+ *
2347
+ * Netlify accepts a site's domain in place of its id where a site appears in a
2348
+ * *path* (`/sites/{site_id}`), but the environment variable endpoints take the
2349
+ * site as a `?site_id=` **query parameter** instead, and Netlify documents no
2350
+ * name resolution there. That asymmetry is why this is strict where the Heroku
2351
+ * and Fly destinations are permissive: a `site_id` Netlify does not resolve
2352
+ * does not 404 — the write lands on the *team*, as a shared variable inherited
2353
+ * by every site in it. Refusing anything but the UUID keeps a slip from turning
2354
+ * into a much wider blast radius than the operator asked for.
2355
+ */
2356
+ const netlifySiteIdSchema = z.string().trim().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "must be the site's API ID (a UUID), not its name or URL");
2357
+ /**
2358
+ * The Netlify site, and which of its deploy contexts a binding owns.
2359
+ *
2360
+ * Netlify keys a value by (site, context), so the destination is that pair.
2361
+ * Contexts are a list rather than a single value for the same reason Vercel's
2362
+ * `targets` is one: a binding is unique per (connection, environment), so an
2363
+ * environment that feeds both production and deploy previews has to say so in
2364
+ * one destination or not at all.
2365
+ *
2366
+ * A push writes **only** the contexts listed here. Other contexts of the same
2367
+ * variable — and every variable this binding does not manage — are left as they
2368
+ * are, which is what makes it safe to point at a site that already has
2369
+ * variables set by hand.
2370
+ *
2371
+ * `secret` marks what seekrit creates as a Netlify **secret**: write-only, and
2372
+ * unreadable afterwards through the UI, CLI, and API. On by default, since that
2373
+ * is the whole point of pushing from a secrets manager. It applies only to
2374
+ * variables seekrit *creates* — Netlify will not let a flag be added to an
2375
+ * existing variable, or removed from one ever — and it needs a plan that
2376
+ * includes Secrets Controller.
2377
+ */
2378
+ const netlifyDestinationSchema = z.object({
2379
+ provider: z.literal("netlify"),
2380
+ /** Site API ID (a UUID), from Project configuration → General. */
2381
+ siteId: netlifySiteIdSchema,
2382
+ /** Which deploy contexts receive these values. At least one. */
2383
+ contexts: z.array(z.enum(NETLIFY_CONTEXTS)).min(1),
2384
+ /** Branch name, required when `contexts` includes `branch`; ignored otherwise. */
2385
+ branch: z.string().trim().min(1).max(255).optional(),
2386
+ /** Create variables as Netlify secrets (default true). */
2387
+ secret: z.boolean().optional()
2388
+ }).refine((d) => !d.contexts.includes("branch") || d.branch !== void 0, {
2389
+ message: "a branch context needs the branch name it applies to",
2390
+ path: ["branch"]
2391
+ });
2392
+ /**
2393
+ * A Bunnyshell resource id, as the platform hands it out.
2394
+ *
2395
+ * Deliberately loose. Bunnyshell documents no format for these — they are
2396
+ * opaque strings from `bns environments list` or the dashboard URL — so
2397
+ * asserting a shape here would be inventing a rule the platform never stated,
2398
+ * and the failure mode would be seekrit refusing an id that works.
2399
+ *
2400
+ * Being loose is affordable here in a way it is not on Netlify, where an
2401
+ * unresolved `site_id` silently widens a write to the whole team. Both
2402
+ * Bunnyshell variable collections name their parent in the **request body** of
2403
+ * a create, as a required relation: an id the platform cannot resolve is a 422
2404
+ * naming the field, not a write that lands somewhere broader. The listing side
2405
+ * is fenced separately — the connector re-checks every variable's own parent
2406
+ * before it touches it, so a filter that failed to bite cannot turn into an
2407
+ * edit of a neighbouring environment's variables.
2408
+ */
2409
+ const bunnyshellIdSchema = z.string().trim().min(1).max(64).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "must be a Bunnyshell ID — no slashes or spaces");
2410
+ /**
2411
+ * Variables on one Bunnyshell **environment** — the set every component in it
2412
+ * inherits, and the closest match to a seekrit environment.
2413
+ *
2414
+ * This is the destination for an environment that already exists and stays
2415
+ * around: a primary environment, or a long-lived ephemeral one.
2416
+ */
2417
+ const bunnyshellEnvironmentDestinationSchema = z.object({
2418
+ provider: z.literal("bunnyshell"),
2419
+ kind: z.literal("environment"),
2420
+ /** Environment ID, as `bns environments list` prints it. */
2421
+ environmentId: bunnyshellIdSchema,
2422
+ /** Mark what seekrit creates as a Bunnyshell secret (default true). */
2423
+ secret: z.boolean().optional()
2424
+ });
2425
+ /**
2426
+ * Variables on a Bunnyshell **project** — inherited by every environment
2427
+ * created in it from then on.
2428
+ *
2429
+ * Worth thinking about twice, for the reason a Render environment group is:
2430
+ * the blast radius is the project, not one environment. It earns its place
2431
+ * anyway, because it is the only destination that reaches an environment which
2432
+ * *does not exist yet*. Bunnyshell's whole shape is ephemeral environments spun
2433
+ * up per branch or per pull request; pushing to the environment cannot seed one
2434
+ * that a webhook will create tomorrow, and pushing to the project can.
2435
+ *
2436
+ * An environment inherits the project's value at creation and may then be
2437
+ * overridden at its own scope — so a project binding does not fight an
2438
+ * environment binding pointed at the same name, it loses to it.
2439
+ */
2440
+ const bunnyshellProjectDestinationSchema = z.object({
2441
+ provider: z.literal("bunnyshell"),
2442
+ kind: z.literal("project"),
2443
+ /** Project ID, as `bns projects list` prints it. */
2444
+ projectId: bunnyshellIdSchema,
2445
+ /** Mark what seekrit creates as a Bunnyshell secret (default true). */
2446
+ secret: z.boolean().optional()
2447
+ });
2448
+ /**
2449
+ * Where in Bunnyshell a binding writes.
2450
+ *
2451
+ * Split on `kind` rather than into two providers — the way Render's service and
2452
+ * environment group are, and unlike Cloudflare's three — because the two are the
2453
+ * same API twice over: `/v1/environment_variables` and `/v1/project_variables`
2454
+ * take the same fields, fail the same ways, and differ only in which parent they
2455
+ * name. One connector serves both, so one provider does too.
2456
+ *
2457
+ * `secret` is Bunnyshell's `isSecret`, and means less than Netlify's flag of the
2458
+ * same name: Bunnyshell encrypts every variable with an organization key whether
2459
+ * or not the flag is set, so this only decides whether the value is obscured in
2460
+ * the dashboard and stored encrypted in an exported definition. It is on by
2461
+ * default all the same — a value pushed from a secrets manager should not be
2462
+ * sitting in plain view of everyone with project access. It applies only to
2463
+ * variables seekrit **creates**: an update never sends the flag, so a variable
2464
+ * an operator deliberately un-secreted stays that way.
2465
+ */
2466
+ const bunnyshellDestinationSchema = z.discriminatedUnion("kind", [bunnyshellEnvironmentDestinationSchema, bunnyshellProjectDestinationSchema]);
2467
+ /**
2468
+ * Which repositories in an organization can read an org-level secret.
2469
+ *
2470
+ * GitHub's own enum, unchanged. There is deliberately **no default**: `all` hands
2471
+ * the value to every repository in the organization — including ones added
2472
+ * tomorrow, and including forks' workflows to the extent the org allows them —
2473
+ * and that is not a blast radius a secrets manager should pick on an operator's
2474
+ * behalf. Naming it is the point.
2475
+ */
2476
+ const GITHUB_ACTIONS_VISIBILITIES = [
2477
+ "all",
2478
+ "private",
2479
+ "selected"
2480
+ ];
2481
+ /**
2482
+ * A GitHub account or organization login, matching GitHub's own rule:
2483
+ * alphanumeric with single internal hyphens, 39 characters at most.
2484
+ *
2485
+ * Checked here so the habitual slip — pasting a URL, or `owner/repo` into the
2486
+ * owner field — fails at the form rather than as a 404 from an alarm with nobody
2487
+ * watching.
2488
+ */
2489
+ const githubOwnerSchema = z.string().trim().min(1).max(39).regex(/^[A-Za-z0-9](?:-?[A-Za-z0-9])*$/, "must be a GitHub user or organization login — not a URL or an owner/repo pair");
2490
+ /**
2491
+ * A repository name. GitHub's rules are looser than an owner's: letters,
2492
+ * numbers, hyphens, underscores, and periods, up to 100 characters. `.` and `..`
2493
+ * are refused outright — they would traverse the API path rather than name a
2494
+ * repository.
2495
+ */
2496
+ const githubRepoSchema = z.string().trim().min(1).max(100).regex(/^[A-Za-z0-9._-]+$/, "must be a repository name alone (`api`), not `owner/repo` or a URL").refine((value) => value !== "." && value !== "..", "is not a repository name");
2497
+ /**
2498
+ * A deployment environment name.
2499
+ *
2500
+ * Deliberately permissive: GitHub allows spaces and most punctuation here, and
2501
+ * the dashboard shows names like `prod (eu-west)`. Only the two things that would
2502
+ * break the request are refused — an empty name, and the path separators that
2503
+ * would let a name escape its URL segment. Everything else is GitHub's to reject.
2504
+ */
2505
+ const githubEnvironmentSchema = z.string().trim().min(1).max(255).refine((value) => !value.includes("/") && !value.includes("\\"), "cannot contain a slash — that is a path separator, not part of an environment name");
2506
+ /**
2507
+ * One repository's Actions secrets.
2508
+ *
2509
+ * Every workflow in the repository can read these, including one added by a pull
2510
+ * request from a collaborator with write access. That is GitHub's model, not a
2511
+ * choice this connector makes — but it is the reason `environment` exists below,
2512
+ * and the reason to prefer it for anything that touches production.
2513
+ */
2514
+ const githubActionsRepoDestinationSchema = z.object({
2515
+ provider: z.literal("github-actions"),
2516
+ kind: z.literal("repo"),
2517
+ /** Repository owner — a user or organization login. */
2518
+ owner: githubOwnerSchema,
2519
+ /** Repository name, without the owner. */
2520
+ repo: githubRepoSchema
2521
+ });
2522
+ /**
2523
+ * One deployment environment's Actions secrets — the narrowest scope GitHub has.
2524
+ *
2525
+ * A job reads these only by declaring `environment: <name>`, which also subjects
2526
+ * it to that environment's protection rules: required reviewers, wait timers, and
2527
+ * the branch policy. That combination is the closest GitHub gets to "this secret
2528
+ * is for production, and reaching it requires approval", and it is the scope to
2529
+ * reach for by default.
2530
+ *
2531
+ * The environment must already exist. This connector will not create one: an
2532
+ * environment is a deployment gate, and silently creating an unprotected one
2533
+ * because a name was misspelled would quietly remove the protection the operator
2534
+ * was relying on.
2535
+ */
2536
+ const githubActionsEnvironmentDestinationSchema = z.object({
2537
+ provider: z.literal("github-actions"),
2538
+ kind: z.literal("environment"),
2539
+ owner: githubOwnerSchema,
2540
+ repo: githubRepoSchema,
2541
+ /** Environment name, exactly as the repository's Settings → Environments shows it. */
2542
+ environment: githubEnvironmentSchema
2543
+ });
2544
+ /**
2545
+ * An organization's Actions secrets.
2546
+ *
2547
+ * The widest scope in the product, and the only destination on any provider that
2548
+ * can hand a value to repositories nobody named. Read {@link
2549
+ * GITHUB_ACTIONS_VISIBILITIES} before using it.
2550
+ *
2551
+ * `selectedRepositoryIds` takes numeric repository **ids**, not names, because
2552
+ * that is what GitHub's API takes. An id is visible at
2553
+ * `GET /repos/{owner}/{repo}` as `id`, and in the dashboard nowhere at all —
2554
+ * which is friction worth accepting rather than resolving names to ids here: name
2555
+ * resolution would mean this connector picking which repository an ambiguous
2556
+ * name meant, and getting that wrong widens a secret's reach silently.
2557
+ */
2558
+ const githubActionsOrgDestinationSchema = z.object({
2559
+ provider: z.literal("github-actions"),
2560
+ kind: z.literal("org"),
2561
+ /** Organization login. */
2562
+ org: githubOwnerSchema,
2563
+ /** Which repositories may read these secrets. Stated, never defaulted. */
2564
+ visibility: z.enum(GITHUB_ACTIONS_VISIBILITIES),
2565
+ /** Numeric repository ids, required when `visibility` is `selected`. */
2566
+ selectedRepositoryIds: z.array(z.number().int().positive()).max(500).optional()
2567
+ }).refine((dest) => dest.visibility !== "selected" || dest.selectedRepositoryIds !== void 0 && dest.selectedRepositoryIds.length > 0, {
2568
+ message: "selected visibility needs at least one repository id",
2569
+ path: ["selectedRepositoryIds"]
2570
+ }).refine((dest) => dest.visibility === "selected" || dest.selectedRepositoryIds === void 0, {
2571
+ message: "repository ids only apply to selected visibility — remove them, or select it",
2572
+ path: ["selectedRepositoryIds"]
2573
+ });
2574
+ const githubActionsDestinationSchema = z.discriminatedUnion("kind", [
2575
+ githubActionsRepoDestinationSchema,
2576
+ githubActionsEnvironmentDestinationSchema,
2577
+ githubActionsOrgDestinationSchema
2578
+ ]);
2579
+ /**
2580
+ * How a binding lays its secrets out in Secret Manager. The same two shapes the
2581
+ * AWS Secrets Manager destination offers, and for the same reasons:
2582
+ *
2583
+ * - `secret-per-name` — one GCP secret per seekrit secret. The direct
2584
+ * translation, and what Cloud Run's `--set-secrets` and GKE's Secret Manager
2585
+ * CSI driver mount one at a time.
2586
+ * - `json-bundle` — every value as one JSON object in a single secret. Costs one
2587
+ * active version instead of fifty, which is the whole billing unit here.
2588
+ */
2589
+ const GCP_SECRET_MANAGER_LAYOUTS = ["secret-per-name", "json-bundle"];
2590
+ /**
2591
+ * Where Google keeps the copies of a secret. Chosen at creation and
2592
+ * **immutable** afterwards — changing it means deleting the secret and letting
2593
+ * the next run recreate it.
2594
+ *
2595
+ * - `automatic` — Google picks the locations. One billable replica, and what
2596
+ * you want unless a policy says otherwise.
2597
+ * - `user-managed` — the binding names the regions. This is how data residency
2598
+ * is expressed for global secrets, and each region is billed as its own
2599
+ * active version.
2600
+ */
2601
+ const GCP_REPLICATION_POLICIES = ["automatic", "user-managed"];
2602
+ /**
2603
+ * A Secret Manager secret ID. Google's own rule, quoted from the API reference:
2604
+ * "a string with a maximum length of 255 characters and can contain uppercase
2605
+ * and lowercase letters, numerals, and the hyphen (`-`) and underscore (`_`)
2606
+ * characters."
2607
+ *
2608
+ * Notably **no slashes and no dots**, which is what makes this a different
2609
+ * field from AWS's `pathPrefix` rather than the same idea renamed: a Secret
2610
+ * Manager namespace is spelled `prod-storefront-DB_URL`, not
2611
+ * `prod/storefront/DB_URL`.
2612
+ */
2613
+ const gcpSecretIdSchema = z.string().trim().min(1).max(255).regex(/^[A-Za-z0-9_-]+$/, "may contain letters, digits, hyphens, and underscores");
2614
+ /**
2615
+ * A GCP region for a user-managed replica (`us-east1`, `europe-west4`,
2616
+ * `northamerica-northeast1`). Validated by shape rather than against a list,
2617
+ * because Google adds regions faster than we ship — a name Secret Manager does
2618
+ * not know is refused by Google with a clear message at creation.
2619
+ */
2620
+ const gcpLocationSchema = z.string().trim().regex(/^[a-z]+-[a-z]+\d+$/, "must be a GCP region ID, e.g. us-east1");
2621
+ /**
2622
+ * A Cloud KMS key, as its full resource name — the only form the API accepts:
2623
+ * `projects/p/locations/l/keyRings/r/cryptoKeys/k`.
2624
+ *
2625
+ * Stricter than AWS's `kmsKeyId` (which tolerates five spellings) because
2626
+ * Google tolerates exactly one, and because a key in the wrong *location* is
2627
+ * rejected at creation: an automatic-replication secret needs a `global` key,
2628
+ * and a user-managed replica needs one in its own region.
2629
+ */
2630
+ const gcpKmsKeyNameSchema = z.string().trim().max(1024).regex(/^projects\/[^/]+\/locations\/[^/]+\/keyRings\/[^/]+\/cryptoKeys\/[^/]+$/, "must be a full Cloud KMS key name (projects/…/locations/…/keyRings/…/cryptoKeys/…)");
2631
+ /**
2632
+ * Where inside a project's Secret Manager a binding writes.
2633
+ *
2634
+ * ## Every push would otherwise cost a version
2635
+ *
2636
+ * Secret Manager has no "set the value" call — only `addVersion`, which appends.
2637
+ * A run pushes the whole environment (never a diff), so changing one secret in
2638
+ * an environment of fifty would leave fifty new versions behind, forty-nine of
2639
+ * them identical to their predecessors, each one billed for as long as it stays
2640
+ * active.
2641
+ *
2642
+ * So this connector writes a version only when the value actually changed,
2643
+ * decided from a keyed digest it keeps in the secret's own **annotations** — see
2644
+ * `apps/api/src/lib/sync/connectors/gcp-secret-manager.ts` for why it is keyed
2645
+ * and what that costs. `pruneVersions` is the other half of the bill: with it
2646
+ * on, the version a push supersedes is destroyed as soon as the new one lands,
2647
+ * so a secret keeps exactly one active version.
2648
+ */
2649
+ const gcpSecretManagerDestinationSchema = z.object({
2650
+ provider: z.literal("gcp-secret-manager"),
2651
+ layout: z.enum(GCP_SECRET_MANAGER_LAYOUTS).default("secret-per-name"),
2652
+ /**
2653
+ * `secret-per-name` only: prepended to every secret ID, e.g.
2654
+ * `prod-storefront-`. Optional, but strongly advised in a project that holds
2655
+ * anything else — without it a binding writes at the root of a namespace it
2656
+ * does not own, and Secret Manager has no folders to hide behind.
2657
+ */
2658
+ idPrefix: z.string().trim().max(200).regex(/^[A-Za-z0-9_-]*$/, "may contain letters, digits, hyphens, and underscores").optional(),
2659
+ /** `json-bundle` only: the one secret that holds every value, e.g. `prod-storefront-env`. */
2660
+ secretId: gcpSecretIdSchema.optional(),
2661
+ replication: z.enum(GCP_REPLICATION_POLICIES).default("automatic"),
2662
+ /** `user-managed` only: the regions to replicate to. At least one. */
2663
+ locations: z.array(gcpLocationSchema).min(1).max(16).optional(),
2664
+ /** Customer-managed encryption key. Omitted means Google-managed keys. */
2665
+ kmsKeyName: gcpKmsKeyNameSchema.optional(),
2666
+ /** Destroy the version each push supersedes, keeping one active version. */
2667
+ pruneVersions: z.boolean().optional()
2668
+ }).refine((d) => d.layout !== "json-bundle" || d.secretId !== void 0, {
2669
+ message: "a json-bundle destination needs the ID of the secret to write",
2670
+ path: ["secretId"]
2671
+ }).refine((d) => d.replication !== "user-managed" || (d.locations?.length ?? 0) > 0, {
2672
+ message: "user-managed replication needs at least one location",
2673
+ path: ["locations"]
2674
+ }).refine((d) => d.kmsKeyName === void 0 || d.replication === "automatic" || (d.locations?.length ?? 0) === 1, {
2675
+ message: "a customer-managed key covers one location — use automatic replication, or a single location",
2676
+ path: ["kmsKeyName"]
2677
+ });
2081
2678
  const syncDestinationSchema = z.discriminatedUnion("provider", [
2082
2679
  vercelDestinationSchema,
2083
2680
  cloudflareWorkersDestinationSchema,
@@ -2090,7 +2687,11 @@ const syncDestinationSchema = z.discriminatedUnion("provider", [
2090
2687
  flyDestinationSchema,
2091
2688
  northflankDestinationSchema,
2092
2689
  digitalOceanDestinationSchema,
2093
- herokuDestinationSchema
2690
+ herokuDestinationSchema,
2691
+ netlifyDestinationSchema,
2692
+ bunnyshellDestinationSchema,
2693
+ githubActionsDestinationSchema,
2694
+ gcpSecretManagerDestinationSchema
2094
2695
  ]);
2095
2696
  /**
2096
2697
  * How seekrit secret names become destination key names. Applied in order:
@@ -2539,6 +3140,43 @@ async function decryptDataKey(material, wrapped) {
2539
3140
  }
2540
3141
  }
2541
3142
  //#endregion
3143
+ //#region ../../packages/crypto/src/random.ts
3144
+ const ALPHABETS = {
3145
+ alphanumeric: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
3146
+ hex: "0123456789abcdef",
3147
+ base64url: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_",
3148
+ /**
3149
+ * Alphanumerics plus punctuation chosen to survive being pasted anywhere a
3150
+ * secret goes: no quote of either kind, no backslash, backtick, `$`, or
3151
+ * whitespace, so the value can't break out of a shell word, a SQL literal, a
3152
+ * URL component, or a `.env` line.
3153
+ */
3154
+ printable: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~!*+="
3155
+ };
3156
+ /**
3157
+ * A cryptographically random string of `length` characters drawn uniformly from
3158
+ * `alphabet` (default `alphanumeric`, ≈5.95 bits/char — 32 chars ≈ 190 bits).
3159
+ *
3160
+ * Uses rejection sampling: bytes at or above the largest multiple of the
3161
+ * alphabet size are discarded rather than folded, so `% n` introduces no modulo
3162
+ * bias toward the low end of the alphabet.
3163
+ */
3164
+ function generateSecretValue(length, alphabet = "alphanumeric") {
3165
+ if (!Number.isInteger(length) || length < 1) throw new RangeError("length must be a positive integer");
3166
+ const chars = ALPHABETS[alphabet];
3167
+ const n = chars.length;
3168
+ const limit = 256 - 256 % n;
3169
+ let out = "";
3170
+ while (out.length < length) {
3171
+ const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
3172
+ for (const byte of bytes) {
3173
+ if (byte < limit) out += chars[byte % n];
3174
+ if (out.length === length) break;
3175
+ }
3176
+ }
3177
+ return out;
3178
+ }
3179
+ //#endregion
2542
3180
  //#region ../../packages/crypto/src/mongodb.ts
2543
3181
  /** Generate the ephemeral P-256 keypair a client uses to receive one MongoDB lease. */
2544
3182
  async function generateMongoRecipientKeyPair() {
@@ -2603,7 +3241,6 @@ function mongoConnectionUri(cred) {
2603
3241
  * SCRAM helper does, with no hand-rolled hash primitive.
2604
3242
  */
2605
3243
  const DEFAULT_PASSWORD_LENGTH$2 = 32;
2606
- const PASSWORD_ALPHABET$2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
2607
3244
  async function sha1(data) {
2608
3245
  return new Uint8Array(await crypto.subtle.digest("SHA-1", data));
2609
3246
  }
@@ -2612,17 +3249,6 @@ function toUpperHex(bytes) {
2612
3249
  for (const b of bytes) hex += b.toString(16).padStart(2, "0");
2613
3250
  return hex.toUpperCase();
2614
3251
  }
2615
- function randomPassword$2(length) {
2616
- let out = "";
2617
- while (out.length < length) {
2618
- const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
2619
- for (const byte of bytes) {
2620
- if (byte < 248) out += PASSWORD_ALPHABET$2[byte % 62];
2621
- if (out.length === length) break;
2622
- }
2623
- }
2624
- return out;
2625
- }
2626
3252
  /**
2627
3253
  * Compute the `mysql_native_password` auth string `*<UPPER(HEX(SHA1(SHA1(pw))))>`
2628
3254
  * for a known password. Pass the result straight to
@@ -2636,7 +3262,7 @@ async function mysqlNativePasswordVerifier(password) {
2636
3262
  * — the client-side half of a Vault-style dynamic MySQL credential.
2637
3263
  */
2638
3264
  async function generateMysqlCredential(options = {}) {
2639
- const password = randomPassword$2(options.length ?? DEFAULT_PASSWORD_LENGTH$2);
3265
+ const password = generateSecretValue(options.length ?? DEFAULT_PASSWORD_LENGTH$2);
2640
3266
  return {
2641
3267
  password,
2642
3268
  verifier: await mysqlNativePasswordVerifier(password)
@@ -2916,7 +3542,6 @@ async function combineRecoveryShares(shareBytes) {
2916
3542
  * helper does, with no hand-rolled hash primitive.
2917
3543
  */
2918
3544
  const DEFAULT_PASSWORD_LENGTH$1 = 32;
2919
- const PASSWORD_ALPHABET$1 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
2920
3545
  async function sha256$1(data) {
2921
3546
  return new Uint8Array(await crypto.subtle.digest("SHA-256", data));
2922
3547
  }
@@ -2925,17 +3550,6 @@ function toLowerHex(bytes) {
2925
3550
  for (const b of bytes) hex += b.toString(16).padStart(2, "0");
2926
3551
  return hex;
2927
3552
  }
2928
- function randomPassword$1(length) {
2929
- let out = "";
2930
- while (out.length < length) {
2931
- const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
2932
- for (const byte of bytes) {
2933
- if (byte < 248) out += PASSWORD_ALPHABET$1[byte % 62];
2934
- if (out.length === length) break;
2935
- }
2936
- }
2937
- return out;
2938
- }
2939
3553
  /**
2940
3554
  * Compute the Redis ACL password verifier `LOWER(HEX(SHA256(password)))` for a
2941
3555
  * known password. Pass the result straight to `ACL SETUSER … on #<verifier>`.
@@ -2948,7 +3562,7 @@ async function redisSha256Verifier(password) {
2948
3562
  * client-side half of a Vault-style dynamic Redis credential.
2949
3563
  */
2950
3564
  async function generateRedisCredential(options = {}) {
2951
- const password = randomPassword$1(options.length ?? DEFAULT_PASSWORD_LENGTH$1);
3565
+ const password = generateSecretValue(options.length ?? DEFAULT_PASSWORD_LENGTH$1);
2952
3566
  return {
2953
3567
  password,
2954
3568
  verifier: await redisSha256Verifier(password)
@@ -2956,7 +3570,6 @@ async function generateRedisCredential(options = {}) {
2956
3570
  }
2957
3571
  const SALT_LENGTH = 16;
2958
3572
  const DEFAULT_PASSWORD_LENGTH = 32;
2959
- const PASSWORD_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
2960
3573
  async function hmacSha256(key, message) {
2961
3574
  const k = await crypto.subtle.importKey("raw", key, {
2962
3575
  name: "HMAC",
@@ -2977,17 +3590,6 @@ async function saltPassword(password, salt, iterations) {
2977
3590
  }, material, 256);
2978
3591
  return new Uint8Array(bits);
2979
3592
  }
2980
- function randomPassword(length) {
2981
- let out = "";
2982
- while (out.length < length) {
2983
- const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
2984
- for (const byte of bytes) {
2985
- if (byte < 248) out += PASSWORD_ALPHABET[byte % 62];
2986
- if (out.length === length) break;
2987
- }
2988
- }
2989
- return out;
2990
- }
2991
3593
  /**
2992
3594
  * Compute the `SCRAM-SHA-256$<i>:<salt>$<StoredKey>:<ServerKey>` verifier for a
2993
3595
  * known password. Pass the result straight to `CREATE ROLE … PASSWORD`.
@@ -3005,7 +3607,7 @@ async function scramSha256Verifier(password, options = {}) {
3005
3607
  * client-side half of a Vault-style dynamic Postgres credential.
3006
3608
  */
3007
3609
  async function generatePostgresCredential(options = {}) {
3008
- const password = randomPassword(options.length ?? DEFAULT_PASSWORD_LENGTH);
3610
+ const password = generateSecretValue(options.length ?? DEFAULT_PASSWORD_LENGTH);
3009
3611
  const iterations = options.iterations ?? 4096;
3010
3612
  return {
3011
3613
  password,
@@ -3295,7 +3897,7 @@ function isCliSessionToken(value) {
3295
3897
  }
3296
3898
  //#endregion
3297
3899
  //#region package.json
3298
- var version = "0.39.0";
3900
+ var version = "0.41.0";
3299
3901
  //#endregion
3300
3902
  //#region ../../packages/api-client/src/index.ts
3301
3903
  var SeekritApiError = class extends Error {
@@ -3727,6 +4329,39 @@ var SeekritClient = class {
3727
4329
  revokeLease(orgId, leaseId) {
3728
4330
  return this.request("DELETE", `/v1/orgs/${orgId}/leases/${leaseId}`);
3729
4331
  }
4332
+ /**
4333
+ * The rotator public key (the broker DO's), plus the environments that have
4334
+ * already granted it. Wrap an environment's DEK to this key client-side before
4335
+ * configuring rotation — that wrap IS the grant, and the server can't make it.
4336
+ */
4337
+ getRotatorKey(orgId) {
4338
+ return this.request("GET", `/v1/orgs/${orgId}/rotation/rotator-key`);
4339
+ }
4340
+ listRotations(orgId) {
4341
+ return this.request("GET", `/v1/orgs/${orgId}/rotation`);
4342
+ }
4343
+ getRotation(orgId, rotationId) {
4344
+ return this.request("GET", `/v1/orgs/${orgId}/rotation/${rotationId}`);
4345
+ }
4346
+ /**
4347
+ * Configure (or replace) a secret's rotation policy. `version` comes back only
4348
+ * when `rotateNow` was set — a rotated secret's new version number, never its
4349
+ * value.
4350
+ */
4351
+ configureRotation(orgId, input) {
4352
+ return this.request("POST", `/v1/orgs/${orgId}/rotation`, input);
4353
+ }
4354
+ updateRotation(orgId, rotationId, input) {
4355
+ return this.request("PATCH", `/v1/orgs/${orgId}/rotation/${rotationId}`, input);
4356
+ }
4357
+ /** Rotate now. Returns the new version — the value stays where it belongs. */
4358
+ rotateSecretNow(orgId, rotationId) {
4359
+ return this.request("POST", `/v1/orgs/${orgId}/rotation/${rotationId}/rotate`);
4360
+ }
4361
+ /** Disable rotation. `rotatorRevoked` reports whether the broker's key grant went too. */
4362
+ disableRotation(orgId, rotationId) {
4363
+ return this.request("DELETE", `/v1/orgs/${orgId}/rotation/${rotationId}`);
4364
+ }
3730
4365
  listAudit(orgId, query = {}) {
3731
4366
  const params = new URLSearchParams();
3732
4367
  if (query.cursor) params.set("cursor", query.cursor);
@@ -3783,6 +4418,19 @@ var SeekritClient = class {
3783
4418
  cancelSubscription(orgId) {
3784
4419
  return this.request("POST", `/v1/orgs/${orgId}/billing/cancel`);
3785
4420
  }
4421
+ /**
4422
+ * Redeem a promo code, comping the org onto the plan the code grants.
4423
+ * Admin-only. Casing, spaces, and dashes are normalized server-side, so pass
4424
+ * the code as the user typed it. Returns the refreshed billing view.
4425
+ *
4426
+ * Every invalid code fails the same way regardless of why (unknown, expired,
4427
+ * fully redeemed, already used by this org) — the API deliberately won't
4428
+ * confirm that a code exists. Show the returned message as-is rather than
4429
+ * guessing at a more specific one.
4430
+ */
4431
+ redeemPromoCode(orgId, input) {
4432
+ return this.request("POST", `/v1/orgs/${orgId}/billing/promo`, input);
4433
+ }
3786
4434
  };
3787
4435
  async function unauthenticatedPost(baseUrl, path, body, fetchImpl, client) {
3788
4436
  const headers = {
@@ -6770,6 +7418,184 @@ function collect$1(value, acc) {
6770
7418
  acc.push(value);
6771
7419
  return acc;
6772
7420
  }
7421
+ //#endregion
7422
+ //#region src/rotation.ts
7423
+ /**
7424
+ * `seekrit rotation` — managed, scheduled rotation of a stored secret's value.
7425
+ *
7426
+ * Zero-knowledge: enabling rotation unwraps this environment's DEK **on this
7427
+ * machine** and re-wraps it to the rotator public key (the per-org broker
7428
+ * Durable Object's), so the control plane only ever relays ciphertext. That wrap
7429
+ * is what lets the broker write a new value in place — and it is exactly why the
7430
+ * server can't enable rotation on its own.
7431
+ *
7432
+ * Rotated values are never printed here. Read them like any other secret
7433
+ * (`seekrit secrets get NAME`), which decrypts locally.
7434
+ */
7435
+ /** Parse a duration like `30m`, `24h`, `90d`, or a bare seconds count. */
7436
+ function parseDurationSeconds(input, flag) {
7437
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
7438
+ if (!m) fail(`invalid ${flag} "${input}" (try 12h, 7d, 90d)`);
7439
+ return Number(m[1]) * ({
7440
+ s: 1,
7441
+ m: 60,
7442
+ h: 3600,
7443
+ d: 86400
7444
+ }[m[2] || "s"] ?? 1);
7445
+ }
7446
+ function formatInterval(seconds) {
7447
+ if (seconds % 86400 === 0) return `${seconds / 86400}d`;
7448
+ if (seconds % 3600 === 0) return `${seconds / 3600}h`;
7449
+ if (seconds % 60 === 0) return `${seconds / 60}m`;
7450
+ return `${seconds}s`;
7451
+ }
7452
+ /** One policy as a tab-separated line: name, kind, cadence, status, next run. */
7453
+ function rotationLine(r) {
7454
+ const status = r.failureCount > 0 ? `${r.status} (${r.failureCount} failed)` : r.status;
7455
+ return [
7456
+ r.id,
7457
+ r.secretName,
7458
+ r.kind,
7459
+ `every ${formatInterval(r.intervalSeconds)}`,
7460
+ status,
7461
+ `next ${r.nextRotateAt}`
7462
+ ].join(" ");
7463
+ }
7464
+ /** Find a policy by id, or by secret name when it is unambiguous in the org. */
7465
+ async function resolveRotation(ctx, orgId, ref) {
7466
+ const { rotations } = await ctx.client.listRotations(orgId);
7467
+ const byId = rotations.find((r) => r.id === ref);
7468
+ if (byId) return byId;
7469
+ const byName = rotations.filter((r) => r.secretName === ref);
7470
+ if (byName.length === 1) return byName[0];
7471
+ if (byName.length > 1) fail(`"${ref}" rotates in ${byName.length} environments — pass the rotation id instead:\n${byName.map((r) => ` ${r.id}\t${r.environmentId}`).join("\n")}`);
7472
+ fail(`no rotation policy "${ref}"`);
7473
+ }
7474
+ /** Build the rotation config from the CLI flags for a given kind. */
7475
+ function buildConfig$1(kind, options) {
7476
+ const length = Number.parseInt(options.length, 10);
7477
+ if (!Number.isFinite(length)) fail(`invalid --length "${options.length}"`);
7478
+ if (kind === "generated") {
7479
+ const alphabets = [
7480
+ "alphanumeric",
7481
+ "hex",
7482
+ "base64url",
7483
+ "printable"
7484
+ ];
7485
+ if (!alphabets.includes(options.alphabet)) fail(`--alphabet must be one of ${alphabets.join(", ")}`);
7486
+ return {
7487
+ kind: "generated",
7488
+ length,
7489
+ alphabet: options.alphabet
7490
+ };
7491
+ }
7492
+ if (!options.username) fail(`--username is required for a ${kind} rotation`);
7493
+ const username = options.username;
7494
+ if (kind === "postgres") return {
7495
+ kind: "postgres",
7496
+ username,
7497
+ passwordLength: length
7498
+ };
7499
+ if (kind === "mysql") return {
7500
+ kind: "mysql",
7501
+ username,
7502
+ passwordLength: length,
7503
+ ...options.userHost ? { userHost: options.userHost } : {}
7504
+ };
7505
+ if (kind === "redis") return {
7506
+ kind: "redis",
7507
+ username,
7508
+ passwordLength: length
7509
+ };
7510
+ return fail(`--kind must be generated, postgres, mysql, or redis (got "${kind}")`);
7511
+ }
7512
+ function registerRotationCommands(program) {
7513
+ const rotation = program.command("rotation").description("managed rotation of stored secret values (scheduled, zero-knowledge)");
7514
+ rotation.command("enable <secretName>").description("configure rotation for an existing secret").option("--org <slug>").option("--app <slug>").option("--group <slug>", "rotate a secret in a group environment").requiredOption("--env <slug>").requiredOption("--kind <kind>", "generated | postgres | mysql | redis").requiredOption("--every <duration>", "rotation cadence, e.g. 24h, 30d").option("--username <name>", "the EXISTING database account to re-key (db kinds)").option("--target <idOrName>", "registered lease target to rotate against (db kinds)").option("--user-host <host>", "MySQL account host part (default %)").option("--length <n>", "generated value length", "32").option("--alphabet <set>", "generated kind only: alphanumeric | hex | base64url | printable", "alphanumeric").option("--now", "rotate immediately as well as on the schedule").action(async (secretName, options) => {
7515
+ const ctx = buildContext();
7516
+ const target = await resolveEnvTarget(ctx, options);
7517
+ const config = buildConfig$1(options.kind, options);
7518
+ const intervalSeconds = parseDurationSeconds(options.every, "--every");
7519
+ let targetId;
7520
+ if (config.kind !== "generated") {
7521
+ if (!options.target) fail(`--target is required for a ${config.kind} rotation`);
7522
+ const { targets } = await ctx.client.listLeaseTargets(target.orgId);
7523
+ const t = targets.find((x) => x.id === options.target || x.name === options.target);
7524
+ if (!t) fail(`no lease target "${options.target}" — register one with \`seekrit ${config.kind === "postgres" ? "pg" : config.kind} target add\``);
7525
+ targetId = t.id;
7526
+ }
7527
+ const { publicKeyJwk, grantedEnvironmentIds } = await ctx.client.getRotatorKey(target.orgId);
7528
+ let wrappedDek;
7529
+ if (!grantedEnvironmentIds.includes(target.envId)) wrappedDek = await wrapDek(await getDek(ctx, target.orgId, target.envId), publicKeyJwk);
7530
+ const { rotation: created, version } = await ctx.client.configureRotation(target.orgId, {
7531
+ environmentId: target.envId,
7532
+ secretName,
7533
+ config,
7534
+ intervalSeconds,
7535
+ ...targetId ? { targetId } : {},
7536
+ ...wrappedDek ? { wrappedDek } : {},
7537
+ ...options.now ? { rotateNow: true } : {}
7538
+ });
7539
+ console.error(`rotating ${created.secretName} in ${target.label} every ${formatInterval(created.intervalSeconds)} (${created.kind})${wrappedDek ? " — rotator key granted" : ""}`);
7540
+ if (version !== void 0) console.error(`rotated now — ${created.secretName} is at version ${version}`);
7541
+ else console.error(`first rotation: ${created.nextRotateAt}`);
7542
+ console.log(created.id);
7543
+ });
7544
+ rotation.command("list").description("list rotation policies (schedules only — never values)").option("--org <slug>").option("--json", "print the full policies as JSON").action(async (options) => {
7545
+ const ctx = buildContext();
7546
+ const org = await resolveOrg(ctx, options.org);
7547
+ const { rotations } = await ctx.client.listRotations(org.id);
7548
+ if (options.json) {
7549
+ console.log(JSON.stringify(rotations, null, 2));
7550
+ return;
7551
+ }
7552
+ if (rotations.length === 0) {
7553
+ console.error(`no rotation policies in ${org.slug}`);
7554
+ return;
7555
+ }
7556
+ for (const r of rotations) console.log(rotationLine(r));
7557
+ });
7558
+ rotation.command("show <rotationOrSecret>").description("show one policy, including the last failure if any").option("--org <slug>").action(async (ref, options) => {
7559
+ const ctx = buildContext();
7560
+ const r = await resolveRotation(ctx, (await resolveOrg(ctx, options.org)).id, ref);
7561
+ console.log(JSON.stringify(r, null, 2));
7562
+ });
7563
+ rotation.command("rotate <rotationOrSecret>").description("rotate now (the same path the scheduler uses)").option("--org <slug>").action(async (ref, options) => {
7564
+ const ctx = buildContext();
7565
+ const org = await resolveOrg(ctx, options.org);
7566
+ const r = await resolveRotation(ctx, org.id, ref);
7567
+ const { version, rotatedAt } = await ctx.client.rotateSecretNow(org.id, r.id);
7568
+ console.error(`rotated ${r.secretName} at ${rotatedAt} — now at version ${version}. Read it with \`seekrit secrets get ${r.secretName}\`.`);
7569
+ });
7570
+ rotation.command("pause <rotationOrSecret>").description("stop rotating, keeping the policy").option("--org <slug>").action(async (ref, options) => {
7571
+ const ctx = buildContext();
7572
+ const org = await resolveOrg(ctx, options.org);
7573
+ const r = await resolveRotation(ctx, org.id, ref);
7574
+ await ctx.client.updateRotation(org.id, r.id, { status: "paused" });
7575
+ console.error(`paused rotation of ${r.secretName}`);
7576
+ });
7577
+ rotation.command("resume <rotationOrSecret>").description("resume rotating (also clears a failed streak)").option("--org <slug>").action(async (ref, options) => {
7578
+ const ctx = buildContext();
7579
+ const org = await resolveOrg(ctx, options.org);
7580
+ const r = await resolveRotation(ctx, org.id, ref);
7581
+ const { rotation: updated } = await ctx.client.updateRotation(org.id, r.id, { status: "active" });
7582
+ console.error(`resumed rotation of ${r.secretName} — next ${updated.nextRotateAt}`);
7583
+ });
7584
+ rotation.command("set-interval <rotationOrSecret>").description("change the cadence").requiredOption("--every <duration>", "new cadence, e.g. 24h, 30d").option("--org <slug>").action(async (ref, options) => {
7585
+ const ctx = buildContext();
7586
+ const org = await resolveOrg(ctx, options.org);
7587
+ const r = await resolveRotation(ctx, org.id, ref);
7588
+ const { rotation: updated } = await ctx.client.updateRotation(org.id, r.id, { intervalSeconds: parseDurationSeconds(options.every, "--every") });
7589
+ console.error(`${updated.secretName} now rotates every ${formatInterval(updated.intervalSeconds)} — next ${updated.nextRotateAt}`);
7590
+ });
7591
+ rotation.command("disable <rotationOrSecret>").description("stop rotating and remove the policy (the secret is untouched)").option("--org <slug>").action(async (ref, options) => {
7592
+ const ctx = buildContext();
7593
+ const org = await resolveOrg(ctx, options.org);
7594
+ const r = await resolveRotation(ctx, org.id, ref);
7595
+ const { rotatorRevoked } = await ctx.client.disableRotation(org.id, r.id);
7596
+ console.error(`disabled rotation of ${r.secretName}${rotatorRevoked ? " — rotator key access revoked for this environment" : ""}`);
7597
+ });
7598
+ }
6773
7599
  /**
6774
7600
  * Fetch + decrypt every secret in a single environment.
6775
7601
  *
@@ -7172,6 +7998,77 @@ function assertProvider(value) {
7172
7998
  if (!SYNC_PROVIDER_KINDS.includes(value)) fail(`unknown provider "${value}" — one of: ${SYNC_PROVIDER_KINDS.join(", ")}`);
7173
7999
  return value;
7174
8000
  }
8001
+ /**
8002
+ * Netlify names a site by its **API ID**, and only that.
8003
+ *
8004
+ * Netlify does accept a site's domain where a site appears in a URL path, which
8005
+ * makes the strictness here look gratuitous — but the environment variable
8006
+ * endpoints take the site as a `?site_id=` query parameter, where Netlify
8007
+ * documents no name resolution. A site id it cannot resolve does not fail: the
8008
+ * variables land on the **team**, shared by every site in it. Refusing anything
8009
+ * but the UUID keeps a slip from writing much wider than was asked.
8010
+ */
8011
+ function assertNetlifySite(value) {
8012
+ if (!value) fail("--netlify-site is required for netlify (the site's API ID, a UUID)");
8013
+ const site = value.trim();
8014
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(site)) fail(`--netlify-site "${site}" is not a site API ID — Netlify shows it under Project configuration → General → Project information, and it is a UUID, not the site name or its .netlify.app address`);
8015
+ return site;
8016
+ }
8017
+ /**
8018
+ * A Bunnyshell environment or project ID, as the platform hands it out.
8019
+ *
8020
+ * Checked only for shape, not format: Bunnyshell documents none for these, so
8021
+ * asserting one would be inventing a rule and refusing IDs that work. Both
8022
+ * variable collections name their parent in the **body** of a create, as a
8023
+ * required relation, so an ID Bunnyshell cannot resolve is a 422 naming the
8024
+ * field — not a write that lands somewhere wider. That is why this is loose
8025
+ * where {@link assertNetlifySite} is strict.
8026
+ */
8027
+ function assertBunnyshellId(value, flag) {
8028
+ if (!value) fail(`${flag} is required for bunnyshell (the ID from \`bns\` or the dashboard URL)`);
8029
+ const id = value.trim();
8030
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id)) fail(`${flag} "${id}" is not a Bunnyshell ID — no slashes or spaces`);
8031
+ return id;
8032
+ }
8033
+ /**
8034
+ * Split `--gh-repo owner/name` into the two halves GitHub's paths need.
8035
+ *
8036
+ * Taken as one flag rather than two because `owner/name` is how GitHub writes a
8037
+ * repository everywhere — in its URLs, in `gh repo view`, in every workflow file —
8038
+ * and asking for it in two pieces invites the mistake of pasting the pair into
8039
+ * one of them.
8040
+ */
8041
+ function assertGithubRepo(value) {
8042
+ if (!value) fail("--gh-repo is required for github-actions (owner/name)");
8043
+ const parts = value.trim().split("/");
8044
+ if (parts.length !== 2 || !parts[0] || !parts[1]) fail(`--gh-repo "${value}" should be owner/name, e.g. acme/storefront`);
8045
+ const [owner, repo] = parts;
8046
+ if (!/^[A-Za-z0-9](?:-?[A-Za-z0-9])*$/.test(owner) || owner.length > 39) fail(`--gh-repo owner "${owner}" is not a GitHub login`);
8047
+ if (!/^[A-Za-z0-9._-]+$/.test(repo) || repo === "." || repo === "..") fail(`--gh-repo name "${repo}" is not a repository name`);
8048
+ return {
8049
+ owner,
8050
+ repo
8051
+ };
8052
+ }
8053
+ /** Parse `--gh-repo-ids 1,2,3` — GitHub's org endpoint takes numeric ids, not names. */
8054
+ function assertRepoIds(raw) {
8055
+ const parts = list(raw, "");
8056
+ if (parts.length === 0) fail("--gh-repo-ids is required with `--gh-visibility selected` — numeric repository IDs, which `gh api repos/<owner>/<name> --jq .id` prints");
8057
+ return parts.map((part) => {
8058
+ const id = Number(part);
8059
+ if (!Number.isInteger(id) || id <= 0) fail(`--gh-repo-ids "${part}" is not a repository ID — GitHub takes numeric IDs, not names`);
8060
+ return id;
8061
+ });
8062
+ }
8063
+ /**
8064
+ * What `sync connect` calls the secret it reads from stdin. Only the wording
8065
+ * differs — every provider's credential is wrapped the same way.
8066
+ */
8067
+ function credentialNoun(provider) {
8068
+ if (provider.startsWith("aws-")) return "secret access key";
8069
+ if (provider === "gcp-secret-manager") return "service-account key JSON";
8070
+ return "API token";
8071
+ }
7175
8072
  /** Account-scope config for a connection (never the credential itself). */
7176
8073
  function buildConfig(provider, options) {
7177
8074
  switch (provider) {
@@ -7209,6 +8106,23 @@ function buildConfig(provider, options) {
7209
8106
  case "northflank": return { provider: "northflank" };
7210
8107
  case "digitalocean": return { provider: "digitalocean" };
7211
8108
  case "heroku": return { provider: "heroku" };
8109
+ case "netlify":
8110
+ if (!options.accountId) fail("--account-id is required for netlify — the team slug from app.netlify.com/teams/<slug>, or the account ID");
8111
+ return {
8112
+ provider: "netlify",
8113
+ accountId: options.accountId
8114
+ };
8115
+ case "bunnyshell": return { provider: "bunnyshell" };
8116
+ case "github-actions": return {
8117
+ provider: "github-actions",
8118
+ ...options.baseUrl ? { baseUrl: options.baseUrl } : {}
8119
+ };
8120
+ case "gcp-secret-manager":
8121
+ if (!options.projectId) fail("--project-id is required for gcp-secret-manager — the project ID (or number) whose Secret Manager to write");
8122
+ return {
8123
+ provider: "gcp-secret-manager",
8124
+ projectId: options.projectId
8125
+ };
7212
8126
  }
7213
8127
  }
7214
8128
  /** Where inside the platform a binding writes. */
@@ -7330,6 +8244,83 @@ function buildDestination(provider, options) {
7330
8244
  provider: "heroku",
7331
8245
  app: assertHerokuApp(options.herokuApp)
7332
8246
  };
8247
+ case "netlify": {
8248
+ const contexts = assertMembers(list(options.target, "production"), NETLIFY_CONTEXTS, "--target");
8249
+ const branch = options.gitBranch?.trim();
8250
+ if (contexts.includes("branch") && !branch) fail("--git-branch is required with `--target branch` — the branch the values apply to");
8251
+ return {
8252
+ provider: "netlify",
8253
+ siteId: assertNetlifySite(options.netlifySite),
8254
+ contexts,
8255
+ ...contexts.includes("branch") && branch ? { branch } : {},
8256
+ secret: options.netlifySecret !== false
8257
+ };
8258
+ }
8259
+ case "bunnyshell": {
8260
+ const environmentId = options.bunnyshellEnvironment?.trim();
8261
+ const projectId = options.project?.trim();
8262
+ if (environmentId && projectId) fail("pass --bunnyshell-environment or --project for bunnyshell, not both — a binding writes to one");
8263
+ const secret = options.bunnyshellSecret !== false;
8264
+ if (projectId) return {
8265
+ provider: "bunnyshell",
8266
+ kind: "project",
8267
+ projectId: assertBunnyshellId(projectId, "--project"),
8268
+ secret
8269
+ };
8270
+ return {
8271
+ provider: "bunnyshell",
8272
+ kind: "environment",
8273
+ environmentId: assertBunnyshellId(environmentId, "--bunnyshell-environment"),
8274
+ secret
8275
+ };
8276
+ }
8277
+ case "github-actions": {
8278
+ if (options.ghOrg) {
8279
+ if (options.ghRepo || options.ghEnvironment) fail("--gh-org writes organization secrets — drop --gh-repo and --gh-environment");
8280
+ const visibility = assertMember(options.ghVisibility, GITHUB_ACTIONS_VISIBILITIES, "--gh-visibility", "private");
8281
+ if (visibility !== "selected" && options.ghRepoIds) fail(`--gh-repo-ids only applies to \`--gh-visibility selected\`, not ${visibility}`);
8282
+ return {
8283
+ provider: "github-actions",
8284
+ kind: "org",
8285
+ org: options.ghOrg.trim(),
8286
+ visibility,
8287
+ ...visibility === "selected" ? { selectedRepositoryIds: assertRepoIds(options.ghRepoIds) } : {}
8288
+ };
8289
+ }
8290
+ const { owner, repo } = assertGithubRepo(options.ghRepo);
8291
+ const environment = options.ghEnvironment?.trim();
8292
+ if (!environment) return {
8293
+ provider: "github-actions",
8294
+ kind: "repo",
8295
+ owner,
8296
+ repo
8297
+ };
8298
+ return {
8299
+ provider: "github-actions",
8300
+ kind: "environment",
8301
+ owner,
8302
+ repo,
8303
+ environment
8304
+ };
8305
+ }
8306
+ case "gcp-secret-manager": {
8307
+ const layout = assertMember(options.layout, GCP_SECRET_MANAGER_LAYOUTS, "--layout", "secret-per-name");
8308
+ if (layout === "json-bundle" && !options.secretName) fail("--secret-name is required for --layout json-bundle (the one secret to write)");
8309
+ const replication = assertMember(options.gcpReplication, GCP_REPLICATION_POLICIES, "--gcp-replication", "automatic");
8310
+ const locations = options.gcpLocations ? list(options.gcpLocations, "") : [];
8311
+ if (replication === "user-managed" && locations.length === 0) fail("--gcp-locations is required with --gcp-replication user-managed, e.g. us-east1");
8312
+ if (options.gcpKmsKey && replication === "user-managed" && locations.length > 1) fail("--gcp-kms-key covers one location — use automatic replication, or a single --gcp-locations entry");
8313
+ return {
8314
+ provider: "gcp-secret-manager",
8315
+ layout,
8316
+ replication,
8317
+ ...options.gcpPrefix ? { idPrefix: options.gcpPrefix } : {},
8318
+ ...layout === "json-bundle" && options.secretName ? { secretId: options.secretName } : {},
8319
+ ...replication === "user-managed" ? { locations } : {},
8320
+ ...options.gcpKmsKey ? { kmsKeyName: options.gcpKmsKey } : {},
8321
+ ...options.gcpPruneVersions ? { pruneVersions: true } : {}
8322
+ };
8323
+ }
7333
8324
  }
7334
8325
  }
7335
8326
  /** One-line description of a destination, for list output. */
@@ -7347,6 +8338,14 @@ function describeDestination(destination) {
7347
8338
  case "northflank": return `${destination.projectId} / ${destination.secretGroupId}`;
7348
8339
  case "digitalocean": return `${destination.appId}${destination.kind === "component" ? ` / ${destination.componentName}` : ""} (${destination.scope})`;
7349
8340
  case "heroku": return destination.app;
8341
+ case "netlify": return `${destination.siteId} (${destination.contexts.map((context) => context === "branch" ? `branch @${destination.branch}` : context).join(", ")})`;
8342
+ case "bunnyshell": return destination.kind === "environment" ? `environment ${destination.environmentId}` : `project ${destination.projectId} (inherited by new environments)`;
8343
+ case "gcp-secret-manager": return destination.layout === "json-bundle" ? `${destination.secretId} · json` : `${destination.idPrefix ?? ""}* · one per name`;
8344
+ case "github-actions": switch (destination.kind) {
8345
+ case "repo": return `${destination.owner}/${destination.repo}`;
8346
+ case "environment": return `${destination.owner}/${destination.repo} @${destination.environment}`;
8347
+ case "org": return `org ${destination.org} (${destination.visibility}${destination.selectedRepositoryIds ? `: ${destination.selectedRepositoryIds.length} repos` : ""})`;
8348
+ }
7350
8349
  }
7351
8350
  }
7352
8351
  /**
@@ -7357,7 +8356,7 @@ function describeDestination(destination) {
7357
8356
  * application whose environment the binding reads from.
7358
8357
  */
7359
8358
  function destinationOptions(command) {
7360
- return command.option("--project <id>", "vercel: project id or name · cloudflare-pages / northflank: project name or slug").option("--target <list>", "vercel / cloudflare-pages: comma-separated deployment targets", "production").option("--git-branch <branch>", "vercel: restrict preview writes to one branch").option("--script <name>", "cloudflare-workers: Worker script name").option("--store-id <id>", "cloudflare-secrets-store: store ID (32 hex)").option("--scopes <list>", "cloudflare-secrets-store: comma-separated scopes", "workers").option("--railway-project <id>", "railway: project ID (a UUID)").option("--railway-environment <id>", "railway: environment ID (a UUID)").option("--service <id>", "railway: service ID (omit for the environment's shared variables) · render: service ID (srv-…, or crn-… for a cron job)").option("--skip-deploys", "railway: stage values without triggering a redeploy").option("--path <path>", "aws-parameter-store: hierarchy, e.g. /prod/storefront/ · aws-secrets-manager: name prefix").option("--layout <layout>", `aws-secrets-manager: ${AWS_SECRETS_MANAGER_LAYOUTS.join(" | ")}`).option("--secret-name <name>", "aws-secrets-manager: the secret a json-bundle writes to").option("--param-type <type>", `aws-parameter-store: ${AWS_PARAMETER_TYPES.join(" | ")}`).option("--tier <tier>", `aws-parameter-store: ${AWS_PARAMETER_TIERS.join(" | ")}`).option("--kms-key-id <id>", "aws: customer-managed KMS key id, ARN, or alias").option("--env-group <id>", "render: environment group ID (evg-…)").option("--fly-app <name>", "fly: app name, as `fly apps list` shows it").option("--secret-group <id>", "northflank: secret group ID (the slug in its URL)").option("--do-app <id>", "digitalocean: App Platform app ID (the UUID in its URL)").option("--component <name>", "digitalocean: write to one component's variables (omit for app-level)").option("--env-scope <scope>", `digitalocean: ${DIGITALOCEAN_ENV_SCOPES.join(" | ")}`).option("--heroku-app <name>", "heroku: app name, as `heroku apps` shows it (or its UUID)");
8359
+ return command.option("--project <id>", "vercel: project id or name · cloudflare-pages / northflank: project name or slug · bunnyshell: project ID (writes variables new environments inherit)").option("--target <list>", "vercel / cloudflare-pages: comma-separated deployment targets", "production").option("--git-branch <branch>", "vercel: restrict preview writes to one branch").option("--script <name>", "cloudflare-workers: Worker script name").option("--store-id <id>", "cloudflare-secrets-store: store ID (32 hex)").option("--scopes <list>", "cloudflare-secrets-store: comma-separated scopes", "workers").option("--railway-project <id>", "railway: project ID (a UUID)").option("--railway-environment <id>", "railway: environment ID (a UUID)").option("--service <id>", "railway: service ID (omit for the environment's shared variables) · render: service ID (srv-…, or crn-… for a cron job)").option("--skip-deploys", "railway: stage values without triggering a redeploy").option("--path <path>", "aws-parameter-store: hierarchy, e.g. /prod/storefront/ · aws-secrets-manager: name prefix").option("--layout <layout>", `aws-secrets-manager: ${AWS_SECRETS_MANAGER_LAYOUTS.join(" | ")}`).option("--secret-name <name>", "aws-secrets-manager: the secret a json-bundle writes to").option("--param-type <type>", `aws-parameter-store: ${AWS_PARAMETER_TYPES.join(" | ")}`).option("--tier <tier>", `aws-parameter-store: ${AWS_PARAMETER_TIERS.join(" | ")}`).option("--kms-key-id <id>", "aws: customer-managed KMS key id, ARN, or alias").option("--env-group <id>", "render: environment group ID (evg-…)").option("--fly-app <name>", "fly: app name, as `fly apps list` shows it").option("--secret-group <id>", "northflank: secret group ID (the slug in its URL)").option("--do-app <id>", "digitalocean: App Platform app ID (the UUID in its URL)").option("--component <name>", "digitalocean: write to one component's variables (omit for app-level)").option("--env-scope <scope>", `digitalocean: ${DIGITALOCEAN_ENV_SCOPES.join(" | ")}`).option("--heroku-app <name>", "heroku: app name, as `heroku apps` shows it (or its UUID)").option("--netlify-site <id>", "netlify: site API ID (the UUID under Project configuration)").option("--no-netlify-secret", "netlify: create readable variables instead of Netlify secrets (write-only)").option("--bunnyshell-environment <id>", "bunnyshell: environment ID (omit to write a project)").option("--no-bunnyshell-secret", "bunnyshell: create variables visible in the dashboard instead of secret ones").option("--gh-repo <owner/name>", "github-actions: repository, e.g. acme/storefront").option("--gh-environment <name>", "github-actions: write to one deployment environment's secrets (needs --gh-repo)").option("--gh-org <login>", "github-actions: write organization secrets instead of a repo's").option("--gh-visibility <v>", `github-actions org secrets: ${GITHUB_ACTIONS_VISIBILITIES.join(" | ")}`).option("--gh-repo-ids <ids>", "github-actions: comma-separated numeric repository IDs for --gh-visibility selected").option("--gcp-prefix <prefix>", "gcp: prepended to every secret ID, e.g. prod-storefront-").option("--gcp-replication <policy>", `gcp: ${GCP_REPLICATION_POLICIES.join(" | ")}`, "automatic").option("--gcp-locations <list>", "gcp: regions for user-managed replication, e.g. us-east1").option("--gcp-kms-key <name>", "gcp: Cloud KMS key (projects/…/cryptoKeys/…)").option("--gcp-prune-versions", "gcp: destroy the version each push supersedes, keeping one active version");
7361
8360
  }
7362
8361
  /** Find a connection by id or name — nobody keeps `syc_…` ids in their head. */
7363
8362
  async function resolveConnection(ctx, orgId, ref) {
@@ -7381,11 +8380,11 @@ function registerSyncCommands(program) {
7381
8380
  col("id", (c) => c.id)
7382
8381
  ], "no connections — add one with `seekrit sync connect`"));
7383
8382
  });
7384
- sync.command("connect").description("register a destination account (reads its API token from stdin)").option("--org <slug>").requiredOption("--name <name>", "what to call this account, e.g. acme-vercel").option("--provider <kind>", SYNC_PROVIDER_KINDS.join(" | "), "vercel").option("--team-id <id>", "vercel: Team id (omit for a personal account)").option("--token-kind <kind>", `railway: ${RAILWAY_TOKEN_KINDS.join(" | ")}`, "account").option("--account-id <id>", "cloudflare: account ID (32 hex, from the dashboard sidebar)").option("--region <region>", "aws: region ID, e.g. us-east-1").option("--access-key-id <id>", "aws: IAM access key ID (the secret key is read from stdin)").action(async (options) => {
8383
+ sync.command("connect").description("register a destination account (reads its API token from stdin)").option("--org <slug>").requiredOption("--name <name>", "what to call this account, e.g. acme-vercel").option("--provider <kind>", SYNC_PROVIDER_KINDS.join(" | "), "vercel").option("--team-id <id>", "vercel: Team id (omit for a personal account)").option("--token-kind <kind>", `railway: ${RAILWAY_TOKEN_KINDS.join(" | ")}`, "account").option("--account-id <id>", "cloudflare: account ID (32 hex, from the dashboard sidebar) · netlify: team slug or account ID").option("--region <region>", "aws: region ID, e.g. us-east-1").option("--access-key-id <id>", "aws: IAM access key ID (the secret key is read from stdin)").option("--base-url <url>", "github-actions: GitHub Enterprise Server API root (omit for github.com)").option("--project-id <id>", "gcp: project ID or number whose Secret Manager to write").action(async (options) => {
7385
8384
  const provider = assertProvider(options.provider);
7386
8385
  const ctx = buildContext();
7387
8386
  const ref = await resolveOrg(ctx, options.org);
7388
- const noun = provider.startsWith("aws-") ? "secret access key" : "API token";
8387
+ const noun = credentialNoun(provider);
7389
8388
  const credential = (process.stdin.isTTY ? await promptHidden(`${provider} ${noun}: `) : await readStdin()).trim();
7390
8389
  if (!credential) fail(`no ${noun} given`);
7391
8390
  const id = randomId("syc");
@@ -8249,6 +9248,7 @@ registerAwsCommands(program);
8249
9248
  registerGcpCommands(program);
8250
9249
  registerMongoCommands(program);
8251
9250
  registerKmsCommands(program);
9251
+ registerRotationCommands(program);
8252
9252
  registerRecoveryCommands(program);
8253
9253
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
8254
9254
  const { runMcpServer } = await import("./mcp-DR-zla_u.js");