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