Public: anyone can read this; the crew can change it.
The terminal lists where you are signed in and ends one session with a confirm, as MCP `sessions` does #7246
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7237-the-terminal-lists-where-you-are-signed → main 2 commits fork 3 threads · 3 unresolved
6 files
changed +316
−0
README.md +2
| @@ -405,4 +405,6 @@ This page is the front door. Everything else is a file of its own under | ||
| 405 | 405 | - [](docs/DESIGN.md) — the design system's spine; its sections are under `docs/design/sections/`. |
| 406 | 406 | - [](docs/SPIKE-CLOUDFLARE.md), [](docs/AUDIT-MANY-HANDS.md), [](docs/BACKLOG.md) and [](docs/research/) — a spike, an audit, a backlog and research notes, kept as written. |
| 407 | 407 | |
| 408 | The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do (`docs/CREW.md`, "Where you are signed in"). | |
| 409 | ||
| 408 | 410 | `/changelog` lists the last 30 releases of `FORGE_REPO` (public only) with their pulls, for anyone; the terminal's palette has `Changelog` for the same list (`docs/features/changelog.md`). |
docs/CREW.md +2
| @@ -303,6 +303,8 @@ one the provider rests hands its runs to the other until the reset. | ||
| 303 | 303 | |
| 304 | 304 | **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); nothing switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` (`{ items, next_cursor }`, as `/admin/accounts` and `/admin/invites` answer, with `limit` 1-200, 50 by default, and `cursor`) and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token (with `{"kind": "passkeys"}` it lists your own passkeys instead, `{ items }` of id, name, created, lastUsed, people only; the page is Settings → Passkeys); in the terminal it is the **What I can do** command (read only, agents included). |
| 305 | 305 | |
| 306 | **Where you are signed in.** Anyone signed in sees their own browsers and tokens, never a secret, at `/settings`, over the API (`GET /api/v1/users/:username/signins`), over MCP (`sessions`), and in the terminal as the **Signed in** command (`src/tui/screens/signins.ts`; a guest has no such command). The terminal lists this terminal's SSH key first, marked "this one", then each browser (`web browser` and the tail of its session id, so two read apart) and each token (name and scope), with when it was last seen and since when. `d` on a browser asks to confirm (Keep is the default) and signs it out through the same `endAccountSession` as Settings and MCP, so its cookie reads as signed out at once. `d` does not end this terminal (disconnect, or remove its key in Settings → SSH keys) or a token (revoked in Settings → Personal access tokens); it says so. No browser row is marked as the current one there: the terminal holds no browser cookie. | |
| 307 | ||
| 306 | 308 | **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting, revoking and using a link are events (`invite.minted`, `invite.revoked`, `invite.used`; the last names the invite and the account it let in, never the token). The same is the **Invite links** card on `/admin/limits` (a form with expiry days, 1 to 30; the new link is shown once, with a copy button; an open link has a Revoke button) and the terminal's **Invite links** command (`m` makes one and prints the link once, `x` revokes the selected open one). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link, and the `invites` tool (`{action: "list"|"revoke", id?}`, the same person admin and admin token, for both actions) lists them without links or revokes one. While signup is `invite`, the link opens the door once: `/signup?invite=…` (the form and the passkey door) or `invite` in the `POST /api/v1/users` body. In the terminal, with signup = invite the create-account screen has a fourth, masked **Invite** field that takes the token (not the whole link); a blank one is asked for, and a bad one is refused with the same sentence as `/signup`. The account and the invite's use are one write, so two sign-ups with one link make one account; a used, revoked or expired link, or any link while signup is `closed`, makes none. While signup is `open`, a link is not spent. |
| 307 | 309 | |
| 308 | 310 | **Spend ceilings.** Apart from the grant, a forge admin who is a person may set any account's — a person's, an agent's or an organization's — daily (UTC) and per-run ceilings in dollars: on the account's page (`/settings/accounts/<name>`, an organization's too, by that address; an agent and an admin's own account have no such page), the terminal's **Model ceiling** command, `POST /api/v1/admin/accounts/:name/ceiling` (`{"dailyUsd": 20, "perRunUsd": 5}`) or the MCP `model_ceiling` tool; each change is in the audit. Both empty on the page, `none` in the terminal (its prompt shows the current ceilings, and an empty answer changes nothing), or `null` for both on the API and MCP takes them back. An organization's page is linked from its own page for an admin. An account has no ceiling until one is set, so this forge's own crew is never stopped by a default nobody chose. A run on an account with a ceiling does not start when the day's is spent or the per-run one is too small, is capped at the smaller of the per-run ceiling and what is left of the day, and stops mid-run when the day's is spent. |
docs/TOUR.md +3
| @@ -153,6 +153,9 @@ to `/recover`: give the email on your account and, if it is verified, a link | ||
| 153 | 153 | that lasts 30 minutes and works once registers a new passkey and signs you in. |
| 154 | 154 | The relying party is the address the browser used (or |
| 155 | 155 | `FORGE_URL`), so a passkey does not follow a forge to another hostname. |
| 156 | Where you are signed in, and signing a browser out, is on `/settings`, MCP | |
| 157 | `sessions` and the terminal's **Signed in** command (`d` asks, then signs the | |
| 158 | selected browser out); see `docs/CREW.md`, "Where you are signed in". | |
| 156 | 159 | Sign-up is a username and a password of at least 12 characters (`/signup`, `POST /api/v1/users` with `{username, password, email?}`, the terminal), hashed with argon2id; the email is optional and, when given, gets the verify link (an account with none has nothing to prove). A passkey (`src/web/pages/signup-passkey.ts`, which does take an email) is offered beside the password. Nothing in the path touches an edge, parent or Forgejo account. An operator can still refuse password sign-ups with `passkeyOnlySignup`. The owner claim of a spawned forge (`/claim`, `web/pages/claim.ts`) takes the same: a username and a password with the email optional, or a passkey (which needs an email; challenges from `POST /claim/options`), and a claim with neither a long enough password nor a passkey is refused without spending the link. An admin's everyday account (`web/pages/everyday.ts`) still makes its account with a passkey only (`web/passkey-ceremony.ts`, challenges from `POST /settings/everyday/options`): it reads no password, and without JavaScript the page says a passkey needs it. |
| 157 | 160 | |
| 158 | 161 | Password checks are bounded (`src/core/hash-gate.ts`): at most 4 argon2 verifications run at once, and a password over 1024 bytes is never hashed and cannot be set (sign-up, claim and change-password refuse it with "Use 1024 bytes or fewer."). A check over the cap is refused at once, not queued: the caller sees the same `throttled` answer as a full sign-in window (429 with `Retry-After: 1` on HTTP Basic, the API and the sign-in page, "try again" in the terminal; an over-long Basic password is a plain 401), and the attempt is given back to the sign-in windows. The 4-hash cap is a deliberate trade-off: an attacker who keeps 4 hashes busy can make real sign-ins get "try again in 1s"; the per-source sign-in window is what limits how long they can. |
src/tui/forge.ts +8
| @@ -59,6 +59,7 @@ import { peopleScreen } from "./screens/people.ts"; | ||
| 59 | 59 | import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/model.ts"; |
| 60 | 60 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; |
| 61 | 61 | import { invitesScreen } from "./screens/invites.ts"; |
| 62 | import { signInsScreen } from "./screens/signins.ts"; | |
| 62 | 63 | import { quotasScreen } from "./screens/quotas.ts"; |
| 63 | 64 | import { teamsScreen } from "./screens/teams.ts"; |
| 64 | 65 | import { domainsScreen } from "./screens/domains.ts"; |
| @@ -248,6 +249,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 248 | 249 | title: "Account settings", |
| 249 | 250 | run: () => openOnWeb(session, ctx, "/settings", "Account settings"), |
| 250 | 251 | }, |
| 252 | // Settings → Sessions and MCP `sessions`, in the terminal (#7237). | |
| 253 | { | |
| 254 | id: "signins", | |
| 255 | title: "Signed in", | |
| 256 | hint: "your browsers and tokens; d signs a browser out", | |
| 257 | run: () => ctx.push(signInsScreen(session, ctx)), | |
| 258 | }, | |
| 251 | 259 | ); |
| 252 | 260 | // The forge's own faults, as the board's strip shows them: a site admin's. |
| 253 | 261 | if (session.user?.isAdmin === true) |
src/tui/screens/signins.ts added +131
| @@ -0,0 +1,131 @@ | ||
| 1 | /** | |
| 2 | * Where you are signed in, in the terminal (#7237): this terminal's key, | |
| 3 | * your browsers and your tokens, each with its kind, where and when it was | |
| 4 | * last seen, as Settings → Sessions and MCP `sessions` list them — never a | |
| 5 | * secret. `d` signs the selected browser out after a confirm, through | |
| 6 | * `endAccountSession`, so the gate is the action's: your own account only. | |
| 7 | * This terminal is held by its key, not a browser session, and cannot be | |
| 8 | * ended from here; a token is revoked from Settings → Personal access tokens. | |
| 9 | */ | |
| 10 | ||
| 11 | import { accountSignIns, endAccountSession } from "../../actions/signins.ts"; | |
| 12 | import { listKeys } from "../../core/keys.ts"; | |
| 13 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 14 | import { binding } from "../app/keys.ts"; | |
| 15 | import { style } from "../term/style.ts"; | |
| 16 | import { truncate } from "../term/width.ts"; | |
| 17 | import type { Session } from "../session.ts"; | |
| 18 | import { fill, len, split } from "../ui/layout.ts"; | |
| 19 | import { ListState, ago, confirmScreen, drawList, type ListRow } from "./common.ts"; | |
| 20 | ||
| 21 | interface SignIn { | |
| 22 | readonly kind: "terminal" | "browser" | "token"; | |
| 23 | readonly id: string; | |
| 24 | readonly where: string; | |
| 25 | readonly seen: string | null; | |
| 26 | readonly since: string; | |
| 27 | readonly here: boolean; | |
| 28 | } | |
| 29 | ||
| 30 | /** The tail of an id: two browsers signed in the same day still read apart. */ | |
| 31 | const shortId = (id: string): string => id.slice(-6).toLowerCase(); | |
| 32 | ||
| 33 | /** The rows, this terminal first; empty for a guest. */ | |
| 34 | export function signInsOf(f: Session): SignIn[] { | |
| 35 | const user = f.user; | |
| 36 | if (user === undefined) return []; | |
| 37 | // Without `current`: the terminal holds no browser cookie, so no browser | |
| 38 | // row is ever "this one" here; this terminal's key is. | |
| 39 | const seen = accountSignIns(f.app, user, user.username); | |
| 40 | if (!seen.ok) return []; | |
| 41 | const key = f.keyId === undefined ? undefined : listKeys(f.db, user.id).find((k) => k.id === f.keyId); | |
| 42 | return [ | |
| 43 | ...(key === undefined | |
| 44 | ? [] | |
| 45 | : [{ kind: "terminal" as const, id: key.id, where: key.name, seen: key.lastUsedAt, since: key.createdAt, here: true }]), | |
| 46 | ...seen.value.sessions.map((s) => ({ | |
| 47 | kind: "browser" as const, | |
| 48 | id: s.id, | |
| 49 | where: `web browser ${shortId(s.id)}`, | |
| 50 | seen: s.lastSeenAt, | |
| 51 | since: s.createdAt, | |
| 52 | here: s.current, | |
| 53 | })), | |
| 54 | ...seen.value.tokens.map((t) => ({ | |
| 55 | kind: "token" as const, | |
| 56 | id: t.id, | |
| 57 | where: `${t.name} · ${t.scope}`, | |
| 58 | seen: t.lastUsedAt, | |
| 59 | since: t.createdAt, | |
| 60 | here: false, | |
| 61 | })), | |
| 62 | ]; | |
| 63 | } | |
| 64 | ||
| 65 | export function signInsScreen(f: Session, ctx: Ctx): Screen { | |
| 66 | const state = new ListState(); | |
| 67 | let note = ""; | |
| 68 | ||
| 69 | const rows = (all: readonly SignIn[]): ListRow[] => | |
| 70 | all.map((s) => ({ | |
| 71 | title: s.here ? `${s.where} · this one` : s.where, | |
| 72 | secondary: s.kind, | |
| 73 | meta: `last seen ${s.seen === null ? "never" : ago(s.seen)} · since ${s.since.slice(0, 10)}`, | |
| 74 | })); | |
| 75 | ||
| 76 | const end = (picked: SignIn): void => { | |
| 77 | if (picked.kind === "terminal") { | |
| 78 | note = "This terminal cannot be ended from here: disconnect, or remove its key in Settings → SSH keys."; | |
outdated
Browser rows are indistinguishable: both read 'web browser'. Add the since date or a short id to the title so the person can tell which row |
||
| 79 | ctx.invalidate(); | |
| 80 | return; | |
| 81 | } | |
| 82 | if (picked.kind === "token") { | |
| 83 | note = "A token is revoked from Settings → Personal access tokens."; | |
| 84 | ctx.invalidate(); | |
| 85 | return; | |
| 86 | } | |
| 87 | ctx.push( | |
| 88 | confirmScreen(ctx, screen, { | |
| 89 | title: "Sign this browser out", | |
| 90 | body: [`${picked.where}: last seen ${picked.seen === null ? "never" : ago(picked.seen)}, signed in ${picked.since.slice(0, 10)}.`], | |
| 91 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Sign out", variant: "danger" }], | |
| 92 | onChoose: (i) => { | |
| 93 | if (i < 1) return; | |
| 94 | const user = f.user; | |
| 95 | const done = user !== undefined && endAccountSession(f.app, user, user.username, picked.id).ok; | |
| 96 | note = done ? "Signed that browser out." : "That browser is already signed out."; | |
| 97 | ctx.invalidate(); | |
| 98 | }, | |
| 99 | }), | |
| 100 | ); | |
| 101 | }; | |
| 102 | ||
| 103 | const screen: Screen = { | |
| 104 | title: "Signed in", | |
| 105 | keys: [binding(["j", "k"], "move cursor"), binding(["d"], "sign a browser out")], | |
| 106 | render(area, s) { | |
| 107 | const c = ctx.theme.c; | |
| 108 | const all = signInsOf(f); | |
| 109 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 110 | const text = | |
| 111 | note !== "" | |
| 112 | ? note | |
| 113 | : f.user === undefined | |
| 114 | ? "Sign in to see where you are signed in." | |
| 115 | : "Where you are signed in. d signs the selected browser out."; | |
| 116 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 117 | drawList(s, body!, c, state, rows(all)); | |
| 118 | }, | |
| 119 | key(e) { | |
outdated
Redundant invalidate: end() already invalidates on its own paths. Keep one. |
||
| 120 | note = ""; | |
| 121 | const all = signInsOf(f); | |
| 122 | if (state.key(e, all.length)) return true; | |
| 123 | if (e.ctrl || e.alt || e.key !== "d") return false; | |
| 124 | const picked = all[state.index]; | |
| 125 | if (picked === undefined) return false; | |
| 126 | end(picked); | |
| 127 | return true; | |
| 128 | }, | |
| 129 | }; | |
| 130 | return screen; | |
| 131 | } |
test/tui-signins.test.ts added +170
| @@ -0,0 +1,170 @@ | ||
| 1 | /** | |
| 2 | * Where you are signed in, in the terminal (#7237): the palette's "Signed in" | |
| 3 | * lists your browsers and tokens and this terminal's key, never a secret; `d` | |
| 4 | * signs a browser out after a confirm, after which its cookie reads as signed | |
| 5 | * out; this terminal cannot be ended from here; another person's sign-ins are | |
| 6 | * never listed. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 13 | import { addKey, parseAuthorizedKey } from "../src/core/keys.ts"; | |
| 14 | import { createSession, sessionOf } from "../src/core/sessions.ts"; | |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 16 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 17 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 18 | import { signInsOf, signInsScreen } from "../src/tui/screens/signins.ts"; | |
| 19 | import { Session } from "../src/tui/session.ts"; | |
| 20 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | import { settleApp } from "./helpers/tui.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | const KEY = | |
| 27 | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo terminal-test"; | |
| 28 | ||
| 29 | describe("where you are signed in, in the terminal", () => { | |
| 30 | let dataDir: string; | |
| 31 | let forge: Forge; | |
| 32 | let keyId: string; | |
| 33 | let older: string; | |
| 34 | let newer: string; | |
| 35 | let bobs: string; | |
| 36 | let token: string; | |
| 37 | ||
| 38 | function boot(username: string | undefined) { | |
| 39 | const terminal = headlessTerminal(140, 32); | |
| 40 | const user = username === undefined ? undefined : getUserByName(forge.db, username)!; | |
| 41 | const { app } = createForgeApp( | |
| 42 | forge, | |
| 43 | { sshUsername: "t", remoteAddress: "127.0.0.1", user, ...(username === "alice" ? { keyId } : {}) }, | |
| 44 | { terminal, exitHooks: false }, | |
| 45 | ); | |
| 46 | app.start(); | |
| 47 | const key = (k: string, ctrl = false) => | |
| 48 | app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 49 | return { | |
| 50 | text: () => app.screen.toText(), | |
| 51 | press: async (...keys: string[]) => { | |
| 52 | keys.forEach((k) => key(k)); | |
| 53 | await settleApp(app); | |
| 54 | }, | |
| 55 | open: async () => { | |
| 56 | key("k", true); | |
| 57 | await settleApp(app); | |
| 58 | for (const ch of "Signed in") key(ch); | |
| 59 | await settleApp(app); | |
| 60 | key("enter"); | |
| 61 | await settleApp(app); | |
| 62 | }, | |
| 63 | stop: () => app.stop(), | |
| 64 | }; | |
| 65 | } | |
| 66 | ||
| 67 | beforeAll(async () => { | |
| 68 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-signins-")); | |
| 69 | forge = createApp({ dataDir, port: 0 }); | |
| 70 | for (const username of ["alice", "bob"]) { | |
| 71 | const made = await createUser(forge.db, { | |
| 72 | origin: "seed", | |
| 73 | trusted: true, | |
| 74 | username, | |
| 75 | email: `${username}@forge.local`, | |
| 76 | password: "a-good-passphrase", | |
| 77 | adminIfFirst: false, | |
| 78 | }); | |
| 79 | if (!made.ok) throw new Error(made.error); | |
| 80 | } | |
| 81 | const alice = getUserByName(forge.db, "alice")!; | |
| 82 | const bob = getUserByName(forge.db, "bob")!; | |
| 83 | const parsed = parseAuthorizedKey(KEY); | |
| 84 | if (!parsed.ok) throw new Error(parsed.error); | |
| 85 | const added = addKey(forge.db, alice.id, "alice-laptop-key", parsed.value); | |
| 86 | if (!added.ok) throw new Error(added.error); | |
| 87 | keyId = added.value.id; | |
| 88 | older = createSession(forge.db, alice.id).token; | |
| 89 | newer = createSession(forge.db, alice.id).token; | |
| 90 | bobs = createSession(forge.db, bob.id).token; | |
| 91 | token = createToken(forge.db, alice.id, "alice-ci", "ro").token; | |
| 92 | createToken(forge.db, bob.id, "bob-secret-box", "ro"); | |
| 93 | }); | |
| 94 | afterAll(async () => { | |
| 95 | forge.close(); | |
| 96 | await rm(dataDir, { recursive: true, force: true }); | |
| 97 | }); | |
| 98 | ||
| 99 | test("lists two browsers, a token and this terminal, and nothing of bob's", async () => { | |
| 100 | const h = boot("alice"); | |
| 101 | await h.open(); | |
| 102 | const text = h.text(); | |
| 103 | expect(text).toContain("alice-laptop-key · this one"); | |
| 104 | expect(text.match(/web browser/g)?.length).toBe(2); | |
| 105 | expect(text).toContain("alice-ci · ro"); | |
| 106 | expect(text).not.toContain("bob-secret-box"); | |
| 107 | expect(text).not.toContain(token); | |
| 108 | expect(text).not.toContain(older); | |
| 109 | h.stop(); | |
| 110 | }); | |
| 111 | ||
| 112 | test("this terminal cannot be ended from here, and says so", async () => { | |
| 113 | const h = boot("alice"); | |
| 114 | await h.open(); | |
| 115 | await h.press("d"); | |
| 116 | expect(h.text()).toContain("This terminal cannot be ended from here"); | |
| 117 | expect(h.text()).toContain("alice-laptop-key"); | |
| 118 | h.stop(); | |
| 119 | }); | |
| 120 | ||
| 121 | test("d with a confirm signs one browser out; its cookie reads as signed out", async () => { | |
| 122 | const h = boot("alice"); | |
| 123 | await h.open(); | |
| 124 | const browsers = signInsOf(new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, "alice")! })) | |
| 125 | .filter((s) => s.kind === "browser") | |
| 126 | .map((s) => s.where); | |
| 127 | // Two browsers signed in the same minute still read apart. | |
| 128 | expect(new Set(browsers).size).toBe(2); | |
| 129 | await h.press("j", "d"); | |
| 130 | expect(h.text()).toContain("Sign this browser out"); | |
| 131 | expect(h.text()).toContain(`${browsers[0]}: last seen`); | |
| 132 | // Keep is the default: enter on it ends nothing. | |
| 133 | await h.press("enter"); | |
| 134 | expect(sessionOf(forge.db, older).ok && sessionOf(forge.db, newer).ok).toBe(true); | |
| 135 | await h.press("d", "right", "enter"); | |
| 136 | expect(h.text()).toContain("Signed that browser out."); | |
| 137 | expect(h.text().match(/web browser/g)?.length).toBe(1); | |
| 138 | const left = [older, newer].filter((t) => sessionOf(forge.db, t).ok); | |
| 139 | expect(left.length).toBe(1); | |
| 140 | expect(sessionOf(forge.db, bobs).ok).toBe(true); | |
| 141 | h.stop(); | |
| 142 | }); | |
| 143 | ||
| 144 | test("bob sees only his own", async () => { | |
| 145 | const h = boot("bob"); | |
| 146 | await h.open(); | |
| 147 | const text = h.text(); | |
| 148 | expect(text.match(/web browser/g)?.length).toBe(1); | |
| 149 | expect(text).toContain("bob-secret-box"); | |
| 150 | expect(text).not.toContain("alice-ci"); | |
| 151 | expect(text).not.toContain("alice-laptop-key"); | |
| 152 | h.stop(); | |
| 153 | }); | |
| 154 | ||
| 155 | test("a guest has no Signed in command, and d on its empty screen does nothing", async () => { | |
| 156 | const h = boot(undefined); | |
| 157 | await h.open(); | |
| 158 | expect(h.text()).not.toContain("Sign in to see where you are signed in"); | |
| 159 | expect(h.text()).not.toContain("web browser"); | |
| 160 | h.stop(); | |
| 161 | const guest = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1" }); | |
| 162 | expect(signInsOf(guest)).toEqual([]); | |
| 163 | const pushed: unknown[] = []; | |
| 164 | const ctx = { push: (s: unknown) => pushed.push(s), invalidate() {} } as never; | |
| 165 | const screen = signInsScreen(guest, ctx); | |
| 166 | const handled = screen.key?.({ type: "key", key: "d", ctrl: false, alt: false, shift: false } as never); | |
| 167 | expect(handled).toBe(false); | |
| 168 | expect(pushed).toEqual([]); | |
| 169 | }); | |
| 170 | }); |
new above
offline
The command is signed-in only, but no test pins that a guest does not get it. Add one. Document the command in docs/CREW.md and the README.