@redaktyn/cli 1.1.0 → 1.2.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/README.md CHANGED
@@ -193,6 +193,58 @@ After generating the JSON:
193
193
 
194
194
  ---
195
195
 
196
+ ## `redaktyn-connect` and `redaktyn-invite` — a developer who never holds admin credentials
197
+
198
+ `redaktyn-import` and `redaktyn-scan` normally need an OWNER/ADMIN login token and the workspace
199
+ passphrase. That is the right thing for an admin, and the wrong thing to hand a developer who only has to
200
+ enrol one engagement's secrets. An invite gives them the narrow version instead.
201
+
202
+ **Admin: mint a single-use link for one project.**
203
+
204
+ ```bash
205
+ export REDAKTYN_SERVER_URL=https://redaktyn-api.onrender.com
206
+ export REDAKTYN_TOKEN=... # OWNER/ADMIN login token
207
+ export REDAKTYN_PASSPHRASE='...' # stays on this machine
208
+
209
+ redaktyn-invite --project acme-engagement --can-enrol --expires-days 3
210
+ # -> https://redaktyn.com/connect?code=ABCD1234#w=...
211
+ ```
212
+
213
+ The project's scanning key is derived locally and sealed under a one-time key that exists only in the link's
214
+ `#fragment`. The server stores ciphertext it cannot open, and the fragment is never sent to it. `--can-enrol`
215
+ is off unless you ask: without it the link is scan-only. A link carries a key for **one** device, so it works
216
+ once.
217
+
218
+ **Developer: connect once, then work.**
219
+
220
+ ```bash
221
+ redaktyn-connect # prompts for the link, so it stays out of shell history
222
+ redaktyn-import --env .env # dry run, no environment variables needed
223
+ redaktyn-import --env .env --apply
224
+ redaktyn-scan --staged # also uses the saved key
225
+ redaktyn-connect --status | --logout
226
+ ```
227
+
228
+ What the developer has, and does not have:
229
+
230
+ | | |
231
+ |---|---|
232
+ | Can | scan, and (with `--can-enrol`) register and re-fingerprint secrets **on that one project** |
233
+ | Cannot | enrol anywhere else, delete or edit a secret, see another project's digests, or reach any admin route |
234
+ | Holds | that project's key only (`HKDF(workspace key, project id)`), never the workspace key or passphrase |
235
+ | Revoked by | an admin removing the device (`DELETE /api/users/:id`): it stops working at once, not when a token expires |
236
+
237
+ The session lives in `~/.config/redaktyn/session.json` (directory `0700`, file `0600`), the same protection
238
+ `~/.aws/credentials` relies on. It is a file and not the OS keychain on purpose: macOS `security
239
+ add-generic-password` takes the secret as a command-line argument, which any process on the machine can read
240
+ with `ps` while it runs. The token renews itself; if the session cannot be renewed, or an admin removed the
241
+ device, the command says to ask for a new link. An admin's own `REDAKTYN_TOKEN` in the environment always takes
242
+ precedence over a saved session.
243
+
244
+ **Server note:** `--can-enrol` needs the `InviteEnrolGrant` table. Schema changes here are applied by hand
245
+ (`npx prisma db push`), and an invite minted before that is refused with `ENROL_GRANTS_UNAVAILABLE` rather
246
+ than quietly becoming a plain link.
247
+
196
248
  ## `redaktyn-import` — bulk enrollment from files and vaults
197
249
 
198
250
  Enrollment used to mean typing every secret into the dashboard by hand, so coverage was
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@redaktyn/cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Redaktyn CLI: bulk-enrol secrets from a .env or any vault (--project aware), local fingerprints, git/directory digest scans. Digests are computed on your machine; secret values never leave it.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "redaktyn-fingerprint": "./redaktyn-fingerprint.mjs",
8
8
  "redaktyn-import": "./redaktyn-import.mjs",
9
9
  "redaktyn-scan": "./redaktyn-scan.mjs",
10
- "redaktyn-verify": "./redaktyn-verify.mjs"
10
+ "redaktyn-verify": "./redaktyn-verify.mjs",
11
+ "redaktyn-connect": "./redaktyn-connect.mjs",
12
+ "redaktyn-invite": "./redaktyn-invite.mjs"
11
13
  },
12
14
  "dependencies": {
13
15
  "@redaktyn/shared": "^1.0.0"
@@ -37,10 +39,13 @@
37
39
  "access": "public"
38
40
  },
39
41
  "files": [
42
+ "README.md",
43
+ "redaktyn-connect.mjs",
40
44
  "redaktyn-fingerprint.mjs",
41
45
  "redaktyn-import.mjs",
46
+ "redaktyn-invite.mjs",
42
47
  "redaktyn-scan.mjs",
43
- "redaktyn-verify.mjs",
44
- "README.md"
48
+ "redaktyn-session.mjs",
49
+ "redaktyn-verify.mjs"
45
50
  ]
46
51
  }
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * redaktyn-connect: set this machine up from an invite link, so enrolling secrets
4
+ * never needs an admin login or the workspace passphrase.
5
+ *
6
+ * redaktyn-connect prompts for the link (kept out of shell history)
7
+ * redaktyn-connect --stdin reads the link from standard input
8
+ * redaktyn-connect '<link>' quote it: the part after # is the key
9
+ * redaktyn-connect --status what this machine is connected to
10
+ * redaktyn-connect --logout forget the session on this machine
11
+ *
12
+ * After connecting, `redaktyn-import --env .env` needs no environment variables.
13
+ * Ask your admin for the link; they create it with `redaktyn-invite`.
14
+ */
15
+ import { pathToFileURL } from "node:url";
16
+ import { realpathSync } from "node:fs";
17
+ import * as readline from "node:readline";
18
+
19
+ import { SessionError, connectWithInvite, deleteSession, loadSession, sessionDir } from "./redaktyn-session.mjs";
20
+
21
+ function help() {
22
+ console.log(`redaktyn-connect: set this machine up from an invite link
23
+
24
+ redaktyn-connect prompt for the link (not saved in shell history)
25
+ redaktyn-connect --stdin read the link from standard input
26
+ redaktyn-connect '<link>' pass the link; quote it, the key is after the #
27
+ redaktyn-connect --status show what this machine is connected to
28
+ redaktyn-connect --logout forget the session on this machine
29
+
30
+ --server <url> Override the server (default: the link's own address)
31
+
32
+ The session is stored in ${sessionDir()} (readable only by you).`);
33
+ }
34
+
35
+ function ask(question) {
36
+ return new Promise((resolve) => {
37
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
38
+ rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
39
+ });
40
+ }
41
+ async function readStdin() {
42
+ let s = "";
43
+ for await (const c of process.stdin) s += c;
44
+ return s.trim();
45
+ }
46
+
47
+ function show(s) {
48
+ console.log(`Connected to ${s.orgName ?? s.orgId}${s.projectName ? `, project ${s.projectName}` : ""}.`);
49
+ console.log(` server ${s.serverUrl}`);
50
+ console.log(` can enrol ${s.canEnrol ? "yes, on this project only" : "no (scan only). Ask for an invite that allows enrolling."}`);
51
+ console.log(` key ${s.keyFingerprint} (a fingerprint, never the key)`);
52
+ }
53
+
54
+ export async function main(argv) {
55
+ const args = argv.slice(2);
56
+ if (args.includes("--help") || args.includes("-h")) return help(), 0;
57
+ if (args.includes("--logout")) {
58
+ const had = !!loadSession();
59
+ deleteSession();
60
+ console.log(had ? "Forgot this machine's Redaktyn session." : "There was no session to forget.");
61
+ return 0;
62
+ }
63
+ if (args.includes("--status")) {
64
+ const s = loadSession();
65
+ if (!s) { console.log("Not connected. Run: redaktyn-connect"); return 1; }
66
+ show(s);
67
+ return 0;
68
+ }
69
+ const si = args.indexOf("--server");
70
+ const server = si >= 0 ? args[si + 1] : undefined;
71
+ let link = args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--server");
72
+ if (args.includes("--stdin")) link = await readStdin();
73
+ if (!link) {
74
+ if (!process.stdin.isTTY) { console.error("Pass the link, use --stdin, or run this in a terminal."); return 2; }
75
+ link = await ask("Invite link: ");
76
+ }
77
+ try {
78
+ const s = await connectWithInvite(link, { serverUrl: server });
79
+ show(s);
80
+ console.log("\nNext: redaktyn-import --env .env (add --apply to register)");
81
+ return 0;
82
+ } catch (e) {
83
+ if (e instanceof SessionError) { console.error(e.message); return 1; }
84
+ throw e;
85
+ }
86
+ }
87
+
88
+ function realArgv1() { try { return realpathSync(process.argv[1]); } catch { return process.argv[1]; } }
89
+ if (process.argv[1] && import.meta.url === pathToFileURL(realArgv1()).href) {
90
+ main(process.argv).then((c) => process.exit(c), (e) => { console.error(e.message); process.exit(1); });
91
+ }
@@ -32,7 +32,7 @@
32
32
  import { createHmac, pbkdf2 as _pbkdf2, createHash, hkdfSync } from "node:crypto";
33
33
  import { promisify } from "node:util";
34
34
  import * as readline from "node:readline";
35
- import { readFileSync, existsSync } from "node:fs";
35
+ import { readFileSync, existsSync, realpathSync } from "node:fs";
36
36
  import { pathToFileURL } from "node:url";
37
37
 
38
38
  const pbkdf2 = promisify(_pbkdf2);
@@ -803,8 +803,15 @@ async function main() {
803
803
  Without this check, importing anything from here would run the whole CLI —
804
804
  argv policy, wizard and all — as a side effect of asking for a function.
805
805
  */
806
+ // argv[1] is the path the shell used. Under npx, `npm i -g` or any package-manager
807
+ // bin it is a SYMLINK, so comparing it to import.meta.url unresolved is false and the
808
+ // command exits silently without running (found 2026-10-09, first published 1.1.0).
809
+ // Resolve it first.
810
+ function realArgv1() {
811
+ try { return realpathSync(process.argv[1]); } catch { return process.argv[1]; }
812
+ }
806
813
  const invokedDirectly =
807
- !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
814
+ !!process.argv[1] && import.meta.url === pathToFileURL(realArgv1()).href;
808
815
 
809
816
  if (invokedDirectly) {
810
817
  main().catch((err) => {
@@ -36,7 +36,7 @@
36
36
  * Requires Node.js >= 18.
37
37
  */
38
38
 
39
- import { readFileSync } from "node:fs";
39
+ import { readFileSync, realpathSync } from "node:fs";
40
40
  import { basename } from "node:path";
41
41
  import * as readline from "node:readline";
42
42
  import { pathToFileURL } from "node:url";
@@ -48,6 +48,7 @@ import {
48
48
  deriveProjectKey,
49
49
  enrollPayload,
50
50
  } from "./redaktyn-fingerprint.mjs";
51
+ import { freshSession, loadSession } from "./redaktyn-session.mjs";
51
52
 
52
53
  /* ═══════════════════════════════════════════════════════════════
53
54
  Colors
@@ -945,8 +946,16 @@ async function resolveProject(serverUrl, token, wanted) {
945
946
  return matches[0];
946
947
  }
947
948
 
948
- async function fetchExistingSecrets(serverUrl, token) {
949
+ async function fetchExistingSecrets(serverUrl, token, viaSession = false) {
949
950
  const r = await apiRequest(serverUrl, token, "GET", "/api/secrets");
951
+ if (viaSession && (r.status === 401 || r.status === 403)) {
952
+ // A connected developer is not holding an admin token, so telling them to set
953
+ // one would send them the wrong way. Their device was removed or ended.
954
+ throw new UsageError(
955
+ "This machine's Redaktyn session is no longer valid: the device was removed or its session ended. " +
956
+ "Ask your admin for a new invite link and run `redaktyn-connect` again."
957
+ );
958
+ }
950
959
  if (r.status === 401) {
951
960
  throw new UsageError(
952
961
  "Server rejected the token (401). REDAKTYN_TOKEN must be a current OWNER or ADMIN login token."
@@ -1411,6 +1420,59 @@ async function resolveCredentials(args) {
1411
1420
  return { serverUrl, token, orgId, passphrase };
1412
1421
  }
1413
1422
 
1423
+ /**
1424
+ * Who is enrolling, and with which key.
1425
+ *
1426
+ * Two ways in. An admin sets REDAKTYN_TOKEN and the workspace passphrase and may
1427
+ * name any project with --project. A developer who ran `redaktyn-connect` has a
1428
+ * saved session instead: its key is already the project's key, so no passphrase or
1429
+ * admin token is involved, and it can only ever act on the project its invite named.
1430
+ * An admin's environment wins when both exist, so a shell set up for admin work is
1431
+ * never silently downgraded to a developer session.
1432
+ */
1433
+ async function resolveIdentity(args) {
1434
+ const adminEnv = process.env["REDAKTYN_TOKEN"] || process.env["REDAKTYN_JWT"];
1435
+ const saved = adminEnv ? null : loadSession();
1436
+ if (saved) {
1437
+ let session;
1438
+ try {
1439
+ session = await freshSession(saved);
1440
+ } catch (e) {
1441
+ throw new UsageError(e.message);
1442
+ }
1443
+ if (args.project) {
1444
+ const w = args.project.trim().toLowerCase();
1445
+ if (w !== String(session.projectId).toLowerCase() && w !== String(session.projectName || "").toLowerCase()) {
1446
+ throw new UsageError(
1447
+ `This machine is connected to project "${session.projectName ?? session.projectId}" only, not "${args.project}". ` +
1448
+ "Drop --project, or ask for an invite to the other project."
1449
+ );
1450
+ }
1451
+ }
1452
+ if (!session.projectId) throw new UsageError("This session is not tied to a project. Ask for a new invite link.");
1453
+ if (args.apply && !session.canEnrol) {
1454
+ throw new UsageError(
1455
+ "This invite lets you scan but not enrol secrets. Ask your admin for a link made with: redaktyn-invite --can-enrol"
1456
+ );
1457
+ }
1458
+ return {
1459
+ serverUrl: args.server || session.serverUrl,
1460
+ token: session.token,
1461
+ key: Buffer.from(session.key, "hex"),
1462
+ project: { id: session.projectId, name: session.projectName ?? session.projectId, usesOrgKey: session.projectUsesOrgKey === true },
1463
+ viaSession: true,
1464
+ };
1465
+ }
1466
+ const { serverUrl, token, orgId, passphrase } = await resolveCredentials(args);
1467
+ const orgKey = await deriveKey(passphrase, orgId);
1468
+ const project = args.project ? await resolveProject(serverUrl, token, args.project) : null;
1469
+ // A project with its own key hashes under HKDF(orgKey, projectId); the default
1470
+ // project carries the org key itself. Filing a digest under the wrong one lists
1471
+ // as ACTIVE and matches nothing, which is why the server also checks.
1472
+ const key = project && !project.usesOrgKey ? deriveProjectKey(orgKey, project.id) : orgKey;
1473
+ return { serverUrl, token, key, project, viaSession: false };
1474
+ }
1475
+
1414
1476
  async function main() {
1415
1477
  const args = parseArgs(process.argv);
1416
1478
  if (args.help) {
@@ -1437,15 +1499,8 @@ async function main() {
1437
1499
  );
1438
1500
  }
1439
1501
 
1440
- const { serverUrl, token, orgId, passphrase } = await resolveCredentials(args);
1441
-
1502
+ const { serverUrl, token, key, project, viaSession } = await resolveIdentity(args);
1442
1503
  const minLength = args.minLength ?? DEFAULT_MIN_LENGTH;
1443
- const orgKey = await deriveKey(passphrase, orgId);
1444
- const project = args.project ? await resolveProject(serverUrl, token, args.project) : null;
1445
- // A project with its own key hashes under HKDF(orgKey, projectId); the default
1446
- // project carries the org key itself. Filing a digest under the wrong one lists
1447
- // as ACTIVE and matches nothing, which is why the server also checks.
1448
- const key = project && !project.usesOrgKey ? deriveProjectKey(orgKey, project.id) : orgKey;
1449
1504
 
1450
1505
  const candidates = [];
1451
1506
  const rejected = [];
@@ -1471,7 +1526,7 @@ async function main() {
1471
1526
  });
1472
1527
  }
1473
1528
 
1474
- let existing = await fetchExistingSecrets(serverUrl, token);
1529
+ let existing = await fetchExistingSecrets(serverUrl, token, viaSession);
1475
1530
  // Compared only with the chosen project's own secrets: the same label in
1476
1531
  // another project is a different secret under a different key.
1477
1532
  if (project) existing = existing.filter((s) => s && s.projectId === project.id);
@@ -1635,8 +1690,15 @@ function projectAfterImport({ existing, plan, byKey }) {
1635
1690
  return out;
1636
1691
  }
1637
1692
 
1693
+ // argv[1] is the path the shell used. Under npx, `npm i -g` or any package-manager
1694
+ // bin it is a SYMLINK, so comparing it to import.meta.url unresolved is false and the
1695
+ // command exits silently without running (found 2026-10-09, first published 1.1.0).
1696
+ // Resolve it first.
1697
+ function realArgv1() {
1698
+ try { return realpathSync(process.argv[1]); } catch { return process.argv[1]; }
1699
+ }
1638
1700
  const invokedDirectly =
1639
- !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
1701
+ !!process.argv[1] && import.meta.url === pathToFileURL(realArgv1()).href;
1640
1702
 
1641
1703
  if (invokedDirectly) {
1642
1704
  main()
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * redaktyn-invite: for an admin. Mint a single-use invite link for one project,
4
+ * optionally allowing the person who redeems it from the CLI to enrol secrets.
5
+ *
6
+ * redaktyn-invite --project acme-engagement --can-enrol
7
+ *
8
+ * Needs what any admin action needs: REDAKTYN_SERVER_URL, REDAKTYN_TOKEN (OWNER or
9
+ * ADMIN login token) and REDAKTYN_PASSPHRASE. The passphrase stays on this machine:
10
+ * it derives the project's key, which is sealed under a one-time key that exists
11
+ * only in the link's #fragment. The server stores ciphertext it cannot open.
12
+ *
13
+ * Send the link over a channel you trust. Whoever opens it first (once) gets the
14
+ * project's key and, with --can-enrol, the right to register secrets on it.
15
+ */
16
+ import { pathToFileURL } from "node:url";
17
+ import { realpathSync } from "node:fs";
18
+ import * as readline from "node:readline";
19
+
20
+ import { deriveKey, deriveProjectKey } from "./redaktyn-fingerprint.mjs";
21
+ import { SessionError, apiJson, sealInviteKey } from "./redaktyn-session.mjs";
22
+
23
+ function help() {
24
+ console.log(`redaktyn-invite: mint a single-use invite link for one project
25
+
26
+ --project <name|id> The project the link is for (required)
27
+ --can-enrol Let the redeeming CLI register secrets on that project
28
+ --expires-days <n> Link lifetime (default 7)
29
+ --name <text> Name to show for the person
30
+ --email <address> Restrict the link to this email
31
+ --app-url <url> Address to put in the link (default https://redaktyn.com)
32
+ --server <url> API address (default REDAKTYN_SERVER_URL)
33
+
34
+ Environment: REDAKTYN_SERVER_URL, REDAKTYN_TOKEN (OWNER/ADMIN), REDAKTYN_PASSPHRASE`);
35
+ }
36
+
37
+ function parse(argv) {
38
+ const o = { project: null, canEnrol: false, days: 7, name: null, email: null, appUrl: "https://redaktyn.com", server: null, help: false };
39
+ for (let i = 2; i < argv.length; i++) {
40
+ const a = argv[i];
41
+ const need = () => { const v = argv[++i]; if (v === undefined || v.startsWith("--")) throw new SessionError(`${a} needs a value`); return v; };
42
+ if (a === "--help" || a === "-h") o.help = true;
43
+ else if (a === "--project") o.project = need();
44
+ else if (a === "--can-enrol" || a === "--can-enroll") o.canEnrol = true;
45
+ else if (a === "--expires-days") o.days = Number(need());
46
+ else if (a === "--name") o.name = need();
47
+ else if (a === "--email") o.email = need();
48
+ else if (a === "--app-url") o.appUrl = need();
49
+ else if (a === "--server") o.server = need();
50
+ else throw new SessionError(`Unknown flag: ${a}`);
51
+ }
52
+ if (!Number.isInteger(o.days) || o.days < 1 || o.days > 365) throw new SessionError("--expires-days must be 1 to 365");
53
+ return o;
54
+ }
55
+
56
+ function promptHidden(q) {
57
+ return new Promise((resolve) => {
58
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true });
59
+ process.stderr.write(q + " ");
60
+ rl._writeToOutput = () => {};
61
+ rl.question("", (a) => { process.stderr.write("\n"); rl.close(); resolve(a); });
62
+ });
63
+ }
64
+ function orgIdFrom(token) {
65
+ try { return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString()).orgId; } catch { return null; }
66
+ }
67
+
68
+ export async function main(argv) {
69
+ const o = parse(argv);
70
+ if (o.help) return help(), 0;
71
+ if (!o.project) throw new SessionError("Say which project the link is for: --project <name|id>");
72
+ const server = o.server || process.env["REDAKTYN_SERVER_URL"];
73
+ const token = process.env["REDAKTYN_TOKEN"];
74
+ if (!server || !token) throw new SessionError("Set REDAKTYN_SERVER_URL and REDAKTYN_TOKEN (an OWNER or ADMIN login token).");
75
+ const orgId = orgIdFrom(token);
76
+ if (!orgId) throw new SessionError("That token carries no org id.");
77
+ let passphrase = process.env["REDAKTYN_PASSPHRASE"];
78
+ if (!passphrase) {
79
+ if (!process.stdin.isTTY) throw new SessionError("Set REDAKTYN_PASSPHRASE (used locally to derive the key, never sent).");
80
+ passphrase = await promptHidden("Workspace passphrase:");
81
+ }
82
+
83
+ const pr = await apiJson(server, "GET", "/api/projects", { token });
84
+ if (!pr.ok || !Array.isArray(pr.data)) throw new SessionError(`GET /api/projects failed (${pr.status}). Is the token an OWNER/ADMIN token?`);
85
+ const want = o.project.trim();
86
+ const found = pr.data.filter((p) => p.id === want).length ? pr.data.filter((p) => p.id === want) : pr.data.filter((p) => String(p.name).toLowerCase() === want.toLowerCase());
87
+ if (found.length !== 1) throw new SessionError(found.length ? `"${want}" matches several projects; pass the id.` : `No project "${want}". Known: ${pr.data.map((p) => p.name).join(", ")}.`);
88
+ const project = found[0];
89
+ if (project.status === "CLOSED") throw new SessionError(`Project ${project.name} is closed.`);
90
+
91
+ const orgKey = await deriveKey(passphrase, orgId);
92
+ const key = project.usesOrgKey ? orgKey : deriveProjectKey(orgKey, project.id);
93
+ const { w, blob } = await sealInviteKey(key, project.usesOrgKey ? 1 : 2);
94
+
95
+ const r = await apiJson(server, "POST", "/api/invites", {
96
+ token,
97
+ body: {
98
+ projectId: project.id, keyBlob: blob, maxUses: 1, expiresInDays: o.days,
99
+ ...(o.canEnrol ? { canEnrol: true } : {}),
100
+ ...(o.name ? { assignedName: o.name } : {}), ...(o.email ? { assignedEmail: o.email } : {}),
101
+ },
102
+ });
103
+ if (!r.ok) throw new SessionError(`The server refused the invite (${r.status}): ${r.data?.error || r.text.slice(0, 200)}`);
104
+
105
+ const link = `${o.appUrl.replace(/\/+$/, "")}/connect?code=${r.data.code}#w=${w}`;
106
+ console.log(`Invite for project ${project.name}${o.canEnrol ? " (may enrol secrets)" : " (scan only)"}, single use, expires in ${o.days} day(s):\n`);
107
+ console.log(link);
108
+ console.log(`\nSend it over a channel you trust: whoever opens it first gets this project's key.`);
109
+ console.log(`They run: redaktyn-connect and paste it. Revoke before use: DELETE /api/invites/${r.data.id}`);
110
+ return 0;
111
+ }
112
+
113
+ function realArgv1() { try { return realpathSync(process.argv[1]); } catch { return process.argv[1]; } }
114
+ if (process.argv[1] && import.meta.url === pathToFileURL(realArgv1()).href) {
115
+ main(process.argv).then((c) => process.exit(c), (e) => { console.error(e.message); process.exit(e instanceof SessionError ? 1 : 2); });
116
+ }
package/redaktyn-scan.mjs CHANGED
@@ -299,18 +299,38 @@ async function main() {
299
299
  process.exit(2);
300
300
  }
301
301
 
302
+ // A machine set up with `redaktyn-connect` scans with its saved project key and
303
+ // token, no environment needed. Any REDAKTYN_* credential in the environment
304
+ // takes precedence, so an existing pipeline behaves exactly as before.
305
+ const envCreds =
306
+ process.env["REDAKTYN_DEVICE_TOKEN"] || process.env["REDAKTYN_TOKEN"] || process.env["REDAKTYN_JWT"] ||
307
+ process.env["REDAKTYN_PASSPHRASE"];
308
+ let saved = null;
309
+ if (!envCreds) {
310
+ const { loadSession, freshSession } = await import("./redaktyn-session.mjs");
311
+ if (loadSession()) {
312
+ try {
313
+ saved = await freshSession();
314
+ } catch (e) {
315
+ console.error(e.message);
316
+ process.exit(2);
317
+ }
318
+ }
319
+ }
320
+
302
321
  const serverUrl =
303
- args.server || process.env["REDAKTYN_SERVER_URL"] || process.env["REDAKTYN_SERVER"];
322
+ args.server || saved?.serverUrl || process.env["REDAKTYN_SERVER_URL"] || process.env["REDAKTYN_SERVER"];
304
323
  const token =
324
+ saved?.token ||
305
325
  process.env["REDAKTYN_DEVICE_TOKEN"] ||
306
326
  process.env["REDAKTYN_TOKEN"] ||
307
327
  process.env["REDAKTYN_JWT"];
308
- const passphrase = process.env["REDAKTYN_PASSPHRASE"];
328
+ const passphrase = saved ? "session" : process.env["REDAKTYN_PASSPHRASE"];
309
329
  const envOrgId = args.orgId || process.env["REDAKTYN_ORG_ID"];
310
330
 
311
331
  if (!serverUrl || !token || !passphrase) {
312
332
  console.error(
313
- "Missing env: REDAKTYN_SERVER_URL, REDAKTYN_PASSPHRASE, and REDAKTYN_DEVICE_TOKEN (or REDAKTYN_TOKEN)."
333
+ "Not connected. Run `redaktyn-connect` with an invite link, or set REDAKTYN_SERVER_URL, REDAKTYN_PASSPHRASE and REDAKTYN_DEVICE_TOKEN (or REDAKTYN_TOKEN)."
314
334
  );
315
335
  process.exit(2);
316
336
  }
@@ -377,7 +397,7 @@ async function main() {
377
397
  process.exit(2);
378
398
  }
379
399
 
380
- const key = await deriveHmacKeyFromPassphrase(passphrase, orgId);
400
+ const key = saved ? new Uint8Array(Buffer.from(saved.key, "hex")) : await deriveHmacKeyFromPassphrase(passphrase, orgId);
381
401
  const stats = { total: 0, passesRun: [], exhausted: false, truncatedChars: false };
382
402
  const endpoint = `${serverUrl.replace(/\/+$/, "")}/api/cli/scan`;
383
403
 
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The CLI's own enrolment: redeeming an invite so a developer can enrol secrets
3
+ * without ever holding an admin login or the workspace passphrase.
4
+ *
5
+ * What an invite hands a device is exactly what the browser extension gets: the
6
+ * project's scanning key, sealed under a one-time key that lives only in the link's
7
+ * fragment, plus a short-lived device token. The server never sees the unsealed key.
8
+ * This module redeems the link, unseals the key locally, keeps the result in a file
9
+ * only the current user can read, and renews the token as it ages.
10
+ *
11
+ * Why a file and not the OS keychain: macOS `security add-generic-password` takes
12
+ * the secret as a command-line argument, which any process on the machine can read
13
+ * with `ps` for as long as it runs. A 0600 file in a 0700 directory is the same
14
+ * protection `~/.aws/credentials` and `~/.ssh` rely on, without that window.
15
+ */
16
+ import { createHash, randomBytes, webcrypto } from "node:crypto";
17
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync, renameSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ export const TOKEN_REFRESH_AFTER_MS = 20 * 60 * 60 * 1000; // tokens last 24h
22
+ export const INVITE_KEY_BYTES = 32;
23
+
24
+ export class SessionError extends Error {}
25
+
26
+ /* ── where it lives ─────────────────────────────────────────── */
27
+
28
+ export function sessionDir() {
29
+ const base =
30
+ process.env["REDAKTYN_CONFIG_DIR"] ||
31
+ (process.env["XDG_CONFIG_HOME"] ? join(process.env["XDG_CONFIG_HOME"], "redaktyn") : join(homedir(), ".config", "redaktyn"));
32
+ return base;
33
+ }
34
+ const sessionFile = () => join(sessionDir(), "session.json");
35
+ const deviceFile = () => join(sessionDir(), "device-id");
36
+
37
+ function ensureDir() {
38
+ mkdirSync(sessionDir(), { recursive: true, mode: 0o700 });
39
+ try { chmodSync(sessionDir(), 0o700); } catch { /* best effort on exotic filesystems */ }
40
+ }
41
+
42
+ /** Written to a temp file and renamed, so a crash never leaves half a session. */
43
+ function writePrivate(path, text) {
44
+ ensureDir();
45
+ const tmp = `${path}.${process.pid}.tmp`;
46
+ writeFileSync(tmp, text, { mode: 0o600 });
47
+ try { chmodSync(tmp, 0o600); } catch { /* ignore */ }
48
+ renameSync(tmp, path);
49
+ }
50
+
51
+ export function loadSession() {
52
+ const f = sessionFile();
53
+ if (!existsSync(f)) return null;
54
+ try {
55
+ const s = JSON.parse(readFileSync(f, "utf8"));
56
+ if (s && s.token && s.key && s.serverUrl && s.orgId) return s;
57
+ } catch { /* fall through */ }
58
+ return null;
59
+ }
60
+ export function saveSession(session) {
61
+ writePrivate(sessionFile(), JSON.stringify(session, null, 2) + "\n");
62
+ }
63
+ export function deleteSession() {
64
+ const f = sessionFile();
65
+ if (existsSync(f)) rmSync(f);
66
+ return true;
67
+ }
68
+
69
+ /** Stable per machine, so reconnecting the same CLI does not spend another seat. */
70
+ export function deviceId() {
71
+ const f = deviceFile();
72
+ if (existsSync(f)) {
73
+ const v = readFileSync(f, "utf8").trim();
74
+ if (/^cli-[a-f0-9]{32}$/.test(v)) return v;
75
+ }
76
+ const id = `cli-${randomBytes(16).toString("hex")}`;
77
+ writePrivate(f, id + "\n");
78
+ return id;
79
+ }
80
+
81
+ /* ── the invite link ────────────────────────────────────────── */
82
+
83
+ const b64uToBytes = (t) => Buffer.from(t.replace(/-/g, "+").replace(/_/g, "/"), "base64");
84
+ export const bytesToB64u = (b) => Buffer.from(b).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
85
+
86
+ /** `https://host/connect?code=ABC#w=KEY` -> { origin, code, w }. */
87
+ export function parseInviteLink(link) {
88
+ let url;
89
+ try {
90
+ url = new URL(String(link).trim());
91
+ } catch {
92
+ throw new SessionError("That is not an invite link. It looks like https://…/connect?code=…#w=…");
93
+ }
94
+ const code = url.searchParams.get("code");
95
+ const w = new URLSearchParams(url.hash.replace(/^#/, "")).get("w");
96
+ if (!code) throw new SessionError("The link has no invite code.");
97
+ if (!w) {
98
+ throw new SessionError(
99
+ "The link has no key part (the bit after #w=). Copy the whole link, including everything after the #, and quote it so the shell does not cut it."
100
+ );
101
+ }
102
+ if (b64uToBytes(w).length !== INVITE_KEY_BYTES) throw new SessionError("The key part of the link is the wrong length.");
103
+ return { origin: url.origin, code, w };
104
+ }
105
+
106
+ /** AES-GCM, no AAD: the exact inverse of wrapScanKeyForInvite in the extension. */
107
+ export async function unsealInviteKey(blob, wB64u) {
108
+ if (!blob || typeof blob !== "object" || (blob.v !== 1 && blob.v !== 2) || typeof blob.iv !== "string" || typeof blob.ct !== "string") {
109
+ throw new SessionError("The server sent no sealed key for this invite.");
110
+ }
111
+ const key = await webcrypto.subtle.importKey("raw", b64uToBytes(wB64u), { name: "AES-GCM" }, false, ["decrypt"]);
112
+ try {
113
+ const plain = await webcrypto.subtle.decrypt({ name: "AES-GCM", iv: Buffer.from(blob.iv, "base64") }, key, Buffer.from(blob.ct, "base64"));
114
+ const out = Buffer.from(plain);
115
+ if (out.length !== INVITE_KEY_BYTES) throw new Error("length");
116
+ return out;
117
+ } catch {
118
+ throw new SessionError("Could not open the sealed key with this link. The link may be incomplete or altered.");
119
+ }
120
+ }
121
+
122
+ /** Seal a 32-byte key for a link: returns the blob to POST and the link's `w`. */
123
+ export async function sealInviteKey(keyBytes, scope) {
124
+ const w = randomBytes(INVITE_KEY_BYTES);
125
+ const iv = randomBytes(12);
126
+ const aes = await webcrypto.subtle.importKey("raw", w, { name: "AES-GCM" }, false, ["encrypt"]);
127
+ const ct = Buffer.from(await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, aes, keyBytes));
128
+ return { w: bytesToB64u(w), blob: { v: scope, iv: iv.toString("base64"), ct: ct.toString("base64") } };
129
+ }
130
+
131
+ /* ── talking to the server ──────────────────────────────────── */
132
+
133
+ const trim = (u) => u.replace(/\/+$/, "");
134
+
135
+ export async function apiJson(serverUrl, method, path, { token, body } = {}) {
136
+ let res;
137
+ try {
138
+ res = await fetch(`${trim(serverUrl)}${path}`, {
139
+ method,
140
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body ? { "Content-Type": "application/json" } : {}) },
141
+ ...(body ? { body: JSON.stringify(body) } : {}),
142
+ });
143
+ } catch (e) {
144
+ throw new SessionError(`Cannot reach ${trim(serverUrl)}: ${e.message}`);
145
+ }
146
+ const text = await res.text();
147
+ let data = null;
148
+ try { data = text ? JSON.parse(text) : null; } catch { data = null; }
149
+ return { status: res.status, ok: res.ok, data, text };
150
+ }
151
+
152
+ /** Redeem a link and keep the result. Returns the saved session. */
153
+ export async function connectWithInvite(link, { serverUrl } = {}) {
154
+ const { origin, code, w } = parseInviteLink(link);
155
+ const server = serverUrl || process.env["REDAKTYN_SERVER_URL"] || origin;
156
+ const r = await apiJson(server, "POST", "/api/auth/connect", {
157
+ body: { inviteCode: code, extensionId: deviceId(), client: "cli" },
158
+ });
159
+ if (!r.ok) {
160
+ const why = r.data?.error || r.text.slice(0, 200) || `HTTP ${r.status}`;
161
+ throw new SessionError(`The server refused the invite (${r.status}): ${why}`);
162
+ }
163
+ const d = r.data || {};
164
+ if (!d.token || !d.keyBlob) {
165
+ throw new SessionError("The server accepted the invite but sent no sealed key, so nothing could be set up. Ask for a new link.");
166
+ }
167
+ const key = await unsealInviteKey(d.keyBlob, w);
168
+ const session = {
169
+ serverUrl: trim(server),
170
+ orgId: d.orgId,
171
+ orgName: d.orgName ?? null,
172
+ projectId: d.projectId ?? null,
173
+ projectName: d.projectName ?? null,
174
+ projectUsesOrgKey: d.projectUsesOrgKey ?? null,
175
+ canEnrol: d.canEnrol === true,
176
+ token: d.token,
177
+ tokenIssuedAt: Date.now(),
178
+ key: key.toString("hex"),
179
+ keyFingerprint: createHash("sha256").update(key).digest("hex").slice(0, 8),
180
+ connectedAt: new Date().toISOString(),
181
+ };
182
+ saveSession(session);
183
+ return session;
184
+ }
185
+
186
+ /** Renew the token if it is getting old. A refusal means the device was removed. */
187
+ export async function freshSession(session = loadSession()) {
188
+ if (!session) throw new SessionError("Not connected. Run: redaktyn-connect '<invite link>'");
189
+ if (Date.now() - (session.tokenIssuedAt || 0) < TOKEN_REFRESH_AFTER_MS) return session;
190
+ const r = await apiJson(session.serverUrl, "POST", "/api/auth/refresh", { token: session.token });
191
+ if (!r.ok || !r.data?.token) {
192
+ const code = r.data?.code;
193
+ const hint = code === "ENROLLMENT_REVOKED" || code === "SESSION_REVOKED"
194
+ ? "This device was removed by an admin."
195
+ : "The session could not be renewed (it may have passed its maximum age).";
196
+ throw new SessionError(`${hint} Ask for a new invite link and run redaktyn-connect again.`);
197
+ }
198
+ const next = { ...session, token: r.data.token, tokenIssuedAt: Date.now() };
199
+ saveSession(next);
200
+ return next;
201
+ }