diff --git a/.env.example b/.env.example index 83c2fde..87c4f19 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,6 @@ TWITTER_LITE_MASTODON_ORIGINS=https://fedi.yutakobayashi.com # TWITTER_LITE_CODEX_URL=ws://127.0.0.1:4500 # TWITTER_LITE_CODEX_MODEL=gpt-6-astra # TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research + +# Optional initial owner setup code (32+ characters). Otherwise generated beside DB. +# WORKSPACE_SETUP_TOKEN= diff --git a/.gitignore b/.gitignore index cc1f0d4..ac80fcd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ node_modules/ playwright-report/ test-results/ .data/ +*.setup-token diff --git a/README.md b/README.md index 8203579..1f5560a 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,13 @@ or change direction. Original-post links open their source site; SNS reply threads are not rendered inside the app. Bluesky, Threads, and Nostr connectors are not implemented. +## Login and onboarding + +The workspace requires a single owner account with email and password. First +use opens account setup, followed by a short onboarding. Run +`nix develop -c pnpm account:setup` on the server to obtain the private setup +code. See [owner login](docs/login.md) for configuration, sessions, and limits. + ## Requirements and setup Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc` @@ -107,6 +114,7 @@ nix develop -c pnpm test:e2e nix develop -c pnpm test:live nix develop -c pnpm build nix develop -c pnpm start +nix develop -c pnpm account:setup nix develop -c pnpm db:generate nix develop -c pnpm codex:serve ``` @@ -271,10 +279,10 @@ to localhost does not supply the required identity. Playwright supplies an explicit fixture identity to its isolated test server. For a private, tailnet-only reverse proxy such as Traefik, explicitly set -`TWITTER_LITE_AUTH_MODE=none` to disable application identity checks. This mode -does not require `TWITTER_LITE_ALLOWED_LOGIN` or Tailscale identity headers; -anyone who can reach that proxy can use the app and its connected accounts and -Codex. Bind the backend to loopback or its Tailscale address and restrict proxy +`TWITTER_LITE_AUTH_MODE=none` to disable the Tailscale identity check. This mode +does not require `TWITTER_LITE_ALLOWED_LOGIN` or Tailscale identity headers. +App login remains mandatory, including access to connected accounts and Codex. +Bind the backend to loopback or its Tailscale address and restrict proxy access to the tailnet. Both modes require the exact configured `Origin` for state-changing requests, including chat and deck mutations. Missing origin or an unknown auth mode diff --git a/docs/login.md b/docs/login.md new file mode 100644 index 0000000..885f58b --- /dev/null +++ b/docs/login.md @@ -0,0 +1,65 @@ +# Owner login and onboarding + +This is a single-owner workspace. App login is mandatory for all workspace +pages, server functions, and research streams. The existing Tailscale identity +check is an optional outer boundary; `TWITTER_LITE_AUTH_MODE=none` disables only +that outer check, not app login. Origin checks still protect mutations. + +## First use + +Start the server with its usual database and public-origin configuration, then +run this on the server using the same database: + +```bash +nix develop -c pnpm account:setup +``` + +The Nix package also provides `twitter-lite-setup`; run it with the service +database path and filesystem permissions. + +The command loads `.env.local` when present. An exported `TWITTER_LITE_DB_PATH` +takes precedence. It prints a setup code for `/setup`; keep that code private. +The code is created in `.setup-token` with mode 0600, or supplied +through `WORKSPACE_SETUP_TOKEN` (at least 32 characters). It is never returned +by the app's public API. The owner account can be created only once, including +when two setup requests arrive together. The code cannot register another +account or reset an existing password after setup. + +Visit the app, enter the setup code, your name, email, and a password of 15–128 +characters. The email is a login identifier; this prototype does not verify it +or send email. Choose your display name and finish the short onboarding to +open Home. Onboarding completion and the name persist in SQLite. Home greets +you by that name. + +## Sessions + +Passwords use salted scrypt hashes (N=2^17, r=8, p=1). Session cookies are +HttpOnly, SameSite=Lax, host-only, and Secure for an HTTPS public origin. +Only token hashes are stored in SQLite; sessions expire after 30 days. +Setup and login share a persistent limit of 10 attempts per 15 minutes; +successful authentication clears that limit. + +Open the avatar menu and choose **Sign out** to revoke the current session. +Open research streams stop sending updates when the session is revoked or +expires, with an idle check every 15 seconds. Other logged-in devices retain +their own sessions. Authenticated responses are not cached. + +Account registration is closed once the owner exists. Multiple users, +email-based password recovery, and account management are not implemented. +Back up the SQLite database as described in [storage](storage-and-oauth.md). +Home tasks, messages, and journal entries remain session-local prototypes; +onboarding does not make those records persistent. + +## Implementation + +- `src/features/auth/auth.server.ts`: owner, password hashing, throttling, sessions. +- `src/features/auth/gate.server.ts` and `src/start.ts`: request-level protection. +- `/api/auth`: session state, setup, sign-in, onboarding, and sign-out. +- Root route guard: client navigation and onboarding redirects. +- `/setup`, `/login`, `/onboarding`: English forms using the existing UI system. + +Password and session handling follow the relevant +[OWASP password storage](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) +and [session management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) +guidance. Tests cover invalid credentials, bootstrap ownership, expiry, +revocation, request protection, and onboarding persistence. diff --git a/docs/storage-and-oauth.md b/docs/storage-and-oauth.md index a2000a8..cc3b530 100644 --- a/docs/storage-and-oauth.md +++ b/docs/storage-and-oauth.md @@ -1,7 +1,8 @@ # Shared storage and Mastodon OAuth Personal Workspace runs as a single personal server behind Tailscale Serve. The -backend binds to loopback and accepts only the configured Tailscale login. +backend binds to loopback. The configured Tailscale identity is an outer access +check; a separate [owner login](login.md) is required in every deployment mode. Browser requests that change state must have the configured Origin. OAuth callbacks also pass the owner check; the browser must be able to reach the tailnet HTTPS address after Mastodon authorization. diff --git a/drizzle/0004_good_natasha_romanoff.sql b/drizzle/0004_good_natasha_romanoff.sql new file mode 100644 index 0000000..718d581 --- /dev/null +++ b/drizzle/0004_good_natasha_romanoff.sql @@ -0,0 +1,23 @@ +CREATE TABLE `auth_sessions` ( + `token_hash` text PRIMARY KEY NOT NULL, + `owner_id` integer NOT NULL, + `expires_at` integer NOT NULL, + FOREIGN KEY (`owner_id`) REFERENCES `workspace_owner`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `auth_throttle` ( + `id` integer PRIMARY KEY NOT NULL, + `attempts` integer NOT NULL, + `reset_at` integer NOT NULL, + CONSTRAINT "auth_throttle_singleton" CHECK("auth_throttle"."id" = 1) +); +--> statement-breakpoint +CREATE TABLE `workspace_owner` ( + `id` integer PRIMARY KEY NOT NULL, + `email` text NOT NULL, + `name` text NOT NULL, + `password_hash` text NOT NULL, + `onboarding_completed_at` integer, + `created_at` integer NOT NULL, + CONSTRAINT "workspace_owner_singleton" CHECK("workspace_owner"."id" = 1) +); diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..23c6c39 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,696 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "52f68a9d-b9da-4739-9635-b0b5e3e8040b", + "prevId": "d021bc5b-2422-467a-ab92-2493da6e642a", + "tables": { + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_sessions_owner_id_workspace_owner_id_fk": { + "name": "auth_sessions_owner_id_workspace_owner_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "workspace_owner", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_throttle": { + "name": "auth_throttle", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reset_at": { + "name": "reset_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "auth_throttle_singleton": { + "name": "auth_throttle_singleton", + "value": "\"auth_throttle\".\"id\" = 1" + } + } + }, + "connection_credentials": { + "name": "connection_credentials", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "encrypted_token": { + "name": "encrypted_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "connection_credentials_connection_id_connections_id_fk": { + "name": "connection_credentials_connection_id_connections_id_fk", + "tableFrom": "connection_credentials", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connection_credentials_app_id_oauth_apps_id_fk": { + "name": "connection_credentials_app_id_oauth_apps_id_fk", + "tableFrom": "connection_credentials", + "tableTo": "oauth_apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "connections": { + "name": "connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "relay_profile": { + "name": "relay_profile", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connections_account": { + "name": "connections_account", + "columns": ["platform", "origin", "account_id"], + "isUnique": true + }, + "connections_relay_profile": { + "name": "connections_relay_profile", + "columns": ["origin", "relay_profile"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deck_columns": { + "name": "deck_columns", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deck_id": { + "name": "deck_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "deck_columns_position": { + "name": "deck_columns_position", + "columns": ["deck_id", "position"], + "isUnique": true + } + }, + "foreignKeys": { + "deck_columns_deck_id_decks_id_fk": { + "name": "deck_columns_deck_id_decks_id_fk", + "tableFrom": "deck_columns", + "tableTo": "decks", + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_columns_connection_id_connections_id_fk": { + "name": "deck_columns_connection_id_connections_id_fk", + "tableFrom": "deck_columns", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deck_columns_deck_id_id_pk": { + "columns": ["deck_id", "id"], + "name": "deck_columns_deck_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "deck_columns_valid_position": { + "name": "deck_columns_valid_position", + "value": "\"deck_columns\".\"position\" >= 0" + } + } + }, + "decks": { + "name": "decks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "decks_positive_revision": { + "name": "decks_positive_revision", + "value": "\"decks\".\"revision\" >= 1" + } + } + }, + "legacy_imports": { + "name": "legacy_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deck_ids": { + "name": "deck_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_apps": { + "name": "oauth_apps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_apps_configuration": { + "name": "oauth_apps_configuration", + "columns": ["origin", "redirect_uri", "scopes"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_attempts": { + "name": "oauth_attempts", + "columns": { + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_verifier": { + "name": "encrypted_verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_attempts_app_id_oauth_apps_id_fk": { + "name": "oauth_attempts_app_id_oauth_apps_id_fk", + "tableFrom": "oauth_attempts", + "tableTo": "oauth_apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_attempts_connection_id_connections_id_fk": { + "name": "oauth_attempts_connection_id_connections_id_fk", + "tableFrom": "oauth_attempts", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "research_sessions": { + "name": "research_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot": { + "name": "snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "research_state": { + "name": "research_state", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "active_session_id": { + "name": "active_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "research_state_active_session_id_research_sessions_id_fk": { + "name": "research_state_active_session_id_research_sessions_id_fk", + "tableFrom": "research_state", + "tableTo": "research_sessions", + "columnsFrom": ["active_session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "research_state_singleton": { + "name": "research_state_singleton", + "value": "\"research_state\".\"id\" = 1" + } + } + }, + "workspace_owner": { + "name": "workspace_owner", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "workspace_owner_singleton": { + "name": "workspace_owner_singleton", + "value": "\"workspace_owner\".\"id\" = 1" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 818e70b..2e1ed50 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1790242505709, "tag": "0003_romantic_scrambler", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1790589494499, + "tag": "0004_good_natasha_romanoff", + "breakpoints": true } ] } diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index fdc57a7..f0425d7 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -1,12 +1,63 @@ import { basename, dirname, isAbsolute } from 'node:path' import AxeBuilder from '@axe-core/playwright' import { test as base } from '@playwright/test' +import { + completeOnboarding, + createOwner, + readAuthState, + signIn, +} from '../src/features/auth/auth.server' import { openDatabase } from '../src/features/storage/database.server' export const test = base.extend<{ a11y: () => AxeBuilder resetDecks: undefined + sessionToken: string }>({ + // biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments. + sessionToken: async ({}, use) => { + const path = process.env.TWITTER_LITE_E2E_DB_PATH + if (!path || !basename(dirname(path)).startsWith('twitter-lite-e2e-')) + throw new Error('Isolated E2E database is required.') + const database = openDatabase(path) + const credentials = { + email: 'owner@workspace.invalid', + password: 'E2E-only-passphrase-2026', + } + process.env.WORKSPACE_SETUP_TOKEN = + 'isolated-e2e-setup-token-not-for-production' + const result = readAuthState(database).needsSetup + ? await createOwner( + { + ...credentials, + name: 'Yuta', + setupToken: process.env.WORKSPACE_SETUP_TOKEN, + }, + database, + ) + : await signIn(credentials, database) + if (!result.owner.onboardingCompletedAt) + completeOnboarding(result.sessionToken, { name: 'Yuta' }, database) + database.$client.close() + await use(result.sessionToken) + }, + storageState: async ({ sessionToken }, use) => { + await use({ + cookies: [ + { + name: 'workspace_session', + value: sessionToken, + domain: '127.0.0.1', + path: '/', + expires: Math.floor(Date.now() / 1000) + 3600, + httpOnly: true, + secure: false, + sameSite: 'Lax', + }, + ], + origins: [], + }) + }, resetDecks: [ // biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments. async ({}, use) => { diff --git a/e2e/integrations/auth.test.ts b/e2e/integrations/auth.test.ts new file mode 100644 index 0000000..145725d --- /dev/null +++ b/e2e/integrations/auth.test.ts @@ -0,0 +1,172 @@ +import { openDatabase } from '../../src/features/storage/database.server' +import { expect, test } from '../fixtures' + +const origin = 'http://127.0.0.1:4173' + +test('creates the owner once and enters onboarding with a real session', async ({ + page, + context, + a11y, +}) => { + const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? '') + db.$client.exec('DELETE FROM workspace_owner; DELETE FROM auth_throttle;') + db.$client.close() + await context.clearCookies() + await page.goto('/', { waitUntil: 'networkidle' }) + await expect(page).toHaveURL(/\/setup$/) + expect((await a11y().analyze()).violations).toEqual([]) + await page.getByLabel('Your name', { exact: true }).fill('Yuta') + await page.getByLabel('Email address').fill('owner@workspace.invalid') + await page + .getByLabel('Password', { exact: true }) + .fill('E2E-only-passphrase-2026') + await page + .getByLabel('Confirm password', { exact: true }) + .fill('E2E-only-passphrase-2026') + await page + .getByLabel('Setup code') + .fill('isolated-e2e-setup-token-not-for-production') + await page + .getByRole('button', { name: 'Create account', exact: true }) + .click() + await expect(page).toHaveURL(/\/onboarding$/) + await page.waitForLoadState('networkidle') + await page.getByRole('button', { name: 'Continue', exact: true }).click() + await page.getByRole('button', { name: 'Open workspace' }).click() + await expect( + page.getByRole('heading', { name: 'Good morning, Yuta' }), + ).toBeVisible() +}) + +test('requires login for documents, server functions, and live updates', async ({ + page, + context, +}) => { + const serverRequest = page.waitForRequest((request) => + request.url().includes('/_serverFn/'), + ) + await page.goto('/deck') + const serverUrl = (await serverRequest).url() + await context.clearCookies() + for (const path of ['/api/research/events', serverUrl]) { + const result = await context.request.get(path, { + headers: { Origin: origin, 'Sec-Fetch-Site': 'same-origin' }, + }) + expect(result.status()).toBe(401) + } + await page.goto('/journal') + await expect(page).toHaveURL(/\/login$/) + await expect( + page.getByRole('heading', { name: 'Welcome back.' }), + ).toBeVisible() + await expect( + page.getByRole('link', { name: 'Journal', exact: true }), + ).toHaveCount(0) +}) + +test('signs in and revokes the session on sign out', async ({ + page, + context, + a11y, +}) => { + await context.clearCookies() + await page.goto('/login', { waitUntil: 'networkidle' }) + expect((await a11y().analyze()).violations).toEqual([]) + await page.getByLabel('Email address').fill('owner@workspace.invalid') + await page + .getByLabel('Password', { exact: true }) + .fill('Wrong-passphrase-2026') + await page.getByRole('button', { name: 'Sign in', exact: true }).click() + await expect(page.getByRole('alert')).toContainText( + 'Invalid email or password.', + ) + await page + .getByLabel('Password', { exact: true }) + .fill('E2E-only-passphrase-2026') + await page.getByRole('button', { name: 'Sign in', exact: true }).click() + await expect(page).toHaveURL(`${origin}/`) + await page.waitForLoadState('networkidle') + const token = (await context.cookies()).find( + (cookie) => cookie.name === 'workspace_session', + ) + expect(token?.httpOnly).toBe(true) + expect(token?.sameSite).toBe('Lax') + await page.getByRole('button', { name: 'Manage connected accounts' }).click() + await page.getByRole('button', { name: 'Sign out', exact: true }).click() + await expect(page).toHaveURL(/\/login$/) + const replay = await context.request.get('/api/auth', { + headers: { Cookie: `workspace_session=${token?.value}` }, + }) + expect((await replay.json()).owner).toBeNull() + await page.goBack() + await expect( + page.getByRole('link', { name: 'Home', exact: true }), + ).toHaveCount(0) +}) + +test('requires onboarding and remembers its completion and name', async ({ + page, + request, + a11y, +}) => { + const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? '') + db.$client + .prepare( + 'UPDATE workspace_owner SET onboarding_completed_at = NULL WHERE id = 1', + ) + .run() + try { + expect((await request.get('/api/research/events')).status()).toBe(403) + await page.goto('/', { waitUntil: 'networkidle' }) + await expect(page).toHaveURL(/\/onboarding$/) + expect((await a11y().analyze()).violations).toEqual([]) + await page.getByLabel('What should we call you?').fill('Yuta Test') + await page.getByRole('button', { name: 'Continue', exact: true }).click() + await page.getByRole('button', { name: 'Open workspace' }).click() + await expect( + page.getByRole('heading', { name: 'Good morning, Yuta Test' }), + ).toBeVisible() + await page.reload() + await expect( + page.getByRole('heading', { name: 'Good morning, Yuta Test' }), + ).toBeVisible() + await page.goto('/onboarding') + await expect(page).toHaveURL(`${origin}/`) + } finally { + db.$client + .prepare( + 'UPDATE workspace_owner SET name = ?, onboarding_completed_at = ? WHERE id = 1', + ) + .run('Yuta', Date.now()) + db.$client.close() + } +}) + +test('rejects cross-origin login and further account registration', async ({ + request, + page, + context, +}) => { + expect( + ( + await request.post('/api/auth', { + headers: { Origin: 'https://other.invalid' }, + data: { action: 'logout' }, + }) + ).status(), + ).toBe(403) + const result = await request.post('/api/auth', { + headers: { Origin: origin }, + data: { + action: 'setup', + email: 'intruder@example.com', + name: 'Intruder', + password: 'Long-enough-password', + setupToken: 'arbitrary-token', + }, + }) + expect(result.status()).toBe(409) + await context.clearCookies() + await page.goto('/setup') + await expect(page).toHaveURL(/\/login$/) +}) diff --git a/e2e/integrations/deck.test.ts b/e2e/integrations/deck.test.ts index d025b33..e917e73 100644 --- a/e2e/integrations/deck.test.ts +++ b/e2e/integrations/deck.test.ts @@ -296,6 +296,7 @@ test('keeps an open draft through remote edits and rejects its stale save', asyn baseURL, }) => { const other = await browser.newContext({ + storageState: await page.context().storageState(), baseURL, extraHTTPHeaders: { 'Tailscale-User-Login': 'owner@twitter-lite.invalid' }, }) diff --git a/e2e/integrations/webmcp.test.ts b/e2e/integrations/webmcp.test.ts index b1be344..4136494 100644 --- a/e2e/integrations/webmcp.test.ts +++ b/e2e/integrations/webmcp.test.ts @@ -136,6 +136,7 @@ test('creates temporary research, edits it, persists explicitly and reopens it o ).not.toBe(true) expect(savedCount()).toEqual({ count: 1 }) const other = await browser.newContext({ + storageState: await page.context().storageState(), baseURL, extraHTTPHeaders: { 'Tailscale-User-Login': 'owner@twitter-lite.invalid' }, }) diff --git a/flake.nix b/flake.nix index b0b0151..391f8f5 100644 --- a/flake.nix +++ b/flake.nix @@ -46,6 +46,8 @@ --add-flags "$out/lib/twitter-lite/server/index.mjs" makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite-backup \ --add-flags "$out/lib/twitter-lite/server/tools/backup-database.js" + makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite-setup \ + --add-flags "$out/lib/twitter-lite/server/tools/account-setup.js" runHook postInstall ''; diff --git a/package.json b/package.json index d67a4fc..f585b84 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "test:watch": "vitest", "test:e2e": "playwright test", "test:live": "TWITTER_LITE_LIVE=1 vitest run tests/live/relay.test.ts", - "lint:ui": "oxlint src" + "lint:ui": "oxlint src", + "account:setup": "node --env-file-if-exists=.env.local --import tsx scripts/account-setup.ts" }, "dependencies": { "@base-ui/react": "1.8.0", diff --git a/playwright.config.ts b/playwright.config.ts index 10297f4..a611b22 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -36,7 +36,7 @@ export default defineConfig({ timeout: 120_000, }, { - command: `TWITTER_LITE_CODEX_URL= TWITTER_LITE_REPORT_ROOT= TWITTER_LITE_CODEX_MODEL= TWITTER_LITE_MASTODON_ORIGINS= TWITTER_LITE_CREDENTIAL_KEY_FILE= TWITTER_LITE_DB_PATH=${databasePath} TWITTER_LITE_ORIGIN=http://127.0.0.1:${appPort} TWITTER_LITE_ALLOWED_LOGIN=owner@twitter-lite.invalid TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`, + command: `WORKSPACE_SETUP_TOKEN=isolated-e2e-setup-token-not-for-production TWITTER_LITE_CODEX_URL= TWITTER_LITE_REPORT_ROOT= TWITTER_LITE_CODEX_MODEL= TWITTER_LITE_MASTODON_ORIGINS= TWITTER_LITE_CREDENTIAL_KEY_FILE= TWITTER_LITE_DB_PATH=${databasePath} TWITTER_LITE_ORIGIN=http://127.0.0.1:${appPort} TWITTER_LITE_ALLOWED_LOGIN=owner@twitter-lite.invalid TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`, port: appPort, reuseExistingServer: false, timeout: 120_000, diff --git a/scripts/account-setup.ts b/scripts/account-setup.ts new file mode 100644 index 0000000..2ec2fce --- /dev/null +++ b/scripts/account-setup.ts @@ -0,0 +1,13 @@ +import { getSetupToken, readAuthState } from '../src/features/auth/auth.server' + +if (!readAuthState().needsSetup) { + console.error( + 'This workspace already has an owner. Sign in with your existing account.', + ) + process.exitCode = 1 +} else { + console.log( + 'Open /setup in your workspace and enter this one-time setup code:', + ) + console.log(getSetupToken()) +} diff --git a/scripts/build-tools.mjs b/scripts/build-tools.mjs index 82c8f28..d9a4291 100644 --- a/scripts/build-tools.mjs +++ b/scripts/build-tools.mjs @@ -1,10 +1,13 @@ import { build } from 'vite' -await build({ - configFile: false, - build: { - ssr: 'scripts/backup-database.ts', - outDir: '.output/server/tools', - rollupOptions: { external: ['better-sqlite3'] }, - }, -}) +for (const entry of ['backup-database', 'account-setup']) { + await build({ + configFile: false, + build: { + ssr: `scripts/${entry}.ts`, + outDir: '.output/server/tools', + emptyOutDir: false, + rollupOptions: { external: ['better-sqlite3'] }, + }, + }) +} diff --git a/src/components/workspace-navigation.tsx b/src/components/workspace-navigation.tsx index ac0ed86..bc3eac7 100644 --- a/src/components/workspace-navigation.tsx +++ b/src/components/workspace-navigation.tsx @@ -1,4 +1,4 @@ -import { Link, useLocation } from '@tanstack/react-router' +import { Link, useLocation, useRouteContext } from '@tanstack/react-router' import { Activity, House, @@ -35,6 +35,7 @@ export function WorkspaceNavigation({ footer, hasTemporaryDecks, }: WorkspaceNavigationProps) { + const { auth } = useRouteContext({ from: '__root__' }) const [managingAccounts, setManagingAccounts] = useState(false) const pathname = useLocation({ select: (location) => location.pathname }) const navigation = [ @@ -129,7 +130,9 @@ export function WorkspaceNavigation({ onClick={() => setManagingAccounts(true)} > - Y + + {auth.owner?.name.slice(0, 1).toUpperCase()} + diff --git a/src/features/auth/auth-client.ts b/src/features/auth/auth-client.ts new file mode 100644 index 0000000..dc52866 --- /dev/null +++ b/src/features/auth/auth-client.ts @@ -0,0 +1,34 @@ +export type SafeOwner = { + id: number + email: string + name: string + onboardingCompletedAt: number | null +} + +export type AuthState = { needsSetup: boolean; owner: SafeOwner | null } + +type AuthInput = + | { action: 'login'; email: string; password: string } + | { + action: 'setup' + email: string + password: string + name: string + setupToken: string + } + | { action: 'logout' } + | { action: 'onboard'; name: string } + +export async function authRequest(input: AuthInput): Promise { + 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() + if (!response.ok) { + throw new Error(result.error || 'Something went wrong. Please try again.') + } + return result +} diff --git a/src/features/auth/auth-page.tsx b/src/features/auth/auth-page.tsx new file mode 100644 index 0000000..da5369a --- /dev/null +++ b/src/features/auth/auth-page.tsx @@ -0,0 +1,199 @@ +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' + +export function AuthFrame({ children }: { children: ReactNode }) { + return ( +
+
+
+
+ {children} +
+
+ ) +} + +export function LoginPage() { + return +} + +export function SetupPage() { + return +} + +function CredentialsForm({ setup }: { setup: boolean }) { + const [pending, setPending] = useState(false) + const [error, setError] = useState('') + + async function submit(event: FormEvent) { + 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 + } + setPending(true) + setError('') + try { + const state = await authRequest( + setup + ? { + action: 'setup', + email, + password, + name: String(data.get('name') ?? '').trim(), + setupToken: String(data.get('setupToken') ?? '').trim(), + } + : { action: 'login', email, password }, + ) + if (!state.owner?.onboardingCompletedAt) { + 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) + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : 'Unable to sign in. Please try again.', + ) + setPending(false) + } + } + + return ( + +
+

{setup ? 'Make yourself at home.' : 'Welcome back.'}

+

+ {setup + ? 'Create the owner account for your personal workspace.' + : 'Sign in to your personal workspace.'} +

+
+
+ {setup && ( +
+ + +
+ )} +
+ + +
+
+ + + {setup && ( +

+ Use 15–128 characters. A few memorable words work well. +

+ )} +
+ {setup && ( + <> +
+ + +
+
+ + +

+ Use the setup code provided by your server administrator. +

+
+ + )} + {error && ( +

+ {error} +

+ )} + +
+

+ {setup + ? 'This workspace has one owner. Connections can be added later.' + : 'A private workspace. Access is limited to its owner.'} +

+
+ ) +} diff --git a/src/features/auth/auth.css b/src/features/auth/auth.css new file mode 100644 index 0000000..6315a2b --- /dev/null +++ b/src/features/auth/auth.css @@ -0,0 +1,136 @@ +.auth-page { + min-height: 100svh; + display: grid; + place-items: center; + padding: 48px 24px; + background: var(--background); + color: var(--foreground); +} +.auth-panel { + width: 100%; + max-width: 390px; + min-width: 0; +} +.auth-brand { + display: flex; + align-items: center; + gap: 9px; + color: var(--muted-foreground); + font-size: 13px; + margin-bottom: 44px; +} +.auth-heading { + margin-bottom: 28px; +} +.auth-heading h1 { + font-size: 27px; + font-weight: 550; + letter-spacing: -0.8px; + line-height: 1.2; + overflow-wrap: anywhere; +} +.auth-heading p { + color: var(--muted-foreground); + font-size: 14px; + line-height: 1.6; + margin-top: 12px; +} +.auth-form { + display: grid; + gap: 20px; +} +.auth-field { + display: grid; + gap: 9px; +} +.auth-field input { + height: 40px; +} +.auth-hint, +.auth-footnote, +.auth-prototype { + font-size: 12px; + line-height: 1.6; + color: var(--muted-foreground); +} +.auth-submit { + width: 100%; + margin-top: 4px; + justify-content: space-between; + padding-inline: 14px; +} +.auth-footnote { + margin-top: 24px; +} +.auth-error { + color: var(--destructive); + font-size: 13px; + line-height: 1.6; + overflow-wrap: anywhere; +} +.auth-step { + color: var(--muted-foreground); + font-size: 12px; + margin-bottom: 16px; +} +.auth-overview { + list-style: none; + padding: 0; + margin: 0 0 28px; + display: grid; + gap: 24px; +} +.auth-overview li { + display: flex; + gap: 14px; + align-items: flex-start; +} +.auth-overview svg { + width: 18px; + height: 18px; + flex-shrink: 0; + margin-top: 2px; + color: var(--muted-foreground); +} +.auth-overview strong { + font-size: 14px; + font-weight: 500; +} +.auth-overview p { + font-size: 13px; + line-height: 1.6; + color: var(--muted-foreground); + margin-top: 4px; +} +.auth-prototype { + padding-top: 20px; + border-top: 1px solid var(--border); +} +.auth-actions { + margin-top: 26px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.auth-footer { + margin-top: 32px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--muted-foreground); + font-size: 12px; +} +.auth-footer span { + min-width: 0; + overflow-wrap: anywhere; +} +@media (max-width: 480px) { + .auth-page { + padding: 32px 24px; + } + .auth-brand { + margin-bottom: 36px; + } +} diff --git a/src/features/auth/auth.server.ts b/src/features/auth/auth.server.ts new file mode 100644 index 0000000..d436ed8 --- /dev/null +++ b/src/features/auth/auth.server.ts @@ -0,0 +1,241 @@ +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 +const derive = (password: string, salt: string) => + new Promise((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 + +export class AuthError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message) + this.name = 'AuthError' + } +} +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))) +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(), + } +} +/** 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 + if (configured) { + if (configured.length < 32) + throw new AuthError( + 503, + 'The setup token must contain at least 32 characters.', + ) + return configured + } + 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 }) + try { + writeFileSync(path, randomBytes(32).toString('base64url'), { + mode: 0o600, + flag: 'wx', + }) + } catch (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 +} +function consumeAttempt(database: AppDatabase) { + database.transaction( + (tx) => { + 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.', + ) + tx.update(authThrottle) + .set({ attempts: current.attempts + 1 }) + .where(eq(authThrottle.id, 1)) + .run() + } else { + tx.insert(authThrottle) + .values({ id: 1, attempts: 1, resetAt: now + throttleWindow }) + .onConflictDoUpdate({ + target: authThrottle.id, + set: { attempts: 1, resetAt: now + throttleWindow }, + }) + .run() + } + }, + { behavior: 'immediate' }, + ) +} +function session( + database: Pick, + owner: typeof workspaceOwner.$inferSelect, +) { + const sessionToken = randomBytes(32).toString('base64url') + database + .delete(authSessions) + .where(lte(authSessions.expiresAt, Date.now())) + .run() + database + .insert(authSessions) + .values({ + tokenHash: hash(sessionToken), + 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) } +} +function cleanName(name: string) { + const cleaned = name.trim() + if (!cleaned || cleaned.length > 80) + 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) + if (!equal(input.setupToken, getSetupToken())) + 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.') + 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 + return database.transaction( + (tx) => { + if (tx.select().from(workspaceOwner).get()) + throw new AuthError( + 409, + 'This workspace is already set up. Please sign in.', + ) + const owner = { + id: 1, + email, + name, + passwordHash: `${salt}:${derived.toString('hex')}`, + onboardingCompletedAt: null, + createdAt: Date.now(), + } + tx.insert(workspaceOwner).values(owner).run() + return session(tx, owner) + }, + { behavior: 'immediate' }, + ) +} +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 + if ( + !owner || + !equal(input.email.trim().toLowerCase(), owner.email) || + !timingSafeEqual(actual, Buffer.from(expected, 'hex')) + ) + 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 + const found = database + .select() + .from(authSessions) + .where(eq(authSessions.tokenHash, hash(token))) + .get() + if (!found) return null + if (found.expiresAt <= Date.now()) { + 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 +} +export function signOut(token: string | undefined, database = getDatabase()) { + if (token) + database + .delete(authSessions) + .where(eq(authSessions.tokenHash, hash(token))) + .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 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 } +} diff --git a/src/features/auth/auth.test.ts b/src/features/auth/auth.test.ts new file mode 100644 index 0000000..c7d8b98 --- /dev/null +++ b/src/features/auth/auth.test.ts @@ -0,0 +1,150 @@ +// @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 { + completeOnboarding, + createOwner, + getSession, + getSetupToken, + readAuthState, + SESSION_MAX_AGE_SECONDS, + signIn, + signOut, +} from './auth.server' + +let database: ReturnType +const credentials = { + email: 'owner@example.com', + password: 'correct horse battery staple', + name: 'Owner', + setupToken: 's'.repeat(32), +} +beforeEach(() => { + 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 }) + 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) + await expect(createOwner(credentials, database)).rejects.toMatchObject({ + status: 409, + }) + }) + 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) + await expect( + signIn({ ...credentials, password: 'wrong' }, database), + ).rejects.toMatchObject({ + status: 401, + message: 'Invalid email or password.', + }) + await expect( + signIn({ ...credentials, email: 'other@example.com' }, database), + ).rejects.toMatchObject({ + status: 401, + message: 'Invalid email or password.', + }) + const result = await signIn( + { ...credentials, email: 'OWNER@example.com' }, + 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 }) + 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 () => { + 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-')) + 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) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/src/features/auth/gate.server.ts b/src/features/auth/gate.server.ts new file mode 100644 index 0000000..938ec86 --- /dev/null +++ b/src/features/auth/gate.server.ts @@ -0,0 +1,36 @@ +import { getSession, readAuthState } from './auth.server' +import { sessionToken } from './http.server' + +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 document = + 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' + return new Response(null, { + status: 303, + headers: { location, 'cache-control': 'no-store' }, + }) + } + return Response.json( + { + error: owner + ? 'Complete onboarding to continue.' + : 'Sign in to continue.', + }, + { status: owner ? 403 : 401, headers: { 'cache-control': 'no-store' } }, + ) +} diff --git a/src/features/auth/http.server.ts b/src/features/auth/http.server.ts new file mode 100644 index 0000000..5b5a33e --- /dev/null +++ b/src/features/auth/http.server.ts @@ -0,0 +1,103 @@ +import { + AuthError, + completeOnboarding, + createOwner, + getSession, + readAuthState, + SESSION_MAX_AGE_SECONDS, + signIn, + signOut, +} from './auth.server' + +const SESSION_COOKIE = 'workspace_session' + +export function sessionToken(request: Request) { + return request.headers + .get('cookie') + ?.split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(`${SESSION_COOKIE}=`)) + ?.slice(SESSION_COOKIE.length + 1) +} + +export function authState(request: 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' : ''}` +} + +export async function authEndpoint(request: Request) { + const headers = new Headers({ + '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 + try { + 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.') + } + const text = (key: string) => + typeof data[key] === 'string' ? data[key] : '' + const oldToken = sessionToken(request) + switch (data.action) { + case 'setup': + case 'login': { + const result = + data.action === 'setup' + ? await createOwner({ + 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 }, + ) + } + 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 }, + ) + default: + 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: 'Unable to complete the request. Please try again.' }, + { status: 500, headers }, + ) + } +} diff --git a/src/features/auth/onboarding-page.tsx b/src/features/auth/onboarding-page.tsx new file mode 100644 index 0000000..11f4009 --- /dev/null +++ b/src/features/auth/onboarding-page.tsx @@ -0,0 +1,151 @@ +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('') + + async function finish() { + setPending(true) + setError('') + try { + 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) + } + } + + async function signOut() { + setPending(true) + setError('') + try { + 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) + } + } + + function next(event: FormEvent) { + event.preventDefault() + if (!name.trim()) return + setStep(2) + } + + return ( + +

{step} of 2

+ {step === 1 ? ( + <> +
+

A little context, to start.

+

Your day, conversations, and reading, together in one place.

+
+
+
+ + setName(event.target.value)} + autoComplete="given-name" + required + maxLength={80} + disabled={pending} + /> +
+ +
+ + ) : ( + <> +
+

Start with today.

+

You can take it one thing at a time.

+
+
    +
  • +
  • +
  • +
  • +
  • +
  • +
+

+ Home, contacts, notes, reading, and vitals currently use sample + data. You can add connections from your profile menu whenever you’re + ready. +

+
+ + +
+ + )} + {error && ( +

+ {error} +

+ )} +
+ {auth.owner?.email} + +
+
+ ) +} diff --git a/src/features/auth/session.ts b/src/features/auth/session.ts new file mode 100644 index 0000000..703120c --- /dev/null +++ b/src/features/auth/session.ts @@ -0,0 +1,14 @@ +import { createIsomorphicFn } from '@tanstack/react-start' +import type { AuthState } from './auth-client' + +export const loadAuthState = createIsomorphicFn() + .server(async (): Promise => { + const { getRequest } = await import('@tanstack/react-start/server') + const { authState } = await import('./http.server') + return authState(getRequest()) + }) + .client(async (): Promise => { + const response = await fetch('/api/auth', { cache: 'no-store' }) + if (!response.ok) throw new Error('Unable to check your session.') + return response.json() + }) diff --git a/src/features/connections/connection-manager-dialog.tsx b/src/features/connections/connection-manager-dialog.tsx index c4c1531..7308881 100644 --- a/src/features/connections/connection-manager-dialog.tsx +++ b/src/features/connections/connection-manager-dialog.tsx @@ -1,7 +1,10 @@ import { useQuery } from '@tanstack/react-query' +import { useRouteContext } from '@tanstack/react-router' import { useServerFn } from '@tanstack/react-start' +import { useState } from 'react' import { Dialog } from '#/components/dialog' import { Button } from '#/components/ui/button' +import { authRequest } from '#/features/auth/auth-client' import { ConnectionManager } from './connection-manager' import { loadConnections } from './server-functions' @@ -12,6 +15,20 @@ export function ConnectionManagerDialog({ onClose: () => void hasTemporaryDecks?: boolean }) { + const { auth } = useRouteContext({ from: '__root__' }) + const [signingOut, setSigningOut] = useState(false) + const [error, setError] = useState('') + async function logout() { + setSigningOut(true) + setError('') + try { + await authRequest({ action: 'logout' }) + window.location.assign('/login') + } catch { + setError('Unable to sign out. Please try again.') + setSigningOut(false) + } + } const fetchConnections = useServerFn(loadConnections) const connections = useQuery({ queryKey: ['connections'], @@ -25,6 +42,22 @@ export function ConnectionManagerDialog({ onClose={onClose} className="sm:max-w-2xl" > +
+
+

{auth.owner?.name}

+

+ {auth.owner?.email} +

+
+ +
+ {error &&

{error}

} {connections.isPending ? (

Loading connected accounts…

) : connections.isError ? ( diff --git a/src/features/home/home-page.tsx b/src/features/home/home-page.tsx index 8ffcc08..f1318f4 100644 --- a/src/features/home/home-page.tsx +++ b/src/features/home/home-page.tsx @@ -1,4 +1,4 @@ -import { Link, useNavigate } from '@tanstack/react-router' +import { Link, useNavigate, useRouteContext } from '@tanstack/react-router' import { ArrowDown, ArrowUp, @@ -32,6 +32,7 @@ import { type HomeAction, interpretAction } from './interpret-action' import './home.css' export function HomePage() { + const { auth } = useRouteContext({ from: '__root__' }) const { tasks, setTasks, @@ -175,7 +176,7 @@ export function HomePage() {

Today

-

Good morning, Yuta

+

Good morning, {auth.owner?.name}

void) => () => void /** Subscribe to current state and subsequent changes; disconnect only this viewer. */ -export function researchEvents(request: Request, subscribe: Subscribe) { +export function researchEvents( + request: Request, + subscribe: Subscribe, + isAuthorized: () => boolean = () => true, +) { const encoder = new TextEncoder() let dispose = () => {} let flushPending = () => {} @@ -14,7 +18,15 @@ export function researchEvents(request: Request, subscribe: Subscribe) { let closed = false let unsubscribe = () => {} let heartbeat: ReturnType | undefined + const checkSession = () => { + if (closed) return false + if (isAuthorized()) return true + dispose() + controller.close() + return false + } const flush = () => { + if (!checkSession()) return if ( !closed && pending !== undefined && @@ -46,7 +58,12 @@ export function researchEvents(request: Request, subscribe: Subscribe) { pending = `data: ${JSON.stringify(snapshot)}\n\n` flush() }) + if (closed) { + unsubscribe() + return + } heartbeat = setInterval(() => { + if (!checkSession()) return if ( !closed && pending === undefined && diff --git a/src/features/research/events.test.ts b/src/features/research/events.test.ts index 3550261..ab0f7b2 100644 --- a/src/features/research/events.test.ts +++ b/src/features/research/events.test.ts @@ -81,3 +81,25 @@ it('bounds a slow viewer buffer while retaining the latest state', async () => { ) await reader.cancel() }) + +it('stops sending updates when its login session is revoked', async () => { + let authorized = true + let emit = (_value: { configured: boolean; run: null }) => {} + const unsubscribe = vi.fn() + const response = researchEvents( + new Request('http://127.0.0.1/events'), + (listener) => { + emit = listener + listener({ configured: false, run: null }) + return unsubscribe + }, + () => authorized, + ) + const reader = response.body?.getReader() + expect.assert.isDefined(reader) + await reader.read() + authorized = false + emit({ configured: true, run: null }) + expect((await reader.read()).done).toBe(true) + expect(unsubscribe).toHaveBeenCalledOnce() +}) diff --git a/src/features/storage/migrations.generated.ts b/src/features/storage/migrations.generated.ts index e31281c..6b326ef 100644 --- a/src/features/storage/migrations.generated.ts +++ b/src/features/storage/migrations.generated.ts @@ -42,4 +42,14 @@ export const migrations = [ folderMillis: 1790242505709, hash: '5b74e60a806df54dc3c6897aa84305b80a3b097dd70cd6cf1feebcfd3b72a135', }, + { + sql: [ + 'CREATE TABLE `auth_sessions` (\n\t`token_hash` text PRIMARY KEY NOT NULL,\n\t`owner_id` integer NOT NULL,\n\t`expires_at` integer NOT NULL,\n\tFOREIGN KEY (`owner_id`) REFERENCES `workspace_owner`(`id`) ON UPDATE no action ON DELETE cascade\n);\n', + '\nCREATE TABLE `auth_throttle` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`attempts` integer NOT NULL,\n\t`reset_at` integer NOT NULL,\n\tCONSTRAINT "auth_throttle_singleton" CHECK("auth_throttle"."id" = 1)\n);\n', + '\nCREATE TABLE `workspace_owner` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`email` text NOT NULL,\n\t`name` text NOT NULL,\n\t`password_hash` text NOT NULL,\n\t`onboarding_completed_at` integer,\n\t`created_at` integer NOT NULL,\n\tCONSTRAINT "workspace_owner_singleton" CHECK("workspace_owner"."id" = 1)\n);\n', + ], + bps: true, + folderMillis: 1790589494499, + hash: 'c2093b5a2e4f84ffa333dff896fc8a7bf86015ce84ae562765b525b763af749e', + }, ] diff --git a/src/features/storage/schema.ts b/src/features/storage/schema.ts index e5a6d71..12b5396 100644 --- a/src/features/storage/schema.ts +++ b/src/features/storage/schema.ts @@ -146,3 +146,34 @@ export const researchState = sqliteTable( }, (table) => [check('research_state_singleton', sql`${table.id} = 1`)], ) + +export const workspaceOwner = sqliteTable( + 'workspace_owner', + { + id: integer('id').primaryKey(), + email: text('email').notNull(), + name: text('name').notNull(), + passwordHash: text('password_hash').notNull(), + onboardingCompletedAt: integer('onboarding_completed_at'), + createdAt: integer('created_at').notNull(), + }, + (table) => [check('workspace_owner_singleton', sql`${table.id} = 1`)], +) + +export const authSessions = sqliteTable('auth_sessions', { + tokenHash: text('token_hash').primaryKey(), + ownerId: integer('owner_id') + .notNull() + .references(() => workspaceOwner.id, { onDelete: 'cascade' }), + expiresAt: integer('expires_at').notNull(), +}) + +export const authThrottle = sqliteTable( + 'auth_throttle', + { + id: integer('id').primaryKey(), + attempts: integer('attempts').notNull(), + resetAt: integer('reset_at').notNull(), + }, + (table) => [check('auth_throttle_singleton', sql`${table.id} = 1`)], +) diff --git a/src/features/vitals/vitals-page.tsx b/src/features/vitals/vitals-page.tsx index 36b9402..d36286c 100644 --- a/src/features/vitals/vitals-page.tsx +++ b/src/features/vitals/vitals-page.tsx @@ -1,3 +1,4 @@ +import { useRouteContext } from '@tanstack/react-router' import { Activity, Bell, @@ -52,6 +53,7 @@ const nav = [ { name: 'Profile', icon: UserRound }, ] export function VitalsPage() { + const { auth } = useRouteContext({ from: '__root__' }) const [section, setSection] = useState('Body') const [mobileMenu, setMobileMenu] = useState(false) const [detail, setDetail] = useState() @@ -141,7 +143,10 @@ export function VitalsPage() { className="vital-profile" onClick={() => setDetail('Profile')} > - YYuta + + {auth.owner?.name.slice(0, 1).toUpperCase()} + + {auth.owner?.name}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 2499b60..07fba18 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,10 +11,14 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as VitalsRouteImport } from './routes/vitals' import { Route as SupportRouteImport } from './routes/support' +import { Route as SetupRouteImport } from './routes/setup' +import { Route as OnboardingRouteImport } from './routes/onboarding' +import { Route as LoginRouteImport } from './routes/login' import { Route as JournalRouteImport } from './routes/journal' import { Route as InboxRouteImport } from './routes/inbox' import { Route as DeckRouteImport } from './routes/deck' import { Route as IndexRouteImport } from './routes/index' +import { Route as ApiAuthRouteImport } from './routes/api/auth' import { Route as OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback' import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events' @@ -28,6 +32,21 @@ const SupportRoute = SupportRouteImport.update({ path: '/support', getParentRoute: () => rootRouteImport, } as any) +const SetupRoute = SetupRouteImport.update({ + id: '/setup', + path: '/setup', + getParentRoute: () => rootRouteImport, +} as any) +const OnboardingRoute = OnboardingRouteImport.update({ + id: '/onboarding', + path: '/onboarding', + getParentRoute: () => rootRouteImport, +} as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) const JournalRoute = JournalRouteImport.update({ id: '/journal', path: '/journal', @@ -48,6 +67,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const ApiAuthRoute = ApiAuthRouteImport.update({ + id: '/api/auth', + path: '/api/auth', + getParentRoute: () => rootRouteImport, +} as any) const OauthMastodonCallbackRoute = OauthMastodonCallbackRouteImport.update({ id: '/oauth/mastodon/callback', path: '/oauth/mastodon/callback', @@ -64,8 +88,12 @@ export interface FileRoutesByFullPath { '/deck': typeof DeckRoute '/inbox': typeof InboxRoute '/journal': typeof JournalRoute + '/login': typeof LoginRoute + '/onboarding': typeof OnboardingRoute + '/setup': typeof SetupRoute '/support': typeof SupportRoute '/vitals': typeof VitalsRoute + '/api/auth': typeof ApiAuthRoute '/api/research/events': typeof ApiResearchEventsRoute '/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute } @@ -74,8 +102,12 @@ export interface FileRoutesByTo { '/deck': typeof DeckRoute '/inbox': typeof InboxRoute '/journal': typeof JournalRoute + '/login': typeof LoginRoute + '/onboarding': typeof OnboardingRoute + '/setup': typeof SetupRoute '/support': typeof SupportRoute '/vitals': typeof VitalsRoute + '/api/auth': typeof ApiAuthRoute '/api/research/events': typeof ApiResearchEventsRoute '/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute } @@ -85,8 +117,12 @@ export interface FileRoutesById { '/deck': typeof DeckRoute '/inbox': typeof InboxRoute '/journal': typeof JournalRoute + '/login': typeof LoginRoute + '/onboarding': typeof OnboardingRoute + '/setup': typeof SetupRoute '/support': typeof SupportRoute '/vitals': typeof VitalsRoute + '/api/auth': typeof ApiAuthRoute '/api/research/events': typeof ApiResearchEventsRoute '/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute } @@ -97,8 +133,12 @@ export interface FileRouteTypes { | '/deck' | '/inbox' | '/journal' + | '/login' + | '/onboarding' + | '/setup' | '/support' | '/vitals' + | '/api/auth' | '/api/research/events' | '/oauth/mastodon/callback' fileRoutesByTo: FileRoutesByTo @@ -107,8 +147,12 @@ export interface FileRouteTypes { | '/deck' | '/inbox' | '/journal' + | '/login' + | '/onboarding' + | '/setup' | '/support' | '/vitals' + | '/api/auth' | '/api/research/events' | '/oauth/mastodon/callback' id: @@ -117,8 +161,12 @@ export interface FileRouteTypes { | '/deck' | '/inbox' | '/journal' + | '/login' + | '/onboarding' + | '/setup' | '/support' | '/vitals' + | '/api/auth' | '/api/research/events' | '/oauth/mastodon/callback' fileRoutesById: FileRoutesById @@ -128,8 +176,12 @@ export interface RootRouteChildren { DeckRoute: typeof DeckRoute InboxRoute: typeof InboxRoute JournalRoute: typeof JournalRoute + LoginRoute: typeof LoginRoute + OnboardingRoute: typeof OnboardingRoute + SetupRoute: typeof SetupRoute SupportRoute: typeof SupportRoute VitalsRoute: typeof VitalsRoute + ApiAuthRoute: typeof ApiAuthRoute ApiResearchEventsRoute: typeof ApiResearchEventsRoute OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute } @@ -150,6 +202,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SupportRouteImport parentRoute: typeof rootRouteImport } + '/setup': { + id: '/setup' + path: '/setup' + fullPath: '/setup' + preLoaderRoute: typeof SetupRouteImport + parentRoute: typeof rootRouteImport + } + '/onboarding': { + id: '/onboarding' + path: '/onboarding' + fullPath: '/onboarding' + preLoaderRoute: typeof OnboardingRouteImport + parentRoute: typeof rootRouteImport + } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } '/journal': { id: '/journal' path: '/journal' @@ -178,6 +251,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/api/auth': { + id: '/api/auth' + path: '/api/auth' + fullPath: '/api/auth' + preLoaderRoute: typeof ApiAuthRouteImport + parentRoute: typeof rootRouteImport + } '/oauth/mastodon/callback': { id: '/oauth/mastodon/callback' path: '/oauth/mastodon/callback' @@ -200,8 +280,12 @@ const rootRouteChildren: RootRouteChildren = { DeckRoute: DeckRoute, InboxRoute: InboxRoute, JournalRoute: JournalRoute, + LoginRoute: LoginRoute, + OnboardingRoute: OnboardingRoute, + SetupRoute: SetupRoute, SupportRoute: SupportRoute, VitalsRoute: VitalsRoute, + ApiAuthRoute: ApiAuthRoute, ApiResearchEventsRoute: ApiResearchEventsRoute, OauthMastodonCallbackRoute: OauthMastodonCallbackRoute, } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index aa95695..7d3b4fd 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -3,14 +3,36 @@ import { createRootRouteWithContext, HeadContent, Outlet, + redirect, Scripts, } from '@tanstack/react-router' +import { loadAuthState } from '#/features/auth/session' import { WorkspaceStateProvider } from '#/features/workspace/workspace-state' import appCss from '../styles.css?url' type RouterContext = { queryClient: QueryClient } export const Route = createRootRouteWithContext()({ + beforeLoad: async ({ location }) => { + const auth = await loadAuthState() + const path = location.pathname + const entry = path === '/login' || path === '/setup' + if (!auth.owner && auth.needsSetup && path === '/login') + throw redirect({ to: '/setup' }) + if (!auth.owner && !entry) + throw redirect({ to: auth.needsSetup ? '/setup' : '/login' }) + if (!auth.owner && path === '/setup' && !auth.needsSetup) + throw redirect({ to: '/login' }) + if ( + auth.owner && + !auth.owner.onboardingCompletedAt && + path !== '/onboarding' + ) + throw redirect({ to: '/onboarding' }) + if (auth.owner?.onboardingCompletedAt && (entry || path === '/onboarding')) + throw redirect({ to: '/' }) + return { auth } + }, head: () => ({ meta: [ { charSet: 'utf-8' }, diff --git a/src/routes/api/auth.ts b/src/routes/api/auth.ts new file mode 100644 index 0000000..8f21777 --- /dev/null +++ b/src/routes/api/auth.ts @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' + +async function handle({ request }: { request: Request }) { + const { authEndpoint } = await import('../../features/auth/http.server') + return authEndpoint(request) +} + +export const Route = createFileRoute('/api/auth')({ + server: { handlers: { GET: handle, POST: handle } }, +}) diff --git a/src/routes/api/research/events.ts b/src/routes/api/research/events.ts index 8e1661a..35b1734 100644 --- a/src/routes/api/research/events.ts +++ b/src/routes/api/research/events.ts @@ -8,7 +8,16 @@ export const Route = createFileRoute('/api/research/events')({ import('../../../features/research/events.server'), import('../../../features/research/runner.server'), ]) - return researchEvents(request, researchService().subscribe) + const { getSession } = await import( + '../../../features/auth/auth.server' + ) + const { sessionToken } = await import( + '../../../features/auth/http.server' + ) + const token = sessionToken(request) + return researchEvents(request, researchService().subscribe, () => + Boolean(getSession(token)?.onboardingCompletedAt), + ) }, }, }, diff --git a/src/routes/login.tsx b/src/routes/login.tsx new file mode 100644 index 0000000..65f722c --- /dev/null +++ b/src/routes/login.tsx @@ -0,0 +1,3 @@ +import { createFileRoute } from '@tanstack/react-router' +import { LoginPage } from '#/features/auth/auth-page' +export const Route = createFileRoute('/login')({ component: LoginPage }) diff --git a/src/routes/onboarding.tsx b/src/routes/onboarding.tsx new file mode 100644 index 0000000..9e3d578 --- /dev/null +++ b/src/routes/onboarding.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' +import { OnboardingPage } from '#/features/auth/onboarding-page' +export const Route = createFileRoute('/onboarding')({ + component: OnboardingPage, +}) diff --git a/src/routes/setup.tsx b/src/routes/setup.tsx new file mode 100644 index 0000000..f9d4004 --- /dev/null +++ b/src/routes/setup.tsx @@ -0,0 +1,3 @@ +import { createFileRoute } from '@tanstack/react-router' +import { SetupPage } from '#/features/auth/auth-page' +export const Route = createFileRoute('/setup')({ component: SetupPage }) diff --git a/src/start.ts b/src/start.ts index 575853b..b95c57a 100644 --- a/src/start.ts +++ b/src/start.ts @@ -15,6 +15,13 @@ const csrf = createCsrfMiddleware({ filter: (context) => context.handlerType === 'serverFn', }) +const appSession = createMiddleware().server(async ({ request, next }) => { + const { checkSessionAccess } = await import('./features/auth/gate.server') + const { setResponseHeader } = await import('@tanstack/react-start/server') + setResponseHeader('Cache-Control', 'no-store') + return checkSessionAccess(request) ?? next() +}) + const storage = createMiddleware().server(async ({ next }) => { const { getDatabase } = await import('./features/storage/database.server') getDatabase() @@ -22,5 +29,5 @@ const storage = createMiddleware().server(async ({ next }) => { }) export const startInstance = createStart(() => ({ - requestMiddleware: [ownerAccess, csrf, storage], + requestMiddleware: [ownerAccess, csrf, storage, appSession], }))