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
10 changes: 10 additions & 0 deletions apps/web/src/otterware/account/AccountSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ export function AccountSettingsPage() {
</Button>
}
/>
<SettingsRow
id="account-command-line"
title="Command line"
description={
<>
Run <code>otterdrive auth login</code> in your terminal, then approve the sign-in in
your browser with your Otter Account.
</>
}
/>
</SettingsSection>
{environmentId !== null ? (
<SettingsSection id="account-environment" title="Environment">
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/otterware/settingsRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,17 @@ const ACCOUNT_SEARCH_ITEM: OtterwareSettingsSearchItem = {
id: "account",
title: "Otter Account",
to: ACCOUNT_SETTINGS_PATH,
searchTerms: ["account", "sign in", "sign out", "otter", "devices", "tokens"],
searchTerms: [
"account",
"sign in",
"sign out",
"otter",
"devices",
"tokens",
"command line",
"CLI",
"otterdrive auth login",
],
};

const MODULES_SEARCH_ITEM: OtterwareSettingsSearchItem = {
Expand Down
8 changes: 5 additions & 3 deletions docs/operations/otter-fork.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ git merge --no-ff --no-commit upstream/main
Resolve every conflict with upstream's current behavior, keeping only the additional Otterware
functionality. Do not hand-resolve generated files: run `vp i` for `pnpm-lock.yaml`, regenerate
`apps/web/src/routeTree.gen.ts` when routes changed, and rerun
`.github/scripts/otter-adapt-upstream.sh` so its output matches the new upstream. If upstream added
`.github/scripts/otter-adapt-upstream.sh` so its output matches the new upstream. Run
`scripts/otter/rebrand-ui-text.sh` for user-facing text upstream added; a sync is where one-off
rebrands of upstream files belong, so they never enter the seam list. If upstream added
relay migrations that branch from the fork's chain, add a merge migration that records both heads as
parents without changing the schema:

Expand Down Expand Up @@ -85,8 +87,8 @@ onto the new `origin/main`, using their previous base explicitly (`git rebase --

**Keep the diff small:** prefer repository variables and secrets over code, and new files over
edits to upstream files. Leave internal names (`@t3tools/*`, `T3CODE_*`, code identifiers) alone.
`scripts/otter/rebrand-ui-text.sh` renames client UI text; it runs only by hand, and its exclusions
list text that must keep matching what the server or agents emit.
`scripts/otter/rebrand-ui-text.sh` renames client UI text; it runs by hand during a sync, and its
exclusions list text that must keep matching what the server or agents emit.

## Identities and infrastructure

Expand Down
12 changes: 8 additions & 4 deletions infra/cloud/CUTOVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,15 @@ newer local edits. `sync.changed` is an invalidation hint, never the copy of rec
no account continue using the same local store and outbox.

The imported Drive API is at `/api/v1/`; it accepts API keys/Drive sessions and Cloud tokens.
Cloud users must already have an explicitly linked Drive identity (no matching by email).
Cloud users get a Drive identity and personal drive on their first authenticated request, linked
by the verified Otter Account subject (never by matching email).
Preview and thumbnail grants continue targeting **only** `usercontent.otterware.app`, using the
existing key. The Cloud host rejects `/raw/*` before dispatch. The Drive profile's W5 static
build replaces `public/` only at cutover; this API deployment does not ship an incomplete Drive UI.
The imported CLI source is in `drive-cli/`, built by `build:cli`; no CLI release is published.
existing key. The Cloud host rejects `/raw/*` before dispatch. The Cloud build ships the
standalone Drive profile under `public/drive/`; moving the canonical Drive hostname still waits
for cutover. CLI device approval at `/device` is served by the Worker
on that canonical hostname, independently of the SPA, using Drive's Otter Account OAuth session.
The CLI source is in `apps/drive-cli/`, built by `build:cli`. Its separate
`publish-drive-cli.yml` workflow publishes to npm only on manual dispatch.

OpenRouter uses `/v1/cloud/agent/*` and a new `CloudAgentHub` namespace, preserving the imported
`/connection`, `/sessions`, `/turn`, `/tool-result`, and `/cancel` protocol. Keys are sealed;
Expand Down
123 changes: 123 additions & 0 deletions infra/cloud/drive/src/server/device-approval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { html, raw } from "hono/html";
import { createAuth } from "./auth";
import type { Env } from "./types";

/** The CLI's browser approval survives independently of the Drive profile's SPA. */
export async function deviceApproval(request: Request, env: Env): Promise<Response> {
if (request.method !== "GET" && request.method !== "HEAD")
return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });
const userCode = new URL(request.url).searchParams.get("user_code") ?? "";
let content;
let script = "";
let status = 200;
if (!/^[A-Za-z0-9-]{4,32}$/.test(userCode)) {
content = html`<p>Enter the code shown by <code>otterdrive auth login</code>.</p>
<form method="get">
<label for="user_code">Connection code</label>
<input id="user_code" name="user_code" required autocomplete="off" maxlength="32" />
<button>Continue</button>
</form>`;
} else {
const auth = createAuth(env);
const session = await auth.api.getSession({ headers: request.headers });
if (!session) {
content = html`<p>
Sign in with your Otter Account to review code <strong>${userCode}</strong>.
</p>
<button id="sign-in">Sign in with Otter Account</button>`;
const callbackURL = `${env.APP_URL}/device?${new URLSearchParams({ user_code: userCode })}`;
script = `document.querySelector('#sign-in').onclick = async () => {
try {
const response = await fetch('/api/auth/sign-in/social', { method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({provider: 'otter', callbackURL: ${JSON.stringify(callbackURL)}, disableRedirect: true}) });
const result = await response.json();
if (!response.ok || !result.url) throw new Error();
location.assign(result.url);
} catch { document.querySelector('#result').textContent = 'Could not sign in. Please try again.'; }
};`;
} else {
const device = await auth.api
.deviceVerify({
headers: request.headers,
query: { user_code: userCode },
})
.catch(() => null);
if (!device || !["otterdrive-cli", "otterware-cli"].includes(device.client_id ?? "")) {
status = 400;
content = html`<p>
This code is invalid or expired. Run <code>otterdrive auth login</code> again.
</p>`;
} else if (device.status !== "pending") {
content = html`<p>This connection has already been reviewed. Return to your terminal.</p>`;
} else {
content = html`<p>
Connect the CLI displaying <strong>${userCode}</strong> to
<strong>${session.user.email}</strong>.
</p>
<p>Approve only if you started this sign-in on your own machine.</p>
<form id="review">
<button value="approve">Approve</button><button value="deny">Deny</button>
</form>`;
script = `document.querySelector('#review').onsubmit = async event => {
event.preventDefault();
try {
const action = event.submitter.value;
const response = await fetch('/api/auth/device/' + action, { method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({userCode: ${JSON.stringify(userCode)}}) });
if (!response.ok) throw new Error();
document.querySelector('#result').textContent = action === 'approve' ? 'Approved. Return to your terminal.' : 'Connection denied.';
document.querySelector('#review').remove();
} catch { document.querySelector('#result').textContent = 'Could not review this connection. Please try again.'; }
};`;
}
}
}
const nonce = crypto.randomUUID();
const body = await html`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Connect Otter Drive</title>
<style nonce="${nonce}">
:root {
color-scheme: light dark;
font-family: system-ui, sans-serif;
}
body {
max-width: 28rem;
margin: 12vh auto;
padding: 1.5rem;
line-height: 1.6;
}
button,
input {
font: inherit;
padding: 0.5rem 0.75rem;
margin: 0.5rem 0.5rem 0.5rem 0;
}
label {
display: block;
}
</style>
</head>
<body>
<main>
<h1>Connect Otter Drive</h1>
${content}
<p id="result" role="status"></p>
</main>
<script nonce="${nonce}">
${raw(script)};
</script>
</body>
</html>`;
return new Response(request.method === "HEAD" ? null : body.toString(), {
status,
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
"referrer-policy": "no-referrer",
"x-content-type-options": "nosniff",
"content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`,
},
});
}
27 changes: 24 additions & 3 deletions infra/cloud/drive/src/server/identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createAuth } from "./auth";
import { authenticate } from "./actor";
import { deleteIdentity, identityRequest } from "./identity";
import { browserAuth } from "./browser-auth";
import { deviceApproval } from "./device-approval";
import type { Env } from "./types";

let server: ReturnType<typeof createServer>;
Expand Down Expand Up @@ -358,12 +359,24 @@ describe("shared Otter identity", () => {
user_code: string;
device_code: string;
};
const claim = await auth.handler(
new Request(`${env.APP_URL}/api/auth/device?user_code=${code.user_code}`, { headers }),
);
const pageUrl = `${env.APP_URL}/device?user_code=${code.user_code}`;
const signedOut = await deviceApproval(new Request(pageUrl), env);
expect(await signedOut.text()).toContain("Sign in with Otter Account");
const claim = await deviceApproval(new Request(pageUrl, { headers }), env);
expect(claim.status).toBe(200);
const page = await claim.text();
expect(page).toContain(code.user_code);
expect(page).toContain("owner@example.com");
expect(page).toContain('value="approve"');
expect(page).toContain('value="deny"');
expect(
(await auth.api.deviceVerify({ headers, query: { user_code: code.user_code } })).status,
).toBe("pending");
const approved = await call("/device/approve", { userCode: code.user_code }, true);
expect(approved.status).toBe(200);
expect(await (await deviceApproval(new Request(pageUrl, { headers }), env)).text()).toContain(
"already been reviewed",
);
const granted = await call("/device/token", {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: code.device_code,
Expand All @@ -381,6 +394,14 @@ describe("shared Otter identity", () => {
expect(actor.userId).toBe("stable-otter-id");
expect(actor.folderId).toBe("folder");
});
it("rejects invalid or expired approval codes and never accepts a page mutation", async () => {
const { response } = await login();
const request = new Request(`${env.APP_URL}/device?user_code=INVALID`, {
headers: { cookie: cookies(response) },
});
expect((await deviceApproval(request, env)).status).toBe(400);
expect((await deviceApproval(new Request(request, { method: "POST" }), env)).status).toBe(405);
});
it("does not link a new identity to an existing user just because the emails match", async () => {
const { response } = await login("different-otter-id");
expect(response.headers.get("location")).toContain("error=");
Expand Down
2 changes: 2 additions & 0 deletions infra/cloud/src/legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { authenticate as cloudAuthenticate } from "./auth.ts";
import { createAuth } from "../drive/src/server/auth.ts";
import { authenticate } from "../drive/src/server/actor.ts";
import { ensureDriveIdentity } from "./drive-identity.ts";
import { deviceApproval } from "../drive/src/server/device-approval.ts";

export function accountsEnv(env: Env): AccountsEnv {
return {
Expand Down Expand Up @@ -91,6 +92,7 @@ export async function drive(request: Request, env: Env, parallel: boolean) {
if (!isAllowedHostPath(request, hostBindings)) return hostNotFound();
const url = new URL(request.url);
try {
if (url.pathname === "/device") return await deviceApproval(request, bindings);
if (url.pathname.startsWith("/api/v1/")) {
// Provision from the authenticated Accounts subject, never an email match.
if (request.headers.has("x-otter-audience")) {
Expand Down
41 changes: 41 additions & 0 deletions infra/cloud/src/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
import { bundle } from "../scripts/bundle.ts";

let runtime: Miniflare;
let workerScript: string;
const origin = "https://cloud.otterware.app";
const audience = "environment:test";
const identityScript = `
Expand All @@ -29,6 +30,7 @@ export default { async fetch(request, env) {
`;
beforeAll(async () => {
const result = await bundle("src/worker.ts", false);
workerScript = result.outputFiles![0]!.text;
runtime = new Miniflare(
convertV4MiniflareOptions({
workers: [
Expand Down Expand Up @@ -134,6 +136,45 @@ const mutation = (
) => ({ collection: "settings", id, modifiedAt, value, mutationId });

describe("parallel Cloud Worker", () => {
it("serves CLI approval on the Drive host without falling through to SPA assets", async () => {
const cutover = new Miniflare(
convertV4MiniflareOptions({
modules: true,
script: workerScript,
compatibilityDate: "2026-09-01",
compatibilityFlags: ["nodejs_compat"],
bindings: {
CLOUD_ORIGIN: origin,
LEGACY_HOSTS: "enabled",
DRIVE_SECRET: "test-drive-secret-is-at-least-32-characters",
CONTENT_SIGNING_KEY: "test-content-secret-is-at-least-32-characters",
},
d1Databases: { DRIVE_DB: "device-test" },
}),
);
try {
const page = await cutover.dispatchFetch("https://drive.otterware.app/device");
expect(page.status).toBe(200);
expect(page.headers.get("cache-control")).toBe("no-store");
expect(await page.text()).toContain("otterdrive auth login");
expect((await cutover.dispatchFetch("https://usercontent.otterware.app/device")).status).toBe(
404,
);
expect(
(await cutover.dispatchFetch("https://drive.otterware.app/device", { method: "POST" }))
.status,
).toBe(405);
} finally {
await cutover.dispose();
}
});
it("sends device approval to Drive's OAuth origin, preserving the CLI code", async () => {
const response = await request("/device?user_code=ABCD-EFGH", { redirect: "manual" });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(
"https://drive.otterware.app/device?user_code=ABCD-EFGH",
);
});
it("serves health only on the new hostname and rejects uploaded-content paths", async () => {
expect((await request("/health")).status).toBe(200);
expect((await request("/raw/a/test/version/index.html")).status).toBe(404);
Expand Down
4 changes: 4 additions & 0 deletions infra/cloud/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ function forwardToMail(c: Context<App>, request: Request, sessionToken: string)
}
app.all("/v1/cloud/mail/*", (c) => forwardToMail(c, c.req.raw, c.var.identity.sessionToken));
app.all("/api/*", (c) => drive(c.req.raw, c.env, true));
// CLI auth remains on Drive's canonical origin, including its OAuth callback cookie.
app.get("/device", (c) =>
c.redirect(`https://drive.otterware.app/device${new URL(c.req.url).search}`, 302),
);
app.notFound((c) => {
const redirect = driveStaticRedirect(c.req.raw);
if (redirect) return redirect;
Expand Down
14 changes: 7 additions & 7 deletions modules/mail/contracts/src/rpc.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { describe, expect, it } from "vite-plus/test";
import * as Schema from "effect/Schema";
import { MailContract, MailEvent, MailOperations } from "./rpc.ts";
import { MailEvent, MailOperations } from "./rpc.ts";

const send = Schema.decodeUnknownSync(MailOperations["gmail:sendMessage"].payloadSchema);
const signInImap = Schema.decodeUnknownSync(MailOperations["gmail:signInImap"].payloadSchema);
const event = Schema.decodeUnknownSync(MailEvent);

describe("Mail RPC boundary", () => {
it("rejects malformed send and mailbox parameters before dispatch", () => {
const send = Schema.decodeUnknownSync(MailOperations["gmail:sendMessage"].payloadSchema);
expect(() => send({ accountId: "a", to: 123, subject: "test", body: "text" })).toThrow();
expect(() =>
send({
Expand All @@ -17,15 +20,12 @@ describe("Mail RPC boundary", () => {
).toThrow();
expect(send({ accountId: "a", to: "b", subject: "test", body: "text" }).to).toBe("b");
expect(() =>
Schema.decodeUnknownSync(MailOperations["gmail:signInImap"].payloadSchema)({
signInImap({
accountId: "a",
}),
).toThrow();
});
it("authorizes each named method and refuses arbitrary event channels", () => {
expect(Object.keys(MailContract.scopes)).toHaveLength(Object.keys(MailOperations).length + 1);
expect(Object.keys(MailContract.scopes).some((name) => /invoke/.test(name))).toBe(false);
const event = Schema.decodeUnknownSync(MailEvent);
it("refuses arbitrary event channels and malformed event payloads", () => {
expect(() => event({ channel: "agent:send", params: {} })).toThrow();
expect(() =>
event({ channel: "gmail:send-failed", params: { subject: "Hi", savedToDrafts: "yes" } }),
Expand Down
Loading
Loading