From 838af38aa284d51d7ff36f67433b4c94cdb4159d Mon Sep 17 00:00:00 2001 From: Soorya U Date: Fri, 7 Aug 2026 10:43:44 +0530 Subject: [PATCH 1/2] Simplify apps/server auth options conditionals Extract isProduction once and reuse it for emailAndPassword and cookie security, and drop the redundant inline logger.log parameter types that better-auth's own types already infer. Co-Authored-By: Claude Sonnet 5 --- apps/server/src/auth/options.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/server/src/auth/options.ts b/apps/server/src/auth/options.ts index b76c0d5..afb54d7 100644 --- a/apps/server/src/auth/options.ts +++ b/apps/server/src/auth/options.ts @@ -12,13 +12,12 @@ import { log } from "evlog"; import { env } from "../config/env"; import { sendMagicLinkEmail as sendMagicLink } from "../emails/magic-email"; -const emailAndPassword = - env.NODE_ENV === "production" ? {} : { emailAndPassword: { enabled: true } }; +const isProduction = env.NODE_ENV === "production"; export const authOptions = { appName: "Cyrus", basePath: "/api/auth", - ...emailAndPassword, + emailAndPassword: { enabled: !isProduction }, trustedOrigins: [...env.ALLOWED_ORIGINS, env.PRODUCTION_URL], socialProviders: { github: { @@ -36,15 +35,11 @@ export const authOptions = { defaultCookieAttributes: { sameSite: "lax" as const, httpOnly: true, - secure: env.NODE_ENV === "production", + secure: isProduction, }, }, logger: { - log: ( - level: "debug" | "info" | "warn" | "error", - message: string, - ...args: unknown[] - ) => log[level]({ message, args }), + log: (level, message, ...args) => log[level]({ message, args }), level: env.LOG_LEVEL, }, plugins: [ From 885faad3b5f7b1d31c577c1fdbdb430da4c1133b Mon Sep 17 00:00:00 2001 From: Soorya U Date: Fri, 7 Aug 2026 10:44:51 +0530 Subject: [PATCH 2/2] Show workers as their own labeled section in active sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login sessions from browser/desktop/mobile controllers and from the CLI worker (cyrusd) were indistinguishable in the active-sessions list. Add a nullable workerName field to the session model (set only by cyrusd, via the OAuth device-authorization grant it exclusively uses to log in) and split the UI into Controllers and Workers sections, the latter showing a count and each row labeled with its worker name and a terminal icon instead of a browser/OS parse. The CLI pushes its name via better-auth's built-in updateSession client method rather than a custom endpoint. Both login and rename treat a failed push as fatal — login removes the just-stored token and rename leaves local config untouched — so a session can never end up desynced from its worker name. See docs/adr/0023-worker-name-discriminates-login-sessions.md. Co-Authored-By: Claude Sonnet 5 --- CONTEXT.md | 4 + apps/cli/src/commands/auth/login.ts | 21 +- apps/cli/src/commands/config/rename.test.ts | 110 ++++ apps/cli/src/commands/config/rename.ts | 14 +- apps/cli/src/lib/auth.ts | 27 +- apps/server/drizzle.config.ts | 2 +- apps/server/src/auth/cli.ts | 5 + apps/server/src/auth/options.ts | 8 + .../migration.sql | 1 + .../snapshot.json | 592 ++++++++++++++++++ apps/server/src/db/models/auth.ts | 1 + .../auth/security/active-session.test.tsx | 19 + .../auth/security/active-session.tsx | 51 +- .../auth/security/active-sessions.test.tsx | 85 +++ .../auth/security/active-sessions.tsx | 57 +- ...orker-name-discriminates-login-sessions.md | 11 + 16 files changed, 958 insertions(+), 50 deletions(-) create mode 100644 apps/cli/src/commands/config/rename.test.ts create mode 100644 apps/server/src/db/migrations/20260806160054_add_worker_name_to_session/migration.sql create mode 100644 apps/server/src/db/migrations/20260806160054_add_worker_name_to_session/snapshot.json create mode 100644 apps/web/src/components/auth/security/active-sessions.test.tsx create mode 100644 docs/adr/0023-worker-name-discriminates-login-sessions.md diff --git a/CONTEXT.md b/CONTEXT.md index 1e4027c..0d4e28e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -29,6 +29,10 @@ The room-level connection through the sync server that lets peers discover and d A controller's direct connection to one specific worker, over which all worker RPCs run. _Avoid_: RTC connection (as a domain term), channel, controller (for the connection) +**Login session**: +A better-auth credential grant — the row a peer gets on signing in, holding its token, IP, and user agent. Carries a `workerName` when it belongs to a worker (set via the CLI's device-authorization login), and is `null` when it belongs to a controller (web, desktop, mobile sign-in). Distinct from an ACP **Session**. +_Avoid_: session (ambiguous with ACP Session), auth session, device + ### Projects and threads **Project**: diff --git a/apps/cli/src/commands/auth/login.ts b/apps/cli/src/commands/auth/login.ts index 0716e4a..d168532 100644 --- a/apps/cli/src/commands/auth/login.ts +++ b/apps/cli/src/commands/auth/login.ts @@ -1,13 +1,21 @@ import { generateName } from "@cyrus/utils/identity"; import { Result } from "better-result"; -import { authClient } from "@/lib/auth"; -import { getOrCreate, set } from "@/store/config"; +import { authClient, syncWorkerName } from "@/lib/auth"; +import { getOrCreate, remove, set } from "@/store/config"; import { createSpinner } from "@/utils/spinner"; import { blue, bold, cyan, print, underline } from "@/utils/style"; export const CLIENT_ID = "cyrusd"; export const GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; +async function syncNewWorkerName(): Promise> { + const nameResult = await Result.tryPromise(() => + getOrCreate("name", generateName) + ); + if (nameResult.isErr()) return Result.err("no local name"); + return await syncWorkerName(nameResult.value); +} + export async function login(): Promise { const { data, error } = await authClient.device.code({ client_id: CLIENT_ID, @@ -61,7 +69,14 @@ export async function login(): Promise { if (accessToken) { await set("token", accessToken); - await Result.tryPromise(() => getOrCreate("name", generateName)); + + const syncResult = await syncNewWorkerName(); + if (syncResult.isErr()) { + await remove("token"); + spinner.error(`Failed to sync worker name: ${syncResult.error}`); + process.exit(1); + } + const session = await authClient.getSession(); const email = session.data?.user?.email; spinner.success(`Logged in${email ? ` as ${email}` : ""}.`); diff --git a/apps/cli/src/commands/config/rename.test.ts b/apps/cli/src/commands/config/rename.test.ts new file mode 100644 index 0000000..490da5a --- /dev/null +++ b/apps/cli/src/commands/config/rename.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { tempCyrusHomeFixture } from "@cyrus/test/fixtures/cyrus-home"; +import { YAML } from "bun"; + +const CLI = join(import.meta.dir, "../../cli.ts"); +const tempHome = tempCyrusHomeFixture(afterEach, "cyrus-rename-"); + +async function seedConfig( + home: string, + config: { token?: string; id?: string; name?: string } +): Promise { + await Bun.write(join(home, "config.yml"), YAML.stringify(config)); +} + +async function readConfig( + home: string +): Promise<{ token?: string; id?: string; name?: string }> { + return YAML.parse(await Bun.file(join(home, "config.yml")).text()) as { + token?: string; + id?: string; + name?: string; + }; +} + +async function runRename( + home: string, + name: string, + serverUrl: string +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const proc = Bun.spawn(["bun", CLI, "rename", name], { + cwd: join(import.meta.dir, "../../.."), + env: { + ...process.env, + CYRUS_HOME: home, + CLI_PUBLIC_SERVER_URL: serverUrl, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { exitCode, stdout, stderr }; +} + +describe("cyrusd rename", () => { + test("pushes the new name to the server and updates local config on success", async () => { + const home = await tempHome(); + await seedConfig(home, { token: "test-token", name: "old-name" }); + + using server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/api/auth/update-session") { + return Response.json({ session: { workerName: "new-name" } }); + } + return new Response("not found", { status: 404 }); + }, + }); + + const result = await runRename(home, "new-name", server.url.toString()); + + expect(result.exitCode).toBe(0); + expect(await readConfig(home)).toMatchObject({ name: "new-name" }); + }); + + test("fails and leaves local config untouched when the server rejects the update", async () => { + const home = await tempHome(); + await seedConfig(home, { token: "test-token", name: "old-name" }); + + using server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/api/auth/update-session") { + return Response.json( + { message: "Internal Server Error" }, + { status: 500 } + ); + } + return new Response("not found", { status: 404 }); + }, + }); + + const result = await runRename(home, "new-name", server.url.toString()); + + expect(result.exitCode).toBe(1); + expect(await readConfig(home)).toMatchObject({ name: "old-name" }); + }); + + test("fails without contacting the server when not logged in", async () => { + const home = await tempHome(); + + using server = Bun.serve({ + port: 0, + fetch() { + throw new Error("should not be called"); + }, + }); + + const result = await runRename(home, "new-name", server.url.toString()); + + expect(result.exitCode).toBe(1); + expect(result.stderr + result.stdout).toContain("logged in"); + }); +}); diff --git a/apps/cli/src/commands/config/rename.ts b/apps/cli/src/commands/config/rename.ts index e42cfd3..7fa79d9 100644 --- a/apps/cli/src/commands/config/rename.ts +++ b/apps/cli/src/commands/config/rename.ts @@ -1,8 +1,20 @@ -import { set } from "@/store/config"; +import { syncWorkerName } from "@/lib/auth"; +import { get, set } from "@/store/config"; import { runningPid } from "@/utils/process"; import { print } from "@/utils/style"; export async function rename(name: string): Promise { + if ((await get("token")) === null) { + print.dim`Not logged in. Run \`cyrusd login\`.`; + process.exit(1); + } + + const syncResult = await syncWorkerName(name); + if (syncResult.isErr()) { + print.error`Failed to update worker name on the server: ${syncResult.error}`; + process.exit(1); + } + await set("name", name); print.success`✓ renamed to "${name}"`; if ((await runningPid()) !== null) diff --git a/apps/cli/src/lib/auth.ts b/apps/cli/src/lib/auth.ts index 2198a31..3090b7f 100644 --- a/apps/cli/src/lib/auth.ts +++ b/apps/cli/src/lib/auth.ts @@ -1,12 +1,24 @@ import { wsTicketClientPlugin } from "@soorya-u/better-auth-ws-ticket/client"; import { createAuthClient } from "better-auth/client"; -import { deviceAuthorizationClient } from "better-auth/client/plugins"; +import { + deviceAuthorizationClient, + inferAdditionalFields, +} from "better-auth/client/plugins"; +import { Result } from "better-result"; import { get } from "@/store/config"; import { env } from "./env"; export const authClient = createAuthClient({ baseURL: env.CLI_PUBLIC_SERVER_URL, - plugins: [deviceAuthorizationClient(), wsTicketClientPlugin()], + plugins: [ + deviceAuthorizationClient(), + wsTicketClientPlugin(), + inferAdditionalFields({ + session: { + workerName: { type: "string", required: false }, + }, + }), + ], fetchOptions: { auth: { type: "Bearer", @@ -14,3 +26,14 @@ export const authClient = createAuthClient({ }, }, }); + +export async function syncWorkerName( + name: string +): Promise> { + const { error } = await authClient.updateSession({ workerName: name }); + if (error) + return Result.err( + error.message || "Failed to sync worker name with the server" + ); + return Result.ok(); +} diff --git a/apps/server/drizzle.config.ts b/apps/server/drizzle.config.ts index 373a8a9..232d147 100644 --- a/apps/server/drizzle.config.ts +++ b/apps/server/drizzle.config.ts @@ -4,7 +4,7 @@ import { env } from "./src/db/env"; const { driver, ...dbCredentials } = env; export default defineConfig({ - schema: "./src/db/models/index.ts", + schema: "./src/db/models/auth.ts", out: "./src/db/migrations", dialect: "sqlite", driver, diff --git a/apps/server/src/auth/cli.ts b/apps/server/src/auth/cli.ts index 07408a3..9569e05 100644 --- a/apps/server/src/auth/cli.ts +++ b/apps/server/src/auth/cli.ts @@ -1,13 +1,18 @@ import { betterAuth } from "better-auth"; import { withCloudflare } from "better-auth-cloudflare"; +import { drizzle } from "drizzle-orm/d1"; +import { models as schema } from "../db/models"; import { authOptions } from "./options"; // This if for CLI schema generation (`auth:generate`) +const db = drizzle({} as never); + export const auth = betterAuth({ ...withCloudflare( { autoDetectIpAddress: false, geolocationTracking: false, + d1: { db: db as never, options: { schema } }, }, authOptions ), diff --git a/apps/server/src/auth/options.ts b/apps/server/src/auth/options.ts index afb54d7..014945b 100644 --- a/apps/server/src/auth/options.ts +++ b/apps/server/src/auth/options.ts @@ -31,6 +31,14 @@ export const authOptions = { }, secret: env.BETTER_AUTH_SECRET, baseURL: env.WEB_APP_URL, + session: { + additionalFields: { + workerName: { + type: "string", + required: false, + }, + }, + }, advanced: { defaultCookieAttributes: { sameSite: "lax" as const, diff --git a/apps/server/src/db/migrations/20260806160054_add_worker_name_to_session/migration.sql b/apps/server/src/db/migrations/20260806160054_add_worker_name_to_session/migration.sql new file mode 100644 index 0000000..947501c --- /dev/null +++ b/apps/server/src/db/migrations/20260806160054_add_worker_name_to_session/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `session` ADD `worker_name` text; diff --git a/apps/server/src/db/migrations/20260806160054_add_worker_name_to_session/snapshot.json b/apps/server/src/db/migrations/20260806160054_add_worker_name_to_session/snapshot.json new file mode 100644 index 0000000..2956845 --- /dev/null +++ b/apps/server/src/db/migrations/20260806160054_add_worker_name_to_session/snapshot.json @@ -0,0 +1,592 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "8fff4d4d-8f7c-4d6f-9d90-e44ca4baaa72", + "prevIds": ["609c46c4-c618-4c84-a383-83e777c62e88"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "device_code", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "verification", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token_expires_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token_expires_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "device_code" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "device_code", + "entityType": "columns", + "table": "device_code" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_code", + "entityType": "columns", + "table": "device_code" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "device_code" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "device_code" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "device_code" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_polled_at", + "entityType": "columns", + "table": "device_code" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "polling_interval", + "entityType": "columns", + "table": "device_code" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "client_id", + "entityType": "columns", + "table": "device_code" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "device_code" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ip_address", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worker_name", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "email_verified", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "image", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "identifier", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "verification" + }, + { + "columns": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_account_user_id_user_id_fk", + "entityType": "fks", + "table": "account" + }, + { + "columns": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_user_id_user_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "device_code_pk", + "table": "device_code", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "user_pk", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "verification_pk", + "table": "verification", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "account_userId_idx", + "entityType": "indexes", + "table": "account" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_userId_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "identifier", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "verification_identifier_idx", + "entityType": "indexes", + "table": "verification" + }, + { + "columns": ["token"], + "nameExplicit": false, + "name": "session_token_unique", + "entityType": "uniques", + "table": "session" + }, + { + "columns": ["email"], + "nameExplicit": false, + "name": "user_email_unique", + "entityType": "uniques", + "table": "user" + } + ], + "renames": [] +} diff --git a/apps/server/src/db/models/auth.ts b/apps/server/src/db/models/auth.ts index d45c05a..ac46dc4 100644 --- a/apps/server/src/db/models/auth.ts +++ b/apps/server/src/db/models/auth.ts @@ -36,6 +36,7 @@ export const session = sqliteTable( userId: text("user_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), + workerName: text("worker_name"), }, (table) => [index("session_userId_idx").on(table.userId)] ); diff --git a/apps/web/src/components/auth/security/active-session.test.tsx b/apps/web/src/components/auth/security/active-session.test.tsx index d62b987..18e5552 100644 --- a/apps/web/src/components/auth/security/active-session.test.tsx +++ b/apps/web/src/components/auth/security/active-session.test.tsx @@ -71,4 +71,23 @@ describe("ActiveSession", () => { expect(navigateMock).toHaveBeenCalledWith({ to: "/auth/sign-out" }); expect(revokeSessionMock).not.toHaveBeenCalled(); }); + + test("renders the worker name instead of browser/OS when workerName is set", () => { + render( + + ); + + expect(screen.getByText("sooryas-macbook")).toBeInTheDocument(); + expect(screen.queryByText("Unknown Browser")).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/auth/security/active-session.tsx b/apps/web/src/components/auth/security/active-session.tsx index cdb4b8e..b625fab 100644 --- a/apps/web/src/components/auth/security/active-session.tsx +++ b/apps/web/src/components/auth/security/active-session.tsx @@ -1,9 +1,7 @@ -"use client"; - import { useAuth, useRevokeSession, useSession } from "@better-auth-ui/react"; import type { Session } from "better-auth"; import Bowser from "bowser"; -import { LogOut, Monitor, Smartphone, X } from "lucide-react"; +import { LogOut, Monitor, Smartphone, TerminalIcon, X } from "lucide-react"; import { toast } from "sonner"; import { Show } from "@/components/helpers/show"; @@ -40,19 +38,12 @@ function timeAgo(date: Date) { return rtf.format(0, "second"); } +export type SessionWithWorkerName = Session & { workerName?: string | null }; + export type ActiveSessionProps = { - activeSession: Session; + activeSession: SessionWithWorkerName; }; -/** - * Render a single active session row with device info and revoke control. - * - * Shows the session's browser, OS, and creation time. The current session is marked - * and navigates to sign-out on click, while other sessions can be revoked individually. - * - * @param session - The session object containing id, token, userAgent, ipAddress, and createdAt - * @returns A JSX element containing the active session row - */ export function ActiveSession({ activeSession }: ActiveSessionProps) { const { authClient, basePaths, localization, viewPaths, navigate } = useAuth(); @@ -67,22 +58,34 @@ export function ActiveSession({ activeSession }: ActiveSessionProps) { ); const isCurrentSession = activeSession.token === session?.session.token; - const ua = Bowser.parse(activeSession.userAgent || ""); + const { workerName } = activeSession; + const ua = workerName + ? null + : Bowser.parse(activeSession.userAgent || "unknown"); const isMobile = - ua.platform.type === "mobile" || ua.platform.type === "tablet"; + ua?.platform.type === "mobile" || ua?.platform.type === "tablet"; + + let icon = ; + if (workerName) { + icon = ; + } else if (isMobile) { + icon = ; + } + + const title = workerName ? ( + workerName + ) : ( + <> + {ua?.browser.name || "Unknown Browser"} + {ua?.os.name ? `, ${ua.os.name}` : ""} + + ); return ( - - } when={isMobile}> - - - + {icon} - - {ua.browser.name || "Unknown Browser"} - {ua.os.name ? `, ${ua.os.name}` : ""} - + {title} diff --git a/apps/web/src/components/auth/security/active-sessions.test.tsx b/apps/web/src/components/auth/security/active-sessions.test.tsx new file mode 100644 index 0000000..4af9306 --- /dev/null +++ b/apps/web/src/components/auth/security/active-sessions.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { ActiveSessions } from "./active-sessions"; + +const revokeSessionMock = vi.fn(); +const WORKERS_HEADING = /Workers/; + +let listSessionsData: unknown[] = []; + +vi.mock("@better-auth-ui/react", () => ({ + useAuth: () => ({ + authClient: {}, + basePaths: { auth: "/auth" }, + viewPaths: { auth: { signOut: "sign-out" } }, + navigate: vi.fn(), + localization: { + auth: { signOut: "Sign out" }, + settings: { + activeSessions: "Active sessions", + currentSession: "Current session", + revoke: "Revoke", + revokeSession: "Revoke session", + revokeSessionSuccess: "Session revoked", + }, + }, + }), + useSession: () => ({ data: { session: { token: "current-token" } } }), + useRevokeSession: () => ({ + mutate: revokeSessionMock, + isPending: false, + }), + useListSessions: () => ({ data: listSessionsData, isPending: false }), +})); + +describe("ActiveSessions", () => { + test("splits sessions into a Controllers section and a Workers section with a count", () => { + listSessionsData = [ + { + id: "session-1", + token: "current-token", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + createdAt: new Date(), + workerName: null, + }, + { + id: "session-2", + token: "worker-token-1", + userAgent: "", + createdAt: new Date(), + workerName: "sooryas-macbook", + }, + { + id: "session-3", + token: "worker-token-2", + userAgent: "", + createdAt: new Date(), + workerName: "office-desktop", + }, + ]; + + render(); + + expect(screen.getByText("Controllers")).toBeInTheDocument(); + expect(screen.getByText(WORKERS_HEADING)).toHaveTextContent("Workers (2)"); + expect(screen.getByText("sooryas-macbook")).toBeInTheDocument(); + expect(screen.getByText("office-desktop")).toBeInTheDocument(); + }); + + test("omits the Workers section entirely when there are no worker sessions", () => { + listSessionsData = [ + { + id: "session-1", + token: "current-token", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + createdAt: new Date(), + workerName: null, + }, + ]; + + render(); + + expect(screen.getByText("Controllers")).toBeInTheDocument(); + expect(screen.queryByText(WORKERS_HEADING)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/auth/security/active-sessions.tsx b/apps/web/src/components/auth/security/active-sessions.tsx index a7e0b10..535222a 100644 --- a/apps/web/src/components/auth/security/active-sessions.tsx +++ b/apps/web/src/components/auth/security/active-sessions.tsx @@ -11,44 +11,63 @@ import { ItemSeparator, } from "@/components/ui/item"; import { Skeleton } from "@/components/ui/skeleton"; -import { ActiveSession } from "./active-session"; +import { ActiveSession, type SessionWithWorkerName } from "./active-session"; export type ActiveSessionsProps = { className?: string; }; -/** - * Render a card listing all active sessions for the current user with revoke controls. - * - * Shows each session's browser, OS, IP address, and creation time. The current session is marked - * and navigates to sign-out on click, while other sessions can be revoked individually. - * - * @returns A JSX element containing the sessions card - */ export function ActiveSessions({ className }: ActiveSessionsProps) { - const { authClient, localization } = useAuth(); + const { authClient } = useAuth(); const { data: session } = useSession(authClient); const { data: sessions, isPending } = useListSessions(authClient); - const activeSessions = [...(sessions ?? [])].sort( - (a, b) => - Number(b.id === session?.session.id) - - Number(a.id === session?.session.id) + const allSessions = ((sessions as SessionWithWorkerName[] | undefined) ?? []) + .slice() + .sort( + (a, b) => + Number(b.id === session?.session.id) - + Number(a.id === session?.session.id) + ); + const controllerSessions = allSessions.filter((s) => !s.workerName); + const workerSessions = allSessions.filter((s) => s.workerName); + + return ( +
+ + 0}> + + +
); +} + +type SessionSectionProps = { + title: string; + sessions: SessionWithWorkerName[]; + isPending: boolean; +}; +function SessionSection({ title, sessions, isPending }: SessionSectionProps) { return (
-

- {localization.settings.activeSessions} -

+

{title}

- + - {activeSessions?.map((activeSession, index) => ( + {sessions.map((activeSession, index) => ( 0}> diff --git a/docs/adr/0023-worker-name-discriminates-login-sessions.md b/docs/adr/0023-worker-name-discriminates-login-sessions.md new file mode 100644 index 0000000..193e6e6 --- /dev/null +++ b/docs/adr/0023-worker-name-discriminates-login-sessions.md @@ -0,0 +1,11 @@ +# Login sessions gain a nullable `workerName` to discriminate workers from controllers + +_Decided 2026-08-06._ + +Better-auth's `session` table is shared by every peer type — browser sign-in, the desktop OAuth webview, the mobile Expo client, and the CLI worker (`cyrusd`) via the OAuth Device Authorization grant — with no way to tell them apart short of guessing from `userAgent`. The device-authorization grant is exclusively how `cyrusd` logs in today (no other peer type uses it), so we add a nullable `workerName` field to `session` via `authOptions.session.additionalFields` (backed by a `worker_name` D1 column, generated through the existing `auth:generate` → `db:generate` pipeline) and treat its presence as the discriminator: absent means a controller session (web/desktop/mobile), present means a worker session, holding that worker's display name. A separate boolean was considered and rejected as redundant — the name itself is both the flag and the label the UI needs. + +The CLI pushes its locally-generated name (`getOrCreate("name", generateName)`) to the server via better-auth's built-in `updateSession` endpoint. No custom API route is needed: better-auth's client exposes every registered server route — including core, non-plugin ones — through a dynamic path proxy, and `updateSession` already accepts arbitrary `additionalFields` on the caller's own session, authenticated the same way the CLI's other bearer-token calls already are. + +Both write sites — `login()` and `rename()` — treat a failed push as a hard failure of the whole command, not a warning: `login()` must not leave a token stored whose session lacks a synced `workerName`, and `rename()` must not update local config unless the server accepted the new name. This keeps local CLI config and the server's session row strictly in lock-step rather than allowing eventual-consistency drift that would show a stale or missing worker badge in the UI. + +The active-sessions UI splits into two sections (Controllers, Workers) filtered on this field, rather than inferring worker-ness heuristically from user-agent parsing — a heuristic would silently break if the CLI's HTTP client's default `User-Agent` ever changed.