@seekrit/cli 0.26.0 → 0.28.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 +1928 -293
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { z } from "zod";
5
5
  import { Command } from "commander";
6
- import { homedir, tmpdir } from "node:os";
6
+ import { homedir, hostname, tmpdir, userInfo } from "node:os";
7
7
  import { dirname, join, parse } from "node:path";
8
8
  import { createInterface } from "node:readline";
9
9
  import { Writable } from "node:stream";
@@ -39,6 +39,12 @@ const ENTITLEMENT_KEYS = Object.keys({
39
39
  description: "Single sign-on beyond the built-in providers.",
40
40
  default: true
41
41
  },
42
+ "feature.sync": {
43
+ kind: "feature",
44
+ label: "Third-party sync",
45
+ description: "Push environment secrets to external platforms like Vercel.",
46
+ default: true
47
+ },
42
48
  "apps.max": {
43
49
  kind: "limit",
44
50
  label: "Applications",
@@ -87,6 +93,12 @@ const ENTITLEMENT_KEYS = Object.keys({
87
93
  description: "Maximum registered temporary-access targets.",
88
94
  default: null
89
95
  },
96
+ "sync.connections.max": {
97
+ kind: "limit",
98
+ label: "Sync connections",
99
+ description: "Maximum registered third-party sync destinations.",
100
+ default: null
101
+ },
90
102
  members: {
91
103
  kind: "metered",
92
104
  label: "Members",
@@ -135,7 +147,8 @@ const PLAN_FAMILIES = {
135
147
  }
136
148
  };
137
149
  const PLAN_FAMILY_IDS = Object.keys(PLAN_FAMILIES);
138
- PLAN_FAMILY_IDS.filter((family) => !PLAN_FAMILIES[family].hidden);
150
+ /** Plan families a plan picker should render. Excludes families marked `hidden`. */
151
+ const VISIBLE_PLAN_FAMILY_IDS = PLAN_FAMILY_IDS.filter((family) => !PLAN_FAMILIES[family].hidden);
139
152
  //#endregion
140
153
  //#region ../../packages/core/src/billing.ts
141
154
  /**
@@ -195,6 +208,24 @@ function parseBranchTtl(input) {
195
208
  return value * multiplier;
196
209
  }
197
210
  //#endregion
211
+ //#region ../../packages/core/src/ids.ts
212
+ const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
213
+ /**
214
+ * Generate a prefixed, URL-safe random ID, e.g. `org_h9K2x…`.
215
+ * Uses rejection sampling so every character is uniformly distributed.
216
+ */
217
+ function randomId(prefix, length = 24) {
218
+ let out = "";
219
+ while (out.length < length) {
220
+ const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
221
+ for (const byte of bytes) {
222
+ if (byte < 248) out += ALPHABET[byte % 62];
223
+ if (out.length === length) break;
224
+ }
225
+ }
226
+ return `${prefix}_${out}`;
227
+ }
228
+ //#endregion
198
229
  //#region ../../packages/core/src/interpolate.ts
199
230
  /**
200
231
  * Secret references: `${OTHER_SECRET}` inside a secret value.
@@ -829,6 +860,84 @@ function sshHostSetupInstructions(config) {
829
860
  }
830
861
  //#endregion
831
862
  //#region ../../packages/core/src/types.ts
863
+ const AUDIT_ACTIONS = [
864
+ "org.created",
865
+ "org.updated",
866
+ "org.member_added",
867
+ "org.member_removed",
868
+ "org.member_invited",
869
+ "org.invite_revoked",
870
+ "org.mfa_policy_changed",
871
+ "user.keys_updated",
872
+ "user.notification_prefs_updated",
873
+ "app.created",
874
+ "app.updated",
875
+ "app.deleted",
876
+ "group.created",
877
+ "group.updated",
878
+ "group.deleted",
879
+ "env.created",
880
+ "env.updated",
881
+ "env.deleted",
882
+ "env.group_linked",
883
+ "env.group_unlinked",
884
+ "env.resolved",
885
+ "env.resolve_denied",
886
+ "env.key_granted",
887
+ "env.key_revoked",
888
+ "branch.created",
889
+ "branch.deleted",
890
+ "branch.expired",
891
+ "secret.created",
892
+ "secret.updated",
893
+ "secret.deleted",
894
+ "secret.read",
895
+ "secret.restored",
896
+ "token.created",
897
+ "token.revoked",
898
+ "token.deleted",
899
+ "cli_session.approved",
900
+ "cli_session.denied",
901
+ "cli_session.revoked",
902
+ "m2m.client_created",
903
+ "m2m.client_revoked",
904
+ "lease.target_registered",
905
+ "lease.target_deleted",
906
+ "lease.created",
907
+ "lease.revoked",
908
+ "lease.expired",
909
+ "log_sink.configured",
910
+ "log_sink.deleted",
911
+ "log_sink.test",
912
+ "subscription.updated",
913
+ "subscription.canceled",
914
+ "entitlement.override_set",
915
+ "entitlement.override_cleared",
916
+ "billing.checkout_started",
917
+ "billing.portal_opened",
918
+ "kms.key_created",
919
+ "kms.key_granted",
920
+ "kms.key_revoked",
921
+ "kms.key_rotated",
922
+ "kms.key_disabled",
923
+ "kms.key_deleted",
924
+ "recovery.configured",
925
+ "recovery.rotated",
926
+ "recovery.disabled",
927
+ "recovery.env_wrapped",
928
+ "recovery.requested",
929
+ "recovery.share_contributed",
930
+ "recovery.completed",
931
+ "recovery.canceled",
932
+ "sync.connection_created",
933
+ "sync.connection_updated",
934
+ "sync.connection_deleted",
935
+ "sync.binding_created",
936
+ "sync.binding_updated",
937
+ "sync.binding_deleted",
938
+ "sync.run_succeeded",
939
+ "sync.run_failed"
940
+ ];
832
941
  /**
833
942
  * Transactional notification emails seekrit can send. Each id is one
834
943
  * user-facing on/off toggle (see `NOTIFICATION_TYPE_META`). These carry only
@@ -844,8 +953,57 @@ const NOTIFICATION_TYPES = [
844
953
  "resolve_denied",
845
954
  "org_welcome",
846
955
  "token_expiring",
847
- "lease_expired"
956
+ "lease_expired",
957
+ "sync_failed"
848
958
  ];
959
+ /** UI-facing copy + default for each notification type. */
960
+ const NOTIFICATION_TYPE_META = {
961
+ token_created: {
962
+ label: "Service token created",
963
+ description: "A new service token that can decrypt secrets was created in one of your organizations.",
964
+ defaultEnabled: true
965
+ },
966
+ token_revoked: {
967
+ label: "Service token revoked",
968
+ description: "A service token was revoked in one of your organizations.",
969
+ defaultEnabled: true
970
+ },
971
+ env_access_granted: {
972
+ label: "Environment access granted",
973
+ description: "You were granted access to decrypt an environment's secrets.",
974
+ defaultEnabled: true
975
+ },
976
+ env_access_revoked: {
977
+ label: "Environment access revoked",
978
+ description: "Your access to an environment's secrets was revoked.",
979
+ defaultEnabled: true
980
+ },
981
+ resolve_denied: {
982
+ label: "Denied-access alerts",
983
+ description: "A principal was denied when trying to resolve an environment's secrets (throttled).",
984
+ defaultEnabled: true
985
+ },
986
+ org_welcome: {
987
+ label: "Welcome & key setup",
988
+ description: "You joined an organization — a welcome with next steps for finishing encryption key setup.",
989
+ defaultEnabled: true
990
+ },
991
+ token_expiring: {
992
+ label: "Service token expiring",
993
+ description: "A service token in one of your organizations is expiring within 7 days.",
994
+ defaultEnabled: true
995
+ },
996
+ lease_expired: {
997
+ label: "Temporary credential expired",
998
+ description: "A temporary (leased) credential you own expired and was automatically revoked.",
999
+ defaultEnabled: true
1000
+ },
1001
+ sync_failed: {
1002
+ label: "Third-party sync failed",
1003
+ description: "A sync to an external destination failed repeatedly and stopped retrying. The destination is now holding stale values.",
1004
+ defaultEnabled: true
1005
+ }
1006
+ };
849
1007
  //#endregion
850
1008
  //#region ../../packages/core/src/schemas.ts
851
1009
  /** URL-safe identifier segment: `my-app`, `production`, … */
@@ -1064,6 +1222,16 @@ z.object({
1064
1222
  expiresAt: z.iso.datetime().nullish()
1065
1223
  });
1066
1224
  z.object({ family: planFamilySchema });
1225
+ z.object({
1226
+ sessionId: z.string().regex(/^skc_[0-9A-Za-z]+$/),
1227
+ /** SHA-256 hash (base64url) of the full session token string. */
1228
+ tokenHash: z.string().min(1).max(128),
1229
+ /** Display-only, e.g. `miles@studio.local`. */
1230
+ deviceLabel: z.string().trim().min(1).max(120),
1231
+ /** Display-only, e.g. `cli/0.4.2`. */
1232
+ client: z.string().trim().max(60).optional()
1233
+ });
1234
+ z.object({ code: z.string().trim().min(1).max(32) });
1067
1235
  z.object({
1068
1236
  cursor: z.string().optional(),
1069
1237
  limit: z.coerce.number().int().min(1).max(200).default(50),
@@ -1071,6 +1239,128 @@ z.object({
1071
1239
  resourceType: z.string().optional()
1072
1240
  });
1073
1241
  //#endregion
1242
+ //#region ../../packages/core/src/sync.ts
1243
+ /**
1244
+ * Third-party sync: seekrit as source of truth, pushing an environment's
1245
+ * resolved secrets out to a platform that holds its own copy (Vercel project
1246
+ * env vars, GitHub Actions secrets, a cloud secret manager, …).
1247
+ *
1248
+ * This file is the transport-free vocabulary — provider kinds, connection and
1249
+ * destination shapes, the name-mapping rules, and the request schemas. The
1250
+ * execution side (the per-org `SyncEngine` Durable Object and the connectors)
1251
+ * lives in `apps/api`.
1252
+ *
1253
+ * ## Sync is the one place plaintext exists server-side
1254
+ *
1255
+ * Every other seekrit feature keeps decryption on the caller's machine. A sync
1256
+ * destination needs the plaintext value, and sync runs when nobody is logged
1257
+ * in, so the sync engine must be able to decrypt on its own. That capability is
1258
+ * granted exactly like any other: an `environment_keys` row of principal type
1259
+ * `sync`, whose `principalId` is the **connection id** and whose wrapped DEK is
1260
+ * computed client-side by a key holder. No grant, no sync — the control plane
1261
+ * cannot manufacture one.
1262
+ *
1263
+ * Keying per connection (not per org) is deliberate: one stray grant exposes
1264
+ * one named destination, and deleting a connection destroys its keypair, which
1265
+ * turns every grant to it into dead ciphertext.
1266
+ *
1267
+ * See `docs/third-party-sync.md`.
1268
+ */
1269
+ /** Platforms seekrit can push to. Append-only — persisted in `sync_connections.provider`. */
1270
+ const SYNC_PROVIDER_KINDS = ["vercel"];
1271
+ z.enum(SYNC_PROVIDER_KINDS);
1272
+ /**
1273
+ * Vercel account scope. The API token itself is never here — it is wrapped to
1274
+ * the connection's public key and stored as ciphertext.
1275
+ *
1276
+ * `teamId` is required for tokens scoped to a Vercel Team; personal-account
1277
+ * tokens omit it. Vercel rejects team-owned project calls that lack it with a
1278
+ * bare 403, so we pass it through as `?teamId=` on every request.
1279
+ */
1280
+ const vercelConnectionConfigSchema = z.object({
1281
+ provider: z.literal("vercel"),
1282
+ /** Vercel Team id (`team_…`). Omit for a personal account. */
1283
+ teamId: z.string().trim().min(1).max(128).optional()
1284
+ });
1285
+ const syncConnectionConfigSchema = z.discriminatedUnion("provider", [vercelConnectionConfigSchema]);
1286
+ /** Vercel's three deployment targets. A binding writes to one or more. */
1287
+ const VERCEL_TARGETS = [
1288
+ "production",
1289
+ "preview",
1290
+ "development"
1291
+ ];
1292
+ const vercelDestinationSchema = z.object({
1293
+ provider: z.literal("vercel"),
1294
+ /** Vercel project id (`prj_…`) or project name. */
1295
+ projectId: z.string().trim().min(1).max(128),
1296
+ /** Which deployment targets receive these values. At least one. */
1297
+ targets: z.array(z.enum(VERCEL_TARGETS)).min(1),
1298
+ /**
1299
+ * Restrict `preview` writes to one git branch. Vercel only honors this when
1300
+ * `targets` includes `preview`; ignored otherwise.
1301
+ */
1302
+ gitBranch: z.string().trim().min(1).max(255).optional()
1303
+ });
1304
+ const syncDestinationSchema = z.discriminatedUnion("provider", [vercelDestinationSchema]);
1305
+ /**
1306
+ * How seekrit secret names become destination key names. Applied in order:
1307
+ * explicit `rename` (wins outright), then `prefix`/`suffix`, then `case`.
1308
+ */
1309
+ const nameTransformSchema = z.object({
1310
+ prefix: z.string().max(64).regex(/^[A-Za-z0-9_]*$/, "must be alphanumeric or underscore").optional(),
1311
+ suffix: z.string().max(64).regex(/^[A-Za-z0-9_]*$/, "must be alphanumeric or underscore").optional(),
1312
+ case: z.enum([
1313
+ "preserve",
1314
+ "upper",
1315
+ "lower"
1316
+ ]).optional(),
1317
+ /** Exact per-secret overrides, seekrit name → destination name. */
1318
+ rename: z.record(secretNameSchema, secretNameSchema).optional()
1319
+ });
1320
+ z.object({
1321
+ /**
1322
+ * The id the client already fetched a public key for. Connections are a
1323
+ * two-step dance — mint the key, wrap the credential to it, then create the
1324
+ * row — so the id has to be chosen before the row exists. Omit it and the
1325
+ * server generates one (only useful when there is no credential to wrap yet).
1326
+ */
1327
+ id: z.string().regex(/^syc_[A-Za-z0-9]{24}$/, "must be a connection id from the public-key call").optional(),
1328
+ name: z.string().trim().min(1).max(128),
1329
+ config: syncConnectionConfigSchema,
1330
+ /**
1331
+ * The destination's API credential (a Vercel token), encrypted client-side to
1332
+ * the connection's public key (a `wd1.` wrap). The control plane stores only
1333
+ * this ciphertext; it is unwrapped transiently inside the sync engine DO.
1334
+ */
1335
+ wrappedCredential: z.string().min(1)
1336
+ });
1337
+ z.object({ destination: syncDestinationSchema });
1338
+ const globListSchema = z.array(z.string().trim().min(1).max(256)).max(100);
1339
+ z.object({
1340
+ connectionId: z.string().min(1),
1341
+ environmentId: z.string().min(1),
1342
+ destination: syncDestinationSchema,
1343
+ nameTransform: nameTransformSchema.optional(),
1344
+ include: globListSchema.optional(),
1345
+ exclude: globListSchema.optional(),
1346
+ onDelete: z.enum(["delete", "retain"]).default("delete"),
1347
+ mode: z.enum(["auto", "manual"]).default("auto"),
1348
+ wrappedDeks: z.array(z.object({
1349
+ environmentId: z.string().min(1),
1350
+ wrappedDek: z.string().min(1)
1351
+ })).min(1),
1352
+ acknowledgedDecryption: z.literal(true)
1353
+ });
1354
+ z.object({
1355
+ destination: syncDestinationSchema.optional(),
1356
+ nameTransform: nameTransformSchema.nullable().optional(),
1357
+ include: globListSchema.nullable().optional(),
1358
+ exclude: globListSchema.nullable().optional(),
1359
+ onDelete: z.enum(["delete", "retain"]).optional(),
1360
+ mode: z.enum(["auto", "manual"]).optional(),
1361
+ enabled: z.boolean().optional()
1362
+ });
1363
+ //#endregion
1074
1364
  //#region ../../packages/crypto/src/encoding.ts
1075
1365
  const CHUNK = 32768;
1076
1366
  /** Base64url (no padding) — portable across browsers, Workers, and Node. */
@@ -2139,9 +2429,12 @@ function encodeOpensshPrivateKey(seed, pub, comment) {
2139
2429
  * Format: `skt_<token id>_<private key pkcs8, base64url>`
2140
2430
  */
2141
2431
  const TOKEN_PREFIX = "skt";
2432
+ const CLI_SESSION_PREFIX = "skc";
2142
2433
  const TOKEN_ID_LENGTH = 22;
2143
2434
  const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
2144
- function randomTokenId() {
2435
+ /** 32 bytes of entropy for the CLI session secret. */
2436
+ const CLI_SESSION_SECRET_BYTES = 32;
2437
+ function randomTokenId(prefix = TOKEN_PREFIX) {
2145
2438
  let out = "";
2146
2439
  while (out.length < TOKEN_ID_LENGTH) {
2147
2440
  const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_ID_LENGTH - out.length));
@@ -2150,7 +2443,7 @@ function randomTokenId() {
2150
2443
  if (out.length === TOKEN_ID_LENGTH) break;
2151
2444
  }
2152
2445
  }
2153
- return `${TOKEN_PREFIX}_${out}`;
2446
+ return `${prefix}_${out}`;
2154
2447
  }
2155
2448
  async function hashToken(token) {
2156
2449
  const digest = await crypto.subtle.digest("SHA-256", utf8Encode(token));
@@ -2184,9 +2477,27 @@ async function parseServiceToken(token) {
2184
2477
  function isServiceToken(value) {
2185
2478
  return value.startsWith(`${TOKEN_PREFIX}_`);
2186
2479
  }
2480
+ async function createCliSessionToken() {
2481
+ const sessionId = randomTokenId(CLI_SESSION_PREFIX);
2482
+ const token = `${sessionId}_${toBase64Url(crypto.getRandomValues(new Uint8Array(CLI_SESSION_SECRET_BYTES)))}`;
2483
+ return {
2484
+ token,
2485
+ sessionId,
2486
+ tokenHash: await hashToken(token)
2487
+ };
2488
+ }
2489
+ /** The public `skc_…` id embedded in a CLI session token. */
2490
+ function parseCliSessionToken(token) {
2491
+ const match = /^(skc_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)$/.exec(token);
2492
+ if (!match) throw new SeekritCryptoError("MALFORMED_TOKEN", "not a valid seekrit CLI session token");
2493
+ return { sessionId: match[1] };
2494
+ }
2495
+ function isCliSessionToken(value) {
2496
+ return value.startsWith(`${CLI_SESSION_PREFIX}_`);
2497
+ }
2187
2498
  //#endregion
2188
2499
  //#region package.json
2189
- var version = "0.26.0";
2500
+ var version = "0.28.0";
2190
2501
  //#endregion
2191
2502
  //#region ../../packages/api-client/src/index.ts
2192
2503
  var SeekritApiError = class extends Error {
@@ -2248,6 +2559,32 @@ var SeekritClient = class {
2248
2559
  getMyNotificationPrefs() {
2249
2560
  return this.request("GET", "/v1/me/notifications");
2250
2561
  }
2562
+ /**
2563
+ * Devices this user has authorized. `currentSessionId` is set when the caller
2564
+ * *is* a CLI session, so it can label (or revoke) itself.
2565
+ */
2566
+ listCliSessions() {
2567
+ return this.request("GET", "/v1/me/cli-sessions");
2568
+ }
2569
+ /** Sign a device out. Its token stops authenticating immediately. */
2570
+ revokeCliSession(sessionId) {
2571
+ return this.request("DELETE", `/v1/me/cli-sessions/${sessionId}`);
2572
+ }
2573
+ /** What a pending login request is asking for — for the approval screen. */
2574
+ getCliLoginRequest(code) {
2575
+ return this.request("GET", `/v1/cli-login/${encodeURIComponent(code)}`);
2576
+ }
2577
+ /**
2578
+ * Authorize a device. Requires a browser session; members with a second
2579
+ * factor must have re-entered it just now, else this rejects with
2580
+ * `mfa_required` (recoverable — prompt for a code and retry).
2581
+ */
2582
+ approveCliLogin(code) {
2583
+ return this.request("POST", `/v1/cli-login/${encodeURIComponent(code)}/approve`);
2584
+ }
2585
+ denyCliLogin(code) {
2586
+ return this.request("POST", `/v1/cli-login/${encodeURIComponent(code)}/deny`);
2587
+ }
2251
2588
  setMyNotificationPrefs(input) {
2252
2589
  return this.request("PUT", "/v1/me/notifications", input);
2253
2590
  }
@@ -2525,6 +2862,54 @@ var SeekritClient = class {
2525
2862
  deleteLeaseTarget(orgId, targetId) {
2526
2863
  return this.request("DELETE", `/v1/orgs/${orgId}/leases/targets/${targetId}`);
2527
2864
  }
2865
+ listSyncConnections(orgId) {
2866
+ return this.request("GET", `/v1/orgs/${orgId}/sync/connections`);
2867
+ }
2868
+ /**
2869
+ * Mint (or re-read) the keypair for a connection id, *before* the connection
2870
+ * exists. Wrap the destination credential and every environment DEK to this
2871
+ * key, then pass the same id to {@link createSyncConnection}.
2872
+ */
2873
+ getSyncConnectionKey(orgId, connectionId) {
2874
+ return this.request("GET", `/v1/orgs/${orgId}/sync/connections/${connectionId}/public-key`);
2875
+ }
2876
+ createSyncConnection(orgId, input) {
2877
+ return this.request("POST", `/v1/orgs/${orgId}/sync/connections`, input);
2878
+ }
2879
+ /** Test the stored credential against a destination. Never throws on a bad token. */
2880
+ verifySyncConnection(orgId, connectionId, destination) {
2881
+ return this.request("POST", `/v1/orgs/${orgId}/sync/connections/${connectionId}/verify`, { destination });
2882
+ }
2883
+ /** Deletes the connection, its bindings, its key grants, and its keypair. */
2884
+ deleteSyncConnection(orgId, connectionId) {
2885
+ return this.request("DELETE", `/v1/orgs/${orgId}/sync/connections/${connectionId}`);
2886
+ }
2887
+ listSyncBindings(orgId) {
2888
+ return this.request("GET", `/v1/orgs/${orgId}/sync/bindings`);
2889
+ }
2890
+ /**
2891
+ * Enable sync for one environment. `wrappedDeks` must cover the target
2892
+ * environment *and* every group environment it composes, each wrapped to the
2893
+ * connection's public key — the API cannot compute these, which is what keeps
2894
+ * enabling sync a key-holder operation.
2895
+ */
2896
+ createSyncBinding(orgId, input) {
2897
+ return this.request("POST", `/v1/orgs/${orgId}/sync/bindings`, input);
2898
+ }
2899
+ updateSyncBinding(orgId, bindingId, input) {
2900
+ return this.request("PATCH", `/v1/orgs/${orgId}/sync/bindings/${bindingId}`, input);
2901
+ }
2902
+ deleteSyncBinding(orgId, bindingId) {
2903
+ return this.request("DELETE", `/v1/orgs/${orgId}/sync/bindings/${bindingId}`);
2904
+ }
2905
+ /** Push now, synchronously. */
2906
+ runSyncBinding(orgId, bindingId) {
2907
+ return this.request("POST", `/v1/orgs/${orgId}/sync/bindings/${bindingId}/run`);
2908
+ }
2909
+ listSyncRuns(orgId, bindingId) {
2910
+ const qs = bindingId ? `?bindingId=${encodeURIComponent(bindingId)}` : "";
2911
+ return this.request("GET", `/v1/orgs/${orgId}/sync/runs${qs}`);
2912
+ }
2528
2913
  listLeases(orgId) {
2529
2914
  return this.request("GET", `/v1/orgs/${orgId}/leases`);
2530
2915
  }
@@ -2591,6 +2976,39 @@ var SeekritClient = class {
2591
2976
  return this.request("POST", `/v1/orgs/${orgId}/billing/cancel`);
2592
2977
  }
2593
2978
  };
2979
+ async function unauthenticatedPost(baseUrl, path, body, fetchImpl, client) {
2980
+ const headers = {
2981
+ accept: "application/json",
2982
+ "content-type": "application/json"
2983
+ };
2984
+ if (client) headers["x-seekrit-client"] = client;
2985
+ const res = await fetchImpl(`${baseUrl.replace(/\/$/, "")}${path}`, {
2986
+ method: "POST",
2987
+ headers,
2988
+ body: JSON.stringify(body)
2989
+ });
2990
+ if (!res.ok) {
2991
+ const fallback = { error: {
2992
+ code: "internal",
2993
+ message: `HTTP ${res.status}`
2994
+ } };
2995
+ const payload = await res.json().catch(() => fallback);
2996
+ throw new SeekritApiError(res.status, payload.error?.code ?? "internal", payload.error?.message ?? `HTTP ${res.status}`);
2997
+ }
2998
+ return await res.json();
2999
+ }
3000
+ /**
3001
+ * Open a browser-approved login request. `input.tokenHash` is the SHA-256 of a
3002
+ * session token the caller minted locally and keeps — never send the token.
3003
+ */
3004
+ function startCliLogin(baseUrl, input, options = {}) {
3005
+ return unauthenticatedPost(baseUrl, "/v1/cli-login", input, options.fetch ?? ((...args) => fetch(...args)), options.client);
3006
+ }
3007
+ /** Ask whether a human has authorized the request yet. */
3008
+ function pollCliLogin(baseUrl, code, options = {}) {
3009
+ const fetchImpl = options.fetch ?? ((...args) => fetch(...args));
3010
+ return unauthenticatedPost(baseUrl, "/v1/cli-login/poll", { code }, fetchImpl, options.client);
3011
+ }
2594
3012
  const PROJECT_FILE = "seekrit.json";
2595
3013
  function globalConfigPath() {
2596
3014
  return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
@@ -2600,6 +3018,10 @@ function readGlobalConfig() {
2600
3018
  if (!existsSync(path)) return {};
2601
3019
  return JSON.parse(readFileSync(path, "utf8"));
2602
3020
  }
3021
+ /**
3022
+ * Merge into the saved config. A key set to `undefined` is *removed* (JSON
3023
+ * drops it), which is how the login paths clear a credential they replace.
3024
+ */
2603
3025
  function writeGlobalConfig(update) {
2604
3026
  const path = globalConfigPath();
2605
3027
  const merged = {
@@ -2671,6 +3093,54 @@ function promptHidden(question) {
2671
3093
  });
2672
3094
  });
2673
3095
  }
3096
+ /**
3097
+ * Wait for the user to press Enter (or Ctrl-C). Resolves immediately when stdin
3098
+ * isn't a TTY — a piped or CI invocation has nobody to press a key, and blocking
3099
+ * there would hang `seekrit login` forever.
3100
+ */
3101
+ function promptEnter(question) {
3102
+ if (!process.stdin.isTTY) {
3103
+ process.stderr.write("\n");
3104
+ return Promise.resolve();
3105
+ }
3106
+ process.stderr.write(question);
3107
+ const rl = createInterface({
3108
+ input: process.stdin,
3109
+ output: process.stderr,
3110
+ terminal: true
3111
+ });
3112
+ return new Promise((resolve) => {
3113
+ rl.question("", () => {
3114
+ rl.close();
3115
+ resolve();
3116
+ });
3117
+ });
3118
+ }
3119
+ function askYesNo(question) {
3120
+ const rl = createInterface({
3121
+ input: process.stdin,
3122
+ output: process.stderr,
3123
+ terminal: true
3124
+ });
3125
+ return new Promise((resolve) => {
3126
+ rl.question(`${question} [y/N] `, (answer) => {
3127
+ rl.close();
3128
+ resolve(/^y(es)?$/i.test(answer.trim()));
3129
+ });
3130
+ });
3131
+ }
3132
+ /**
3133
+ * Gate an irreversible action behind an explicit yes.
3134
+ *
3135
+ * The flag skips the question outright. Without it, an interactive run has to
3136
+ * answer; a non-interactive one (a script, CI, an agent) is refused and told
3137
+ * about the flag, because a destructive command must never proceed on silence.
3138
+ */
3139
+ async function confirmDestructive(confirmed, question, flag = "--yes") {
3140
+ if (confirmed) return;
3141
+ if (!process.stdin.isTTY) fail(`${question} Re-run with ${flag} to confirm.`);
3142
+ if (!await askYesNo(question)) fail("aborted");
3143
+ }
2674
3144
  /** Read all of stdin (for `seekrit secrets set NAME -` piping). */
2675
3145
  async function readStdin() {
2676
3146
  const chunks = [];
@@ -2696,7 +3166,7 @@ function tryBuildContext(dotenvVars = {}) {
2696
3166
  const config = readGlobalConfig();
2697
3167
  const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
2698
3168
  const apiUrl = fromEnv("SEEKRIT_API_URL") ?? config.apiUrl ?? "https://api.seekrit.dev";
2699
- const token = fromEnv("SEEKRIT_TOKEN") ?? config.token;
3169
+ const token = fromEnv("SEEKRIT_TOKEN") ?? config.token ?? config.sessionToken;
2700
3170
  const devUser = fromEnv("SEEKRIT_DEV_USER") ?? config.devUser;
2701
3171
  let auth;
2702
3172
  if (token) auth = {
@@ -2719,7 +3189,7 @@ function tryBuildContext(dotenvVars = {}) {
2719
3189
  }
2720
3190
  function buildContext() {
2721
3191
  const ctx = tryBuildContext();
2722
- if (!ctx) fail("no credentials found — run `seekrit login --token skt_…` (or `--client-id … --client-secret …`), or set SEEKRIT_TOKEN / SEEKRIT_CLIENT_ID + SEEKRIT_CLIENT_SECRET / SEEKRIT_DEV_USER");
3192
+ if (!ctx) fail("no credentials found — run `seekrit login` to sign in through your browser (or `seekrit login --token skt_…` / `--client-id … --client-secret …`), or set SEEKRIT_TOKEN / SEEKRIT_CLIENT_ID + SEEKRIT_CLIENT_SECRET / SEEKRIT_DEV_USER");
2723
3193
  return ctx;
2724
3194
  }
2725
3195
  function isTokenAuth(ctx) {
@@ -2744,6 +3214,69 @@ async function getDek(ctx, orgId, envId) {
2744
3214
  return unwrapDek(wrappedDek, privateKey);
2745
3215
  }
2746
3216
  //#endregion
3217
+ //#region src/output.ts
3218
+ /** Shorthand for a column whose header is fixed and value is one field. */
3219
+ function col(header, value) {
3220
+ return {
3221
+ header,
3222
+ value: (row) => stringify(value(row))
3223
+ };
3224
+ }
3225
+ function stringify(value) {
3226
+ if (value === null || value === void 0) return "-";
3227
+ return String(value);
3228
+ }
3229
+ /**
3230
+ * Print `data` as JSON when `--json` was passed, otherwise run `render`.
3231
+ * Keeps the JSON branch identical across commands (and machine-parseable even
3232
+ * for detail views, which have no table form).
3233
+ */
3234
+ function emit(options, data, render) {
3235
+ if (options.json) {
3236
+ console.log(JSON.stringify(data, null, 2));
3237
+ return;
3238
+ }
3239
+ render();
3240
+ }
3241
+ /**
3242
+ * Render rows as a table. `empty` is the stderr note shown when there is
3243
+ * nothing to list — an empty list is a normal answer, not an error, so it must
3244
+ * not go to stdout and must not exit non-zero.
3245
+ */
3246
+ function printTable(rows, columns, empty = "none") {
3247
+ if (rows.length === 0) {
3248
+ console.error(empty);
3249
+ return;
3250
+ }
3251
+ const cells = rows.map((row) => columns.map((c) => c.value(row)));
3252
+ if (!process.stdout.isTTY) {
3253
+ for (const line of cells) console.log(line.join(" "));
3254
+ return;
3255
+ }
3256
+ const widths = columns.map((c, i) => Math.max(c.header.length, ...cells.map((line) => (line[i] ?? "").length)));
3257
+ const format = (line) => line.map((cell, i) => i === line.length - 1 ? cell : cell.padEnd(widths[i] ?? 0)).join(" ");
3258
+ console.log(format(columns.map((c) => c.header.toUpperCase())));
3259
+ for (const line of cells) console.log(format(line));
3260
+ }
3261
+ /**
3262
+ * Render a detail view: one `label value` per line, labels aligned. Null and
3263
+ * undefined values are dropped rather than printed as blanks, so a caller can
3264
+ * build the list unconditionally and let absent fields disappear.
3265
+ */
3266
+ function printFields(fields) {
3267
+ const present = fields.filter(([, value]) => value !== null && value !== void 0);
3268
+ const width = Math.max(0, ...present.map(([label]) => label.length));
3269
+ for (const [label, value] of present) console.log(`${label.padEnd(width)} ${value}`);
3270
+ }
3271
+ /**
3272
+ * A titled break between the sections of a detail view. Detail views are for
3273
+ * reading — a script wanting to parse one should ask for `--json`.
3274
+ */
3275
+ function section(title) {
3276
+ console.log("");
3277
+ console.log(title);
3278
+ }
3279
+ //#endregion
2747
3280
  //#region src/target.ts
2748
3281
  /** Resolve the target org from a flag, the committed config, or a lone org. */
2749
3282
  async function resolveOrg(ctx, orgSlug) {
@@ -2859,144 +3392,149 @@ async function resolveGroup(ctx, opts) {
2859
3392
  };
2860
3393
  }
2861
3394
  //#endregion
2862
- //#region src/aws.ts
3395
+ //#region src/access.ts
2863
3396
  /**
2864
- * `seekrit aws` temporary AWS credentials via STS AssumeRole (Vault-style
2865
- * dynamic secrets, the tier-2 sibling of `seekrit pg`).
3397
+ * Human-readable labels for the principals holding a key grant. `grant list`
3398
+ * would otherwise be a column of opaque ids, which is exactly the question
3399
+ * ("who can read prod?") it exists to answer.
2866
3400
  *
2867
- * Zero-knowledge for the leased credential: minting generates an ephemeral P-256
2868
- * keypair on THIS machine and sends only the public key; STS mints the
2869
- * credential and the broker returns it wrapped to that key, so the control plane
2870
- * only ever relays ciphertext and only this machine can unwrap it. Registering a
2871
- * target wraps the base IAM credential to the broker's public key locally, so
2872
- * the control plane never sees it either — its only needed permission is
2873
- * `sts:AssumeRole` on the target role.
3401
+ * Both lookups are best-effort: `listMembers` is open to any member but
3402
+ * `listTokens` may not be, and a grant can outlive the principal it names.
2874
3403
  */
2875
- /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
2876
- function parseTtlSeconds$6(input) {
2877
- const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
2878
- if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
2879
- return Number(m[1]) * ({
2880
- s: 1,
2881
- m: 60,
2882
- h: 3600,
2883
- d: 86400
2884
- }[m[2] || "s"] ?? 1);
3404
+ async function principalLabels(ctx, orgId) {
3405
+ const labels = /* @__PURE__ */ new Map();
3406
+ const [members, tokens] = await Promise.all([ctx.client.listMembers(orgId).then((r) => r.members).catch(() => []), ctx.client.listTokens(orgId).then((r) => r.tokens).catch(() => [])]);
3407
+ for (const m of members) labels.set(m.userId, m.email);
3408
+ for (const t of tokens) labels.set(t.id, t.name);
3409
+ return labels;
3410
+ }
3411
+ /** Turn `--user email` / `--token skt_…` into the principal to wrap a DEK to. */
3412
+ async function resolvePrincipal(ctx, orgId, options) {
3413
+ if (!options.user === !options.token) fail("pass exactly one of --user or --token");
3414
+ if (options.user) {
3415
+ const { members } = await ctx.client.listMembers(orgId);
3416
+ const member = members.find((m) => m.email === options.user);
3417
+ if (!member) fail(`no member ${options.user}`);
3418
+ if (!member.publicKeyJwk) fail(`${options.user} has not completed key setup`);
3419
+ return {
3420
+ type: "user",
3421
+ id: member.userId,
3422
+ publicKeyJwk: member.publicKeyJwk
3423
+ };
3424
+ }
3425
+ const { tokens } = await ctx.client.listTokens(orgId);
3426
+ const token = tokens.find((t) => t.id === options.token);
3427
+ if (!token) fail(`no service token ${options.token}`);
3428
+ return {
3429
+ type: "service_token",
3430
+ id: token.id,
3431
+ publicKeyJwk: token.publicKeyJwk
3432
+ };
3433
+ }
3434
+ /** The `--org/--app/--group/--env` selection every grant command shares. */
3435
+ function withEnvTarget(cmd) {
3436
+ return cmd.option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--group <slug>", "target a group environment instead of an app").requiredOption("--env <slug>");
2885
3437
  }
2886
3438
  /**
2887
- * The base IAM credential the broker assumes the role with. From flags or the
2888
- * standard AWS env vars; JSON-serialized so the executor can parse it. Never the
2889
- * plaintext leaves this machine unwrapped it is wrapped to the broker key.
3439
+ * Environment key grants who can decrypt what.
3440
+ *
3441
+ * `seekrit grant` keeps working as the bare "give access" verb (it is the
3442
+ * default subcommand), with `list` and `rm` alongside it so access can be
3443
+ * audited and taken back from the CLI rather than only the dashboard.
2890
3444
  */
2891
- function resolveBaseCredential(opts) {
2892
- const accessKeyId = opts.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID;
2893
- const secretAccessKey = opts.secretAccessKey ?? process.env.AWS_SECRET_ACCESS_KEY;
2894
- const sessionToken = process.env.AWS_SESSION_TOKEN;
2895
- if (!accessKeyId || !secretAccessKey) fail("provide the base IAM credential via --access-key-id/--secret-access-key or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY (it needs only sts:AssumeRole on the role)");
2896
- return JSON.stringify({
2897
- accessKeyId,
2898
- secretAccessKey,
2899
- ...sessionToken ? { sessionToken } : {}
2900
- });
2901
- }
2902
- function registerAwsCommands(program) {
2903
- const aws = program.command("aws").description("temporary AWS credentials (STS AssumeRole, zero-knowledge)");
2904
- const target = aws.command("target").description("manage AWS role targets");
2905
- target.command("add").description("register an assumable IAM role to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-deploy").requiredOption("--role-arn <arn>", "the IAM role to assume, arn:aws:iam::<acct>:role/<name>").requiredOption("--region <region>", "region whose STS endpoint to call, e.g. us-east-1").option("--org <slug>").option("--external-id <id>", "STS ExternalId the role's trust policy requires").option("--session-policy <file>", "path to an inline session policy JSON (further restricts)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--access-key-id <id>", "base IAM access key id (else AWS_ACCESS_KEY_ID)").option("--secret-access-key <secret>", "base IAM secret (else AWS_SECRET_ACCESS_KEY)").action(async (options) => {
3445
+ function registerAccessCommands(program) {
3446
+ const grant = program.command("grant").description("manage who can decrypt an environment (`seekrit grant --help`)");
3447
+ withEnvTarget(grant.command("add", { isDefault: true }).description("give a member or service token access to an environment's key").option("--user <email>", "grant to an org member by email").option("--token <tokenId>", "grant to a service token by id (skt_…)")).action(async (options) => {
3448
+ if (!options.user === !options.token) fail("pass exactly one of --user or --token");
2906
3449
  const ctx = buildContext();
2907
- const org = await resolveOrg(ctx, options.org);
2908
- const sessionPolicy = options.sessionPolicy ? readFileSync(options.sessionPolicy, "utf8").trim() : void 0;
2909
- const config = {
2910
- provider: "aws",
2911
- executor: "in_do",
2912
- roleArn: options.roleArn,
2913
- region: options.region,
2914
- ...options.externalId ? { externalId: options.externalId } : {},
2915
- ...sessionPolicy ? { sessionPolicy } : {},
2916
- ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$6(options.maxTtl) } : {}
2917
- };
2918
- const baseCredential = resolveBaseCredential(options);
2919
- const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
2920
- const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(baseCredential), publicKeyJwk);
2921
- const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
2922
- name: options.name,
2923
- config,
2924
- wrappedAdminSecret
3450
+ const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
3451
+ const principal = await resolvePrincipal(ctx, orgId, options);
3452
+ const dek = await getDek(ctx, orgId, envId);
3453
+ await ctx.client.grantEnvKey(orgId, envId, {
3454
+ principalType: principal.type,
3455
+ principalId: principal.id,
3456
+ wrappedDek: await wrapDek(dek, principal.publicKeyJwk)
2925
3457
  });
2926
- console.error(`registered AWS target ${created.name} (${created.id})`);
2927
- console.error("\nEnsure the role trusts the admin principal, then `seekrit aws lease`:\n");
2928
- console.log(awsTrustPolicyInstructions(config));
3458
+ console.error(`granted ${label} access to ${principal.id}`);
2929
3459
  });
2930
- target.command("list").description("list AWS role targets").option("--org <slug>").action(async (options) => {
3460
+ withEnvTarget(grant.command("list").alias("ls").description("list who holds a key for an environment").option("--json", "print the raw API response")).action(async (options) => {
2931
3461
  const ctx = buildContext();
2932
- const org = await resolveOrg(ctx, options.org);
2933
- const { targets } = await ctx.client.listLeaseTargets(org.id);
2934
- for (const t of targets) {
2935
- const cfg = t.config;
2936
- if (cfg.provider !== "aws") continue;
2937
- console.log(`${t.id}\t${t.name}\t${cfg.region}\t${cfg.roleArn}`);
2938
- }
3462
+ const { orgId, envId } = await resolveEnvTarget(ctx, options);
3463
+ const [{ grants }, labels] = await Promise.all([ctx.client.listEnvKeys(orgId, envId), principalLabels(ctx, orgId)]);
3464
+ emit(options, { grants }, () => printTable(grants, [
3465
+ col("principal", (g) => labels.get(g.principalId) ?? g.principalId),
3466
+ col("type", (g) => g.principalType === "service_token" ? "token" : "user"),
3467
+ col("granted", (g) => g.createdAt),
3468
+ col("id", (g) => g.principalId)
3469
+ ], "nobody holds a key for this environment"));
2939
3470
  });
2940
- target.command("trust <targetId>").description("reprint the IAM trust-policy setup for an AWS target").option("--org <slug>").action(async (targetId, options) => {
3471
+ withEnvTarget(grant.command("rm").alias("revoke").description("take away a member's or token's access to an environment's key").option("--user <email>", "revoke an org member by email").option("--token <tokenId>", "revoke a service token by id (skt_…)").option("--yes", "skip the confirmation prompt")).action(async (options) => {
3472
+ if (!options.user === !options.token) fail("pass exactly one of --user or --token");
2941
3473
  const ctx = buildContext();
2942
- const org = await resolveOrg(ctx, options.org);
2943
- const { targets } = await ctx.client.listLeaseTargets(org.id);
2944
- const t = targets.find((x) => x.id === targetId || x.name === targetId);
2945
- if (!t) fail(`no target "${targetId}" in ${org.slug}`);
2946
- const cfg = t.config;
2947
- if (cfg.provider !== "aws") fail("not an aws target (see `seekrit pg`/`seekrit ssh`)");
2948
- console.log(awsTrustPolicyInstructions(cfg));
3474
+ const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
3475
+ const { grants } = await ctx.client.listEnvKeys(orgId, envId);
3476
+ let principalId = options.token;
3477
+ if (options.user) {
3478
+ const { members } = await ctx.client.listMembers(orgId);
3479
+ principalId = members.find((m) => m.email === options.user)?.userId;
3480
+ if (!principalId) fail(`no member ${options.user}`);
3481
+ }
3482
+ const grantRow = grants.find((g) => g.principalId === principalId);
3483
+ if (!grantRow) fail(`${options.user ?? options.token} holds no key for ${label}`);
3484
+ await confirmDestructive(options.yes, `Revoke ${options.user ?? options.token}'s key for ${label}? Anything already decrypted stays decrypted.`);
3485
+ await ctx.client.revokeEnvKey(orgId, envId, grantRow.id);
3486
+ console.error(`revoked ${options.user ?? options.token}'s access to ${label}`);
2949
3487
  });
2950
- target.command("rm <targetId>").description("delete an AWS role target").option("--org <slug>").action(async (targetId, options) => {
2951
- const ctx = buildContext();
2952
- const org = await resolveOrg(ctx, options.org);
2953
- await ctx.client.deleteLeaseTarget(org.id, targetId);
2954
- console.error(`deleted ${targetId}`);
3488
+ }
3489
+ //#endregion
3490
+ //#region src/account.ts
3491
+ function sessionStatus(session) {
3492
+ if (session.revokedAt) return "revoked";
3493
+ if (session.expiresAt && Date.parse(session.expiresAt) < Date.now()) return "expired";
3494
+ return "active";
3495
+ }
3496
+ /**
3497
+ * Your own account: the devices signed in as you, and which emails seekrit
3498
+ * sends you. Both are per-user (not per-org), so neither takes `--org`.
3499
+ */
3500
+ function registerAccountCommands(program) {
3501
+ const session = program.command("session").description("manage the devices authorized by `seekrit login`");
3502
+ session.command("list").alias("ls").description("list CLI sessions authorized for your account").option("--all", "include revoked and expired sessions").option("--json", "print the raw API response").action(async (options) => {
3503
+ const { sessions, currentSessionId } = await buildContext().client.listCliSessions();
3504
+ const rows = options.all ? sessions : sessions.filter((s) => sessionStatus(s) === "active");
3505
+ emit(options, {
3506
+ sessions: rows,
3507
+ currentSessionId
3508
+ }, () => printTable(rows, [
3509
+ col("device", (s) => s.deviceLabel),
3510
+ col("client", (s) => s.client),
3511
+ col("status", (s) => sessionStatus(s)),
3512
+ col("last used", (s) => s.lastUsedAt),
3513
+ col("ip", (s) => s.ipAddress),
3514
+ col("id", (s) => `${s.id}${s.id === currentSessionId ? " (this one)" : ""}`)
3515
+ ], options.all ? "no CLI sessions" : "no active CLI sessions (try --all)"));
2955
3516
  });
2956
- aws.command("lease <target>").description("mint short-lived AWS credentials; prints ready-to-source export lines").option("--org <slug>").option("--ttl <duration>", "credential lifetime, e.g. 15m, 1h, 12h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
3517
+ session.command("revoke <sessionId>").description("sign a device out its token stops working immediately").option("--yes", "skip the confirmation prompt").action(async (sessionId, options) => {
2957
3518
  const ctx = buildContext();
2958
- const org = await resolveOrg(ctx, options.org);
2959
- const { targets } = await ctx.client.listLeaseTargets(org.id);
2960
- const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
2961
- if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
2962
- const cfg = t.config;
2963
- if (cfg.provider !== "aws") fail(`"${t.name}" is not an aws target (see \`seekrit pg\`)`);
2964
- const ttlSeconds = parseTtlSeconds$6(options.ttl);
2965
- if (ttlSeconds < 900) fail(`--ttl must be at least ${900 / 60}m (STS minimum)`);
2966
- const recipient = await generateAwsRecipientKeyPair();
2967
- const { aws: leased } = await ctx.client.mintLease(org.id, {
2968
- provider: "aws",
2969
- targetId: t.id,
2970
- recipientPublicKey: recipient.publicKeyJwk,
2971
- ttlSeconds
2972
- });
2973
- const cred = await unwrapAwsCredential(leased.wrappedCredential, recipient.privateKeyJwk);
2974
- console.error(`leased ${cfg.roleArn} in ${cred.region} — expires ${cred.expiration}`);
2975
- if (options.json) console.log(JSON.stringify({
2976
- ...cred,
2977
- roleArn: cfg.roleArn
2978
- }, null, 2));
2979
- else {
2980
- console.log(`export AWS_ACCESS_KEY_ID=${cred.accessKeyId}`);
2981
- console.log(`export AWS_SECRET_ACCESS_KEY=${cred.secretAccessKey}`);
2982
- console.log(`export AWS_SESSION_TOKEN=${cred.sessionToken}`);
2983
- console.log(`export AWS_REGION=${cred.region}`);
2984
- }
3519
+ const self = ctx.auth.type === "bearer" && isCliSessionToken(ctx.auth.token) && parseCliSessionToken(ctx.auth.token).sessionId === sessionId;
3520
+ await confirmDestructive(options.yes, self ? `${sessionId} is the session you are using right now — sign it out?` : `Sign out ${sessionId}?`);
3521
+ await ctx.client.revokeCliSession(sessionId);
3522
+ console.error(`${sessionId} revoked${self ? " — run `seekrit login` to sign back in" : ""}`);
2985
3523
  });
2986
- aws.command("leases").description("list AWS leases (the ledger never secret material)").option("--org <slug>").action(async (options) => {
2987
- const ctx = buildContext();
2988
- const org = await resolveOrg(ctx, options.org);
2989
- const { leases } = await ctx.client.listLeases(org.id);
2990
- for (const l of leases) {
2991
- if (l.provider !== "aws") continue;
2992
- console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
2993
- }
3524
+ const notifications = program.command("notifications").description("manage the emails seekrit sends you");
3525
+ notifications.command("list", { isDefault: true }).alias("ls").description("show your notification preferences").option("--json", "print the raw API response").action(async (options) => {
3526
+ const { prefs } = await buildContext().client.getMyNotificationPrefs();
3527
+ emit(options, { prefs }, () => printTable(NOTIFICATION_TYPES, [
3528
+ col("type", (t) => t),
3529
+ col("email", (t) => prefs[t] ? "on" : "off"),
3530
+ col("about", (t) => NOTIFICATION_TYPE_META[t].label)
3531
+ ]));
2994
3532
  });
2995
- aws.command("revoke <leaseId>").description("mark a lease revoked in the ledger (STS credentials stay valid until they expire)").option("--org <slug>").action(async (leaseId, options) => {
2996
- const ctx = buildContext();
2997
- const org = await resolveOrg(ctx, options.org);
2998
- await ctx.client.revokeLease(org.id, leaseId);
2999
- console.error(`revoked ${leaseId} (issued credentials remain valid until they expire)`);
3533
+ notifications.command("set <type> <state>").description("turn one notification on or off (`state` is on or off)").action(async (type, state) => {
3534
+ if (state !== "on" && state !== "off") fail("state must be on or off");
3535
+ if (!NOTIFICATION_TYPES.includes(type)) fail(`unknown notification "${type}" — one of: ${NOTIFICATION_TYPES.join(", ")}`);
3536
+ await buildContext().client.setMyNotificationPrefs({ prefs: { [type]: state === "on" } });
3537
+ console.error(`${type} emails ${state}`);
3000
3538
  });
3001
3539
  }
3002
3540
  //#endregion
@@ -3485,6 +4023,477 @@ function registerRecoveryCommands(program) {
3485
4023
  });
3486
4024
  }
3487
4025
  //#endregion
4026
+ //#region src/apps.ts
4027
+ /**
4028
+ * Applications and their environments — the structure everything else hangs
4029
+ * off. Creating an environment is the one command here that does crypto: the
4030
+ * data key is generated locally and only ciphertext leaves the machine.
4031
+ */
4032
+ function registerAppCommands(program) {
4033
+ const app = program.command("app").description("manage applications");
4034
+ app.command("list").alias("ls").description("list applications in an organization").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
4035
+ const ctx = buildContext();
4036
+ const ref = await resolveOrg(ctx, options.org);
4037
+ const { apps } = await ctx.client.listApps(ref.id);
4038
+ emit(options, { apps }, () => printTable(apps, [
4039
+ col("slug", (a) => a.slug),
4040
+ col("name", (a) => a.name),
4041
+ col("created", (a) => a.createdAt),
4042
+ col("id", (a) => a.id)
4043
+ ], "no applications — create one with `seekrit app create`"));
4044
+ });
4045
+ app.command("show [slug]").description("show an application, its environments, and your access to each").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--json", "print the raw API response").action(async (slug, options) => {
4046
+ const ctx = buildContext();
4047
+ const ref = await resolveApp(ctx, {
4048
+ org: options.org,
4049
+ app: slug ?? options.app
4050
+ });
4051
+ const [{ app: row, environments }, { branches }] = await Promise.all([ctx.client.getApp(ref.orgId, ref.id), ctx.client.listAppBranches(ref.orgId, ref.id)]);
4052
+ emit(options, {
4053
+ app: row,
4054
+ environments,
4055
+ branches
4056
+ }, () => {
4057
+ printFields([
4058
+ ["slug", row.slug],
4059
+ ["name", row.name],
4060
+ ["id", row.id],
4061
+ ["org", ref.orgSlug],
4062
+ ["created", row.createdAt]
4063
+ ]);
4064
+ section("environments");
4065
+ printTable(environments, [
4066
+ col("slug", (e) => e.slug),
4067
+ col("name", (e) => e.name),
4068
+ col("access", (e) => e.canDecrypt ? "can decrypt" : "no key"),
4069
+ col("id", (e) => e.id)
4070
+ ], "no environments — create one with `seekrit env create`");
4071
+ if (branches.length > 0) {
4072
+ section("branches");
4073
+ printTable(branches, [
4074
+ col("slug", (b) => b.slug),
4075
+ col("expires", (b) => b.expiresAt ?? "never"),
4076
+ col("id", (b) => b.id)
4077
+ ]);
4078
+ }
4079
+ });
4080
+ });
4081
+ app.command("create").description("create an application").option("--org <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
4082
+ const ctx = buildContext();
4083
+ const orgRef = await resolveOrg(ctx, options.org);
4084
+ const created = await ctx.client.createApp(orgRef.id, {
4085
+ name: options.name,
4086
+ slug: options.slug
4087
+ });
4088
+ console.error(`created app ${created.app.slug} (${created.app.id})`);
4089
+ });
4090
+ app.command("rename [slug]").description("change an application's display name (the slug is permanent)").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").requiredOption("--name <name>", "new display name").action(async (slug, options) => {
4091
+ const ctx = buildContext();
4092
+ const ref = await resolveApp(ctx, {
4093
+ org: options.org,
4094
+ app: slug ?? options.app
4095
+ });
4096
+ const { app: row } = await ctx.client.updateApp(ref.orgId, ref.id, { name: options.name });
4097
+ console.error(`renamed ${row.slug} to "${row.name}"`);
4098
+ });
4099
+ app.command("rm <slug>").alias("delete").description("delete an application and every environment and secret in it").option("--org <slug>").option("--yes", "skip the confirmation prompt").action(async (slug, options) => {
4100
+ const ctx = buildContext();
4101
+ const ref = await resolveApp(ctx, {
4102
+ org: options.org,
4103
+ app: slug
4104
+ });
4105
+ const { environments } = await ctx.client.getApp(ref.orgId, ref.id);
4106
+ await confirmDestructive(options.yes, `Delete ${ref.slug} and its ${environments.length} environment(s), with every secret in them?`);
4107
+ await ctx.client.deleteApp(ref.orgId, ref.id);
4108
+ console.error(`deleted app ${ref.slug}`);
4109
+ });
4110
+ const env = program.command("env").description("manage environments");
4111
+ env.command("list").alias("ls").description("list an application's environments").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--json", "print the raw API response").action(async (options) => {
4112
+ const ctx = buildContext();
4113
+ const ref = await resolveApp(ctx, options);
4114
+ const { environments } = await ctx.client.getApp(ref.orgId, ref.id);
4115
+ emit(options, { environments }, () => printTable(environments, [
4116
+ col("slug", (e) => e.slug),
4117
+ col("name", (e) => e.name),
4118
+ col("access", (e) => e.canDecrypt ? "can decrypt" : "no key"),
4119
+ col("id", (e) => e.id)
4120
+ ], "no environments — create one with `seekrit env create`"));
4121
+ });
4122
+ env.command("show").description("show one environment: composed groups, who holds a key, secret count").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").requiredOption("--env <slug>").option("--json", "print the raw API response").action(async (options) => {
4123
+ const ctx = buildContext();
4124
+ const target = await resolveAppEnv(ctx, options);
4125
+ const [{ environment }, { groups }, { secrets }, { branches }] = await Promise.all([
4126
+ ctx.client.getEnv(target.orgId, target.envId),
4127
+ ctx.client.listEnvGroups(target.orgId, target.envId),
4128
+ ctx.client.listSecrets(target.orgId, target.envId),
4129
+ ctx.client.listBranches(target.orgId, target.envId)
4130
+ ]);
4131
+ const grants = await ctx.client.listEnvKeys(target.orgId, target.envId).then((r) => r.grants).catch(() => null);
4132
+ emit(options, {
4133
+ environment,
4134
+ groups,
4135
+ secretCount: secrets.length,
4136
+ branches,
4137
+ grants
4138
+ }, () => {
4139
+ printFields([
4140
+ ["slug", environment.slug],
4141
+ ["name", environment.name],
4142
+ ["id", environment.id],
4143
+ ["app", target.appSlug],
4144
+ ["secrets", secrets.length],
4145
+ ["created", environment.createdAt]
4146
+ ]);
4147
+ section("composed groups (lowest precedence first)");
4148
+ printTable([...groups].sort((a, b) => a.position - b.position), [
4149
+ col("position", (g) => g.position),
4150
+ col("group", (g) => g.slug),
4151
+ col("name", (g) => g.name)
4152
+ ], "none — this environment's secrets are all its own");
4153
+ section("key holders");
4154
+ if (grants === null) console.error("(admin only)");
4155
+ else printTable(grants, [col("type", (g) => g.principalType), col("principal", (g) => g.principalId)], "none");
4156
+ if (branches.length > 0) {
4157
+ section("branches");
4158
+ printTable(branches, [col("slug", (b) => b.slug), col("expires", (b) => b.expiresAt ?? "never")]);
4159
+ }
4160
+ });
4161
+ });
4162
+ env.command("create").description("create an application environment (generates its data key locally)").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
4163
+ const ctx = buildContext();
4164
+ const orgRef = await resolveOrg(ctx, options.org);
4165
+ const { apps } = await ctx.client.listApps(orgRef.id);
4166
+ const appRow = apps.find((a) => a.slug === options.app || a.id === options.app);
4167
+ if (!appRow) fail(`no app "${options.app}" in ${orgRef.slug}`);
4168
+ const { user } = await ctx.client.me();
4169
+ if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
4170
+ const dek = generateDek();
4171
+ const wrappedDek = await wrapDek(dek, user.publicKeyJwk);
4172
+ const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, orgRef.id, dek);
4173
+ const created = await ctx.client.createEnv(orgRef.id, appRow.id, {
4174
+ name: options.name,
4175
+ slug: options.slug,
4176
+ wrappedDek,
4177
+ recoveryWrappedDek
4178
+ });
4179
+ console.error(`created environment ${created.environment.slug} (${created.environment.id})`);
4180
+ });
4181
+ env.command("rm").alias("delete").description("delete an environment and every secret in it").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--group <slug>", "delete a group environment instead of an app one").requiredOption("--env <slug>").option("--yes", "skip the confirmation prompt").action(async (options) => {
4182
+ const ctx = buildContext();
4183
+ const target = await resolveEnvTarget(ctx, options);
4184
+ const { secrets } = await ctx.client.listSecrets(target.orgId, target.envId);
4185
+ await confirmDestructive(options.yes, `Delete ${target.label} and the ${secrets.length} secret(s) in it?`);
4186
+ await ctx.client.deleteEnv(target.orgId, target.envId);
4187
+ console.error(`deleted ${target.label}`);
4188
+ });
4189
+ const envGroups = env.command("groups").description("compose shared groups into an application environment");
4190
+ envGroups.command("add").description("compose a group into an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").requiredOption("--group <slug>").option("--position <n>", "precedence among groups (higher wins)").action(async (options) => {
4191
+ const ctx = buildContext();
4192
+ const target = await resolveAppEnv(ctx, options);
4193
+ const group = await resolveGroup(ctx, {
4194
+ org: options.org,
4195
+ group: options.group
4196
+ });
4197
+ await ctx.client.linkEnvGroup(target.orgId, target.envId, {
4198
+ groupId: group.id,
4199
+ position: options.position === void 0 ? void 0 : Number.parseInt(options.position, 10)
4200
+ });
4201
+ console.error(`composed ${group.slug} into ${target.appSlug}/${target.envSlug}`);
4202
+ });
4203
+ envGroups.command("list").alias("ls").description("list groups composed into an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").option("--json", "print the raw API response").action(async (options) => {
4204
+ const ctx = buildContext();
4205
+ const target = await resolveAppEnv(ctx, options);
4206
+ const { groups } = await ctx.client.listEnvGroups(target.orgId, target.envId);
4207
+ emit(options, { groups }, () => printTable([...groups].sort((a, b) => a.position - b.position), [
4208
+ col("position", (g) => g.position),
4209
+ col("group", (g) => g.slug),
4210
+ col("name", (g) => g.name)
4211
+ ], "no groups composed into this environment"));
4212
+ });
4213
+ envGroups.command("rm").description("remove a group from an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").requiredOption("--group <slug>").action(async (options) => {
4214
+ const ctx = buildContext();
4215
+ const target = await resolveAppEnv(ctx, options);
4216
+ const group = await resolveGroup(ctx, {
4217
+ org: options.org,
4218
+ group: options.group
4219
+ });
4220
+ await ctx.client.unlinkEnvGroup(target.orgId, target.envId, group.id);
4221
+ console.error(`removed ${group.slug} from ${target.appSlug}/${target.envSlug}`);
4222
+ });
4223
+ }
4224
+ //#endregion
4225
+ //#region src/audit.ts
4226
+ /** The API's per-page ceiling (`auditQuerySchema.limit`). */
4227
+ const MAX_PAGE = 200;
4228
+ function parseLimit(raw) {
4229
+ const n = Number(raw);
4230
+ if (!Number.isInteger(n) || n < 1) fail(`--limit must be a positive whole number, got "${raw}"`);
4231
+ return Math.min(n, MAX_PAGE);
4232
+ }
4233
+ /**
4234
+ * The org audit trail. Append-only and admin-only, so this is the record of
4235
+ * who did what — the CLI needs the same filters the dashboard has, plus
4236
+ * pagination, or "find the grant that shouldn't be there" means scrolling.
4237
+ */
4238
+ function registerAuditCommands(program) {
4239
+ const audit = program.command("audit").description("read the org audit trail");
4240
+ audit.command("list", { isDefault: true }).alias("ls").description("show the org audit trail").option("--org <slug>").option("--limit <n>", `entries per page (max ${MAX_PAGE})`, "50").option("--action <action>", "only this action, e.g. env.key_granted").option("--resource-type <type>", "only this resource type, e.g. environment").option("--cursor <cursor>", "continue from a previous page's cursor").option("--all", "page through the whole trail, not just the first page").option("--metadata", "include each entry's metadata as JSON").option("--json", "print the raw API response").action(async (options) => {
4241
+ if (options.action && !AUDIT_ACTIONS.includes(options.action)) fail(`unknown action "${options.action}" — see \`seekrit audit actions\``);
4242
+ const ctx = buildContext();
4243
+ const ref = await resolveOrg(ctx, options.org);
4244
+ const limit = parseLimit(options.limit);
4245
+ const entries = [];
4246
+ let cursor = options.cursor;
4247
+ let nextCursor = null;
4248
+ do {
4249
+ const page = await ctx.client.listAudit(ref.id, {
4250
+ limit,
4251
+ cursor,
4252
+ action: options.action,
4253
+ resourceType: options.resourceType
4254
+ });
4255
+ entries.push(...page.entries);
4256
+ nextCursor = page.nextCursor;
4257
+ cursor = page.nextCursor ?? void 0;
4258
+ } while (options.all && cursor);
4259
+ emit(options, {
4260
+ entries,
4261
+ nextCursor
4262
+ }, () => {
4263
+ printTable(entries, [
4264
+ col("when", (e) => e.createdAt),
4265
+ col("action", (e) => e.action),
4266
+ col("actor", (e) => `${e.actorType}:${e.actorId}`),
4267
+ col("resource", (e) => `${e.resourceType}${e.resourceId ? `:${e.resourceId}` : ""}`),
4268
+ ...options.metadata ? [col("metadata", (e) => e.metadata ? JSON.stringify(e.metadata) : "-")] : []
4269
+ ], "no audit entries match");
4270
+ if (nextCursor && !options.all) console.error(`more entries — continue with --cursor ${nextCursor} (or pass --all)`);
4271
+ });
4272
+ });
4273
+ audit.command("actions").description("list every action the audit trail can record (for `audit --action`)").option("--json", "print the raw API response").action((options) => {
4274
+ emit(options, { actions: AUDIT_ACTIONS }, () => {
4275
+ for (const action of AUDIT_ACTIONS) console.log(action);
4276
+ });
4277
+ });
4278
+ }
4279
+ //#endregion
4280
+ //#region src/aws.ts
4281
+ /**
4282
+ * `seekrit aws` — temporary AWS credentials via STS AssumeRole (Vault-style
4283
+ * dynamic secrets, the tier-2 sibling of `seekrit pg`).
4284
+ *
4285
+ * Zero-knowledge for the leased credential: minting generates an ephemeral P-256
4286
+ * keypair on THIS machine and sends only the public key; STS mints the
4287
+ * credential and the broker returns it wrapped to that key, so the control plane
4288
+ * only ever relays ciphertext and only this machine can unwrap it. Registering a
4289
+ * target wraps the base IAM credential to the broker's public key locally, so
4290
+ * the control plane never sees it either — its only needed permission is
4291
+ * `sts:AssumeRole` on the target role.
4292
+ */
4293
+ /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
4294
+ function parseTtlSeconds$6(input) {
4295
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
4296
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
4297
+ return Number(m[1]) * ({
4298
+ s: 1,
4299
+ m: 60,
4300
+ h: 3600,
4301
+ d: 86400
4302
+ }[m[2] || "s"] ?? 1);
4303
+ }
4304
+ /**
4305
+ * The base IAM credential the broker assumes the role with. From flags or the
4306
+ * standard AWS env vars; JSON-serialized so the executor can parse it. Never the
4307
+ * plaintext leaves this machine unwrapped — it is wrapped to the broker key.
4308
+ */
4309
+ function resolveBaseCredential(opts) {
4310
+ const accessKeyId = opts.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID;
4311
+ const secretAccessKey = opts.secretAccessKey ?? process.env.AWS_SECRET_ACCESS_KEY;
4312
+ const sessionToken = process.env.AWS_SESSION_TOKEN;
4313
+ if (!accessKeyId || !secretAccessKey) fail("provide the base IAM credential via --access-key-id/--secret-access-key or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY (it needs only sts:AssumeRole on the role)");
4314
+ return JSON.stringify({
4315
+ accessKeyId,
4316
+ secretAccessKey,
4317
+ ...sessionToken ? { sessionToken } : {}
4318
+ });
4319
+ }
4320
+ function registerAwsCommands(program) {
4321
+ const aws = program.command("aws").description("temporary AWS credentials (STS AssumeRole, zero-knowledge)");
4322
+ const target = aws.command("target").description("manage AWS role targets");
4323
+ target.command("add").description("register an assumable IAM role to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-deploy").requiredOption("--role-arn <arn>", "the IAM role to assume, arn:aws:iam::<acct>:role/<name>").requiredOption("--region <region>", "region whose STS endpoint to call, e.g. us-east-1").option("--org <slug>").option("--external-id <id>", "STS ExternalId the role's trust policy requires").option("--session-policy <file>", "path to an inline session policy JSON (further restricts)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--access-key-id <id>", "base IAM access key id (else AWS_ACCESS_KEY_ID)").option("--secret-access-key <secret>", "base IAM secret (else AWS_SECRET_ACCESS_KEY)").action(async (options) => {
4324
+ const ctx = buildContext();
4325
+ const org = await resolveOrg(ctx, options.org);
4326
+ const sessionPolicy = options.sessionPolicy ? readFileSync(options.sessionPolicy, "utf8").trim() : void 0;
4327
+ const config = {
4328
+ provider: "aws",
4329
+ executor: "in_do",
4330
+ roleArn: options.roleArn,
4331
+ region: options.region,
4332
+ ...options.externalId ? { externalId: options.externalId } : {},
4333
+ ...sessionPolicy ? { sessionPolicy } : {},
4334
+ ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$6(options.maxTtl) } : {}
4335
+ };
4336
+ const baseCredential = resolveBaseCredential(options);
4337
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
4338
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(baseCredential), publicKeyJwk);
4339
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
4340
+ name: options.name,
4341
+ config,
4342
+ wrappedAdminSecret
4343
+ });
4344
+ console.error(`registered AWS target ${created.name} (${created.id})`);
4345
+ console.error("\nEnsure the role trusts the admin principal, then `seekrit aws lease`:\n");
4346
+ console.log(awsTrustPolicyInstructions(config));
4347
+ });
4348
+ target.command("list").description("list AWS role targets").option("--org <slug>").action(async (options) => {
4349
+ const ctx = buildContext();
4350
+ const org = await resolveOrg(ctx, options.org);
4351
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
4352
+ for (const t of targets) {
4353
+ const cfg = t.config;
4354
+ if (cfg.provider !== "aws") continue;
4355
+ console.log(`${t.id}\t${t.name}\t${cfg.region}\t${cfg.roleArn}`);
4356
+ }
4357
+ });
4358
+ target.command("trust <targetId>").description("reprint the IAM trust-policy setup for an AWS target").option("--org <slug>").action(async (targetId, options) => {
4359
+ const ctx = buildContext();
4360
+ const org = await resolveOrg(ctx, options.org);
4361
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
4362
+ const t = targets.find((x) => x.id === targetId || x.name === targetId);
4363
+ if (!t) fail(`no target "${targetId}" in ${org.slug}`);
4364
+ const cfg = t.config;
4365
+ if (cfg.provider !== "aws") fail("not an aws target (see `seekrit pg`/`seekrit ssh`)");
4366
+ console.log(awsTrustPolicyInstructions(cfg));
4367
+ });
4368
+ target.command("rm <targetId>").description("delete an AWS role target").option("--org <slug>").action(async (targetId, options) => {
4369
+ const ctx = buildContext();
4370
+ const org = await resolveOrg(ctx, options.org);
4371
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
4372
+ console.error(`deleted ${targetId}`);
4373
+ });
4374
+ aws.command("lease <target>").description("mint short-lived AWS credentials; prints ready-to-source export lines").option("--org <slug>").option("--ttl <duration>", "credential lifetime, e.g. 15m, 1h, 12h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
4375
+ const ctx = buildContext();
4376
+ const org = await resolveOrg(ctx, options.org);
4377
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
4378
+ const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
4379
+ if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
4380
+ const cfg = t.config;
4381
+ if (cfg.provider !== "aws") fail(`"${t.name}" is not an aws target (see \`seekrit pg\`)`);
4382
+ const ttlSeconds = parseTtlSeconds$6(options.ttl);
4383
+ if (ttlSeconds < 900) fail(`--ttl must be at least ${900 / 60}m (STS minimum)`);
4384
+ const recipient = await generateAwsRecipientKeyPair();
4385
+ const { aws: leased } = await ctx.client.mintLease(org.id, {
4386
+ provider: "aws",
4387
+ targetId: t.id,
4388
+ recipientPublicKey: recipient.publicKeyJwk,
4389
+ ttlSeconds
4390
+ });
4391
+ const cred = await unwrapAwsCredential(leased.wrappedCredential, recipient.privateKeyJwk);
4392
+ console.error(`leased ${cfg.roleArn} in ${cred.region} — expires ${cred.expiration}`);
4393
+ if (options.json) console.log(JSON.stringify({
4394
+ ...cred,
4395
+ roleArn: cfg.roleArn
4396
+ }, null, 2));
4397
+ else {
4398
+ console.log(`export AWS_ACCESS_KEY_ID=${cred.accessKeyId}`);
4399
+ console.log(`export AWS_SECRET_ACCESS_KEY=${cred.secretAccessKey}`);
4400
+ console.log(`export AWS_SESSION_TOKEN=${cred.sessionToken}`);
4401
+ console.log(`export AWS_REGION=${cred.region}`);
4402
+ }
4403
+ });
4404
+ aws.command("leases").description("list AWS leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
4405
+ const ctx = buildContext();
4406
+ const org = await resolveOrg(ctx, options.org);
4407
+ const { leases } = await ctx.client.listLeases(org.id);
4408
+ for (const l of leases) {
4409
+ if (l.provider !== "aws") continue;
4410
+ console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
4411
+ }
4412
+ });
4413
+ aws.command("revoke <leaseId>").description("mark a lease revoked in the ledger (STS credentials stay valid until they expire)").option("--org <slug>").action(async (leaseId, options) => {
4414
+ const ctx = buildContext();
4415
+ const org = await resolveOrg(ctx, options.org);
4416
+ await ctx.client.revokeLease(org.id, leaseId);
4417
+ console.error(`revoked ${leaseId} (issued credentials remain valid until they expire)`);
4418
+ });
4419
+ }
4420
+ //#endregion
4421
+ //#region src/billing.ts
4422
+ /** `included: null` means unlimited; an unavailable meter is unknown, not zero. */
4423
+ function usageLine(usage) {
4424
+ if (!usage.available) return "unknown";
4425
+ return `${usage.quantity} of ${usage.included ?? "unlimited"}`;
4426
+ }
4427
+ /**
4428
+ * Plan, usage, and self-serve billing.
4429
+ *
4430
+ * Checkout and the billing portal are browser flows the biller hosts, so those
4431
+ * commands print a URL rather than pretending to complete a payment in the
4432
+ * terminal.
4433
+ */
4434
+ function registerBillingCommands(program) {
4435
+ const billing = program.command("billing").description("plan, usage, and subscription");
4436
+ billing.command("show", { isDefault: true }).description("show the org's plan, what it includes, and current usage").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
4437
+ const ctx = buildContext();
4438
+ const ref = await resolveOrg(ctx, options.org);
4439
+ const info = await ctx.client.getBilling(ref.id);
4440
+ emit(options, info, () => {
4441
+ printFields([
4442
+ ["plan", `${info.plan.name} (${info.plan.id})`],
4443
+ ["status", info.subscription?.status ?? "no subscription (default plan)"],
4444
+ ["renews", info.subscription?.currentPeriodEnd ?? null],
4445
+ ["cancels", info.subscription?.cancelAt ?? null],
4446
+ ["trial ends", info.subscription?.trialEndsAt ?? null],
4447
+ ["limits enforced", info.enforced ? "yes" : "no (informational only)"],
4448
+ ["self-serve", [info.manage.checkout ? `checkout (${info.manage.checkoutFamilies.join(", ")})` : null, info.manage.portal ? "portal" : null].filter(Boolean).join(", ") || "unavailable"]
4449
+ ]);
4450
+ section("usage");
4451
+ printTable(info.usage, [col("metric", (u) => u.label), col("used", (u) => usageLine(u))], "nothing metered yet");
4452
+ if (info.overrides.length > 0) {
4453
+ section("overrides");
4454
+ printTable(info.overrides, [
4455
+ col("key", (o) => o.key),
4456
+ col("value", (o) => String(o.value)),
4457
+ col("note", (o) => o.note),
4458
+ col("expires", (o) => o.expiresAt)
4459
+ ]);
4460
+ }
4461
+ });
4462
+ });
4463
+ billing.command("entitlements").description("list every entitlement this org resolves to").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
4464
+ const ctx = buildContext();
4465
+ const ref = await resolveOrg(ctx, options.org);
4466
+ const info = await ctx.client.getBilling(ref.id);
4467
+ emit(options, { entitlements: info.entitlements }, () => printTable(info.entitlements, [
4468
+ col("key", (e) => e.key),
4469
+ col("value", (e) => e.value === null ? "unlimited" : String(e.value)),
4470
+ col("source", (e) => e.source)
4471
+ ]));
4472
+ });
4473
+ billing.command("checkout <family>").description(`start a self-serve upgrade (${VISIBLE_PLAN_FAMILY_IDS.join(" | ")}) — prints a URL`).option("--org <slug>").action(async (family, options) => {
4474
+ if (!PLAN_FAMILY_IDS.includes(family)) fail(`unknown plan "${family}" — one of: ${PLAN_FAMILY_IDS.join(", ")}`);
4475
+ const ctx = buildContext();
4476
+ const ref = await resolveOrg(ctx, options.org);
4477
+ const { url } = await ctx.client.startCheckout(ref.id, { family });
4478
+ console.error("open this to complete checkout:");
4479
+ console.log(url);
4480
+ });
4481
+ billing.command("portal").description("open the billing portal (prints a URL) to manage payment and invoices").option("--org <slug>").action(async (options) => {
4482
+ const ctx = buildContext();
4483
+ const ref = await resolveOrg(ctx, options.org);
4484
+ const { url } = await ctx.client.openBillingPortal(ref.id);
4485
+ console.error("open this to manage billing:");
4486
+ console.log(url);
4487
+ });
4488
+ billing.command("cancel").description("cancel the subscription and drop back to the Free plan").option("--org <slug>").option("--yes", "skip the confirmation prompt").action(async (options) => {
4489
+ const ctx = buildContext();
4490
+ const ref = await resolveOrg(ctx, options.org);
4491
+ await confirmDestructive(options.yes, `Cancel ${ref.slug}'s subscription and move it to the Free plan?`);
4492
+ await ctx.client.cancelSubscription(ref.id);
4493
+ console.error(`${ref.slug} moved to the Free plan`);
4494
+ });
4495
+ }
4496
+ //#endregion
3488
4497
  //#region src/branches.ts
3489
4498
  /**
3490
4499
  * Resolve `--from` to the environment being branched.
@@ -3557,17 +4566,21 @@ function registerBranchCommands(program) {
3557
4566
  console.error(created.branch.expiresAt ? `expires ${created.branch.expiresAt}` : "no expiry — delete it explicitly when the PR closes");
3558
4567
  if (grants.length > 0) console.error(`shared with ${grants.length} other reader(s)`);
3559
4568
  });
3560
- branch.command("list").description("list branches in an application (or of one environment)").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--env <slug>", "only branches of this environment").action(async (options) => {
4569
+ branch.command("list").alias("ls").description("list branches in an application (or of one environment)").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--env <slug>", "only branches of this environment").option("--json", "print the raw API response").action(async (options) => {
3561
4570
  const ctx = buildContext();
3562
- if (options.env) {
4571
+ const branches = options.env ? await (async () => {
3563
4572
  const parent = await resolveAppEnv(ctx, options);
3564
- const { branches } = await ctx.client.listBranches(parent.orgId, parent.envId);
3565
- for (const b of branches) console.log(`${b.slug}\t${b.id}\t${b.expiresAt ?? "never"}`);
3566
- return;
3567
- }
3568
- const app = await resolveApp(ctx, options);
3569
- const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
3570
- for (const b of branches) console.log(`${b.slug}\t${b.id}\t${b.expiresAt ?? "never"}`);
4573
+ return (await ctx.client.listBranches(parent.orgId, parent.envId)).branches;
4574
+ })() : await (async () => {
4575
+ const app = await resolveApp(ctx, options);
4576
+ return (await ctx.client.listAppBranches(app.orgId, app.id)).branches;
4577
+ })();
4578
+ emit(options, { branches }, () => printTable(branches, [
4579
+ col("slug", (b) => b.slug),
4580
+ col("expires", (b) => b.expiresAt ?? "never"),
4581
+ col("created", (b) => b.createdAt),
4582
+ col("id", (b) => b.id)
4583
+ ], "no branches — create one with `seekrit branch create`"));
3571
4584
  });
3572
4585
  branch.command("delete <slug>").alias("rm").description("tear down a branch and everything it overrode").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").action(async (slug, options) => {
3573
4586
  const ctx = buildContext();
@@ -3770,6 +4783,181 @@ function registerGcpCommands(program) {
3770
4783
  });
3771
4784
  }
3772
4785
  //#endregion
4786
+ //#region src/groups.ts
4787
+ /**
4788
+ * Shared groups: reusable secret bags composed into application environments
4789
+ * by matching slug (`seekrit env groups add`). A group holds one environment
4790
+ * per slug — the "variants" of the shared values.
4791
+ */
4792
+ function registerGroupCommands(program) {
4793
+ const group = program.command("group").description("manage shared groups (reusable secret bags)");
4794
+ group.command("list").alias("ls").description("list shared groups in an organization").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
4795
+ const ctx = buildContext();
4796
+ const ref = await resolveOrg(ctx, options.org);
4797
+ const { groups } = await ctx.client.listGroups(ref.id);
4798
+ emit(options, { groups }, () => printTable(groups, [
4799
+ col("slug", (g) => g.slug),
4800
+ col("name", (g) => g.name),
4801
+ col("created", (g) => g.createdAt),
4802
+ col("id", (g) => g.id)
4803
+ ], "no groups — create one with `seekrit group create`"));
4804
+ });
4805
+ group.command("show <slug>").description("show a group and the environments (value sets) it holds").option("--org <slug>").option("--json", "print the raw API response").action(async (slug, options) => {
4806
+ const ctx = buildContext();
4807
+ const ref = await resolveGroup(ctx, {
4808
+ org: options.org,
4809
+ group: slug
4810
+ });
4811
+ const { group: row, environments } = await ctx.client.getGroup(ref.orgId, ref.id);
4812
+ emit(options, {
4813
+ group: row,
4814
+ environments
4815
+ }, () => {
4816
+ printFields([
4817
+ ["slug", row.slug],
4818
+ ["name", row.name],
4819
+ ["id", row.id],
4820
+ ["created", row.createdAt]
4821
+ ]);
4822
+ section("environments");
4823
+ printTable(environments, [
4824
+ col("slug", (e) => e.slug),
4825
+ col("name", (e) => e.name),
4826
+ col("access", (e) => e.canDecrypt ? "can decrypt" : "no key"),
4827
+ col("id", (e) => e.id)
4828
+ ], "no environments — create one with `seekrit group env create`");
4829
+ });
4830
+ });
4831
+ group.command("create").description("create a shared group").option("--org <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
4832
+ const ctx = buildContext();
4833
+ const orgRef = await resolveOrg(ctx, options.org);
4834
+ const created = await ctx.client.createGroup(orgRef.id, {
4835
+ name: options.name,
4836
+ slug: options.slug
4837
+ });
4838
+ console.error(`created group ${created.group.slug} (${created.group.id})`);
4839
+ });
4840
+ group.command("rename <slug>").description("change a group's display name (the slug is permanent)").option("--org <slug>").requiredOption("--name <name>", "new display name").action(async (slug, options) => {
4841
+ const ctx = buildContext();
4842
+ const ref = await resolveGroup(ctx, {
4843
+ org: options.org,
4844
+ group: slug
4845
+ });
4846
+ const { group: row } = await ctx.client.updateGroup(ref.orgId, ref.id, { name: options.name });
4847
+ console.error(`renamed ${row.slug} to "${row.name}"`);
4848
+ });
4849
+ group.command("rm <slug>").alias("delete").description("delete a group, its environments, and their secrets").option("--org <slug>").option("--yes", "skip the confirmation prompt").action(async (slug, options) => {
4850
+ const ctx = buildContext();
4851
+ const ref = await resolveGroup(ctx, {
4852
+ org: options.org,
4853
+ group: slug
4854
+ });
4855
+ const { environments } = await ctx.client.getGroup(ref.orgId, ref.id);
4856
+ await confirmDestructive(options.yes, `Delete group ${ref.slug} and its ${environments.length} environment(s)? Environments composing it will stop receiving these values.`);
4857
+ await ctx.client.deleteGroup(ref.orgId, ref.id);
4858
+ console.error(`deleted group ${ref.slug}`);
4859
+ });
4860
+ const groupEnv = group.command("env").description("manage a group’s environments (per-slug value sets / variants)");
4861
+ groupEnv.command("list").alias("ls").description("list a group's environments").option("--org <slug>").requiredOption("--group <slug>").option("--json", "print the raw API response").action(async (options) => {
4862
+ const ctx = buildContext();
4863
+ const ref = await resolveGroup(ctx, options);
4864
+ const { environments } = await ctx.client.listGroupEnvs(ref.orgId, ref.id);
4865
+ emit(options, { environments }, () => printTable(environments, [
4866
+ col("slug", (e) => e.slug),
4867
+ col("name", (e) => e.name),
4868
+ col("created", (e) => e.createdAt),
4869
+ col("id", (e) => e.id)
4870
+ ], "no environments — create one with `seekrit group env create`"));
4871
+ });
4872
+ groupEnv.command("create").description("create a group environment (generates its data key locally)").option("--org <slug>").requiredOption("--group <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
4873
+ const ctx = buildContext();
4874
+ const groupRef = await resolveGroup(ctx, {
4875
+ org: options.org,
4876
+ group: options.group
4877
+ });
4878
+ const { user } = await ctx.client.me();
4879
+ if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
4880
+ const dek = generateDek();
4881
+ const wrappedDek = await wrapDek(dek, user.publicKeyJwk);
4882
+ const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, groupRef.orgId, dek);
4883
+ const created = await ctx.client.createGroupEnv(groupRef.orgId, groupRef.id, {
4884
+ name: options.name,
4885
+ slug: options.slug,
4886
+ wrappedDek,
4887
+ recoveryWrappedDek
4888
+ });
4889
+ console.error(`created ${groupRef.slug}@${created.environment.slug} (${created.environment.id})`);
4890
+ });
4891
+ }
4892
+ //#endregion
4893
+ //#region src/logsink.ts
4894
+ /** Collect repeated `--header Name: value` flags into a map. */
4895
+ function collectHeader(value, acc = {}) {
4896
+ const sep = value.indexOf(":");
4897
+ if (sep <= 0) fail(`expected "Name: value", got "${value}"`);
4898
+ acc[value.slice(0, sep).trim()] = value.slice(sep + 1).trim();
4899
+ return acc;
4900
+ }
4901
+ /**
4902
+ * The org's OTLP audit-log export — near-real-time shipping of every audit row
4903
+ * to a customer SIEM. Header *values* are write-only: they are encrypted at
4904
+ * rest and never echoed back, so `show` reports only the header names.
4905
+ */
4906
+ function registerLogSinkCommands(program) {
4907
+ const sink = program.command("log-sink").description("stream the audit trail to your own OTLP collector (SIEM)");
4908
+ sink.command("show", { isDefault: true }).description("show the configured log sink and its delivery health").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
4909
+ const ctx = buildContext();
4910
+ const ref = await resolveOrg(ctx, options.org);
4911
+ const { sink: config } = await ctx.client.getLogSink(ref.id);
4912
+ emit(options, { sink: config }, () => {
4913
+ if (!config) {
4914
+ console.error("no log sink configured — set one with `seekrit log-sink set`");
4915
+ return;
4916
+ }
4917
+ printFields([
4918
+ ["endpoint", config.endpoint],
4919
+ ["enabled", config.enabled ? "yes" : "no"],
4920
+ ["headers", config.headerNames.length > 0 ? config.headerNames.join(", ") : "none"],
4921
+ ["last success", config.lastSuccessAt ?? "never"],
4922
+ ["last attempt", config.lastAttemptAt ?? "never"],
4923
+ ["last error", config.lastError ?? "none"]
4924
+ ]);
4925
+ });
4926
+ });
4927
+ sink.command("set <endpoint>").description("point the audit export at an OTLP/HTTP logs endpoint").option("--org <slug>").option("--header <name: value>", "auth header to send (repeatable; values are write-only)", collectHeader).option("--clear-headers", "send no headers at all (drops the stored ones)").option("--disabled", "save the config but stop shipping").action(async (endpoint, options) => {
4928
+ if (options.header && options.clearHeaders) fail("pass either --header or --clear-headers, not both");
4929
+ const ctx = buildContext();
4930
+ const ref = await resolveOrg(ctx, options.org);
4931
+ const headers = options.clearHeaders ? {} : options.header;
4932
+ const { sink: config } = await ctx.client.setLogSink(ref.id, {
4933
+ endpoint,
4934
+ headers,
4935
+ enabled: !options.disabled
4936
+ });
4937
+ console.error(`log sink → ${config.endpoint} (${config.enabled ? "enabled" : "disabled"}) — test it with \`seekrit log-sink test\``);
4938
+ });
4939
+ sink.command("test").description("send a probe to the configured endpoint and report the result").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
4940
+ const ctx = buildContext();
4941
+ const ref = await resolveOrg(ctx, options.org);
4942
+ const result = await ctx.client.testLogSink(ref.id);
4943
+ emit(options, result, () => {
4944
+ printFields([
4945
+ ["result", result.ok ? "ok" : "failed"],
4946
+ ["status", result.status],
4947
+ ["error", result.error]
4948
+ ]);
4949
+ });
4950
+ if (!result.ok) process.exitCode = 1;
4951
+ });
4952
+ sink.command("rm").alias("delete").description("stop exporting the audit trail and forget the endpoint").option("--org <slug>").option("--yes", "skip the confirmation prompt").action(async (options) => {
4953
+ const ctx = buildContext();
4954
+ const ref = await resolveOrg(ctx, options.org);
4955
+ await confirmDestructive(options.yes, `Remove ${ref.slug}'s audit log export?`);
4956
+ await ctx.client.deleteLogSink(ref.id);
4957
+ console.error("log sink removed");
4958
+ });
4959
+ }
4960
+ //#endregion
3773
4961
  //#region src/m2m.ts
3774
4962
  /**
3775
4963
  * Resolve M2M client credentials from (in order) the process environment, a
@@ -3787,12 +4975,16 @@ function readM2mCreds(dotenvVars = {}) {
3787
4975
  clientSecret
3788
4976
  };
3789
4977
  }
3790
- /** True when a service/dev credential is already configured explicitly. */
4978
+ /**
4979
+ * True when a service/session/dev credential is already configured explicitly.
4980
+ * A browser-authorized session counts: a human who ran `seekrit login` must not
4981
+ * be silently swapped onto a machine identity.
4982
+ */
3791
4983
  function hasExplicitCredential(dotenvVars) {
3792
4984
  const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
3793
4985
  if (fromEnv("SEEKRIT_TOKEN") || fromEnv("SEEKRIT_DEV_USER")) return true;
3794
4986
  const config = readGlobalConfig();
3795
- return Boolean(config.token || config.devUser);
4987
+ return Boolean(config.token || config.sessionToken || config.devUser);
3796
4988
  }
3797
4989
  /** Mint a fresh org-scoped admin token using M2M credentials (keyless). */
3798
4990
  async function mintAdminToken(apiUrl, creds) {
@@ -4157,6 +5349,164 @@ function collect$3(value, acc) {
4157
5349
  return acc;
4158
5350
  }
4159
5351
  //#endregion
5352
+ //#region src/orgs.ts
5353
+ /**
5354
+ * Counts for `org show`. Each list is admin-gated to a different degree, so a
5355
+ * member who can't read one still gets the rest: a failed call reports as
5356
+ * `null`, which `printFields` drops and JSON preserves as "not visible to you".
5357
+ */
5358
+ async function countOrNull(load) {
5359
+ try {
5360
+ return await load();
5361
+ } catch {
5362
+ return null;
5363
+ }
5364
+ }
5365
+ async function orgOverview(ctx, orgId) {
5366
+ const [apps, groups, members, tokens] = await Promise.all([
5367
+ countOrNull(async () => (await ctx.client.listApps(orgId)).apps.length),
5368
+ countOrNull(async () => (await ctx.client.listGroups(orgId)).groups.length),
5369
+ countOrNull(async () => (await ctx.client.listMembers(orgId)).members.length),
5370
+ countOrNull(async () => (await ctx.client.listTokens(orgId)).tokens.length)
5371
+ ]);
5372
+ return {
5373
+ apps,
5374
+ groups,
5375
+ members,
5376
+ tokens
5377
+ };
5378
+ }
5379
+ function registerOrgCommands(program) {
5380
+ const org = program.command("org").description("manage organizations");
5381
+ org.command("list").alias("ls").description("list the organizations you can access").option("--json", "print the raw API response").action(async (options) => {
5382
+ const { orgs } = await buildContext().client.listOrgs();
5383
+ emit(options, { orgs }, () => printTable(orgs, [
5384
+ col("slug", (o) => o.slug),
5385
+ col("name", (o) => o.name),
5386
+ col("role", (o) => o.role),
5387
+ col("id", (o) => o.id)
5388
+ ], "no organizations — create one with `seekrit org create`"));
5389
+ });
5390
+ org.command("show [slug]").description("show an organization and what it contains").option("--org <slug>", "organization slug (or pass it as the argument)").option("--json", "print the raw API response").action(async (slug, options) => {
5391
+ const ctx = buildContext();
5392
+ const ref = await resolveOrg(ctx, slug ?? options.org);
5393
+ const { org: row } = await ctx.client.getOrg(ref.id);
5394
+ const counts = await orgOverview(ctx, ref.id);
5395
+ const shown = (n) => n === null ? "—" : n;
5396
+ emit(options, {
5397
+ org: row,
5398
+ counts
5399
+ }, () => {
5400
+ printFields([
5401
+ ["slug", row.slug],
5402
+ ["name", row.name],
5403
+ ["id", row.id],
5404
+ ["your role", row.role],
5405
+ ["created", row.createdAt],
5406
+ ["applications", shown(counts.apps)],
5407
+ ["groups", shown(counts.groups)],
5408
+ ["members", shown(counts.members)],
5409
+ ["service tokens", shown(counts.tokens)]
5410
+ ]);
5411
+ });
5412
+ });
5413
+ org.command("create").description("create an organization (you become its owner)").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
5414
+ const created = await buildContext().client.createOrg({
5415
+ name: options.name,
5416
+ slug: options.slug
5417
+ });
5418
+ console.error(`created org ${created.org.slug} (${created.org.id})`);
5419
+ });
5420
+ org.command("rename").description("change an organization's display name (the slug is permanent)").option("--org <slug>").requiredOption("--name <name>", "new display name").action(async (options) => {
5421
+ const ctx = buildContext();
5422
+ const ref = await resolveOrg(ctx, options.org);
5423
+ const { org: row } = await ctx.client.updateOrg(ref.id, { name: options.name });
5424
+ console.error(`renamed ${row.slug} to "${row.name}"`);
5425
+ });
5426
+ org.command("member").description("view organization members").command("list").alias("ls").description("list members, and whether each has finished key setup").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
5427
+ const ctx = buildContext();
5428
+ const ref = await resolveOrg(ctx, options.org);
5429
+ const { members } = await ctx.client.listMembers(ref.id);
5430
+ emit(options, { members }, () => printTable(members, [
5431
+ col("email", (m) => m.email),
5432
+ col("role", (m) => m.role),
5433
+ col("name", (m) => m.name),
5434
+ col("keys", (m) => m.publicKeyJwk ? "ready" : "pending"),
5435
+ col("id", (m) => m.userId)
5436
+ ], "no members"));
5437
+ });
5438
+ const invite = org.command("invite").description("manage pending invitations");
5439
+ invite.command("list").alias("ls").description("list outstanding invitations").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
5440
+ const ctx = buildContext();
5441
+ const ref = await resolveOrg(ctx, options.org);
5442
+ const { invites } = await ctx.client.listInvites(ref.id);
5443
+ emit(options, { invites }, () => printTable(invites, [
5444
+ col("email", (i) => i.email),
5445
+ col("role", (i) => i.role),
5446
+ col("invited", (i) => i.createdAt),
5447
+ col("id", (i) => i.id)
5448
+ ], "no pending invitations"));
5449
+ });
5450
+ invite.command("add <email>").description("invite someone to the organization").option("--org <slug>").option("--role <role>", "admin | member", "member").action(async (email, options) => {
5451
+ if (options.role !== "admin" && options.role !== "member") fail("--role must be admin or member");
5452
+ const ctx = buildContext();
5453
+ const ref = await resolveOrg(ctx, options.org);
5454
+ const { invite: row } = await ctx.client.createInvite(ref.id, {
5455
+ email,
5456
+ role: options.role
5457
+ });
5458
+ console.error(`invited ${row.email} as ${row.role} (${row.id}) — they join when they first sign in`);
5459
+ });
5460
+ invite.command("rm <inviteId>").description("rescind an invitation").option("--org <slug>").action(async (inviteId, options) => {
5461
+ const ctx = buildContext();
5462
+ const ref = await resolveOrg(ctx, options.org);
5463
+ await ctx.client.revokeInvite(ref.id, inviteId);
5464
+ console.error(`${inviteId} revoked`);
5465
+ });
5466
+ org.command("mfa").description("show or set the org-wide second-factor requirement").option("--org <slug>").option("--set <policy>", "required | optional").option("--json", "print the raw API response").action(async (options) => {
5467
+ const ctx = buildContext();
5468
+ const ref = await resolveOrg(ctx, options.org);
5469
+ if (options.set !== void 0 && options.set !== "required" && options.set !== "optional") fail("--set must be required or optional");
5470
+ const policy = options.set === void 0 ? await ctx.client.getMfaPolicy(ref.id) : await ctx.client.setMfaPolicy(ref.id, { required: options.set === "required" });
5471
+ emit(options, policy, () => {
5472
+ if (!policy.configured) {
5473
+ console.error("no identity provider configured — MFA cannot be enforced here");
5474
+ return;
5475
+ }
5476
+ console.log(policy.required ? "required for all members" : "optional");
5477
+ });
5478
+ });
5479
+ org.command("tree").description("print the org's applications, environments, and groups as a tree").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
5480
+ const ctx = buildContext();
5481
+ const ref = await resolveOrg(ctx, options.org);
5482
+ const [{ apps }, { groups }] = await Promise.all([ctx.client.listApps(ref.id), ctx.client.listGroups(ref.id)]);
5483
+ const appTree = await Promise.all(apps.map(async (a) => ({
5484
+ ...a,
5485
+ environments: (await ctx.client.listEnvs(ref.id, a.id)).environments
5486
+ })));
5487
+ const groupTree = await Promise.all(groups.map(async (g) => ({
5488
+ ...g,
5489
+ environments: (await ctx.client.listGroupEnvs(ref.id, g.id)).environments
5490
+ })));
5491
+ emit(options, {
5492
+ org: ref.slug,
5493
+ apps: appTree,
5494
+ groups: groupTree
5495
+ }, () => {
5496
+ console.log(ref.slug);
5497
+ for (const a of appTree) {
5498
+ console.log(` app ${a.slug}`);
5499
+ for (const e of a.environments) console.log(` ${e.slug}`);
5500
+ }
5501
+ for (const g of groupTree) {
5502
+ console.log(` group ${g.slug}`);
5503
+ for (const e of g.environments) console.log(` ${e.slug}`);
5504
+ }
5505
+ if (appTree.length === 0 && groupTree.length === 0) console.error("no applications or groups yet");
5506
+ });
5507
+ });
5508
+ }
5509
+ //#endregion
4160
5510
  //#region src/pg.ts
4161
5511
  /**
4162
5512
  * `seekrit pg` — temporary Postgres credentials (Vault-style dynamic secrets).
@@ -4735,6 +6085,383 @@ function collect(value, acc) {
4735
6085
  return acc;
4736
6086
  }
4737
6087
  //#endregion
6088
+ //#region src/sync.ts
6089
+ function assertProvider(value) {
6090
+ if (!SYNC_PROVIDER_KINDS.includes(value)) fail(`unknown provider "${value}" — one of: ${SYNC_PROVIDER_KINDS.join(", ")}`);
6091
+ return value;
6092
+ }
6093
+ /** Account-scope config for a connection (never the credential itself). */
6094
+ function buildConfig(provider, options) {
6095
+ switch (provider) {
6096
+ case "vercel": return {
6097
+ provider: "vercel",
6098
+ ...options.teamId ? { teamId: options.teamId } : {}
6099
+ };
6100
+ }
6101
+ }
6102
+ /** Where inside the platform a binding writes. */
6103
+ function buildDestination(provider, options) {
6104
+ switch (provider) {
6105
+ case "vercel": {
6106
+ if (!options.project) fail("--project is required for vercel (a prj_… id or project name)");
6107
+ const targets = (options.target ?? "production").split(",").map((t) => t.trim());
6108
+ const unknown = targets.filter((t) => !VERCEL_TARGETS.includes(t));
6109
+ if (unknown.length > 0) fail(`unknown --target ${unknown.join(", ")} — one of: ${VERCEL_TARGETS.join(", ")}`);
6110
+ return {
6111
+ provider: "vercel",
6112
+ projectId: options.project,
6113
+ targets,
6114
+ ...options.gitBranch ? { gitBranch: options.gitBranch } : {}
6115
+ };
6116
+ }
6117
+ }
6118
+ }
6119
+ /** One-line description of a destination, for list output. */
6120
+ function describeDestination(destination) {
6121
+ switch (destination.provider) {
6122
+ case "vercel": return `${destination.projectId} (${destination.targets.join(", ")}${destination.gitBranch ? ` @${destination.gitBranch}` : ""})`;
6123
+ }
6124
+ }
6125
+ /** Find a connection by id or name — nobody keeps `syc_…` ids in their head. */
6126
+ async function resolveConnection(ctx, orgId, ref) {
6127
+ const { connections } = await ctx.client.listSyncConnections(orgId);
6128
+ const matches = connections.filter((c) => c.id === ref || c.name === ref);
6129
+ if (matches.length === 0) fail(`no sync connection "${ref}"`);
6130
+ if (matches.length > 1) fail(`"${ref}" matches ${matches.length} connections — use the id`);
6131
+ return matches[0];
6132
+ }
6133
+ function registerSyncCommands(program) {
6134
+ const sync = program.command("sync").description("push environments to a third-party platform (Vercel, …)");
6135
+ sync.command("connections").alias("conns").description("list destination accounts seekrit can push to").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
6136
+ const ctx = buildContext();
6137
+ const ref = await resolveOrg(ctx, options.org);
6138
+ const { connections } = await ctx.client.listSyncConnections(ref.id);
6139
+ emit(options, { connections }, () => printTable(connections, [
6140
+ col("name", (c) => c.name),
6141
+ col("provider", (c) => c.provider),
6142
+ col("status", (c) => c.status),
6143
+ col("error", (c) => c.lastError),
6144
+ col("id", (c) => c.id)
6145
+ ], "no connections — add one with `seekrit sync connect`"));
6146
+ });
6147
+ 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)").action(async (options) => {
6148
+ const provider = assertProvider(options.provider);
6149
+ const ctx = buildContext();
6150
+ const ref = await resolveOrg(ctx, options.org);
6151
+ const credential = (process.stdin.isTTY ? await promptHidden(`${provider} API token: `) : await readStdin()).trim();
6152
+ if (!credential) fail("no API token given");
6153
+ const id = randomId("syc");
6154
+ const { publicKeyJwk } = await ctx.client.getSyncConnectionKey(ref.id, id);
6155
+ const created = await ctx.client.createSyncConnection(ref.id, {
6156
+ id,
6157
+ name: options.name,
6158
+ config: buildConfig(provider, options),
6159
+ wrappedCredential: await wrapDek(new TextEncoder().encode(credential), publicKeyJwk)
6160
+ });
6161
+ console.error(`connected ${created.connection.name} (${created.connection.id})`);
6162
+ });
6163
+ sync.command("verify <connection>").description("check a stored credential against a destination").option("--org <slug>").option("--provider <kind>", SYNC_PROVIDER_KINDS.join(" | "), "vercel").option("--project <id>", "vercel: project id or name").option("--target <list>", "vercel: comma-separated targets", "production").option("--git-branch <branch>", "vercel: restrict preview writes to one branch").option("--json", "print the raw API response").action(async (connection, options) => {
6164
+ const provider = assertProvider(options.provider);
6165
+ const ctx = buildContext();
6166
+ const ref = await resolveOrg(ctx, options.org);
6167
+ const conn = await resolveConnection(ctx, ref.id, connection);
6168
+ const result = await ctx.client.verifySyncConnection(ref.id, conn.id, buildDestination(provider, options));
6169
+ emit(options, result, () => printFields([["result", result.ok ? "ok" : "failed"], ["error", result.error ?? null]]));
6170
+ if (!result.ok) process.exitCode = 1;
6171
+ });
6172
+ sync.command("disconnect <connection>").description("delete a destination account, its bindings, and its keypair").option("--org <slug>").option("--yes", "skip the confirmation prompt").action(async (connection, options) => {
6173
+ const ctx = buildContext();
6174
+ const ref = await resolveOrg(ctx, options.org);
6175
+ const conn = await resolveConnection(ctx, ref.id, connection);
6176
+ const { bindings } = await ctx.client.listSyncBindings(ref.id);
6177
+ const mine = bindings.filter((b) => b.connectionId === conn.id);
6178
+ await confirmDestructive(options.yes, `Delete connection ${conn.name} and its ${mine.length} binding(s)? Values already pushed stay on the destination.`);
6179
+ await ctx.client.deleteSyncConnection(ref.id, conn.id);
6180
+ console.error(`disconnected ${conn.name}`);
6181
+ });
6182
+ sync.command("bindings").description("list which environments are syncing where").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
6183
+ const ctx = buildContext();
6184
+ const ref = await resolveOrg(ctx, options.org);
6185
+ const [{ bindings }, { connections }] = await Promise.all([ctx.client.listSyncBindings(ref.id), ctx.client.listSyncConnections(ref.id)]);
6186
+ const names = new Map(connections.map((c) => [c.id, c.name]));
6187
+ emit(options, { bindings }, () => printTable(bindings, [
6188
+ col("connection", (b) => names.get(b.connectionId) ?? b.connectionId),
6189
+ col("destination", (b) => describeDestination(b.destination)),
6190
+ col("env", (b) => b.environmentId),
6191
+ col("mode", (b) => b.enabled ? b.mode : "disabled"),
6192
+ col("last run", (b) => b.lastRunAt),
6193
+ col("error", (b) => b.lastError),
6194
+ col("id", (b) => b.id)
6195
+ ], "nothing is syncing — enable it with `seekrit sync enable`"));
6196
+ });
6197
+ sync.command("enable").description("start syncing one environment to a destination (lets seekrit decrypt it)").option("--org <slug>").requiredOption("--connection <name>", "destination account, by name or id").option("--app <slug>", "application slug (defaults to seekrit.json)").requiredOption("--env <slug>", "the environment to push").option("--provider <kind>", SYNC_PROVIDER_KINDS.join(" | "), "vercel").option("--project <id>", "vercel: project id or name").option("--target <list>", "vercel: comma-separated targets", "production").option("--git-branch <branch>", "vercel: restrict preview writes to one branch").option("--prefix <prefix>", "prepend this to every destination key name").option("--include <globs>", "comma-separated name globs to push (default: all)").option("--exclude <globs>", "comma-separated name globs to skip").option("--on-delete <action>", "delete | retain — what happens when a secret is removed", "delete").option("--mode <mode>", "auto (push on write) | manual", "auto").option("--acknowledge-decryption", "confirm that seekrit's servers may decrypt this environment to push it").action(async (options) => {
6198
+ const provider = assertProvider(options.provider);
6199
+ if (options.onDelete !== "delete" && options.onDelete !== "retain") fail("--on-delete must be delete or retain");
6200
+ if (options.mode !== "auto" && options.mode !== "manual") fail("--mode must be auto or manual");
6201
+ const ctx = buildContext();
6202
+ const target = await resolveAppEnv(ctx, options);
6203
+ const conn = await resolveConnection(ctx, target.orgId, options.connection);
6204
+ const destination = buildDestination(provider, options);
6205
+ await confirmDestructive(options.acknowledgeDecryption, `Enabling sync lets seekrit's servers decrypt ${target.appSlug}/${target.envSlug} in memory to push it to ${conn.name}. Continue?`, "--acknowledge-decryption");
6206
+ const resolved = await ctx.client.resolve({ env: target.envId });
6207
+ const privateKey = await getPrivateKey(ctx);
6208
+ const wrappedDeks = await Promise.all(resolved.layers.map(async (layer) => {
6209
+ let dek;
6210
+ try {
6211
+ dek = await unwrapDek(layer.wrappedDek, privateKey);
6212
+ } catch {
6213
+ fail(`you hold no key for ${layer.groupSlug ? `group "${layer.groupSlug}"` : "this environment"}, so sync cannot be granted access to it`);
6214
+ }
6215
+ return {
6216
+ environmentId: layer.environmentId,
6217
+ wrappedDek: await wrapDek(dek, conn.publicKeyJwk)
6218
+ };
6219
+ }));
6220
+ const globs = (raw) => raw ? raw.split(",").map((g) => g.trim()).filter(Boolean) : void 0;
6221
+ const { binding } = await ctx.client.createSyncBinding(target.orgId, {
6222
+ connectionId: conn.id,
6223
+ environmentId: target.envId,
6224
+ destination,
6225
+ ...options.prefix ? { nameTransform: { prefix: options.prefix } } : {},
6226
+ ...globs(options.include) ? { include: globs(options.include) } : {},
6227
+ ...globs(options.exclude) ? { exclude: globs(options.exclude) } : {},
6228
+ onDelete: options.onDelete,
6229
+ mode: options.mode,
6230
+ wrappedDeks,
6231
+ acknowledgedDecryption: true
6232
+ });
6233
+ console.error(`syncing ${target.appSlug}/${target.envSlug} → ${conn.name} ${describeDestination(destination)} (${binding.id})`);
6234
+ if (binding.mode === "manual") console.error("mode is manual — push with `seekrit sync run`");
6235
+ });
6236
+ sync.command("pause <bindingId>").description("stop pushing on this binding without deleting it").option("--org <slug>").action(async (bindingId, options) => {
6237
+ const ctx = buildContext();
6238
+ const ref = await resolveOrg(ctx, options.org);
6239
+ await ctx.client.updateSyncBinding(ref.id, bindingId, { enabled: false });
6240
+ console.error(`${bindingId} paused`);
6241
+ });
6242
+ sync.command("resume <bindingId>").description("start pushing on a paused binding again").option("--org <slug>").action(async (bindingId, options) => {
6243
+ const ctx = buildContext();
6244
+ const ref = await resolveOrg(ctx, options.org);
6245
+ await ctx.client.updateSyncBinding(ref.id, bindingId, { enabled: true });
6246
+ console.error(`${bindingId} resumed`);
6247
+ });
6248
+ sync.command("disable <bindingId>").alias("rm").description("stop syncing an environment and revoke seekrit's key for it").option("--org <slug>").option("--yes", "skip the confirmation prompt").action(async (bindingId, options) => {
6249
+ const ctx = buildContext();
6250
+ const ref = await resolveOrg(ctx, options.org);
6251
+ await confirmDestructive(options.yes, `Delete binding ${bindingId}? Values already pushed stay on the destination.`);
6252
+ await ctx.client.deleteSyncBinding(ref.id, bindingId);
6253
+ console.error(`${bindingId} deleted`);
6254
+ });
6255
+ sync.command("run <bindingId>").description("push now, synchronously, and report what landed").option("--org <slug>").option("--json", "print the raw API response").action(async (bindingId, options) => {
6256
+ const ctx = buildContext();
6257
+ const ref = await resolveOrg(ctx, options.org);
6258
+ const { run } = await ctx.client.runSyncBinding(ref.id, bindingId);
6259
+ emit(options, { run }, () => {
6260
+ printFields([
6261
+ ["status", run.status],
6262
+ ["pushed", run.pushed.length],
6263
+ ["deleted", run.deleted.length],
6264
+ ["failed", run.failures.length],
6265
+ ["error", run.error]
6266
+ ]);
6267
+ if (run.failures.length > 0) {
6268
+ section("failures");
6269
+ printTable(run.failures, [col("name", (f) => f.name), col("error", (f) => f.error)]);
6270
+ }
6271
+ });
6272
+ if (run.status !== "succeeded") process.exitCode = 1;
6273
+ });
6274
+ sync.command("runs").description("show the sync run history").option("--org <slug>").option("--binding <id>", "only runs of this binding").option("--json", "print the raw API response").action(async (options) => {
6275
+ const ctx = buildContext();
6276
+ const ref = await resolveOrg(ctx, options.org);
6277
+ const { runs } = await ctx.client.listSyncRuns(ref.id, options.binding);
6278
+ emit(options, { runs }, () => printTable(runs, [
6279
+ col("started", (r) => r.startedAt),
6280
+ col("status", (r) => r.status),
6281
+ col("trigger", (r) => r.trigger),
6282
+ col("pushed", (r) => r.pushedCount),
6283
+ col("deleted", (r) => r.deletedCount),
6284
+ col("error", (r) => r.error),
6285
+ col("binding", (r) => r.bindingId)
6286
+ ], "no sync runs yet"));
6287
+ });
6288
+ }
6289
+ //#endregion
6290
+ //#region src/web-login.ts
6291
+ /**
6292
+ * `seekrit login` — sign in through the browser.
6293
+ *
6294
+ * The credential is born here and never leaves: we mint a CLI session token
6295
+ * locally, register only its SHA-256 hash, and wait for a human to authorize
6296
+ * that hash in the dashboard. When they do, we already hold the token — the
6297
+ * approval round-trip carries nothing secret, so there is no window in which the
6298
+ * API (or anything watching it) could learn our credential.
6299
+ *
6300
+ * The saved session authenticates as *you*, which is why it needs no org, app,
6301
+ * or environment selection: commands see every org you're a member of, and
6302
+ * decryption still runs through your own passphrase-unlocked key. Machines want
6303
+ * the opposite trade — a scoped, key-carrying credential — and keep using
6304
+ * `seekrit login --token skt_…`.
6305
+ */
6306
+ /** Open a URL in the platform's default browser. Best-effort and silent. */
6307
+ function openBrowser(url) {
6308
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", [
6309
+ "/c",
6310
+ "start",
6311
+ "",
6312
+ url
6313
+ ]] : ["xdg-open", [url]];
6314
+ try {
6315
+ const child = spawn(command, args, {
6316
+ stdio: "ignore",
6317
+ detached: true
6318
+ });
6319
+ child.on("error", () => void 0);
6320
+ child.unref();
6321
+ } catch {}
6322
+ }
6323
+ /** A frame of the waiting spinner, or a static line on a non-TTY. */
6324
+ const SPINNER = [
6325
+ "⠋",
6326
+ "⠙",
6327
+ "⠹",
6328
+ "⠸",
6329
+ "⠼",
6330
+ "⠴",
6331
+ "⠦",
6332
+ "⠧",
6333
+ "⠇",
6334
+ "⠏"
6335
+ ];
6336
+ function startWaitingIndicator(message) {
6337
+ if (!process.stderr.isTTY) {
6338
+ process.stderr.write(`${message}\n`);
6339
+ return () => void 0;
6340
+ }
6341
+ let frame = 0;
6342
+ const timer = setInterval(() => {
6343
+ process.stderr.write(`\r${SPINNER[frame % SPINNER.length]} ${message}`);
6344
+ frame++;
6345
+ }, 80);
6346
+ return () => {
6347
+ clearInterval(timer);
6348
+ process.stderr.write(`\r${" ".repeat(message.length + 2)}\r`);
6349
+ };
6350
+ }
6351
+ function sleep(ms) {
6352
+ return new Promise((resolve) => setTimeout(resolve, ms));
6353
+ }
6354
+ async function runWebLogin(options) {
6355
+ const config = readGlobalConfig();
6356
+ const apiUrl = options.apiUrl ?? process.env.SEEKRIT_API_URL ?? config.apiUrl ?? "https://api.seekrit.dev";
6357
+ const client = `cli/${version}`;
6358
+ const session = await createCliSessionToken();
6359
+ const deviceLabel = `${userInfo().username}@${hostname()}`;
6360
+ const started = await startCliLogin(apiUrl, {
6361
+ sessionId: session.sessionId,
6362
+ tokenHash: session.tokenHash,
6363
+ deviceLabel,
6364
+ client
6365
+ }, { client }).catch((err) => {
6366
+ fail(`couldn't start sign-in: ${err instanceof Error ? err.message : String(err)}`);
6367
+ });
6368
+ console.error(`Sign in to seekrit to authorize this device (${deviceLabel}).\n`);
6369
+ console.error(` ${started.verifyUrl}\n`);
6370
+ console.error(` code: ${started.code} — check it matches the one in your browser\n`);
6371
+ if (options.browser === false) console.error("Open that URL to continue.\n");
6372
+ else {
6373
+ await promptEnter("Press [Enter] to open it in your browser (Ctrl-C to cancel)… ");
6374
+ openBrowser(started.verifyUrl);
6375
+ }
6376
+ const stopWaiting = startWaitingIndicator("Waiting for you to authorize…");
6377
+ try {
6378
+ const deadline = Date.parse(started.requestExpiresAt);
6379
+ while (true) {
6380
+ const result = await pollCliLogin(apiUrl, started.code, { client }).catch(() => null);
6381
+ if (result?.status === "approved") {
6382
+ writeGlobalConfig({
6383
+ sessionToken: session.token,
6384
+ token: void 0,
6385
+ ...options.apiUrl ? { apiUrl: options.apiUrl } : {}
6386
+ });
6387
+ stopWaiting();
6388
+ const who = result.email ?? "your account";
6389
+ console.error(`Signed in as ${who} — this device is authorized for 90 days.`);
6390
+ if (config.token) console.error("(the service token saved here was replaced; SEEKRIT_TOKEN still wins)");
6391
+ await reportOrgs(apiUrl, session.token, client);
6392
+ return;
6393
+ }
6394
+ if (result?.status === "denied") {
6395
+ stopWaiting();
6396
+ fail("sign-in was declined in the browser");
6397
+ }
6398
+ if (result?.status === "expired" || Date.now() > deadline) {
6399
+ stopWaiting();
6400
+ fail("sign-in request expired — run `seekrit login` again");
6401
+ }
6402
+ await sleep(started.pollIntervalSeconds * 1e3);
6403
+ }
6404
+ } finally {
6405
+ stopWaiting();
6406
+ }
6407
+ }
6408
+ /**
6409
+ * Print what the new session can reach, so a successful login ends with proof it
6410
+ * works rather than a bare "ok". Best-effort: a hiccup here doesn't undo a
6411
+ * login that already succeeded.
6412
+ */
6413
+ async function reportOrgs(apiUrl, token, client) {
6414
+ try {
6415
+ const { user, orgs } = await new SeekritClient({
6416
+ baseUrl: apiUrl,
6417
+ auth: {
6418
+ type: "bearer",
6419
+ token
6420
+ },
6421
+ client
6422
+ }).me();
6423
+ for (const org of orgs) console.error(` ${org.slug} (${org.role})`);
6424
+ if (!user.hasKeys) console.error("\nNext: run `seekrit keys setup` to create your encryption keys.");
6425
+ } catch {}
6426
+ }
6427
+ /**
6428
+ * `seekrit logout` — drop the saved credential, and revoke it server-side when
6429
+ * it's a CLI session (the one credential this machine owns outright). A service
6430
+ * token is shared infrastructure that other machines may hold, so it is only
6431
+ * removed locally, never revoked out from under them.
6432
+ */
6433
+ async function runLogout() {
6434
+ const config = readGlobalConfig();
6435
+ const sessionToken = config.sessionToken;
6436
+ if (!sessionToken && !config.token && !config.devUser && !config.clientId) {
6437
+ console.error("not signed in");
6438
+ return;
6439
+ }
6440
+ if (sessionToken) {
6441
+ const api = new SeekritClient({
6442
+ baseUrl: process.env.SEEKRIT_API_URL ?? config.apiUrl ?? "https://api.seekrit.dev",
6443
+ auth: {
6444
+ type: "bearer",
6445
+ token: sessionToken
6446
+ },
6447
+ client: `cli/${version}`
6448
+ });
6449
+ try {
6450
+ const { currentSessionId } = await api.listCliSessions();
6451
+ if (currentSessionId) await api.revokeCliSession(currentSessionId);
6452
+ console.error("signed out — this device is no longer authorized");
6453
+ } catch (err) {
6454
+ console.error(`signed out locally, but couldn't revoke the session: ${err instanceof Error ? err.message : String(err)}`);
6455
+ }
6456
+ }
6457
+ writeGlobalConfig({
6458
+ sessionToken: void 0,
6459
+ token: void 0,
6460
+ devUser: void 0
6461
+ });
6462
+ if (config.clientId) console.error("(machine client credentials are kept — remove them with `seekrit login`)");
6463
+ }
6464
+ //#endregion
4738
6465
  //#region src/index.ts
4739
6466
  /** Collect repeated `--with group=env` flags into a map. */
4740
6467
  function collectKv(value, acc = {}) {
@@ -4752,11 +6479,21 @@ const program = new Command("seekrit").description("End-to-end encrypted secrets
4752
6479
  program.hook("preAction", async () => {
4753
6480
  await ensureM2mAdminToken();
4754
6481
  });
4755
- program.command("login").description("store credentials for the API").option("--token <token>", "service token (skt_…)").option("--client-id <id>", "machine (M2M) client id — auto-mints an admin token").option("--client-secret <secret>", "machine (M2M) client secret").option("--dev-user <email>", "dev-mode identity (local API with AUTH_MODE=dev)").option("--api-url <url>", "API base URL").action((options) => {
6482
+ program.command("login").description("sign in through the browser (or pass a credential to store one directly)").option("--token <token>", "service token (skt_…) — skips the browser").option("--client-id <id>", "machine (M2M) client id — auto-mints an admin token").option("--client-secret <secret>", "machine (M2M) client secret").option("--dev-user <email>", "dev-mode identity (local API with AUTH_MODE=dev)").option("--api-url <url>", "API base URL").option("--no-browser", "print the sign-in URL instead of opening it").action(async (options) => {
4756
6483
  if (options.token && !isServiceToken(options.token)) fail("token must start with skt_");
4757
6484
  if (Boolean(options.clientId) !== Boolean(options.clientSecret)) fail("--client-id and --client-secret must be given together");
6485
+ if (!(options.token || options.clientId || options.devUser)) {
6486
+ await runWebLogin({
6487
+ apiUrl: options.apiUrl,
6488
+ browser: options.browser
6489
+ });
6490
+ return;
6491
+ }
4758
6492
  writeGlobalConfig({
4759
- ...options.token ? { token: options.token } : {},
6493
+ ...options.token ? {
6494
+ token: options.token,
6495
+ sessionToken: void 0
6496
+ } : {},
4760
6497
  ...options.clientId ? { clientId: options.clientId } : {},
4761
6498
  ...options.clientSecret ? { clientSecret: options.clientSecret } : {},
4762
6499
  ...options.devUser ? { devUser: options.devUser } : {},
@@ -4764,6 +6501,9 @@ program.command("login").description("store credentials for the API").option("--
4764
6501
  });
4765
6502
  console.error("credentials saved");
4766
6503
  });
6504
+ program.command("logout").description("forget the saved credentials (revokes a browser-authorized session)").action(async () => {
6505
+ await runLogout();
6506
+ });
4767
6507
  program.command("whoami").description("show the authenticated identity").action(async () => {
4768
6508
  const ctx = buildContext();
4769
6509
  if (isTokenAuth(ctx) && ctx.auth.type === "bearer") {
@@ -4780,6 +6520,10 @@ program.command("whoami").description("show the authenticated identity").action(
4780
6520
  const { user, orgs } = await ctx.client.me();
4781
6521
  console.log(`${user.email}${user.hasKeys ? "" : " (key setup pending — run `seekrit keys setup`)"}`);
4782
6522
  for (const org of orgs) console.log(` ${org.slug} (${org.role})`);
6523
+ if (ctx.auth.type === "bearer" && isCliSessionToken(ctx.auth.token)) {
6524
+ const { sessionId } = parseCliSessionToken(ctx.auth.token);
6525
+ console.log(` via CLI session ${sessionId} (revoke it with \`seekrit logout\`)`);
6526
+ }
4783
6527
  });
4784
6528
  program.command("keys").description("manage your encryption keys").command("setup").description("generate your keypair and protect it with a passphrase").action(async () => {
4785
6529
  const ctx = buildContext();
@@ -4808,101 +6552,9 @@ program.command("init").description("link this directory to an org/app (environm
4808
6552
  });
4809
6553
  console.error(`linked ${org.slug}/${app.slug} → ${path} (environment is selected by the service token at runtime)`);
4810
6554
  });
4811
- program.command("org").description("manage organizations").command("create").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
4812
- const created = await buildContext().client.createOrg({
4813
- name: options.name,
4814
- slug: options.slug
4815
- });
4816
- console.error(`created org ${created.org.slug} (${created.org.id})`);
4817
- });
4818
- program.command("app").description("manage applications").command("create").option("--org <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
4819
- const ctx = buildContext();
4820
- const orgRef = await resolveOrg(ctx, options.org);
4821
- const created = await ctx.client.createApp(orgRef.id, {
4822
- name: options.name,
4823
- slug: options.slug
4824
- });
4825
- console.error(`created app ${created.app.slug} (${created.app.id})`);
4826
- });
4827
- const env = program.command("env").description("manage environments");
4828
- env.command("create").description("create an application environment (generates its data key locally)").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
4829
- const ctx = buildContext();
4830
- const orgRef = await resolveOrg(ctx, options.org);
4831
- const { apps } = await ctx.client.listApps(orgRef.id);
4832
- const appRow = apps.find((a) => a.slug === options.app || a.id === options.app);
4833
- if (!appRow) fail(`no app "${options.app}" in ${orgRef.slug}`);
4834
- const { user } = await ctx.client.me();
4835
- if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
4836
- const dek = generateDek();
4837
- const wrappedDek = await wrapDek(dek, user.publicKeyJwk);
4838
- const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, orgRef.id, dek);
4839
- const created = await ctx.client.createEnv(orgRef.id, appRow.id, {
4840
- name: options.name,
4841
- slug: options.slug,
4842
- wrappedDek,
4843
- recoveryWrappedDek
4844
- });
4845
- console.error(`created environment ${created.environment.slug} (${created.environment.id})`);
4846
- });
4847
- const envGroups = env.command("groups").description("compose shared groups into an application environment");
4848
- envGroups.command("add").description("compose a group into an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").requiredOption("--group <slug>").option("--position <n>", "precedence among groups (higher wins)").action(async (options) => {
4849
- const ctx = buildContext();
4850
- const target = await resolveAppEnv(ctx, options);
4851
- const group = await resolveGroup(ctx, {
4852
- org: options.org,
4853
- group: options.group
4854
- });
4855
- await ctx.client.linkEnvGroup(target.orgId, target.envId, {
4856
- groupId: group.id,
4857
- position: options.position === void 0 ? void 0 : Number.parseInt(options.position, 10)
4858
- });
4859
- console.error(`composed ${group.slug} into ${target.appSlug}/${target.envSlug}`);
4860
- });
4861
- envGroups.command("list").description("list groups composed into an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").action(async (options) => {
4862
- const ctx = buildContext();
4863
- const target = await resolveAppEnv(ctx, options);
4864
- const { groups } = await ctx.client.listEnvGroups(target.orgId, target.envId);
4865
- for (const g of groups) console.log(`${g.position}\t${g.slug}\t${g.name}`);
4866
- });
4867
- envGroups.command("rm").description("remove a group from an app environment").option("--org <slug>").requiredOption("--app <slug>").requiredOption("--env <slug>").requiredOption("--group <slug>").action(async (options) => {
4868
- const ctx = buildContext();
4869
- const target = await resolveAppEnv(ctx, options);
4870
- const group = await resolveGroup(ctx, {
4871
- org: options.org,
4872
- group: options.group
4873
- });
4874
- await ctx.client.unlinkEnvGroup(target.orgId, target.envId, group.id);
4875
- console.error(`removed ${group.slug} from ${target.appSlug}/${target.envSlug}`);
4876
- });
4877
- const group = program.command("group").description("manage shared groups (reusable secret bags)");
4878
- group.command("create").option("--org <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
4879
- const ctx = buildContext();
4880
- const orgRef = await resolveOrg(ctx, options.org);
4881
- const created = await ctx.client.createGroup(orgRef.id, {
4882
- name: options.name,
4883
- slug: options.slug
4884
- });
4885
- console.error(`created group ${created.group.slug} (${created.group.id})`);
4886
- });
4887
- group.command("env").description("manage a group’s environments (per-slug value sets / variants)").command("create").description("create a group environment (generates its data key locally)").option("--org <slug>").requiredOption("--group <slug>").requiredOption("--name <name>").requiredOption("--slug <slug>").action(async (options) => {
4888
- const ctx = buildContext();
4889
- const groupRef = await resolveGroup(ctx, {
4890
- org: options.org,
4891
- group: options.group
4892
- });
4893
- const { user } = await ctx.client.me();
4894
- if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
4895
- const dek = generateDek();
4896
- const wrappedDek = await wrapDek(dek, user.publicKeyJwk);
4897
- const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, groupRef.orgId, dek);
4898
- const created = await ctx.client.createGroupEnv(groupRef.orgId, groupRef.id, {
4899
- name: options.name,
4900
- slug: options.slug,
4901
- wrappedDek,
4902
- recoveryWrappedDek
4903
- });
4904
- console.error(`created ${groupRef.slug}@${created.environment.slug} (${created.environment.id})`);
4905
- });
6555
+ registerOrgCommands(program);
6556
+ registerAppCommands(program);
6557
+ registerGroupCommands(program);
4906
6558
  /** Parse a positive integer flag/argument (version numbers, page sizes). */
4907
6559
  function parseVersion(raw) {
4908
6560
  const n = Number(raw);
@@ -4914,11 +6566,21 @@ function withTarget(cmd) {
4914
6566
  return cmd.option("--org <slug>", "organization slug").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--group <slug>", "target a group environment instead of an app").option("--branch <slug>", "operate on a branch of --env").requiredOption("--env <slug>", "environment slug");
4915
6567
  }
4916
6568
  const secrets = program.command("secrets").description("manage secrets in an application or group environment");
4917
- withTarget(secrets.command("list").description("list secret names (no values)")).action(async (options) => {
6569
+ withTarget(secrets.command("list").alias("ls").description("list secret names (no values)").option("--json", "print the listing as JSON (metadata only — never values)")).action(async (options) => {
4918
6570
  const ctx = buildContext();
4919
- const { orgId, envId } = await resolveEnvTarget(ctx, options);
6571
+ const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
4920
6572
  const { secrets: rows } = await ctx.client.listSecrets(orgId, envId);
4921
- for (const row of rows) console.log(`${row.name}\tv${row.version}\t${row.updatedAt}`);
6573
+ emit(options, { secrets: rows.map(({ id, name, version, createdAt, updatedAt }) => ({
6574
+ id,
6575
+ name,
6576
+ version,
6577
+ createdAt,
6578
+ updatedAt
6579
+ })) }, () => printTable(rows, [
6580
+ col("name", (s) => s.name),
6581
+ col("version", (s) => `v${s.version}`),
6582
+ col("updated", (s) => s.updatedAt)
6583
+ ], `no secrets in ${label}`));
4922
6584
  });
4923
6585
  withTarget(secrets.command("get <name>").description("decrypt and print one secret value").option("--raw", "print the stored value without expanding ${OTHER_SECRET} references").option("--version <n>", "print an earlier version instead of the current one")).action(async (name, options) => {
4924
6586
  const ctx = buildContext();
@@ -4961,15 +6623,19 @@ withTarget(secrets.command("import [file]").description("bulk-import secrets fro
4961
6623
  const { created, updated } = await importSecrets(ctx, orgId, envId, entries);
4962
6624
  console.error(`imported ${created.length + updated.length} secret(s) into ${label} (${created.length} new, ${updated.length} updated)`);
4963
6625
  });
4964
- withTarget(secrets.command("history <name>").description("list a secret's versions (metadata only — no values)").option("--limit <n>", `how many versions to show (max 200)`, "20")).action(async (name, options) => {
6626
+ withTarget(secrets.command("history <name>").description("list a secret's versions (metadata only — no values)").option("--limit <n>", `how many versions to show (max 200)`, "20").option("--json", "print the raw API response")).action(async (name, options) => {
4965
6627
  const ctx = buildContext();
4966
6628
  const { orgId, envId } = await resolveEnvTarget(ctx, options);
4967
6629
  const { versions, currentVersion } = await ctx.client.listSecretVersions(orgId, envId, name, { limit: parseVersion(options.limit) });
4968
- for (const v of versions) {
4969
- const marks = [v.version === currentVersion ? "current" : null, v.restoredFromVersion === null ? null : `restored from v${v.restoredFromVersion}`].filter(Boolean);
4970
- const note = marks.length > 0 ? `\t${marks.join(", ")}` : "";
4971
- console.log(`v${v.version}\t${v.createdAt}\t${v.createdByType}:${v.createdById}${note}`);
4972
- }
6630
+ emit(options, {
6631
+ versions,
6632
+ currentVersion
6633
+ }, () => printTable(versions, [
6634
+ col("version", (v) => `v${v.version}`),
6635
+ col("when", (v) => v.createdAt),
6636
+ col("by", (v) => `${v.createdByType}:${v.createdById}`),
6637
+ col("note", (v) => [v.version === currentVersion ? "current" : null, v.restoredFromVersion === null ? null : `restored from v${v.restoredFromVersion}`].filter(Boolean).join(", "))
6638
+ ], `no versions of ${name}`));
4973
6639
  });
4974
6640
  withTarget(secrets.command("restore <name> <version>").description("roll a secret back to an earlier version (stored as a new version)")).action(async (name, version, options) => {
4975
6641
  const ctx = buildContext();
@@ -5202,42 +6868,7 @@ program.command("export").description("print decrypted secrets (dotenv, json, or
5202
6868
  });
5203
6869
  console.log(formatSecrets(values, options.format));
5204
6870
  });
5205
- program.command("grant").description("give a member or service token access to an environment's key").option("--org <slug>").option("--app <slug>").option("--group <slug>", "grant a group environment instead of an app").requiredOption("--env <slug>").option("--user <email>", "grant to an org member by email").option("--token <tokenId>", "grant to a service token by id (skt_…)").action(async (options) => {
5206
- if (!options.user === !options.token) fail("pass exactly one of --user or --token");
5207
- const ctx = buildContext();
5208
- const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
5209
- const dek = await getDek(ctx, orgId, envId);
5210
- let principalType;
5211
- let principalId;
5212
- let publicKeyJwk;
5213
- if (options.user) {
5214
- const { members } = await ctx.client.listMembers(orgId);
5215
- const member = members.find((m) => m.email === options.user);
5216
- if (!member) fail(`no member ${options.user}`);
5217
- if (!member.publicKeyJwk) fail(`${options.user} has not completed key setup`);
5218
- [principalType, principalId, publicKeyJwk] = [
5219
- "user",
5220
- member.userId,
5221
- member.publicKeyJwk
5222
- ];
5223
- } else {
5224
- const { tokens } = await ctx.client.listTokens(orgId);
5225
- const token = tokens.find((t) => t.id === options.token);
5226
- if (!token) fail(`no service token ${options.token}`);
5227
- [principalType, principalId, publicKeyJwk] = [
5228
- "service_token",
5229
- token.id,
5230
- token.publicKeyJwk
5231
- ];
5232
- }
5233
- const wrappedDek = await wrapDek(dek, publicKeyJwk);
5234
- await ctx.client.grantEnvKey(orgId, envId, {
5235
- principalType,
5236
- principalId,
5237
- wrappedDek
5238
- });
5239
- console.error(`granted ${label} access to ${principalId}`);
5240
- });
6871
+ registerAccessCommands(program);
5241
6872
  const token = program.command("token").description("manage service tokens (CI, docker, agents)");
5242
6873
  token.command("create").description("create a service token (runtime, or --admin for provisioning); prints it once").requiredOption("--name <name>", "display name, e.g. ci-deploy").option("--org <slug>").option("--app <slug>", "application to bind the token to (runtime tokens)").option("--env <slug>", "environment to bind the token to (runtime tokens)").option("--admin", "mint an org-scoped admin token that can provision structure (no env binding required)").option("--allow <group=env>", "also grant an alternate group slice (for `run --with`)", collectKv).option("--no-grant", "skip auto-granting the env + composed group keys").action(async (options) => {
5243
6874
  const ctx = buildContext();
@@ -5288,14 +6919,19 @@ token.command("create").description("create a service token (runtime, or --admin
5288
6919
  console.error(`${role} token created${granted ? " and granted" : ""} for ${scope} — save it now, it is not stored:`);
5289
6920
  console.log(created.token);
5290
6921
  });
5291
- token.command("list").description("list service tokens").option("--org <slug>").action(async (options) => {
6922
+ token.command("list").alias("ls").description("list service tokens").option("--org <slug>").option("--json", "print the raw API response").action(async (options) => {
5292
6923
  const ctx = buildContext();
5293
6924
  const orgRef = await resolveOrg(ctx, options.org);
5294
6925
  const { tokens } = await ctx.client.listTokens(orgRef.id);
5295
- for (const t of tokens) {
5296
- const status = t.revokedAt ? "revoked" : t.expiresAt && Date.parse(t.expiresAt) < Date.now() ? "expired" : "active";
5297
- console.log(`${t.id}\t${t.name}\t${status}\tlast used: ${t.lastUsedAt ?? "never"}`);
5298
- }
6926
+ const status = (t) => t.revokedAt ? "revoked" : t.expiresAt && Date.parse(t.expiresAt) < Date.now() ? "expired" : "active";
6927
+ emit(options, { tokens }, () => printTable(tokens, [
6928
+ col("name", (t) => t.name),
6929
+ col("role", (t) => t.role),
6930
+ col("status", (t) => status(t)),
6931
+ col("env", (t) => t.environmentId ?? "org-scoped"),
6932
+ col("last used", (t) => t.lastUsedAt ?? "never"),
6933
+ col("id", (t) => t.id)
6934
+ ], "no service tokens — create one with `seekrit token create`"));
5299
6935
  });
5300
6936
  token.command("revoke <tokenId>").description("revoke a service token").option("--org <slug>").action(async (tokenId, options) => {
5301
6937
  const ctx = buildContext();
@@ -5324,12 +6960,11 @@ program.command("mcp").description("run an MCP server over stdio so AI agents ca
5324
6960
  const { runMcpServer } = await import("./mcp-DLplPOvz.js");
5325
6961
  await runMcpServer();
5326
6962
  });
5327
- program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
5328
- const ctx = buildContext();
5329
- const orgRef = await resolveOrg(ctx, options.org);
5330
- const { entries } = await ctx.client.listAudit(orgRef.id, { limit: Number.parseInt(options.limit, 10) || 50 });
5331
- for (const entry of entries) console.log(`${entry.createdAt}\t${entry.action}\t${entry.actorType}:${entry.actorId}\t${entry.resourceType}${entry.resourceId ? `:${entry.resourceId}` : ""}`);
5332
- });
6963
+ registerAuditCommands(program);
6964
+ registerAccountCommands(program);
6965
+ registerLogSinkCommands(program);
6966
+ registerSyncCommands(program);
6967
+ registerBillingCommands(program);
5333
6968
  const argv = process.argv.map((arg) => arg === "-v" ? "--version" : arg);
5334
6969
  program.parseAsync(argv).catch((err) => {
5335
6970
  fail(err instanceof Error ? err.message : String(err));