@cloudgrid-io/mcp 0.20.45 → 0.20.46

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.20.45",
3
+ "version": "0.20.46",
4
4
  "description": "MCP access to CloudGrid — the live runtime environment where the user's apps run WITH the infrastructure they need out of the box: managed MongoDB, Redis (cache/queues/pub-sub), a pgvector store, persistent disk, and built-in AI with no API keys, in any language or stack, no Docker or Kubernetes to manage. Brainstorm, build, plug, and improve apps, games, dashboards, and tools; instant single-page publishes and full runtime-app deploys with a live URL — for developers and non-technical users. Three editions: local stdio (full toolset), hosted web (light, CLI-free), and a public docs-search server.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,6 +43,7 @@
43
43
  "smoke:redrop": "node test/smoke-redrop.mjs",
44
44
  "smoke:oauth": "node test/smoke-oauth.mjs",
45
45
  "test:auth": "node test/auth.test.mjs",
46
+ "test:auth-strictness": "node test/auth-strictness.test.mjs",
46
47
  "test:win-cli": "node test/windows-cli-resolution.test.mjs",
47
48
  "test:electron-env": "node test/electron-spawn-env.test.mjs",
48
49
  "smoke:docs": "node test/smoke-docs.mjs",
package/src/auth.js CHANGED
@@ -94,14 +94,28 @@ export async function pollStatusOnce(code) {
94
94
  return res.json();
95
95
  }
96
96
 
97
- // Read the stored credentials, or null if absent/unreadable. Same file the CLI
98
- // writes, so an MCP can reuse a CLI login (and vice versa).
97
+ // Read the stored credentials, or null if absent/unreadable/expired. Same file
98
+ // the CLI writes, so an MCP can reuse a CLI login (and vice versa).
99
+ // Returns { jwt, email, ... } when valid, null otherwise.
99
100
  export async function readCredentials() {
101
+ const result = await readCredentialsStatus();
102
+ if (result.expired || !result.creds) return null;
103
+ return result.creds;
104
+ }
105
+
106
+ // Like readCredentials, but distinguishes "expired" from "absent" so callers
107
+ // (grid_start) can surface a targeted message.
108
+ export async function readCredentialsStatus() {
100
109
  try {
101
110
  const creds = JSON.parse(await readFile(credentialsPath(), "utf8"));
102
- return creds && creds.jwt ? creds : null;
111
+ if (!creds || !creds.jwt) return { creds: null, expired: false };
112
+ const claims = decodeJwt(creds.jwt);
113
+ if (claims.exp && claims.exp * 1000 <= Date.now()) {
114
+ return { creds: null, expired: true };
115
+ }
116
+ return { creds, expired: false };
103
117
  } catch {
104
- return null;
118
+ return { creds: null, expired: false };
105
119
  }
106
120
  }
107
121
 
package/src/index.js CHANGED
@@ -14,6 +14,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
14
14
  import { registerTools } from "./tools.js";
15
15
  import {
16
16
  readCredentials,
17
+ readCredentialsStatus,
17
18
  readActiveGridSlug,
18
19
  writeCredentials,
19
20
  credentialsPath,
@@ -31,6 +32,7 @@ const ctx = {
31
32
  state: { pendingLoginCode: null, lastAnonClaim: null, lastDrop: null, anonCookie: null },
32
33
  canOpenBrowser: true,
33
34
  getToken: async () => (await readCredentials())?.jwt ?? null,
35
+ getCredentialsStatus: async () => await readCredentialsStatus(),
34
36
  getActiveGrid: async () => await readActiveGridSlug(),
35
37
  saveToken: async (jwt) => await writeCredentials(jwt),
36
38
  savedLocationNote: () => `Credentials saved to ${credentialsPath()}.`,
package/src/tools/cli.js CHANGED
@@ -421,14 +421,15 @@ export async function runCloudgrid(args, opts = {}, deps = {}) {
421
421
  }
422
422
  }
423
423
 
424
- export function cliTool(buildArgs, { cwdParam = false } = {}) {
424
+ export function cliTool(buildArgs, { cwdParam = false, excludeDirFromCwd = false } = {}) {
425
425
  return async (input) => {
426
426
  try {
427
427
  const params = input || {};
428
428
  const opts = {};
429
429
  if (cwdParam) {
430
- // Accept cwd, directory, or dir as the working-directory override.
431
- opts.cwd = params.cwd ?? params.directory ?? params.dir;
430
+ opts.cwd = excludeDirFromCwd
431
+ ? (params.cwd ?? params.directory)
432
+ : (params.cwd ?? params.directory ?? params.dir);
432
433
  }
433
434
  return ok(await runCloudgrid(buildArgs(params), opts));
434
435
  } catch (err) {
@@ -95,7 +95,7 @@ export const CLI_TOOL_VERBS = {
95
95
  grid_info: ["info"],
96
96
  grid_get: ["get"],
97
97
  grid_describe_grid: ["describe"],
98
- grid_edit_existing_app: ["pickup"],
98
+ grid_edit_existing_app: ["pull"],
99
99
  grid_rename: ["rename"],
100
100
  grid_take_offline: ["unplug"],
101
101
  grid_delete: ["delete"],
@@ -94,7 +94,7 @@ export async function resolveGridOrAsk(ctx, { token, suppliedGrid, edition }, de
94
94
  lines.push("Pass the grid slug in the `grid` parameter.");
95
95
  const readyCount = annotated.filter((o) => o.render_ready).length;
96
96
  if (readyCount === 0) {
97
- lines.push("Note: none of your grids are fully set up yet. You can use anonymous: true as a fallback.");
97
+ lines.push("Note: none of your grids are fully set up yet. Wait until provisioning completes (grid_start will show render_ready: true) before plugging.");
98
98
  }
99
99
  return {
100
100
  picker: {
@@ -205,7 +205,7 @@ export function secretBlockMessage(label, where) {
205
205
 
206
206
  // ── Direct-API tools (both editions) ───────────────────────────────────────────
207
207
  function looksLikeFullHtml(s) {
208
- const head = s.replace(/^/, "").trimStart().slice(0, 256).toLowerCase();
208
+ const head = s.replace(/^\uFEFF/, "").trimStart().slice(0, 256).toLowerCase();
209
209
  return head.startsWith("<!doctype html") || head.startsWith("<html");
210
210
  }
211
211
 
@@ -774,10 +774,10 @@ function compileIgnorePattern(line) {
774
774
  if (pat.startsWith("/")) pat = pat.slice(1);
775
775
  const rx = pat
776
776
  .replace(/[.+^${}()|[\]\\]/g, "\\$&")
777
- .replace(/\*\*/g, "")
777
+ .replace(/\*\*/g, "\uFFFF")
778
778
  .replace(/\*/g, "[^/]*")
779
779
  .replace(/\?/g, "[^/]")
780
- .replace(//g, ".*");
780
+ .replace(/\uFFFF/g, ".*");
781
781
  const body = anchored ? `^${rx}` : `(^|/)${rx}`;
782
782
  const re = new RegExp(`${body}(/|$)`);
783
783
  return { re, dirOnly };
@@ -1093,7 +1093,7 @@ export function errorGuidance({ status, code, edition, isEdit, isAnon, signedIn
1093
1093
  if (status === 401) {
1094
1094
  return isEdit
1095
1095
  ? "That did not authorize this entity (wrong entity, expired, or already claimed). Sign in if you own it (grid_login), pass its owner_token for an anonymously-created drop, or omit target_entity_id to create a new entity."
1096
- : "Not signed in. Ask the user: sign in (grid_login) to publish to their grid, OR publish anonymously now (re-call grid_plug with anon: true) - it goes live immediately with a claim_url + owner_token to claim into their account later. Do not silently fail; offer both.";
1096
+ : "Your sign-in is missing or expired. Run grid_login, then retry the same grid_plug. Do not offer anonymous publishing as a fix for a failed sign-in; anonymous is only for a user who explicitly asks to publish without attribution.";
1097
1097
  }
1098
1098
  if (status === 403) {
1099
1099
  // NO_ACTIVE_ORG is not a role problem — the account has no grid at all.
@@ -1354,6 +1354,18 @@ export async function runPlug(ctx, input, deps = {}) {
1354
1354
  }
1355
1355
 
1356
1356
  // ── Auth wire selection ─────────────────────────────────────────────────────
1357
+ // Defence in depth: anon: true must only reach here after the explicit choice
1358
+ // prompt was surfaced (register.js sets authChoiceOffered to true). The flag is
1359
+ // undefined when runPlug is called directly (tests bypass the gate), so only
1360
+ // assert when ctx.state has authChoiceOffered as an own property.
1361
+ if (
1362
+ anon === true &&
1363
+ ctx.state &&
1364
+ "authChoiceOffered" in ctx.state &&
1365
+ ctx.state.authChoiceOffered !== true
1366
+ ) {
1367
+ throw new Error("anon: true reached runPlug without the auth choice being offered first. This is a bug.");
1368
+ }
1357
1369
  const authToken = anon === true ? null : await ctx.getToken();
1358
1370
  let ownerToken = typeof owner_token === "string" && owner_token.length > 0 ? owner_token : null;
1359
1371
  if (isEdit && !ownerToken) {
@@ -1747,8 +1759,6 @@ export async function runPlug(ctx, input, deps = {}) {
1747
1759
  return { text: lines.join("\n"), structured };
1748
1760
  }
1749
1761
 
1750
- // ── grid_copy_app / grid_download_source — direct-API verbs (spec v2 §5–6) ────────
1751
-
1752
1762
  async function authedApiCall(ctx, { method, pathName, body, verb }) {
1753
1763
  const token = await ctx.getToken();
1754
1764
  if (!token) {
@@ -3,7 +3,7 @@
3
3
  // Extracted verbatim from src/tools.js (refactor: split tools.js into modules).
4
4
 
5
5
  import { z } from "zod";
6
- import { newLoginCode, buildLoginUrl, pollStatusOnce, checkApiConnectivity } from "../auth.js";
6
+ import { newLoginCode, buildLoginUrl, pollStatusOnce, checkApiConnectivity, decodeJwt as decodeJwtAuth } from "../auth.js";
7
7
  import { PLAYBOOK, fetchCorpus, listWorkflows } from "../playbook.js";
8
8
  import {
9
9
  APPS_WIDGETS_ENABLED,
@@ -227,9 +227,9 @@ export function registerTools(server, ctx) {
227
227
  // acknowledgement.
228
228
  regTool(
229
229
  "grid_note",
230
- "Optionally leave a one-paragraph summary of what you built this session and why. Call it BEFORE a plug, or in a session that ends without one — a successful plug has already posted the QA log, so pass grid_plug's session_note instead. Recorded for CloudGrid QA. No side effects.",
230
+ "Optionally leave a one-paragraph summary of what you built this session and why. Call it BEFORE a plug, or in a session that ends without one — a successful plug has already posted the QA log, so pass grid_plug's session_note instead. The summary is recorded with the CloudGrid team for QA review.",
231
231
  { summary: z.string().describe("A short plain-language summary of what was built and why.") },
232
- { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
232
+ { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
233
233
  async (input) => {
234
234
  try { ctx.logger?.setNarrative(input?.summary); } catch { /* never */ }
235
235
  // Honesty after flush: a successful deploy posts the QA log on that same
@@ -327,10 +327,6 @@ export function registerTools(server, ctx) {
327
327
  ),
328
328
  };
329
329
 
330
- // grid_plug is the create/re-plug verb. `grid_deploy` is kept as a deprecated
331
- // back-compat alias (registered right after the primary below); the canonical
332
- // name is grid_plug, and "deploy" survives only as an intent keyword in this
333
- // tool's description. MCP-tool name only — the CLI verb `grid plug` is unchanged.
334
330
  const plugConfig = {
335
331
  description:
336
332
  "Plug an app, website, game, or single HTML page into CloudGrid — the live runtime that runs it and provides its infrastructure — and get a live public URL. " +
@@ -454,17 +450,30 @@ export function registerTools(server, ctx) {
454
450
  }
455
451
  if (!isEdit) {
456
452
  const token = await ctx.getToken();
457
- if (!token && (input?.anon !== true || ctx.state?.authChoiceOffered !== true)) {
458
- // AUTH HARD GATE (create only): not signed in → do NOT silently
459
- // ride the anonymous wire — EVEN when the model passed anon: true.
460
- // Field bug (2026-07-26, Claude web): INSTRUCTIONS_WEB said "no
461
- // account needed", the model self-served anon: true on the first
462
- // call, and the user's page landed on the Guest Grid without ever
463
- // being asked. anon: true is model-attestable, so it cannot be the
464
- // gate; the session flag is. First unauthenticated create in a
465
- // session ALWAYS returns the sign-in-vs-guest choice; a re-call
466
- // with anon: true after the choice was surfaced proceeds. (Rule 17,
467
- // enforced server-side.)
453
+ if (input?.anon === true && ctx.state?.authChoiceOffered !== true) {
454
+ if (ctx.state) ctx.state.authChoiceOffered = true;
455
+ if (token) {
456
+ const claims = decodeJwtAuth(token);
457
+ const who = claims.email ? ` as ${claims.email}` : "";
458
+ return okResult({
459
+ text:
460
+ `You are signed in${who}. The user has not explicitly chosen anonymous publishing. Ask the user which they want — do not choose for them:\n` +
461
+ ` 1. Publish to your grid (re-call grid_plug without anon: true).\n` +
462
+ ` 2. Publish anonymously — a guest link that expires in 7 days unless claimed (re-call grid_plug with anon: true).\n` +
463
+ `Relay this question to the user and STOP until they answer.`,
464
+ structured: { needs_auth: true },
465
+ });
466
+ }
467
+ return okResult({
468
+ text:
469
+ "This is a new publish and the user is not signed in. Ask the user which they want — do not choose for them:\n" +
470
+ " 1. Sign in and plug it to their grid — run grid_login (opens the browser), then re-call grid_plug.\n" +
471
+ " 2. Plug it as a guest — live immediately at a guest link that expires in 7 days unless claimed (re-call grid_plug with anon: true).\n" +
472
+ "Relay this question to the user and STOP until they answer.",
473
+ structured: { needs_auth: true },
474
+ });
475
+ }
476
+ if (!token && input?.anon !== true) {
468
477
  if (ctx.state) ctx.state.authChoiceOffered = true;
469
478
  return okResult({
470
479
  text:
@@ -752,7 +761,7 @@ export function registerTools(server, ctx) {
752
761
  });
753
762
  const readyCount = annotated.filter((o) => o.render_ready).length;
754
763
  if (readyCount === 0 && annotated.length > 0) {
755
- lines.push("\nNone of your grids are fully set up yet. You can use an anonymous drop as a fallback, or wait until provisioning completes.");
764
+ lines.push("\nNone of your grids are fully set up yet. Wait until provisioning completes (grid_start will show render_ready: true) before plugging. Do not switch to anonymous unless the user asks for it.");
756
765
  }
757
766
  // Structured output stays `orgs` (its declared schema); user text says grid.
758
767
  return okResult({ text: lines.join("\n"), structured: { orgs: annotated } });
@@ -786,6 +795,9 @@ export function registerTools(server, ctx) {
786
795
  .object({
787
796
  active_grid: z.string().nullable().describe("The user's active grid/org slug, or null."),
788
797
  signed_in: z.boolean().describe("Whether the current session is signed in."),
798
+ email: z.string().optional().describe("The signed-in user's email, when available."),
799
+ session_expired: z.boolean().optional().describe("True when credentials exist but the JWT has expired. Run grid_login to sign in again."),
800
+ identity_changed: z.boolean().optional().describe("True when the session's identity changed via a different transport Bearer. Session state was reset."),
789
801
  update_available: z
790
802
  .object({
791
803
  current: z.string().describe("This MCP's version."),
@@ -802,9 +814,26 @@ export function registerTools(server, ctx) {
802
814
  async () => {
803
815
  const workflows = listWorkflows();
804
816
  let signedIn = false;
817
+ let sessionExpired = false;
818
+ let signedInEmail = null;
805
819
  let activeGrid = null;
806
820
  try {
807
- signedIn = Boolean(await ctx.getToken());
821
+ if (ctx.getCredentialsStatus) {
822
+ const status = await ctx.getCredentialsStatus();
823
+ sessionExpired = status.expired;
824
+ if (status.creds?.jwt) {
825
+ signedIn = true;
826
+ const claims = decodeJwtAuth(status.creds.jwt);
827
+ signedInEmail = claims.email ?? null;
828
+ }
829
+ } else {
830
+ const token = await ctx.getToken();
831
+ signedIn = Boolean(token);
832
+ if (token) {
833
+ const claims = decodeJwtAuth(token);
834
+ signedInEmail = claims.email ?? null;
835
+ }
836
+ }
808
837
  } catch {
809
838
  signedIn = false;
810
839
  }
@@ -813,28 +842,33 @@ export function registerTools(server, ctx) {
813
842
  } catch {
814
843
  activeGrid = null;
815
844
  }
816
- // Staleness (local edition): populated fire-and-forget at boot. The
817
- // .mcpb never auto-updates, so this is where silent rot becomes a
818
- // one-line user action — the model relays the note in-session.
845
+ const identityChanged = ctx.state?.identityChanged === true;
846
+ if (identityChanged) ctx.state.identityChanged = false;
819
847
  const stale = stalenessNote(ctx.staleness);
820
- const structured = {
821
- playbook: PLAYBOOK,
822
- workflows,
823
- context: {
824
- active_grid: activeGrid,
825
- signed_in: signedIn,
826
- ...(ctx.staleness?.behind
827
- ? { update_available: { current: ctx.staleness.current, latest: ctx.staleness.latest } }
828
- : {}),
829
- },
848
+ const contextObj = {
849
+ active_grid: activeGrid,
850
+ signed_in: signedIn,
851
+ ...(signedInEmail ? { email: signedInEmail } : {}),
852
+ ...(sessionExpired ? { session_expired: true } : {}),
853
+ ...(identityChanged ? { identity_changed: true } : {}),
854
+ ...(ctx.staleness?.behind
855
+ ? { update_available: { current: ctx.staleness.current, latest: ctx.staleness.latest } }
856
+ : {}),
830
857
  };
858
+ const structured = { playbook: PLAYBOOK, workflows, context: contextObj };
831
859
  const wfLines = workflows.length
832
860
  ? workflows.map((w) => ` - ${w.name}: ${w.when || w.summary}`).join("\n")
833
861
  : " (none available)";
834
- const text =
862
+ let text =
835
863
  `${PLAYBOOK}\n\nAvailable workflows:\n${wfLines}\n\n` +
836
864
  `Next: match the intent to a workflow and call grid_get_template({kind:"workflow", name}).` +
837
865
  (stale ? `\n\n${stale}` : "");
866
+ if (sessionExpired) {
867
+ text += "\n\nYour CloudGrid sign-in has expired. Run grid_login to sign in again.";
868
+ }
869
+ if (identityChanged) {
870
+ text += "\n\nYour session identity changed (a different account connected via transport). Session state has been reset.";
871
+ }
838
872
  return okResult({ text, structured });
839
873
  },
840
874
  );
@@ -965,7 +999,7 @@ export function registerTools(server, ctx) {
965
999
  cwd: z.string().optional().describe("Working directory. The CLI runs in this directory. Defaults to the MCP server's working directory."),
966
1000
  },
967
1001
  { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
968
- cliTool((input) => buildCreateProjectArgs(input), { cwdParam: true }),
1002
+ cliTool((input) => buildCreateProjectArgs(input), { cwdParam: true, excludeDirFromCwd: true }),
969
1003
  );
970
1004
 
971
1005
  // NOTE: grid_plug is no longer CLI-wrapping — the unified direct-API verb
package/src/web.js CHANGED
@@ -55,6 +55,12 @@ function bearerOf(req) {
55
55
  return h && /^Bearer\s+\S+/i.test(h) ? h.replace(/^Bearer\s+/i, "") : null;
56
56
  }
57
57
 
58
+ function isTokenExpired(jwt) {
59
+ if (!jwt) return false;
60
+ const claims = decodeJwt(jwt);
61
+ return Boolean(claims.exp && claims.exp * 1000 <= Date.now());
62
+ }
63
+
58
64
  // A web session: identity lives in memory for the session's lifetime only. The
59
65
  // session id doubles as the stable, opaque end-user id for the trusted-server cap.
60
66
  function makeWebContext(sessionId) {
@@ -63,10 +69,17 @@ function makeWebContext(sessionId) {
63
69
  edition: "web",
64
70
  state: { pendingLoginCode: null, lastAnonClaim: null, lastDrop: null, anonCookie: null },
65
71
  canOpenBrowser: false,
66
- // Transport OAuth wins; the in-tool login flow is the fallback.
67
- getToken: async () => sessionAuth[sessionId] ?? sessionToken,
68
- // No local config on a shared host. The user passes `grid`, or the API returns
69
- // the list of grids to choose from.
72
+ getToken: async () => {
73
+ const jwt = sessionAuth[sessionId] ?? sessionToken;
74
+ if (jwt && isTokenExpired(jwt)) return null;
75
+ return jwt;
76
+ },
77
+ getCredentialsStatus: async () => {
78
+ const jwt = sessionAuth[sessionId] ?? sessionToken;
79
+ if (!jwt) return { creds: null, expired: false };
80
+ if (isTokenExpired(jwt)) return { creds: null, expired: true };
81
+ return { creds: { jwt }, expired: false };
82
+ },
70
83
  getActiveGrid: async () => null,
71
84
  saveToken: async (jwt) => {
72
85
  sessionToken = jwt;
@@ -89,6 +102,7 @@ mountOAuth(app, PUBLIC_BASE);
89
102
 
90
103
  // One transport per MCP session, keyed by the session id.
91
104
  const transports = Object.create(null);
105
+ const sessionContexts = Object.create(null);
92
106
 
93
107
  app.post("/mcp", async (req, res) => {
94
108
  const sessionId = req.headers["mcp-session-id"];
@@ -106,7 +120,22 @@ app.post("/mcp", async (req, res) => {
106
120
  }
107
121
 
108
122
  if (transport) {
109
- if (jwt) sessionAuth[sessionId] = jwt;
123
+ if (jwt) {
124
+ const prev = sessionAuth[sessionId];
125
+ if (prev) {
126
+ const prevSub = decodeJwt(prev).sub;
127
+ const newSub = decodeJwt(jwt).sub;
128
+ if (prevSub && newSub && prevSub !== newSub) {
129
+ const ctxForSession = sessionContexts[sessionId];
130
+ if (ctxForSession) {
131
+ ctxForSession.state.identityChanged = true;
132
+ ctxForSession.state.pendingLoginCode = null;
133
+ ctxForSession.state.authChoiceOffered = false;
134
+ }
135
+ }
136
+ }
137
+ sessionAuth[sessionId] = jwt;
138
+ }
110
139
  await transport.handleRequest(req, res, req.body);
111
140
  return;
112
141
  }
@@ -138,10 +167,12 @@ app.post("/mcp", async (req, res) => {
138
167
  if (transport.sessionId) {
139
168
  delete transports[transport.sessionId];
140
169
  delete sessionAuth[transport.sessionId];
170
+ delete sessionContexts[transport.sessionId];
141
171
  }
142
172
  };
143
173
  const server = new McpServer({ name: "cloudgrid-mcp-web", version }, { instructions: INSTRUCTIONS_WEB });
144
174
  const webCtx = makeWebContext(newSessionId);
175
+ sessionContexts[newSessionId] = webCtx;
145
176
  // QA session log for this MCP session (dark by default). Keyed by the session
146
177
  // id — the host connection boundary. No seed user_request here: current hosts
147
178
  // don't forward the first message. The model-as-courier path fills it instead —
@@ -178,7 +209,22 @@ async function handleSessionRequest(req, res) {
178
209
  return;
179
210
  }
180
211
  const jwt = bearerOf(req);
181
- if (jwt) sessionAuth[sessionId] = jwt;
212
+ if (jwt) {
213
+ const prev = sessionAuth[sessionId];
214
+ if (prev) {
215
+ const prevSub = decodeJwt(prev).sub;
216
+ const newSub = decodeJwt(jwt).sub;
217
+ if (prevSub && newSub && prevSub !== newSub) {
218
+ const ctxForSession = sessionContexts[sessionId];
219
+ if (ctxForSession) {
220
+ ctxForSession.state.identityChanged = true;
221
+ ctxForSession.state.pendingLoginCode = null;
222
+ ctxForSession.state.authChoiceOffered = false;
223
+ }
224
+ }
225
+ }
226
+ sessionAuth[sessionId] = jwt;
227
+ }
182
228
  await transport.handleRequest(req, res);
183
229
  }
184
230