@standchat/cli 0.0.0-stage → 0.1.1

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/config.js ADDED
@@ -0,0 +1,444 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, readdir, rename, rm } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { isAbsolute, join } from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+ import { CliError, isObject, usage } from './errors.js';
8
+ import { safeUrl, sleep } from './transport.js';
9
+ import { validateDpopPrivateJwk } from './dpop.js';
10
+ const DEFAULT_PROFILE = { apiUrl: 'https://api.stand.chat', issuer: 'https://auth.stand.chat/realms/stand' };
11
+ export function configHome(env = process.env) {
12
+ const root = env.STAND_CONFIG_HOME ?? join(env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'stand');
13
+ if (!isAbsolute(root))
14
+ usage('STAND_CONFIG_HOME and XDG_CONFIG_HOME must be absolute paths. Project configuration is never loaded.');
15
+ return root;
16
+ }
17
+ export function profileName(value) {
18
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(value))
19
+ usage('Profile names use 1–64 letters, numbers, underscores, or hyphens.');
20
+ return value;
21
+ }
22
+ async function assertPrivate(path, directory) {
23
+ const stat = await lstat(path);
24
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) {
25
+ throw new CliError('UNSAFE_CONFIG', 'Configuration and credential paths must be regular files/directories, not symbolic links.', 4);
26
+ }
27
+ if (process.platform !== 'win32' && ((stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid()))) {
28
+ throw new CliError('UNSAFE_CONFIG', 'Configuration/credential storage is not private to your user. Use mode 700 for its directory and 600 for files.', 4);
29
+ }
30
+ }
31
+ export async function privateDirectory(path) {
32
+ await mkdir(path, { recursive: true, mode: 0o700 });
33
+ await assertPrivate(path, true);
34
+ }
35
+ export async function readPrivateJson(path) {
36
+ try {
37
+ await assertPrivate(path, false);
38
+ const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
39
+ try {
40
+ const stat = await file.stat();
41
+ if (!stat.isFile() || stat.size > 128 * 1024 || (process.platform !== 'win32' && (stat.mode & 0o077) !== 0)) {
42
+ throw new CliError('UNSAFE_CONFIG', 'Invalid credential/config file type, size, or permissions.', 4);
43
+ }
44
+ try {
45
+ return JSON.parse(await file.readFile('utf8'));
46
+ }
47
+ catch {
48
+ throw new CliError('CONFIG_INVALID', 'Configuration or credential file is invalid JSON.', 4);
49
+ }
50
+ }
51
+ finally {
52
+ await file.close();
53
+ }
54
+ }
55
+ catch (error) {
56
+ if (isObject(error) && error.code === 'ENOENT')
57
+ return undefined;
58
+ throw error;
59
+ }
60
+ }
61
+ export async function writePrivateJson(path, data) {
62
+ try {
63
+ await assertPrivate(path, false);
64
+ }
65
+ catch (error) {
66
+ if (!isObject(error) || error.code !== 'ENOENT')
67
+ throw error;
68
+ }
69
+ const temporary = `${path}.${randomUUID()}.tmp`;
70
+ try {
71
+ const file = await open(temporary, 'wx', 0o600);
72
+ try {
73
+ await file.writeFile(`${JSON.stringify(data, null, 2)}\n`);
74
+ await file.sync();
75
+ }
76
+ finally {
77
+ await file.close();
78
+ }
79
+ await rename(temporary, path);
80
+ }
81
+ finally {
82
+ await rm(temporary, { force: true });
83
+ }
84
+ }
85
+ async function withPrivateFileLock(root, path, resource, fn, signal) {
86
+ const busyCode = resource === 'credential' ? 'CREDENTIAL_BUSY' : 'CONFIG_BUSY';
87
+ const title = resource === 'credential' ? 'Credential' : 'Configuration';
88
+ const cancelled = () => { if (signal?.aborted)
89
+ throw new CliError('CANCELLED', 'Operation cancelled.', 130); };
90
+ cancelled();
91
+ await privateDirectory(root);
92
+ const queue = `${path}.queue`;
93
+ await privateDirectory(queue);
94
+ const id = `${process.pid}-${randomUUID()}.json`;
95
+ const claim = join(queue, id);
96
+ const deadline = performance.now() + 120_000;
97
+ const wait = async () => {
98
+ cancelled();
99
+ if (performance.now() >= deadline)
100
+ throw new CliError(busyCode, `Another command owns this ${resource}, or its ownership cannot be verified. Retry after it finishes. Queue: ${queue}. Repair this directory only after verifying no Stand CLI process is running; never remove a live process’s lock.`, 4);
101
+ await sleep(50, signal);
102
+ };
103
+ const dead = (pid) => {
104
+ try {
105
+ process.kill(pid, 0);
106
+ return false;
107
+ }
108
+ catch (error) {
109
+ return isObject(error) && error.code === 'ESRCH';
110
+ }
111
+ };
112
+ const claims = async () => {
113
+ const active = [];
114
+ for (const name of await readdir(queue)) {
115
+ if (!/^\d+-[0-9a-f-]{36}\.json$/.test(name))
116
+ continue;
117
+ const file = join(queue, name);
118
+ const value = await readPrivateJson(file);
119
+ if (value === undefined)
120
+ continue;
121
+ if (!isObject(value) || !Number.isSafeInteger(value.pid) || Number(value.pid) <= 0
122
+ || name.split('-')[0] !== String(value.pid) || !Number.isSafeInteger(value.ticket) || Number(value.ticket) < 0) {
123
+ throw new CliError(busyCode, `${title} lock ownership is invalid. Queue: ${queue}. Verify no Stand CLI process is running before repairing these files.`, 4);
124
+ }
125
+ if (dead(Number(value.pid))) {
126
+ // Names are unique and never reused. A competing cleanup cannot delete
127
+ // another process's replacement claim as it could with a fixed lock path.
128
+ await rm(file, { force: true });
129
+ }
130
+ else
131
+ active.push({ id: name, ticket: Number(value.ticket) });
132
+ }
133
+ return active;
134
+ };
135
+ // Each attempt owns a unique claim for its whole lifetime, so stale cleanup
136
+ // never unlinks a new owner's claim. Publish choosing=0 atomically before
137
+ // inspecting peers; arrivals then observe us or wait for our ticket. The
138
+ // bakery queue is the only lock: there is no reusable PID-file pathname.
139
+ try {
140
+ await writePrivateJson(claim, { pid: process.pid, ticket: 0 });
141
+ const ticket = Math.max(0, ...(await claims()).map(peer => peer.ticket)) + 1;
142
+ if (!Number.isSafeInteger(ticket))
143
+ throw new CliError(busyCode, `${title} lock ticket limit reached.`, 4);
144
+ await writePrivateJson(claim, { pid: process.pid, ticket });
145
+ while ((await claims()).some(peer => peer.id !== id
146
+ && (peer.ticket === 0 || peer.ticket < ticket || (peer.ticket === ticket && peer.id < id))))
147
+ await wait();
148
+ cancelled();
149
+ return await fn();
150
+ }
151
+ finally {
152
+ await rm(claim, { force: true });
153
+ }
154
+ }
155
+ export class ConfigStore {
156
+ root;
157
+ constructor(root = configHome()) {
158
+ this.root = root;
159
+ if (!isAbsolute(root))
160
+ usage('Configuration directory must be absolute. Project-local configuration is never loaded.');
161
+ }
162
+ async read() {
163
+ // No directory creation for read-only commands. Still reject unsafe existing roots.
164
+ try {
165
+ await assertPrivate(this.root, true);
166
+ }
167
+ catch (error) {
168
+ if (isObject(error) && error.code === 'ENOENT')
169
+ return { version: 1, profiles: {} };
170
+ throw error;
171
+ }
172
+ const data = await readPrivateJson(join(this.root, 'config.json'));
173
+ if (data === undefined)
174
+ return { version: 1, profiles: {} };
175
+ if (!isObject(data) || data.version !== 1 || !isObject(data.profiles))
176
+ throw new CliError('CONFIG_INVALID', 'Unsupported configuration format.', 2);
177
+ const profiles = Object.create(null);
178
+ for (const [name, raw] of Object.entries(data.profiles)) {
179
+ if (!isObject(raw) || typeof raw.apiUrl !== 'string' || typeof raw.issuer !== 'string')
180
+ throw new CliError('CONFIG_INVALID', 'Invalid profile.', 2);
181
+ if (raw.credentialStore !== undefined && raw.credentialStore !== 'keychain' && raw.credentialStore !== 'file')
182
+ throw new CliError('CONFIG_INVALID', 'Invalid credential store.', 2);
183
+ profiles[profileName(name)] = {
184
+ apiUrl: safeUrl(raw.apiUrl).origin,
185
+ issuer: safeUrl(raw.issuer, 'issuer').href.replace(/\/$/, ''),
186
+ credentialStore: raw.credentialStore,
187
+ };
188
+ }
189
+ return { version: 1, profiles };
190
+ }
191
+ async profile(name, env = process.env) {
192
+ const config = await this.read();
193
+ const saved = config.profiles[profileName(name)] ?? (name === 'default' ? DEFAULT_PROFILE : undefined);
194
+ if (!saved)
195
+ throw new CliError('PROFILE_NOT_FOUND', 'Profile does not exist. Run stand config set --profile NAME --api-url URL --issuer URL.', 2);
196
+ return {
197
+ ...saved,
198
+ apiUrl: safeUrl(env.STAND_API_URL ?? saved.apiUrl).origin,
199
+ issuer: safeUrl(env.STAND_ISSUER ?? saved.issuer, 'issuer').href.replace(/\/$/, ''),
200
+ };
201
+ }
202
+ // Every mutation of the shared file must derive its result after acquiring
203
+ // this global lock. A per-profile lock cannot protect the other profile rows.
204
+ async update(name, change, signal) {
205
+ const selected = profileName(name);
206
+ return withPrivateFileLock(this.root, join(this.root, 'config.json.lock'), 'configuration', async () => {
207
+ const config = await this.read();
208
+ const profile = change(config.profiles[selected]);
209
+ config.profiles[selected] = profile;
210
+ await writePrivateJson(join(this.root, 'config.json'), config);
211
+ return profile;
212
+ }, signal);
213
+ }
214
+ async save(name, profile, signal) {
215
+ await this.update(name, () => profile, signal);
216
+ }
217
+ }
218
+ const run = (command, args, input) => new Promise(resolve => {
219
+ const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
220
+ let output = '';
221
+ let diagnostic = '';
222
+ const timeout = setTimeout(() => child.kill(), 30_000);
223
+ child.stdout.on('data', chunk => { output += String(chunk); if (output.length > 128 * 1024)
224
+ child.kill(); });
225
+ child.stderr.on('data', chunk => { diagnostic += String(chunk); if (diagnostic.length > 128 * 1024)
226
+ child.kill(); });
227
+ child.on('error', () => { clearTimeout(timeout); resolve({ code: -1, stdout: '', stderr: 'Credential-store command unavailable' }); });
228
+ child.on('close', code => { clearTimeout(timeout); resolve({ code: code ?? -1, stdout: output, stderr: diagnostic }); });
229
+ child.stdin.on('error', () => { });
230
+ child.stdin.end(input);
231
+ });
232
+ export function keychainCommandQuote(value) {
233
+ if (/[\x00-\x1f\x7f]/.test(value))
234
+ throw new CliError('CREDENTIAL_INVALID', 'Credential contains unsupported control characters.', 4);
235
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
236
+ }
237
+ export class CredentialVault {
238
+ root;
239
+ name;
240
+ profile;
241
+ runner;
242
+ platform;
243
+ trackLocation;
244
+ key;
245
+ constructor(root, name, profile, runner = run, platform = process.platform, trackLocation = true) {
246
+ this.root = root;
247
+ this.name = name;
248
+ this.profile = profile;
249
+ this.runner = runner;
250
+ this.platform = platform;
251
+ this.trackLocation = trackLocation;
252
+ this.key = createHash('sha256').update(JSON.stringify([name, profile.apiUrl, profile.issuer, 'stand-cli'])).digest('hex');
253
+ }
254
+ async store() {
255
+ if (this.profile.credentialStore === 'file') {
256
+ if (this.platform === 'win32')
257
+ throw new CliError('CREDENTIAL_STORE_UNAVAILABLE', 'File credentials require POSIX permissions. On Windows, use the CLI inside WSL with a supported Linux credential store.', 4);
258
+ return 'file';
259
+ }
260
+ if (this.platform === 'darwin')
261
+ return 'keychain';
262
+ if (this.platform === 'linux')
263
+ return 'keychain';
264
+ throw new CliError('CREDENTIAL_STORE_UNAVAILABLE', 'No supported OS keychain is available. Explicitly select --credential-store file on a trusted POSIX machine, or use the CLI inside WSL with a supported Linux credential store.', 4);
265
+ }
266
+ validate(value) {
267
+ if (!isObject(value) || value.version !== 1 || value.apiUrl !== this.profile.apiUrl || value.issuer !== this.profile.issuer
268
+ || value.clientId !== 'stand-cli' || typeof value.accessToken !== 'string' || !value.accessToken || /\s/.test(value.accessToken)
269
+ || (value.refreshToken !== undefined && typeof value.refreshToken !== 'string')
270
+ || (value.expiresAt !== undefined && (typeof value.expiresAt !== 'number' || !Number.isFinite(value.expiresAt)))
271
+ || (value.scopes !== undefined && typeof value.scopes !== 'string')) {
272
+ throw new CliError('CREDENTIAL_INVALID', 'Stored credential does not match the selected API, issuer, and client.', 4);
273
+ }
274
+ if (value.dpopPrivateJwk !== undefined)
275
+ validateDpopPrivateJwk(value.dpopPrivateJwk);
276
+ return value;
277
+ }
278
+ locationDirectory() {
279
+ return join(this.root, 'credential-locations', createHash('sha256').update(this.name).digest('hex'));
280
+ }
281
+ async locationPath() {
282
+ return join(this.locationDirectory(), `${this.key}-${await this.store()}.json`);
283
+ }
284
+ async rememberLocation() {
285
+ if (!this.trackLocation)
286
+ return;
287
+ await privateDirectory(this.root);
288
+ await privateDirectory(join(this.root, 'credential-locations'));
289
+ await privateDirectory(this.locationDirectory());
290
+ // Persist the non-secret recovery address before the secret, so interruption
291
+ // cannot orphan a credential after switching endpoint or storage backend.
292
+ await writePrivateJson(await this.locationPath(), { version: 1, apiUrl: this.profile.apiUrl,
293
+ issuer: this.profile.issuer, credentialStore: await this.store() });
294
+ }
295
+ async locations() {
296
+ const directory = this.locationDirectory();
297
+ try {
298
+ await assertPrivate(directory, true);
299
+ }
300
+ catch (error) {
301
+ if (isObject(error) && error.code === 'ENOENT')
302
+ return [this];
303
+ throw error;
304
+ }
305
+ const entries = await readdir(directory);
306
+ if (entries.length > 128)
307
+ throw new CliError('CONFIG_INVALID', 'Too many saved credential locations for this profile.', 4);
308
+ const vaults = [];
309
+ for (const entry of entries) {
310
+ if (!/^[a-f0-9]{64}-(?:file|keychain)\.json$/.test(entry))
311
+ continue;
312
+ const value = await readPrivateJson(join(directory, entry));
313
+ if (value === undefined)
314
+ continue;
315
+ if (!isObject(value) || value.version !== 1 || typeof value.apiUrl !== 'string' || typeof value.issuer !== 'string'
316
+ || !['file', 'keychain'].includes(String(value.credentialStore))) {
317
+ throw new CliError('CONFIG_INVALID', 'Invalid saved credential location.', 4);
318
+ }
319
+ const profile = { apiUrl: safeUrl(value.apiUrl).origin,
320
+ issuer: safeUrl(value.issuer, 'issuer').href.replace(/\/$/, ''), credentialStore: value.credentialStore };
321
+ const vault = new CredentialVault(this.root, this.name, profile, this.runner, this.platform);
322
+ if (join(directory, entry) !== await vault.locationPath())
323
+ throw new CliError('CONFIG_INVALID', 'Credential location does not match its identifier.', 4);
324
+ vaults.push(vault);
325
+ }
326
+ // The selected target may predate location tracking while another target
327
+ // was recorded later. Always include it, without visiting the same store twice.
328
+ const selectedPath = await this.locationPath();
329
+ if (!(await Promise.all(vaults.map(vault => vault.locationPath()))).includes(selectedPath))
330
+ vaults.push(this);
331
+ return vaults;
332
+ }
333
+ get profileName() { return this.name; }
334
+ get target() { return { ...this.profile }; }
335
+ /** Exercise read/write/delete before requesting browser consent; never overwrite a saved credential. */
336
+ async preflight() {
337
+ const probe = new CredentialVault(this.root, `${this.name}-probe-${randomUUID()}`, this.profile, this.runner, this.platform, false);
338
+ const marker = `storage-probe-${randomUUID()}`;
339
+ try {
340
+ await probe.save({ version: 1, apiUrl: this.profile.apiUrl, issuer: this.profile.issuer, clientId: 'stand-cli', accessToken: marker });
341
+ if ((await probe.load())?.accessToken !== marker)
342
+ throw new CliError('CREDENTIAL_STORE_UNAVAILABLE', 'Credential storage failed its read-back check. No browser authorization was requested.', 4);
343
+ }
344
+ finally {
345
+ await probe.delete();
346
+ }
347
+ }
348
+ async loadRaw() {
349
+ const store = await this.store();
350
+ if (store === 'file') {
351
+ const dir = join(this.root, 'credentials');
352
+ try {
353
+ await assertPrivate(dir, true);
354
+ }
355
+ catch (error) {
356
+ if (isObject(error) && error.code === 'ENOENT')
357
+ return undefined;
358
+ throw error;
359
+ }
360
+ const raw = await readPrivateJson(join(dir, `${this.key}.json`));
361
+ return raw;
362
+ }
363
+ const result = this.platform === 'darwin'
364
+ ? await this.runner('/usr/bin/security', ['find-generic-password', '-s', 'chat.stand.cli', '-a', this.key, '-w'])
365
+ : await this.runner('secret-tool', ['lookup', 'service', 'chat.stand.cli', 'account', this.key]);
366
+ if (result.code !== 0) {
367
+ // macOS errSecItemNotFound (44); secret-tool uses 1 for no matching item.
368
+ if (result.code === (this.platform === 'darwin' ? 44 : 1)
369
+ && (this.platform === 'darwin' || !result.stderr?.trim()))
370
+ return undefined;
371
+ throw new CliError('CREDENTIAL_STORE_UNAVAILABLE', 'Cannot read the OS keychain. Unlock it and retry.', 4);
372
+ }
373
+ // Keep deletion verification independent of credential parsing: malformed
374
+ // entries must still be removable with --local-only.
375
+ return result.stdout;
376
+ }
377
+ async load() {
378
+ const raw = await this.loadRaw();
379
+ if (raw === undefined)
380
+ return undefined;
381
+ if (typeof raw !== 'string')
382
+ return this.validate(raw);
383
+ try {
384
+ return this.validate(JSON.parse(raw));
385
+ }
386
+ catch (error) {
387
+ if (error instanceof CliError)
388
+ throw error;
389
+ throw new CliError('CREDENTIAL_INVALID', 'Keychain credential is invalid.', 4);
390
+ }
391
+ }
392
+ async save(credential) {
393
+ this.validate(credential);
394
+ const store = await this.store();
395
+ if (store === 'file') {
396
+ await privateDirectory(this.root);
397
+ const dir = join(this.root, 'credentials');
398
+ await privateDirectory(dir);
399
+ await this.rememberLocation();
400
+ await writePrivateJson(join(dir, `${this.key}.json`), credential);
401
+ return;
402
+ }
403
+ const json = JSON.stringify(credential);
404
+ // Secrets go through stdin, never process arguments or a shell.
405
+ const command = this.platform === 'darwin'
406
+ ? `add-generic-password -U -s chat.stand.cli -a ${this.key} -w ${keychainCommandQuote(json)}\n` : undefined;
407
+ // security -i uses a 4096-byte line buffer. Never let a truncated line
408
+ // overwrite the prior entry or interpret its remainder as another command.
409
+ if (command && Buffer.byteLength(command, 'utf8') > 4095) {
410
+ throw new CliError('CREDENTIAL_TOO_LARGE', 'This credential exceeds the macOS keychain input limit. The existing entry was not changed. Select --credential-store file on a trusted POSIX machine and sign in again.', 4);
411
+ }
412
+ await this.rememberLocation();
413
+ const result = this.platform === 'darwin'
414
+ ? await this.runner('/usr/bin/security', ['-i'], command)
415
+ : await this.runner('secret-tool', ['store', '--label=Stand Chat CLI', 'service', 'chat.stand.cli', 'account', this.key], json);
416
+ if (result.code !== 0)
417
+ throw new CliError('CREDENTIAL_STORE_UNAVAILABLE', 'Could not save the credential in the OS keychain. No file fallback was used.', 4);
418
+ const stored = await this.load();
419
+ if (stored?.accessToken !== credential.accessToken || stored?.refreshToken !== credential.refreshToken
420
+ || JSON.stringify(stored?.dpopPrivateJwk) !== JSON.stringify(credential.dpopPrivateJwk)) {
421
+ throw new CliError('CREDENTIAL_STORE_UNAVAILABLE', 'Could not verify the credential saved in the OS keychain. No file fallback was used.', 4);
422
+ }
423
+ }
424
+ async delete() {
425
+ if (await this.store() === 'file') {
426
+ await rm(join(this.root, 'credentials', `${this.key}.json`), { force: true });
427
+ }
428
+ else {
429
+ const result = this.platform === 'darwin'
430
+ ? await this.runner('/usr/bin/security', ['delete-generic-password', '-s', 'chat.stand.cli', '-a', this.key])
431
+ : await this.runner('secret-tool', ['clear', 'service', 'chat.stand.cli', 'account', this.key]);
432
+ if ((result.code !== 0 && result.code !== (this.platform === 'darwin' ? 44 : 1))
433
+ || (this.platform === 'linux' && Boolean(result.stderr?.trim())))
434
+ throw new CliError('CREDENTIAL_STORE_UNAVAILABLE', 'Could not remove the credential from the OS keychain. Unlock it and retry.', 4);
435
+ }
436
+ if (await this.loadRaw() !== undefined)
437
+ throw new CliError('CREDENTIAL_STORE_UNAVAILABLE', 'Credential removal could not be verified. The saved credential may still exist.', 4);
438
+ if (this.trackLocation)
439
+ await rm(await this.locationPath(), { force: true });
440
+ }
441
+ async locked(fn, signal) {
442
+ return withPrivateFileLock(this.root, join(this.root, `credential-${this.key}.lock`), 'credential', fn, signal);
443
+ }
444
+ }
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { isObject } from './errors.js';
3
+ export const BUNDLED_SCHEMA = JSON.parse(readFileSync(new URL('./openapi.json', import.meta.url), 'utf8'));
4
+ export const DELEGATED_SCOPES = (() => {
5
+ const paths = isObject(BUNDLED_SCHEMA) && isObject(BUNDLED_SCHEMA.paths) ? BUNDLED_SCHEMA.paths : {};
6
+ const scopes = new Set();
7
+ for (const item of Object.values(paths)) {
8
+ if (!isObject(item))
9
+ continue;
10
+ for (const operation of Object.values(item)) {
11
+ if (isObject(operation) && typeof operation['x-required-scope'] === 'string') {
12
+ scopes.add(operation['x-required-scope']);
13
+ }
14
+ }
15
+ }
16
+ return [...scopes].sort();
17
+ })();
18
+ /** Inspect the bundled public operation without trusting a server error body. */
19
+ export function publicOperation(path, method) {
20
+ const paths = isObject(BUNDLED_SCHEMA) && isObject(BUNDLED_SCHEMA.paths) ? BUNDLED_SCHEMA.paths : {};
21
+ const segments = path.split('?')[0].split('/');
22
+ for (const [pattern, item] of Object.entries(paths)) {
23
+ if (!isObject(item))
24
+ continue;
25
+ const parts = pattern.split('/');
26
+ if (parts.length !== segments.length || !parts.every((part, index) => /^\{[^}]+\}$/.test(part) || part === segments[index]))
27
+ continue;
28
+ const operation = item[method.toLowerCase()];
29
+ if (isObject(operation))
30
+ return operation;
31
+ }
32
+ return undefined;
33
+ }
34
+ export function operationScope(path, method) {
35
+ const operation = publicOperation(path, method);
36
+ return typeof operation?.['x-required-scope'] === 'string' ? operation['x-required-scope'] : undefined;
37
+ }
package/dist/dpop.js ADDED
@@ -0,0 +1,63 @@
1
+ import { createECDH, createHash, createPrivateKey, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
2
+ import { CliError, isObject } from './errors.js';
3
+ function bindingRequired() {
4
+ throw new CliError('AUTH_BINDING_REQUIRED', 'The saved session or issuer does not support the required refresh-token binding. Run stand auth login again. No unbound refresh was attempted.', 4);
5
+ }
6
+ export function validateDpopPrivateJwk(value) {
7
+ if (!isObject(value) || value.kty !== 'EC' || value.crv !== 'P-256'
8
+ || ![value.x, value.y, value.d].every(component => typeof component === 'string' && /^[A-Za-z0-9_-]{43}$/.test(component))) {
9
+ return bindingRequired();
10
+ }
11
+ const key = { kty: 'EC', crv: 'P-256', x: value.x, y: value.y, d: value.d };
12
+ try {
13
+ // Validate canonical 32-byte coordinates and that the public point belongs
14
+ // to this private scalar before persisting or signing with a restored key.
15
+ for (const component of [key.x, key.y, key.d]) {
16
+ if (Buffer.from(component, 'base64url').toString('base64url') !== component)
17
+ return bindingRequired();
18
+ }
19
+ const curve = createECDH('prime256v1');
20
+ curve.setPrivateKey(Buffer.from(key.d, 'base64url'));
21
+ const point = curve.getPublicKey();
22
+ if (point.subarray(1, 33).toString('base64url') !== key.x || point.subarray(33).toString('base64url') !== key.y)
23
+ return bindingRequired();
24
+ return key;
25
+ }
26
+ catch {
27
+ return bindingRequired();
28
+ }
29
+ }
30
+ export function generateDpopPrivateJwk() {
31
+ return validateDpopPrivateJwk(generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey.export({ format: 'jwk' }));
32
+ }
33
+ export function dpopThumbprint(key) {
34
+ // RFC 7638 uses only the required public members, in lexicographic order.
35
+ return createHash('sha256').update(JSON.stringify({ crv: key.crv, kty: key.kty, x: key.x, y: key.y })).digest('base64url');
36
+ }
37
+ export function createDpopProof(value, endpoint, nonce) {
38
+ const key = validateDpopPrivateJwk(value);
39
+ const url = new URL(endpoint);
40
+ if (url.search || url.hash)
41
+ throw new CliError('OIDC_METADATA', 'DPoP endpoints must not contain a query or fragment.', 4);
42
+ const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: { kty: key.kty, crv: key.crv, x: key.x, y: key.y } };
43
+ const claims = { htu: endpoint, htm: 'POST', iat: Math.floor(Date.now() / 1000), jti: randomUUID(), ...(nonce === undefined ? {} : { nonce }) };
44
+ const signingInput = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}`;
45
+ const signature = sign('sha256', Buffer.from(signingInput), { key: createPrivateKey({ key: { ...key }, format: 'jwk' }), dsaEncoding: 'ieee-p1363' });
46
+ return `${signingInput}.${signature.toString('base64url')}`;
47
+ }
48
+ export function assertRefreshBinding(token, key) {
49
+ // This is a compatibility assertion on a token received from the authenticated
50
+ // issuer endpoint, not JWT signature verification or an authorization decision.
51
+ // The API and identity provider remain responsible for validating their tokens.
52
+ try {
53
+ const parts = token.split('.');
54
+ if (parts.length !== 3 || parts.some(part => !part || !/^[A-Za-z0-9_-]+$/.test(part)))
55
+ return bindingRequired();
56
+ const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
57
+ if (!isObject(claims) || !isObject(claims.cnf) || claims.cnf.jkt !== dpopThumbprint(key))
58
+ return bindingRequired();
59
+ }
60
+ catch {
61
+ return bindingRequired();
62
+ }
63
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,30 @@
1
+ export class CliError extends Error {
2
+ code;
3
+ exitCode;
4
+ status;
5
+ retryAfter;
6
+ reconciliation;
7
+ constructor(code, message, exitCode = 1, status, retryAfter, reconciliation) {
8
+ super(message);
9
+ this.code = code;
10
+ this.exitCode = exitCode;
11
+ this.status = status;
12
+ this.retryAfter = retryAfter;
13
+ this.reconciliation = reconciliation;
14
+ this.name = 'CliError';
15
+ }
16
+ }
17
+ export function usage(message) {
18
+ throw new CliError('USAGE', message, 2);
19
+ }
20
+ export function isObject(value) {
21
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
22
+ }
23
+ // API strings are untrusted terminal content, including user-authored names.
24
+ export function terminalText(value) {
25
+ return value.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, '');
26
+ }
27
+ // JSON remains lossless and parseable while controls cannot affect a terminal.
28
+ export function terminalJson(value, pretty = false) {
29
+ return JSON.stringify(value, null, pretty ? 2 : undefined).replace(/[\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, character => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
30
+ }