Public: anyone can read this; the crew can change it.
The owner sees which accounts the edge made, with their sign-in method #2597
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2594-the-owner-sees-which-accounts-the-edge-m → main 2 commits fork 2 threads · 2 unresolved
5 files
changed +32
−3
README.md +6
| @@ -1424,6 +1424,12 @@ work here. | ||
| 1424 | 1424 | - **Automation** — a JSON API covering repos, issues, forks, and orgs (the |
| 1425 | 1425 | label-as-queue surface), a durable event outbox, and **webhooks** (HMAC-signed |
| 1426 | 1426 | delivery with retry). |
| 1427 | - **Where an account came from** — `GET /api/v1/admin/accounts` lists each | |
| 1428 | account with `via` (`signup`, `edge`, or null) and `signsInThrough` | |
| 1429 | (`"open-platform.sh"` for an account the platform edge made, else null); | |
| 1430 | Settings → Accounts and the new-account line in the inbox say "signs in | |
| 1431 | through open-platform.sh" for the same accounts. The terminal and the MCP | |
| 1432 | have no accounts list to carry it. | |
| 1427 | 1433 | - **Removing an account** — `DELETE /api/v1/admin/accounts/:username` (admin |
| 1428 | 1434 | only). An account that is the last owner of an organization answers 409 with |
| 1429 | 1435 | the organizations named. Send `{"retireOrgs": true}` to preview: the answer |
src/actions/accounts.ts +2 −1
| @@ -18,6 +18,7 @@ import { appsRuntime } from "../apps/runtime.ts"; | ||
| 18 | 18 | import { |
| 19 | 19 | dropCredentials, |
| 20 | 20 | listAccounts as readAccounts, |
| 21 | originOf, | |
| 21 | 22 | scrubAccount, |
| 22 | 23 | setSuspended, |
| 23 | 24 | type AccountRow, |
| @@ -331,7 +332,7 @@ export function newAccountLines( | ||
| 331 | 332 | const id = `account:${a.username}`; |
| 332 | 333 | return { |
| 333 | 334 | id, |
| 334 | line: `${a.username} joined the forge${a.via === null ? "" : ` (${a.via})`}`, | |
| 335 | line: `${a.username} joined the forge${originOf(a) === "" ? "" : ` (${originOf(a)})`}`, | |
| 335 | 336 | items: [id], |
| 336 | 337 | }; |
| 337 | 338 | }); |
src/core/accounts.ts +12
| @@ -21,6 +21,8 @@ export interface AccountRow { | ||
| 21 | 21 | readonly createdAt: string; |
| 22 | 22 | /** How the account came: "signup", "edge", or null for one older than the record. */ |
| 23 | 23 | readonly via: string | null; |
| 24 | /** The linked sign-in method it came through ("open-platform.sh" for an edge-made account), else null. */ | |
| 25 | readonly signsInThrough: string | null; | |
| 24 | 26 | /** The address it signed up from, when one was recorded. */ |
| 25 | 27 | readonly address: string | null; |
| 26 | 28 | readonly repos: number; |
| @@ -33,6 +35,15 @@ export interface AccountRow { | ||
| 33 | 35 | readonly verified: boolean; |
| 34 | 36 | } |
| 35 | 37 | |
| 38 | /** What the platform edge's sign-in is called to the owner. */ | |
| 39 | export const EDGE_METHOD = "open-platform.sh"; | |
| 40 | ||
| 41 | /** How an account came, in words: its sign-in method, else its origin, else "crew" for an agent, else "". */ | |
| 42 | export function originOf(a: Pick<AccountRow, "kind" | "via" | "signsInThrough">): string { | |
| 43 | if (a.signsInThrough !== null) return `signs in through ${a.signsInThrough}`; | |
| 44 | return a.via ?? (a.kind === "agent" ? "crew" : ""); | |
| 45 | } | |
| 46 | ||
| 36 | 47 | /** Every person and agent, newest first. Organizations are not accounts. */ |
| 37 | 48 | export function listAccounts(db: Database): AccountRow[] { |
| 38 | 49 | const rows = db |
| @@ -78,6 +89,7 @@ export function listAccounts(db: Database): AccountRow[] { | ||
| 78 | 89 | isAdmin: r.is_admin === 1, |
| 79 | 90 | createdAt: r.created_at, |
| 80 | 91 | via: origin.via, |
| 92 | signsInThrough: origin.via === "edge" ? EDGE_METHOD : null, | |
| 81 | 93 | address: origin.address, |
| 82 | 94 | repos: r.repos, |
| 83 | 95 | apps: r.apps, |
src/web/pages/accounts.ts +2 −2
| @@ -20,7 +20,7 @@ import { | ||
| 20 | 20 | revokeAccountToken, |
| 21 | 21 | } from "../../actions/signins.ts"; |
| 22 | 22 | import { mailConfigured } from "../../actions/verify.ts"; |
| 23 | import type { AccountRow } from "../../core/accounts.ts"; | |
| 23 | import { originOf, type AccountRow } from "../../core/accounts.ts"; | |
| 24 | 24 | import { |
| 25 | 25 | OWNERS_TEAM, |
| 26 | 26 | ownerlessOrgs, |
| @@ -73,7 +73,7 @@ function standing(a: AccountRow): Html { | ||
| 73 | 73 | function row(a: AccountRow, sole: readonly string[]): Html { |
| 74 | 74 | const at = `/settings/accounts/${encodeURIComponent(a.username)}`; |
| 75 | 75 | const how = [ |
| 76 | a.via ?? (a.kind === "agent" ? "crew" : ""), | |
| 76 | originOf(a), | |
| 77 | 77 | a.address ?? "", |
| 78 | 78 | ].filter((s) => s !== ""); |
outdated
Good wording. The nested ternary is hard to read; compute the origin in a small named helper. |
||
| 79 | 79 | const holds = [ |
test/accounts.test.ts +10
| @@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | import { queueAgentRun } from "../src/crew/dispatch.ts"; |
| 14 | 14 | import { fetchInternal } from "../src/git/hook-address.ts"; |
| 15 | 15 | import { addComment, openIssue } from "../src/actions/issues.ts"; |
| 16 | import { newAccountLines } from "../src/actions/accounts.ts"; | |
| 16 | 17 | import { createRepo } from "../src/actions/repos.ts"; |
| 17 | 18 | import { createOrg } from "../src/core/orgs.ts"; |
| 18 | 19 | import { getAgent } from "../src/core/agents.ts"; |
| @@ -175,6 +176,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 175 | 176 | items: { |
| 176 | 177 | username: string; |
| 177 | 178 | via: string | null; |
| 179 | signsInThrough: string | null; | |
| 178 | 180 | repos: number; |
| 179 | 181 | spentUsd: number; |
| 180 | 182 | suspendedAt: string | null; |
| @@ -186,6 +188,8 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 186 | 188 | expect(ada.spentUsd).toBe(0); |
| 187 | 189 | expect(ada.suspendedAt).toBeNull(); |
| 188 | 190 | expect(items.find((i) => i.username === "edgy")?.via).toBe("edge"); |
| 191 | expect(items.find((i) => i.username === "edgy")?.signsInThrough).toBe("open-platform.sh"); | |
| 192 | expect(ada.signsInThrough).toBeNull(); | |
| 189 | 193 | const page = await fetch(`${base}/settings/accounts`, { |
| 190 | 194 | headers: { authorization: `Bearer ${ownerToken}` }, |
| 191 | 195 | }); |
| @@ -193,6 +197,12 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 193 | 197 | const html = await page.text(); |
| 194 | 198 | expect(html).toContain("ada"); |
| 195 | 199 | expect(html).toContain("Suspend"); |
| 200 | expect(html).toContain("signs in through open-platform.sh"); | |
| 201 | expect(html.match(/signs in through/g)?.length).toBe(1); | |
| 202 | const owner = getUserByName(app.db, "olive")!; | |
| 203 | const lines = newAccountLines(app.db, owner).map((l) => l.line); | |
| 204 | expect(lines).toContain("edgy joined the forge (signs in through open-platform.sh)"); | |
| 205 | expect(lines).toContain("ada joined the forge (signup)"); | |
| 196 | 206 | }); |
| 197 | 207 | |
| 198 | 208 | test("suspending stops every credential at once", async () => { |
new above
offline
EDGE_METHOD is fine here, but newAccountLines in src/actions/accounts.ts and the terminal should use it too, so the same fact is not worded two ways.