From e19b18cea1882827a063f1a1ac1dc0f0aaae7bc0 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Mon, 26 Oct 2026 19:12:37 -0500 Subject: [PATCH 1/3] feat(core,dashboard): WorkOS account names and private key aliases Core records the WorkOS first and last name from the user record it already fetches on verified account requests, and returns it on resolved human Brain identity descriptions under the same gates as the email. Malformed, oversized or missing names never block sign-in; they clear the stored name. Signed-in accounts can keep a private alias per exact key through new account-authenticated routes. Aliases are visible only to their writer and are never identity evidence. The dashboard Brain roster shows an alias first, marked "Your alias", with Finite's own name, email or Unknown beneath it, and keeps working when aliases are unavailable. Migration 0042 is additive: nullable users columns and account_key_aliases. Not yet compiled or tested: the build volume was full (see handoff). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + .../src/app/api/brain/key-aliases/route.ts | 46 ++++ .../src/components/brain-identity-roster.tsx | 67 ++++- .../src/components/brain-key-alias-dialog.tsx | 78 ++++++ .../dashboard/src/hooks/use-key-aliases.ts | 44 ++++ .../src/lib/brain-key-aliases.test.ts | 25 ++ .../dashboard/src/lib/brain-key-aliases.ts | 83 ++++++ .../apps/dashboard/src/lib/core-client.ts | 33 +++ .../crates/finite-saas-core/Cargo.toml | 1 + .../0043_account_names_and_key_aliases.sql | 41 +++ .../finite-saas-core/src/account_labels.rs | 132 ++++++++++ .../src/account_labels/tests.rs | 94 +++++++ .../crates/finite-saas-core/src/api.rs | 15 ++ .../finite-saas-core/src/api/identity.rs | 16 +- .../finite-saas-core/src/api/key_aliases.rs | 88 +++++++ .../src/api/runtime_routes.rs | 9 +- .../crates/finite-saas-core/src/api/tests.rs | 2 + .../src/api/tests/account_names.rs | 227 ++++++++++++++++ .../src/api/tests/brain_identity.rs | 16 +- .../src/api/tests/key_aliases.rs | 248 ++++++++++++++++++ .../crates/finite-saas-core/src/auth.rs | 13 + .../finite-saas-core/src/auth/test_support.rs | 29 ++ .../crates/finite-saas-core/src/auth/tests.rs | 56 ++++ .../crates/finite-saas-core/src/lib.rs | 1 + .../crates/finite-saas-core/src/schema.rs | 4 +- .../crates/finite-saas-core/src/store.rs | 2 + .../src/store/brain_identity.rs | 9 +- .../src/store/brain_identity/scoped.rs | 1 + .../src/store/brain_identity/snapshot.rs | 5 +- .../src/store/identity_rows.rs | 33 +++ .../finite-saas-core/src/store/key_aliases.rs | 155 +++++++++++ .../docs/brain-identity-descriptions-v1.md | 44 ++++ ...rain-identity-descriptions-v1.example.json | 1 + 33 files changed, 1582 insertions(+), 37 deletions(-) create mode 100644 finitecomputer-v2/apps/dashboard/src/app/api/brain/key-aliases/route.ts create mode 100644 finitecomputer-v2/apps/dashboard/src/components/brain-key-alias-dialog.tsx create mode 100644 finitecomputer-v2/apps/dashboard/src/hooks/use-key-aliases.ts create mode 100644 finitecomputer-v2/apps/dashboard/src/lib/brain-key-aliases.test.ts create mode 100644 finitecomputer-v2/apps/dashboard/src/lib/brain-key-aliases.ts create mode 100644 finitecomputer-v2/crates/finite-saas-core/migrations/0043_account_names_and_key_aliases.sql create mode 100644 finitecomputer-v2/crates/finite-saas-core/src/account_labels.rs create mode 100644 finitecomputer-v2/crates/finite-saas-core/src/account_labels/tests.rs create mode 100644 finitecomputer-v2/crates/finite-saas-core/src/api/key_aliases.rs create mode 100644 finitecomputer-v2/crates/finite-saas-core/src/api/tests/account_names.rs create mode 100644 finitecomputer-v2/crates/finite-saas-core/src/api/tests/key_aliases.rs create mode 100644 finitecomputer-v2/crates/finite-saas-core/src/store/key_aliases.rs diff --git a/Cargo.lock b/Cargo.lock index 264f19934..37ccad178 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/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-roster.tsx b/finitecomputer-v2/apps/dashboard/src/components/brain-identity-roster.tsx index 85558ab7e..e80bee515 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,10 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { KeyAliasDialog, type AliasTarget } from "@/components/brain-key-alias-dialog"; +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 +14,65 @@ 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) { + if (row.kind === "agent") return row.name ? `${row.name} · Agent` : null; + return [row.name, row.email].filter(Boolean).join(" · ") || null; +} + +function shortKey(npub: string) { + return `${npub.slice(0, 12)}…${npub.slice(-6)}`; +} + +function Identity({ row, alias }: { row: BrainIdentity; alias: string | null }) { + if (alias) { + return <> + {alias}{" "} + Your alias + {actualLabel(row) ?? `Unknown · ${shortKey(row.npub)}`} + ; + } + const detail = row.kind === "agent" ? "Agent" : row.name ? row.email : null; + const primary = row.kind === "agent" ? row.name : row.name ?? row.email; + if (!primary) return Unknown; + return <>{primary}{detail && {detail}}; +} + 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 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 => - - - - - - )} + + {data.map(row => { + const alias = aliases.aliasFor(row.npub); + const name = alias ?? (row.kind === "agent" ? row.name : row.name ?? row.email) ?? `unknown key ${shortKey(row.npub)}`; + return + + + + + + + ; + })}
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)}`}
IdentityYour aliasOwnerRoleFoldersKey
{aliases.status === "ready" && }{row.ownerEmail}{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)} />}
; } 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..41c578aee --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/src/components/brain-key-alias-dialog.tsx @@ -0,0 +1,78 @@ +"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 }: { + 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; +}) { + 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) return; + void run("save", () => onSave(target.npub, alias)); + }; + return { if (!open && !busy) onClose(); }}> + +
+ + {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} +
+ {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..a39509a01 --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/src/lib/brain-key-aliases.ts @@ -0,0 +1,83 @@ +/** + * 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. +// eslint-disable-next-line no-control-regex +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/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..97dd89ddb 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,7 @@ mod finite_private_keys; mod finite_private_usage; mod hosted_access; mod identity_authorization; +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..0e33e16d0 --- /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 to_jsonb(workos_display_name) 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_aliases.rs b/finitecomputer-v2/crates/finite-saas-core/src/api/tests/key_aliases.rs new file mode 100644 index 000000000..dad321b00 --- /dev/null +++ b/finitecomputer-v2/crates/finite-saas-core/src/api/tests/key_aliases.rs @@ -0,0 +1,248 @@ +//! 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", &[maya.clone()]).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", &[maya_npub.clone()]).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 and a fresh router (a Core restart). + let restarted = router(db.store.clone(), 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..ac0f7d0ff 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,11 @@ 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 +102,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..49e8f4ce4 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,29 @@ 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 and never moves + /// `users.updated_at`, which dates the account contact. + pub async fn link_verified_user_with_name( + &self, + input: LinkVerifiedUserInput, + display_name: Option<&str>, + ) -> CoreResult { + self.link_user(input, Some(display_name)).await + } + + 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 +41,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..bd8e4783e --- /dev/null +++ b/finitecomputer-v2/crates/finite-saas-core/src/store/key_aliases.rs @@ -0,0 +1,155 @@ +//! 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`. + 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. + 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/docs/brain-identity-descriptions-v1.md b/finitecomputer-v2/docs/brain-identity-descriptions-v1.md index 2bd52624a..b7c9504c7 100644 --- a/finitecomputer-v2/docs/brain-identity-descriptions-v1.md +++ b/finitecomputer-v2/docs/brain-identity-descriptions-v1.md @@ -28,6 +28,8 @@ 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. +Core's supported human name is the account's WorkOS name (see "Account names +and private aliases"). ## Trusted hosted observation @@ -297,3 +299,45 @@ 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 `0042_account_names_and_key_aliases.sql` adds nullable +`users.workos_display_name` (with its change time) and `account_key_aliases`. +Nothing is backfilled. + +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. Names never block sign-in, enrollment or +Chat and are never derived from email. The column changes only when the value +changes and does not move `users.updated_at`. + +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. 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, and an unlinked account gets 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 Finite's own name, email or "Unknown" +beneath it. When alias lookup fails or Core lacks these routes, the roster shows +without aliases. + +Rollback: older Core ignores the new column and table. Names stop refreshing +and aliases stop showing until the new Core returns; both are kept. 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", From 0aca26f10d8810dad954e157aa362aee883b1644 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Mon, 26 Oct 2026 19:30:47 -0500 Subject: [PATCH 2/3] test(brain): verify private aliases through Core and dashboard --- .../scripts/brain-key-alias-browser-proof.ts | 204 ++++++++++++++++++ .../src/components/brain-identity-roster.tsx | 15 +- .../src/components/brain-key-alias-dialog.tsx | 11 +- .../dashboard/src/lib/brain-key-aliases.ts | 1 - .../dashboard/src/styles/brain-overview.css | 2 + .../crates/finite-saas-core/src/api/tests.rs | 1 + .../src/api/tests/account_names.rs | 2 +- .../src/api/tests/key_alias_browser.rs | 77 +++++++ .../src/api/tests/key_aliases.rs | 11 +- .../src/store/identity_rows.rs | 5 +- .../finite-saas-core/src/store/key_aliases.rs | 2 + .../docs/brain-identity-descriptions-v1.md | 42 ++-- 12 files changed, 342 insertions(+), 31 deletions(-) create mode 100644 finitecomputer-v2/apps/dashboard/scripts/brain-key-alias-browser-proof.ts create mode 100644 finitecomputer-v2/crates/finite-saas-core/src/api/tests/key_alias_browser.rs 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..4ef99f9df --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/scripts/brain-key-alias-browser-proof.ts @@ -0,0 +1,204 @@ +/** 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]); + const descriptions = await request(config.identityUrl, "/api/core/internal/v1/brain-identity-descriptions", "", { + version: "finite-core-brain-identity-descriptions-v2", brainServer: "https://brain.test", brainId: "brain_alpha", + requestedByPublicKeyHex: config.keys[1], publicKeysHex: 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 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", email: null, ownerEmail: "owner@example.test", folders: [] }, + { npub: config.npubs[2], role: "member", kind: null, name: null, email: null, ownerEmail: null, 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.*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"]) { + 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.match(await row.innerText(), label === "Moss" ? /Moss.*Agent/ : /Unknown/); + 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 name display, 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/components/brain-identity-roster.tsx b/finitecomputer-v2/apps/dashboard/src/components/brain-identity-roster.tsx index e80bee515..3772632fa 100644 --- a/finitecomputer-v2/apps/dashboard/src/components/brain-identity-roster.tsx +++ b/finitecomputer-v2/apps/dashboard/src/components/brain-identity-roster.tsx @@ -1,5 +1,5 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { KeyAliasDialog, type AliasTarget } from "@/components/brain-key-alias-dialog"; import { Button } from "@/components/ui/button"; import { useAgentInventory } from "@/hooks/use-agent-inventory"; @@ -44,6 +44,7 @@ export function BrainIdentityRoster({ runtimeId, brain }: { runtimeId: string; b 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}

@@ -51,8 +52,8 @@ export function BrainIdentityRoster({ runtimeId, brain }: { runtimeId: string; b {data && data.length > 0 && aliases.status === "unavailable" &&

Your private aliases are unavailable right now.

}
{data && data.length > 0 &&
- - +
IdentityYour aliasOwnerRoleFoldersKey
+ {data.map(row => { const alias = aliases.aliasFor(row.npub); const name = alias ?? (row.kind === "agent" ? row.name : row.name ?? row.email) ?? `unknown key ${shortKey(row.npub)}`; @@ -60,7 +61,10 @@ export function BrainIdentityRoster({ runtimeId, brain }: { runtimeId: string; b @@ -73,6 +77,7 @@ export function BrainIdentityRoster({ runtimeId, brain }: { runtimeId: string; b } {editing && row.npub !== editing.npub).flatMap(row => aliases.aliasFor(row.npub) ?? [])} - onSave={aliases.save} onClear={aliases.clear} onClose={() => setEditing(null)} />} + 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 index 41c578aee..a57b84e73 100644 --- a/finitecomputer-v2/apps/dashboard/src/components/brain-key-alias-dialog.tsx +++ b/finitecomputer-v2/apps/dashboard/src/components/brain-key-alias-dialog.tsx @@ -8,13 +8,14 @@ import { aliasLength, MAX_ALIAS_CHARS, normalizeAlias } from "@/lib/brain-key-al 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 }: { +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(); @@ -33,11 +34,11 @@ export function KeyAliasDialog({ target, otherAliases, onSave, onClear, onClose }; const submit = (event: FormEvent) => { event.preventDefault(); - if (!alias) return; + 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"} @@ -49,12 +50,12 @@ export function KeyAliasDialog({ target, otherAliases, onSave, onClear, onClose
- { 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} + {aliasLength(value)}/{MAX_ALIAS_CHARS} characters
{problem &&

{problem}

}
diff --git a/finitecomputer-v2/apps/dashboard/src/lib/brain-key-aliases.ts b/finitecomputer-v2/apps/dashboard/src/lib/brain-key-aliases.ts index a39509a01..e7bed17a0 100644 --- a/finitecomputer-v2/apps/dashboard/src/lib/brain-key-aliases.ts +++ b/finitecomputer-v2/apps/dashboard/src/lib/brain-key-aliases.ts @@ -13,7 +13,6 @@ const MAX_ALIAS_BYTES = 256; export const MAX_ALIAS_LOOKUP_KEYS = 1024; const NPUB = /^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/; // Control characters and bidirectional overrides can hide or reorder text. -// eslint-disable-next-line no-control-regex const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/; export class KeyAliasError extends Error { 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/src/api/tests.rs b/finitecomputer-v2/crates/finite-saas-core/src/api/tests.rs index 97dd89ddb..ab80b2e20 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/api/tests.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/api/tests.rs @@ -20,6 +20,7 @@ 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; 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 index 0e33e16d0..e773815df 100644 --- 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 @@ -33,7 +33,7 @@ async fn me(app: &Router, bearer: &str) -> StatusCode { async fn stored_name(db: &crate::test_support::TestDb) -> Value { db.query_json( - "SELECT to_jsonb(workos_display_name) FROM users WHERE normalized_email = $1", + "SELECT COALESCE(to_jsonb(workos_display_name), 'null'::jsonb) FROM users WHERE normalized_email = $1", &[&EMAIL], ) .await 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..37f6b1589 --- /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] + .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 index dad321b00..dc21abfee 100644 --- 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 @@ -51,7 +51,7 @@ async fn aliases_are_private_to_the_verified_viewer() { 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", &[maya.clone()]).await; + let (status, empty) = lookup(&app, "ana@example.org", std::slice::from_ref(&maya)).await; assert_eq!( (status, empty), (StatusCode::OK, serde_json::json!({"aliases": []})) @@ -79,7 +79,7 @@ async fn aliases_are_private_to_the_verified_viewer() { assert_eq!(rows.len(), 1); // Ben sees nothing of Ana's, and his own alias is separate. - let (_, bens) = lookup(&app, "ben@example.org", &[maya_npub.clone()]).await; + 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); @@ -93,8 +93,11 @@ async fn aliases_are_private_to_the_verified_viewer() { .await; assert_eq!(status, StatusCode::NO_CONTENT); - // Ana's survives Ben's clear and a fresh router (a Core restart). - let restarted = router(db.store.clone(), test_auth()); + // 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; 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 49e8f4ce4..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 @@ -9,8 +9,8 @@ impl CoreStore { /// 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 and never moves - /// `users.updated_at`, which dates the account contact. + /// 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, @@ -19,6 +19,7 @@ impl CoreStore { 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, 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 index bd8e4783e..0a241e0e4 100644 --- a/finitecomputer-v2/crates/finite-saas-core/src/store/key_aliases.rs +++ b/finitecomputer-v2/crates/finite-saas-core/src/store/key_aliases.rs @@ -57,6 +57,7 @@ impl CoreStore { /// 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, @@ -117,6 +118,7 @@ impl CoreStore { } /// 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, diff --git a/finitecomputer-v2/docs/brain-identity-descriptions-v1.md b/finitecomputer-v2/docs/brain-identity-descriptions-v1.md index b7c9504c7..3ee8beb1c 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,11 +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. -Core's supported human name is the account's WorkOS name (see "Account names -and private aliases"). +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 @@ -266,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. @@ -304,7 +306,8 @@ are described only after their next qualifying action. Core migration `0042_account_names_and_key_aliases.sql` adds nullable `users.workos_display_name` (with its change time) and `account_key_aliases`. -Nothing is backfilled. +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` @@ -314,9 +317,11 @@ 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. Names never block sign-in, enrollment or -Chat and are never derived from email. The column changes only when the value -changes and does not move `users.updated_at`. +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: @@ -328,8 +333,9 @@ 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, and an unlinked account gets 403 -`account_not_linked` without enrollment. Keys must be valid x-only secp256k1 +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 @@ -339,5 +345,15 @@ an alias first, marked "Your alias", with Finite's own name, email or "Unknown" beneath it. 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. From 2517eba63e0fdde42e25a076f4bb6a7480c35d34 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Mon, 26 Oct 2026 20:59:12 -0500 Subject: [PATCH 3/3] fix(brain): preserve scoped identities when adding private aliases --- .../browser/brain-scoped-roster.browser.ts | 4 +- .../scripts/brain-key-alias-browser-proof.ts | 28 +++- .../components/brain-identity-cell.test.ts | 22 ++- .../src/components/brain-identity-cell.tsx | 28 ++-- .../src/components/brain-identity-roster.tsx | 25 +-- .../src/api/tests/key_alias_browser.rs | 2 +- .../src/store/brain_identity/snapshot.rs | 6 +- .../src/store/tests/brain_identity/scoped.rs | 1 + .../brain_identity/scoped/account_names.rs | 46 ++++++ .../tests/account_names_migration.rs | 155 ++++++++++++++++++ .../docs/brain-identity-descriptions-v1.md | 16 +- 11 files changed, 284 insertions(+), 49 deletions(-) create mode 100644 finitecomputer-v2/crates/finite-saas-core/src/store/tests/brain_identity/scoped/account_names.rs create mode 100644 finitecomputer-v2/crates/finite-saas-core/tests/account_names_migration.rs 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 index 4ef99f9df..e634d523d 100644 --- a/finitecomputer-v2/apps/dashboard/scripts/brain-key-alias-browser-proof.ts +++ b/finitecomputer-v2/apps/dashboard/scripts/brain-key-alias-browser-proof.ts @@ -72,12 +72,20 @@ async function main() { 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-identity-descriptions-v2", brainServer: "https://brain.test", brainId: "brain_alpha", - requestedByPublicKeyHex: config.keys[1], publicKeysHex: config.keys, + 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() }); @@ -97,8 +105,11 @@ async function main() { 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", email: null, ownerEmail: "owner@example.test", 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 }); }); @@ -117,7 +128,7 @@ async function main() { 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.*runtime-auth@finite.test/); + 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`, { @@ -143,14 +154,17 @@ async function main() { 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"]) { + 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.match(await row.innerText(), label === "Moss" ? /Moss.*Agent/ : /Unknown/); + 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(); @@ -182,7 +196,7 @@ async function main() { await page.unroute("**/api/brain/key-aliases"); } assert.deepEqual(errors, []); - console.log("PASS: real Core/Postgres + dashboard name display, alias save/reload/edit/clear, viewer isolation, origin rejection, keyboard, bounds, mobile and unavailable fallback"); + 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(); 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 ; + 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 ; } 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 3772632fa..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,6 +1,7 @@ "use client"; 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 { useKeyAliases } from "@/hooks/use-key-aliases"; @@ -16,28 +17,14 @@ const folderStates: Record = { grantMissing: "key pending", revo /** What Finite knows about this key, without any alias. */ function actualLabel(row: BrainIdentity) { - if (row.kind === "agent") return row.name ? `${row.name} · Agent` : null; - return [row.name, row.email].filter(Boolean).join(" · ") || null; + const { primary, detail } = identityLabel(row); + return [primary, detail].filter(Boolean).join(" · ") || null; } function shortKey(npub: string) { return `${npub.slice(0, 12)}…${npub.slice(-6)}`; } -function Identity({ row, alias }: { row: BrainIdentity; alias: string | null }) { - if (alias) { - return <> - {alias}{" "} - Your alias - {actualLabel(row) ?? `Unknown · ${shortKey(row.npub)}`} - ; - } - const detail = row.kind === "agent" ? "Agent" : row.name ? row.email : null; - const primary = row.kind === "agent" ? row.name : row.name ?? row.email; - if (!primary) return Unknown; - return <>{primary}{detail && {detail}}; -} - 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); @@ -56,9 +43,9 @@ export function BrainIdentityRoster({ runtimeId, brain }: { runtimeId: string; b {data.map(row => { const alias = aliases.aliasFor(row.npub); - const name = alias ?? (row.kind === "agent" ? row.name : row.name ?? row.email) ?? `unknown key ${shortKey(row.npub)}`; + const name = alias ?? identityLabel(row).primary ?? `unknown key ${shortKey(row.npub)}`; return - + - + 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 index 37f6b1589..6ee1fb9f8 100644 --- 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 @@ -30,7 +30,7 @@ async fn key_alias_browser_composition() { 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] + let keys: Vec<_> = [21, 22, 23, 24, 25, 26] .into_iter() .map(key_aliases::valid_key) .collect(); 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 ac0f7d0ff..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 @@ -92,7 +92,11 @@ impl IdentitySnapshot { 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), &(MAX_DISPLAY_NAME_BYTES as i32)], + &[ + &releasable_ids, + &(MAX_CONTACT_BYTES as i32), + &(MAX_DISPLAY_NAME_BYTES as i32), + ], ) .await .map_err(store_error)? 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 3ee8beb1c..e8d1a6835 100644 --- a/finitecomputer-v2/docs/brain-identity-descriptions-v1.md +++ b/finitecomputer-v2/docs/brain-identity-descriptions-v1.md @@ -304,7 +304,7 @@ are described only after their next qualifying action. ## Account names and private aliases -Core migration `0042_account_names_and_key_aliases.sql` adds nullable +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. @@ -326,8 +326,10 @@ 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. Agent rows -keep the project name. `responsibleAccount` carries no name because current +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 @@ -341,9 +343,11 @@ 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 Finite's own name, email or "Unknown" -beneath it. When alias lookup fails or Core lacks these routes, the roster shows -without aliases. +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
IdentityYour aliasOwnerRoleFoldersKey
{aliases.status === "ready" && } {row.ownerEmail} - Unknown{alias ? <> + {alias}{" "} + Your alias + {identity} + : identity}
IdentityYour aliasOwnerRoleFoldersKey
{aliases.status === "ready" && }{row.ownerEmail}{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)}