Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
21 changes: 18 additions & 3 deletions apps/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -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<Result<void, string>> {
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<void> {
const { data, error } = await authClient.device.code({
client_id: CLIENT_ID,
Expand Down Expand Up @@ -61,7 +69,14 @@ export async function login(): Promise<void> {

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}` : ""}.`);
Expand Down
110 changes: 110 additions & 0 deletions apps/cli/src/commands/config/rename.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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");
});
});
14 changes: 13 additions & 1 deletion apps/cli/src/commands/config/rename.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
Expand Down
27 changes: 25 additions & 2 deletions apps/cli/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,39 @@
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",
token: async () => (await get("token")) ?? undefined,
},
},
});

export async function syncWorkerName(
name: string
): Promise<Result<void, string>> {
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();
}
2 changes: 1 addition & 1 deletion apps/server/drizzle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/auth/cli.ts
Original file line number Diff line number Diff line change
@@ -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
),
Expand Down
21 changes: 12 additions & 9 deletions apps/server/src/auth/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -32,19 +31,23 @@ export const authOptions = {
},
secret: env.BETTER_AUTH_SECRET,
baseURL: env.WEB_APP_URL,
session: {
additionalFields: {
workerName: {
type: "string",
required: false,
},
},
},
Comment thread
soorya-u marked this conversation as resolved.
advanced: {
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: [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `session` ADD `worker_name` text;
Loading