feat: require owner login and add onboarding

This commit is contained in:
2026-09-28 19:14:14 +09:00
parent 57882cf5f9
commit 85d23a328b
42 changed files with 2400 additions and 23 deletions
+3
View File
@@ -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_URL=ws://127.0.0.1:4500
# TWITTER_LITE_CODEX_MODEL=gpt-6-astra # TWITTER_LITE_CODEX_MODEL=gpt-6-astra
# TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research # 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=
+1
View File
@@ -9,3 +9,4 @@ node_modules/
playwright-report/ playwright-report/
test-results/ test-results/
.data/ .data/
*.setup-token
+12 -4
View File
@@ -63,6 +63,13 @@ or change direction.
Original-post links open their source site; SNS reply threads are not rendered Original-post links open their source site; SNS reply threads are not rendered
inside the app. Bluesky, Threads, and Nostr connectors are not implemented. 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 ## Requirements and setup
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc` 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 test:live
nix develop -c pnpm build nix develop -c pnpm build
nix develop -c pnpm start nix develop -c pnpm start
nix develop -c pnpm account:setup
nix develop -c pnpm db:generate nix develop -c pnpm db:generate
nix develop -c pnpm codex:serve 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. explicit fixture identity to its isolated test server.
For a private, tailnet-only reverse proxy such as Traefik, explicitly set For a private, tailnet-only reverse proxy such as Traefik, explicitly set
`TWITTER_LITE_AUTH_MODE=none` to disable application identity checks. This mode `TWITTER_LITE_AUTH_MODE=none` to disable the Tailscale identity check. This mode
does not require `TWITTER_LITE_ALLOWED_LOGIN` or Tailscale identity headers; 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 App login remains mandatory, including access to connected accounts and Codex.
Codex. Bind the backend to loopback or its Tailscale address and restrict proxy Bind the backend to loopback or its Tailscale address and restrict proxy
access to the tailnet. access to the tailnet.
Both modes require the exact configured `Origin` for state-changing requests, Both modes require the exact configured `Origin` for state-changing requests,
including chat and deck mutations. Missing origin or an unknown auth mode including chat and deck mutations. Missing origin or an unknown auth mode
+65
View File
@@ -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 `<database path>.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.
+2 -1
View File
@@ -1,7 +1,8 @@
# Shared storage and Mastodon OAuth # Shared storage and Mastodon OAuth
Personal Workspace runs as a single personal server behind Tailscale Serve. The 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 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 callbacks also pass the owner check; the browser must be able to reach the
tailnet HTTPS address after Mastodon authorization. tailnet HTTPS address after Mastodon authorization.
+23
View File
@@ -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)
);
+696
View File
@@ -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": {}
}
}
+7
View File
@@ -29,6 +29,13 @@
"when": 1790242505709, "when": 1790242505709,
"tag": "0003_romantic_scrambler", "tag": "0003_romantic_scrambler",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1790589494499,
"tag": "0004_good_natasha_romanoff",
"breakpoints": true
} }
] ]
} }
+51
View File
@@ -1,12 +1,63 @@
import { basename, dirname, isAbsolute } from 'node:path' import { basename, dirname, isAbsolute } from 'node:path'
import AxeBuilder from '@axe-core/playwright' import AxeBuilder from '@axe-core/playwright'
import { test as base } from '@playwright/test' 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' import { openDatabase } from '../src/features/storage/database.server'
export const test = base.extend<{ export const test = base.extend<{
a11y: () => AxeBuilder a11y: () => AxeBuilder
resetDecks: undefined 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: '[email protected]',
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: [ resetDecks: [
// biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments. // biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments.
async ({}, use) => { async ({}, use) => {
+172
View File
@@ -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('[email protected]')
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('[email protected]')
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: '[email protected]',
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$/)
})
+1
View File
@@ -296,6 +296,7 @@ test('keeps an open draft through remote edits and rejects its stale save', asyn
baseURL, baseURL,
}) => { }) => {
const other = await browser.newContext({ const other = await browser.newContext({
storageState: await page.context().storageState(),
baseURL, baseURL,
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' }, extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' },
}) })
+1
View File
@@ -136,6 +136,7 @@ test('creates temporary research, edits it, persists explicitly and reopens it o
).not.toBe(true) ).not.toBe(true)
expect(savedCount()).toEqual({ count: 1 }) expect(savedCount()).toEqual({ count: 1 })
const other = await browser.newContext({ const other = await browser.newContext({
storageState: await page.context().storageState(),
baseURL, baseURL,
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' }, extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' },
}) })
+2
View File
@@ -46,6 +46,8 @@
--add-flags "$out/lib/twitter-lite/server/index.mjs" --add-flags "$out/lib/twitter-lite/server/index.mjs"
makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite-backup \ makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite-backup \
--add-flags "$out/lib/twitter-lite/server/tools/backup-database.js" --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 runHook postInstall
''; '';
+2 -1
View File
@@ -41,7 +41,8 @@
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:live": "TWITTER_LITE_LIVE=1 vitest run tests/live/relay.test.ts", "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": { "dependencies": {
"@base-ui/react": "1.8.0", "@base-ui/react": "1.8.0",
+1 -1
View File
@@ -36,7 +36,7 @@ export default defineConfig({
timeout: 120_000, 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} [email protected] 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} [email protected] 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, port: appPort,
reuseExistingServer: false, reuseExistingServer: false,
timeout: 120_000, timeout: 120_000,
+13
View File
@@ -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())
}
+6 -3
View File
@@ -1,10 +1,13 @@
import { build } from 'vite' import { build } from 'vite'
await build({ for (const entry of ['backup-database', 'account-setup']) {
await build({
configFile: false, configFile: false,
build: { build: {
ssr: 'scripts/backup-database.ts', ssr: `scripts/${entry}.ts`,
outDir: '.output/server/tools', outDir: '.output/server/tools',
emptyOutDir: false,
rollupOptions: { external: ['better-sqlite3'] }, rollupOptions: { external: ['better-sqlite3'] },
}, },
}) })
}
+5 -2
View File
@@ -1,4 +1,4 @@
import { Link, useLocation } from '@tanstack/react-router' import { Link, useLocation, useRouteContext } from '@tanstack/react-router'
import { import {
Activity, Activity,
House, House,
@@ -35,6 +35,7 @@ export function WorkspaceNavigation({
footer, footer,
hasTemporaryDecks, hasTemporaryDecks,
}: WorkspaceNavigationProps) { }: WorkspaceNavigationProps) {
const { auth } = useRouteContext({ from: '__root__' })
const [managingAccounts, setManagingAccounts] = useState(false) const [managingAccounts, setManagingAccounts] = useState(false)
const pathname = useLocation({ select: (location) => location.pathname }) const pathname = useLocation({ select: (location) => location.pathname })
const navigation = [ const navigation = [
@@ -129,7 +130,9 @@ export function WorkspaceNavigation({
onClick={() => setManagingAccounts(true)} onClick={() => setManagingAccounts(true)}
> >
<Avatar className="size-8"> <Avatar className="size-8">
<AvatarFallback>Y</AvatarFallback> <AvatarFallback>
{auth.owner?.name.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar> </Avatar>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
+34
View File
@@ -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<AuthState> {
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
}
+199
View File
@@ -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 (
<main className="auth-page">
<div className="auth-panel">
<div className="auth-brand">
<LockKeyhole size={16} aria-hidden="true" />
Personal Workspace
</div>
{children}
</div>
</main>
)
}
export function LoginPage() {
return <CredentialsForm setup={false} />
}
export function SetupPage() {
return <CredentialsForm setup />
}
function CredentialsForm({ setup }: { setup: boolean }) {
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
}
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 (
<AuthFrame>
<header className="auth-heading">
<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.'}
</p>
</header>
<form onSubmit={submit} className="auth-form" aria-busy={pending}>
{setup && (
<div className="auth-field">
<Label htmlFor="name">Your name</Label>
<Input
id="name"
name="name"
autoComplete="given-name"
required
maxLength={80}
disabled={pending}
/>
</div>
)}
<div className="auth-field">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="username"
required
maxLength={254}
disabled={pending}
/>
</div>
<div className="auth-field">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete={setup ? 'new-password' : 'current-password'}
required
minLength={setup ? 15 : undefined}
maxLength={128}
aria-describedby={setup ? 'password-hint' : undefined}
disabled={pending}
/>
{setup && (
<p id="password-hint" className="auth-hint">
Use 15–128 characters. A few memorable words work well.
</p>
)}
</div>
{setup && (
<>
<div className="auth-field">
<Label htmlFor="confirmPassword">Confirm password</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
minLength={15}
maxLength={128}
disabled={pending}
/>
</div>
<div className="auth-field">
<Label htmlFor="setupToken">Setup code</Label>
<Input
id="setupToken"
name="setupToken"
type="password"
autoComplete="off"
required
aria-describedby="setup-hint"
disabled={pending}
/>
<p id="setup-hint" className="auth-hint">
Use the setup code provided by your server administrator.
</p>
</div>
</>
)}
{error && (
<p className="auth-error" role="alert">
{error}
</p>
)}
<Button
type="submit"
size="lg"
disabled={pending}
className="auth-submit"
>
{pending
? setup
? 'Creating account…'
: 'Signing in…'
: setup
? '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.'}
</p>
</AuthFrame>
)
}
+136
View File
@@ -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;
}
}
+241
View File
@@ -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<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
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<AppDatabase, 'insert' | 'delete'>,
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 }
}
+150
View File
@@ -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<typeof openDatabase>
const credentials = {
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)
})
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: '[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 () => {
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 })
}
})
})
+36
View File
@@ -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' } },
)
}
+103
View File
@@ -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<string, unknown>
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 },
)
}
}
+151
View File
@@ -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<HTMLFormElement>) {
event.preventDefault()
if (!name.trim()) return
setStep(2)
}
return (
<AuthFrame>
<p className="auth-step">{step} of 2</p>
{step === 1 ? (
<>
<header className="auth-heading">
<h1>A little context, to start.</h1>
<p>Your day, conversations, and reading, together in one place.</p>
</header>
<form className="auth-form" onSubmit={next}>
<div className="auth-field">
<Label htmlFor="displayName">What should we call you?</Label>
<Input
id="displayName"
name="displayName"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="given-name"
required
maxLength={80}
disabled={pending}
/>
</div>
<Button
type="submit"
className="auth-submit"
size="lg"
disabled={pending || !name.trim()}
>
Continue
<ArrowRight aria-hidden="true" />
</Button>
</form>
</>
) : (
<>
<header className="auth-heading">
<h1>Start with today.</h1>
<p>You can take it one thing at a time.</p>
</header>
<ul className="auth-overview">
<li>
<House aria-hidden="true" />
<div>
<strong>A place to begin</strong>
<p>A brief and flexible tasks help you find your next step.</p>
</div>
</li>
<li>
<MessagesSquare aria-hidden="true" />
<div>
<strong>Room for the everyday</strong>
<p>Keep conversations and quick notes close at hand.</p>
</div>
</li>
<li>
<BookOpen aria-hidden="true" />
<div>
<strong>Follow your curiosity</strong>
<p>Explore reading and research alongside your day.</p>
</div>
</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.
</p>
<div className="auth-actions">
<Button
variant="ghost"
onClick={() => setStep(1)}
disabled={pending}
>
Back
</Button>
<Button size="lg" onClick={finish} disabled={pending}>
{pending ? 'Saving…' : 'Open workspace'}
<ArrowRight aria-hidden="true" />
</Button>
</div>
</>
)}
{error && (
<p className="auth-error" role="alert">
{error}
</p>
)}
<footer className="auth-footer">
<span>{auth.owner?.email}</span>
<Button variant="ghost" size="sm" onClick={signOut} disabled={pending}>
Sign out
</Button>
</footer>
</AuthFrame>
)
}
+14
View File
@@ -0,0 +1,14 @@
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())
})
.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()
})
@@ -1,7 +1,10 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useRouteContext } from '@tanstack/react-router'
import { useServerFn } from '@tanstack/react-start' import { useServerFn } from '@tanstack/react-start'
import { useState } from 'react'
import { Dialog } from '#/components/dialog' import { Dialog } from '#/components/dialog'
import { Button } from '#/components/ui/button' import { Button } from '#/components/ui/button'
import { authRequest } from '#/features/auth/auth-client'
import { ConnectionManager } from './connection-manager' import { ConnectionManager } from './connection-manager'
import { loadConnections } from './server-functions' import { loadConnections } from './server-functions'
@@ -12,6 +15,20 @@ export function ConnectionManagerDialog({
onClose: () => void onClose: () => void
hasTemporaryDecks?: boolean 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 fetchConnections = useServerFn(loadConnections)
const connections = useQuery({ const connections = useQuery({
queryKey: ['connections'], queryKey: ['connections'],
@@ -25,6 +42,22 @@ export function ConnectionManagerDialog({
onClose={onClose} onClose={onClose}
className="sm:max-w-2xl" className="sm:max-w-2xl"
> >
<div className="mb-5 flex items-center justify-between gap-4 border-b pb-4">
<div className="min-w-0">
<p className="truncate font-medium">{auth.owner?.name}</p>
<p className="truncate text-sm text-muted-foreground">
{auth.owner?.email}
</p>
</div>
<Button
variant="outline"
disabled={signingOut}
onClick={() => void logout()}
>
{signingOut ? 'Signing out…' : 'Sign out'}
</Button>
</div>
{error && <p role="alert">{error}</p>}
{connections.isPending ? ( {connections.isPending ? (
<p role="status">Loading connected accounts…</p> <p role="status">Loading connected accounts…</p>
) : connections.isError ? ( ) : connections.isError ? (
+3 -2
View File
@@ -1,4 +1,4 @@
import { Link, useNavigate } from '@tanstack/react-router' import { Link, useNavigate, useRouteContext } from '@tanstack/react-router'
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
@@ -32,6 +32,7 @@ import { type HomeAction, interpretAction } from './interpret-action'
import './home.css' import './home.css'
export function HomePage() { export function HomePage() {
const { auth } = useRouteContext({ from: '__root__' })
const { const {
tasks, tasks,
setTasks, setTasks,
@@ -175,7 +176,7 @@ export function HomePage() {
<div className="home-content"> <div className="home-content">
<header className="home-greeting"> <header className="home-greeting">
<p className="text-sm text-muted-foreground">Today</p> <p className="text-sm text-muted-foreground">Today</p>
<h1>Good morning, Yuta</h1> <h1>Good morning, {auth.owner?.name}</h1>
<DailyBrief onOpenContact={setContactId} /> <DailyBrief onOpenContact={setContactId} />
</header> </header>
<form <form
+18 -1
View File
@@ -4,7 +4,11 @@ type Snapshot = { configured: boolean; run: ResearchRun | null }
type Subscribe = (listener: (snapshot: Snapshot) => void) => () => void type Subscribe = (listener: (snapshot: Snapshot) => void) => () => void
/** Subscribe to current state and subsequent changes; disconnect only this viewer. */ /** 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() const encoder = new TextEncoder()
let dispose = () => {} let dispose = () => {}
let flushPending = () => {} let flushPending = () => {}
@@ -14,7 +18,15 @@ export function researchEvents(request: Request, subscribe: Subscribe) {
let closed = false let closed = false
let unsubscribe = () => {} let unsubscribe = () => {}
let heartbeat: ReturnType<typeof setInterval> | undefined let heartbeat: ReturnType<typeof setInterval> | undefined
const checkSession = () => {
if (closed) return false
if (isAuthorized()) return true
dispose()
controller.close()
return false
}
const flush = () => { const flush = () => {
if (!checkSession()) return
if ( if (
!closed && !closed &&
pending !== undefined && pending !== undefined &&
@@ -46,7 +58,12 @@ export function researchEvents(request: Request, subscribe: Subscribe) {
pending = `data: ${JSON.stringify(snapshot)}\n\n` pending = `data: ${JSON.stringify(snapshot)}\n\n`
flush() flush()
}) })
if (closed) {
unsubscribe()
return
}
heartbeat = setInterval(() => { heartbeat = setInterval(() => {
if (!checkSession()) return
if ( if (
!closed && !closed &&
pending === undefined && pending === undefined &&
+22
View File
@@ -81,3 +81,25 @@ it('bounds a slow viewer buffer while retaining the latest state', async () => {
) )
await reader.cancel() 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()
})
@@ -42,4 +42,14 @@ export const migrations = [
folderMillis: 1790242505709, folderMillis: 1790242505709,
hash: '5b74e60a806df54dc3c6897aa84305b80a3b097dd70cd6cf1feebcfd3b72a135', 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',
},
] ]
+31
View File
@@ -146,3 +146,34 @@ export const researchState = sqliteTable(
}, },
(table) => [check('research_state_singleton', sql`${table.id} = 1`)], (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`)],
)
+6 -1
View File
@@ -1,3 +1,4 @@
import { useRouteContext } from '@tanstack/react-router'
import { import {
Activity, Activity,
Bell, Bell,
@@ -52,6 +53,7 @@ const nav = [
{ name: 'Profile', icon: UserRound }, { name: 'Profile', icon: UserRound },
] ]
export function VitalsPage() { export function VitalsPage() {
const { auth } = useRouteContext({ from: '__root__' })
const [section, setSection] = useState('Body') const [section, setSection] = useState('Body')
const [mobileMenu, setMobileMenu] = useState(false) const [mobileMenu, setMobileMenu] = useState(false)
const [detail, setDetail] = useState<string>() const [detail, setDetail] = useState<string>()
@@ -141,7 +143,10 @@ export function VitalsPage() {
className="vital-profile" className="vital-profile"
onClick={() => setDetail('Profile')} onClick={() => setDetail('Profile')}
> >
<span className="vital-avatar">Y</span>Yuta <span className="vital-avatar">
{auth.owner?.name.slice(0, 1).toUpperCase()}
</span>
{auth.owner?.name}
<ChevronDown /> <ChevronDown />
</button> </button>
</div> </div>
+84
View File
@@ -11,10 +11,14 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as VitalsRouteImport } from './routes/vitals' import { Route as VitalsRouteImport } from './routes/vitals'
import { Route as SupportRouteImport } from './routes/support' 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 JournalRouteImport } from './routes/journal'
import { Route as InboxRouteImport } from './routes/inbox' import { Route as InboxRouteImport } from './routes/inbox'
import { Route as DeckRouteImport } from './routes/deck' import { Route as DeckRouteImport } from './routes/deck'
import { Route as IndexRouteImport } from './routes/index' 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 OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback'
import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events' import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events'
@@ -28,6 +32,21 @@ const SupportRoute = SupportRouteImport.update({
path: '/support', path: '/support',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } 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({ const JournalRoute = JournalRouteImport.update({
id: '/journal', id: '/journal',
path: '/journal', path: '/journal',
@@ -48,6 +67,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiAuthRoute = ApiAuthRouteImport.update({
id: '/api/auth',
path: '/api/auth',
getParentRoute: () => rootRouteImport,
} as any)
const OauthMastodonCallbackRoute = OauthMastodonCallbackRouteImport.update({ const OauthMastodonCallbackRoute = OauthMastodonCallbackRouteImport.update({
id: '/oauth/mastodon/callback', id: '/oauth/mastodon/callback',
path: '/oauth/mastodon/callback', path: '/oauth/mastodon/callback',
@@ -64,8 +88,12 @@ export interface FileRoutesByFullPath {
'/deck': typeof DeckRoute '/deck': typeof DeckRoute
'/inbox': typeof InboxRoute '/inbox': typeof InboxRoute
'/journal': typeof JournalRoute '/journal': typeof JournalRoute
'/login': typeof LoginRoute
'/onboarding': typeof OnboardingRoute
'/setup': typeof SetupRoute
'/support': typeof SupportRoute '/support': typeof SupportRoute
'/vitals': typeof VitalsRoute '/vitals': typeof VitalsRoute
'/api/auth': typeof ApiAuthRoute
'/api/research/events': typeof ApiResearchEventsRoute '/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute '/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
} }
@@ -74,8 +102,12 @@ export interface FileRoutesByTo {
'/deck': typeof DeckRoute '/deck': typeof DeckRoute
'/inbox': typeof InboxRoute '/inbox': typeof InboxRoute
'/journal': typeof JournalRoute '/journal': typeof JournalRoute
'/login': typeof LoginRoute
'/onboarding': typeof OnboardingRoute
'/setup': typeof SetupRoute
'/support': typeof SupportRoute '/support': typeof SupportRoute
'/vitals': typeof VitalsRoute '/vitals': typeof VitalsRoute
'/api/auth': typeof ApiAuthRoute
'/api/research/events': typeof ApiResearchEventsRoute '/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute '/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
} }
@@ -85,8 +117,12 @@ export interface FileRoutesById {
'/deck': typeof DeckRoute '/deck': typeof DeckRoute
'/inbox': typeof InboxRoute '/inbox': typeof InboxRoute
'/journal': typeof JournalRoute '/journal': typeof JournalRoute
'/login': typeof LoginRoute
'/onboarding': typeof OnboardingRoute
'/setup': typeof SetupRoute
'/support': typeof SupportRoute '/support': typeof SupportRoute
'/vitals': typeof VitalsRoute '/vitals': typeof VitalsRoute
'/api/auth': typeof ApiAuthRoute
'/api/research/events': typeof ApiResearchEventsRoute '/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute '/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
} }
@@ -97,8 +133,12 @@ export interface FileRouteTypes {
| '/deck' | '/deck'
| '/inbox' | '/inbox'
| '/journal' | '/journal'
| '/login'
| '/onboarding'
| '/setup'
| '/support' | '/support'
| '/vitals' | '/vitals'
| '/api/auth'
| '/api/research/events' | '/api/research/events'
| '/oauth/mastodon/callback' | '/oauth/mastodon/callback'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@@ -107,8 +147,12 @@ export interface FileRouteTypes {
| '/deck' | '/deck'
| '/inbox' | '/inbox'
| '/journal' | '/journal'
| '/login'
| '/onboarding'
| '/setup'
| '/support' | '/support'
| '/vitals' | '/vitals'
| '/api/auth'
| '/api/research/events' | '/api/research/events'
| '/oauth/mastodon/callback' | '/oauth/mastodon/callback'
id: id:
@@ -117,8 +161,12 @@ export interface FileRouteTypes {
| '/deck' | '/deck'
| '/inbox' | '/inbox'
| '/journal' | '/journal'
| '/login'
| '/onboarding'
| '/setup'
| '/support' | '/support'
| '/vitals' | '/vitals'
| '/api/auth'
| '/api/research/events' | '/api/research/events'
| '/oauth/mastodon/callback' | '/oauth/mastodon/callback'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -128,8 +176,12 @@ export interface RootRouteChildren {
DeckRoute: typeof DeckRoute DeckRoute: typeof DeckRoute
InboxRoute: typeof InboxRoute InboxRoute: typeof InboxRoute
JournalRoute: typeof JournalRoute JournalRoute: typeof JournalRoute
LoginRoute: typeof LoginRoute
OnboardingRoute: typeof OnboardingRoute
SetupRoute: typeof SetupRoute
SupportRoute: typeof SupportRoute SupportRoute: typeof SupportRoute
VitalsRoute: typeof VitalsRoute VitalsRoute: typeof VitalsRoute
ApiAuthRoute: typeof ApiAuthRoute
ApiResearchEventsRoute: typeof ApiResearchEventsRoute ApiResearchEventsRoute: typeof ApiResearchEventsRoute
OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute
} }
@@ -150,6 +202,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SupportRouteImport preLoaderRoute: typeof SupportRouteImport
parentRoute: typeof rootRouteImport 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': { '/journal': {
id: '/journal' id: '/journal'
path: '/journal' path: '/journal'
@@ -178,6 +251,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/auth': {
id: '/api/auth'
path: '/api/auth'
fullPath: '/api/auth'
preLoaderRoute: typeof ApiAuthRouteImport
parentRoute: typeof rootRouteImport
}
'/oauth/mastodon/callback': { '/oauth/mastodon/callback': {
id: '/oauth/mastodon/callback' id: '/oauth/mastodon/callback'
path: '/oauth/mastodon/callback' path: '/oauth/mastodon/callback'
@@ -200,8 +280,12 @@ const rootRouteChildren: RootRouteChildren = {
DeckRoute: DeckRoute, DeckRoute: DeckRoute,
InboxRoute: InboxRoute, InboxRoute: InboxRoute,
JournalRoute: JournalRoute, JournalRoute: JournalRoute,
LoginRoute: LoginRoute,
OnboardingRoute: OnboardingRoute,
SetupRoute: SetupRoute,
SupportRoute: SupportRoute, SupportRoute: SupportRoute,
VitalsRoute: VitalsRoute, VitalsRoute: VitalsRoute,
ApiAuthRoute: ApiAuthRoute,
ApiResearchEventsRoute: ApiResearchEventsRoute, ApiResearchEventsRoute: ApiResearchEventsRoute,
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute, OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
} }
+22
View File
@@ -3,14 +3,36 @@ import {
createRootRouteWithContext, createRootRouteWithContext,
HeadContent, HeadContent,
Outlet, Outlet,
redirect,
Scripts, Scripts,
} from '@tanstack/react-router' } from '@tanstack/react-router'
import { loadAuthState } from '#/features/auth/session'
import { WorkspaceStateProvider } from '#/features/workspace/workspace-state' import { WorkspaceStateProvider } from '#/features/workspace/workspace-state'
import appCss from '../styles.css?url' import appCss from '../styles.css?url'
type RouterContext = { queryClient: QueryClient } type RouterContext = { queryClient: QueryClient }
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({
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: () => ({ head: () => ({
meta: [ meta: [
{ charSet: 'utf-8' }, { charSet: 'utf-8' },
+10
View File
@@ -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 } },
})
+10 -1
View File
@@ -8,7 +8,16 @@ export const Route = createFileRoute('/api/research/events')({
import('../../../features/research/events.server'), import('../../../features/research/events.server'),
import('../../../features/research/runner.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),
)
}, },
}, },
}, },
+3
View File
@@ -0,0 +1,3 @@
import { createFileRoute } from '@tanstack/react-router'
import { LoginPage } from '#/features/auth/auth-page'
export const Route = createFileRoute('/login')({ component: LoginPage })
+5
View File
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { OnboardingPage } from '#/features/auth/onboarding-page'
export const Route = createFileRoute('/onboarding')({
component: OnboardingPage,
})
+3
View File
@@ -0,0 +1,3 @@
import { createFileRoute } from '@tanstack/react-router'
import { SetupPage } from '#/features/auth/auth-page'
export const Route = createFileRoute('/setup')({ component: SetupPage })
+8 -1
View File
@@ -15,6 +15,13 @@ const csrf = createCsrfMiddleware({
filter: (context) => context.handlerType === 'serverFn', 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 storage = createMiddleware().server(async ({ next }) => {
const { getDatabase } = await import('./features/storage/database.server') const { getDatabase } = await import('./features/storage/database.server')
getDatabase() getDatabase()
@@ -22,5 +29,5 @@ const storage = createMiddleware().server(async ({ next }) => {
}) })
export const startInstance = createStart(() => ({ export const startInstance = createStart(() => ({
requestMiddleware: [ownerAccess, csrf, storage], requestMiddleware: [ownerAccess, csrf, storage, appSession],
})) }))