@invitedlabs/cli 0.0.0-stage → 0.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/dist/main.js ADDED
@@ -0,0 +1,212 @@
1
+ import { homedir } from 'node:os';
2
+ import { checkOptions, COMMAND_OPTIONS, parseArgs } from './args.js';
3
+ import { openBrowser } from './browser.js';
4
+ import { assistantsLine, fragments, login, logout, privateFragments, status, whoami, } from './commands.js';
5
+ import { connect, HOSTS } from './connect.js';
6
+ import { configDir, credentialStore } from './credentials.js';
7
+ import { CliError } from './errors.js';
8
+ import { style as outputStyle } from './format.js';
9
+ import { VERSION } from './version.js';
10
+ /**
11
+ * `invited`: the command-line client of Invited's MCP connector
12
+ * (docs/mcp-connector-2026-10-11.md, "3e: CLI"). It configures assistants'
13
+ * hosts to use the connector, and logs in itself as the pre-registered
14
+ * client `invited-cli` to print, in a terminal, the fragments the member
15
+ * chooses to share with it on the consent screen (owner, 8 November 2026: the
16
+ * connector reads those alone, never the Page). It holds an OAuth refresh
17
+ * token like any other client, never a vault key.
18
+ */
19
+ export const DEFAULT_ORIGIN = 'https://invited.you';
20
+ const defaults = () => ({
21
+ fetch: (input, init) => fetch(input, init),
22
+ env: process.env,
23
+ out: text => void process.stdout.write(text),
24
+ err: text => void process.stderr.write(text),
25
+ now: Date.now,
26
+ home: homedir(),
27
+ platform: process.platform,
28
+ openBrowser: url => openBrowser(url, process.platform),
29
+ loginTimeoutMs: 5 * 60_000,
30
+ });
31
+ /** Invited's address: https, or http on this computer (a local Invited such
32
+ * as http://127.0.0.1:4180). The connector's own address (…/mcp) is accepted. */
33
+ export function normalOrigin(value) {
34
+ let url;
35
+ try {
36
+ url = new URL(value);
37
+ }
38
+ catch {
39
+ throw new CliError(`${value} is not an address. Use ${DEFAULT_ORIGIN}, or http://127.0.0.1:4180 for a local Invited.`);
40
+ }
41
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
42
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))
43
+ throw new CliError(`${value} is not safe to log in to: use https, or http only on this computer.`);
44
+ if (url.username || url.password || url.search || url.hash || !['', '/', '/mcp', '/mcp/'].includes(url.pathname))
45
+ throw new CliError(`Give Invited's address without a path, such as ${DEFAULT_ORIGIN}.`);
46
+ return url.origin;
47
+ }
48
+ const HELP = {
49
+ connect: `Usage: invited connect <host> [--dry-run] [--bridge] [--config <file>] [--origin <url>]
50
+
51
+ Adds Invited's MCP server (${DEFAULT_ORIGIN}/mcp) to an assistant's host under the
52
+ name "invited", keeping every other server in the file as it is. Hosts:
53
+ claude-code ~/.claude.json (user scope)
54
+ cursor ~/.cursor/mcp.json
55
+ codex ~/.codex/config.toml ($CODEX_HOME)
56
+ gemini ~/.gemini/settings.json
57
+ The host then logs in to Invited itself: you approve it in your browser.
58
+
59
+ --dry-run Show what would be written; write nothing
60
+ --bridge Use the mcp-remote bridge (npx -y mcp-remote@0.14.3) instead of the
61
+ host's own log-in, for a host version without remote log-in
62
+ --config <file> The configuration file to edit, instead of the host's usual one
63
+ --origin <url> Invited's address (default ${DEFAULT_ORIGIN})
64
+ `,
65
+ login: `Usage: invited login [--no-browser] [--origin <url>]
66
+
67
+ Connects this CLI to your Identity: your browser opens Invited, you approve the
68
+ Invited CLI and choose the fragments it may read and for how long, and the
69
+ browser returns to a listener on this computer (http://127.0.0.1, a port the
70
+ system picks), which closes as soon as it answers or after 5 minutes. The log-in
71
+ is saved in ~/.invited/credentials.json. (--private, which 0.1.0 needed for this,
72
+ is still taken.)
73
+
74
+ --no-browser Print the address to open instead of opening the browser
75
+ --origin <url> Invited's address (default ${DEFAULT_ORIGIN})
76
+ `,
77
+ whoami: `Usage: invited whoami [--json] [--origin <url>]
78
+
79
+ Prints the fragments you share with this CLI, by section, and when that sharing
80
+ ends: as Markdown (invited://identity), or as JSON with --json
81
+ (invited://identity.json).
82
+ `,
83
+ fragments: `Usage: invited fragments --type <type> [--type <type> ...] [--json]
84
+ invited fragments --search <words> [--json]
85
+
86
+ Prints the fragments you share with this CLI of the given types (get_fragments),
87
+ or the ones that match some words, best first (search_fragments). Types are keys
88
+ such as skill, language, experience, cuisines or dietaryPreferences.
89
+ `,
90
+ private: `Usage: invited private [--type <type> ...] [--json] [--origin <url>]
91
+
92
+ Prints the fragments you share with this CLI (private_fragments), and when that
93
+ sharing ends. You choose them in your browser with \`invited login\`. While they
94
+ are shared, Invited's server can read them to pass them on.
95
+ `,
96
+ status: `Usage: invited status [--json] [--origin <url>]
97
+
98
+ Shows the log-in this CLI holds: your Identity, its scopes, when it was last
99
+ used and when it expires. It reads the credentials file only. Listing every
100
+ assistant you have connected needs your browser: Activity, Assistants.
101
+ `,
102
+ logout: `Usage: invited logout [--origin <url>] (also: invited revoke)
103
+
104
+ Revokes this CLI's own log-in at Invited and deletes its credentials. Other
105
+ assistants are disconnected in Activity, not here.
106
+ `,
107
+ };
108
+ HELP.revoke = HELP.logout;
109
+ function help(topic, style) {
110
+ if (topic && HELP[topic])
111
+ return HELP[topic];
112
+ return `invited ${VERSION}: connect AI assistants and terminal agents to your Invited Identity.
113
+
114
+ ${style.bold('Usage')}
115
+ invited <command> [options]
116
+
117
+ ${style.bold('Commands')}
118
+ connect <host> Add Invited's MCP server to ${HOSTS.slice(0, -1).join(', ')} or ${HOSTS.at(-1)}
119
+ login Connect this CLI to your Identity (you approve and choose in your browser)
120
+ whoami Print the fragments you share with this CLI, as Markdown
121
+ fragments Print shared fragments: --type <type> (repeatable) or --search <words>
122
+ private Print the fragments you share with this CLI and when that ends
123
+ status Show the log-in this CLI holds
124
+ logout Revoke this CLI's log-in and delete its credentials (also: revoke)
125
+
126
+ ${style.bold('Options')}
127
+ --origin <url> Invited's address (default ${DEFAULT_ORIGIN}, or INVITED_ORIGIN)
128
+ --json Print JSON (whoami, fragments, private, status)
129
+ --dry-run connect: show what would be written, write nothing
130
+ --bridge connect: use the mcp-remote bridge instead of the host's own log-in
131
+ --config <file> connect: the configuration file to edit
132
+ --no-browser login: print the address instead of opening a browser
133
+ -h, --help Show help (invited help <command> for one command)
134
+ -v, --version Show the version
135
+
136
+ The CLI keeps its log-in in ~/.invited/credentials.json (INVITED_CONFIG_DIR moves it)
137
+ and reads only the fragments you choose to share with it, for as long as you chose,
138
+ within 60 reads a minute.
139
+ ${assistantsLine(DEFAULT_ORIGIN)}
140
+ `;
141
+ }
142
+ /** Runs one command; resolves to the exit code (0 or 1). */
143
+ export async function main(argv, partial = {}) {
144
+ const environment = { ...defaults(), ...partial };
145
+ const style = outputStyle(environment.env);
146
+ try {
147
+ const { command, positionals, flags, given } = parseArgs(argv);
148
+ if (flags.version) {
149
+ environment.out(`invited ${VERSION}\n`);
150
+ return 0;
151
+ }
152
+ if (command === null || command === 'help') {
153
+ environment.out(help(command === 'help' ? positionals[0] : undefined, style));
154
+ return command === null && !flags.help ? 1 : 0;
155
+ }
156
+ if (!(command in COMMAND_OPTIONS))
157
+ throw new CliError(`Unknown command ${command}. Run \`invited --help\` to see the commands.`);
158
+ if (flags.help) {
159
+ environment.out(help(command, style));
160
+ return 0;
161
+ }
162
+ checkOptions(command, given);
163
+ const most = command === 'connect' || command === 'revoke' ? 1 : 0;
164
+ if (positionals.length > most)
165
+ throw new CliError(`\`invited ${command}\` was given too many words: ${positionals.slice(most).join(' ')}.`);
166
+ const origin = normalOrigin(flags.origin ?? (environment.env.INVITED_ORIGIN?.trim() || DEFAULT_ORIGIN));
167
+ if (command === 'connect') {
168
+ await connect({
169
+ host: positionals[0],
170
+ origin,
171
+ home: environment.home,
172
+ env: environment.env,
173
+ platform: environment.platform,
174
+ config: flags.config,
175
+ dryRun: flags.dryRun,
176
+ bridge: flags.bridge,
177
+ out: environment.out,
178
+ });
179
+ return 0;
180
+ }
181
+ const store = credentialStore(configDir(environment.env, environment.home));
182
+ if (await store.tooOpen())
183
+ environment.err(`Warning: other users of this computer can read ${store.path}. Run: chmod 600 ${store.path}\n`);
184
+ const ctx = { ...environment, origin, store, style };
185
+ switch (command) {
186
+ case 'login':
187
+ await login(ctx, { noBrowser: flags.noBrowser });
188
+ return 0;
189
+ case 'whoami':
190
+ await whoami(ctx, flags.json);
191
+ return 0;
192
+ case 'fragments':
193
+ await fragments(ctx, { types: flags.types, search: flags.search, json: flags.json });
194
+ return 0;
195
+ case 'private':
196
+ await privateFragments(ctx, { types: flags.types, json: flags.json });
197
+ return 0;
198
+ case 'status':
199
+ return await status(ctx, flags.json);
200
+ default:
201
+ await logout(ctx, positionals[0]);
202
+ return 0;
203
+ }
204
+ }
205
+ catch (error) {
206
+ if (error instanceof CliError)
207
+ environment.err(error.message + '\n');
208
+ else
209
+ environment.err(`Something went wrong: ${error instanceof Error ? error.message : String(error)}\n`);
210
+ return 1;
211
+ }
212
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,144 @@
1
+ import { CliError } from './errors.js';
2
+ import { readJson, retryAfter, send } from './http.js';
3
+ import { mcpUrl } from './oauth.js';
4
+ import { VERSION } from './version.js';
5
+ /**
6
+ * A client of Invited's MCP server at revision 2026-07-28 only
7
+ * (docs/mcp-connector-2026-10-11.md, "3b: server"): one POST per message,
8
+ * stateless, no initialize handshake. Every request carries its protocol
9
+ * version, client information and capabilities in `params._meta`, and mirrors
10
+ * them in the headers 3b checks: MCP-Protocol-Version, Mcp-Method and, for
11
+ * tools/call and resources/read, Mcp-Name. This CLI talks only to Invited, so
12
+ * it falls back to no older revision.
13
+ */
14
+ export const PROTOCOL_VERSION = '2026-07-28';
15
+ const NAMED = { 'tools/call': 'name', 'resources/read': 'uri' };
16
+ /** Streamable HTTP "Value Encoding": a header value outside printable ASCII
17
+ * is sent as =?base64?…?=. */
18
+ export const headerValue = (value) => /^[\x20-\x7e]*$/.test(value) && !/^=\?base64\?/.test(value)
19
+ ? value
20
+ : `=?base64?${Buffer.from(value, 'utf8').toString('base64')}?=`;
21
+ /** A JSON-RPC error in an answer the server sent with HTTP 200 (an unknown
22
+ * resource or tool). */
23
+ export class McpError extends CliError {
24
+ code;
25
+ data;
26
+ constructor(code, message, data) {
27
+ super(message);
28
+ this.code = code;
29
+ this.data = data;
30
+ }
31
+ }
32
+ /** 403 insufficient_scope: this connection lacks the scope a read needs, or
33
+ * (identity:private) the member shares nothing with it now. The member's
34
+ * consent is the way on (MCP authorization, "Scope Challenge Handling"). */
35
+ export class ScopeRefused extends CliError {
36
+ scope;
37
+ constructor(scope, message) {
38
+ super(message);
39
+ this.scope = scope;
40
+ }
41
+ }
42
+ let nextId = 1;
43
+ export async function mcpRequest(fetch, origin, token, method, params) {
44
+ const id = nextId++;
45
+ const body = JSON.stringify({
46
+ jsonrpc: '2.0',
47
+ id,
48
+ method,
49
+ params: {
50
+ ...params,
51
+ _meta: {
52
+ 'io.modelcontextprotocol/protocolVersion': PROTOCOL_VERSION,
53
+ 'io.modelcontextprotocol/clientInfo': { name: 'invited-cli', title: 'Invited CLI', version: VERSION },
54
+ 'io.modelcontextprotocol/clientCapabilities': {},
55
+ },
56
+ },
57
+ });
58
+ const named = NAMED[method];
59
+ let response;
60
+ for (let attempt = 0;; attempt++) {
61
+ response = await send(fetch, mcpUrl(origin), {
62
+ method: 'POST',
63
+ headers: {
64
+ authorization: `Bearer ${await token(attempt > 0)}`,
65
+ 'content-type': 'application/json',
66
+ accept: 'application/json, text/event-stream',
67
+ 'mcp-protocol-version': PROTOCOL_VERSION,
68
+ 'mcp-method': headerValue(method),
69
+ ...(named ? { 'mcp-name': headerValue(String(params[named])) } : {}),
70
+ },
71
+ body,
72
+ });
73
+ // An access token refused once is renewed once (it may have expired, or
74
+ // been revoked: then the renewal says so).
75
+ if (response.status !== 401 || attempt > 0)
76
+ break;
77
+ await response.body?.cancel();
78
+ }
79
+ return answer(response, id);
80
+ }
81
+ async function answer(response, id) {
82
+ const type = response.headers.get('content-type') ?? '';
83
+ if (response.status === 200 && /^text\/event-stream/i.test(type))
84
+ return outcome(await fromEventStream(response, id));
85
+ const body = await readJson(response);
86
+ const error = (typeof body.error === 'object' && body.error !== null ? body.error : {});
87
+ const message = typeof error.message === 'string' ? error.message : null;
88
+ switch (response.status) {
89
+ case 200:
90
+ return outcome(body);
91
+ case 401: {
92
+ const reason = typeof body.error_description === 'string' ? ` (${body.error_description.replace(/\.$/, '')})` : '';
93
+ throw new CliError(`Invited did not accept this CLI's log-in${reason}. Run \`invited login\` to connect again.`);
94
+ }
95
+ case 403: {
96
+ const challenge = response.headers.get('www-authenticate') ?? '';
97
+ const data = (error.data ?? {});
98
+ const scope = /(?:^|[\s,])scope="([^"]+)"/.exec(challenge)?.[1] ?? (typeof data.scope === 'string' ? data.scope : null);
99
+ if (/error="insufficient_scope"/.test(challenge) && scope)
100
+ throw new ScopeRefused(scope, `Invited refused this read: ${message ?? `it needs the ${scope} scope.`}`);
101
+ throw new CliError(`Invited refused this read: ${message ?? 'this connection may not read it.'}`);
102
+ }
103
+ case 429: {
104
+ const data = (error.data ?? {});
105
+ const wait = retryAfter(response) ?? (typeof data.retryAfter === 'number' ? data.retryAfter : 60);
106
+ throw new CliError(`Too many reads: this connection has used its 60 reads a minute, which every Invited CLI logged in to your Identity shares. Try again in ${wait} seconds.`);
107
+ }
108
+ case 503:
109
+ throw new CliError(`Invited could not complete this request. Try again in ${retryAfter(response) ?? 30} seconds.`);
110
+ default:
111
+ throw new CliError(`Invited answered ${response.status}${message ? ': ' + message : '.'}`);
112
+ }
113
+ }
114
+ function outcome(message) {
115
+ if (typeof message.error === 'object' && message.error !== null) {
116
+ const error = message.error;
117
+ throw new McpError(typeof error.code === 'number' ? error.code : 0, typeof error.message === 'string' ? error.message : 'Invited refused the request.', error.data);
118
+ }
119
+ if (typeof message.result !== 'object' || message.result === null)
120
+ throw new CliError('Invited answered in a form this CLI does not know.');
121
+ return message.result;
122
+ }
123
+ /** A request-scoped SSE answer (Streamable HTTP lets a server send one;
124
+ * Invited sends JSON): the JSON-RPC response with this request's id. */
125
+ async function fromEventStream(response, id) {
126
+ for (const event of (await response.text()).split(/\r?\n\r?\n/)) {
127
+ const data = event
128
+ .split(/\r?\n/)
129
+ .filter(line => line.startsWith('data:'))
130
+ .map(line => line.slice(5).replace(/^ /, ''))
131
+ .join('\n');
132
+ if (!data)
133
+ continue;
134
+ try {
135
+ const message = JSON.parse(data);
136
+ if (message.id === id && ('result' in message || 'error' in message))
137
+ return message;
138
+ }
139
+ catch {
140
+ // Not a JSON-RPC message: skip it.
141
+ }
142
+ }
143
+ throw new CliError('Invited ended its answer without a result.');
144
+ }
package/dist/oauth.js ADDED
@@ -0,0 +1,166 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { CliError } from './errors.js';
3
+ import { readJson, retryAfter, send } from './http.js';
4
+ /**
5
+ * The CLI as an OAuth 2.1 client of Invited's authorization server
6
+ * (docs/mcp-connector-2026-10-11.md, "3a"): the pre-registered public client
7
+ * `invited-cli`, whose return addresses are http://127.0.0.1/callback and
8
+ * http://localhost/callback on any port (RFC 8252 section 7.3). PKCE (S256),
9
+ * `state`, the `resource` indicator (RFC 8707) and the `iss` response
10
+ * parameter (RFC 9207). It only ever talks to the Invited origin it was
11
+ * given: discovery must name that origin as the issuer and every endpoint.
12
+ */
13
+ export const CLIENT_ID = 'invited-cli';
14
+ /** What the CLI asks for, every log-in: both scopes, as the consent screen
15
+ * grants identity:private only beside identity:public. Since 8 November 2026
16
+ * every read needs identity:private: the fragments the member chooses to share
17
+ * on the consent screen (Phase 4, step 4b), never the Page. */
18
+ export const PUBLIC_SCOPE = 'identity:public';
19
+ export const PRIVATE_SCOPE = 'identity:private';
20
+ export const mcpUrl = (origin) => origin + '/mcp';
21
+ async function getJson(fetch, url) {
22
+ const response = await send(fetch, url, { headers: { accept: 'application/json' } });
23
+ if (response.status !== 200)
24
+ throw new CliError(`${new URL(url).origin} answered ${response.status} for ${new URL(url).pathname}.`);
25
+ return readJson(response);
26
+ }
27
+ const notInvited = (origin) => new CliError(`${origin} does not describe itself as Invited's connector, so this CLI will not log in there.`);
28
+ /** The protected resource metadata (RFC 9728), then the authorization server
29
+ * metadata (RFC 8414), each checked against the origin. */
30
+ export async function discover(fetch, origin) {
31
+ const resource = mcpUrl(origin);
32
+ const protectedResource = await getJson(fetch, origin + '/.well-known/oauth-protected-resource/mcp');
33
+ if (protectedResource.resource !== resource ||
34
+ !Array.isArray(protectedResource.authorization_servers) ||
35
+ !protectedResource.authorization_servers.includes(origin))
36
+ throw notInvited(origin);
37
+ const server = await getJson(fetch, origin + '/.well-known/oauth-authorization-server');
38
+ const endpoint = (value) => (typeof value === 'string' && value.startsWith(origin + '/') ? value : null);
39
+ const authorization = endpoint(server.authorization_endpoint), token = endpoint(server.token_endpoint), revocation = endpoint(server.revocation_endpoint);
40
+ const methods = server.code_challenge_methods_supported;
41
+ if (server.issuer !== origin ||
42
+ !authorization ||
43
+ !token ||
44
+ !revocation ||
45
+ !Array.isArray(methods) ||
46
+ !methods.includes('S256'))
47
+ throw notInvited(origin);
48
+ return {
49
+ issuer: origin,
50
+ resource,
51
+ authorization,
52
+ token,
53
+ revocation,
54
+ issInResponse: server.authorization_response_iss_parameter_supported === true,
55
+ };
56
+ }
57
+ /** RFC 7636: a 43-character verifier from 32 random bytes, and its S256 challenge. */
58
+ export function pkcePair() {
59
+ const verifier = randomBytes(32).toString('base64url');
60
+ return { verifier, challenge: createHash('sha256').update(verifier, 'ascii').digest('base64url') };
61
+ }
62
+ export const newState = () => randomBytes(24).toString('base64url');
63
+ export function authorizationUrl(endpoints, redirectUri, challenge, state, scopes = [PUBLIC_SCOPE]) {
64
+ const url = new URL(endpoints.authorization);
65
+ url.search = new URLSearchParams({
66
+ response_type: 'code',
67
+ client_id: CLIENT_ID,
68
+ redirect_uri: redirectUri,
69
+ code_challenge: challenge,
70
+ code_challenge_method: 'S256',
71
+ state,
72
+ scope: scopes.join(' '),
73
+ resource: endpoints.resource,
74
+ }).toString();
75
+ return url.href;
76
+ }
77
+ /** The token endpoint refused the grant (RFC 6749 section 5.2). */
78
+ export class GrantRefused extends CliError {
79
+ error;
80
+ description;
81
+ constructor(error, description) {
82
+ super(description);
83
+ this.error = error;
84
+ this.description = description;
85
+ }
86
+ }
87
+ /** The Identity the access token names. Invited's access tokens are JWTs
88
+ * (RFC 9068) whose `identity` claim is the member's Identity; the CLI reads
89
+ * it as information only (it is not the token's audience). */
90
+ function identityOf(accessToken) {
91
+ try {
92
+ const payload = JSON.parse(Buffer.from(accessToken.split('.')[1] ?? '', 'base64url').toString('utf8'));
93
+ if (typeof payload.identity === 'string' && payload.identity)
94
+ return payload.identity;
95
+ }
96
+ catch {
97
+ // Not a JWT: refused below.
98
+ }
99
+ throw new CliError('Invited answered with an access token this CLI cannot read.');
100
+ }
101
+ async function tokenRequest(fetch, endpoint, form, now) {
102
+ const response = await send(fetch, endpoint, {
103
+ method: 'POST',
104
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
105
+ body: new URLSearchParams(form).toString(),
106
+ });
107
+ const body = await readJson(response);
108
+ if (response.status === 429)
109
+ throw new CliError(`Invited is limiting requests for this log-in. Try again in ${retryAfter(response) ?? 60} seconds.`);
110
+ if (response.status >= 500)
111
+ throw new CliError(`Invited could not complete this request. Try again in ${retryAfter(response) ?? 30} seconds.`);
112
+ if (response.status !== 200)
113
+ throw new GrantRefused(typeof body.error === 'string' ? body.error : 'invalid_request', typeof body.error_description === 'string' ? body.error_description : `Invited answered ${response.status}.`);
114
+ const { access_token: accessToken, refresh_token: refreshToken, token_type: type, expires_in: expiresIn } = body;
115
+ if (typeof accessToken !== 'string' ||
116
+ typeof refreshToken !== 'string' ||
117
+ !refreshToken ||
118
+ typeof type !== 'string' ||
119
+ type.toLowerCase() !== 'bearer' ||
120
+ typeof expiresIn !== 'number')
121
+ throw new CliError('Invited answered the token request in a form this CLI does not know.');
122
+ const tokens = {
123
+ accessToken,
124
+ accessExpiresAt: now + expiresIn * 1000,
125
+ refreshToken,
126
+ scopes: typeof body.scope === 'string' ? body.scope.split(' ').filter(Boolean) : [PUBLIC_SCOPE],
127
+ identityId: identityOf(accessToken),
128
+ };
129
+ return tokens;
130
+ }
131
+ /** The authorization code grant with the PKCE verifier (OAuth 2.1 section 4.1.3). */
132
+ export const exchangeCode = (fetch, endpoints, values, now) => tokenRequest(fetch, endpoints.token, {
133
+ grant_type: 'authorization_code',
134
+ code: values.code,
135
+ code_verifier: values.verifier,
136
+ redirect_uri: values.redirectUri,
137
+ client_id: CLIENT_ID,
138
+ resource: endpoints.resource,
139
+ }, now);
140
+ /** The refresh token grant: Invited spends the token and issues the next one
141
+ * in the same family (rotation), with a new one-hour access token. */
142
+ export const refreshTokens = (fetch, connection, now) => tokenRequest(fetch, connection.tokenEndpoint, {
143
+ grant_type: 'refresh_token',
144
+ refresh_token: connection.refreshToken,
145
+ client_id: connection.clientId,
146
+ resource: connection.resource,
147
+ }, now);
148
+ /** RFC 7009: revoking the refresh token ends its whole family, which is this
149
+ * CLI's own connection and never another assistant's. */
150
+ export async function revokeRefreshToken(fetch, connection) {
151
+ const response = await send(fetch, connection.revocationEndpoint, {
152
+ method: 'POST',
153
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
154
+ body: new URLSearchParams({
155
+ token: connection.refreshToken,
156
+ token_type_hint: 'refresh_token',
157
+ client_id: connection.clientId,
158
+ }).toString(),
159
+ });
160
+ if (response.status === 200)
161
+ return;
162
+ const body = await readJson(response);
163
+ const wait = retryAfter(response);
164
+ const detail = typeof body.error_description === 'string' ? `: ${body.error_description}` : '';
165
+ throw new CliError(`Invited did not revoke the log-in (${response.status}${detail}).` + (wait ? ` Try again in ${wait} seconds.` : ''));
166
+ }