diff --git a/Cargo.lock b/Cargo.lock index add93e989..9606b02ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1121,6 +1121,7 @@ dependencies = [ "reqwest", "rsa", "rustix", + "secp256k1", "serde", "serde_json", "sha2 0.10.9", diff --git a/finitecomputer-v2/apps/dashboard/browser/brain-scoped-roster.browser.ts b/finitecomputer-v2/apps/dashboard/browser/brain-scoped-roster.browser.ts index 7d5215bf5..78c714274 100644 --- a/finitecomputer-v2/apps/dashboard/browser/brain-scoped-roster.browser.ts +++ b/finitecomputer-v2/apps/dashboard/browser/brain-scoped-roster.browser.ts @@ -68,9 +68,9 @@ test("admin roster renders dated scoped labels in existing cells, reloads, and c await roster.getByText("Invitation key · Evidence 2026-10-02", { exact: true }).waitFor(); await roster.getByText("Agent (retired)", { exact: true }).waitFor(); assert.equal(await roster.getByRole("row").count(), 6); - assert.equal(await roster.getByRole("columnheader").allTextContents().then(v => v.join(",")), "Identity,Owner,Role,Folders,Key"); + assert.equal(await roster.getByRole("columnheader").allTextContents().then(v => v.join(",")), "Identity,Your alias,Owner,Role,Folders,Key"); assert.equal(await roster.getByText("Unknown", { exact: true }).count(), 1); - const ownerCells = await roster.locator("tbody tr td:nth-child(2)").allTextContents(); + const ownerCells = await roster.locator("tbody tr td:nth-child(3)").allTextContents(); assert.deepEqual(ownerCells, ["owner@example.test", "", "", "", ""]); } const artifacts = process.env.FC_BROWSER_ARTIFACT_DIR; diff --git a/finitecomputer-v2/apps/dashboard/scripts/brain-key-alias-browser-proof.ts b/finitecomputer-v2/apps/dashboard/scripts/brain-key-alias-browser-proof.ts new file mode 100644 index 000000000..e634d523d --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/scripts/brain-key-alias-browser-proof.ts @@ -0,0 +1,218 @@ +/** Invoked by Core's key_alias_browser_composition test under devfinity. + * Real: dashboard server/API/UI, Core auth/description/aliases, Postgres. + * Fixtures: WorkOS provider and unrelated Agent/Brain roster transport. + * Tokens arrive through stdin, never argv, output or committed artifacts. + */ +import assert from "node:assert/strict"; +import { spawn, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { mkdir } from "node:fs/promises"; +import http from "node:http"; +import path from "node:path"; +import { chromium, type Browser } from "playwright"; +import { chromiumLaunchOptions } from "./playwright-browser"; + +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + const config = JSON.parse(input) as { + coreUrl: string; identityUrl: string; runtimeId: string; + owner: string; other: string; keys: string[]; npubs: string[]; + observationToken: string; descriptionToken: string; + }; + for (const origin of [config.coreUrl, config.identityUrl]) { + const url = new URL(origin); + assert.equal(url.hostname, "127.0.0.1"); + assert.equal(url.protocol, "http:"); + } + const servers: ChildProcess[] = []; + let browser: Browser | undefined; + async function request(base: string, endpoint: string, token: string, body?: unknown, credential?: [string, string]) { + const response = await fetch(`${base}${endpoint}`, { + method: body ? "POST" : "GET", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...(credential ? { [credential[0]]: credential[1] } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + assert.equal(response.status, 200, `HTTP ${response.status} for ${endpoint}`); + return response.json(); + } + async function startDashboard(token: string, subject: string, email: string) { + const reservation = http.createServer().listen(0, "127.0.0.1"); + await once(reservation, "listening"); + const address = reservation.address(); + assert(address && typeof address !== "string"); + await new Promise(resolve => reservation.close(() => resolve())); + const base = `http://127.0.0.1:${address.port}`; + const environment: NodeJS.ProcessEnv = { NODE_ENV: "production" }; + for (const name of ["PATH", "HOME", "TMPDIR", "SSL_CERT_FILE", "NIX_SSL_CERT_FILE"]) { + if (process.env[name]) environment[name] = process.env[name]; + } + const child = spawn(process.execPath, ["node_modules/next/dist/bin/next", "start", "--hostname", "127.0.0.1", "--port", String(address.port)], { + env: { ...environment, NODE_ENV: "production", NEXT_TELEMETRY_DISABLED: "1", + FC_CORE_BASE_URL: config.coreUrl, FC_DASHBOARD_BASE_URL: base, FC_WORKOS_AUTH_ENABLED: "0", + FC_DASHBOARD_ALLOW_DEV_ACCOUNT_AUTH: "1", FC_DASHBOARD_DEV_EMAIL: email, + FC_DASHBOARD_DEV_WORKOS_USER_ID: subject, FC_DASHBOARD_DEV_WORKOS_ACCESS_TOKEN: token, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + servers.push(child); + // Drain logs without echoing request or credential-bearing diagnostics. + child.stdout?.resume(); child.stderr?.resume(); + await waitFor(async () => { + assert.equal(child.exitCode, null, "dashboard exited before readiness"); + return fetch(`${base}/healthz`).then(response => response.ok).catch(() => false); + }); + return base; + } + try { + await request(config.coreUrl, "/api/core/v1/me", config.owner); + await request(config.coreUrl, "/api/core/v1/me", config.other); + await request(config.identityUrl, "/api/core/internal/v1/brain-account-observations", config.owner, { + version: "finite-core-brain-account-observation-v1", operationId: "browser_alias_observation_0001", + brainServer: "https://brain.test", brainId: "brain_alpha", observedAt: new Date().toISOString(), + actionKind: "humanHostedAction", observedHumanPublicKeyHex: config.keys[0], participatingPublicKeyHex: config.keys[0], + }, ["x-finite-brain-observation-credential", config.observationToken]); + await request(config.identityUrl, "/api/core/internal/v1/brain-account-observations", config.other, { + version: "finite-core-brain-account-observation-v1", operationId: "browser_alias_observation_0002", + brainServer: "https://brain.test", brainId: "brain_alpha", observedAt: new Date().toISOString(), + actionKind: "humanHostedAction", observedHumanPublicKeyHex: config.keys[5], participatingPublicKeyHex: config.keys[5], + }, ["x-finite-brain-observation-credential", config.observationToken]); + const descriptions = await request(config.identityUrl, "/api/core/internal/v1/brain-identity-descriptions", "", { + version: "finite-core-brain-descriptions-v3", brainServer: "https://brain.test", brainId: "brain_alpha", + requestedByPublicKeyHex: config.keys[1], publicKeysHex: config.keys, participatingPublicKeysHex: config.keys, + }, ["x-finite-brain-description-credential", config.descriptionToken]); + const human = descriptions.results.find((row: { publicKeyHex: string }) => row.publicKeyHex === config.keys[0]); + assert.equal(human.displayName, "Ada Lovelace"); + const unnamed = descriptions.results.find((row: { publicKeyHex: string }) => row.publicKeyHex === config.keys[5]); + assert.equal(unnamed.displayName, undefined); + assert.equal(unnamed.accountEmail, "other@finite.test"); + const base = await startDashboard(config.owner, "runtime-auth-user", "runtime-auth@finite.test"); + const otherBase = await startDashboard(config.other, "alias-browser-other", "other@finite.test"); + browser = await chromium.launch({ headless: true, ...chromiumLaunchOptions() }); + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, reducedMotion: "reduce" }); + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await page.route("**/api/chat/**", route => route.fulfill({ status: 503, json: {} })); + await page.route("**/api/agents/*/hermes-access", route => route.fulfill({ json: { + baseUrl: "https://brain-alias.fixture.test/", accessToken: "synthetic-roster", expiresAt: 100, + } })); + // Only the unchanged runtime/Brain transport is synthetic. The human's + // name/email above were read through the real private Core HTTP listener. + await page.route("https://brain-alias.fixture.test/**", async route => { + const headers = { "access-control-allow-origin": base, "access-control-allow-headers": "authorization" }; + if (route.request().method() === "OPTIONS") return route.fulfill({ headers }); + const overview = route.request().url().endsWith("/overview"); + const json = overview ? { version: 1, brains: [{ id: "brain_alpha", name: "Staff brain", kind: "organization", role: "admin", folders: [] }] } + : { version: 1, brainId: "brain_alpha", identities: [ + { npub: config.npubs[0], role: "member", kind: "human", name: human.displayName, email: human.accountEmail, ownerEmail: null, folders: [] }, + { npub: config.npubs[1], role: "admin", kind: "agent", name: "Moss", lifecycle: "retired", email: null, ownerEmail: "owner@example.test", folders: [] }, + { npub: config.npubs[2], role: "member", kind: null, name: null, email: null, ownerEmail: null, folders: [] }, + { npub: config.npubs[3], role: "member", kind: null, publicName: "Stored person", publicNameStoredAt: "2026-10-01T00:00:00Z", folders: [] }, + { npub: config.npubs[4], role: "member", kind: null, operatorAnnotation: { kind: "historical_identity", label: "Earlier person", observedAt: "2026-10-01T00:00:00Z" }, folders: [] }, + { npub: config.npubs[5], role: "member", kind: "human", email: unnamed.accountEmail, folders: [] }, + ] }; + await route.fulfill({ headers, json }); + }); + const openRoster = async () => { + await page.goto(`${base}/dashboard/machines/${config.runtimeId}/brain`); + await page.getByRole("button", { name: "Staff brain", exact: true }).click(); + await page.getByRole("button", { name: /Add alias for Ada|Edit alias for/ }).first().waitFor(); + }; + await openRoster(); + const roster = page.getByRole("region", { name: "Identities in Staff brain", exact: true }); + const humanRow = roster.getByRole("row").filter({ hasText: "runtime-auth@finite.test" }); + await humanRow.getByRole("button", { name: "Add alias for Ada Lovelace", exact: true }).focus(); + await page.keyboard.press("Enter"); + await page.getByRole("textbox", { name: "Alias", exact: true }).fill(" Ada (staff) "); + await page.getByRole("button", { name: "Save alias", exact: true }).click(); + await page.getByRole("dialog").waitFor({ state: "hidden" }); + await humanRow.getByText("Ada (staff)", { exact: true }).waitFor(); + await waitFor(() => humanRow.getByRole("button", { name: "Edit alias for Ada (staff)", exact: true }).evaluate(element => element === document.activeElement)); + assert.match(await humanRow.innerText(), /Ada Lovelace[\s\S]*runtime-auth@finite.test/); + await openRoster(); + await humanRow.getByText("Ada (staff)", { exact: true }).waitFor(); + const privateLookup = await fetch(`${otherBase}/api/brain/key-aliases`, { + method: "POST", headers: { "content-type": "application/json", origin: otherBase }, body: JSON.stringify({ npubs: [config.npubs[0]] }), + }); + assert.equal(privateLookup.status, 200); + assert.deepEqual(await privateLookup.json(), { aliases: [] }); + const forged = await fetch(`${base}/api/brain/key-aliases`, { + method: "PUT", headers: { "content-type": "application/json", origin: "https://untrusted.example" }, + body: JSON.stringify({ npub: config.npubs[0], alias: "forged" }), + }); + assert.equal(forged.status, 403); + await humanRow.getByRole("button", { name: "Edit alias for Ada (staff)", exact: true }).click(); + await page.getByRole("textbox", { name: "Alias", exact: true }).fill("x".repeat(65)); + assert(await page.getByRole("button", { name: "Save alias", exact: true }).isDisabled()); + await page.getByRole("alert").filter({ hasText: "64 characters" }).waitFor(); + await page.getByRole("textbox", { name: "Alias", exact: true }).fill("Research lead"); + await page.route("**/api/brain/key-aliases", route => route.request().method() === "PUT" + ? route.fulfill({ status: 503, json: { error: "Try again later." } }) : route.continue()); + await page.getByRole("button", { name: "Save alias", exact: true }).click(); + await page.getByRole("alert").filter({ hasText: "Try again later." }).waitFor(); + assert.equal(await page.getByRole("textbox", { name: "Alias", exact: true }).inputValue(), "Research lead"); + await page.unroute("**/api/brain/key-aliases"); + await page.getByRole("button", { name: "Save alias", exact: true }).click(); + await humanRow.getByText("Research lead", { exact: true }).waitFor(); + for (const label of ["Moss", "Unknown", "Stored person", "Earlier person", "other@finite.test"]) { + const row = roster.getByRole("row").filter({ hasText: label }); + await row.getByRole("button", { name: /Add alias/ }).click(); + await page.getByRole("textbox", { name: "Alias", exact: true }).fill("Research lead"); + await page.getByText("You already use this alias for another key here. Check the key before saving.", { exact: true }).waitFor(); + await page.getByRole("button", { name: "Save alias", exact: true }).click(); + await row.getByText("Your alias", { exact: true }).waitFor(); + assert((await row.innerText()).includes(label)); + if (label === "Moss") await row.getByText("Agent (retired)", { exact: true }).waitFor(); + if (label === "Stored person") await row.getByText("Public name", { exact: true }).waitFor(); + if (label === "Earlier person") await row.getByText("Historical identity · Evidence 2026-10-01", { exact: true }).waitFor(); + if (label === "Moss") assert.match(await row.innerText(), /owner@example.test/); + await row.getByRole("button", { name: /Edit alias/ }).click(); + await page.getByRole("button", { name: "Clear alias", exact: true }).click(); + await row.getByRole("button", { name: /Add alias/ }).waitFor(); + } + const artifacts = process.env.FC_BROWSER_ARTIFACT_DIR; + if (artifacts) { + await mkdir(artifacts, { recursive: true }); + await page.screenshot({ path: path.join(artifacts, "brain-alias-desktop.png"), fullPage: true }); + } + await page.setViewportSize({ width: 390, height: 844 }); + await waitFor(() => page.evaluate(() => { + const sidebar = document.querySelector(".finite-agent-shell__sidebar"); + return (!sidebar || sidebar.getBoundingClientRect().right <= 0) && document.documentElement.scrollWidth <= innerWidth; + })); + if (artifacts) await page.screenshot({ path: path.join(artifacts, "brain-alias-mobile.png"), fullPage: true }); + await humanRow.getByRole("button", { name: "Edit alias for Research lead", exact: true }).click(); + if (artifacts) await page.screenshot({ path: path.join(artifacts, "brain-alias-dialog-mobile.png"), fullPage: true }); + await page.getByRole("button", { name: "Clear alias", exact: true }).click(); + await humanRow.getByRole("button", { name: "Add alias for Ada Lovelace", exact: true }).waitFor(); + await openRoster(); + assert.equal(await humanRow.getByText("Your alias", { exact: true }).count(), 0); + for (const status of [404, 503]) { + await page.route("**/api/brain/key-aliases", route => route.fulfill({ status, json: { error: "Unavailable" } })); + await page.getByRole("button", { name: "Refresh brains", exact: true }).click(); + await page.getByText("Your private aliases are unavailable right now.", { exact: true }).waitFor(); + assert.match(await humanRow.innerText(), /Ada Lovelace/); + assert.equal(await roster.getByRole("button", { name: /alias/ }).count(), 0); + await page.unroute("**/api/brain/key-aliases"); + } + assert.deepEqual(errors, []); + console.log("PASS: real Core/Postgres + dashboard v3 name/fallback display, scoped-label preservation, alias save/reload/edit/clear, viewer isolation, origin rejection, keyboard, bounds, mobile and unavailable fallback"); + console.log("BOUNDARY: WorkOS provider and Agent/Brain roster transport are fixtures; live OAuth, runtime/Brain services and production rollout are not claimed."); + } finally { + await browser?.close(); + await Promise.all(servers.map(async server => { + if (server.exitCode !== null) return; + server.kill("SIGTERM"); + await once(server, "exit"); + })); + } +} +async function waitFor(check: () => Promise) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (await check()) return; + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error("Timed out waiting for browser proof readiness"); +} +main().catch(error => { console.error(error instanceof Error ? error.message : "Browser proof failed"); process.exitCode = 1; }); diff --git a/finitecomputer-v2/apps/dashboard/src/app/api/brain/key-aliases/route.ts b/finitecomputer-v2/apps/dashboard/src/app/api/brain/key-aliases/route.ts new file mode 100644 index 000000000..b961b646c --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/src/app/api/brain/key-aliases/route.ts @@ -0,0 +1,46 @@ +import { clearCoreKeyAlias, CoreFetchError, lookupCoreKeyAliases, setCoreKeyAlias } from "@/lib/core-client"; +import { isNpub, MAX_ALIAS_LOOKUP_KEYS, normalizeAlias } from "@/lib/brain-key-aliases"; +import { requestOriginMatchesHost } from "@/lib/http-headers"; + +// Private key aliases for the signed-in account. The browser sends only keys +// and alias text; Core derives the account from the server-held session. +export const POST = handle; +export const PUT = handle; +export const DELETE = handle; + +const headers = { "cache-control": "no-store" }; +const fail = (status: number, error = "Aliases are unavailable right now. Try again.") => + Response.json({ error }, { status, headers }); + +async function handle(request: Request) { + if (!requestOriginMatchesHost(request)) return fail(403); + const payload = await request.json().catch(() => null) as Record | null; + if (!payload || typeof payload !== "object") return fail(400, "That request was not understood."); + try { + if (request.method === "POST") { + const npubs = payload.npubs; + if (!Array.isArray(npubs) || npubs.length > MAX_ALIAS_LOOKUP_KEYS || !npubs.every(isNpub)) { + return fail(400, "That request was not understood."); + } + const unique = [...new Set(npubs)]; + const aliases = unique.length ? await lookupCoreKeyAliases(unique, request.signal) : []; + return Response.json({ aliases }, { headers }); + } + if (!isNpub(payload.npub)) return fail(400, "That key is not valid."); + if (request.method === "DELETE") { + await clearCoreKeyAlias(payload.npub, request.signal); + return new Response(null, { status: 204, headers }); + } + const normalized = typeof payload.alias === "string" ? normalizeAlias(payload.alias) : null; + if (!normalized || "error" in normalized) return fail(400, normalized?.error ?? "Enter an alias."); + return Response.json(await setCoreKeyAlias(payload.npub, normalized.alias, request.signal), { headers }); + } catch (error) { + if (!(error instanceof CoreFetchError)) return fail(503); + if (error.status === 400) return fail(400, "That alias can't be saved. Use 64 characters or fewer, without control characters."); + if (error.status === 409) return fail(409, "You've reached the alias limit. Clear an alias you no longer need."); + if ([401, 403].includes(error.status)) return fail(error.status, "Sign in again to manage aliases."); + // An older Core without alias routes answers 404 or 405. + if ([404, 405].includes(error.status)) return fail(404); + return fail(503); + } +} diff --git a/finitecomputer-v2/apps/dashboard/src/components/brain-identity-cell.test.ts b/finitecomputer-v2/apps/dashboard/src/components/brain-identity-cell.test.ts index 1d6fef0ce..8bff7342b 100644 --- a/finitecomputer-v2/apps/dashboard/src/components/brain-identity-cell.test.ts +++ b/finitecomputer-v2/apps/dashboard/src/components/brain-identity-cell.test.ts @@ -9,8 +9,8 @@ const base: BrainIdentity = { npub: "npub1ccz8l9zpa47k6vz9gphftsrumpw80rjt3nhnefat4symjhrsnmjs38mnyd", role: "member", kind: null, name: null, email: null, ownerEmail: null, publicName: null, publicNameStoredAt: null, lifecycle: null, operatorAnnotation: null, folders: [], }; -const cell = (row: Partial) => renderToStaticMarkup(createElement("table", null, - createElement("tbody", null, createElement("tr", null, createElement(IdentityCell, { row: { ...base, ...row } }))))); +const cell = (row: Partial, alias?: string) => renderToStaticMarkup(createElement("table", null, + createElement("tbody", null, createElement("tr", null, createElement(IdentityCell, { row: { ...base, ...row }, alias }))))); const secondary = (text: string) => `${text}`; const stored = { publicName: "cara@finite.vip", publicNameStoredAt: "2026-09-17T20:39:30Z" }; @@ -39,3 +39,21 @@ test("annotations render as dated text and lifecycle qualifiers stay in the exis assert.match(cell({ kind: "agent", name: "Example", lifecycle: "offboarding" }), /Agent \(offboarding\)/); assert.doesNotMatch(cell({ kind: "human", email: "example@example.test", operatorAnnotation: annotation }), /Previous/); }); + + +test("private aliases preserve authoritative identity and every scoped fallback", () => { + const cases: [Partial, string[]][] = [ + [{ kind: "human", name: "Maya", email: "maya@example.test" }, ["Maya", "maya@example.test"]], + [{ kind: "human", email: "maya@example.test" }, ["maya@example.test"]], + [{ kind: "agent", name: "Example", lifecycle: "retired" }, ["Example", "Agent (retired)"]], + [{ operatorAnnotation: { kind: "invitation_key", label: "Invitation", observedAt: "2026-01-01T00:00:00Z" } }, ["Invitation", "Invitation key · Evidence 2026-01-01"]], + [stored, [stored.publicName, "Public name"]], + [{}, ["Unknown"]], + ]; + for (const [row, labels] of cases) { + const html = cell(row, "My colleague"); + assert(html.includes("My colleague")); + assert(html.includes("Your alias")); + for (const label of labels) assert(html.includes(label), `${label} missing from ${html}`); + } +}); diff --git a/finitecomputer-v2/apps/dashboard/src/components/brain-identity-cell.tsx b/finitecomputer-v2/apps/dashboard/src/components/brain-identity-cell.tsx index 0aae73df4..e17fe6b8d 100644 --- a/finitecomputer-v2/apps/dashboard/src/components/brain-identity-cell.tsx +++ b/finitecomputer-v2/apps/dashboard/src/components/brain-identity-cell.tsx @@ -1,21 +1,27 @@ import type { BrainIdentity } from "@/lib/brain-identity-roster"; -function Name({ primary, detail }: { primary: string; detail: string | null }) { - return <>{primary}{detail && {detail}}; -} - /** Current Core identity, dated scoped annotation, stored public name, then Unknown. */ -export function IdentityCell({ row }: { row: BrainIdentity }) { +export function identityLabel(row: BrainIdentity): { primary: string | null; detail: string | null; title?: string } { const primary = row.kind === "agent" ? row.name : row.name ?? row.email; const agentDetail = row.lifecycle === "retired" ? "Agent (retired)" : row.lifecycle === "offboarding" ? "Agent (offboarding)" : "Agent"; - if (primary) return ; + if (primary) return { primary, detail: row.kind === "agent" ? agentDetail : row.name ? row.email : null }; if (row.operatorAnnotation) { const annotation = row.operatorAnnotation; const kind = annotation.kind === "historical_identity" ? "Historical identity" : "Invitation key"; - return ; + return { primary: annotation.label, detail: `${kind} · Evidence ${annotation.observedAt.slice(0, 10)}`, title: `Evidence ${annotation.observedAt}` }; } - if (row.publicName) return - ; - return Unknown; + if (row.publicName) return { primary: row.publicName, detail: "Public name", title: row.publicNameStoredAt ? `Stored ${row.publicNameStoredAt}` : undefined }; + return { primary: null, detail: null }; +} + +export function IdentityCell({ row, alias }: { row: BrainIdentity; alias?: string | null }) { + const { primary, detail, title } = identityLabel(row); + const identity = primary + ? <>{primary}{detail && {detail}} + : Unknown; + return {alias ? <> + {alias}{" "} + Your alias + {identity} + : identity}; } diff --git a/finitecomputer-v2/apps/dashboard/src/components/brain-identity-roster.tsx b/finitecomputer-v2/apps/dashboard/src/components/brain-identity-roster.tsx index 85558ab7e..a44451dd7 100644 --- a/finitecomputer-v2/apps/dashboard/src/components/brain-identity-roster.tsx +++ b/finitecomputer-v2/apps/dashboard/src/components/brain-identity-roster.tsx @@ -1,8 +1,11 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { KeyAliasDialog, type AliasTarget } from "@/components/brain-key-alias-dialog"; +import { IdentityCell, identityLabel } from "@/components/brain-identity-cell"; +import { Button } from "@/components/ui/button"; import { useAgentInventory } from "@/hooks/use-agent-inventory"; -import { readBrainIdentities } from "@/lib/brain-identity-roster"; -import { IdentityCell } from "@/components/brain-identity-cell"; +import { useKeyAliases } from "@/hooks/use-key-aliases"; +import { readBrainIdentities, type BrainIdentity } from "@/lib/brain-identity-roster"; import type { BrainRow } from "@/lib/agent-product-inventory"; import tableStyles from "@/styles/dashboard-table.module.css"; @@ -12,24 +15,56 @@ const roles: Record = { }; const folderStates: Record = { grantMissing: "key pending", revocationIncomplete: "removal pending" }; +/** What Finite knows about this key, without any alias. */ +function actualLabel(row: BrainIdentity) { + const { primary, detail } = identityLabel(row); + return [primary, detail].filter(Boolean).join(" · ") || null; +} + +function shortKey(npub: string) { + return `${npub.slice(0, 12)}…${npub.slice(-6)}`; +} + export function BrainIdentityRoster({ runtimeId, brain }: { runtimeId: string; brain: BrainRow }) { const read = useCallback((id: string, signal: AbortSignal) => readBrainIdentities(id, brain.id, signal), [brain.id]); const { data, busy } = useAgentInventory(runtimeId, read); + const npubs = useMemo(() => data?.map(row => row.npub) ?? null, [data]); + const aliases = useKeyAliases(npubs); + const [editing, setEditing] = useState(null); + const aliasTrigger = useRef(null); const folderNames = new Map(brain.folders?.map(folder => [folder.id, folder.name])); return

{brain.name}

-

{data ? `${data.length} identities` : busy ? "Loading identities…" : "Identities unavailable"}

+

{data ? `${data.length} identities` : busy ? "Loading identities…" : "Identities unavailable"}

+ {data && data.length > 0 && aliases.status === "unavailable" &&

Your private aliases are unavailable right now.

} + {data && data.length > 0 &&
- - - {data.map(row => - - - - - - )} +
IdentityOwnerRoleFoldersKey
{row.kind === "agent" ? row.ownerEmail : null}{roles[row.role] ?? row.role}{row.folders.map(folder => `${folderNames.get(folder.id) ?? folder.id}${folderStates[folder.state] ? ` (${folderStates[folder.state]})` : ""}`).join(", ")}{`${row.npub.slice(0, 12)}…${row.npub.slice(-6)}`}
+ + {data.map(row => { + const alias = aliases.aliasFor(row.npub); + const name = alias ?? identityLabel(row).primary ?? `unknown key ${shortKey(row.npub)}`; + return + + + + + + + ; + })}
IdentityYour aliasOwnerRoleFoldersKey
{aliases.status === "ready" && }{row.kind === "agent" ? row.ownerEmail : null}{roles[row.role] ?? row.role}{row.folders.map(folder => `${folderNames.get(folder.id) ?? folder.id}${folderStates[folder.state] ? ` (${folderStates[folder.state]})` : ""}`).join(", ")}{shortKey(row.npub)}
} + {editing && row.npub !== editing.npub).flatMap(row => aliases.aliasFor(row.npub) ?? [])} + onSave={aliases.save} onClear={aliases.clear} onClose={() => setEditing(null)} + onRestoreFocus={() => aliasTrigger.current?.focus()} />}
; } diff --git a/finitecomputer-v2/apps/dashboard/src/components/brain-key-alias-dialog.tsx b/finitecomputer-v2/apps/dashboard/src/components/brain-key-alias-dialog.tsx new file mode 100644 index 000000000..a57b84e73 --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/src/components/brain-key-alias-dialog.tsx @@ -0,0 +1,79 @@ +"use client"; +import { useId, useState, type FormEvent } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { aliasLength, MAX_ALIAS_CHARS, normalizeAlias } from "@/lib/brain-key-aliases"; + +export type AliasTarget = { npub: string; alias: string | null; actual: string | null }; + +/** Add, edit or clear the signed-in account's private alias for one key. */ +export function KeyAliasDialog({ target, otherAliases, onSave, onClear, onClose, onRestoreFocus }: { + target: AliasTarget; + /** Your aliases for the other keys in this list, to flag look-alikes. */ + otherAliases: string[]; + onSave: (npub: string, alias: string) => Promise; + onClear: (npub: string) => Promise; + onClose: () => void; + onRestoreFocus: () => void; +}) { + const inputId = useId(); + const hintId = useId(); + const [value, setValue] = useState(target.alias ?? ""); + const [busy, setBusy] = useState<"save" | "clear" | null>(null); + const [error, setError] = useState(null); + const normalized = normalizeAlias(value); + const alias = "alias" in normalized ? normalized.alias : null; + const problem = error ?? (value.trim() && "error" in normalized ? normalized.error : null); + const duplicate = alias !== null && otherAliases.some(other => other.toLocaleLowerCase() === alias.toLocaleLowerCase()); + const run = async (kind: "save" | "clear", action: () => Promise) => { + setBusy(kind); setError(null); + try { await action(); onClose(); } catch (caught) { + setError(caught instanceof Error ? caught.message : "Aliases are unavailable right now. Try again."); + } finally { setBusy(null); } + }; + const submit = (event: FormEvent) => { + event.preventDefault(); + if (!alias || busy) return; + void run("save", () => onSave(target.npub, alias)); + }; + return { if (!open && !busy) onClose(); }}> + { event.preventDefault(); onRestoreFocus(); }}> +
+ + {target.alias ? "Edit your alias" : "Add your alias"} + Only you can see this alias. It doesn’t change who this is or what they can access. + +
+
Finite shows
{target.actual ?? "Unknown"}
+
Key
{target.npub}
+
+
+ + { setValue(event.target.value); setError(null); }} /> +
+ {duplicate ? "You already use this alias for another key here. Check the key before saving." : "A private reminder, like a nickname or role."} + {aliasLength(value)}/{MAX_ALIAS_CHARS} characters +
+ {problem &&

{problem}

} +
+ + {target.alias + ? + : } +
+ + +
+
+
+
+
; +} diff --git a/finitecomputer-v2/apps/dashboard/src/hooks/use-key-aliases.ts b/finitecomputer-v2/apps/dashboard/src/hooks/use-key-aliases.ts new file mode 100644 index 000000000..13ba3a7f8 --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/src/hooks/use-key-aliases.ts @@ -0,0 +1,44 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { clearKeyAlias, readKeyAliases, saveKeyAlias } from "@/lib/brain-key-aliases"; + +type Loaded = { key: string; aliases: Map | null }; + +/** + * The signed-in account's private aliases for one roster's keys. A failed or + * unsupported lookup leaves `status` "unavailable" and never hides the roster. + * The caller remounts this hook per account, agent and Brain, so nothing + * carries over between them. Results for an older key set are ignored. + */ +export function useKeyAliases(npubs: string[] | null) { + const key = npubs?.join(",") ?? ""; + const [loaded, setLoaded] = useState(null); + // Your own saves since the lookup started. They win over a lookup that may + // have been read before them. + const [edits, setEdits] = useState>(() => new Map()); + useEffect(() => { + if (!key) return; + const controller = new AbortController(); + readKeyAliases(key.split(","), controller.signal).then( + aliases => { if (!controller.signal.aborted) setLoaded({ key, aliases }); }, + () => { if (!controller.signal.aborted) setLoaded({ key, aliases: null }); }, + ); + return () => controller.abort(); + }, [key]); + const current = loaded?.key === key ? loaded : null; + const status = !key ? "ready" : !current ? "loading" : current.aliases ? "ready" : "unavailable"; + const aliasFor = useCallback((npub: string) => { + if (edits.has(npub)) return edits.get(npub) ?? null; + return current?.aliases?.get(npub) ?? null; + }, [current, edits]); + const save = useCallback(async (npub: string, alias: string) => { + const saved = await saveKeyAlias(npub, alias); + setEdits(previous => new Map(previous).set(npub, saved.alias)); + }, []); + const clear = useCallback(async (npub: string) => { + await clearKeyAlias(npub); + setEdits(previous => new Map(previous).set(npub, null)); + }, []); + return { status, aliasFor, save, clear } as const; +} diff --git a/finitecomputer-v2/apps/dashboard/src/lib/brain-key-aliases.test.ts b/finitecomputer-v2/apps/dashboard/src/lib/brain-key-aliases.test.ts new file mode 100644 index 000000000..a0241788e --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/src/lib/brain-key-aliases.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { KeyAliasError, normalizeAlias, parseKeyAliases } from "./brain-key-aliases"; + +const npub = "npub1ccz8l9zpa47k6vz9gphftsrumpw80rjt3nhnefat4symjhrsnmjs38mnyd"; + +test("aliases mirror Core's trim, collapse and bounds", () => { + assert.deepEqual(normalizeAlias(" Maya (design) "), { alias: "Maya (design)" }); + assert.deepEqual(normalizeAlias("👩🏽\u200d💻".repeat(9)), { alias: "👩🏽\u200d💻".repeat(9) }); + for (const value of ["", " ", "x".repeat(65), "tab\there", "\u202ereversed", "bell\u0007"]) { + assert("error" in normalizeAlias(value), JSON.stringify(value)); + } + assert("alias" in normalizeAlias("x".repeat(64))); +}); + +test("alias responses keep only npub and alias and refuse anything malformed", () => { + assert.deepEqual( + parseKeyAliases({ aliases: [{ npub, alias: "Maya", publicKeyHex: "ab", updatedAt: "t" }] }), + [{ npub, alias: "Maya" }], + ); + for (const value of [null, {}, { aliases: [{ npub: "npub1short", alias: "x" }] }, { aliases: [{ npub, alias: "" }] }, + { aliases: [{ npub, alias: "x".repeat(65) }] }]) { + assert.throws(() => parseKeyAliases(value), KeyAliasError); + } +}); diff --git a/finitecomputer-v2/apps/dashboard/src/lib/brain-key-aliases.ts b/finitecomputer-v2/apps/dashboard/src/lib/brain-key-aliases.ts new file mode 100644 index 000000000..e7bed17a0 --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/src/lib/brain-key-aliases.ts @@ -0,0 +1,82 @@ +/** + * Private key aliases: a label the signed-in account gives one exact key. + * Only that account sees it. It is never identity evidence and never changes + * Brain access. Core is the authority for validation; this module mirrors its + * rules so the editor can explain a problem before saving. + */ + +export type KeyAlias = { npub: string; alias: string }; + +export const MAX_ALIAS_CHARS = 64; +const MAX_ALIAS_BYTES = 256; +/** Keys per lookup; the roster lists at most this many identities. */ +export const MAX_ALIAS_LOOKUP_KEYS = 1024; +const NPUB = /^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/; +// Control characters and bidirectional overrides can hide or reorder text. +const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/; + +export class KeyAliasError extends Error { + constructor(message: string, readonly status: number) { super(message); } +} + +export function isNpub(value: unknown): value is string { + return typeof value === "string" && NPUB.test(value); +} + +/** Trimmed alias with inner whitespace collapsed, or the reason it is invalid. */ +export function normalizeAlias(value: string): { alias: string } | { error: string } { + if (UNSAFE.test(value)) return { error: "Use letters, numbers, spaces and punctuation only." }; + const alias = value.trim().split(/\s+/).filter(Boolean).join(" "); + if (!alias) return { error: "Enter an alias, or clear it instead." }; + if ([...alias].length > MAX_ALIAS_CHARS || new TextEncoder().encode(alias).length > MAX_ALIAS_BYTES) { + return { error: `Use ${MAX_ALIAS_CHARS} characters or fewer.` }; + } + return { alias }; +} + +export function aliasLength(value: string) { + return [...value.trim().split(/\s+/).filter(Boolean).join(" ")].length; +} + +/** One alias from Core or the dashboard route; anything else is refused. */ +export function parseKeyAlias(value: unknown): KeyAlias { + const row = value && typeof value === "object" ? value as Record : {}; + if (!isNpub(row.npub) || typeof row.alias !== "string" || !row.alias || [...row.alias].length > MAX_ALIAS_CHARS) { + throw new KeyAliasError("Aliases returned an unexpected response.", 502); + } + return { npub: row.npub, alias: row.alias }; +} + +export function parseKeyAliases(value: unknown): KeyAlias[] { + const list = value && typeof value === "object" ? (value as Record).aliases : null; + if (!Array.isArray(list) || list.length > MAX_ALIAS_LOOKUP_KEYS) { + throw new KeyAliasError("Aliases returned an unexpected response.", 502); + } + return list.map(parseKeyAlias); +} + +async function request(init: RequestInit & { body: string }) { + const response = await fetch("/api/brain/key-aliases", { + ...init, cache: "no-store", headers: { "content-type": "application/json" }, + }); + const payload = response.status === 204 ? null : await response.json().catch(() => null); + if (!response.ok) { + const message = payload && typeof payload.error === "string" ? payload.error : "Aliases are unavailable. Try again."; + throw new KeyAliasError(message, response.status); + } + return payload; +} + +/** Your aliases for these keys, keyed by npub. */ +export async function readKeyAliases(npubs: string[], signal: AbortSignal) { + const aliases = parseKeyAliases(await request({ method: "POST", body: JSON.stringify({ npubs }), signal })); + return new Map(aliases.map(row => [row.npub, row.alias])); +} + +export async function saveKeyAlias(npub: string, alias: string, signal?: AbortSignal) { + return parseKeyAlias(await request({ method: "PUT", body: JSON.stringify({ npub, alias }), signal })); +} + +export async function clearKeyAlias(npub: string, signal?: AbortSignal) { + await request({ method: "DELETE", body: JSON.stringify({ npub }), signal }); +} diff --git a/finitecomputer-v2/apps/dashboard/src/lib/core-client.ts b/finitecomputer-v2/apps/dashboard/src/lib/core-client.ts index 3ea1d4467..7196e3bad 100644 --- a/finitecomputer-v2/apps/dashboard/src/lib/core-client.ts +++ b/finitecomputer-v2/apps/dashboard/src/lib/core-client.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { getAccountAuthContext, type AccountAuthContext } from "@/lib/dashboard-auth"; import { parseHostedHermesAccess, parseHostedHermesSession } from "@/lib/hosted-hermes-status"; +import { parseKeyAlias, parseKeyAliases, type KeyAlias } from "@/lib/brain-key-aliases"; import { invalidateServerSwrCache, readThroughServerSwr, @@ -1125,6 +1126,38 @@ export async function createCoreHostedHermesSession(runtimeId: string, signal?: return parseHostedHermesSession(grant); } +// --- Private key aliases --- +// +// Core derives the viewer from the signed-in account's WorkOS token. No +// request names an owner, account or email. + +const CORE_ALIAS_LOOKUP_KEYS = 100; + +export async function lookupCoreKeyAliases(npubs: string[], signal?: AbortSignal): Promise { + const aliases: KeyAlias[] = []; + for (let start = 0; start < npubs.length; start += CORE_ALIAS_LOOKUP_KEYS) { + const page = await coreAdminFetch("/api/core/v1/me/key-aliases/lookup", { + method: "POST", signal, + body: JSON.stringify({ publicKeys: npubs.slice(start, start + CORE_ALIAS_LOOKUP_KEYS) }), + }); + aliases.push(...parseKeyAliases(page)); + } + return aliases; +} + +export async function setCoreKeyAlias(npub: string, alias: string, signal?: AbortSignal) { + return parseKeyAlias(await coreAdminFetch( + `/api/core/v1/me/key-aliases/${encodeURIComponent(npub)}`, + { method: "PUT", body: JSON.stringify({ alias }), signal } + )); +} + +export async function clearCoreKeyAlias(npub: string, signal?: AbortSignal) { + await coreAdminFetch(`/api/core/v1/me/key-aliases/${encodeURIComponent(npub)}`, { + method: "DELETE", signal, + }); +} + export async function loadCoreAdminRuntimes(): Promise { const status = coreBridgeStatus(); if (!status.configured) { diff --git a/finitecomputer-v2/apps/dashboard/src/styles/brain-overview.css b/finitecomputer-v2/apps/dashboard/src/styles/brain-overview.css index 60cf60530..b69ecd7f2 100644 --- a/finitecomputer-v2/apps/dashboard/src/styles/brain-overview.css +++ b/finitecomputer-v2/apps/dashboard/src/styles/brain-overview.css @@ -28,6 +28,8 @@ .brain-preview-options summary { cursor: pointer; margin-bottom: 10px; width: fit-content; } .brain-table-preview summary:focus-visible { outline: 2px solid #879fc3; outline-offset: 3px; border-radius: 2px; } @media (max-width: 640px) { .brain-table th, .brain-table td { padding: 14px 10px; } .brain-table { min-width: 480px; } } +.brain-identity-table { min-width: 900px; } +@media (max-width: 640px) { .brain-identity-table th:first-child { width: 230px; } } .brain-table tr > :first-child { padding-left: 0; } .brain-table tr > :last-child { padding-right: 0; } .brain-table-refresh button { width: 32px; height: 32px; padding: 0; border: 0; background: transparent; } diff --git a/finitecomputer-v2/crates/finite-saas-core/Cargo.toml b/finitecomputer-v2/crates/finite-saas-core/Cargo.toml index e638f83ec..481192445 100644 --- a/finitecomputer-v2/crates/finite-saas-core/Cargo.toml +++ b/finitecomputer-v2/crates/finite-saas-core/Cargo.toml @@ -17,6 +17,7 @@ getrandom = { version = "0.2", features = ["std"] } jsonwebtoken = "9.3" reqwest = { workspace = true } rustix = { workspace = true } +secp256k1 = { workspace = true } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" diff --git a/finitecomputer-v2/crates/finite-saas-core/migrations/0043_account_names_and_key_aliases.sql b/finitecomputer-v2/crates/finite-saas-core/migrations/0043_account_names_and_key_aliases.sql new file mode 100644 index 000000000..7f4e602ff --- /dev/null +++ b/finitecomputer-v2/crates/finite-saas-core/migrations/0043_account_names_and_key_aliases.sql @@ -0,0 +1,41 @@ +-- Account display names from WorkOS and private per-account key aliases. +-- Purely additive and safe to reapply at every Core startup. No existing row +-- is rewritten or backfilled: a name appears after the account's next +-- verified sign-in, and aliases start empty. +-- +-- users.workos_display_name writer: the verified-account link on +-- GET /api/core/v1/me and /me/dashboard-summary, from the WorkOS user that +-- request already fetched. NULL means WorkOS gave no usable name. Reader: the +-- Brain identity description route, for human rows it already releases. +ALTER TABLE users + ADD COLUMN IF NOT EXISTS workos_display_name TEXT, + ADD COLUMN IF NOT EXISTS workos_display_name_updated_at TIMESTAMPTZ; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'users_workos_display_name_bounded' + ) THEN + ALTER TABLE users ADD CONSTRAINT users_workos_display_name_bounded + CHECK (workos_display_name IS NULL + OR (octet_length(workos_display_name) BETWEEN 1 AND 200 + AND workos_display_name = btrim(workos_display_name))); + END IF; +END $$; + +-- A private label one account gives one exact key. Visible only to that +-- account; never identity evidence and never read by access decisions. +-- Writer and reader: the account-authenticated /api/core/v1/me/key-aliases +-- routes only. +CREATE TABLE IF NOT EXISTS account_key_aliases ( + viewer_user_id TEXT NOT NULL REFERENCES users(id), + public_key_hex TEXT NOT NULL CHECK (public_key_hex ~ '^[0-9a-f]{64}$'), + alias TEXT NOT NULL CHECK ( + char_length(alias) BETWEEN 1 AND 64 + AND octet_length(alias) <= 256 + AND alias = btrim(alias) + ), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (viewer_user_id, public_key_hex) +); diff --git a/finitecomputer-v2/crates/finite-saas-core/src/account_labels.rs b/finitecomputer-v2/crates/finite-saas-core/src/account_labels.rs new file mode 100644 index 000000000..35777204c --- /dev/null +++ b/finitecomputer-v2/crates/finite-saas-core/src/account_labels.rs @@ -0,0 +1,132 @@ +//! Account display names from WorkOS and private per-viewer key aliases. +//! +//! Neither value is identity evidence. A WorkOS name only labels an account +//! that Core already describes; an alias only labels a key for the one account +//! that wrote it. Neither grants access, ownership, membership or roles. + +use crate::brain_identity::{MAX_DISPLAY_NAME_BYTES, npub_for_hex}; +use bech32::Hrp; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Longest single WorkOS name part Core accepts, in characters. +pub const MAX_NAME_PART_CHARS: usize = 100; +/// Longest alias, in characters and in UTF-8 bytes. +pub const MAX_ALIAS_CHARS: usize = 64; +pub const MAX_ALIAS_BYTES: usize = 256; +/// Aliases one account may keep. +pub const MAX_ALIASES_PER_ACCOUNT: i64 = 1_000; +/// Keys one alias lookup may name. +pub const MAX_ALIAS_LOOKUP_KEYS: usize = 100; +pub const MAX_ALIAS_LOOKUP_BODY_BYTES: usize = 16 * 1024; +pub const MAX_ALIAS_WRITE_BODY_BYTES: usize = 2 * 1024; + +/// Trim, collapse inner whitespace to single spaces and refuse text that +/// could hide or reorder what a reader sees: control characters and +/// bidirectional overrides. Returns `None` for blank or unusable text. +fn display_text(value: &str, max_chars: usize, max_bytes: usize) -> Option { + if value.chars().any(|c| c.is_control() || bidi_control(c)) { + return None; + } + let text = value.split_whitespace().collect::>().join(" "); + (!text.is_empty() && text.chars().count() <= max_chars && text.len() <= max_bytes) + .then_some(text) +} + +fn bidi_control(c: char) -> bool { + matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') +} + +/// The account name to store for one fetched WorkOS user. +/// +/// `first_name` and `last_name` are optional in WorkOS. A missing, null or +/// blank part is skipped. Any part that is not a string, is longer than +/// [`MAX_NAME_PART_CHARS`], or contains control or bidi characters makes the +/// whole name unusable. The parts are joined by one space. `None` means +/// "WorkOS gives no usable name": Core clears any stored name, so it never +/// shows a name WorkOS no longer has. Core never derives a name from email. +pub fn workos_display_name(first: Option<&Value>, last: Option<&Value>) -> Option { + let mut parts = Vec::new(); + for part in [first, last] { + match part { + None | Some(Value::Null) => {} + Some(Value::String(text)) if text.trim().is_empty() => {} + Some(Value::String(text)) => parts.push(display_text( + text, + MAX_NAME_PART_CHARS, + MAX_DISPLAY_NAME_BYTES, + )?), + Some(_) => return None, + } + } + let name = parts.join(" "); + (!name.is_empty() && name.len() <= MAX_DISPLAY_NAME_BYTES).then_some(name) +} + +/// A private alias as typed: trimmed, inner whitespace collapsed, 1 to +/// [`MAX_ALIAS_CHARS`] characters, no control or bidi characters. +pub fn normalize_alias(value: &str) -> Option { + display_text(value, MAX_ALIAS_CHARS, MAX_ALIAS_BYTES) +} + +/// Canonical lowercase hex for a Nostr public key given as `npub1…` or as +/// 64 hex characters. The 32 bytes must be a valid x-only secp256k1 key, so a +/// well-shaped string that is not a key is refused. +pub fn canonical_public_key(value: &str) -> Option { + let value = value.trim(); + let bytes: Vec = if value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) { + (0..32) + .map(|index| u8::from_str_radix(&value[2 * index..2 * index + 2], 16)) + .collect::>() + .ok()? + } else { + let (hrp, data) = bech32::decode(value).ok()?; + if hrp != Hrp::parse("npub").ok()? || value.to_ascii_lowercase() != value { + return None; + } + data + }; + secp256k1::XOnlyPublicKey::from_slice(&bytes).ok()?; + Some(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyAlias { + pub public_key_hex: String, + pub npub: String, + pub alias: String, + pub updated_at: String, +} + +impl KeyAlias { + pub(crate) fn new(public_key_hex: String, alias: String, updated_at: String) -> Self { + Self { + npub: npub_for_hex(&public_key_hex).unwrap_or_default(), + public_key_hex, + alias, + updated_at, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct KeyAliasLookupRequest { + pub public_keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetKeyAliasRequest { + pub alias: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyAliasesResponse { + pub aliases: Vec, +} + +#[cfg(test)] +mod tests; diff --git a/finitecomputer-v2/crates/finite-saas-core/src/account_labels/tests.rs b/finitecomputer-v2/crates/finite-saas-core/src/account_labels/tests.rs new file mode 100644 index 000000000..c9ae63c67 --- /dev/null +++ b/finitecomputer-v2/crates/finite-saas-core/src/account_labels/tests.rs @@ -0,0 +1,94 @@ +use super::*; +use serde_json::json; + +const HEX: &str = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; +const NPUB: &str = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + +fn name(first: Value, last: Value) -> Option { + workos_display_name(Some(&first), Some(&last)) +} + +#[test] +fn workos_names_join_present_parts_and_never_guess() { + assert_eq!( + name(json!(" Ada "), json!("Lovelace")).as_deref(), + Some("Ada Lovelace") + ); + assert_eq!( + name(json!("Mary Ann"), json!(null)).as_deref(), + Some("Mary Ann") + ); + assert_eq!(name(json!(""), json!("Hopper")).as_deref(), Some("Hopper")); + assert_eq!(workos_display_name(None, None), None); + assert_eq!(name(json!(" "), json!(null)), None); + assert_eq!( + name(json!("名前"), json!("田中")).as_deref(), + Some("名前 田中") + ); +} + +#[test] +fn malformed_or_oversized_workos_names_are_unusable_not_fatal() { + for (first, last) in [ + (json!(42), json!("Lovelace")), + (json!(["Ada"]), json!(null)), + (json!("Ada"), json!({"x": 1})), + (json!("Ada\u{0000}"), json!(null)), + (json!("Ada\nLovelace"), json!(null)), + (json!("Ada\u{202E}ecalevol"), json!(null)), + (json!("A".repeat(MAX_NAME_PART_CHARS + 1)), json!(null)), + // Two in-bound parts whose UTF-8 total exceeds the 200-byte wire cap. + (json!("é".repeat(60)), json!("é".repeat(60))), + ] { + assert_eq!(name(first.clone(), last.clone()), None, "{first} {last}"); + } + assert!(name(json!("A".repeat(MAX_NAME_PART_CHARS)), json!(null)).is_some()); +} + +#[test] +fn aliases_are_trimmed_bounded_and_printable() { + assert_eq!( + normalize_alias(" Maya (design) ").as_deref(), + Some("Maya (design)") + ); + assert_eq!( + normalize_alias(&"x".repeat(MAX_ALIAS_CHARS)) + .as_deref() + .map(str::len), + Some(MAX_ALIAS_CHARS) + ); + for rejected in [ + String::new(), + " ".to_string(), + "x".repeat(MAX_ALIAS_CHARS + 1), + "tab\there".to_string(), + "bell\u{7}".to_string(), + "\u{2066}hidden".to_string(), + ] { + assert_eq!(normalize_alias(&rejected), None, "{rejected:?}"); + } + // Multi-byte characters count as characters, within the byte cap. + assert!(normalize_alias(&"👩🏽‍💻".repeat(9)).is_some()); +} + +#[test] +fn public_keys_canonicalize_from_npub_or_hex_and_must_be_curve_points() { + assert_eq!(canonical_public_key(NPUB).as_deref(), Some(HEX)); + assert_eq!(canonical_public_key(HEX).as_deref(), Some(HEX)); + assert_eq!( + canonical_public_key(&HEX.to_uppercase()).as_deref(), + Some(HEX) + ); + for rejected in [ + String::new(), + "f".repeat(64), + HEX[..62].to_string(), + format!("{HEX}00"), + NPUB.to_uppercase(), + NPUB.replacen("npub", "nsec", 1), + NPUB[..NPUB.len() - 1].to_string(), + "npub1".to_string(), + ] { + assert_eq!(canonical_public_key(&rejected), None, "{rejected}"); + } +} diff --git a/finitecomputer-v2/crates/finite-saas-core/src/api.rs b/finitecomputer-v2/crates/finite-saas-core/src/api.rs index e78299cc8..a5b812ff2 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/api.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/api.rs @@ -76,6 +76,7 @@ mod authorization; use authorization::*; mod brain_identity; pub use brain_identity::brain_identity_router; +mod key_aliases; #[cfg(test)] mod tests; @@ -353,6 +354,20 @@ fn router_from_state(state: CoreApiState) -> Router { "/api/core/v1/admin/finite-private/grants/{grant_id}/limit-profile", post(admin_assign_finite_private_limit_profile), ) + .route( + key_aliases::LOOKUP_PATH, + post(key_aliases::lookup).layer(axum::extract::DefaultBodyLimit::max( + crate::account_labels::MAX_ALIAS_LOOKUP_BODY_BYTES, + )), + ) + .route( + key_aliases::ALIAS_PATH, + axum::routing::put(key_aliases::set) + .delete(key_aliases::clear) + .layer(axum::extract::DefaultBodyLimit::max( + crate::account_labels::MAX_ALIAS_WRITE_BODY_BYTES, + )), + ) .route("/api/core/v1/me", get(me)) .route("/api/core/v1/me/dashboard-summary", get(dashboard_summary)) .route( diff --git a/finitecomputer-v2/crates/finite-saas-core/src/api/identity.rs b/finitecomputer-v2/crates/finite-saas-core/src/api/identity.rs index 8cc35337a..6b93f6207 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/api/identity.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/api/identity.rs @@ -55,11 +55,14 @@ pub(super) async fn link_verified_identity( ) -> Result<(), ApiError> { state .store - .link_verified_user(LinkVerifiedUserInput { - verified_email: identity.email.clone(), - workos_user_id: identity.workos_user_id.clone(), - now: None, - }) + .link_verified_user_with_name( + LinkVerifiedUserInput { + verified_email: identity.email.clone(), + workos_user_id: identity.workos_user_id.clone(), + now: None, + }, + identity.display_name.as_deref(), + ) .await?; Ok(()) } @@ -104,6 +107,8 @@ pub(super) async fn projects( pub(super) struct VerifiedIdentity { pub(super) email: String, pub(super) workos_user_id: String, + /// WorkOS name from the user record this request already fetched. + pub(super) display_name: Option, workos_organization_id: Option, } @@ -143,6 +148,7 @@ pub(super) async fn require_verified_identity( Ok(VerifiedIdentity { email, workos_user_id: session.subject, + display_name: user.display_name, workos_organization_id: session.organization_id, }) } diff --git a/finitecomputer-v2/crates/finite-saas-core/src/api/key_aliases.rs b/finitecomputer-v2/crates/finite-saas-core/src/api/key_aliases.rs new file mode 100644 index 000000000..a280c4eb2 --- /dev/null +++ b/finitecomputer-v2/crates/finite-saas-core/src/api/key_aliases.rs @@ -0,0 +1,88 @@ +//! Private key aliases for the signed-in account. The viewer is always the +//! verified WorkOS account; no route accepts an owner, account or email. + +use super::*; +use crate::account_labels::{ + KeyAlias, KeyAliasLookupRequest, KeyAliasesResponse, MAX_ALIAS_LOOKUP_KEYS, SetKeyAliasRequest, + canonical_public_key, normalize_alias, +}; +use crate::store::KeyAliasError; +use axum::body::Bytes; + +pub(super) const LOOKUP_PATH: &str = "/api/core/v1/me/key-aliases/lookup"; +pub(super) const ALIAS_PATH: &str = "/api/core/v1/me/key-aliases/{public_key}"; + +fn alias_error(error: KeyAliasError) -> ApiError { + match error { + KeyAliasError::AccountNotLinked => ApiError::forbidden("account_not_linked"), + KeyAliasError::LimitReached => ApiError::conflict("alias_limit_reached"), + KeyAliasError::Store(error) => error.into(), + } +} + +fn key_from_path(value: &str) -> Result { + canonical_public_key(value).ok_or_else(|| ApiError::bad_request("invalid public key")) +} + +pub(super) async fn lookup( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let identity = require_verified_identity(&state, &headers).await?; + let request: KeyAliasLookupRequest = serde_json::from_slice(&body) + .map_err(|_| ApiError::bad_request("invalid alias lookup body"))?; + if request.public_keys.is_empty() || request.public_keys.len() > MAX_ALIAS_LOOKUP_KEYS { + return Err(ApiError::bad_request("publicKeys must contain 1-100 keys")); + } + let mut keys = request + .public_keys + .iter() + .map(|key| canonical_public_key(key)) + .collect::>>() + .ok_or_else(|| ApiError::bad_request("invalid public key"))?; + keys.sort(); + keys.dedup(); + let aliases = state + .store + .key_aliases_for_viewer(&identity.workos_user_id, &keys) + .await?; + Ok(Json(KeyAliasesResponse { aliases })) +} + +pub(super) async fn set( + State(state): State, + headers: HeaderMap, + Path(public_key): Path, + body: Bytes, +) -> Result, ApiError> { + let identity = require_verified_identity(&state, &headers).await?; + let key = key_from_path(&public_key)?; + let request: SetKeyAliasRequest = + serde_json::from_slice(&body).map_err(|_| ApiError::bad_request("invalid alias body"))?; + let alias = normalize_alias(&request.alias).ok_or_else(|| { + ApiError::bad_request("alias must be 1-64 characters without control characters") + })?; + let now = crate::current_time_iso()?; + let saved = state + .store + .set_key_alias(&identity.workos_user_id, &key, &alias, &now) + .await + .map_err(alias_error)?; + Ok(Json(saved)) +} + +pub(super) async fn clear( + State(state): State, + headers: HeaderMap, + Path(public_key): Path, +) -> Result { + let identity = require_verified_identity(&state, &headers).await?; + let key = key_from_path(&public_key)?; + state + .store + .clear_key_alias(&identity.workos_user_id, &key) + .await + .map_err(alias_error)?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/finitecomputer-v2/crates/finite-saas-core/src/api/runtime_routes.rs b/finitecomputer-v2/crates/finite-saas-core/src/api/runtime_routes.rs index 71e679938..6882ebc0a 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/api/runtime_routes.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/api/runtime_routes.rs @@ -107,14 +107,7 @@ pub(super) async fn resolve_runtime_route( Path(identifier): Path, ) -> Result, ApiError> { let identity = require_verified_identity(&state, &headers).await?; - state - .store - .link_verified_user(LinkVerifiedUserInput { - verified_email: identity.email, - workos_user_id: identity.workos_user_id.clone(), - now: None, - }) - .await?; + link_verified_identity(&state, &identity).await?; let identifier = identifier.trim(); let resolution = state .store diff --git a/finitecomputer-v2/crates/finite-saas-core/src/api/tests.rs b/finitecomputer-v2/crates/finite-saas-core/src/api/tests.rs index d4f67acd2..ab80b2e20 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/api/tests.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/api/tests.rs @@ -11,6 +11,7 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::ServiceExt; mod account_email_change; +mod account_names; mod admin_authorization; mod agent_creation; mod agent_creation_control; @@ -19,6 +20,8 @@ mod finite_private_keys; mod finite_private_usage; mod hosted_access; mod identity_authorization; +mod key_alias_browser; +mod key_aliases; mod launch_code_tiers; mod public_contracts; mod relocation_credential; diff --git a/finitecomputer-v2/crates/finite-saas-core/src/api/tests/account_names.rs b/finitecomputer-v2/crates/finite-saas-core/src/api/tests/account_names.rs new file mode 100644 index 000000000..e773815df --- /dev/null +++ b/finitecomputer-v2/crates/finite-saas-core/src/api/tests/account_names.rs @@ -0,0 +1,227 @@ +//! WorkOS account names: captured from the verified user Core already +//! fetches, cleared when WorkOS drops them, and released only on human +//! descriptions Core already releases. Aliases never reach descriptions. + +use super::brain_identity::{ + DESCRIPTION_TOKEN, OBSERVATION_TOKEN, description_body, identity_app, key, observation_body, + post, +}; +use super::*; +use crate::auth::test_support::access_token_with_profile; +use crate::brain_identity::{ + DESCRIPTION_CREDENTIAL_HEADER, DESCRIPTIONS_PATH, DESCRIPTIONS_VERSION_V2, + OBSERVATION_CREDENTIAL_HEADER, OBSERVATION_PATH, +}; +use serde_json::{Value, json}; + +const SUBJECT: &str = "user_named_account"; +const EMAIL: &str = "ada@acme.example"; + +fn signed_in(first: Option, last: Option) -> String { + format!( + "Bearer {}", + access_token_with_profile(SUBJECT, EMAIL, first, last) + ) +} + +async fn me(app: &Router, bearer: &str) -> StatusCode { + let headers = [("authorization".to_string(), bearer.to_string())]; + send_json(app, "GET", "/api/core/v1/me", &headers, None) + .await + .0 +} + +async fn stored_name(db: &crate::test_support::TestDb) -> Value { + db.query_json( + "SELECT COALESCE(to_jsonb(workos_display_name), 'null'::jsonb) FROM users WHERE normalized_email = $1", + &[&EMAIL], + ) + .await + .into_iter() + .next() + .unwrap_or(Value::Null) +} + +async fn describe(app: &Router, version: &str, brain_id: &str) -> Value { + let mut body = description_body(&[key(1), key(2)]); + body["version"] = json!(version); + body["brainId"] = json!(brain_id); + let describe = [(DESCRIPTION_CREDENTIAL_HEADER, DESCRIPTION_TOKEN.to_string())]; + let (status, response) = post( + app, + DESCRIPTIONS_PATH, + &describe, + serde_json::to_vec(&body).unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response}"); + response +} + +#[tokio::test] +async fn workos_names_follow_the_verified_user_and_reach_human_descriptions() { + with_isolated_postgres(|db| async move { + let account = router(db.store.clone(), test_auth()); + let identity = identity_app(db.store.clone()); + + let bearer = signed_in(Some(json!(" Ada ")), Some(json!("Lovelace"))); + assert_eq!(me(&account, &bearer).await, StatusCode::OK); + assert_eq!(stored_name(&db).await, json!("Ada Lovelace")); + + // Ada's hosted key acts in brain_alpha; Bob has an email but no name. + let observe = [ + (OBSERVATION_CREDENTIAL_HEADER, OBSERVATION_TOKEN.to_string()), + ("authorization", bearer.clone()), + ]; + let body = serde_json::to_vec(&observation_body("ada", &key(1))).unwrap(); + let (status, outcome) = post(&identity, OBSERVATION_PATH, &observe, body).await; + assert_eq!(status, StatusCode::OK, "{outcome}"); + let bob = workos_headers("bob@acme.example", true, None); + assert_eq!( + send_json(&account, "GET", "/api/core/v1/me", &bob, None) + .await + .0, + StatusCode::OK + ); + let bob_observe = [ + (OBSERVATION_CREDENTIAL_HEADER, OBSERVATION_TOKEN.to_string()), + ("authorization", bob[0].1.clone()), + ]; + let body = serde_json::to_vec(&observation_body("bob", &key(2))).unwrap(); + assert_eq!( + post(&identity, OBSERVATION_PATH, &bob_observe, body) + .await + .0, + StatusCode::OK + ); + + // An alias Bob keeps for Ada's key is private and never described. + let (status, _) = send_json( + &account, + "PUT", + &format!( + "/api/core/v1/me/key-aliases/{}", + super::key_aliases::valid_key(9) + ), + &bob, + Some(json!({"alias": "Not Ada"})), + ) + .await; + assert_eq!(status, StatusCode::OK); + + for version in [ + crate::brain_identity::DESCRIPTIONS_VERSION, + DESCRIPTIONS_VERSION_V2, + ] { + let response = describe(&identity, version, "brain_alpha").await; + let ada = &response["results"][0]; + assert_eq!(ada["state"], "resolved", "{response}"); + assert_eq!(ada["kind"], "human"); + assert_eq!(ada["displayName"], "Ada Lovelace"); + assert_eq!(ada["accountEmail"], EMAIL); + assert_eq!(ada["source"]["kind"], "hostedDeviceObservation"); + let bob = &response["results"][1]; + assert_eq!(bob["accountEmail"], "bob@acme.example"); + assert!(bob.get("displayName").is_none(), "{bob}"); + } + // The v1 sharing gate withholds the name with the email. + let other_brain = describe( + &identity, + crate::brain_identity::DESCRIPTIONS_VERSION, + "brain_beta", + ) + .await; + assert_eq!( + other_brain["results"][0], + json!({"publicKeyHex": key(1), "state": "notShared"}) + ); + + // A WorkOS rename replaces the name on the next verified request. + assert_eq!( + me( + &account, + &signed_in(Some(json!("Ada")), Some(json!("King"))) + ) + .await, + StatusCode::OK + ); + assert_eq!(stored_name(&db).await, json!("Ada King")); + let response = describe(&identity, DESCRIPTIONS_VERSION_V2, "brain_alpha").await; + assert_eq!(response["results"][0]["displayName"], "Ada King"); + + // Malformed or removed names never block sign-in; they clear the + // stored name, and the description falls back to email only. + for (first, last) in [ + (Some(json!({"given": "Ada"})), Some(json!("King"))), + (Some(json!("A".repeat(500))), None), + (Some(json!("Ada\u{0}")), None), + (None, None), + (Some(json!(" ")), Some(Value::Null)), + ] { + assert_eq!( + me( + &account, + &signed_in(Some(json!("Ada")), Some(json!("King"))) + ) + .await, + StatusCode::OK + ); + assert_eq!(stored_name(&db).await, json!("Ada King")); + assert_eq!( + me(&account, &signed_in(first.clone(), last.clone())).await, + StatusCode::OK + ); + assert_eq!(stored_name(&db).await, Value::Null, "{first:?} {last:?}"); + } + let response = describe(&identity, DESCRIPTIONS_VERSION_V2, "brain_alpha").await; + let ada = &response["results"][0]; + assert_eq!(ada["state"], "resolved"); + assert_eq!(ada["accountEmail"], EMAIL); + assert!(ada.get("displayName").is_none(), "{ada}"); + }) + .await; +} + +#[tokio::test] +async fn unchanged_names_are_not_rewritten() { + // Its own WorkOS subject: the test registry is shared across tests. + const SUBJECT: &str = "user_named_unchanged"; + const EMAIL: &str = "grace@acme.example"; + with_isolated_postgres(|db| async move { + let account = router(db.store.clone(), test_auth()); + let bearer = format!( + "Bearer {}", + access_token_with_profile(SUBJECT, EMAIL, Some(json!("Grace")), Some(json!("Hopper"))) + ); + assert_eq!(me(&account, &bearer).await, StatusCode::OK); + let read = |column: &'static str| { + let db = &db; + async move { + db.query_json( + &format!("SELECT to_jsonb({column}) FROM users WHERE normalized_email = $1"), + &[&EMAIL], + ) + .await + } + }; + let first = read("workos_display_name_updated_at").await; + assert_ne!(first, vec![Value::Null]); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert_eq!(me(&account, &bearer).await, StatusCode::OK); + assert_eq!(read("workos_display_name_updated_at").await, first); + // A link that carries no name observation leaves the name alone. + db.store + .link_verified_user(LinkVerifiedUserInput { + verified_email: EMAIL.to_string(), + workos_user_id: SUBJECT.to_string(), + now: None, + }) + .await + .unwrap(); + assert_eq!( + read("workos_display_name").await, + vec![json!("Grace Hopper")] + ); + }) + .await; +} diff --git a/finitecomputer-v2/crates/finite-saas-core/src/api/tests/brain_identity.rs b/finitecomputer-v2/crates/finite-saas-core/src/api/tests/brain_identity.rs index 362a92215..f1a1abfbf 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/api/tests/brain_identity.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/api/tests/brain_identity.rs @@ -8,9 +8,9 @@ use crate::brain_identity::{ OBSERVATION_CREDENTIAL_HEADER, OBSERVATION_PATH, OBSERVATION_VERSION, }; -const SERVER: &str = "https://brain.test"; -const OBSERVATION_TOKEN: &str = "brain-observation-test-token"; -const DESCRIPTION_TOKEN: &str = "brain-description-test-token"; +pub(super) const SERVER: &str = "https://brain.test"; +pub(super) const OBSERVATION_TOKEN: &str = "brain-observation-test-token"; +pub(super) const DESCRIPTION_TOKEN: &str = "brain-description-test-token"; fn config(auth: &CoreAuth) -> BrainIdentityConfig { BrainIdentityConfig::from_values( @@ -24,17 +24,17 @@ fn config(auth: &CoreAuth) -> BrainIdentityConfig { .unwrap() } -fn identity_app(store: CoreStore) -> Router { +pub(super) fn identity_app(store: CoreStore) -> Router { let auth = scoped_test_auth(); let config = config(&auth); brain_identity_router(store, auth, config) } -fn key(n: u8) -> String { +pub(super) fn key(n: u8) -> String { format!("{n:02x}").repeat(32) } -fn observation_body(operation: &str, human: &str) -> serde_json::Value { +pub(super) fn observation_body(operation: &str, human: &str) -> serde_json::Value { serde_json::json!({ "version": OBSERVATION_VERSION, "operationId": format!("operation_{operation}_0000"), @@ -47,7 +47,7 @@ fn observation_body(operation: &str, human: &str) -> serde_json::Value { }) } -fn description_body(keys: &[String]) -> serde_json::Value { +pub(super) fn description_body(keys: &[String]) -> serde_json::Value { serde_json::json!({ "version": DESCRIPTIONS_VERSION, "brainServer": SERVER, @@ -57,7 +57,7 @@ fn description_body(keys: &[String]) -> serde_json::Value { }) } -async fn post( +pub(super) async fn post( app: &Router, path: &str, headers: &[(&str, String)], diff --git a/finitecomputer-v2/crates/finite-saas-core/src/api/tests/key_alias_browser.rs b/finitecomputer-v2/crates/finite-saas-core/src/api/tests/key_alias_browser.rs new file mode 100644 index 000000000..6ee1fb9f8 --- /dev/null +++ b/finitecomputer-v2/crates/finite-saas-core/src/api/tests/key_alias_browser.rs @@ -0,0 +1,77 @@ +//! Local browser composition: real dashboard routes, Core, signed WorkOS +//! fixture requests and disposable Postgres. The unrelated Agent/Brain roster +//! transport is a fixture; its names come from Core's actual description API. +use super::*; +use crate::auth::test_support::access_token_with_profile; +use crate::store::runtime_credentials::tests::{complete, register, requested}; +use tokio::io::AsyncWriteExt; + +#[tokio::test] +#[ignore = "requires built dashboard, locked pnpm dependencies and Chromium"] +async fn key_alias_browser_composition() { + with_isolated_postgres(|db| async move { + let creation = requested(&db).await; + let runtime = register(&db, &creation).await; + complete(&db, &creation).await.unwrap(); + let owner = access_token_with_profile( + "runtime-auth-user", + "runtime-auth@finite.test", + Some(serde_json::json!("Ada")), + Some(serde_json::json!("Lovelace")), + ); + let other = + access_token_with_subject("alias-browser-other", "other@finite.test", true, None); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let core_url = format!("http://{}", listener.local_addr().unwrap()); + let app = router(db.store.clone(), test_auth()); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let identity_url = format!("http://{}", listener.local_addr().unwrap()); + let app = brain_identity::identity_app(db.store.clone()); + let identity_server = + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let keys: Vec<_> = [21, 22, 23, 24, 25, 26] + .into_iter() + .map(key_aliases::valid_key) + .collect(); + let npubs: Vec<_> = keys + .iter() + .map(|key| crate::brain_identity::npub_for_hex(key).unwrap()) + .collect(); + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.."); + let mut child = tokio::process::Command::new("node") + .args([ + "--import", + "tsx", + "scripts/brain-key-alias-browser-proof.ts", + ]) + .current_dir(root.join("finitecomputer-v2/apps/dashboard")) + .stdin(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let config = serde_json::json!({ + "coreUrl":core_url, "identityUrl":identity_url, "runtimeId":runtime, + "owner":owner, "other":other, "keys":keys, "npubs":npubs, + "observationToken":brain_identity::OBSERVATION_TOKEN, + "descriptionToken":brain_identity::DESCRIPTION_TOKEN, + }); + child + .stdin + .take() + .unwrap() + .write_all(config.to_string().as_bytes()) + .await + .unwrap(); + let result = tokio::time::timeout(std::time::Duration::from_secs(180), child.wait()).await; + server.abort(); + identity_server.abort(); + assert!( + result + .expect("browser proof exceeded deadline") + .unwrap() + .success() + ); + }) + .await; +} diff --git a/finitecomputer-v2/crates/finite-saas-core/src/api/tests/key_aliases.rs b/finitecomputer-v2/crates/finite-saas-core/src/api/tests/key_aliases.rs new file mode 100644 index 000000000..dc21abfee --- /dev/null +++ b/finitecomputer-v2/crates/finite-saas-core/src/api/tests/key_aliases.rs @@ -0,0 +1,251 @@ +//! Private key aliases: viewer isolation, strict inputs, upsert, clear and +//! the per-account bound, over HTTP against real Postgres. + +use super::*; +use crate::account_labels::MAX_ALIASES_PER_ACCOUNT; +use crate::brain_identity::npub_for_hex; + +pub(super) fn valid_key(seed: u8) -> String { + let secp = secp256k1::Secp256k1::new(); + let secret = secp256k1::SecretKey::from_slice(&[seed; 32]).unwrap(); + let (key, _) = secret.x_only_public_key(&secp); + key.serialize().iter().map(|b| format!("{b:02x}")).collect() +} + +fn viewer(email: &str) -> Vec<(String, String)> { + workos_headers(email, true, None) +} + +async fn enroll(app: &Router, email: &str) { + let (status, _) = send_json(app, "GET", "/api/core/v1/me", &viewer(email), None).await; + assert_eq!(status, StatusCode::OK); +} + +async fn lookup(app: &Router, email: &str, keys: &[String]) -> (StatusCode, serde_json::Value) { + send_json( + app, + "POST", + "/api/core/v1/me/key-aliases/lookup", + &viewer(email), + Some(serde_json::json!({ "publicKeys": keys })), + ) + .await +} + +async fn put(app: &Router, email: &str, key: &str, alias: &str) -> (StatusCode, serde_json::Value) { + send_json( + app, + "PUT", + &format!("/api/core/v1/me/key-aliases/{key}"), + &viewer(email), + Some(serde_json::json!({ "alias": alias })), + ) + .await +} + +#[tokio::test] +async fn aliases_are_private_to_the_verified_viewer() { + with_isolated_postgres(|db| async move { + let app = router(db.store.clone(), test_auth()); + let maya = valid_key(1); + let maya_npub = npub_for_hex(&maya).unwrap(); + + // No linked account: nothing to read, and writes enroll nobody. + let (status, empty) = lookup(&app, "ana@example.org", std::slice::from_ref(&maya)).await; + assert_eq!( + (status, empty), + (StatusCode::OK, serde_json::json!({"aliases": []})) + ); + let (status, refused) = put(&app, "ana@example.org", &maya, "Maya").await; + assert_eq!(status, StatusCode::FORBIDDEN, "{refused}"); + assert!(db.all("users").await.is_empty()); + + enroll(&app, "ana@example.org").await; + enroll(&app, "ben@example.org").await; + let (status, saved) = put(&app, "ana@example.org", &maya_npub, " Maya (design) ").await; + assert_eq!(status, StatusCode::OK, "{saved}"); + assert_eq!(saved["alias"], "Maya (design)"); + assert_eq!(saved["publicKeyHex"], maya); + assert_eq!(saved["npub"], maya_npub); + + // Same key by hex: the conflict branch replaces the one row. + let (status, replaced) = + put(&app, "ana@example.org", &maya.to_uppercase(), "Maya R.").await; + assert_eq!(status, StatusCode::OK, "{replaced}"); + assert_eq!(replaced["alias"], "Maya R."); + let rows = db + .query_json("SELECT to_jsonb(a) FROM account_key_aliases a", &[]) + .await; + assert_eq!(rows.len(), 1); + + // Ben sees nothing of Ana's, and his own alias is separate. + let (_, bens) = lookup(&app, "ben@example.org", std::slice::from_ref(&maya_npub)).await; + assert_eq!(bens, serde_json::json!({"aliases": []})); + let (status, _) = put(&app, "ben@example.org", &maya, "Designer").await; + assert_eq!(status, StatusCode::OK); + let (status, _) = send_json( + &app, + "DELETE", + &format!("/api/core/v1/me/key-aliases/{maya}"), + &viewer("ben@example.org"), + None, + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // Ana's survives Ben's clear, a fresh connection pool and reapplying + // startup migrations, including the new alias schema. + let reopened = CoreStore::connect(&db.url).await.unwrap(); + reopened.migrate().await.unwrap(); + let restarted = router(reopened, test_auth()); + let other = valid_key(2); + let (status, anas) = + lookup(&restarted, "ana@example.org", &[other, maya_npub.clone()]).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(anas["aliases"].as_array().unwrap().len(), 1); + assert_eq!(anas["aliases"][0]["alias"], "Maya R."); + + // Clear returns to no alias; clearing again is harmless. + for _ in 0..2 { + let (status, _) = send_json( + &restarted, + "DELETE", + &format!("/api/core/v1/me/key-aliases/{maya_npub}"), + &viewer("ana@example.org"), + None, + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + } + let (_, cleared) = lookup(&restarted, "ana@example.org", &[maya]).await; + assert_eq!(cleared, serde_json::json!({"aliases": []})); + }) + .await; +} + +#[tokio::test] +async fn alias_inputs_are_strict_and_owner_context_cannot_be_forged() { + with_isolated_postgres(|db| async move { + let app = router(db.store.clone(), test_auth()); + enroll(&app, "ana@example.org").await; + let key = valid_key(3); + let path = format!("/api/core/v1/me/key-aliases/{key}"); + + for alias in [ + String::new(), + " ".to_string(), + "x".repeat(65), + "line\nbreak".to_string(), + "nul\u{0}".to_string(), + "\u{202E}reversed".to_string(), + ] { + let (status, _) = put(&app, "ana@example.org", &key, &alias).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{alias:?}"); + } + for bad_key in ["npub1short", &"f".repeat(64), "not-a-key", &key[..62]] { + let (status, _) = put(&app, "ana@example.org", bad_key, "Maya").await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{bad_key}"); + let (status, _) = lookup(&app, "ana@example.org", &[bad_key.to_string()]).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{bad_key}"); + } + let (status, _) = lookup(&app, "ana@example.org", &[]).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let many = (1..=101).map(valid_key).collect::>(); + let (status, _) = lookup(&app, "ana@example.org", &many).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + // The body cannot name another viewer, and identity headers or a + // missing bearer are refused before any write. + let (status, _) = send_json( + &app, + "PUT", + &path, + &viewer("ana@example.org"), + Some(serde_json::json!({"alias": "Maya", "viewerEmail": "ben@example.org"})), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let mut forged = viewer("ana@example.org"); + forged.push(( + WORKOS_EMAIL_HEADER.to_string(), + "ben@example.org".to_string(), + )); + let (status, _) = send_json( + &app, + "PUT", + &path, + &forged, + Some(serde_json::json!({"alias": "Maya"})), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let (status, _) = send_json( + &app, + "PUT", + &path, + &[], + Some(serde_json::json!({"alias": "Maya"})), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let service = [("authorization".to_string(), format!("Bearer {TOKEN}"))]; + let (status, _) = send_json( + &app, + "PUT", + &path, + &service, + Some(serde_json::json!({"alias": "Maya"})), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let (status, _) = send_json( + &app, + "PUT", + &path, + &viewer("ana@example.org"), + Some(serde_json::json!({"alias": "x".repeat(4096)})), + ) + .await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + assert!( + db.query_json("SELECT to_jsonb(a) FROM account_key_aliases a", &[]) + .await + .is_empty() + ); + }) + .await; +} + +#[tokio::test] +async fn one_account_keeps_a_bounded_number_of_aliases() { + with_isolated_postgres(|db| async move { + let app = router(db.store.clone(), test_auth()); + enroll(&app, "ana@example.org").await; + // Fill to one below the limit directly (synthetic keys), then reach + // it through the route. + db.query_json( + "WITH filled AS ( + INSERT INTO account_key_aliases (viewer_user_id, public_key_hex, alias, created_at, updated_at) + SELECT users.id, lpad(to_hex(n), 64, '0'), 'Alias ' || n, NOW(), NOW() + FROM users, generate_series(1, $1::bigint - 1) AS n + RETURNING 1) + SELECT to_jsonb(count(*)) FROM filled", + &[&MAX_ALIASES_PER_ACCOUNT], + ) + .await; + let (status, _) = put(&app, "ana@example.org", &valid_key(5), "Last one").await; + assert_eq!(status, StatusCode::OK); + let (status, refused) = put(&app, "ana@example.org", &valid_key(4), "One more").await; + assert_eq!(status, StatusCode::CONFLICT, "{refused}"); + assert_eq!(refused["error"], "alias_limit_reached"); + // Renaming an existing alias is still allowed at the limit. + let (status, renamed) = put(&app, "ana@example.org", &valid_key(5), "Renamed").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(renamed["alias"], "Renamed"); + let count = db + .query_json("SELECT to_jsonb(count(*)) FROM account_key_aliases", &[]) + .await; + assert_eq!(count, vec![serde_json::json!(MAX_ALIASES_PER_ACCOUNT)]); + }) + .await; +} diff --git a/finitecomputer-v2/crates/finite-saas-core/src/auth.rs b/finitecomputer-v2/crates/finite-saas-core/src/auth.rs index 7f9a27d51..d93d5cee8 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/auth.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/auth.rs @@ -426,6 +426,10 @@ impl WorkosAuthenticator { return Err(WorkosAuthError::InvalidUser); } Ok(VerifiedWorkosUser { + display_name: crate::account_labels::workos_display_name( + user.first_name.as_ref(), + user.last_name.as_ref(), + ), id: user.id, email: email.to_string(), }) @@ -582,6 +586,9 @@ pub struct VerifiedWorkosSession { pub struct VerifiedWorkosUser { pub id: String, pub email: String, + /// Normalized WorkOS first and last name; `None` when WorkOS has no + /// usable name. Never required for sign-in. + pub display_name: Option, } #[derive(Debug, Deserialize)] @@ -602,6 +609,12 @@ struct WorkosUser { id: String, email: String, email_verified: bool, + // Optional and untrusted in shape: any JSON value is accepted here so a + // malformed name can never fail sign-in. See `workos_display_name`. + #[serde(default)] + first_name: Option, + #[serde(default)] + last_name: Option, } #[derive(Debug, Clone, Deserialize)] diff --git a/finitecomputer-v2/crates/finite-saas-core/src/auth/test_support.rs b/finitecomputer-v2/crates/finite-saas-core/src/auth/test_support.rs index 2a4aec87f..5674d0fea 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/auth/test_support.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/auth/test_support.rs @@ -242,11 +242,38 @@ pub(crate) fn access_token_with_subject( id: subject.to_string(), email: email.to_string(), email_verified, + first_name: None, + last_name: None, }, ); encode_claims(TestClaims::valid(subject, organization_id), key()) } +/// A verified test user whose WorkOS record carries the given raw name +/// fields, which may be any JSON value (including malformed ones). +pub(crate) fn access_token_with_profile( + subject: &str, + email: &str, + first_name: Option, + last_name: Option, +) -> String { + source() + .users + .write() + .expect("test WorkOS user registry should not be poisoned") + .insert( + subject.to_string(), + WorkosUser { + id: subject.to_string(), + email: email.to_string(), + email_verified: true, + first_name, + last_name, + }, + ); + encode_claims(TestClaims::valid(subject, None), key()) +} + fn test_subject(email: &str, verified: bool) -> String { let digest = Sha256::digest(email.trim().to_ascii_lowercase().as_bytes()); let fingerprint = digest[..8] @@ -323,6 +350,8 @@ pub(crate) fn register_user_record( id: record_id.to_string(), email: email.to_string(), email_verified, + first_name: None, + last_name: None, }, ); } diff --git a/finitecomputer-v2/crates/finite-saas-core/src/auth/tests.rs b/finitecomputer-v2/crates/finite-saas-core/src/auth/tests.rs index 330339d3b..9e253cc36 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/auth/tests.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/auth/tests.rs @@ -185,6 +185,62 @@ async fn user_lookup_fails_closed_for_unknown_or_mismatched_records() { ); } +/// The WorkOS User Management body Core already fetches. Names are optional +/// and their shape is untrusted: no name value may fail a valid sign-in. +#[tokio::test] +async fn workos_user_names_are_optional_and_never_fail_sign_in() { + let parse = |body: serde_json::Value| serde_json::from_value::(body).unwrap(); + let base = serde_json::json!({ + "object": "user", "id": "user_named", "email": "ada@example.org", + "email_verified": true, "profile_picture_url": null, + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z" + }); + let with = |first: serde_json::Value, last: serde_json::Value| { + let mut body = base.clone(); + body["first_name"] = first; + body["last_name"] = last; + parse(body) + }; + let display = |user: WorkosUser| { + crate::account_labels::workos_display_name( + user.first_name.as_ref(), + user.last_name.as_ref(), + ) + }; + assert_eq!(display(parse(base.clone())), None); + assert_eq!( + display(with( + serde_json::json!("Ada"), + serde_json::json!("Lovelace") + )) + .as_deref(), + Some("Ada Lovelace") + ); + assert_eq!( + display(with(serde_json::json!(7), serde_json::json!(["x"]))), + None + ); + assert_eq!( + display(with( + serde_json::json!("x".repeat(5_000)), + serde_json::json!(null) + )), + None + ); + + let token = test_support::access_token_with_profile( + "user_named_malformed", + "named@example.org", + Some(serde_json::json!({"given": "Ada"})), + Some(serde_json::json!("Lovelace")), + ); + let authenticator = authenticator(); + let session = authenticator.verify_access_token(&token).await.unwrap(); + let user = authenticator.verified_user(&session.subject).await.unwrap(); + assert_eq!(user.email, "named@example.org"); + assert_eq!(user.display_name, None); +} + #[test] fn service_route_credentials_must_be_distinct() { let result = CoreAuth::new(authenticator(), "same", "same", "usage"); diff --git a/finitecomputer-v2/crates/finite-saas-core/src/lib.rs b/finitecomputer-v2/crates/finite-saas-core/src/lib.rs index dfdb27792..526eb71b8 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/lib.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/lib.rs @@ -1,5 +1,6 @@ //! Core domain contracts and the stable crate-root facade. +pub mod account_labels; pub mod api; pub mod auth; pub mod billing; diff --git a/finitecomputer-v2/crates/finite-saas-core/src/schema.rs b/finitecomputer-v2/crates/finite-saas-core/src/schema.rs index 06a40842a..faf9813a6 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/schema.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/schema.rs @@ -83,7 +83,9 @@ pub const CORE_SCHEMA_SQL: &str = concat!( "\n", include_str!("../migrations/0041_trial_campaign_codes.sql"), "\n", - include_str!("../migrations/0042_brain_scoped_records.sql") + include_str!("../migrations/0042_brain_scoped_records.sql"), + "\n", + include_str!("../migrations/0043_account_names_and_key_aliases.sql") ); pub const RUNTIME_UPGRADE_ROLLBACK_RESCUE_SQL: &str = diff --git a/finitecomputer-v2/crates/finite-saas-core/src/store.rs b/finitecomputer-v2/crates/finite-saas-core/src/store.rs index 9699a01e4..4748dddec 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/store.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/store.rs @@ -170,6 +170,8 @@ mod health; use health::*; mod identity_rows; use identity_rows::*; +mod key_aliases; +pub use key_aliases::KeyAliasError; mod launch_code_rows; use launch_code_rows::*; mod launch_codes; diff --git a/finitecomputer-v2/crates/finite-saas-core/src/store/brain_identity.rs b/finitecomputer-v2/crates/finite-saas-core/src/store/brain_identity.rs index a79553e0e..67992d49a 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/store/brain_identity.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/store/brain_identity.rs @@ -223,6 +223,8 @@ fn agent_evidence<'a>(rows: &[&'a AgentPinRow]) -> AgentEvidence<'a> { struct AccountRow { /// `None` when the stored email is over the wire bound. email: Option, + /// WorkOS name; `None` when absent or over the wire bound. + display_name: Option, linked: bool, workos_subject: Option, updated_at: String, @@ -475,12 +477,15 @@ fn describe_key( if !disclosable(&human.user_id) { return not_shared(); } - let Some(email) = bounded(accounts[&human.user_id].email.as_deref(), MAX_CONTACT_BYTES) - else { + let account = &accounts[&human.user_id]; + let Some(email) = bounded(account.email.as_deref(), MAX_CONTACT_BYTES) else { return IdentityDescription::bare(key, DescriptionState::Unknown); }; IdentityDescription { kind: Some(DescribedKind::Human), + // The account's WorkOS name, released with the email under + // the same gate. Optional: an unusable name is omitted. + display_name: bounded(account.display_name.as_deref(), MAX_DISPLAY_NAME_BYTES), account_email: Some(email), source: Some(DescriptionSource { kind: "hostedDeviceObservation".to_string(), diff --git a/finitecomputer-v2/crates/finite-saas-core/src/store/brain_identity/scoped.rs b/finitecomputer-v2/crates/finite-saas-core/src/store/brain_identity/scoped.rs index 63bfeba34..d07d12c62 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/store/brain_identity/scoped.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/store/brain_identity/scoped.rs @@ -198,6 +198,7 @@ pub(super) fn binding( return Ok(ValidBinding { description: IdentityDescription { kind: Some(DescribedKind::Human), + display_name: bounded(account.display_name.as_deref(), MAX_DISPLAY_NAME_BYTES), account_email: Some(email), ..IdentityDescription::bare(key, DescriptionState::Resolved) }, diff --git a/finitecomputer-v2/crates/finite-saas-core/src/store/brain_identity/snapshot.rs b/finitecomputer-v2/crates/finite-saas-core/src/store/brain_identity/snapshot.rs index 7c55e0080..9d13dd980 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/store/brain_identity/snapshot.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/store/brain_identity/snapshot.rs @@ -88,9 +88,15 @@ impl IdentitySnapshot { "SELECT id, CASE WHEN octet_length(normalized_email) <= $2 THEN normalized_email END AS normalized_email, + CASE WHEN octet_length(workos_display_name) <= $3 + THEN workos_display_name END AS display_name, link_status, workos_user_id, core_rfc3339(updated_at) AS updated_at FROM users WHERE id = ANY($1)", - &[&releasable_ids, &(MAX_CONTACT_BYTES as i32)], + &[ + &releasable_ids, + &(MAX_CONTACT_BYTES as i32), + &(MAX_DISPLAY_NAME_BYTES as i32), + ], ) .await .map_err(store_error)? @@ -100,6 +106,7 @@ impl IdentitySnapshot { row.get::<_, String>("id"), AccountRow { email: row.get("normalized_email"), + display_name: row.get("display_name"), linked: row.get::<_, String>("link_status") == "linked", workos_subject: row.get("workos_user_id"), updated_at: row.get("updated_at"), diff --git a/finitecomputer-v2/crates/finite-saas-core/src/store/identity_rows.rs b/finitecomputer-v2/crates/finite-saas-core/src/store/identity_rows.rs index 2adbc27d7..72a5b0517 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/store/identity_rows.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/store/identity_rows.rs @@ -1,7 +1,30 @@ use super::*; impl CoreStore { + /// Link a verified account without touching its stored WorkOS name. pub async fn link_verified_user(&self, input: LinkVerifiedUserInput) -> CoreResult { + self.link_user(input, None).await + } + + /// Link a verified account and record the WorkOS name from the same + /// verified user record. `None` clears a stored name: WorkOS is the only + /// source, so Core never keeps showing a name WorkOS no longer gives. + /// The name is written only when it changed. The existing account-link + /// behavior of `users.updated_at` is unchanged. + pub async fn link_verified_user_with_name( + &self, + input: LinkVerifiedUserInput, + display_name: Option<&str>, + ) -> CoreResult { + self.link_user(input, Some(display_name)).await + } + + #[tracing::instrument(skip_all, fields(operation = "link_verified_user", workos_user_id = %input.workos_user_id))] + async fn link_user( + &self, + input: LinkVerifiedUserInput, + display_name: Option>, + ) -> CoreResult { let now = input.now.unwrap_or(current_time_iso()?); let verified_email = normalize_owner_email(Some(&input.verified_email)) .ok_or(CoreError::MissingVerifiedEmail)?; @@ -19,6 +42,17 @@ impl CoreStore { &now, ) .await?; + if let Some(display_name) = display_name { + tx.execute( + "UPDATE users + SET workos_display_name = $2, + workos_display_name_updated_at = $3::text::timestamptz + WHERE id = $1 AND workos_display_name IS DISTINCT FROM $2", + &[&user.id, &display_name, &now], + ) + .await + .map_err(store_error)?; + } self.finish(tx).await?; Ok(user) } diff --git a/finitecomputer-v2/crates/finite-saas-core/src/store/key_aliases.rs b/finitecomputer-v2/crates/finite-saas-core/src/store/key_aliases.rs new file mode 100644 index 000000000..0a241e0e4 --- /dev/null +++ b/finitecomputer-v2/crates/finite-saas-core/src/store/key_aliases.rs @@ -0,0 +1,157 @@ +//! Private per-account key aliases. Every read and write is scoped to the one +//! verified viewer account; there is no cross-account or global read. + +use super::*; +use crate::account_labels::{KeyAlias, MAX_ALIASES_PER_ACCOUNT}; + +#[derive(Debug)] +pub enum KeyAliasError { + /// The verified WorkOS subject has no linked Core account. These routes + /// never enroll an account. + AccountNotLinked, + /// Saving a new alias would exceed `MAX_ALIASES_PER_ACCOUNT`. + LimitReached, + Store(CoreError), +} + +impl From for KeyAliasError { + fn from(error: CoreError) -> Self { + Self::Store(error) + } +} + +const LINKED_VIEWER_SQL: &str = + "SELECT id FROM users WHERE workos_user_id = $1 AND link_status = 'linked'"; + +impl CoreStore { + /// The viewer's aliases for exactly these canonical keys. Read-only; an + /// unlinked viewer simply has none. + pub async fn key_aliases_for_viewer( + &self, + workos_user_id: &str, + public_keys_hex: &[String], + ) -> CoreResult> { + let mut client = self.connection().await?; + let tx = client + .build_transaction() + .read_only(true) + .start() + .await + .map_err(store_error)?; + let rows = tx + .query( + "SELECT alias.public_key_hex, alias.alias, + core_rfc3339(alias.updated_at) AS updated_at + FROM account_key_aliases AS alias + JOIN users AS viewer ON viewer.id = alias.viewer_user_id + WHERE viewer.workos_user_id = $1 AND viewer.link_status = 'linked' + AND alias.public_key_hex = ANY($2) + ORDER BY alias.public_key_hex", + &[&workos_user_id, &public_keys_hex], + ) + .await + .map_err(store_error)?; + tx.commit().await.map_err(store_error)?; + Ok(rows.iter().map(key_alias_from_row).collect()) + } + + /// Create or replace the viewer's alias for one canonical key. Inputs are + /// already normalized by `account_labels`. + #[tracing::instrument(skip(self, alias, now), fields(operation = "set_key_alias"))] + pub async fn set_key_alias( + &self, + workos_user_id: &str, + public_key_hex: &str, + alias: &str, + now: &str, + ) -> Result { + let mut client = self.connection().await?; + let tx = client.transaction().await.map_err(store_error)?; + // Row lock on the viewer only: serializes this account's alias + // writes so the count limit holds under concurrency. + let viewer_id: String = tx + .query_opt( + format!("{LINKED_VIEWER_SQL} FOR NO KEY UPDATE").as_str(), + &[&workos_user_id], + ) + .await + .map_err(store_error)? + .ok_or(KeyAliasError::AccountNotLinked)? + .get("id"); + let existing = tx + .query_opt( + "SELECT 1 FROM account_key_aliases + WHERE viewer_user_id = $1 AND public_key_hex = $2", + &[&viewer_id, &public_key_hex], + ) + .await + .map_err(store_error)? + .is_some(); + if !existing { + let count: i64 = tx + .query_one( + "SELECT count(*) AS count FROM account_key_aliases WHERE viewer_user_id = $1", + &[&viewer_id], + ) + .await + .map_err(store_error)? + .get("count"); + if count >= MAX_ALIASES_PER_ACCOUNT { + return Err(KeyAliasError::LimitReached); + } + } + let row = tx + .query_one( + "INSERT INTO account_key_aliases + (viewer_user_id, public_key_hex, alias, created_at, updated_at) + VALUES ($1, $2, $3, $4::text::timestamptz, $4::text::timestamptz) + ON CONFLICT (viewer_user_id, public_key_hex) DO UPDATE SET + alias = EXCLUDED.alias, + updated_at = EXCLUDED.updated_at + RETURNING public_key_hex, alias, core_rfc3339(updated_at) AS updated_at", + &[&viewer_id, &public_key_hex, &alias, &now], + ) + .await + .map_err(store_error)?; + self.finish(tx).await?; + Ok(key_alias_from_row(&row)) + } + + /// Remove the viewer's alias for one key. Returns whether one existed. + #[tracing::instrument(skip(self), fields(operation = "clear_key_alias"))] + pub async fn clear_key_alias( + &self, + workos_user_id: &str, + public_key_hex: &str, + ) -> Result { + let mut client = self.connection().await?; + let tx = client.transaction().await.map_err(store_error)?; + let viewer_id: String = tx + .query_opt( + format!("{LINKED_VIEWER_SQL} FOR NO KEY UPDATE").as_str(), + &[&workos_user_id], + ) + .await + .map_err(store_error)? + .ok_or(KeyAliasError::AccountNotLinked)? + .get("id"); + let removed = tx + .execute( + "DELETE FROM account_key_aliases + WHERE viewer_user_id = $1 AND public_key_hex = $2", + &[&viewer_id, &public_key_hex], + ) + .await + .map_err(store_error)?; + self.finish(tx).await?; + Ok(removed == 1) + } +} + +fn key_alias_from_row(row: &Row) -> KeyAlias { + KeyAlias::new( + row.get("public_key_hex"), + row.get("alias"), + row.get("updated_at"), + ) +} diff --git a/finitecomputer-v2/crates/finite-saas-core/src/store/tests/brain_identity/scoped.rs b/finitecomputer-v2/crates/finite-saas-core/src/store/tests/brain_identity/scoped.rs index 8f5c004a0..cc136fea0 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/store/tests/brain_identity/scoped.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/store/tests/brain_identity/scoped.rs @@ -1,6 +1,7 @@ //! Synthetic exact-scope operations and disclosure against ephemeral PostgreSQL. use super::*; use crate::brain_identity::scoped::*; +mod account_names; mod guards; fn record( diff --git a/finitecomputer-v2/crates/finite-saas-core/src/store/tests/brain_identity/scoped/account_names.rs b/finitecomputer-v2/crates/finite-saas-core/src/store/tests/brain_identity/scoped/account_names.rs new file mode 100644 index 000000000..3e62a074a --- /dev/null +++ b/finitecomputer-v2/crates/finite-saas-core/src/store/tests/brain_identity/scoped/account_names.rs @@ -0,0 +1,46 @@ +use super::*; + +#[tokio::test] +async fn scoped_account_names_follow_account_and_keep_mapping_scope_and_provenance() { + with_isolated_postgres(|db| async move { + let sam = setup(&db).await; + let input = manifest( + "named_account", + vec![record( + 1, + ScopedRecordKind::AccountChatKey, + Some(&sam), + None, + )], + ); + run(&db, &input).await.unwrap(); + let before = db.all("brain_scoped_records").await; + let first = page(&db, BRAIN, &[1], &[]).await.remove(0); + assert_eq!(first.account_email.as_deref(), Some("sam@example.org")); + assert!(first.display_name.is_none()); + for name in [Some("Sam Rivera"), Some("Sam Renamed"), None] { + db.link_verified_user_with_name( + LinkVerifiedUserInput { + verified_email: "sam@example.org".into(), + workos_user_id: "workos_sam_example_org".into(), + now: None, + }, + name, + ) + .await + .unwrap(); + let row = page(&db, BRAIN, &[1], &[]).await.remove(0); + assert_eq!(row.state, DescriptionState::Resolved); + assert_eq!(row.display_name.as_deref(), name); + assert_eq!(row.account_email, first.account_email); + assert_eq!(row.source, first.source); + assert_eq!(row.operator_record, first.operator_record); + assert_eq!( + page(&db, "other_brain", &[1], &[]).await[0].state, + DescriptionState::NotShared + ); + } + assert_eq!(db.all("brain_scoped_records").await, before); + }) + .await; +} diff --git a/finitecomputer-v2/crates/finite-saas-core/tests/account_names_migration.rs b/finitecomputer-v2/crates/finite-saas-core/tests/account_names_migration.rs new file mode 100644 index 000000000..6aacb8f21 --- /dev/null +++ b/finitecomputer-v2/crates/finite-saas-core/tests/account_names_migration.rs @@ -0,0 +1,155 @@ +//! Upgrade from the deployed scoped-record schema without rewriting its rows. +use finite_saas_core::CORE_SCHEMA_SQL; +use futures_util::FutureExt; +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio_postgres::{Client, NoTls}; + +const NAMES_SQL: &str = include_str!("../migrations/0043_account_names_and_key_aliases.sql"); + +static TEST_DATABASE_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn replace_database(url: &str, database: &str) -> String { + let (base, query) = match url.split_once('?') { + Some((base, query)) => (base, Some(query)), + None => (url, None), + }; + let scheme_end = base.find("://").map(|index| index + 3).unwrap_or(0); + let replaced = match base[scheme_end..].find('/') { + Some(relative_index) => format!("{}/{database}", &base[..scheme_end + relative_index]), + None => format!("{base}/{database}"), + }; + match query { + Some(query) => format!("{replaced}?{query}"), + None => replaced, + } +} + +async fn with_legacy_database(test: F) +where + F: FnOnce(Client) -> Fut, + Fut: std::future::Future, +{ + let admin_url = std::env::var("FC_CORE_POSTGRES_TEST_URL") + .expect("FC_CORE_POSTGRES_TEST_URL is required for Core migration tests; run `just test`"); + + let (admin, admin_connection) = tokio_postgres::connect(&admin_url, NoTls).await.unwrap(); + let admin_connection = tokio::spawn(async move { + let _ = admin_connection.await; + }); + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let database = format!( + "fc_names_migration_{unique}_{}", + TEST_DATABASE_COUNTER.fetch_add(1, Ordering::Relaxed) + ); + admin + .execute(&format!("CREATE DATABASE \"{database}\""), &[]) + .await + .unwrap(); + + let test_url = replace_database(&admin_url, &database); + let (client, connection) = tokio_postgres::connect(&test_url, NoTls).await.unwrap(); + let connection = tokio::spawn(async move { + let _ = connection.await; + }); + let outcome = std::panic::AssertUnwindSafe(test(client)) + .catch_unwind() + .await; + + connection.abort(); + let _ = admin + .execute( + &format!("DROP DATABASE IF EXISTS \"{database}\" WITH (FORCE)"), + &[], + ) + .await; + drop(admin); + admin_connection.abort(); + + if let Err(panic) = outcome { + std::panic::resume_unwind(panic); + } +} + +async fn snapshot(client: &Client, table: &str) -> Vec { + client + .query( + &format!("SELECT to_jsonb(t) FROM {table} t ORDER BY id"), + &[], + ) + .await + .unwrap() + .iter() + .map(|row| row.get(0)) + .collect() +} + +#[tokio::test] +async fn account_names_upgrade_and_reapply_preserve_existing_scoped_mappings() { + with_legacy_database(|client| async move { + let previous = CORE_SCHEMA_SQL.strip_suffix(NAMES_SQL).expect("0043 is the last migration"); + client.batch_execute(previous).await.unwrap(); + client.batch_execute(r#" + INSERT INTO users (id, normalized_email, link_status, workos_user_id, created_at, updated_at) + VALUES ('owner', 'owner@example.test', 'linked', 'workos-owner', now(), now()), + ('viewer', 'viewer@example.test', 'linked', 'workos-viewer', now(), now()); + INSERT INTO customer_orgs (id, owner_user_id, name, billing_class, created_at, updated_at) + VALUES ('org', 'owner', 'Existing org', 'standard', now(), now()); + INSERT INTO projects (id, customer_org_id, owner_user_id, display_name, agent_email, hosting_tier, created_at, updated_at) + VALUES ('project', 'org', 'owner', 'Existing Agent', 'existing@finite.vip', 'standard', now(), now()); + INSERT INTO brain_scoped_record_operations + (id, operation_id, manifest_sha256, action, brain_server, brain_id, + operator_account_id, operator_workos_subject, operator_email, recorded_at, receipt) + VALUES ('operation-row', 'existing-operation', repeat('a',64), 'apply', 'https://brain.test', 'existing', + 'owner', 'workos-owner', 'owner@example.test', now(), '{"synthetic":"existing receipt"}'); + INSERT INTO brain_scoped_records + (id, revision, brain_server, brain_id, public_key_hex, kind, account_id, workos_subject, + project_id, annotation_label, evidence_sha256, evidence_reference, evidence_observed_at, + creating_operation_id, created_at, retired_at) + SELECT 'record-' || n, 'existing-revision-' || n, 'https://brain.test', 'existing', repeat(n::text,64), + CASE n WHEN 1 THEN 'account_chat_key' WHEN 2 THEN 'managed_agent' + WHEN 3 THEN 'historical_identity' ELSE 'invitation_key' END, + CASE WHEN n <= 2 THEN 'owner' END, CASE WHEN n <= 2 THEN 'workos-owner' END, + CASE WHEN n = 2 THEN 'project' END, CASE WHEN n >= 3 THEN 'Existing annotation' END, + repeat('b',64), 'synthetic/existing-mapping', '2026-10-01T00:00:00Z', + 'existing-operation', now(), CASE WHEN n = 4 THEN now() END + FROM generate_series(1,4) AS n; + INSERT INTO account_brain_principals + (id,user_id,public_key_hex,source,issuer,status,first_observed_at,last_observed_at,revision) + VALUES ('principal','owner',repeat('5',64),'hosted_device_observation','synthetic','active',now(),now(),1); + INSERT INTO account_brain_sharing_scopes + (id,user_id,brain_server,brain_id,issuer,established_at,revision) + VALUES ('scope','owner','https://brain.test','existing','synthetic',now(),1); + "#).await.unwrap(); + let tables = ["brain_scoped_records", "brain_scoped_record_operations", "projects", + "account_brain_principals", "account_brain_sharing_scopes"]; + let mut before = Vec::new(); + for table in tables { before.push(snapshot(&client, table).await); } + let users = snapshot(&client, "users").await; + assert!(!client.query_one("SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='workos_display_name')", &[]).await.unwrap().get::<_,bool>(0)); + client.batch_execute(NAMES_SQL).await.unwrap(); + for (mut actual, expected) in snapshot(&client, "users").await.into_iter().zip(users) { + assert!(actual.as_object_mut().unwrap().remove("workos_display_name").unwrap().is_null()); + assert!(actual.as_object_mut().unwrap().remove("workos_display_name_updated_at").unwrap().is_null()); + assert_eq!(actual, expected, "upgrade does not rewrite existing users"); + } + client.batch_execute(r#" + UPDATE users SET workos_display_name='Sam Rivera', workos_display_name_updated_at=now() WHERE id='owner'; + INSERT INTO account_key_aliases VALUES ('viewer',repeat('1',64),'My colleague',now(),now()); + "#).await.unwrap(); + let names = snapshot(&client, "users").await; + let aliases: serde_json::Value = client.query_one("SELECT jsonb_agg(t) FROM account_key_aliases t", &[]).await.unwrap().get(0); + client.batch_execute(NAMES_SQL).await.unwrap(); + client.batch_execute(CORE_SCHEMA_SQL).await.unwrap(); + for (table, expected) in tables.into_iter().zip(before) { + assert_eq!(snapshot(&client, table).await, expected, "preserve {table}"); + } + assert_eq!(snapshot(&client, "users").await, names); + assert_eq!(client.query_one("SELECT jsonb_agg(t) FROM account_key_aliases t", &[]).await.unwrap().get::<_,serde_json::Value>(0), aliases); + // An older Core writer can still create accounts after the expansion. + client.batch_execute("INSERT INTO users (id,normalized_email,link_status,workos_user_id,created_at,updated_at) VALUES ('old-writer','old@example.test','linked','workos-old',now(),now())").await.unwrap(); + assert!(client.query_one("SELECT workos_display_name IS NULL FROM users WHERE id='old-writer'", &[]).await.unwrap().get::<_,bool>(0)); + }).await; +} diff --git a/finitecomputer-v2/docs/brain-identity-descriptions-v1.md b/finitecomputer-v2/docs/brain-identity-descriptions-v1.md index 2bd52624a..e8d1a6835 100644 --- a/finitecomputer-v2/docs/brain-identity-descriptions-v1.md +++ b/finitecomputer-v2/docs/brain-identity-descriptions-v1.md @@ -15,6 +15,7 @@ an admin role, Folder entitlement or a Folder Key Grant. | Fact | Authoritative source | Meaning | | --- | --- | --- | | Account contact | Core `users.normalized_email`, linked through verified WorkOS identity | Current account email, in any domain; not perpetual mailbox verification | +| Account name | Core `users.workos_display_name`, from the verified WorkOS user record | Optional account label, disclosed under the same gate as its email; not key ownership evidence | | Agent name | Core `projects.display_name` | Hosted project's name | | Agent key | Authenticated `agent_runtimes.health_reporting_npub`, consistent active project/runtime link | Exact recorded runtime principal; not inferred from a slug | | Responsible account | Current `projects.owner_user_id` | Account responsible for the hosted agent; not proof of legal ownership or decryption | @@ -25,9 +26,11 @@ Do not use `agent_creation_requests.owner_chat_account_id` as a current reverse registry. It is launch-time input. Do not query the operator runtime inventory as the product API, scan sealed Chat bindings, or derive account IDs from email. -Human names are optional: use an existing exact-key profile only with its stated -source. Without a supported human display name, render the shared account email -as the readable fallback. Never manufacture a person's name from an email slug. +The optional human name is the linked account's WorkOS name, released with its +email under the same gate. `source` describes the key association, not the name. +Without a usable name, render the shared account email as the readable fallback. +Never manufacture a person's name from an email slug. See "Account names and +private aliases" for normalization and freshness. ## Trusted hosted observation @@ -264,8 +267,9 @@ Core (`finite-saas-core`): - Descriptions: header `x-finite-brain-description-credential` only. One REPEATABLE READ, read-only transaction with a 2-second SQL statement timeout. Source fields are length-checked in SQL (email 254 bytes, display - name 200 bytes); oversized or control-character values make the row - `unknown` and are never truncated. Responses over 256 KiB fail with 503. + name 200 bytes). An unusable email or agent name makes the row `unknown`; + an unusable optional human name is omitted while its eligible email remains + resolved. Values are never truncated. Responses over 256 KiB fail with 503. Logs carry the Brain id, the requesting admin key and counts, never contact. - Disclosure: `resolved` needs a linked responsible account (the current project owner for agents) and, under v1 only, an active scope for it. @@ -297,3 +301,63 @@ upstream origin), which must equal `FC_CORE_BRAIN_IDENTITY_BRAIN_SERVER`. Not in v1: scope revocation has no product writer (an operator can set `revoked_at`), there is no owner-transfer writer, and existing hosted humans are described only after their next qualifying action. + +## Account names and private aliases + +Core migration `0043_account_names_and_key_aliases.sql` adds nullable +`users.workos_display_name` (with its change time) and `account_key_aliases`. +Nothing is backfilled. Startup reapplies the additive DDL, including a brief +exclusive lock on `users`; schedule release startup accordingly. + +Name writer: the verified-account link on `GET /api/core/v1/me`, +`/me/dashboard-summary` and `/me/runtime-routes/{id}`. It reads `first_name` +and `last_name` from the WorkOS user record that request already fetched; no +extra WorkOS call. Each part is trimmed and inner whitespace collapsed; a +missing, null or blank part is skipped. A part that is not a string, exceeds 100 +characters, or has control or bidirectional-override characters makes the name +unusable, as does a joined name over 200 bytes. Present parts are joined with +one space. An unusable or absent name clears the stored name, so Core never +shows a name WorkOS no longer gives. An unusable name does not block sign-in, +enrollment or Chat, and names are never derived from email. Name persistence is +part of the existing account-link transaction, so database failures still fail +that transaction. The name column changes only when the value changes; the +existing account-link behavior of `users.updated_at` is unchanged. + +Name reader: a `resolved` human row adds `displayName` under the same gates that +release its `accountEmail`. Wire shape, versions and `source` are unchanged: +`source` still describes the key association, and the name is the linked +account's WorkOS name as of its latest verified dashboard request. This also +applies to v3 scoped account-chat-key records under their existing exact-scope +and account-binding checks; names never change the mapping or its provenance. +Agent rows keep the project name. `responsibleAccount` carries no name because current +Brain servers parse it strictly. + +Private aliases: `POST /api/core/v1/me/key-aliases/lookup` (`publicKeys`, 1-100 +`npub` or hex keys) and `PUT`/`DELETE /api/core/v1/me/key-aliases/{npub-or-hex}` +(`alias`) on the account router. The viewer is the verified WorkOS account; no +route accepts an account, owner or email. An unlinked account's lookup returns +an empty list; PUT and DELETE return 403 `account_not_linked` without enrollment. +Keys must be valid x-only secp256k1 +keys and are stored as lowercase hex. Aliases are 1-64 characters after the +same trim and collapse, with no control or bidirectional-override characters, +at most 1,000 per account (409 `alias_limit_reached`). An alias is visible only +to its writer. It is never identity evidence and is not read by descriptions, +Brain access, ownership, membership, roles or grants. The dashboard roster shows +an alias first, marked "Your alias", with the existing identity label beneath +it. WorkOS name/email, dated scoped annotation, stored public name, lifecycle +qualifiers and the final "Unknown" fallback keep their existing precedence. +When alias lookup fails or Core lacks these routes, the roster shows without +aliases. + +Deploy Core before the dashboard to avoid the older-Core unavailable notice. +Rollback: older Core ignores the new column and table. Names stop refreshing +and aliases stop showing until the new Core returns; both are kept. + +Local browser proof: build the dashboard with `just web-check`, then run +`scripts/with-dev-env cargo run --locked -p devfinity -- run -- cargo test +--locked -p finite-saas-core key_alias_browser_composition -- --ignored +--nocapture`. It uses real Core/Postgres, dashboard routes and Chromium for +name display, alias save/reload/edit/clear, viewer isolation, origin rejection +and unavailable fallback. WorkOS is a signed local fixture; the unchanged +Agent/Brain roster transport is synthetic. This is not live OAuth or runtime +qualification. `FC_BROWSER_ARTIFACT_DIR` optionally records UI screenshots. diff --git a/finitecomputer-v2/docs/contracts/brain-identity-descriptions-v1.example.json b/finitecomputer-v2/docs/contracts/brain-identity-descriptions-v1.example.json index 1c26c4c12..956fc20fb 100644 --- a/finitecomputer-v2/docs/contracts/brain-identity-descriptions-v1.example.json +++ b/finitecomputer-v2/docs/contracts/brain-identity-descriptions-v1.example.json @@ -31,6 +31,7 @@ "publicKeyHex": "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", "state": "resolved", "kind": "human", + "displayName": "Sam Rivera", "accountEmail": "sam@example.org", "source": { "kind": "hostedDeviceObservation",