metergraph-cli 0.1.0 → 0.2.0-preview.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 +500 -33
- package/assets/skill/SKILL.md +17 -1
- package/assets/skill/manifest.json +3 -3
- package/package.json +2 -2
- package/src/args.js +323 -17
- package/src/auth-binding.js +202 -0
- package/src/auth-browser.js +74 -0
- package/src/auth-callback.js +177 -0
- package/src/auth-login.js +410 -0
- package/src/auth-oauth.js +462 -0
- package/src/auth-session.js +174 -0
- package/src/auth-store.js +397 -0
- package/src/cli.js +78 -2
- package/src/constants.js +99 -4
- package/src/deployment-credential.js +207 -0
- package/src/deployment-route.js +449 -0
- package/src/doctor.js +10 -0
- package/src/http.js +1 -1
- package/src/output.js +454 -2
- package/src/read-contract.js +609 -0
- package/src/read-output.js +216 -0
- package/src/read.js +325 -0
- package/src/setup-deployment.js +178 -0
- package/src/setup-env-acl.js +99 -0
- package/src/setup-env-git.js +103 -0
- package/src/setup-env-parse.js +169 -0
- package/src/setup-env.js +685 -0
- package/src/setup-state.js +141 -0
- package/src/setup.js +326 -0
- package/src/skill-bundle.js +1 -1
- package/src/trace-contract.js +129 -0
- package/src/trace-open.js +36 -0
- package/src/transport.js +171 -0
- package/src/verify-output.js +36 -0
- package/src/verify.js +120 -0
|
@@ -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
|
+
}
|