feat: add URL-driven workspace state and streamed Codex research

This commit is contained in:
2026-09-28 20:08:28 +09:00
parent 85d23a328b
commit 8085ad2f90
207 changed files with 15287 additions and 16026 deletions
+22 -22
View File
@@ -1,34 +1,34 @@
export type SafeOwner = {
id: number
email: string
name: string
onboardingCompletedAt: number | null
}
id: number;
email: string;
name: string;
onboardingCompletedAt: number | null;
};
export type AuthState = { needsSetup: boolean; owner: SafeOwner | null }
export type AuthState = { needsSetup: boolean; owner: SafeOwner | null };
type AuthInput =
| { action: 'login'; email: string; password: string }
| { action: "login"; email: string; password: string }
| {
action: 'setup'
email: string
password: string
name: string
setupToken: string
action: "setup";
email: string;
password: string;
name: string;
setupToken: string;
}
| { action: 'logout' }
| { action: 'onboard'; name: string }
| { action: "logout" }
| { action: "onboard"; name: string };
export async function authRequest(input: AuthInput): Promise<AuthState> {
const response = await fetch('/api/auth', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
const response = await fetch("/api/auth", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
})
const result = await response.json()
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Something went wrong. Please try again.')
throw new Error(result.error || "Something went wrong. Please try again.");
}
return result
return result;
}
+51 -64
View File
@@ -1,10 +1,10 @@
import { ArrowRight, LockKeyhole } from 'lucide-react'
import { type FormEvent, type ReactNode, useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { authRequest } from './auth-client'
import './auth.css'
import { ArrowRight, LockKeyhole } from "lucide-react";
import { type FormEvent, type ReactNode, useState } from "react";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { authRequest } from "./auth-client";
import { safeReturnTo } from "./return-to";
export function AuthFrame({ children }: { children: ReactNode }) {
return (
@@ -17,78 +17,70 @@ export function AuthFrame({ children }: { children: ReactNode }) {
{children}
</div>
</main>
)
);
}
export function LoginPage() {
return <CredentialsForm setup={false} />
return <CredentialsForm setup={false} />;
}
export function SetupPage() {
return <CredentialsForm setup />
return <CredentialsForm setup />;
}
function formText(data: FormData, name: string) {
const value = data.get(name);
return typeof value === "string" ? value : "";
}
function CredentialsForm({ setup }: { setup: boolean }) {
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (pending) return
const data = new FormData(event.currentTarget)
const email = String(data.get('email') ?? '').trim()
const password = String(data.get('password') ?? '')
if (setup && password !== data.get('confirmPassword')) {
setError('Passwords do not match.')
return
event.preventDefault();
if (pending) return;
const data = new FormData(event.currentTarget);
const email = formText(data, "email").trim();
const password = formText(data, "password");
if (setup && password !== data.get("confirmPassword")) {
setError("Passwords do not match.");
return;
}
setPending(true)
setError('')
setPending(true);
setError("");
try {
const state = await authRequest(
setup
? {
action: 'setup',
action: "setup",
email,
password,
name: String(data.get('name') ?? '').trim(),
setupToken: String(data.get('setupToken') ?? '').trim(),
name: formText(data, "name").trim(),
setupToken: formText(data, "setupToken").trim(),
}
: { action: 'login', email, password },
)
: { action: "login", email, password },
);
if (!state.owner?.onboardingCompletedAt) {
window.location.assign('/onboarding')
return
window.location.assign("/onboarding");
return;
}
const returnTo = new URLSearchParams(window.location.search).get(
'returnTo',
)
const destination =
returnTo &&
['/', '/deck', '/support', '/journal', '/inbox', '/vitals'].includes(
returnTo,
)
? returnTo
: '/'
window.location.assign(destination)
const returnTo = new URLSearchParams(window.location.search).get("returnTo");
window.location.assign(safeReturnTo(returnTo));
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Unable to sign in. Please try again.',
)
setPending(false)
setError(cause instanceof Error ? cause.message : "Unable to sign in. Please try again.");
setPending(false);
}
}
return (
<AuthFrame>
<header className="auth-heading">
<h1>{setup ? 'Make yourself at home.' : 'Welcome back.'}</h1>
<h1>{setup ? "Make yourself at home." : "Welcome back."}</h1>
<p>
{setup
? 'Create the owner account for your personal workspace.'
: 'Sign in to your personal workspace.'}
? "Create the owner account for your personal workspace."
: "Sign in to your personal workspace."}
</p>
</header>
<form onSubmit={submit} className="auth-form" aria-busy={pending}>
@@ -123,11 +115,11 @@ function CredentialsForm({ setup }: { setup: boolean }) {
id="password"
name="password"
type="password"
autoComplete={setup ? 'new-password' : 'current-password'}
autoComplete={setup ? "new-password" : "current-password"}
required
minLength={setup ? 15 : undefined}
maxLength={128}
aria-describedby={setup ? 'password-hint' : undefined}
aria-describedby={setup ? "password-hint" : undefined}
disabled={pending}
/>
{setup && (
@@ -173,27 +165,22 @@ function CredentialsForm({ setup }: { setup: boolean }) {
{error}
</p>
)}
<Button
type="submit"
size="lg"
disabled={pending}
className="auth-submit"
>
<Button type="submit" size="lg" disabled={pending} className="auth-submit">
{pending
? setup
? 'Creating account…'
: 'Signing in…'
? "Creating account…"
: "Signing in…"
: setup
? 'Create account'
: 'Sign in'}
? "Create account"
: "Sign in"}
<ArrowRight aria-hidden="true" />
</Button>
</form>
<p className="auth-footnote">
{setup
? 'This workspace has one owner. Connections can be added later.'
: 'A private workspace. Access is limited to its owner.'}
? "This workspace has one owner. Connections can be added later."
: "A private workspace. Access is limited to its owner."}
</p>
</AuthFrame>
)
);
}
+90 -122
View File
@@ -1,93 +1,83 @@
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, isAbsolute } from 'node:path'
import { eq, lte } from 'drizzle-orm'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import { authSessions, authThrottle, workspaceOwner } from '../storage/schema'
import type { SafeOwner } from './auth-client'
import { createHash, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute } from "node:path";
import { eq, lte } from "drizzle-orm";
import { type AppDatabase, getDatabase } from "../storage/database.server";
import { authSessions, authThrottle, workspaceOwner } from "../storage/schema";
import type { SafeOwner } from "./auth-client";
export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
const derive = (password: string, salt: string) =>
new Promise<Buffer>((resolve, reject) =>
scrypt(password, salt, 64, scryptOptions, (error, key) =>
error ? reject(error) : resolve(key),
),
)
const scryptOptions = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }
const throttleWindow = 15 * 60_000
);
const scryptOptions = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
const throttleWindow = 15 * 60_000;
export class AuthError extends Error {
constructor(
public status: number,
message: string,
) {
super(message)
this.name = 'AuthError'
super(message);
this.name = "AuthError";
}
}
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
const equal = (left: string, right: string) =>
timingSafeEqual(Buffer.from(hash(left)), Buffer.from(hash(right)))
timingSafeEqual(Buffer.from(hash(left)), Buffer.from(hash(right)));
function safeOwner(owner: typeof workspaceOwner.$inferSelect): SafeOwner {
return {
id: owner.id,
email: owner.email,
name: owner.name,
onboardingCompletedAt: owner.onboardingCompletedAt,
}
};
}
export function readAuthState(database = getDatabase()) {
return {
needsSetup: !database
.select({ id: workspaceOwner.id })
.from(workspaceOwner)
.get(),
}
needsSetup: !database.select({ id: workspaceOwner.id }).from(workspaceOwner).get(),
};
}
/** Bootstrap secret is read only by the server or operator CLI, never sent to clients. */
export function getSetupToken() {
const configured = process.env.WORKSPACE_SETUP_TOKEN
const configured = process.env.WORKSPACE_SETUP_TOKEN;
if (configured) {
if (configured.length < 32)
throw new AuthError(
503,
'The setup token must contain at least 32 characters.',
)
return configured
throw new AuthError(503, "The setup token must contain at least 32 characters.");
return configured;
}
const dbPath = process.env.TWITTER_LITE_DB_PATH
const dbPath = process.env.TWITTER_LITE_DB_PATH;
if (!dbPath || !isAbsolute(dbPath))
throw new AuthError(503, 'Configure a workspace database or setup token.')
const path = `${dbPath}.setup-token`
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
throw new AuthError(503, "Configure a workspace database or setup token.");
const path = `${dbPath}.setup-token`;
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
try {
writeFileSync(path, randomBytes(32).toString('base64url'), {
writeFileSync(path, randomBytes(32).toString("base64url"), {
mode: 0o600,
flag: 'wx',
})
flag: "wx",
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
const token = readFileSync(path, 'utf8').trim()
if (token.length < 32)
throw new AuthError(503, 'The workspace setup token is invalid.')
return token
const token = readFileSync(path, "utf8").trim();
if (token.length < 32) throw new AuthError(503, "The workspace setup token is invalid.");
return token;
}
function consumeAttempt(database: AppDatabase) {
database.transaction(
(tx) => {
const now = Date.now()
const current = tx.select().from(authThrottle).get()
const now = Date.now();
const current = tx.select().from(authThrottle).get();
if (current && current.resetAt > now) {
if (current.attempts >= 10)
throw new AuthError(
429,
'Too many attempts. Please try again in 15 minutes.',
)
throw new AuthError(429, "Too many attempts. Please try again in 15 minutes.");
tx.update(authThrottle)
.set({ attempts: current.attempts + 1 })
.where(eq(authThrottle.id, 1))
.run()
.run();
} else {
tx.insert(authThrottle)
.values({ id: 1, attempts: 1, resetAt: now + throttleWindow })
@@ -95,21 +85,18 @@ function consumeAttempt(database: AppDatabase) {
target: authThrottle.id,
set: { attempts: 1, resetAt: now + throttleWindow },
})
.run()
.run();
}
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
function session(
database: Pick<AppDatabase, 'insert' | 'delete'>,
database: Pick<AppDatabase, "insert" | "delete">,
owner: typeof workspaceOwner.$inferSelect,
) {
const sessionToken = randomBytes(32).toString('base64url')
database
.delete(authSessions)
.where(lte(authSessions.expiresAt, Date.now()))
.run()
const sessionToken = randomBytes(32).toString("base64url");
database.delete(authSessions).where(lte(authSessions.expiresAt, Date.now())).run();
database
.insert(authSessions)
.values({
@@ -117,125 +104,106 @@ function session(
ownerId: owner.id,
expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
})
.run()
database.delete(authThrottle).where(eq(authThrottle.id, 1)).run()
return { sessionToken, owner: safeOwner(owner) }
.run();
database.delete(authThrottle).where(eq(authThrottle.id, 1)).run();
return { sessionToken, owner: safeOwner(owner) };
}
function cleanName(name: string) {
const cleaned = name.trim()
const cleaned = name.trim();
if (!cleaned || cleaned.length > 80)
throw new AuthError(400, 'Enter a name of up to 80 characters.')
return cleaned
throw new AuthError(400, "Enter a name of up to 80 characters.");
return cleaned;
}
export async function createOwner(
input: { email: string; password: string; name: string; setupToken: string },
database = getDatabase(),
) {
if (!readAuthState(database).needsSetup)
throw new AuthError(
409,
'This workspace is already set up. Please sign in.',
)
consumeAttempt(database)
throw new AuthError(409, "This workspace is already set up. Please sign in.");
consumeAttempt(database);
if (!equal(input.setupToken, getSetupToken()))
throw new AuthError(401, 'Invalid setup credentials.')
const email = input.email.trim().toLowerCase()
throw new AuthError(401, "Invalid setup credentials.");
const email = input.email.trim().toLowerCase();
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
throw new AuthError(400, 'Enter a valid email address.')
throw new AuthError(400, "Enter a valid email address.");
if (input.password.length < 15 || input.password.length > 128)
throw new AuthError(400, 'Use a password between 15 and 128 characters.')
const name = cleanName(input.name)
const salt = randomBytes(16).toString('hex')
const derived = (await derive(input.password, salt)) as Buffer
throw new AuthError(400, "Use a password between 15 and 128 characters.");
const name = cleanName(input.name);
const salt = randomBytes(16).toString("hex");
const derived = (await derive(input.password, salt)) as Buffer;
return database.transaction(
(tx) => {
if (tx.select().from(workspaceOwner).get())
throw new AuthError(
409,
'This workspace is already set up. Please sign in.',
)
throw new AuthError(409, "This workspace is already set up. Please sign in.");
const owner = {
id: 1,
email,
name,
passwordHash: `${salt}:${derived.toString('hex')}`,
passwordHash: `${salt}:${derived.toString("hex")}`,
onboardingCompletedAt: null,
createdAt: Date.now(),
}
tx.insert(workspaceOwner).values(owner).run()
return session(tx, owner)
};
tx.insert(workspaceOwner).values(owner).run();
return session(tx, owner);
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
export async function signIn(
input: { email: string; password: string },
database = getDatabase(),
) {
consumeAttempt(database)
const owner = database.select().from(workspaceOwner).get()
export async function signIn(input: { email: string; password: string }, database = getDatabase()) {
consumeAttempt(database);
const owner = database.select().from(workspaceOwner).get();
if (input.password.length > 128 || input.email.length > 254)
throw new AuthError(401, 'Invalid email or password.')
const [salt = '', expected = ''] = owner?.passwordHash.split(':') ?? [
'0'.repeat(32),
'0'.repeat(128),
]
const actual = (await derive(input.password, salt)) as Buffer
throw new AuthError(401, "Invalid email or password.");
const [salt = "", expected = ""] = owner?.passwordHash.split(":") ?? [
"0".repeat(32),
"0".repeat(128),
];
const actual = (await derive(input.password, salt)) as Buffer;
if (
!owner ||
!equal(input.email.trim().toLowerCase(), owner.email) ||
!timingSafeEqual(actual, Buffer.from(expected, 'hex'))
!timingSafeEqual(actual, Buffer.from(expected, "hex"))
)
throw new AuthError(401, 'Invalid email or password.')
return session(database, owner)
throw new AuthError(401, "Invalid email or password.");
return session(database, owner);
}
export function getSession(
token: string | undefined,
database = getDatabase(),
): SafeOwner | null {
if (!token || token.length > 128) return null
export function getSession(token: string | undefined, database = getDatabase()): SafeOwner | null {
if (!token || token.length > 128) return null;
const found = database
.select()
.from(authSessions)
.where(eq(authSessions.tokenHash, hash(token)))
.get()
if (!found) return null
.get();
if (!found) return null;
if (found.expiresAt <= Date.now()) {
database
.delete(authSessions)
.where(eq(authSessions.tokenHash, found.tokenHash))
.run()
return null
database.delete(authSessions).where(eq(authSessions.tokenHash, found.tokenHash)).run();
return null;
}
const owner = database
.select()
.from(workspaceOwner)
.where(eq(workspaceOwner.id, found.ownerId))
.get()
return owner ? safeOwner(owner) : null
.get();
return owner ? safeOwner(owner) : null;
}
export function signOut(token: string | undefined, database = getDatabase()) {
if (token)
database
.delete(authSessions)
.where(eq(authSessions.tokenHash, hash(token)))
.run()
.run();
}
export function completeOnboarding(
token: string,
input: { name: string },
database = getDatabase(),
): SafeOwner {
const owner = getSession(token, database)
if (!owner) throw new AuthError(401, 'Please sign in.')
const owner = getSession(token, database);
if (!owner) throw new AuthError(401, "Please sign in.");
const update = {
name: cleanName(input.name),
onboardingCompletedAt: owner.onboardingCompletedAt ?? Date.now(),
}
database
.update(workspaceOwner)
.set(update)
.where(eq(workspaceOwner.id, owner.id))
.run()
return { ...owner, ...update }
};
database.update(workspaceOwner).set(update).where(eq(workspaceOwner.id, owner.id)).run();
return { ...owner, ...update };
}
+102 -119
View File
@@ -1,10 +1,10 @@
// @vitest-environment node
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { openDatabase } from '../storage/database.server'
import { authSessions, authThrottle, workspaceOwner } from '../storage/schema'
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { openDatabase } from "../storage/database.server";
import { authSessions, authThrottle, workspaceOwner } from "../storage/schema";
import {
completeOnboarding,
createOwner,
@@ -14,137 +14,120 @@ import {
SESSION_MAX_AGE_SECONDS,
signIn,
signOut,
} from './auth.server'
} from "./auth.server";
let database: ReturnType<typeof openDatabase>
let database: ReturnType<typeof openDatabase>;
const credentials = {
email: '[email protected]',
password: 'correct horse battery staple',
name: 'Owner',
setupToken: 's'.repeat(32),
}
email: "[email protected]",
password: "correct horse battery staple",
name: "Owner",
setupToken: "s".repeat(32),
};
beforeEach(() => {
database = openDatabase(':memory:')
vi.stubEnv('WORKSPACE_SETUP_TOKEN', credentials.setupToken)
})
database = openDatabase(":memory:");
vi.stubEnv("WORKSPACE_SETUP_TOKEN", credentials.setupToken);
});
afterEach(() => {
database.$client.close()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
describe('owner authentication', () => {
it('requires a private setup token and stores only derived credentials and session tokens', async () => {
expect(readAuthState(database)).toEqual({ needsSetup: true })
database.$client.close();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
describe("owner authentication", () => {
it("requires a private setup token and stores only derived credentials and session tokens", async () => {
expect(readAuthState(database)).toEqual({ needsSetup: true });
await expect(
createOwner({ ...credentials, setupToken: 'wrong' }, database),
).rejects.toMatchObject({ status: 401 })
expect(readAuthState(database).needsSetup).toBe(true)
const result = await createOwner(credentials, database)
expect(readAuthState(database)).toEqual({ needsSetup: false })
expect(result.owner.email).toBe(credentials.email)
expect(result.owner).not.toHaveProperty('passwordHash')
expect(
database.select().from(workspaceOwner).get()?.passwordHash,
).not.toContain(credentials.password)
expect(database.select().from(authSessions).get()?.tokenHash).not.toEqual(
result.sessionToken,
)
expect(getSession(result.sessionToken, database)).toEqual(result.owner)
createOwner({ ...credentials, setupToken: "wrong" }, database),
).rejects.toMatchObject({ status: 401 });
expect(readAuthState(database).needsSetup).toBe(true);
const result = await createOwner(credentials, database);
expect(readAuthState(database)).toEqual({ needsSetup: false });
expect(result.owner.email).toBe(credentials.email);
expect(result.owner).not.toHaveProperty("passwordHash");
expect(database.select().from(workspaceOwner).get()?.passwordHash).not.toContain(
credentials.password,
);
expect(database.select().from(authSessions).get()?.tokenHash).not.toEqual(result.sessionToken);
expect(getSession(result.sessionToken, database)).toEqual(result.owner);
await expect(createOwner(credentials, database)).rejects.toMatchObject({
status: 409,
})
})
it('only allows one owner even when setup requests race', async () => {
});
});
it("only allows one owner even when setup requests race", async () => {
const results = await Promise.allSettled([
createOwner(credentials, database),
createOwner(credentials, database),
])
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1)
expect(database.select().from(workspaceOwner).all()).toHaveLength(1)
})
it('checks the email and password and revokes logged-out sessions', async () => {
await createOwner(credentials, database)
]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
expect(database.select().from(workspaceOwner).all()).toHaveLength(1);
});
it("checks the email and password and revokes logged-out sessions", async () => {
await createOwner(credentials, database);
await expect(signIn({ ...credentials, password: "wrong" }, database)).rejects.toMatchObject({
status: 401,
message: "Invalid email or password.",
});
await expect(
signIn({ ...credentials, password: 'wrong' }, database),
signIn({ ...credentials, email: "[email protected]" }, database),
).rejects.toMatchObject({
status: 401,
message: 'Invalid email or password.',
})
await expect(
signIn({ ...credentials, email: '[email protected]' }, database),
).rejects.toMatchObject({
status: 401,
message: 'Invalid email or password.',
})
const result = await signIn(
{ ...credentials, email: '[email protected]' },
database,
)
expect(database.select().from(authThrottle).all()).toHaveLength(0)
expect(getSession('invented', database)).toBeNull()
expect(getSession(result.sessionToken, database)).not.toBeNull()
signOut(result.sessionToken, database)
expect(getSession(result.sessionToken, database)).toBeNull()
})
it('expires sessions and stores onboarding completion', async () => {
const result = await createOwner(credentials, database)
expect(result.owner.onboardingCompletedAt).toBeNull()
const owner = completeOnboarding(
result.sessionToken,
{ name: 'Yuta' },
database,
)
expect(owner.name).toBe('Yuta')
expect(owner.onboardingCompletedAt).toBeTypeOf('number')
expect(getSession(result.sessionToken, database)).toEqual(owner)
expect(() =>
completeOnboarding('invalid', { name: 'Other' }, database),
).toThrow('Please sign in.')
vi.spyOn(Date, 'now').mockReturnValue(
Date.now() + SESSION_MAX_AGE_SECONDS * 1000 + 1,
)
expect(getSession(result.sessionToken, database)).toBeNull()
expect(database.select().from(authSessions).all()).toHaveLength(0)
})
it('bounds setup and login attempts persistently and permits retry after cooldown', async () => {
message: "Invalid email or password.",
});
const result = await signIn({ ...credentials, email: "[email protected]" }, database);
expect(database.select().from(authThrottle).all()).toHaveLength(0);
expect(getSession("invented", database)).toBeNull();
expect(getSession(result.sessionToken, database)).not.toBeNull();
signOut(result.sessionToken, database);
expect(getSession(result.sessionToken, database)).toBeNull();
});
it("expires sessions and stores onboarding completion", async () => {
const result = await createOwner(credentials, database);
expect(result.owner.onboardingCompletedAt).toBeNull();
const owner = completeOnboarding(result.sessionToken, { name: "Yuta" }, database);
expect(owner.name).toBe("Yuta");
expect(owner.onboardingCompletedAt).toBeTypeOf("number");
expect(getSession(result.sessionToken, database)).toEqual(owner);
expect(() => completeOnboarding("invalid", { name: "Other" }, database)).toThrow(
"Please sign in.",
);
vi.spyOn(Date, "now").mockReturnValue(Date.now() + SESSION_MAX_AGE_SECONDS * 1000 + 1);
expect(getSession(result.sessionToken, database)).toBeNull();
expect(database.select().from(authSessions).all()).toHaveLength(0);
});
it("bounds setup and login attempts persistently and permits retry after cooldown", async () => {
for (let index = 0; index < 10; index++)
await expect(
createOwner({ ...credentials, setupToken: 'wrong' }, database),
).rejects.toMatchObject({ status: 401 })
createOwner({ ...credentials, setupToken: "wrong" }, database),
).rejects.toMatchObject({ status: 401 });
await expect(signIn(credentials, database)).rejects.toMatchObject({
status: 429,
})
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 15 * 60_000 + 1)
await expect(createOwner(credentials, database)).resolves.toHaveProperty(
'sessionToken',
)
})
it('rejects short passwords and invalid identity fields', async () => {
});
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 15 * 60_000 + 1);
await expect(createOwner(credentials, database)).resolves.toHaveProperty("sessionToken");
});
it("rejects short passwords and invalid identity fields", async () => {
await expect(
createOwner({ ...credentials, password: 'short' }, database),
).rejects.toMatchObject({ status: 400 })
await expect(
createOwner({ ...credentials, email: 'invalid' }, database),
).rejects.toMatchObject({ status: 400 })
await expect(
createOwner({ ...credentials, name: ' ' }, database),
).rejects.toMatchObject({ status: 400 })
})
it('creates a stable local setup token with private file permissions', () => {
const directory = mkdtempSync(join(tmpdir(), 'workspace-setup-'))
createOwner({ ...credentials, password: "short" }, database),
).rejects.toMatchObject({ status: 400 });
await expect(createOwner({ ...credentials, email: "invalid" }, database)).rejects.toMatchObject(
{ status: 400 },
);
await expect(createOwner({ ...credentials, name: " " }, database)).rejects.toMatchObject({
status: 400,
});
});
it("creates a stable local setup token with private file permissions", () => {
const directory = mkdtempSync(join(tmpdir(), "workspace-setup-"));
try {
vi.stubEnv('WORKSPACE_SETUP_TOKEN', '')
const path = join(directory, 'workspace.sqlite')
vi.stubEnv('TWITTER_LITE_DB_PATH', path)
const token = getSetupToken()
expect(token.length).toBeGreaterThanOrEqual(32)
expect(getSetupToken()).toBe(token)
expect(readFileSync(`${path}.setup-token`, 'utf8')).toBe(token)
expect(statSync(`${path}.setup-token`).mode & 0o777).toBe(0o600)
vi.stubEnv("WORKSPACE_SETUP_TOKEN", "");
const path = join(directory, "workspace.sqlite");
vi.stubEnv("TWITTER_LITE_DB_PATH", path);
const token = getSetupToken();
expect(token.length).toBeGreaterThanOrEqual(32);
expect(getSetupToken()).toBe(token);
expect(readFileSync(`${path}.setup-token`, "utf8")).toBe(token);
expect(statSync(`${path}.setup-token`).mode & 0o777).toBe(0o600);
} finally {
rmSync(directory, { recursive: true, force: true })
rmSync(directory, { recursive: true, force: true });
}
})
})
});
});
+25 -24
View File
@@ -1,36 +1,37 @@
import { getSession, readAuthState } from './auth.server'
import { sessionToken } from './http.server'
import { getSession, readAuthState } from "./auth.server";
import { sessionToken } from "./http.server";
import { safeReturnTo } from "./return-to";
const publicPaths = new Set(['/login', '/setup', '/api/auth'])
const publicPaths = new Set(["/login", "/setup", "/api/auth"]);
export function checkSessionAccess(request: Request): Response | null {
const url = new URL(request.url)
if (publicPaths.has(url.pathname)) return null
const owner = getSession(sessionToken(request))
if (owner?.onboardingCompletedAt) return null
if (owner && url.pathname === '/onboarding') return null
const url = new URL(request.url);
if (publicPaths.has(url.pathname)) return null;
const owner = getSession(sessionToken(request));
if (owner?.onboardingCompletedAt) return null;
if (owner && url.pathname === "/onboarding") return null;
const document =
request.method === 'GET' &&
request.headers.get('accept')?.includes('text/html') &&
!url.pathname.startsWith('/api/') &&
!url.pathname.startsWith('/_serverFn/')
request.method === "GET" &&
request.headers.get("accept")?.includes("text/html") &&
!url.pathname.startsWith("/api/") &&
!url.pathname.startsWith("/_serverFn/");
if (document) {
const location = owner
? '/onboarding'
: readAuthState().needsSetup
? '/setup'
: '/login'
const location = owner ? "/onboarding" : readAuthState().needsSetup ? "/setup" : "/login";
return new Response(null, {
status: 303,
headers: { location, 'cache-control': 'no-store' },
})
headers: {
location:
location === "/login" && (url.pathname !== "/" || !!url.search)
? `/login?${new URLSearchParams({ returnTo: safeReturnTo(url.pathname + url.search) })}`
: location,
"cache-control": "no-store",
},
});
}
return Response.json(
{
error: owner
? 'Complete onboarding to continue.'
: 'Sign in to continue.',
error: owner ? "Complete onboarding to continue." : "Sign in to continue.",
},
{ status: owner ? 403 : 401, headers: { 'cache-control': 'no-store' } },
)
{ status: owner ? 403 : 401, headers: { "cache-control": "no-store" } },
);
}
+48 -60
View File
@@ -7,97 +7,85 @@ import {
SESSION_MAX_AGE_SECONDS,
signIn,
signOut,
} from './auth.server'
} from "./auth.server";
const SESSION_COOKIE = 'workspace_session'
const SESSION_COOKIE = "workspace_session";
export function sessionToken(request: Request) {
return request.headers
.get('cookie')
?.split(';')
.get("cookie")
?.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(`${SESSION_COOKIE}=`))
?.slice(SESSION_COOKIE.length + 1)
?.slice(SESSION_COOKIE.length + 1);
}
export function authState(request: Request) {
return { ...readAuthState(), owner: getSession(sessionToken(request)) }
return { ...readAuthState(), owner: getSession(sessionToken(request)) };
}
function cookie(token: string, clear = false) {
const secure = process.env.TWITTER_LITE_ORIGIN?.startsWith('https:')
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_MAX_AGE_SECONDS}${secure ? '; Secure' : ''}`
const secure = process.env.TWITTER_LITE_ORIGIN?.startsWith("https:");
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_MAX_AGE_SECONDS}${secure ? "; Secure" : ""}`;
}
export async function authEndpoint(request: Request) {
const headers = new Headers({
'cache-control': 'no-store',
vary: 'Cookie',
'content-type': 'application/json',
})
"cache-control": "no-store",
vary: "Cookie",
"content-type": "application/json",
});
try {
if (request.method === 'GET')
return Response.json(authState(request), { headers })
if (request.method !== 'POST')
return new Response(null, { status: 405, headers })
if (!request.headers.get('content-type')?.startsWith('application/json'))
throw new AuthError(400, 'Use a JSON request.')
const body = await request.text()
if (body.length > 8192) throw new AuthError(413, 'Request is too large.')
let data: Record<string, unknown>
if (request.method === "GET") return Response.json(authState(request), { headers });
if (request.method !== "POST") return new Response(null, { status: 405, headers });
if (!request.headers.get("content-type")?.startsWith("application/json"))
throw new AuthError(400, "Use a JSON request.");
const body = await request.text();
if (body.length > 8192) throw new AuthError(413, "Request is too large.");
let data: Record<string, unknown>;
try {
const parsed = JSON.parse(body)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
throw new Error('invalid body')
data = parsed
const parsed = JSON.parse(body);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
throw new Error("invalid body");
data = parsed;
} catch {
throw new AuthError(400, 'Check the form and try again.')
throw new AuthError(400, "Check the form and try again.");
}
const text = (key: string) =>
typeof data[key] === 'string' ? data[key] : ''
const oldToken = sessionToken(request)
const text = (key: string) => (typeof data[key] === "string" ? data[key] : "");
const oldToken = sessionToken(request);
switch (data.action) {
case 'setup':
case 'login': {
case "setup":
case "login": {
const result =
data.action === 'setup'
data.action === "setup"
? await createOwner({
email: text('email'),
password: text('password'),
name: text('name'),
setupToken: text('setupToken'),
email: text("email"),
password: text("password"),
name: text("name"),
setupToken: text("setupToken"),
})
: await signIn({ email: text('email'), password: text('password') })
signOut(oldToken)
headers.set('set-cookie', cookie(result.sessionToken))
return Response.json(
{ needsSetup: false, owner: result.owner },
{ headers },
)
: await signIn({ email: text("email"), password: text("password") });
signOut(oldToken);
headers.set("set-cookie", cookie(result.sessionToken));
return Response.json({ needsSetup: false, owner: result.owner }, { headers });
}
case 'onboard': {
const owner = completeOnboarding(oldToken ?? '', { name: text('name') })
return Response.json({ needsSetup: false, owner }, { headers })
case "onboard": {
const owner = completeOnboarding(oldToken ?? "", { name: text("name") });
return Response.json({ needsSetup: false, owner }, { headers });
}
case 'logout':
signOut(oldToken)
headers.set('set-cookie', cookie('', true))
return Response.json(
{ needsSetup: readAuthState().needsSetup, owner: null },
{ headers },
)
case "logout":
signOut(oldToken);
headers.set("set-cookie", cookie("", true));
return Response.json({ needsSetup: readAuthState().needsSetup, owner: null }, { headers });
default:
throw new AuthError(400, 'Unknown action.')
throw new AuthError(400, "Unknown action.");
}
} catch (error) {
if (error instanceof AuthError)
return Response.json(
{ error: error.message },
{ status: error.status, headers },
)
return Response.json({ error: error.message }, { status: error.status, headers });
return Response.json(
{ error: 'Unable to complete the request. Please try again.' },
{ error: "Unable to complete the request. Please try again." },
{ status: 500, headers },
)
);
}
}
+33 -46
View File
@@ -1,55 +1,47 @@
import { useRouteContext } from '@tanstack/react-router'
import { ArrowRight, BookOpen, House, MessagesSquare } from 'lucide-react'
import { type FormEvent, useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { authRequest } from './auth-client'
import { AuthFrame } from './auth-page'
import { useRouteContext } from "@tanstack/react-router";
import { ArrowRight, BookOpen, House, MessagesSquare } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { authRequest } from "./auth-client";
import { AuthFrame } from "./auth-page";
export function OnboardingPage() {
const { auth } = useRouteContext({ from: '__root__' })
const [name, setName] = useState(auth.owner?.name ?? '')
const [step, setStep] = useState(1)
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const { auth } = useRouteContext({ from: "__root__" });
const [name, setName] = useState(auth.owner?.name ?? "");
const [step, setStep] = useState(1);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function finish() {
setPending(true)
setError('')
setPending(true);
setError("");
try {
await authRequest({ action: 'onboard', name: name.trim() })
window.location.assign('/')
await authRequest({ action: "onboard", name: name.trim() });
window.location.assign("/");
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Unable to save. Please try again.',
)
setPending(false)
setError(cause instanceof Error ? cause.message : "Unable to save. Please try again.");
setPending(false);
}
}
async function signOut() {
setPending(true)
setError('')
setPending(true);
setError("");
try {
await authRequest({ action: 'logout' })
window.location.assign('/login')
await authRequest({ action: "logout" });
window.location.assign("/login");
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Unable to sign out. Please try again.',
)
setPending(false)
setError(cause instanceof Error ? cause.message : "Unable to sign out. Please try again.");
setPending(false);
}
}
function next(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!name.trim()) return
setStep(2)
event.preventDefault();
if (!name.trim()) return;
setStep(2);
}
return (
@@ -116,20 +108,15 @@ export function OnboardingPage() {
</li>
</ul>
<p className="auth-prototype">
Home, contacts, notes, reading, and vitals currently use sample
data. You can add connections from your profile menu whenever you’re
ready.
Home, contacts, notes, reading, and vitals currently use sample data. You can add
connections from your profile menu whenever you’re ready.
</p>
<div className="auth-actions">
<Button
variant="ghost"
onClick={() => setStep(1)}
disabled={pending}
>
<Button variant="ghost" onClick={() => setStep(1)} disabled={pending}>
Back
</Button>
<Button size="lg" onClick={finish} disabled={pending}>
{pending ? 'Saving…' : 'Open workspace'}
{pending ? "Saving…" : "Open workspace"}
<ArrowRight aria-hidden="true" />
</Button>
</div>
@@ -147,5 +134,5 @@ export function OnboardingPage() {
</Button>
</footer>
</AuthFrame>
)
);
}
+19
View File
@@ -0,0 +1,19 @@
import { expect, it } from "vitest";
import { safeReturnTo } from "./return-to";
it("keeps the selected view through login", () => {
expect(safeReturnTo("/inbox?q=agents&article=browser#read")).toBe(
"/inbox?q=agents&article=browser#read",
);
});
it.each([
"https://outside.invalid",
"//outside.invalid",
"/\\outside.invalid",
"/api/auth",
"/login",
"/inbox\n",
undefined,
])("rejects an unsafe destination: %s", (value) => {
expect(safeReturnTo(value)).toBe("/");
});
+15
View File
@@ -0,0 +1,15 @@
const paths = new Set(["/", "/deck", "/support", "/journal", "/inbox", "/vitals"]);
export function safeReturnTo(value: unknown): string {
if (
typeof value !== "string" ||
!value.startsWith("/") ||
value.startsWith("//") ||
/[\\\r\n]/.test(value)
)
return "/";
const url = new URL(value, "https://workspace.invalid");
return url.origin === "https://workspace.invalid" && paths.has(url.pathname)
? `${url.pathname}${url.search}${url.hash}`
: "/";
}
+9 -9
View File
@@ -1,14 +1,14 @@
import { createIsomorphicFn } from '@tanstack/react-start'
import type { AuthState } from './auth-client'
import { createIsomorphicFn } from "@tanstack/react-start";
import type { AuthState } from "./auth-client";
export const loadAuthState = createIsomorphicFn()
.server(async (): Promise<AuthState> => {
const { getRequest } = await import('@tanstack/react-start/server')
const { authState } = await import('./http.server')
return authState(getRequest())
const { getRequest } = await import("@tanstack/react-start/server");
const { authState } = await import("./http.server");
return authState(getRequest());
})
.client(async (): Promise<AuthState> => {
const response = await fetch('/api/auth', { cache: 'no-store' })
if (!response.ok) throw new Error('Unable to check your session.')
return response.json()
})
const response = await fetch("/api/auth", { cache: "no-store" });
if (!response.ok) throw new Error("Unable to check your session.");
return response.json();
});