ATProto-based AI Assistant Chat interface.
scn-chat specs foundation.md
13 kB · 168 lines
Markdown

Foundation #

Summary #

The foundation is the empty but working skeleton every later spec builds on. It sets up a pnpm workspace with three packages: generated TypeScript for our lexicons, a Hono server, and a Vite React web app. It adds environment config, a Kysely database layer that runs on SQLite or Postgres, a migration runner, and a test setup that exercises both databases. When it is done, pnpm dev starts the server and web app, the web page shows the server's health, and pnpm test passes on both databases. No chat features exist yet.

Motivation #

Every phase 1 spec needs the same plumbing: config, a database, a server, a web app, and tests. Settling it once keeps later specs about features rather than setup. The foundation also locks in the rules that are painful to retrofit: all lexicon NSIDs come from one generated module, all branding comes from config, the database code stays portable across SQLite and Postgres, and the atproto alpha packages install reproducibly.

Design #

Workspace layout #

package.json            root scripts, pnpm overrides, packageManager, engines
pnpm-workspace.yaml     packages/*, apps/*, plugins/*
tsconfig.base.json      shared strict compiler options
vitest.config.ts        one Vitest run across all packages
.nvmrc                  24
.env.example            the environment settings, with development defaults
lexicons/               lexicon JSON (exists today)
packages/lexicons/      @scn-chat/lexicons: generated schemas and types
apps/server/            @scn-chat/server: Hono API server
apps/web/               @scn-chat/web: Vite React app

All packages are ESM ("type": "module") with TypeScript strict on. Workspace packages export their TypeScript source directly ("exports": "./src/index.ts"), so nothing needs a build step.

The server runs TypeScript natively with Node's built-in type stripping, in development and production, with no compile step and no runner like tsx. That requires Node 22.18 or newer, and the project targets Node 24, the current LTS. The shared tsconfig sets erasableSyntaxOnly, verbatimModuleSyntax, and allowImportingTsExtensions, so the compiler rejects syntax Node cannot strip, such as enums and namespaces. Relative imports use .ts extensions. tsc --noEmit does type checking. Vite handles TypeScript for the web app.

The plugin API package and the plugins/ directory are created by the plugins spec.

atproto alpha packages #

Every @atproto/* package with an alpha release is pinned to one exact alpha version through pnpm.overrides in the root package.json. A range like ^0.0.0-spaces-alpha-* also matches the broken 0.0.0 releases, so ranges are never used. The lockfile is committed. Upgrading the alpha means changing one version string for every override at once.

Lexicon package #

packages/lexicons holds TypeScript generated from lexicons/ by the lex build command from @atproto/lex, the same tool bulletin uses. Its schemas come from @atproto/lex-schema. The package's index is the one module the rest of the code uses for NSIDs, record types, and record validation. No other code writes an NSID as a string.

  • pnpm codegen regenerates it with --clear, --index-file, and --import-ext .ts.
  • The generated files are committed, so reviewers see schema changes in diffs and nothing needs generating after a clone.
  • Upstream lexicons that later specs need, such as com.atproto.space.*, are vendored into lexicons/upstream/ when that spec needs them, with a note recording the source commit, as bulletin does.

Config #

Settings live in one of two places, never both:

  • The environment, loaded from .env by Node's built-in process.loadEnvFile when the file exists, holds the secrets and the settings the server needs before its database is open. .env.example lists them.
  • The app database holds every setting an admin changes while the server runs, edited in the admin area. The admin specs describe them.

apps/server/src/config.ts validates the environment with a zod schema once at startup and exports a typed, frozen config object. An invalid or missing required value stops the server with a message naming every variable at fault.

Variable Default Purpose
NODE_ENV development development, test, or production.
DATABASE_URL sqlite:./data/scn-chat.sqlite sqlite:<path> or postgres://....
PUBLIC_URL http://127.0.0.1:3000 The URL people reach the app at.
PORT 3000
DATA_DIR ./data
LOG_LEVEL info

Later specs add their own variables to the same schema and to .env.example. An empty value counts as unset.

Database #

apps/server/src/db/ creates a Kysely instance from DATABASE_URL:

  • sqlite: uses better-sqlite3 with SqliteDialect, with WAL mode and foreign keys turned on.
  • postgres:// uses pg with PostgresDialect.

The database types live in one Database interface that later specs extend.

Migrations are TypeScript files in apps/server/src/db/migrations/, registered in an explicit ordered map and run by Kysely's Migrator. The server runs pending migrations on startup, and pnpm migrate runs them on their own. The foundation ships the runner with no tables. Each later spec adds its own migrations.

Migrations and queries stay within what both databases support. Timestamps are ISO 8601 text. JSON, including stored lexicon records, is text. Any dialect-specific SQL has to live behind a helper with a test on both databases.

Server #

apps/server uses hono with @hono/node-server. createApp(deps) builds the Hono app from a config and a database, so tests can create an app without opening a port. The entry point creates the config, database, and logger, runs migrations, and starts listening.

  • GET /api/health returns { "status": "ok", "appName": "..." } after a trivial database query succeeds. It returns 503 with { "status": "error" } when the query fails, and logs the error.
  • Every route under /api is private to our web app. None of it is a public API.
  • In production the server also serves the built web app from apps/web/dist, with an index.html fallback for client-side routes. In development Vite serves the web app and proxies /api to the server. index.html carries an __APP_NAME__ placeholder in its title and application-name meta tag, filled with the configured app name by the server in production and by a Vite plugin, which asks the server, in development. The web app reads the name from the meta tag.
  • Logging uses pino, with request logging through a small Hono middleware.

Error responses #

API errors are JSON with an error code and, usually, a message the web app shows as is.

  • Errors the server expects, such as invalid input or an expired session, have their own status and message.
  • A failed call to the user's PDS is passed on. A missing OAuth scope (InsufficientScope or ScopeMissingError) becomes 403 ScopeMissing, telling the user to sign out and back in. Any other PDS error response becomes 502 PdsError with the PDS's code and redacted message, and an unreachable PDS becomes 502 PdsUnreachable.
  • Anything else is 500 InternalServerError, with a short random reference in the message and in the log line, so a report can be matched to the log. The error's details stay in the log, except in development, where the redacted message is appended.

The web app logs every failed API call except a 401 to the browser console, with its status, URL, and message.

Web app #

apps/web is a Vite React app with no router, state library, or CSS framework. The single App component fetches /api/health and shows the app name and whether the server is reachable. There is no styling beyond browser defaults.

Tests #

Vitest runs every package from the root config. Tests live in a tests/ directory in each package that mirrors its src/.

  • Database tests run against both SQLite and Postgres through a shared helper. SQLite uses an in-memory better-sqlite3 database. Postgres uses PGlite, an in-process build of Postgres, through kysely-pglite-dialect, so no Docker or database server is needed.
  • Web tests use jsdom and Testing Library.
  • The lexicon package has tests that every lexicon document validates against the alpha lexicon schema, and that the generated code is current with the JSON.

Scripts #

Root script Does
pnpm dev Runs the server with node --watch and the Vite dev server together
pnpm build Builds the web app
pnpm start Runs the server in production mode, serving the built web app
pnpm test Runs all tests once
pnpm typecheck Type checks every package
pnpm lint Lints and checks formatting with Biome
pnpm format Formats with Biome
pnpm check Runs lint, typecheck, and test, for any CI system
pnpm codegen Regenerates the lexicon package
pnpm migrate Runs pending migrations

Linting and continuous integration #

Biome handles both linting and formatting, configured in one biome.json at the root. No CI configuration is committed, so the project is not tied to one host. Any CI system runs pnpm install --frozen-lockfile and then pnpm check.

Scope Boundaries #

  • No authentication, sessions, or OAuth. That is the auth spec.
  • No database tables. Each later spec adds its own.
  • No plugin interfaces or plugin package. That is the plugins spec.
  • No atproto network calls and no vendored upstream lexicons yet.
  • No CI configuration for any particular host.
  • No UI beyond the health status page.

Edge Cases and Decisions #

  • Deployment is a single Dockerfile on node:24-slim and a docker-compose.yml, described in docs/deployment.md. The image installs dependencies, builds the web app, and runs pnpm start. .env is mounted from the host, data lives in a /data volume with absolute DATA_DIR and DATABASE_URL, since pnpm start runs from apps/server and relative paths would land there, and the port is published on 127.0.0.1 for a reverse proxy that terminates TLS.
  • The server runs TypeScript through Node's native type stripping instead of compiling to JavaScript. If the generated lexicon code turns out to use syntax Node cannot strip, the fallback is running the server through tsx.
  • Node refuses to strip types for files under node_modules. Workspace packages work because pnpm symlinks them there and Node resolves symlinks to their real paths by default. Never run with --preserve-symlinks.
  • SQLite uses better-sqlite3, not Node's built-in node:sqlite, because Kysely's SQLite dialect targets the better-sqlite3 API. It ships prebuilt binaries for common platforms.
  • Generated lexicon code is committed, with a test that fails when it is stale.
  • Migrations run on server startup. Kysely's migrator takes a lock, so several instances starting together on Postgres do not run a migration twice.
  • Kysely 0.29 exports the migrator from kysely/migration, not the main entry.
  • TypeScript 7, the native compiler, handles the project, including the generated lexicon code.
  • Timestamps and JSON are stored as text on both databases, trading Postgres-native types for portability. Search and other features that need native types add them behind dialect helpers.
  • The health status page was the foundation's placeholder. The web-ui spec replaces it with the real app, so its test moved there.
  • Settings started out in a config.yml file. They moved to the environment and the admin area, as the admin-settings spec describes, so admins can change them without editing files or restarting.

Acceptance Criteria #