@zapqr/cli 0.1.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 ADDED
@@ -0,0 +1,112 @@
1
+ # `zapqr` — sign in with ZapQR from a terminal
2
+
3
+ **Not published to npm yet** — `npx @zapqr/cli` will 404. Run it from a checkout:
4
+
5
+ ```sh
6
+ git clone -b feat/cli-device-login https://github.com/dasecure/zapqr-auth-idp.git
7
+ cd zapqr-auth-idp/cli
8
+ npm install # optional: pulls qrcode, for the terminal QR
9
+ node src/index.js login
10
+ ```
11
+
12
+ Once it is published, that becomes:
13
+
14
+ ```sh
15
+ npx @zapqr/cli login
16
+ ```
17
+
18
+ Either way: a QR appears in your terminal. Scan it with your phone, approve, and this
19
+ machine holds tokens. No password to type, no browser needed on this box —
20
+ which is the point: the machine may be one you SSH'd into, and it has no
21
+ business holding your passkey.
22
+
23
+ Under the hood it's the OAuth 2.0 Device Authorization Grant (RFC 8628) — the
24
+ same grant `gh auth login`, Apple TV and Roku use — against `auth.zapqr.ai`.
25
+
26
+ ## Commands
27
+
28
+ | | |
29
+ |---|---|
30
+ | `zapqr login` | Approve on your phone; tokens land here |
31
+ | `zapqr whoami` | Who this machine is signed in as (`--json` for scripts) |
32
+ | `zapqr token` | Print a fresh access token to stdout |
33
+ | `zapqr logout` | Revoke at the provider, then forget locally |
34
+
35
+ `zapqr token` refreshes automatically when the access token has expired, so it
36
+ is safe in a script:
37
+
38
+ ```sh
39
+ curl -H "Authorization: Bearer $(zapqr token)" https://api.example.com/thing
40
+ ```
41
+
42
+ ## Options
43
+
44
+ | Flag | Env | Default |
45
+ |---|---|---|
46
+ | `--issuer <url>` | `ZAPQR_ISSUER` | `https://auth.zapqr.ai` |
47
+ | `--client-id <id>` | `ZAPQR_CLIENT_ID` | `zq-cli` |
48
+ | `--scope <scope>` | `ZAPQR_SCOPE` | `openid email offline_access` |
49
+ | `--no-qr` | | show the code without the QR |
50
+
51
+ ## Registering the client
52
+
53
+ The device grant is opt-in per client. Before `zapqr login` works against a
54
+ provider, register a client there of type **device** with the id `zq-cli`.
55
+ Device clients are public — a CLI on someone else's laptop cannot keep a secret
56
+ — and register with no redirect URIs.
57
+
58
+ Until that client exists you'll get:
59
+
60
+ ```
61
+ unauthorized_client
62
+ "zq-cli" is not registered for the device grant.
63
+ ```
64
+
65
+ To try it before that client exists, borrow the demo one:
66
+
67
+ ```sh
68
+ ZAPQR_CLIENT_ID=zq-unihiker-demo node src/index.js login
69
+ ```
70
+
71
+ ## Where tokens are stored
72
+
73
+ `$XDG_CONFIG_HOME/zapqr/credentials.json` (or `~/.config/...`), mode `0600`,
74
+ keyed by issuer so a staging provider can't overwrite production.
75
+
76
+ Not the OS keychain, deliberately. The obvious macOS route is
77
+ `security add-generic-password -w <secret>`, which puts the refresh token in
78
+ argv where any process running `ps` can read it. Trading a file only you can
79
+ read for a token that briefly leaks to every process on the machine is not an
80
+ upgrade. A proper keychain integration needs a native binding — a dependency
81
+ this CLI doesn't currently pay for.
82
+
83
+ ## Dependencies
84
+
85
+ None required. `qrcode` is an *optional* dependency: with it you get the
86
+ scannable QR, without it you get the URL and code, and the flow is identical.
87
+ That keeps `npx` fast and lets the CLI run from a bare checkout.
88
+
89
+ ## What you'll see
90
+
91
+ ```
92
+ █▀▀▀▀▀█ ▄▀ ▄▄▀█ █▀▀▀▀▀█
93
+ █ ███ █ ▀█▄▀▄ █ █ ███ █
94
+ █ ▀▀▀ █ █▄▀▄█▀▀ █ ▀▀▀ █
95
+ ▀▀▀▀▀▀▀ █▄█▄█▄█ ▀▀▀▀▀▀▀
96
+
97
+ Scan with your phone, or open auth.zapqr.ai/device
98
+ and enter the code WXTM-9K4P
99
+
100
+ Only approve this if you started it. Nobody legitimate will
101
+ ever send you a code to enter here.
102
+
103
+ waiting for approval… 04:31 left
104
+ ```
105
+
106
+ That last warning is not decoration. A code someone else sends you, with a
107
+ plausible reason to type it in, is exactly how this flow gets stolen — the
108
+ same attack the device-link ceremony's copy defends against.
109
+
110
+ ## Exit codes
111
+
112
+ `0` success · `1` failure or declined · `2` the code expired (retry)
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@zapqr/cli",
3
+ "version": "0.1.0",
4
+ "description": "Sign in with ZapQR from a terminal — OAuth 2.0 Device Authorization Grant (RFC 8628)",
5
+ "type": "module",
6
+ "bin": {
7
+ "zapqr": "src/index.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "scripts": {
17
+ "test": "node --test test/*.test.js",
18
+ "prepublishOnly": "node --test test/*.test.js"
19
+ },
20
+ "optionalDependencies": {
21
+ "qrcode": "^1.5.4"
22
+ },
23
+ "keywords": [
24
+ "zapqr",
25
+ "oidc",
26
+ "oauth",
27
+ "device-flow",
28
+ "rfc8628",
29
+ "passwordless",
30
+ "cli"
31
+ ],
32
+ "license": "MIT",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/dasecure/zapqr-auth-idp.git",
39
+ "directory": "cli"
40
+ },
41
+ "homepage": "https://zapqr.ai",
42
+ "bugs": {
43
+ "url": "https://github.com/dasecure/zapqr-auth-idp/issues"
44
+ }
45
+ }
package/src/index.js ADDED
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env node
2
+ // Sign in with ZapQR from a terminal.
3
+ //
4
+ // zapqr login approve on your phone, tokens land here
5
+ // zapqr whoami who this machine is signed in as
6
+ // zapqr token print a fresh access token (for scripts)
7
+ // zapqr logout revoke at the provider, then forget locally
8
+ //
9
+ // The point of the device grant here: the machine running this command may be
10
+ // a box you SSH'd into, with no browser and no business holding your passkey.
11
+ // It shows a code; the phone in your pocket does the authenticating.
12
+
13
+ import { discover, pollForToken, refresh, requestDeviceCode, revoke, userinfo } from './oauth.js';
14
+ import { clear, load, save, storePath } from './store.js';
15
+
16
+ const DEFAULTS = {
17
+ issuer: process.env.ZAPQR_ISSUER || 'https://auth.zapqr.ai',
18
+ clientId: process.env.ZAPQR_CLIENT_ID || 'zq-cli',
19
+ scope: process.env.ZAPQR_SCOPE || 'openid email offline_access',
20
+ };
21
+
22
+ const isTTY = process.stderr.isTTY;
23
+ const bold = (s) => (isTTY ? `\x1b[1m${s}\x1b[0m` : s);
24
+ const dim = (s) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s);
25
+ const red = (s) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s);
26
+ const green = (s) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s);
27
+
28
+ /** Human output goes to stderr; only machine-readable values go to stdout, so
29
+ * `zapqr token` stays pipeable. */
30
+ const say = (...a) => console.error(...a);
31
+
32
+ function parseArgs(argv) {
33
+ const flags = {};
34
+ const rest = [];
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const a = argv[i];
37
+ if (a === '--no-qr') flags.noQr = true;
38
+ else if (a === '--json') flags.json = true;
39
+ else if (a === '--issuer') flags.issuer = argv[++i];
40
+ else if (a === '--client-id') flags.clientId = argv[++i];
41
+ else if (a === '--scope') flags.scope = argv[++i];
42
+ else if (a === '-h' || a === '--help') flags.help = true;
43
+ else rest.push(a);
44
+ }
45
+ return { flags, rest };
46
+ }
47
+
48
+ function config(flags) {
49
+ return {
50
+ issuer: (flags.issuer || DEFAULTS.issuer).replace(/\/+$/, ''),
51
+ clientId: flags.clientId || DEFAULTS.clientId,
52
+ scope: flags.scope || DEFAULTS.scope,
53
+ };
54
+ }
55
+
56
+ /** The QR is what makes this feel like ZapQR rather than code-typing: scan the
57
+ * terminal, approve, done. It is optional on purpose — the CLI has no required
58
+ * dependencies, so a bare `node src/index.js` still signs you in. */
59
+ async function renderQR(url) {
60
+ try {
61
+ const { default: QRCode } = await import('qrcode');
62
+ return await QRCode.toString(url, { type: 'terminal', small: true, errorCorrectionLevel: 'M' });
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ async function cmdLogin(flags) {
69
+ const { issuer, clientId, scope } = config(flags);
70
+ const doc = await discover(issuer);
71
+ const auth = await requestDeviceCode({ doc, clientId, scope });
72
+
73
+ const verifyFull = auth.verification_uri_complete || auth.verification_uri;
74
+ const qr = flags.noQr ? null : await renderQR(verifyFull);
75
+
76
+ say('');
77
+ if (qr) {
78
+ say(qr);
79
+ say(` ${dim('Scan with your phone, or open')} ${bold(auth.verification_uri)}`);
80
+ } else {
81
+ say(` ${dim('On your phone, open')} ${bold(auth.verification_uri)}`);
82
+ }
83
+ say(` ${dim('and enter the code')} ${bold(auth.user_code)}`);
84
+ say('');
85
+ say(` ${dim('Only approve this if you started it. Nobody legitimate will')}`);
86
+ say(` ${dim('ever send you a code to enter here.')}`);
87
+ say('');
88
+
89
+ const result = await pollForToken({
90
+ doc,
91
+ clientId,
92
+ deviceCode: auth.device_code,
93
+ interval: auth.interval,
94
+ expiresIn: auth.expires_in,
95
+ onTick: (left) => {
96
+ if (!isTTY) return;
97
+ const m = String(Math.floor(left / 60)).padStart(2, '0');
98
+ const s = String(left % 60).padStart(2, '0');
99
+ process.stderr.write(`\r waiting for approval… ${m}:${s} left `);
100
+ },
101
+ });
102
+ if (isTTY) process.stderr.write('\r' + ' '.repeat(40) + '\r');
103
+
104
+ if (!result.ok) {
105
+ const msg = {
106
+ declined: 'Declined on the phone.',
107
+ expired: 'The code expired. Run `zapqr login` again.',
108
+ }[result.reason] || `Sign-in failed: ${result.reason}`;
109
+ say(red(` ${msg}`));
110
+ return result.reason === 'expired' ? 2 : 1;
111
+ }
112
+
113
+ const tokens = result.tokens;
114
+ let email = null;
115
+ try {
116
+ email = (await userinfo({ doc, accessToken: tokens.access_token })).email ?? null;
117
+ } catch {
118
+ // A userinfo hiccup does not undo a successful sign-in.
119
+ }
120
+
121
+ save(issuer, {
122
+ client_id: clientId,
123
+ scope,
124
+ email,
125
+ access_token: tokens.access_token,
126
+ refresh_token: tokens.refresh_token ?? null,
127
+ id_token: tokens.id_token ?? null,
128
+ // 30s of slack so a token that dies mid-request is refreshed beforehand.
129
+ expires_at: Date.now() + ((tokens.expires_in ?? 3600) - 30) * 1000,
130
+ });
131
+
132
+ say(green(` ✓ Signed in${email ? ` as ${bold(email)}` : ''}`));
133
+ say(dim(` Tokens stored in ${storePath()} (0600)`));
134
+ if (!tokens.refresh_token) {
135
+ say(dim(' No refresh token — add the offline_access scope to stay signed in.'));
136
+ }
137
+ return 0;
138
+ }
139
+
140
+ /** Anything needing a live token comes through here, so refresh-on-expiry is
141
+ * in exactly one place. rotateRefreshToken is on at the provider: the refresh
142
+ * token that comes back replaces the one we sent, and losing it means the
143
+ * session is stranded. */
144
+ async function freshAccessToken(issuer, flags) {
145
+ const rec = load(issuer);
146
+ if (!rec) return null;
147
+ if (Date.now() < rec.expires_at) return rec;
148
+ if (!rec.refresh_token) return null;
149
+
150
+ const doc = await discover(issuer);
151
+ const tokens = await refresh({ doc, clientId: rec.client_id, refreshToken: rec.refresh_token });
152
+ const updated = {
153
+ ...rec,
154
+ access_token: tokens.access_token,
155
+ refresh_token: tokens.refresh_token ?? rec.refresh_token,
156
+ id_token: tokens.id_token ?? rec.id_token,
157
+ expires_at: Date.now() + ((tokens.expires_in ?? 3600) - 30) * 1000,
158
+ };
159
+ save(issuer, updated);
160
+ return updated;
161
+ }
162
+
163
+ async function cmdWhoami(flags) {
164
+ const { issuer } = config(flags);
165
+ let rec;
166
+ try {
167
+ rec = await freshAccessToken(issuer, flags);
168
+ } catch (err) {
169
+ say(red(` Session could not be refreshed: ${err.message}`));
170
+ say(dim(' Run `zapqr login` again.'));
171
+ return 1;
172
+ }
173
+ if (!rec) {
174
+ say(dim(` Not signed in to ${issuer}. Run \`zapqr login\`.`));
175
+ return 1;
176
+ }
177
+
178
+ const doc = await discover(issuer);
179
+ let info = { email: rec.email };
180
+ try {
181
+ info = await userinfo({ doc, accessToken: rec.access_token });
182
+ } catch {
183
+ // Fall back to what we stored at login rather than failing the command.
184
+ }
185
+
186
+ if (flags.json) {
187
+ console.log(JSON.stringify({ issuer, client_id: rec.client_id, ...info }, null, 2));
188
+ } else {
189
+ say(` ${bold(info.email ?? '(unknown)')}`);
190
+ say(dim(` ${issuer} · client ${rec.client_id}${info.sub ? ` · sub ${info.sub}` : ''}`));
191
+ }
192
+ return 0;
193
+ }
194
+
195
+ async function cmdToken(flags) {
196
+ const { issuer } = config(flags);
197
+ const rec = await freshAccessToken(issuer, flags);
198
+ if (!rec) {
199
+ say(dim(' Not signed in. Run `zapqr login`.'));
200
+ return 1;
201
+ }
202
+ console.log(rec.access_token); // stdout: the whole point is `$(zapqr token)`
203
+ return 0;
204
+ }
205
+
206
+ async function cmdLogout(flags) {
207
+ const { issuer } = config(flags);
208
+ const rec = load(issuer);
209
+ if (!rec) {
210
+ say(dim(' Not signed in.'));
211
+ return 0;
212
+ }
213
+ try {
214
+ const doc = await discover(issuer);
215
+ // Revoking the refresh token is what actually ends the grant; the access
216
+ // token expires on its own within the hour either way.
217
+ if (rec.refresh_token) {
218
+ await revoke({ doc, clientId: rec.client_id, token: rec.refresh_token, hint: 'refresh_token' });
219
+ }
220
+ await revoke({ doc, clientId: rec.client_id, token: rec.access_token, hint: 'access_token' });
221
+ } catch {
222
+ // Offline, or the provider is down: still forget the local copy.
223
+ }
224
+ clear(issuer);
225
+ say(green(' ✓ Signed out'));
226
+ return 0;
227
+ }
228
+
229
+ function usage() {
230
+ say(`${bold('zapqr')} — sign in with ZapQR from a terminal
231
+
232
+ ${bold('zapqr login')} approve on your phone, tokens land here
233
+ ${bold('zapqr whoami')} who this machine is signed in as ${dim('[--json]')}
234
+ ${bold('zapqr token')} print a fresh access token, for scripts
235
+ ${bold('zapqr logout')} revoke at the provider, then forget locally
236
+
237
+ ${dim('Options')}
238
+ --issuer <url> default ${DEFAULTS.issuer} ${dim('(ZAPQR_ISSUER)')}
239
+ --client-id <id> default ${DEFAULTS.clientId} ${dim('(ZAPQR_CLIENT_ID)')}
240
+ --scope <scope> default "${DEFAULTS.scope}" ${dim('(ZAPQR_SCOPE)')}
241
+ --no-qr show the code without the QR
242
+ `);
243
+ }
244
+
245
+ const COMMANDS = { login: cmdLogin, whoami: cmdWhoami, token: cmdToken, logout: cmdLogout };
246
+
247
+ const { flags, rest } = parseArgs(process.argv.slice(2));
248
+ const name = rest[0];
249
+
250
+ if (flags.help || !name) {
251
+ usage();
252
+ // Asking for help is a success; being given no command at all is not.
253
+ process.exit(flags.help ? 0 : 1);
254
+ }
255
+ const run = COMMANDS[name];
256
+ if (!run) {
257
+ say(red(` Unknown command "${name}"`));
258
+ usage();
259
+ process.exit(1);
260
+ }
261
+
262
+ try {
263
+ process.exit(await run(flags));
264
+ } catch (err) {
265
+ say(red(` ${err.message}`));
266
+ process.exit(1);
267
+ }
package/src/oauth.js ADDED
@@ -0,0 +1,134 @@
1
+ // The protocol half of the CLI: RFC 8628 device grant against auth.zapqr.ai.
2
+ //
3
+ // Three moves, and none of them involve a browser on this machine: ask the
4
+ // provider for a code, show it to the human, poll until they approve on their
5
+ // phone. The terminal never sees a credential — only a code that is worthless
6
+ // without the person holding the phone.
7
+
8
+ /** Endpoints move. Discovery is the only URL this file is allowed to know. */
9
+ export async function discover(issuer) {
10
+ const url = issuer.replace(/\/+$/, '') + '/.well-known/openid-configuration';
11
+ const res = await fetch(url, { headers: { accept: 'application/json' } });
12
+ if (!res.ok) throw new Error(`discovery failed: ${res.status} ${url}`);
13
+ const doc = await res.json();
14
+ if (!doc.device_authorization_endpoint) {
15
+ throw new Error(`${issuer} does not advertise a device_authorization_endpoint`);
16
+ }
17
+ return doc;
18
+ }
19
+
20
+ /**
21
+ * OAuth returns its errors as JSON with a 4xx, so a non-2xx is data, not an
22
+ * exception — the poll loop below depends on reading the error code out of it.
23
+ */
24
+ async function postForm(url, fields) {
25
+ const res = await fetch(url, {
26
+ method: 'POST',
27
+ headers: {
28
+ 'content-type': 'application/x-www-form-urlencoded',
29
+ accept: 'application/json',
30
+ },
31
+ body: new URLSearchParams(fields).toString(),
32
+ });
33
+ let body;
34
+ try {
35
+ body = await res.json();
36
+ } catch {
37
+ body = { error: `http_${res.status}` };
38
+ }
39
+ return { status: res.status, body };
40
+ }
41
+
42
+ export async function requestDeviceCode({ doc, clientId, scope }) {
43
+ const { status, body } = await postForm(doc.device_authorization_endpoint, {
44
+ client_id: clientId,
45
+ scope,
46
+ });
47
+ if (status >= 400) {
48
+ const hint = body.error === 'unauthorized_client'
49
+ ? `\n "${clientId}" is not registered for the device grant. Register it at ` +
50
+ `${doc.issuer}/account as a client of type "device", or set ZAPQR_CLIENT_ID.`
51
+ : '';
52
+ throw new Error(`${body.error ?? status}${body.error_description ? `: ${body.error_description}` : ''}${hint}`);
53
+ }
54
+ return body;
55
+ }
56
+
57
+ /**
58
+ * Poll until the human decides. Two rules that are easy to get wrong:
59
+ * - the interval belongs to the SERVER. `slow_down` means back off and stay
60
+ * backed off; a client that ignores it gets throttled or blocked.
61
+ * - `expired_token` must leave the loop by returning, not by breaking out
62
+ * into the success path with no tokens in hand.
63
+ * onTick reports remaining seconds so the caller can redraw a countdown.
64
+ * `sleep` and `now` are injectable so the back-off can be asserted in a test
65
+ * without the test actually sitting there for five seconds.
66
+ */
67
+ export async function pollForToken({
68
+ doc, clientId, deviceCode, interval, expiresIn, onTick,
69
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
70
+ now = () => Date.now(),
71
+ }) {
72
+ let wait = (interval ?? 5) * 1000;
73
+ const deadline = now() + (expiresIn ?? 300) * 1000;
74
+
75
+ while (now() < deadline) {
76
+ await sleep(wait);
77
+ const { status, body } = await postForm(doc.token_endpoint, {
78
+ client_id: clientId,
79
+ device_code: deviceCode,
80
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
81
+ });
82
+
83
+ if (status === 200) return { ok: true, tokens: body };
84
+
85
+ switch (body.error) {
86
+ case 'authorization_pending':
87
+ onTick?.(Math.max(0, Math.round((deadline - now()) / 1000)));
88
+ continue;
89
+ case 'slow_down':
90
+ wait += 5000;
91
+ continue;
92
+ case 'access_denied':
93
+ return { ok: false, reason: 'declined' };
94
+ case 'expired_token':
95
+ return { ok: false, reason: 'expired' };
96
+ default:
97
+ return { ok: false, reason: body.error_description || body.error || `http_${status}` };
98
+ }
99
+ }
100
+ return { ok: false, reason: 'expired' };
101
+ }
102
+
103
+ export async function refresh({ doc, clientId, refreshToken }) {
104
+ const { status, body } = await postForm(doc.token_endpoint, {
105
+ client_id: clientId,
106
+ grant_type: 'refresh_token',
107
+ refresh_token: refreshToken,
108
+ });
109
+ if (status !== 200) throw new Error(body.error_description || body.error || `refresh failed (${status})`);
110
+ return body;
111
+ }
112
+
113
+ /** Best-effort: a revoke that fails must not stop us deleting the local copy. */
114
+ export async function revoke({ doc, clientId, token, hint }) {
115
+ if (!doc.revocation_endpoint) return false;
116
+ try {
117
+ const { status } = await postForm(doc.revocation_endpoint, {
118
+ client_id: clientId,
119
+ token,
120
+ token_type_hint: hint,
121
+ });
122
+ return status < 400;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ export async function userinfo({ doc, accessToken }) {
129
+ const res = await fetch(doc.userinfo_endpoint, {
130
+ headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' },
131
+ });
132
+ if (!res.ok) throw new Error(`userinfo failed: ${res.status}`);
133
+ return res.json();
134
+ }
package/src/store.js ADDED
@@ -0,0 +1,65 @@
1
+ // Where the tokens live between commands.
2
+ //
3
+ // A 0600 file under $XDG_CONFIG_HOME (or ~/.config), not the OS keychain, and
4
+ // that is a deliberate choice rather than a shortcut. The obvious keychain
5
+ // route on macOS is `security add-generic-password -w <secret>`, which puts the
6
+ // refresh token in argv — readable by any process that can run `ps` for as long
7
+ // as the call lasts. Trading a file only this user can read for a token that
8
+ // briefly leaks to every process on the box is not an upgrade. A real keychain
9
+ // integration wants a native binding, which is a dependency this CLI does not
10
+ // currently pay for.
11
+
12
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { dirname, join } from 'node:path';
15
+
16
+ function configPath() {
17
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
18
+ return join(base, 'zapqr', 'credentials.json');
19
+ }
20
+
21
+ /** Keyed by issuer, so a staging provider cannot quietly overwrite production. */
22
+ export function load(issuer) {
23
+ try {
24
+ const all = JSON.parse(readFileSync(configPath(), 'utf8'));
25
+ return all[issuer] ?? null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ export function save(issuer, record) {
32
+ const path = configPath();
33
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
34
+
35
+ let all = {};
36
+ try {
37
+ all = JSON.parse(readFileSync(path, 'utf8'));
38
+ } catch {
39
+ // First write, or a file we cannot parse: start clean rather than refuse.
40
+ }
41
+ all[issuer] = record;
42
+
43
+ // Create with 0600 from the outset — writing then chmod'ing leaves a window
44
+ // where another user on the machine can open the file.
45
+ writeFileSync(path, JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
46
+ chmodSync(path, 0o600); // pre-existing file: writeFileSync's mode is ignored
47
+ return path;
48
+ }
49
+
50
+ export function clear(issuer) {
51
+ const path = configPath();
52
+ try {
53
+ const all = JSON.parse(readFileSync(path, 'utf8'));
54
+ delete all[issuer];
55
+ if (Object.keys(all).length === 0) rmSync(path, { force: true });
56
+ else writeFileSync(path, JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ export function storePath() {
64
+ return configPath();
65
+ }