metergraph-cli 0.1.0 → 0.2.0-preview.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.
@@ -0,0 +1,74 @@
1
+ import childProcess from "node:child_process";
2
+ import path from "node:path";
3
+
4
+ // Opens a URL with the operating system's own launcher: a fixed executable
5
+ // and an argument array, never a shell. The URL is the authorization request
6
+ // (public client ID, state and PKCE challenge); no token, code or verifier is
7
+ // ever on a command line. Resolves true when the launcher started, false
8
+ // when it could not be started. A started launcher does not prove that a
9
+ // browser opened, so callers still wait for the callback with a deadline.
10
+ export function openBrowser(url) {
11
+ const { command, args } = launcher();
12
+ return new Promise((resolve) => {
13
+ let child;
14
+ try {
15
+ child = childProcess.spawn(command, [...args, url], {
16
+ shell: false,
17
+ detached: true,
18
+ stdio: "ignore",
19
+ windowsHide: true,
20
+ });
21
+ } catch {
22
+ resolve(false);
23
+ return;
24
+ }
25
+ child.once("error", () => resolve(false));
26
+ child.once("spawn", () => {
27
+ child.unref();
28
+ resolve(true);
29
+ });
30
+ });
31
+ }
32
+
33
+ function launcher() {
34
+ if (process.platform === "darwin") return { command: "/usr/bin/open", args: [] };
35
+ if (process.platform === "win32") {
36
+ return {
37
+ command: path.win32.join(systemRoot(), "System32", "rundll32.exe"),
38
+ args: ["url.dll,FileProtocolHandler"],
39
+ };
40
+ }
41
+ return { command: "/usr/bin/xdg-open", args: [] };
42
+ }
43
+
44
+ // The Windows directory, from SystemRoot only when it is a plain absolute
45
+ // drive path, otherwise the standard location.
46
+ export function systemRoot() {
47
+ const value = process.env.SystemRoot;
48
+ if (typeof value === "string" && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]*$/.test(value) && !value.includes("..")) {
49
+ return value;
50
+ }
51
+ return "C:\\Windows";
52
+ }
53
+
54
+ // Variables whose presence means the browser a person uses is not on this
55
+ // machine, or no person is present. Only presence is checked; values are
56
+ // never read into output.
57
+ const REMOTE_MARKERS = [
58
+ ["ssh_session", ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]],
59
+ ["cloud_workspace", ["CODESPACES", "GITPOD_WORKSPACE_ID", "CLOUD_SHELL"]],
60
+ ["ci_environment", ["CI"]],
61
+ ];
62
+
63
+ // Returns null for an ordinary local session, or a fixed reason token.
64
+ export function detectRemoteSession(env = process.env) {
65
+ for (const [reason, names] of REMOTE_MARKERS) {
66
+ for (const name of names) {
67
+ const value = env[name];
68
+ if (typeof value === "string" && value !== "" && value !== "0" && value.toLowerCase() !== "false") {
69
+ return reason;
70
+ }
71
+ }
72
+ }
73
+ return null;
74
+ }
@@ -0,0 +1,177 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import http from "node:http";
3
+
4
+ // Loopback listener for the OAuth redirect (RFC 8252 section 7.3). It binds
5
+ // 127.0.0.1 on an ephemeral port and accepts exactly one callback: a GET on
6
+ // the fixed path, with the exact Host, one state that matches, and either one
7
+ // code or one error. Anything else is answered with a fixed page and the
8
+ // listener keeps waiting, so a stray or forged request cannot end the flow.
9
+ // Pages are static: no code, state or other request value is reflected, and
10
+ // nothing about a request is logged.
11
+
12
+ export const CALLBACK_PATH = "/callback";
13
+ const HOST = "127.0.0.1";
14
+ const MAX_URL_LENGTH = 4096;
15
+ const MAX_CODE_LENGTH = 2048;
16
+ const ALLOWED_PARAMS = new Set(["code", "state", "error", "error_description", "error_uri", "iss"]);
17
+
18
+ const PAGE_HEADERS = {
19
+ "content-type": "text/html; charset=utf-8",
20
+ "cache-control": "no-store",
21
+ "content-security-policy": "default-src 'none'",
22
+ "referrer-policy": "no-referrer",
23
+ "x-content-type-options": "nosniff",
24
+ "x-frame-options": "DENY",
25
+ connection: "close",
26
+ };
27
+ const page = (text) =>
28
+ `<!doctype html><meta charset="utf-8"><title>Metergraph CLI</title><p>${text}</p>\n`;
29
+ const PAGES = {
30
+ done: page("Metergraph CLI: authorization received. You can close this tab and return to the terminal."),
31
+ failed: page("Metergraph CLI: authorization did not complete. Return to the terminal for details."),
32
+ invalid: page("Metergraph CLI: this request was not accepted."),
33
+ };
34
+
35
+ // Starts listening. Resolves with { redirectUri, wait, close }.
36
+ // wait({ state, issuer, requireIss, timeoutMs, cancel }) arms the listener
37
+ // synchronously: from the moment it returns, a matching callback is
38
+ // accepted and the timeout is running. Call it before the authorization
39
+ // URL is opened or printed, so a fast browser can never reach an unarmed
40
+ // listener. It may be called once. The promise resolves with
41
+ // { kind: "code", code }
42
+ // { kind: "denied" } the user or server declined
43
+ // { kind: "error", reason } authorization_error,
44
+ // callback_issuer_mismatch or
45
+ // callback_invalid
46
+ // { kind: "timeout" } or { kind: "cancelled" }
47
+ // close() stops the listener, destroys every socket and settles a pending
48
+ // wait as cancelled, clearing its timer. It is safe to call more than once
49
+ // and must be called on every outcome, including early returns.
50
+ export async function startCallbackListener() {
51
+ const sockets = new Set();
52
+ let handler = (request, response) => respond(response, 503, PAGES.invalid);
53
+ const server = http.createServer(
54
+ { maxHeaderSize: 8192, requestTimeout: 10000, headersTimeout: 10000 },
55
+ (request, response) => handler(request, response),
56
+ );
57
+ server.on("connection", (socket) => {
58
+ sockets.add(socket);
59
+ socket.on("close", () => sockets.delete(socket));
60
+ });
61
+ server.on("clientError", (error, socket) => socket.destroy());
62
+
63
+ await new Promise((resolve, reject) => {
64
+ server.once("error", reject);
65
+ server.listen(0, HOST, () => {
66
+ server.removeListener("error", reject);
67
+ resolve();
68
+ });
69
+ });
70
+ const { port } = server.address();
71
+ const host = `${HOST}:${port}`;
72
+ const redirectUri = `http://${host}${CALLBACK_PATH}`;
73
+
74
+ let closed = false;
75
+ let armed = false;
76
+ // Settles the pending wait, if any. Replaced when wait() arms.
77
+ let settlePending = () => {};
78
+
79
+ const close = async () => {
80
+ settlePending({ kind: "cancelled" });
81
+ if (closed) return;
82
+ closed = true;
83
+ const done = new Promise((resolve) => server.close(() => resolve()));
84
+ for (const socket of sockets) socket.destroy();
85
+ await done;
86
+ };
87
+
88
+ const wait = ({ state, issuer, requireIss, timeoutMs, cancel }) => {
89
+ if (armed) throw new Error("callback listener is already armed");
90
+ armed = true;
91
+ // The executor runs synchronously, so the handler and timer are in
92
+ // place before wait() returns.
93
+ return new Promise((resolve) => {
94
+ let settled = false;
95
+ let timer = null;
96
+ const finish = (result) => {
97
+ if (settled) return;
98
+ settled = true;
99
+ if (timer !== null) clearTimeout(timer);
100
+ cancel?.removeEventListener("abort", onCancel);
101
+ resolve(result);
102
+ };
103
+ const onCancel = () => finish({ kind: "cancelled" });
104
+ settlePending = finish;
105
+
106
+ handler = (request, response) => {
107
+ if (settled) return respond(response, 410, PAGES.invalid);
108
+ const verdict = inspect(request, { host, state, issuer, requireIss });
109
+ if (verdict.result === undefined) return respond(response, verdict.status, PAGES.invalid);
110
+ const result = verdict.result;
111
+ respond(response, verdict.status, result.kind === "code" ? PAGES.done : PAGES.failed);
112
+ finish(result);
113
+ };
114
+
115
+ if (closed || cancel?.aborted) {
116
+ onCancel();
117
+ return;
118
+ }
119
+ timer = setTimeout(() => finish({ kind: "timeout" }), timeoutMs);
120
+ cancel?.addEventListener("abort", onCancel, { once: true });
121
+ });
122
+ };
123
+
124
+ return { redirectUri, wait, close };
125
+ }
126
+
127
+ function respond(response, status, body) {
128
+ response.writeHead(status, { ...PAGE_HEADERS, "content-length": Buffer.byteLength(body) });
129
+ response.end(body);
130
+ }
131
+
132
+ // Returns { status } for a request that is ignored, or { status, result }
133
+ // for the one request that ends the flow.
134
+ function inspect(request, { host, state, issuer, requireIss }) {
135
+ const url = request.url ?? "";
136
+ if (url.length > MAX_URL_LENGTH) return { status: 414 };
137
+ if (request.method !== "GET") return { status: 405 };
138
+ if (request.headers.host !== host) return { status: 400 };
139
+ const mark = url.indexOf("?");
140
+ const pathname = mark === -1 ? url : url.slice(0, mark);
141
+ if (pathname !== CALLBACK_PATH) return { status: 404 };
142
+
143
+ let params;
144
+ try {
145
+ params = new URLSearchParams(mark === -1 ? "" : url.slice(mark + 1));
146
+ } catch {
147
+ return { status: 400 };
148
+ }
149
+ const seen = new Set();
150
+ for (const key of params.keys()) {
151
+ if (!ALLOWED_PARAMS.has(key) || seen.has(key)) return { status: 400 };
152
+ seen.add(key);
153
+ }
154
+ // Only a request that carries this flow's exact state can end it.
155
+ if (!seen.has("state") || !sameText(params.get("state"), state)) return { status: 400 };
156
+
157
+ if (seen.has("iss") ? params.get("iss") !== issuer : requireIss) {
158
+ return { status: 400, result: { kind: "error", reason: "callback_issuer_mismatch" } };
159
+ }
160
+ if (seen.has("error")) {
161
+ if (seen.has("code")) return { status: 400, result: { kind: "error", reason: "callback_invalid" } };
162
+ return params.get("error") === "access_denied"
163
+ ? { status: 200, result: { kind: "denied" } }
164
+ : { status: 200, result: { kind: "error", reason: "authorization_error" } };
165
+ }
166
+ const code = params.get("code");
167
+ if (code === null || code.length === 0 || code.length > MAX_CODE_LENGTH || /[^\x21-\x7e]/.test(code)) {
168
+ return { status: 400, result: { kind: "error", reason: "callback_invalid" } };
169
+ }
170
+ return { status: 200, result: { kind: "code", code } };
171
+ }
172
+
173
+ function sameText(actual, expected) {
174
+ const a = Buffer.from(actual);
175
+ const b = Buffer.from(expected);
176
+ return a.length === b.length && timingSafeEqual(a, b);
177
+ }
@@ -0,0 +1,410 @@
1
+ import { detectRemoteSession, openBrowser } from "./auth-browser.js";
2
+ import { startCallbackListener } from "./auth-callback.js";
3
+ import {
4
+ BINDING_PATH,
5
+ bindingFor,
6
+ readBinding,
7
+ removeBinding,
8
+ resolveProject,
9
+ writeBinding,
10
+ } from "./auth-binding.js";
11
+ import {
12
+ authorizationUrl,
13
+ discover,
14
+ endpointsFor,
15
+ exchangeCode,
16
+ newPkce,
17
+ register,
18
+ revoke,
19
+ verifyContext,
20
+ } from "./auth-oauth.js";
21
+ import { LOCK_WAIT_MS, sessionFor } from "./auth-session.js";
22
+ import {
23
+ PROTECTION,
24
+ Stop,
25
+ deleteCredential,
26
+ newSlot,
27
+ openStore,
28
+ readCredential,
29
+ resolveConfigDir,
30
+ withSlotLock,
31
+ writeCredential,
32
+ } from "./auth-store.js";
33
+ import { AUTH_HTTP_TIMEOUT_MS, CONNECTION_GUIDE_URL, METADATA_SCOPE } from "./constants.js";
34
+ import { runDoctor } from "./doctor.js";
35
+ import { deadline, trapSignals } from "./transport.js";
36
+
37
+ // login and logout. Each returns { outcome, reason, data }; every string in
38
+ // data is a fixed token, the validated origin, a supported profile or a
39
+ // server-verified workspace UUID. Token values, the authorization code, the
40
+ // PKCE verifier, absolute paths and server text never leave this module.
41
+ // progress(text) prints human progress lines; it is a no-op with --json.
42
+
43
+ export async function runLogin(options, progress) {
44
+ const ctx = {
45
+ origin: options.origin,
46
+ runtime: options.runtime,
47
+ profile: null,
48
+ workspaceId: null,
49
+ authenticated: false,
50
+ configured: false,
51
+ status: null,
52
+ previousGrant: null,
53
+ };
54
+ const end = (outcome, reason, next = null) => ({ outcome, reason, data: loginData(ctx, next) });
55
+
56
+ // Handoffs come first: no listener, request or file write happens for a
57
+ // session that cannot finish sign in.
58
+ if (options.runtime !== "local") return end("unsupported", "runtime_not_supported", GUIDE);
59
+ const remote = detectRemoteSession();
60
+ if (remote !== null) return end("unsupported", remote, GUIDE);
61
+ if (options.json && options.noBrowser) {
62
+ return end("unsupported", "no_browser_requires_terminal", {
63
+ kind: "run_in_terminal",
64
+ message: "Run the same login command in a terminal without --json to see the sign in URL.",
65
+ });
66
+ }
67
+
68
+ const trap = trapSignals();
69
+ try {
70
+ const root = resolveProject(options.project);
71
+ const configDir = resolveConfigDir(options.configDir);
72
+ const existing = readBinding(root);
73
+ const bound = existing?.binding ?? null;
74
+ // Setup pins a deployment model before sign in. A stale binding for a
75
+ // different profile must never be silently reused or reconnected.
76
+ if (options.expectedProfile && bound !== null && !options.reconnect &&
77
+ bound.deployment_profile !== options.expectedProfile) {
78
+ return end("conflict", "deployment_profile_mismatch");
79
+ }
80
+ if (bound !== null && !options.reconnect) {
81
+ if (bound.origin !== options.origin) return end("conflict", "bound_to_other_origin", RECONNECT);
82
+ if (options.workspace !== null && options.workspace !== bound.workspace_id) {
83
+ return end("conflict", "bound_to_other_workspace", RECONNECT);
84
+ }
85
+ }
86
+ const store = openStore(configDir, { create: true });
87
+
88
+ // A rerun with a working grant changes nothing and opens no browser.
89
+ if (bound !== null && !options.reconnect) {
90
+ const session = await sessionFor(bound, store, trap.signal);
91
+ if (session.ok) {
92
+ Object.assign(ctx, {
93
+ profile: bound.deployment_profile,
94
+ workspaceId: bound.workspace_id,
95
+ authenticated: true,
96
+ configured: true,
97
+ status: "reused",
98
+ });
99
+ return end("ok", null, CONNECTED);
100
+ }
101
+ if (session.outcome !== "login_required") return end(session.outcome, session.reason);
102
+ }
103
+
104
+ const flow = await authorize(options, ctx, trap.signal, progress);
105
+ if (!flow.ok) return end(flow.outcome, flow.reason, flow.next ?? null);
106
+ const { endpoints, clientId, grant } = flow;
107
+
108
+ // The browser chose the workspace. Bind only one that matches what
109
+ // this project is already bound to, unless the user asked to switch.
110
+ if (bound !== null && !options.reconnect && grant.tenant !== bound.workspace_id) {
111
+ await revokeQuietly(endpoints, clientId, grant.refreshToken);
112
+ return end("conflict", "bound_to_other_workspace", RECONNECT);
113
+ }
114
+
115
+ const slot = newSlot();
116
+ try {
117
+ writeCredential(store, slot, {
118
+ schema_version: 1,
119
+ origin: ctx.origin,
120
+ issuer: endpoints.issuer,
121
+ resource: endpoints.resource,
122
+ deployment_profile: ctx.profile,
123
+ workspace_id: grant.tenant,
124
+ client_id: clientId,
125
+ scope: METADATA_SCOPE,
126
+ access_token: grant.accessToken,
127
+ refresh_token: grant.refreshToken,
128
+ expires_at: grant.expiresAt,
129
+ refresh_pending: false,
130
+ });
131
+ } catch (error) {
132
+ await revokeQuietly(endpoints, clientId, grant.refreshToken);
133
+ throw error;
134
+ }
135
+ try {
136
+ writeBinding(root, bindingFor({ origin: ctx.origin, workspaceId: grant.tenant, profile: ctx.profile, slot }), existing);
137
+ } catch (error) {
138
+ let cleaned = true;
139
+ try {
140
+ deleteCredential(store, slot);
141
+ } catch {
142
+ cleaned = false;
143
+ }
144
+ await revokeQuietly(endpoints, clientId, grant.refreshToken);
145
+ if (!cleaned) return end("filesystem_error", "binding_partial_write");
146
+ throw error;
147
+ }
148
+ Object.assign(ctx, { authenticated: true, configured: true, status: bound === null ? "signed_in" : "reconnected" });
149
+
150
+ // The old grant is no longer referenced. Ask the service to revoke it
151
+ // and remove it locally; neither affects the new binding.
152
+ if (bound !== null && bound.credential_slot !== slot) {
153
+ ctx.previousGrant = await retireSlot(store, bound);
154
+ }
155
+ return end("ok", null, CONNECTED);
156
+ } catch (error) {
157
+ if (error instanceof Stop) return end(error.outcome, error.reason);
158
+ throw error;
159
+ } finally {
160
+ trap.release();
161
+ }
162
+ }
163
+
164
+ // Preflight, discovery, registration, the browser round trip, the code
165
+ // exchange and server-side verification. Nothing is written here. A validated
166
+ // grant that is not accepted is sent for revocation before returning; that is
167
+ // best effort, and a token response that fails validation cannot be revoked.
168
+ async function authorize(options, ctx, cancel, progress) {
169
+ const preflight = await runDoctor({ origin: ctx.origin, timeoutMs: AUTH_HTTP_TIMEOUT_MS });
170
+ if (cancel.aborted) return fail("authorization_failed", "cancelled");
171
+ if (preflight.outcome !== "authentication_required") return fail(preflight.outcome, preflight.reason);
172
+ ctx.profile = preflight.report.deployment_profile;
173
+ if (options.expectedProfile && ctx.profile !== options.expectedProfile) {
174
+ return fail("unsupported", "deployment_profile_mismatch");
175
+ }
176
+ if (options.signup && ctx.profile !== "managed") return fail("unsupported", "signup_unsupported");
177
+
178
+ let limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
179
+ const discovered = await discover(ctx.origin, limit.signal);
180
+ if (!discovered.ok) return httpFailure(discovered, cancel);
181
+ const { endpoints, requireIss } = discovered;
182
+
183
+ const listener = await startCallbackListener();
184
+ let callback;
185
+ let clientId;
186
+ let verifier;
187
+ try {
188
+ limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
189
+ const registered = await register(endpoints, listener.redirectUri, limit.signal);
190
+ if (!registered.ok) return httpFailure(registered, cancel);
191
+ clientId = registered.clientId;
192
+
193
+ const pkce = newPkce();
194
+ verifier = pkce.verifier;
195
+ // Arm the exact state, Host and path check and start the timeout before
196
+ // the URL exists anywhere outside this process. Every return below
197
+ // reaches the finally, whose close() settles this wait and clears its
198
+ // timer.
199
+ const pending = listener.wait({
200
+ state: pkce.state,
201
+ issuer: endpoints.issuer,
202
+ requireIss,
203
+ timeoutMs: options.timeoutMs,
204
+ cancel,
205
+ });
206
+ const url = authorizationUrl(endpoints, {
207
+ clientId,
208
+ redirectUri: listener.redirectUri,
209
+ state: pkce.state,
210
+ challenge: pkce.challenge,
211
+ signup: options.signup,
212
+ });
213
+ const seconds = Math.round(options.timeoutMs / 1000);
214
+ // A wait armed after Ctrl+C has already settled; do not show the URL.
215
+ if (cancel.aborted) return fail("authorization_failed", "cancelled");
216
+ if (options.noBrowser) {
217
+ progress(
218
+ "Open this URL in a browser on this machine to sign in:\n" +
219
+ `${url}\n` +
220
+ `Waiting up to ${seconds} seconds for the browser. Press Ctrl+C to cancel.`,
221
+ );
222
+ } else {
223
+ if (!(await openBrowser(url))) {
224
+ return fail("authorization_failed", "browser_unavailable", {
225
+ kind: "no_browser",
226
+ message: "Run login again with --no-browser, without --json, and open the URL it prints.",
227
+ });
228
+ }
229
+ progress(`Opened your browser to sign in. Waiting up to ${seconds} seconds. Press Ctrl+C to cancel.`);
230
+ }
231
+ callback = await pending;
232
+ } finally {
233
+ await listener.close();
234
+ }
235
+
236
+ if (callback.kind === "denied") return fail("authorization_failed", "access_denied");
237
+ if (callback.kind === "timeout") return fail("authorization_failed", "timeout");
238
+ if (callback.kind === "cancelled") return fail("authorization_failed", "cancelled");
239
+ if (callback.kind !== "code") return fail("authorization_failed", callback.reason);
240
+
241
+ limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
242
+ const exchanged = await exchangeCode(
243
+ endpoints,
244
+ { clientId, redirectUri: listener.redirectUri, code: callback.code, verifier },
245
+ limit.signal,
246
+ );
247
+ if (!exchanged.ok) return httpFailure(exchanged, cancel);
248
+ const { grant } = exchanged;
249
+
250
+ const rejectGrant = async (outcome, reason) => {
251
+ await revokeQuietly(endpoints, clientId, grant.refreshToken);
252
+ return fail(outcome, reason);
253
+ };
254
+ if (options.workspace !== null && grant.tenant !== options.workspace) {
255
+ return rejectGrant("verification_failed", "workspace_mismatch");
256
+ }
257
+ limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
258
+ const verified = await verifyContext(
259
+ ctx.origin,
260
+ grant.accessToken,
261
+ { profile: ctx.profile, workspaceId: grant.tenant },
262
+ limit.signal,
263
+ );
264
+ if (!verified.ok) {
265
+ if (cancel.aborted) return rejectGrant("authorization_failed", "cancelled");
266
+ return rejectGrant(verified.outcome, verified.reason);
267
+ }
268
+ ctx.workspaceId = grant.tenant;
269
+ return { ok: true, endpoints, clientId, grant };
270
+ }
271
+
272
+ export async function runLogout(options) {
273
+ const ctx = { origin: null, workspaceId: null, credentials: "none", binding: "none", revocation: "not_attempted" };
274
+ const end = (outcome, reason) => ({ outcome, reason, data: logoutData(ctx) });
275
+ const trap = trapSignals();
276
+ try {
277
+ const root = resolveProject(options.project);
278
+ const store = openStore(resolveConfigDir(options.configDir), { create: false });
279
+ const existing = readBinding(root);
280
+ if (existing === null) return end("ok", null);
281
+ const bound = existing.binding;
282
+ ctx.origin = bound.origin;
283
+ ctx.workspaceId = bound.workspace_id;
284
+ ctx.binding = "kept";
285
+
286
+ if (store !== null) {
287
+ const result = await withSlotLock(
288
+ store,
289
+ bound.credential_slot,
290
+ async () => {
291
+ const record = readOrNull(store, bound.credential_slot);
292
+ // A grant is only ever sent back to the origin it came from.
293
+ if (record !== null && record.origin === bound.origin) {
294
+ const limit = deadline(AUTH_HTTP_TIMEOUT_MS, trap.signal);
295
+ const discovered = await discover(bound.origin, limit.signal);
296
+ ctx.revocation = discovered.ok
297
+ ? await revoke(discovered.endpoints, { clientId: record.client_id, refreshToken: record.refresh_token }, limit.signal)
298
+ : "unconfirmed";
299
+ }
300
+ return deleteCredential(store, bound.credential_slot);
301
+ },
302
+ { waitMs: LOCK_WAIT_MS },
303
+ );
304
+ if (result) ctx.credentials = "removed";
305
+ }
306
+ try {
307
+ removeBinding(root, existing);
308
+ } catch (error) {
309
+ // The grant is already gone; say so instead of the generic write error.
310
+ if (error instanceof Stop && error.outcome === "filesystem_error") {
311
+ return end("filesystem_error", "binding_remove_failed");
312
+ }
313
+ throw error;
314
+ }
315
+ ctx.binding = "removed";
316
+ return ctx.revocation === "unconfirmed" ? end("revocation_unconfirmed", "revocation_unconfirmed") : end("ok", null);
317
+ } catch (error) {
318
+ if (error instanceof Stop) return end(error.outcome, error.reason);
319
+ throw error;
320
+ } finally {
321
+ trap.release();
322
+ }
323
+ }
324
+
325
+ // Revokes and removes the grant a previous binding pointed to. Returns
326
+ // "accepted", "unconfirmed" or "not_attempted". Failures here never undo the
327
+ // new binding.
328
+ async function retireSlot(store, bound) {
329
+ try {
330
+ return await withSlotLock(
331
+ store,
332
+ bound.credential_slot,
333
+ async () => {
334
+ const record = readOrNull(store, bound.credential_slot);
335
+ let revocation = "not_attempted";
336
+ if (record !== null && record.origin === bound.origin) {
337
+ revocation = await revokeQuietly(endpointsFor(record.origin), record.client_id, record.refresh_token);
338
+ }
339
+ deleteCredential(store, bound.credential_slot);
340
+ return revocation;
341
+ },
342
+ { waitMs: LOCK_WAIT_MS },
343
+ );
344
+ } catch {
345
+ return "unconfirmed";
346
+ }
347
+ }
348
+
349
+ function readOrNull(store, slot) {
350
+ try {
351
+ return readCredential(store, slot);
352
+ } catch (error) {
353
+ if (error instanceof Stop && error.outcome === "login_required") return null;
354
+ throw error;
355
+ }
356
+ }
357
+
358
+ // Revocation with its own deadline, not tied to cancellation, so a grant the
359
+ // CLI decided not to keep is still revoked after Ctrl+C.
360
+ async function revokeQuietly(endpoints, clientId, refreshToken) {
361
+ const limit = deadline(AUTH_HTTP_TIMEOUT_MS);
362
+ return revoke(endpoints, { clientId, refreshToken }, limit.signal);
363
+ }
364
+
365
+ function httpFailure(result, cancel) {
366
+ if (cancel.aborted) return fail("authorization_failed", "cancelled");
367
+ return fail(result.outcome, result.reason);
368
+ }
369
+
370
+ function fail(outcome, reason, next = null) {
371
+ return { ok: false, outcome, reason, next };
372
+ }
373
+
374
+ const GUIDE = { kind: "connection_guide", url: CONNECTION_GUIDE_URL };
375
+ const RECONNECT = {
376
+ kind: "reconnect",
377
+ message: "Run login again with --reconnect to switch this project, or run logout first.",
378
+ };
379
+ const CONNECTED = {
380
+ kind: "connected",
381
+ message: 'This project is signed in with Metadata access. Run "metergraph logout" to sign out.',
382
+ };
383
+
384
+ function loginData(ctx, next) {
385
+ return {
386
+ origin: ctx.origin,
387
+ runtime: ctx.runtime,
388
+ deployment_profile: ctx.profile,
389
+ workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId },
390
+ scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
391
+ authenticated: ctx.authenticated,
392
+ configured: ctx.configured,
393
+ status: ctx.status,
394
+ binding: ctx.configured ? BINDING_PATH : null,
395
+ credential_protection: ctx.configured ? PROTECTION : null,
396
+ previous_grant_revocation: ctx.previousGrant,
397
+ next_action: next,
398
+ };
399
+ }
400
+
401
+ function logoutData(ctx) {
402
+ return {
403
+ origin: ctx.origin,
404
+ workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId },
405
+ local_credentials: ctx.credentials,
406
+ binding: ctx.binding,
407
+ revocation: ctx.revocation,
408
+ authenticated: false,
409
+ };
410
+ }