From d245092007161607d3370b0f724e25fe5a952f0e Mon Sep 17 00:00:00 2001 From: Jeany Date: Thu, 15 Oct 2026 22:14:16 -0600 Subject: [PATCH] 1.37.1: ask the server again after a sign-in and a sign-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Account window's Replay casts line (and anything else /v1/info says per caller) was learned once, from the first answer. When that answer came before signing in, it was a guest's, and an admin kept seeing "Off · Not offered to this account for now." until the page reloaded. A sign-in now re-reads /v1/info before refreshing the account, and a sign-out asks again as a guest. Co-Authored-By: Claude Opus 5.5 --- account.ts | 9 ++++++--- dist/plugin.js | 8 +++++--- package-lock.json | 4 ++-- package.json | 2 +- plugin.json | 2 +- tests/plugin.test.ts | 36 ++++++++++++++++++++++++++++++++++-- 6 files changed, 49 insertions(+), 12 deletions(-) diff --git a/account.ts b/account.ts index 1a5588d..dd7f343 100644 --- a/account.ts +++ b/account.ts @@ -225,8 +225,8 @@ export class AccountManager { /** * Ask the server what it offers and what it thinks of the session. Run when the - * dialog opens, before a sign-in, and at activation only when a session is stored; - * a session the server no longer knows is dropped. + * dialog opens, before and after a sign-in, after a sign-out, and at activation only + * when a session is stored; a session the server no longer knows is dropped. */ async connect(): Promise { const info = await this.client.info(); @@ -338,7 +338,8 @@ export class AccountManager { this.view = m.account; this.changed(); finish(() => resolve(m.account!)); - void this.refresh().catch(() => {}); + // (What the server offers depends on who asks: the answer from before the sign-in was a guest's.) + void this.connect().then(() => this.refresh()).catch(() => {}); }); const watch = setInterval(() => { if (popup.closed) finish(() => reject(new ScmjsError("aborted", "the sign-in window was closed."))); }, 500); const limit = setTimeout(() => { finish(() => { try { popup.close(); } catch { /* gone */ } reject(new ScmjsError("network", t("the sign-in did not finish in time."))); }); }, this.deps.signInTimeoutMs ?? 5 * 60_000); @@ -351,6 +352,8 @@ export class AccountManager { this.view = null; this.ledgerRows = []; this.changed(); + // (Ask again as a guest, so nothing the account was offered stays on show.) + await this.connect().catch(() => {}); } /** The payment page for a pack, in a new tab. */ diff --git a/dist/plugin.js b/dist/plugin.js index 2b30660..8e96419 100644 --- a/dist/plugin.js +++ b/dist/plugin.js @@ -1674,8 +1674,8 @@ var AccountManager = class { /* ── The server ───────────────────────────────────────── */ /** * Ask the server what it offers and what it thinks of the session. Run when the - * dialog opens, before a sign-in, and at activation only when a session is stored; - * a session the server no longer knows is dropped. + * dialog opens, before and after a sign-in, after a sign-out, and at activation only + * when a session is stored; a session the server no longer knows is dropped. */ async connect() { const info = await this.client.info(); @@ -1800,7 +1800,7 @@ var AccountManager = class { this.view = m.account; this.changed(); finish(() => resolve(m.account)); - void this.refresh().catch(() => { + void this.connect().then(() => this.refresh()).catch(() => { }); }); const watch = setInterval(() => { @@ -1828,6 +1828,8 @@ var AccountManager = class { this.view = null; this.ledgerRows = []; this.changed(); + await this.connect().catch(() => { + }); } /** The payment page for a pack, in a new tab. */ async topUp(pack) { diff --git a/package-lock.json b/package-lock.json index 4889da6..5307a2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@scm-js/plugin-scmjs-dev", - "version": "1.37.0", + "version": "1.37.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@scm-js/plugin-scmjs-dev", - "version": "1.37.0", + "version": "1.37.1", "license": "MIT", "devDependencies": { "@scm-js/plugin-api": "^1.35.0", diff --git a/package.json b/package.json index 1713353..6db1a6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@scm-js/plugin-scmjs-dev", - "version": "1.37.0", + "version": "1.37.1", "private": true, "description": "scmjs.dev — a plugin for the scmJS StarCraft map editor: your account, map storage with revisions, and the AI features (scenarios, maps, triggers, an assistant) on one free trial and one sign-in.", "author": "Jeany", diff --git a/plugin.json b/plugin.json index 096fcdd..a260a53 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "name": "scmjs.dev", "id": "scmjs-dev", - "version": "1.37.0", + "version": "1.37.1", "description": "Your scmjs.dev account in the editor. Keep maps on your account with revisions and notes, and share the open map so others can edit it with you at the same time. Where the server offers them, AI tools can build, lay out and review a map with you.", "author": "Jeany", "homepage": "https://github.com/scm-js/plugin-scmjs-dev", diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 1070c7f..043b1da 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { AccountManager, memoryStore } from "../account"; import { describeError, fileNameFrom, formatBytes, ScmjsClient, ScmjsError } from "../client"; import { describeMeta, matchesSearch, metaOf } from "../maps"; @@ -169,6 +169,38 @@ describe("the account manager", () => { expect(account.casts).toBeNull(); }); + it("asks again what is offered after a sign-in and a sign-out, so a guest's answer does not stick", async () => { + const accounts = { providers: [{ id: "discord", name: "Discord" }], trial: false, trialUsd: 0, signupUsd: 0, weeklyUsd: 0, packs: [], accountUrl: "https://api.example/account", maps: true }; + const admin = view({ role: "admin", unlimited: true, balanceUsd: 0, weeklyUsd: 0, creditUsd: 0 }); + let session: string | null = null; + const signedIn = (init: RequestInit) => session !== null && ((init.headers ?? {}) as Record).Authorization === `Bearer ${session}`; + const { fetchImpl } = fakeFetch({ + // The server casts for a signed-in admin only. + "GET /v1/info": (init) => json({ name: "Test", version: "1", accounts, speech: true, recipes: [{ name: "cast", enabled: signedIn(init), model: "m" }], caller: signedIn(init) ? { kind: "user", remaining: {}, account: admin } : { kind: "anonymous", remaining: {} } }), + "POST /v1/auth/start": () => json({ url: "https://discord.com/oauth2/authorize?state=abc" }), + "POST /v1/auth/logout": () => { session = null; return json({ ok: true }); }, + "GET /v1/account": () => json({ account: admin, ledger: [] }), + }); + const store = memoryStore(); + let handler: ((e: { origin: string; data: unknown }) => void) | null = null; + const popup = { closed: false, location: { href: "" }, close() { this.closed = true; } }; + const account = new AccountManager(store, new ScmjsClient(() => ({ serverUrl: "https://api.example", session: store.get().session }), fetchImpl), { + openPopup: () => popup, origin: () => "https://editor.example", listen: (h) => { handler = h; return () => { handler = null; }; }, + }); + // The Account window was opened before signing in: a guest's answer. + await account.connect(); + expect(account.casts).toEqual({ on: false, voiced: true }); + const signing = account.signIn(); + await new Promise((r) => setTimeout(r, 0)); + session = "sess_admin"; + handler!({ origin: "https://api.example", data: { type: "scmjs-ai-auth", session: "sess_admin", account: admin } }); + await signing; + await vi.waitFor(() => expect(account.casts).toEqual({ on: true, voiced: true })); + expect(account.kind()).toBe("account"); + await account.signOut(); + expect(account.casts).toEqual({ on: false, voiced: true }); + }); + it("offers the AI only where the server says the role may call it, and keeps the answer for the next start", async () => { let signedIn = false; const accounts = { providers: [{ id: "discord", name: "Discord" }], trial: false, trialUsd: 0, signupUsd: 0, weeklyUsd: 0, packs: [], accountUrl: "https://api.example/account", maps: true, ai: false }; @@ -176,7 +208,7 @@ describe("the account manager", () => { const { fetchImpl } = fakeFetch({ "GET /v1/info": () => json({ name: "Test", version: "1", accounts, caller: signedIn ? { kind: "user", remaining: {}, account: admin } : { kind: "anonymous", remaining: {} } }), "GET /v1/account": () => json({ account: admin, ledger: [] }), - "POST /v1/auth/logout": () => json({ ok: true }), + "POST /v1/auth/logout": () => { signedIn = false; return json({ ok: true }); }, }); const store = memoryStore({ aiOffered: true }); const account = new AccountManager(store, new ScmjsClient(() => ({ serverUrl: "https://api.example", session: store.get().session }), fetchImpl));