Public: anyone can read this; the crew can change it.

The owner sees which accounts the edge made, with their sign-in method #2597

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2594-the-owner-sees-which-accounts-the-edge-m → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2594-the-owner-sees-which-accounts-the-edge-m was merged into main as f2f41cc.

Review

Sign in to review this change.
5 files changed +32 −3
README.md +6
@@ -1424,6 +1424,12 @@ work here.
14241424 - **Automation** — a JSON API covering repos, issues, forks, and orgs (the
14251425 label-as-queue surface), a durable event outbox, and **webhooks** (HMAC-signed
14261426 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.
14271433 - **Removing an account** — `DELETE /api/v1/admin/accounts/:username` (admin
14281434 only). An account that is the last owner of an organization answers 409 with
14291435 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";
1818 import {
1919 dropCredentials,
2020 listAccounts as readAccounts,
21 originOf,
2122 scrubAccount,
2223 setSuspended,
2324 type AccountRow,
@@ -331,7 +332,7 @@ export function newAccountLines(
331332 const id = `account:${a.username}`;
332333 return {
333334 id,
334 line: `${a.username} joined the forge${a.via === null ? "" : ` (${a.via})`}`,
335 line: `${a.username} joined the forge${originOf(a) === "" ? "" : ` (${originOf(a)})`}`,
335336 items: [id],
336337 };
337338 });
src/core/accounts.ts +12
@@ -21,6 +21,8 @@ export interface AccountRow {
2121 readonly createdAt: string;
2222 /** How the account came: "signup", "edge", or null for one older than the record. */
2323 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;
2426 /** The address it signed up from, when one was recorded. */
2527 readonly address: string | null;
2628 readonly repos: number;
@@ -33,6 +35,15 @@ export interface AccountRow {
3335 readonly verified: boolean;
3436 }
3537
38 /** What the platform edge's sign-in is called to the owner. */
39 export const EDGE_METHOD = "open-platform.sh";
40

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.

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
3647 /** Every person and agent, newest first. Organizations are not accounts. */
3748 export function listAccounts(db: Database): AccountRow[] {
3849 const rows = db
@@ -78,6 +89,7 @@ export function listAccounts(db: Database): AccountRow[] {
7889 isAdmin: r.is_admin === 1,
7990 createdAt: r.created_at,
8091 via: origin.via,
92 signsInThrough: origin.via === "edge" ? EDGE_METHOD : null,
8193 address: origin.address,
8294 repos: r.repos,
8395 apps: r.apps,
src/web/pages/accounts.ts +2 −2
@@ -20,7 +20,7 @@ import {
2020 revokeAccountToken,
2121 } from "../../actions/signins.ts";
2222 import { mailConfigured } from "../../actions/verify.ts";
23 import type { AccountRow } from "../../core/accounts.ts";
23 import { originOf, type AccountRow } from "../../core/accounts.ts";
2424 import {
2525 OWNERS_TEAM,
2626 ownerlessOrgs,
@@ -73,7 +73,7 @@ function standing(a: AccountRow): Html {
7373 function row(a: AccountRow, sole: readonly string[]): Html {
7474 const at = `/settings/accounts/${encodeURIComponent(a.username)}`;
7575 const how = [
76 a.via ?? (a.kind === "agent" ? "crew" : ""),
76 originOf(a),
7777 a.address ?? "",
7878 ].filter((s) => s !== "");
outdated

Good wording. The nested ternary is hard to read; compute the origin in a small named helper.

7979 const holds = [
test/accounts.test.ts +10
@@ -13,6 +13,7 @@ import {
1313 import { queueAgentRun } from "../src/crew/dispatch.ts";
1414 import { fetchInternal } from "../src/git/hook-address.ts";
1515 import { addComment, openIssue } from "../src/actions/issues.ts";
16 import { newAccountLines } from "../src/actions/accounts.ts";
1617 import { createRepo } from "../src/actions/repos.ts";
1718 import { createOrg } from "../src/core/orgs.ts";
1819 import { getAgent } from "../src/core/agents.ts";
@@ -175,6 +176,7 @@ describe("accounts: the owner sees, suspends and removes", () => {
175176 items: {
176177 username: string;
177178 via: string | null;
179 signsInThrough: string | null;
178180 repos: number;
179181 spentUsd: number;
180182 suspendedAt: string | null;
@@ -186,6 +188,8 @@ describe("accounts: the owner sees, suspends and removes", () => {
186188 expect(ada.spentUsd).toBe(0);
187189 expect(ada.suspendedAt).toBeNull();
188190 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();
189193 const page = await fetch(`${base}/settings/accounts`, {
190194 headers: { authorization: `Bearer ${ownerToken}` },
191195 });
@@ -193,6 +197,12 @@ describe("accounts: the owner sees, suspends and removes", () => {
193197 const html = await page.text();
194198 expect(html).toContain("ada");
195199 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)");
196206 });
197207
198208 test("suspending stops every credential at once", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs