@seekrit/cli 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -21,6 +21,16 @@ function fromBase64Url(text) {
21
21
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
22
22
  return bytes;
23
23
  }
24
+ /**
25
+ * Standard base64 (with `+`, `/`, and `=` padding). Most seekrit blobs use
26
+ * base64url, but some external wire formats mandate standard base64 — notably
27
+ * PostgreSQL SCRAM-SHA-256 verifier strings (see scram.ts).
28
+ */
29
+ function toBase64(bytes) {
30
+ let binary = "";
31
+ for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
32
+ return btoa(binary);
33
+ }
24
34
  function utf8Encode(text) {
25
35
  return new TextEncoder().encode(text);
26
36
  }
@@ -188,6 +198,65 @@ async function decryptPrivateKey(passphrase, blob) {
188
198
  throw new SeekritCryptoError("DECRYPT_FAILED", "wrong passphrase or corrupted key blob");
189
199
  }
190
200
  }
201
+ const SALT_LENGTH = 16;
202
+ const DEFAULT_PASSWORD_LENGTH = 32;
203
+ const PASSWORD_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
204
+ async function hmacSha256(key, message) {
205
+ const k = await crypto.subtle.importKey("raw", key, {
206
+ name: "HMAC",
207
+ hash: "SHA-256"
208
+ }, false, ["sign"]);
209
+ return new Uint8Array(await crypto.subtle.sign("HMAC", k, message));
210
+ }
211
+ async function sha256(data) {
212
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", data));
213
+ }
214
+ async function saltPassword(password, salt, iterations) {
215
+ const material = await crypto.subtle.importKey("raw", utf8Encode(password), "PBKDF2", false, ["deriveBits"]);
216
+ const bits = await crypto.subtle.deriveBits({
217
+ name: "PBKDF2",
218
+ hash: "SHA-256",
219
+ salt,
220
+ iterations
221
+ }, material, 256);
222
+ return new Uint8Array(bits);
223
+ }
224
+ function randomPassword(length) {
225
+ let out = "";
226
+ while (out.length < length) {
227
+ const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
228
+ for (const byte of bytes) {
229
+ if (byte < 248) out += PASSWORD_ALPHABET[byte % 62];
230
+ if (out.length === length) break;
231
+ }
232
+ }
233
+ return out;
234
+ }
235
+ /**
236
+ * Compute the `SCRAM-SHA-256$<i>:<salt>$<StoredKey>:<ServerKey>` verifier for a
237
+ * known password. Pass the result straight to `CREATE ROLE … PASSWORD`.
238
+ */
239
+ async function scramSha256Verifier(password, options = {}) {
240
+ const salt = options.salt ?? crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
241
+ const iterations = options.iterations ?? 4096;
242
+ const saltedPassword = await saltPassword(password, salt, iterations);
243
+ const storedKey = await sha256(await hmacSha256(saltedPassword, utf8Encode("Client Key")));
244
+ const serverKey = await hmacSha256(saltedPassword, utf8Encode("Server Key"));
245
+ return `SCRAM-SHA-256$${iterations}:${toBase64(salt)}$${toBase64(storedKey)}:${toBase64(serverKey)}`;
246
+ }
247
+ /**
248
+ * Mint a fresh random password and its SCRAM verifier in one step — the
249
+ * client-side half of a Vault-style dynamic Postgres credential.
250
+ */
251
+ async function generatePostgresCredential(options = {}) {
252
+ const password = randomPassword(options.length ?? DEFAULT_PASSWORD_LENGTH);
253
+ const iterations = options.iterations ?? 4096;
254
+ return {
255
+ password,
256
+ verifier: await scramSha256Verifier(password, { iterations }),
257
+ iterations
258
+ };
259
+ }
191
260
  //#endregion
192
261
  //#region ../../packages/crypto/src/token.ts
193
262
  /**
@@ -315,7 +384,7 @@ async function unwrapDek(wrapped, privateKey) {
315
384
  }
316
385
  //#endregion
317
386
  //#region package.json
318
- var version = "0.3.0";
387
+ var version = "0.5.0";
319
388
  const PROJECT_FILE = "seekrit.json";
320
389
  function globalConfigPath() {
321
390
  return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
@@ -506,6 +575,29 @@ var SeekritClient = class {
506
575
  revokeToken(orgId, tokenId) {
507
576
  return this.request("DELETE", `/v1/orgs/${orgId}/tokens/${tokenId}`);
508
577
  }
578
+ /** The broker's public key — wrap the admin credential to it before registering a target. */
579
+ getLeaseBrokerKey(orgId) {
580
+ return this.request("GET", `/v1/orgs/${orgId}/leases/broker-key`);
581
+ }
582
+ listLeaseTargets(orgId) {
583
+ return this.request("GET", `/v1/orgs/${orgId}/leases/targets`);
584
+ }
585
+ registerLeaseTarget(orgId, input) {
586
+ return this.request("POST", `/v1/orgs/${orgId}/leases/targets`, input);
587
+ }
588
+ deleteLeaseTarget(orgId, targetId) {
589
+ return this.request("DELETE", `/v1/orgs/${orgId}/leases/targets/${targetId}`);
590
+ }
591
+ listLeases(orgId) {
592
+ return this.request("GET", `/v1/orgs/${orgId}/leases`);
593
+ }
594
+ /** Mint a temporary credential. Send only the client-computed verifier. */
595
+ mintLease(orgId, input) {
596
+ return this.request("POST", `/v1/orgs/${orgId}/leases`, input);
597
+ }
598
+ revokeLease(orgId, leaseId) {
599
+ return this.request("DELETE", `/v1/orgs/${orgId}/leases/${leaseId}`);
600
+ }
509
601
  listAudit(orgId, query = {}) {
510
602
  const params = new URLSearchParams();
511
603
  if (query.cursor) params.set("cursor", query.cursor);
@@ -563,12 +655,19 @@ async function readStdin() {
563
655
  * Build the client context from configured credentials, or return null when
564
656
  * none are set. `seekrit run` uses this to degrade to a plain launcher instead
565
657
  * of exiting; every other command goes through `buildContext`, which fails.
658
+ *
659
+ * `dotenvVars` supplies `SEEKRIT_*` values read from a `.env` file. They sit
660
+ * below the live `process.env` but above the saved config, so a project-local
661
+ * `.env` can carry the token / API URL — matching `seekrit-run`'s
662
+ * `flag > env > .env` credential resolution. Empty for every command but
663
+ * `seekrit run`, which loads `.env` before authenticating.
566
664
  */
567
- function tryBuildContext() {
665
+ function tryBuildContext(dotenvVars = {}) {
568
666
  const config = readGlobalConfig();
569
- const apiUrl = process.env.SEEKRIT_API_URL ?? config.apiUrl ?? "http://localhost:8787";
570
- const token = process.env.SEEKRIT_TOKEN ?? config.token;
571
- const devUser = process.env.SEEKRIT_DEV_USER ?? config.devUser;
667
+ const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
668
+ const apiUrl = fromEnv("SEEKRIT_API_URL") ?? config.apiUrl ?? "https://api.seekrit.dev";
669
+ const token = fromEnv("SEEKRIT_TOKEN") ?? config.token;
670
+ const devUser = fromEnv("SEEKRIT_DEV_USER") ?? config.devUser;
572
671
  let auth;
573
672
  if (token) auth = {
574
673
  type: "bearer",
@@ -634,6 +733,212 @@ function formatSecrets(values, format) {
634
733
  }
635
734
  }
636
735
  //#endregion
736
+ //#region src/target.ts
737
+ /** Resolve the target org from a flag, the committed config, or a lone org. */
738
+ async function resolveOrg(ctx, orgSlug) {
739
+ const wanted = orgSlug ?? findProjectConfig()?.org;
740
+ const { orgs } = await ctx.client.listOrgs();
741
+ if (wanted) {
742
+ const org = orgs.find((o) => o.slug === wanted || o.id === wanted);
743
+ if (!org) fail(`no accessible org "${wanted}"`);
744
+ return {
745
+ id: org.id,
746
+ slug: org.slug
747
+ };
748
+ }
749
+ const only = orgs[0];
750
+ if (orgs.length === 1 && only) return {
751
+ id: only.id,
752
+ slug: only.slug
753
+ };
754
+ fail("specify --org (or run `seekrit init`)");
755
+ }
756
+ /**
757
+ * Resolve an environment to operate on — an application env (`--app --env`,
758
+ * or the config's app + `--env`) or a group env (`--group --env`).
759
+ */
760
+ async function resolveEnvTarget(ctx, opts) {
761
+ const org = await resolveOrg(ctx, opts.org);
762
+ if (!opts.env) fail("specify --env");
763
+ if (opts.group) {
764
+ const { groups } = await ctx.client.listGroups(org.id);
765
+ const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
766
+ if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
767
+ const { environments } = await ctx.client.listGroupEnvs(org.id, group.id);
768
+ const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
769
+ if (!env) fail(`no environment "${opts.env}" in group ${group.slug}`);
770
+ return {
771
+ orgId: org.id,
772
+ envId: env.id,
773
+ label: `${group.slug}@${env.slug}`
774
+ };
775
+ }
776
+ const appSlug = opts.app ?? findProjectConfig()?.app;
777
+ if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
778
+ const { apps } = await ctx.client.listApps(org.id);
779
+ const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
780
+ if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
781
+ const { environments } = await ctx.client.listEnvs(org.id, app.id);
782
+ const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
783
+ if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
784
+ return {
785
+ orgId: org.id,
786
+ envId: env.id,
787
+ label: `${app.slug}/${env.slug}`
788
+ };
789
+ }
790
+ /** Resolve an application environment, keeping ids + slugs (for token binding). */
791
+ async function resolveAppEnv(ctx, opts) {
792
+ const org = await resolveOrg(ctx, opts.org);
793
+ const appSlug = opts.app ?? findProjectConfig()?.app;
794
+ if (!appSlug) fail("specify --app (or run `seekrit init`)");
795
+ if (!opts.env) fail("specify --env");
796
+ const { apps } = await ctx.client.listApps(org.id);
797
+ const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
798
+ if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
799
+ const { environments } = await ctx.client.listEnvs(org.id, app.id);
800
+ const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
801
+ if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
802
+ return {
803
+ orgId: org.id,
804
+ appId: app.id,
805
+ appSlug: app.slug,
806
+ envId: env.id,
807
+ envSlug: env.slug
808
+ };
809
+ }
810
+ /** Resolve a group by slug within the target org. */
811
+ async function resolveGroup(ctx, opts) {
812
+ const org = await resolveOrg(ctx, opts.org);
813
+ if (!opts.group) fail("specify --group");
814
+ const { groups } = await ctx.client.listGroups(org.id);
815
+ const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
816
+ if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
817
+ return {
818
+ orgId: org.id,
819
+ id: group.id,
820
+ slug: group.slug
821
+ };
822
+ }
823
+ //#endregion
824
+ //#region src/pg.ts
825
+ /**
826
+ * `seekrit pg` — temporary Postgres credentials (Vault-style dynamic secrets).
827
+ *
828
+ * Zero-knowledge: minting generates the password and its SCRAM verifier on THIS
829
+ * machine and sends only the verifier; the plaintext password never reaches the
830
+ * API or gets stored. Registering a target wraps the admin connection string to
831
+ * the broker's public key locally, so the control plane only ever stores
832
+ * ciphertext.
833
+ */
834
+ /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
835
+ function parseTtlSeconds(input) {
836
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
837
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
838
+ return Number(m[1]) * ({
839
+ s: 1,
840
+ m: 60,
841
+ h: 3600,
842
+ d: 86400
843
+ }[m[2] || "s"] ?? 1);
844
+ }
845
+ /** A fresh, valid Postgres role name: `tmp_` + lowercase alphanumerics. */
846
+ function generateRoleName(prefix = "tmp") {
847
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
848
+ let out = "";
849
+ const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
850
+ for (const b of bytes) out += alphabet[b % 36];
851
+ return `${prefix}_${out}`;
852
+ }
853
+ function registerPgCommands(program) {
854
+ const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
855
+ const target = pg.command("target").description("manage provisioning targets");
856
+ target.command("add").description("register a Postgres cluster to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "5432").requiredOption("--database <name>", "database to connect to").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--admin-url <url>", "admin postgres:// connection string (or set SEEKRIT_PG_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect, []).action(async (options) => {
857
+ const ctx = buildContext();
858
+ const org = await resolveOrg(ctx, options.org);
859
+ const executor = options.executor === "remote" ? "remote" : "in_do";
860
+ if (executor === "remote" && !options.provisionerUrl) fail("--provisioner-url is required for the remote executor");
861
+ const adminSecret = options.adminUrl ?? process.env.SEEKRIT_PG_ADMIN_URL;
862
+ if (!adminSecret) fail(executor === "remote" ? "provide the shared HMAC key via --admin-url or SEEKRIT_PG_ADMIN_URL" : "provide the admin connection string via --admin-url or SEEKRIT_PG_ADMIN_URL");
863
+ const config = {
864
+ provider: "postgres",
865
+ executor,
866
+ connection: {
867
+ host: options.host,
868
+ port: Number.parseInt(options.port, 10),
869
+ database: options.database
870
+ },
871
+ ...options.provisionerUrl ? { provisionerUrl: options.provisionerUrl } : {},
872
+ ...options.createStatement.length ? { createStatements: options.createStatement } : {},
873
+ ...options.revokeStatement.length ? { revokeStatements: options.revokeStatement } : {}
874
+ };
875
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
876
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(adminSecret), publicKeyJwk);
877
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
878
+ name: options.name,
879
+ config,
880
+ wrappedAdminSecret
881
+ });
882
+ console.error(`registered target ${created.name} (${created.id})`);
883
+ });
884
+ target.command("list").description("list provisioning targets").option("--org <slug>").action(async (options) => {
885
+ const ctx = buildContext();
886
+ const org = await resolveOrg(ctx, options.org);
887
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
888
+ for (const t of targets) {
889
+ const cfg = t.config;
890
+ console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${cfg.connection.database}\t${cfg.executor}`);
891
+ }
892
+ });
893
+ target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>").action(async (targetId, options) => {
894
+ const ctx = buildContext();
895
+ const org = await resolveOrg(ctx, options.org);
896
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
897
+ console.error(`removed ${targetId}`);
898
+ });
899
+ pg.command("lease <target>").description("mint a temporary credential; prints a ready-to-use connection URL").option("--org <slug>").option("--role <name>", "role name to create (default: a random tmp_ name)").option("--ttl <duration>", "lifetime, e.g. 30m, 1h, 7d", "1h").option("--json", "print the full connection as JSON").action(async (targetRef, options) => {
900
+ const ctx = buildContext();
901
+ const org = await resolveOrg(ctx, options.org);
902
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
903
+ const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
904
+ if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
905
+ const roleName = options.role ?? generateRoleName();
906
+ const ttlSeconds = parseTtlSeconds(options.ttl);
907
+ const { password, verifier } = await generatePostgresCredential();
908
+ const { connection } = await ctx.client.mintLease(org.id, {
909
+ targetId: target.id,
910
+ roleName,
911
+ verifier,
912
+ ttlSeconds
913
+ });
914
+ const url = `postgres://${roleName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
915
+ console.error(`leased ${roleName} on ${connection.host}/${connection.database} — expires ${connection.expiresAt}`);
916
+ if (options.json) console.log(JSON.stringify({
917
+ ...connection,
918
+ password,
919
+ url
920
+ }, null, 2));
921
+ else console.log(url);
922
+ });
923
+ pg.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
924
+ const ctx = buildContext();
925
+ const org = await resolveOrg(ctx, options.org);
926
+ const { leases } = await ctx.client.listLeases(org.id);
927
+ for (const l of leases) console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
928
+ });
929
+ pg.command("revoke <leaseId>").description("revoke a lease now (drops the role immediately)").option("--org <slug>").action(async (leaseId, options) => {
930
+ const ctx = buildContext();
931
+ const org = await resolveOrg(ctx, options.org);
932
+ await ctx.client.revokeLease(org.id, leaseId);
933
+ console.error(`revoked ${leaseId}`);
934
+ });
935
+ }
936
+ /** Collect a repeatable option into an array. */
937
+ function collect(value, acc) {
938
+ acc.push(value);
939
+ return acc;
940
+ }
941
+ //#endregion
637
942
  //#region src/dotenv.ts
638
943
  /**
639
944
  * Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
@@ -734,94 +1039,6 @@ function printExplain(provenance) {
734
1039
  for (const name of names) process.stderr.write(`${name.padEnd(width)} ${provenance[name]}\n`);
735
1040
  }
736
1041
  //#endregion
737
- //#region src/target.ts
738
- /** Resolve the target org from a flag, the committed config, or a lone org. */
739
- async function resolveOrg(ctx, orgSlug) {
740
- const wanted = orgSlug ?? findProjectConfig()?.org;
741
- const { orgs } = await ctx.client.listOrgs();
742
- if (wanted) {
743
- const org = orgs.find((o) => o.slug === wanted || o.id === wanted);
744
- if (!org) fail(`no accessible org "${wanted}"`);
745
- return {
746
- id: org.id,
747
- slug: org.slug
748
- };
749
- }
750
- const only = orgs[0];
751
- if (orgs.length === 1 && only) return {
752
- id: only.id,
753
- slug: only.slug
754
- };
755
- fail("specify --org (or run `seekrit init`)");
756
- }
757
- /**
758
- * Resolve an environment to operate on — an application env (`--app --env`,
759
- * or the config's app + `--env`) or a group env (`--group --env`).
760
- */
761
- async function resolveEnvTarget(ctx, opts) {
762
- const org = await resolveOrg(ctx, opts.org);
763
- if (!opts.env) fail("specify --env");
764
- if (opts.group) {
765
- const { groups } = await ctx.client.listGroups(org.id);
766
- const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
767
- if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
768
- const { environments } = await ctx.client.listGroupEnvs(org.id, group.id);
769
- const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
770
- if (!env) fail(`no environment "${opts.env}" in group ${group.slug}`);
771
- return {
772
- orgId: org.id,
773
- envId: env.id,
774
- label: `${group.slug}@${env.slug}`
775
- };
776
- }
777
- const appSlug = opts.app ?? findProjectConfig()?.app;
778
- if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
779
- const { apps } = await ctx.client.listApps(org.id);
780
- const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
781
- if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
782
- const { environments } = await ctx.client.listEnvs(org.id, app.id);
783
- const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
784
- if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
785
- return {
786
- orgId: org.id,
787
- envId: env.id,
788
- label: `${app.slug}/${env.slug}`
789
- };
790
- }
791
- /** Resolve an application environment, keeping ids + slugs (for token binding). */
792
- async function resolveAppEnv(ctx, opts) {
793
- const org = await resolveOrg(ctx, opts.org);
794
- const appSlug = opts.app ?? findProjectConfig()?.app;
795
- if (!appSlug) fail("specify --app (or run `seekrit init`)");
796
- if (!opts.env) fail("specify --env");
797
- const { apps } = await ctx.client.listApps(org.id);
798
- const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
799
- if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
800
- const { environments } = await ctx.client.listEnvs(org.id, app.id);
801
- const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
802
- if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
803
- return {
804
- orgId: org.id,
805
- appId: app.id,
806
- appSlug: app.slug,
807
- envId: env.id,
808
- envSlug: env.slug
809
- };
810
- }
811
- /** Resolve a group by slug within the target org. */
812
- async function resolveGroup(ctx, opts) {
813
- const org = await resolveOrg(ctx, opts.org);
814
- if (!opts.group) fail("specify --group");
815
- const { groups } = await ctx.client.listGroups(org.id);
816
- const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
817
- if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
818
- return {
819
- orgId: org.id,
820
- id: group.id,
821
- slug: group.slug
822
- };
823
- }
824
- //#endregion
825
1042
  //#region src/index.ts
826
1043
  /** Collect repeated `--with group=env` flags into a map. */
827
1044
  function collectKv(value, acc = {}) {
@@ -1028,8 +1245,10 @@ async function materialize(ctx, options) {
1028
1245
  */
1029
1246
  async function materializeForRun(options) {
1030
1247
  const envFiles = options.envFile ?? [".env"];
1248
+ const dotenvVars = {};
1249
+ overlayEnvFiles(dotenvVars, {}, envFiles);
1031
1250
  try {
1032
- const ctx = tryBuildContext();
1251
+ const ctx = tryBuildContext(dotenvVars);
1033
1252
  if (!ctx) throw new Error("no credentials found (set SEEKRIT_TOKEN / SEEKRIT_DEV_USER or run `seekrit login`)");
1034
1253
  return await materialize(ctx, options);
1035
1254
  } catch (err) {
@@ -1176,8 +1395,9 @@ token.command("revoke <tokenId>").description("revoke a service token").option("
1176
1395
  await ctx.client.revokeToken(orgRef.id, tokenId);
1177
1396
  console.error(`${tokenId} revoked`);
1178
1397
  });
1398
+ registerPgCommands(program);
1179
1399
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
1180
- const { runMcpServer } = await import("./mcp-hxTidFyj.js");
1400
+ const { runMcpServer } = await import("./mcp-CAX4E0Zg.js");
1181
1401
  await runMcpServer();
1182
1402
  });
1183
1403
  program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
@@ -1190,4 +1410,4 @@ program.parseAsync().catch((err) => {
1190
1410
  fail(err instanceof Error ? err.message : String(err));
1191
1411
  });
1192
1412
  //#endregion
1193
- export { parseServiceToken as _, encryptAndSetSecret as a, getDek as c, setFailThrows as d, writeProjectConfig as f, isServiceToken as g, createServiceToken as h, resolveOrg as i, isTokenAuth as l, wrapDek as m, resolveEnvTarget as n, fetchDecryptedSecrets as o, version as p, resolveGroup as r, materializeEnv as s, resolveAppEnv as t, tryBuildContext as u, generateDek as v };
1413
+ export { parseServiceToken as _, resolveEnvTarget as a, getDek as c, setFailThrows as d, writeProjectConfig as f, isServiceToken as g, createServiceToken as h, resolveAppEnv as i, isTokenAuth as l, wrapDek as m, fetchDecryptedSecrets as n, resolveGroup as o, version as p, materializeEnv as r, resolveOrg as s, encryptAndSetSecret as t, tryBuildContext as u, generatePostgresCredential as v, generateDek as y };
@@ -1,9 +1,16 @@
1
- import { _ as parseServiceToken, a as encryptAndSetSecret, c as getDek, d as setFailThrows, f as writeProjectConfig, g as isServiceToken, h as createServiceToken, i as resolveOrg, l as isTokenAuth, m as wrapDek, n as resolveEnvTarget, o as fetchDecryptedSecrets, p as version, r as resolveGroup, s as materializeEnv, t as resolveAppEnv, u as tryBuildContext, v as generateDek } from "./index.js";
1
+ import { _ as parseServiceToken, a as resolveEnvTarget, c as getDek, d as setFailThrows, f as writeProjectConfig, g as isServiceToken, h as createServiceToken, i as resolveAppEnv, l as isTokenAuth, m as wrapDek, n as fetchDecryptedSecrets, o as resolveGroup, p as version, r as materializeEnv, s as resolveOrg, t as encryptAndSetSecret, u as tryBuildContext, v as generatePostgresCredential, y as generateDek } from "./index.js";
2
2
  import { spawn } from "node:child_process";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { z } from "zod";
6
6
  //#region src/mcp.ts
7
+ /** Lowercase-alphanumeric string for a fresh Postgres role name. */
8
+ function randomLower(length) {
9
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
10
+ let out = "";
11
+ for (const b of crypto.getRandomValues(new Uint8Array(length))) out += alphabet[b % 36];
12
+ return out;
13
+ }
7
14
  function jsonText(data) {
8
15
  return { content: [{
9
16
  type: "text",
@@ -534,6 +541,56 @@ async function runMcpServer() {
534
541
  principalId
535
542
  };
536
543
  });
544
+ tool("list_pg_targets", "List registered Postgres provisioning targets for temporary credentials.", { org: z.string().optional() }, async ({ org }) => {
545
+ const ctx = getCtx();
546
+ const orgRef = await resolveOrg(ctx, org);
547
+ return (await ctx.client.listLeaseTargets(orgRef.id)).targets;
548
+ });
549
+ tool("create_pg_lease", "Mint a short-lived Postgres credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its SCRAM verifier is sent to the API — the plaintext never reaches seekrit or Postgres at rest. The role auto-expires; revoke early with revoke_pg_lease.", {
550
+ org: z.string().optional(),
551
+ target: z.string().describe("target id or name"),
552
+ role: z.string().optional().describe("role name to create (default: random tmp_ name)"),
553
+ ttlSeconds: z.number().int().min(60).max(604800).optional().describe("lifetime (default 3600)")
554
+ }, async ({ org, target, role, ttlSeconds }) => {
555
+ const ctx = getCtx();
556
+ const orgRef = await resolveOrg(ctx, org);
557
+ const { targets } = await ctx.client.listLeaseTargets(orgRef.id);
558
+ const t = targets.find((x) => x.id === target || x.name === target);
559
+ if (!t) throw new Error(`no target "${target}" in ${orgRef.slug}`);
560
+ const roleName = role ?? `tmp_${randomLower(12)}`;
561
+ const { password, verifier } = await generatePostgresCredential();
562
+ const { lease, connection } = await ctx.client.mintLease(orgRef.id, {
563
+ targetId: t.id,
564
+ roleName,
565
+ verifier,
566
+ ttlSeconds: ttlSeconds ?? 3600
567
+ });
568
+ const url = `postgres://${roleName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
569
+ return {
570
+ leaseId: lease.id,
571
+ url,
572
+ username: roleName,
573
+ expiresAt: connection.expiresAt,
574
+ note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
575
+ };
576
+ });
577
+ tool("list_pg_leases", "List Postgres leases (the ledger — never secret material).", { org: z.string().optional() }, async ({ org }) => {
578
+ const ctx = getCtx();
579
+ const orgRef = await resolveOrg(ctx, org);
580
+ return (await ctx.client.listLeases(orgRef.id)).leases;
581
+ });
582
+ tool("revoke_pg_lease", "Revoke a Postgres lease now (drops the role immediately).", {
583
+ org: z.string().optional(),
584
+ leaseId: z.string()
585
+ }, async ({ org, leaseId }) => {
586
+ const ctx = getCtx();
587
+ const orgRef = await resolveOrg(ctx, org);
588
+ await ctx.client.revokeLease(orgRef.id, leaseId);
589
+ return {
590
+ ok: true,
591
+ leaseId
592
+ };
593
+ });
537
594
  tool("configure_project", "Link a directory to an org/app by writing seekrit.json (like `seekrit init`). The environment is chosen by the service token at runtime.", {
538
595
  org: z.string(),
539
596
  app: z.string(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {