@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/README.md +101 -2
- package/dist/auth.js +319 -0
- package/dist/commands.js +385 -0
- package/dist/config.js +444 -0
- package/dist/contract.js +37 -0
- package/dist/dpop.js +63 -0
- package/dist/errors.js +30 -0
- package/dist/export-validation.js +164 -0
- package/dist/index.js +349 -0
- package/dist/multipart.js +115 -0
- package/dist/openapi.json +1 -0
- package/dist/setup.js +48 -0
- package/dist/transport.js +322 -0
- package/dist/version.js +3 -0
- package/package.json +32 -4
package/README.md
CHANGED
|
@@ -1,3 +1,102 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Stand Chat CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Command-line access for installing and managing Stand Chat. The CLI covers the 109 published API operations, with named commands for common workflows and `stand api` for every published operation. Requires Node.js 22+ on macOS, Linux or WSL; native Windows is unsupported. Hosted/local MCP and WordPress are outside this release.
|
|
4
|
+
|
|
5
|
+
## Installation and browser handoff
|
|
6
|
+
|
|
7
|
+
After the release is publicly published:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install --global @standchat/cli@0.1.1
|
|
11
|
+
stand auth login --setup --json
|
|
12
|
+
stand setup status --json
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`auth login` emits a `device_authorization` event on stderr with a trusted verification URL and a separate public user code. Keep the command running while the human opens the URL, signs up or signs in, accepts terms and approves access. Do not ask the human to paste access tokens, cookies or refresh tokens into the conversation. Browser approval never permits the agent to invent identity details or accept terms for the human.
|
|
16
|
+
|
|
17
|
+
`setup status` performs reads only. If it reports `browser_onboarding_required`, the human must open the indicated dashboard using the same account and complete the existing profile/organization onboarding form. Run `setup status` again afterward. A ready result means configuration may begin; it does not mean that a widget or chat has been verified. Disabled membership and unreadable account data fail closed. For a custom environment, provide its verified dashboard origin with `--app-url`; no dashboard address is guessed from an arbitrary API hostname.
|
|
18
|
+
|
|
19
|
+
The setup preset requests `openid offline_access` and eight business permissions: `profile:read org:read sites:read sites:write standins:read standins:write knowledge:read knowledge:write`. Broader management permissions are available through explicit `--scopes`. Existing profile edits require `profile:write`; organization renaming additionally requires `org:write`. Initial profile and organization creation remain browser onboarding steps. Normal login defaults to read-only identity, organization and Site access; subsequent login preserves selectable previously granted permissions. Session lifetime follows the identity provider's existing limits. A refresh token is bound to a private proof key and is never printed.
|
|
20
|
+
|
|
21
|
+
macOS uses Keychain by default. Linux uses the OS secret service when available. On a host without a usable OS keychain, including typical WSL environments, explicitly choose `--credential-store file` during login; there is no silent fallback. File credentials are private to the user and never loaded from the project. Use `stand auth logout` to revoke refresh access and remove local credentials; `--local-only` removes local credentials without revocation when remote access is unavailable.
|
|
22
|
+
|
|
23
|
+
## Complete installation
|
|
24
|
+
|
|
25
|
+
Follow the public [agent installation guide](https://stand.chat/guide/ai-setup.md). Inspect schemas before creating configuration:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
stand commands --json
|
|
29
|
+
stand schema createStandin
|
|
30
|
+
stand sites list --json
|
|
31
|
+
stand standins list --json
|
|
32
|
+
stand knowledge list --json
|
|
33
|
+
stand org plan --json
|
|
34
|
+
stand org usage --json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Inspect existing resources and record their public IDs in your installation notes. Reuse a matching authorized Site where appropriate. Create a Site and a complete, initially disabled Stand-in only when needed. Only existing backend operations are used; this release has no configuration revisions or automatic multi-resource transaction.
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
stand sites create --domain example.com --name 'Example' --yes --json
|
|
41
|
+
stand standins create --body-file standin.json --yes --json
|
|
42
|
+
stand knowledge create --body-file knowledge.json --yes --json
|
|
43
|
+
stand knowledge crawl KNOWLEDGE_BASE_ID --body-file crawl.json --yes --json
|
|
44
|
+
stand knowledge tasks KNOWLEDGE_BASE_ID --json
|
|
45
|
+
stand knowledge documents KNOWLEDGE_BASE_ID --json
|
|
46
|
+
stand standins knowledge get STANDIN_ID --json
|
|
47
|
+
stand standins knowledge set STANDIN_ID --body-file attachments.json --yes --json
|
|
48
|
+
stand standins set-enabled STANDIN_ID --enabled true --yes --json
|
|
49
|
+
stand standins status --json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Use only approved public website content, or approved project content within the existing instruction limits. Free Base-plan installation must work without paid uploads or sitemap features. Do not crawl localhost or private addresses. Check task completion and ingested documents before claiming that knowledge is ready. Attachment writes replace the complete list: read existing attachments and preserve those the user has not authorized removing.
|
|
53
|
+
|
|
54
|
+
The account-wide switch is separate from each Stand-in's enablement. `stand standins master --enabled true --yes --json` affects existing enabled Stand-ins as well. Inspect them and obtain approval for that shared effect on an existing account. Site and existing Stand-in configuration updates are supported through `stand sites update` and `stand standins update`. Read the current resource and its request schema first: some settings and collections are complete replacements, and concurrent writes have no conditional revision protection.
|
|
55
|
+
|
|
56
|
+
Install the exact snippet returned by the Site API once, using the website framework's supported mechanism and preserving its consent and Content Security Policy. Never put CLI credentials in the website, browser code or repository. Publish through the project's normal authorized workflow. Open the deployed page on its configured domain, verify the widget, send a real visitor message and confirm a real answer grounded in the approved content. A script load, Site match, mocked response or committed snippet does not establish a working chat. Without deployment access, report the remaining step explicitly.
|
|
57
|
+
|
|
58
|
+
## Manage an existing account
|
|
59
|
+
|
|
60
|
+
Follow the [public API and CLI guide](https://stand.chat/guide/api-cli.md). The 19 business scopes cover profile, Sites, organization, sessions, team reads, digests, Stand-ins, knowledge, integrations, analytics and feedback. Each operation declares its required scope in `stand schema OPERATION_ID`. Scope consent never bypasses ownership, active membership or plan limits. Request only permissions needed for the user's approved work:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
stand auth login --scopes 'profile:read profile:write' --json
|
|
64
|
+
stand schema updateRepProfile --json
|
|
65
|
+
stand api PUT /v1/reps/profile --body-file profile.json --yes --json
|
|
66
|
+
stand sites get SITE_ID --json
|
|
67
|
+
stand sites update SITE_ID --body-file site.json --yes --json
|
|
68
|
+
stand standins update STANDIN_ID --body-file standin.json --yes --json
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Account deletion, subscription/payment management, team-member mutations, initial account bootstrap and JavaScript experimentation opt-in are excluded. An owner must enable experimentation in the dashboard before an authorized client can edit scripts. API calls cannot enable either `advancedBehaviorEnabled` or `allowStandAdminBehavior`; a request that tries to enable them fails before other Site changes are saved. Read the current Site and preserve unrelated behavior. Supplying legacy `embedOptions` replaces behavior rules and resets experimentation consent.
|
|
72
|
+
|
|
73
|
+
Multipart operations accept their documented form fields. Uploads retain existing plan and size limits:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
stand api POST /v1/knowledge-bases/KNOWLEDGE_BASE_ID/uploads \
|
|
77
|
+
--form-file file=/absolute/path/approved-document.pdf --yes --json
|
|
78
|
+
stand api GET /v1/knowledge-bases/KNOWLEDGE_BASE_ID/tasks \
|
|
79
|
+
--query-param view=inspection --query-param limit=20 --json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Explicit knowledge inspection returns bounded metadata pages; follow `nextCursor` as `after` with the same filters. The ordinary knowledge list commands preserve existing full list responses. Inspect completed ingestion and documents before using newly uploaded knowledge.
|
|
83
|
+
|
|
84
|
+
History export streams JSON to a new private file and validates the entire result before making it available. It never overwrites an existing file:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
stand history export --scope myself --output /absolute/path/history.json --json
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
History supports the documented query, date, Site, type, contact and label filters. Export defaults to 600 seconds and 100 MiB; `--timeout SECONDS` and `--max-bytes BYTES` can raise those bounds to at most 3,600 seconds and 1 GiB. The package uses `stream-json` for bounded-memory export validation. Conversations are private data: do not commit export files to a website repository.
|
|
91
|
+
|
|
92
|
+
## Automation and recovery
|
|
93
|
+
|
|
94
|
+
Output is JSON when stdout is not a terminal; `--json` makes this explicit. Diagnostics go to stderr. Mutation commands require `--yes` without a terminal. These confirmations do not replace the user's authorization for the requested action. Input JSON uses `--body-file FILE` or `--body-file -`. `stand api METHOD /v1/path` supports only operations in the bundled public OpenAPI; it cannot access unpublished routes or bypass account permissions. `--query-param key=value` adds a query parameter. Tokens are never accepted as command arguments or URL parameters.
|
|
95
|
+
|
|
96
|
+
GET requests retry a bounded number of transient HTTP failures. Writes never retry automatically. A timeout, lost response, cancellation after dispatch or server failure may leave a completed write: read the relevant list/resource, reconcile with recorded public IDs and approved content, and stop when the result remains uncertain. Do not create another resource merely because its earlier response was lost. Re-running setup must inspect saved progress and the installed snippet before creating or changing anything.
|
|
97
|
+
|
|
98
|
+
`STAND_CONFIG_HOME` must be an absolute private configuration location. `STAND_PROFILE` selects a saved profile. `STAND_API_URL` and `STAND_ISSUER` override endpoints for one invocation without rewriting saved targets. Credentials are bound to the selected API and issuer. `STAND_ACCESS_TOKEN` supports automation with an existing authorized token and requires `STAND_TOKEN_API_URL` to match its authorized API origin; it is never printed or saved by the CLI. Request timeouts default to 30 seconds and are bounded at 120 seconds. Exits: `0` success, `1` API error, `2` usage, `3` schema not found, `4` authentication/permission, `5` network/invalid response, `130` cancellation.
|
|
99
|
+
|
|
100
|
+
## Release validation
|
|
101
|
+
|
|
102
|
+
From the monorepo, run `pnpm --filter @standchat/cli test`. `node cli/scripts/prepare-release.mjs --output /absolute/empty/directory` builds and retains a checksummed npm tarball plus an isolated candidate installer. Candidate installation and synthetic tests do not establish public distribution or production readiness. The release gate requires the exact tarball's public npm integrity, a clean public installation, fresh Codex and Claude Code journeys with real signup and visitor chat, and normal deployment/rollback validation. No publication occurs during build or test.
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { CliError, isObject, usage } from './errors.js';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { fetchText, issuerEndpoint, safeUrl, sleep } from './transport.js';
|
|
4
|
+
import { DELEGATED_SCOPES } from './contract.js';
|
|
5
|
+
import { assertRefreshBinding, createDpopProof, dpopThumbprint, generateDpopPrivateJwk } from './dpop.js';
|
|
6
|
+
export const DEFAULT_SCOPES = ['openid', 'offline_access', 'profile:read', 'sites:read', 'org:read'];
|
|
7
|
+
export const SETUP_SCOPES = ['openid', 'offline_access', 'profile:read', 'sites:read', 'sites:write', 'standins:read', 'standins:write', 'knowledge:read', 'knowledge:write', 'org:read'];
|
|
8
|
+
export const ALLOWED_SCOPES = ['openid', 'offline_access', ...DELEGATED_SCOPES];
|
|
9
|
+
function jsonObject(text) {
|
|
10
|
+
try {
|
|
11
|
+
const value = JSON.parse(text);
|
|
12
|
+
if (isObject(value))
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
catch { /* Intentionally do not include the response. */ }
|
|
16
|
+
throw new CliError('OIDC_RESPONSE', 'The identity provider returned an invalid response.', 4);
|
|
17
|
+
}
|
|
18
|
+
export function requestedScopes(value) {
|
|
19
|
+
const scopes = value === undefined ? DEFAULT_SCOPES : value.split(/[ ,]+/).filter(Boolean);
|
|
20
|
+
if (!scopes.length || scopes.some(scope => !ALLOWED_SCOPES.includes(scope)))
|
|
21
|
+
usage(`Supported scopes: ${ALLOWED_SCOPES.join(' ')}.`);
|
|
22
|
+
return [...new Set(['openid', 'offline_access', ...scopes])].join(' ');
|
|
23
|
+
}
|
|
24
|
+
/** Re-login reuses selectable permissions, not issuer-added default claims. */
|
|
25
|
+
export async function scopesForLogin(vault, explicit) {
|
|
26
|
+
if (explicit !== undefined)
|
|
27
|
+
return requestedScopes(explicit);
|
|
28
|
+
let previous;
|
|
29
|
+
try {
|
|
30
|
+
previous = await vault.load();
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
// A fresh login can replace malformed credentials after storage preflight.
|
|
34
|
+
// Locked/unavailable keychains, unsafe files and I/O failures must still stop.
|
|
35
|
+
if (!(error instanceof CliError) || !['CREDENTIAL_INVALID', 'CONFIG_INVALID'].includes(error.code))
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
const selectable = previous?.scopes?.split(/\s+/).filter(scope => ALLOWED_SCOPES.includes(scope));
|
|
39
|
+
return requestedScopes(selectable?.length ? selectable.join(' ') : undefined);
|
|
40
|
+
}
|
|
41
|
+
export async function discover(profile, signal) {
|
|
42
|
+
const issuer = safeUrl(profile.issuer, 'issuer').href.replace(/\/$/, '');
|
|
43
|
+
const { response, text } = await fetchText(`${issuer}/.well-known/openid-configuration`, { signal }, 15_000, 256 * 1024);
|
|
44
|
+
if (!response.ok)
|
|
45
|
+
throw new CliError('OIDC_DISCOVERY', 'Could not load discovery for the configured issuer.', 4, response.status);
|
|
46
|
+
const data = jsonObject(text);
|
|
47
|
+
if (data.issuer !== issuer)
|
|
48
|
+
throw new CliError('OIDC_ISSUER_MISMATCH', 'Discovery issuer does not exactly match the configured issuer.', 4);
|
|
49
|
+
return {
|
|
50
|
+
issuer,
|
|
51
|
+
device_authorization_endpoint: issuerEndpoint(issuer, data.device_authorization_endpoint),
|
|
52
|
+
token_endpoint: issuerEndpoint(issuer, data.token_endpoint),
|
|
53
|
+
...(data.revocation_endpoint === undefined ? {} : { revocation_endpoint: issuerEndpoint(issuer, data.revocation_endpoint) }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function form(endpoint, fields, signal, proof) {
|
|
57
|
+
const { response, text } = await fetchText(endpoint, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', ...(proof ? { DPoP: proof } : {}) },
|
|
60
|
+
body: new URLSearchParams(fields), signal,
|
|
61
|
+
}, 15_000, 128 * 1024);
|
|
62
|
+
return { status: response.status, data: text ? jsonObject(text) : {}, nonce: response.headers.get('DPoP-Nonce') };
|
|
63
|
+
}
|
|
64
|
+
// Scope nonce state to one endpoint and one login/refresh/revocation operation.
|
|
65
|
+
// Retry only an explicit RFC 9449 nonce challenge, once, with a fresh proof.
|
|
66
|
+
class DpopRequest {
|
|
67
|
+
endpoint;
|
|
68
|
+
key;
|
|
69
|
+
nonce;
|
|
70
|
+
constructor(endpoint, key) {
|
|
71
|
+
this.endpoint = endpoint;
|
|
72
|
+
this.key = key;
|
|
73
|
+
}
|
|
74
|
+
async post(fields, signal) {
|
|
75
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
76
|
+
const result = await form(this.endpoint, fields, signal, createDpopProof(this.key, this.endpoint, this.nonce));
|
|
77
|
+
if (result.nonce !== null) {
|
|
78
|
+
if (!/^[\x21-\x7e]{1,1024}$/.test(result.nonce))
|
|
79
|
+
throw new CliError('OIDC_RESPONSE', 'Identity provider returned an invalid DPoP nonce.', 4);
|
|
80
|
+
this.nonce = result.nonce;
|
|
81
|
+
}
|
|
82
|
+
if (result.status !== 400 || result.data.error !== 'use_dpop_nonce')
|
|
83
|
+
return result;
|
|
84
|
+
if (!result.nonce || attempt !== 0)
|
|
85
|
+
throw new CliError('AUTH_DPOP_NONCE', 'Identity provider could not complete the proof challenge. Run stand auth login again.', 4);
|
|
86
|
+
}
|
|
87
|
+
throw new CliError('AUTH_DPOP_NONCE', 'Identity provider could not complete the proof challenge.', 4);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function tokenResponse(data) {
|
|
91
|
+
if (typeof data.access_token !== 'string' || !data.access_token || /\s/.test(data.access_token)
|
|
92
|
+
|| typeof data.expires_in !== 'number' || !Number.isFinite(data.expires_in) || data.expires_in <= 0
|
|
93
|
+
|| data.token_type !== 'Bearer'
|
|
94
|
+
|| (data.refresh_token !== undefined && (typeof data.refresh_token !== 'string' || /\s/.test(data.refresh_token)))
|
|
95
|
+
|| (data.scope !== undefined && typeof data.scope !== 'string')) {
|
|
96
|
+
throw new CliError('OIDC_RESPONSE', 'Identity provider returned an invalid token response.', 4);
|
|
97
|
+
}
|
|
98
|
+
return data;
|
|
99
|
+
}
|
|
100
|
+
function credential(profile, token, key, scopes) {
|
|
101
|
+
if (!token.refresh_token)
|
|
102
|
+
throw new CliError('AUTH_BINDING_REQUIRED', 'Identity provider did not return a bound refresh token. Run stand auth login again after the issuer supports this client.', 4);
|
|
103
|
+
assertRefreshBinding(token.refresh_token, key);
|
|
104
|
+
return {
|
|
105
|
+
version: 1, apiUrl: profile.apiUrl, issuer: profile.issuer, clientId: 'stand-cli',
|
|
106
|
+
accessToken: token.access_token, refreshToken: token.refresh_token, dpopPrivateJwk: key,
|
|
107
|
+
expiresAt: Date.now() + token.expires_in * 1000,
|
|
108
|
+
scopes: token.scope ?? scopes,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export async function deviceLogin(profile, scopes, hooks, signal) {
|
|
112
|
+
const metadata = await discover(profile, signal);
|
|
113
|
+
// Keycloak applies the client's S256 policy to device grants as well. The
|
|
114
|
+
// verifier stays in this process and is never stored or shown to the user.
|
|
115
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
116
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
117
|
+
const started = await form(metadata.device_authorization_endpoint, {
|
|
118
|
+
client_id: 'stand-cli', scope: scopes, code_challenge_method: 'S256', code_challenge: challenge,
|
|
119
|
+
}, signal);
|
|
120
|
+
if (started.status < 200 || started.status >= 300)
|
|
121
|
+
throw new CliError('DEVICE_AUTH_UNAVAILABLE', 'Device login is unavailable for stand-cli on this issuer. Verify the client has been deployed and approved.', 4, started.status);
|
|
122
|
+
const data = started.data;
|
|
123
|
+
if (typeof data.device_code !== 'string' || !data.device_code || typeof data.user_code !== 'string'
|
|
124
|
+
|| !/^[A-Za-z0-9 -]{1,64}$/.test(data.user_code)
|
|
125
|
+
|| typeof data.expires_in !== 'number' || !Number.isFinite(data.expires_in) || data.expires_in <= 0 || data.expires_in > 3600
|
|
126
|
+
|| (data.interval !== undefined && (typeof data.interval !== 'number' || !Number.isFinite(data.interval) || data.interval < 1 || data.interval > 600))) {
|
|
127
|
+
throw new CliError('OIDC_RESPONSE', 'Identity provider returned invalid device login instructions.', 4);
|
|
128
|
+
}
|
|
129
|
+
// Show the plain verification URL and separate code; do not print device_code or token-bearing URLs.
|
|
130
|
+
const verificationUri = issuerEndpoint(profile.issuer, data.verification_uri);
|
|
131
|
+
hooks.display({ verificationUri, userCode: data.user_code, expiresIn: data.expires_in });
|
|
132
|
+
const key = generateDpopPrivateJwk();
|
|
133
|
+
const tokenRequest = new DpopRequest(metadata.token_endpoint, key);
|
|
134
|
+
const now = hooks.now ?? Date.now;
|
|
135
|
+
const wait = hooks.wait ?? sleep;
|
|
136
|
+
const expires = now() + data.expires_in * 1000;
|
|
137
|
+
let interval = typeof data.interval === 'number' ? data.interval * 1000 : 5000;
|
|
138
|
+
while (now() + interval < expires) {
|
|
139
|
+
await wait(interval, signal);
|
|
140
|
+
if (signal?.aborted)
|
|
141
|
+
throw new CliError('CANCELLED', 'Login cancelled.', 130);
|
|
142
|
+
let result;
|
|
143
|
+
try {
|
|
144
|
+
result = await tokenRequest.post({ client_id: 'stand-cli', device_code: data.device_code, code_verifier: verifier, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' }, signal);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
// Never reduce the provider's interval or accumulated slow_down delay.
|
|
148
|
+
// The device expiry bounds exponential backoff and prevents further polls.
|
|
149
|
+
if (error instanceof CliError && error.code === 'TIMEOUT') {
|
|
150
|
+
interval *= 2;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
if (result.status >= 200 && result.status < 300)
|
|
156
|
+
return credential(profile, tokenResponse(result.data), key, scopes);
|
|
157
|
+
switch (result.data.error) {
|
|
158
|
+
case 'authorization_pending': break;
|
|
159
|
+
case 'slow_down':
|
|
160
|
+
interval += 5000;
|
|
161
|
+
break;
|
|
162
|
+
case 'access_denied': throw new CliError('AUTH_DENIED', 'Device login was denied.', 4);
|
|
163
|
+
case 'expired_token': throw new CliError('AUTH_EXPIRED', 'Device login expired. Run stand auth login again.', 4);
|
|
164
|
+
default: throw new CliError('AUTH_FAILED', 'Device login failed. Verify the client and requested scopes with the operator.', 4, result.status);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
throw new CliError('AUTH_EXPIRED', 'Device login expired. Run stand auth login again.', 4);
|
|
168
|
+
}
|
|
169
|
+
export function checkCancelled(signal) {
|
|
170
|
+
if (signal?.aborted)
|
|
171
|
+
throw new CliError('CANCELLED', 'Operation cancelled. No further request was sent.', 130);
|
|
172
|
+
}
|
|
173
|
+
export function combineSignals(...signals) {
|
|
174
|
+
const present = signals.filter((signal) => signal !== undefined);
|
|
175
|
+
// Node>=22 supports the public API; the pinned TS DOM declaration predates it.
|
|
176
|
+
return present.length ? AbortSignal.any(present) : undefined;
|
|
177
|
+
}
|
|
178
|
+
function selectableBusinessScopes(value) {
|
|
179
|
+
if (typeof value !== 'string' || value.length > 8192)
|
|
180
|
+
throw new CliError('CREDENTIAL_INVALID', 'The CLI permission metadata is invalid.', 4);
|
|
181
|
+
return [...new Set(value.split(/\s+/).filter(scope => scope.includes(':') && ALLOWED_SCOPES.includes(scope)))];
|
|
182
|
+
}
|
|
183
|
+
function accessClaims(token, issuer) {
|
|
184
|
+
// DENY-ONLY compatibility/switch guard. The API, not this decoded payload,
|
|
185
|
+
// verifies the signature and grants authorization for the selected token.
|
|
186
|
+
try {
|
|
187
|
+
const parts = token.split('.');
|
|
188
|
+
if (token.length > 16384 || parts.length !== 3 || parts.some(part => !/^[A-Za-z0-9_-]+$/.test(part)))
|
|
189
|
+
throw Error();
|
|
190
|
+
const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
191
|
+
if (!isObject(claims) || typeof claims.sub !== 'string' || !claims.sub.trim() || claims.sub.length > 255
|
|
192
|
+
|| claims.iss !== issuer || claims.azp !== 'stand-cli' || claims.typ !== 'Bearer'
|
|
193
|
+
|| !(claims.aud === 'stand-api' || (Array.isArray(claims.aud) && claims.aud.includes('stand-api')))
|
|
194
|
+
|| typeof claims.exp !== 'number' || !Number.isFinite(claims.exp) || typeof claims.scope !== 'string')
|
|
195
|
+
throw Error();
|
|
196
|
+
return { sub: claims.sub, exp: claims.exp, scope: claims.scope };
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
throw new CliError('CREDENTIAL_INVALID', 'The saved CLI access token cannot be used by local MCP. Sign in outside MCP.', 4);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
export class AuthSession {
|
|
203
|
+
profile;
|
|
204
|
+
vault;
|
|
205
|
+
env;
|
|
206
|
+
signal;
|
|
207
|
+
constructor(profile, vault, env = process.env, signal) {
|
|
208
|
+
this.profile = profile;
|
|
209
|
+
this.vault = vault;
|
|
210
|
+
this.env = env;
|
|
211
|
+
this.signal = signal;
|
|
212
|
+
}
|
|
213
|
+
environmentToken() {
|
|
214
|
+
const token = this.env.STAND_ACCESS_TOKEN;
|
|
215
|
+
if (token === undefined)
|
|
216
|
+
return undefined;
|
|
217
|
+
if (!token || /\s/.test(token))
|
|
218
|
+
throw new CliError('CREDENTIAL_INVALID', 'STAND_ACCESS_TOKEN must contain a nonempty token without whitespace.', 4);
|
|
219
|
+
if (!this.env.STAND_TOKEN_API_URL || safeUrl(this.env.STAND_TOKEN_API_URL).origin !== this.profile.apiUrl) {
|
|
220
|
+
throw new CliError('TOKEN_ORIGIN_REQUIRED', 'Set STAND_TOKEN_API_URL to the exact API origin authorized for STAND_ACCESS_TOKEN. It must match the selected profile.', 4);
|
|
221
|
+
}
|
|
222
|
+
return token;
|
|
223
|
+
}
|
|
224
|
+
async accessToken(signal = this.signal) {
|
|
225
|
+
const provided = this.environmentToken();
|
|
226
|
+
if (provided)
|
|
227
|
+
return provided;
|
|
228
|
+
return (await this.savedCredential(signal)).accessToken;
|
|
229
|
+
}
|
|
230
|
+
// The normal CLI still supports keychains/access-only credentials. This new
|
|
231
|
+
// local-MCP boundary deliberately requires a saved proof-bound FILE session.
|
|
232
|
+
async boundCredential(signal, expected) {
|
|
233
|
+
if (this.env.STAND_ACCESS_TOKEN !== undefined || this.profile.credentialStore !== 'file' || process.platform === 'win32') {
|
|
234
|
+
throw new CliError('MCP_FILE_LOGIN_REQUIRED', 'Local MCP requires an explicit file-store CLI login on a trusted POSIX machine. Sign in outside MCP with the intended scopes, the same --profile, and --credential-store file.', 4);
|
|
235
|
+
}
|
|
236
|
+
const combined = combineSignals(this.signal, signal);
|
|
237
|
+
const guard = (saved) => {
|
|
238
|
+
if (!saved.refreshToken || !saved.dpopPrivateJwk || !Number.isFinite(saved.expiresAt))
|
|
239
|
+
throw new CliError('AUTH_BINDING_REQUIRED', 'A saved proof-bound CLI login is required. Sign in outside MCP.', 4);
|
|
240
|
+
assertRefreshBinding(saved.refreshToken, saved.dpopPrivateJwk);
|
|
241
|
+
const claims = accessClaims(saved.accessToken, this.profile.issuer);
|
|
242
|
+
if (expected && (expected.keyThumbprint !== dpopThumbprint(saved.dpopPrivateJwk) || expected.subject !== claims.sub)) {
|
|
243
|
+
throw new CliError('MCP_SESSION_CHANGED', 'The saved CLI identity or login changed. Stop this MCP server and reconnect explicitly.', 4);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
const saved = await this.savedCredential(combined, guard);
|
|
247
|
+
checkCancelled(combined);
|
|
248
|
+
const claims = accessClaims(saved.accessToken, this.profile.issuer);
|
|
249
|
+
if (claims.exp * 1000 <= Date.now())
|
|
250
|
+
throw new CliError('AUTH_EXPIRED', 'The CLI access token has expired. Sign in outside MCP.', 4);
|
|
251
|
+
const granted = selectableBusinessScopes(saved.scopes);
|
|
252
|
+
const actual = selectableBusinessScopes(claims.scope);
|
|
253
|
+
return { accessToken: saved.accessToken, subject: claims.sub, keyThumbprint: dpopThumbprint(saved.dpopPrivateJwk),
|
|
254
|
+
scopes: actual.filter(scope => granted.includes(scope)) };
|
|
255
|
+
}
|
|
256
|
+
async savedCredential(signal, guard) {
|
|
257
|
+
checkCancelled(signal);
|
|
258
|
+
return this.vault.locked(async () => {
|
|
259
|
+
checkCancelled(signal);
|
|
260
|
+
const saved = await this.vault.load();
|
|
261
|
+
checkCancelled(signal);
|
|
262
|
+
if (!saved)
|
|
263
|
+
throw new CliError('AUTH_REQUIRED', 'No credential exists for this profile and API. Run stand auth login.', 4);
|
|
264
|
+
if (saved.refreshToken && !saved.dpopPrivateJwk)
|
|
265
|
+
throw new CliError('AUTH_BINDING_REQUIRED', 'This session predates refresh-token binding. Run stand auth login again. No unbound refresh was attempted.', 4);
|
|
266
|
+
if (saved.refreshToken && saved.dpopPrivateJwk)
|
|
267
|
+
assertRefreshBinding(saved.refreshToken, saved.dpopPrivateJwk);
|
|
268
|
+
guard?.(saved);
|
|
269
|
+
const expiry = guard ? Math.min(saved.expiresAt, accessClaims(saved.accessToken, this.profile.issuer).exp * 1000) : saved.expiresAt;
|
|
270
|
+
if (expiry === undefined || expiry > Date.now() + 30_000)
|
|
271
|
+
return saved;
|
|
272
|
+
if (!saved.refreshToken)
|
|
273
|
+
throw new CliError('AUTH_EXPIRED', 'The stored token has expired. Run stand auth login.', 4);
|
|
274
|
+
const metadata = await discover(this.profile, signal);
|
|
275
|
+
const result = await new DpopRequest(metadata.token_endpoint, saved.dpopPrivateJwk).post({ client_id: 'stand-cli', grant_type: 'refresh_token', refresh_token: saved.refreshToken }, signal);
|
|
276
|
+
if (result.status < 200 || result.status >= 300)
|
|
277
|
+
throw new CliError('AUTH_EXPIRED', 'The session cannot be refreshed. Run stand auth login. Existing access was not retried.', 4, result.status);
|
|
278
|
+
const response = tokenResponse(result.data);
|
|
279
|
+
response.refresh_token ??= saved.refreshToken;
|
|
280
|
+
const refreshed = credential(this.profile, response, saved.dpopPrivateJwk, saved.scopes);
|
|
281
|
+
guard?.(refreshed);
|
|
282
|
+
// Once a rotated token was received, finish its atomic save even if the
|
|
283
|
+
// caller cancelled. Keep the lock until save completes, then deny dispatch.
|
|
284
|
+
await this.vault.save(refreshed);
|
|
285
|
+
checkCancelled(signal);
|
|
286
|
+
return refreshed;
|
|
287
|
+
}, signal);
|
|
288
|
+
}
|
|
289
|
+
async loginHint(requiredScope) {
|
|
290
|
+
const saved = this.environmentToken() ? undefined : await this.vault.load();
|
|
291
|
+
const scopes = [...new Set([...(saved?.scopes?.split(/\s+/).filter(scope => ALLOWED_SCOPES.includes(scope)) ?? DEFAULT_SCOPES),
|
|
292
|
+
...(requiredScope && ALLOWED_SCOPES.includes(requiredScope) ? [requiredScope] : [])])];
|
|
293
|
+
return `Run stand auth login --profile ${this.vault.profileName} --scopes '${scopes.join(' ')}'.`;
|
|
294
|
+
}
|
|
295
|
+
async logout(localOnly = false) {
|
|
296
|
+
let revoked = false;
|
|
297
|
+
// Each saved endpoint/store has a non-secret recovery record. Configuration
|
|
298
|
+
// edits and temporary URL overrides must not strand older live credentials.
|
|
299
|
+
for (const vault of await this.vault.locations()) {
|
|
300
|
+
await vault.locked(async () => {
|
|
301
|
+
const saved = localOnly ? undefined : await vault.load();
|
|
302
|
+
if (saved?.refreshToken && !localOnly) {
|
|
303
|
+
const metadata = await discover(vault.target, this.signal);
|
|
304
|
+
if (!metadata.revocation_endpoint)
|
|
305
|
+
throw new CliError('REVOCATION_UNAVAILABLE', 'Issuer has no revocation endpoint. Use --local-only to explicitly remove only local credentials.', 4);
|
|
306
|
+
if (!saved.dpopPrivateJwk)
|
|
307
|
+
throw new CliError('AUTH_BINDING_REQUIRED', 'This session predates refresh-token binding. Use --local-only to remove only local credentials.', 4);
|
|
308
|
+
assertRefreshBinding(saved.refreshToken, saved.dpopPrivateJwk);
|
|
309
|
+
const result = await new DpopRequest(metadata.revocation_endpoint, saved.dpopPrivateJwk).post({ client_id: 'stand-cli', token: saved.refreshToken, token_type_hint: 'refresh_token' }, this.signal);
|
|
310
|
+
if (result.status < 200 || result.status >= 300)
|
|
311
|
+
throw new CliError('REVOCATION_FAILED', 'Token revocation failed. Retry, or use --local-only to explicitly remove only local credentials.', 4, result.status);
|
|
312
|
+
revoked = true;
|
|
313
|
+
}
|
|
314
|
+
await vault.delete();
|
|
315
|
+
}, this.signal);
|
|
316
|
+
}
|
|
317
|
+
return { localCredentialRemoved: true, refreshTokenRevoked: revoked };
|
|
318
|
+
}
|
|
319
|
+
}
|