Files
GearBox/.planning/research/ARCHITECTURE.md

41 KiB

Architecture Research: v2.0 Platform Foundation

Domain: Multi-user gear management and discovery platform Researched: 2026-04-03 Confidence: HIGH

System Overview: Integration Map

The v2.0 transformation touches every layer of the stack. This is not a feature addition -- it is a structural migration. The diagram below shows the current architecture (left) and the target architecture (right), with change annotations.

CURRENT (v1.x)                          TARGET (v2.0)
==============                          =============

CLIENT LAYER
+------------------------------+        +----------------------------------------+
| TanStack Router (file-based) |        | TanStack Router (file-based)           |
| Routes: /, /collection,     |        | Routes: /, /collection, /threads,      |
|   /threads, /setups, /login, |        |   /setups, /login, /settings,          |
|   /settings                  |        |   /profile/:username,      [NEW]       |
|                              |        |   /discover,               [NEW]       |
|                              |        |   /items/:id,              [NEW]       |
+------------------------------+        +----------------------------------------+
| Hooks: useItems, useThreads, |        | Hooks: same + useProfile,  [NEW]      |
|   useSetups, useCandidates,  |        |   useDiscover, useGlobalItems,         |
|   useCategories, useAuth     |        |   useReviews                           |
+------------------------------+        +----------------------------------------+
| lib/api.ts (fetch wrapper)   |        | lib/api.ts (unchanged)                 |
+------------------------------+        +----------------------------------------+

SERVER LAYER
+------------------------------+        +----------------------------------------+
| Hono app                     |        | Hono app                               |
| Middleware:                  |        | Middleware:                             |
|   db injection (c.set("db")) |        |   db injection                         |
|   requireAuth (cookie/apikey)|        |   oidcAuth (@hono/oidc-auth)  [REPLACE]|
|                              |        |   extractUser (c.set("user")) [NEW]    |
+------------------------------+        +----------------------------------------+
| Routes:                      |        | Routes:                                |
|   /api/auth/* (login/setup)  |        |   /api/auth/* (OIDC callbacks)[REPLACE]|
|   /api/items                 |        |   /api/items (scoped by user) [MODIFY] |
|   /api/categories            |        |   /api/categories (user-owned)[MODIFY] |
|   /api/threads               |        |   /api/threads (user-owned)   [MODIFY] |
|   /api/setups                |        |   /api/setups (user-owned)    [MODIFY] |
|   /api/totals                |        |   /api/totals (user-scoped)   [MODIFY] |
|   /api/images                |        |   /api/images                 [MODIFY] |
|   /api/settings              |        |   /api/settings (user-scoped) [MODIFY] |
|   /mcp                       |        |   /api/global-items           [NEW]    |
|                              |        |   /api/reviews                [NEW]    |
|                              |        |   /api/profiles               [NEW]    |
|                              |        |   /api/discover               [NEW]    |
|                              |        |   /mcp (user-scoped)          [MODIFY] |
+------------------------------+        +----------------------------------------+
| Services: item, thread,      |        | Services: same (add userId param)      |
|   setup, category, auth,     |        |   + globalItem.service         [NEW]   |
|   csv, image, totals         |        |   + review.service             [NEW]   |
|                              |        |   + profile.service            [NEW]   |
|                              |        |   + discover.service           [NEW]   |
|                              |        |   auth.service (gutted)        [REPLACE]|
+------------------------------+        +----------------------------------------+

DATABASE LAYER
+------------------------------+        +----------------------------------------+
| SQLite (bun:sqlite)          |        | PostgreSQL (postgres.js)     [REPLACE] |
| Drizzle ORM (sqlite-core)    |        | Drizzle ORM (pg-core)        [REPLACE] |
| Schema: sqliteTable          |        | Schema: pgTable              [REPLACE] |
|                              |        |                                        |
| Tables:                      |        | Tables:                                |
|   categories (no userId)     |        |   categories (+userId)       [MODIFY]  |
|   items (no userId)          |        |   items (+userId, +globalId) [MODIFY]  |
|   threads (no userId)        |        |   threads (+userId)          [MODIFY]  |
|   threadCandidates           |        |   threadCandidates           [NO CHANGE]|
|   setups (no userId)         |        |   setups (+userId,+isPublic) [MODIFY]  |
|   setupItems                 |        |   setupItems                 [NO CHANGE]|
|   settings                   |        |   settings (+userId)         [MODIFY]  |
|   users (password-based)     |        |   users (OIDC subject)       [REPLACE] |
|   sessions (local)           |        |   (removed - OIDC handles)   [DELETE]  |
|   apiKeys                    |        |   apiKeys (+userId)          [MODIFY]  |
|                              |        |   globalItems                [NEW]     |
|                              |        |   reviews                    [NEW]     |
+------------------------------+        +----------------------------------------+

IMAGE STORAGE
+------------------------------+        +----------------------------------------+
| Local filesystem (./uploads) |        | S3-compatible (MinIO)        [REPLACE] |
| serveStatic("/uploads/*")    |        | Presigned URLs               [REPLACE] |
+------------------------------+        +----------------------------------------+

Component-by-Component Analysis

1. Database Migration: SQLite to PostgreSQL

What changes: The entire database layer switches from bun:sqlite + drizzle-orm/bun-sqlite to postgres.js + drizzle-orm/postgres-js. This is a rewrite of the schema file and the db connection module, not an automated migration.

Why a rewrite, not a migration tool: Drizzle uses different table constructors per dialect (sqliteTable vs pgTable), different column type imports (drizzle-orm/sqlite-core vs drizzle-orm/pg-core), and different driver bindings. There is no Drizzle utility to convert between dialects. The schema must be rewritten manually.

Schema translation map:

SQLite (current) PostgreSQL (target) Notes
sqliteTable pgTable Import from drizzle-orm/pg-core
integer("id").primaryKey({ autoIncrement: true }) serial("id").primaryKey() Or integer().primaryKey().generatedAlwaysAsIdentity()
text("name") varchar("name", { length: 255 }) or text("name") Use varchar where length matters
integer("price_cents") integer("price_cents") Same
real("weight_grams") real("weight_grams") or doublePrecision("weight_grams") real maps to Postgres float4
integer("created_at", { mode: "timestamp" }) timestamp("created_at").defaultNow() Native Postgres timestamp
text("status").default("active") text("status").default('active') Same, or use Postgres enum
.all() .execute() or collect from async iterator Postgres queries are async

Connection module change:

// CURRENT: src/db/index.ts
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
const sqlite = new Database("gearbox.db");
export const db = drizzle(sqlite, { schema });

// TARGET: src/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client, { schema });

Critical implication -- synchronous to async: SQLite queries via bun:sqlite are synchronous (.get(), .all(), .run()). Postgres queries are async. Every service function that currently returns a value synchronously will need to become async and return a Promise. This cascades through routes (which are already async in Hono) but changes every service signature.

Service layer impact example:

// CURRENT (synchronous)
export function getAllItems(db: Db) {
  return db.select(...).from(items).all();  // returns Item[]
}

// TARGET (async)
export async function getAllItems(db: Db) {
  return db.select(...).from(items);  // returns Promise<Item[]>
}

Test infrastructure change: The current createTestDb() uses in-memory SQLite. For Postgres testing, options are:

  1. PGlite -- Postgres compiled to WASM, runs in-process like SQLite. Drop-in for tests. Drizzle supports it via drizzle-orm/pglite. This is the recommended approach.
  2. Testcontainers -- Spin up a real Postgres Docker container per test suite. More realistic but slower.
  3. Shared test database -- Single Postgres instance with schema reset between tests. Fast but not isolated.

Recommendation: Use PGlite for unit/integration tests (fastest, no Docker dependency, matches Postgres semantics). Use real Postgres for E2E tests.

Data migration (one-time): Export existing SQLite data as INSERT statements or CSV, import into Postgres. The schema shapes will be different (new columns), so this needs a custom migration script. Since this is a single-user app becoming multi-user, the existing data becomes the first user's data.


2. Multi-Tenancy: Adding userId to Entity Tables

What changes: Every entity table (items, categories, threads, setups, settings) gains a userId column as a NOT NULL foreign key to the users table. All queries are scoped by the authenticated user.

Schema additions:

// All entity tables gain:
userId: integer("user_id").notNull().references(() => users.id),

// The users table is redesigned for OIDC:
export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  externalId: varchar("external_id", { length: 255 }).notNull().unique(), // OIDC subject ID
  username: varchar("username", { length: 100 }).notNull().unique(),
  displayName: varchar("display_name", { length: 255 }),
  avatarUrl: text("avatar_url"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

// Sessions table: REMOVED (OIDC middleware handles sessions via JWT cookies)
// passwordHash on users: REMOVED (OIDC handles passwords)

Service layer pattern change -- userId injection:

The existing service pattern takes (db, ...) as first arguments. Adding userId requires passing it through from the middleware.

// CURRENT
export function getAllItems(db: Db) {
  return db.select(...).from(items).all();
}

// TARGET -- userId as second param
export async function getAllItems(db: Db, userId: number) {
  return db.select(...).from(items).where(eq(items.userId, userId));
}

Middleware provides userId:

// New middleware: extractUser
// Runs after OIDC auth, resolves OIDC subject to local user ID
app.use("/api/*", async (c, next) => {
  const oidcUser = c.get("oidc-user"); // from @hono/oidc-auth
  if (!oidcUser) return next(); // public read routes
  const user = await getOrCreateUser(db, oidcUser.sub, oidcUser);
  c.set("userId", user.id);
  return next();
});

Route layer change -- extract userId and pass to services:

// CURRENT
app.get("/", (c) => {
  const db = c.get("db");
  const items = getAllItems(db);
  return c.json(items);
});

// TARGET
app.get("/", (c) => {
  const db = c.get("db");
  const userId = c.get("userId");
  const items = await getAllItems(db, userId);
  return c.json(items);
});

Tables that get userId:

Table userId Behavior Notes
items Required. User owns their items. Also gains globalItemId FK (optional)
categories Required. Users define their own categories. Seed "Uncategorized" per user on first login
threads Required. User owns their research threads.
setups Required. User owns their setups. Also gains isPublic boolean
settings Required. Per-user settings. Change PK from key to (userId, key)
apiKeys Required. User manages their own API keys.
threadCandidates No change. Scoped via thread's userId. Accessed through parent thread
setupItems No change. Scoped via setup's userId. Accessed through parent setup

Categories: user-scoped, not global. Each user creates their own categories. This keeps the model simple -- no shared namespace conflicts. The "Uncategorized" category is seeded per user on account creation.


3. Authentication: OIDC via External Provider

What changes: The entire auth system (users table with passwordHash, sessions table, login/setup routes, requireAuth middleware) is replaced with OIDC-based authentication delegated to an external provider.

Recommended provider: Authentik because:

  • Full OIDC/OAuth2 support (needed for standard web app auth flow)
  • Modern UI, actively maintained (Python/Django, 40k+ GitHub stars)
  • Self-hosted, open-source (matches project constraints)
  • Supports user registration, password management, MFA out of the box
  • The project already plans to run Postgres (Authentik requires Postgres + Redis)
  • More capable than Authelia (which is only a forward-auth proxy, not a full IdP)
  • Lighter than Keycloak (~600MB vs 1GB+, simpler config)

OIDC integration with Hono:

Use @hono/oidc-auth -- official Hono third-party middleware. It handles the Authorization Code Flow, token exchange, and session cookies (as signed JWTs). No session table needed.

// Configuration
import { oidcAuthMiddleware, getAuth } from "@hono/oidc-auth";

// Environment variables:
// OIDC_AUTH_SECRET - signing key for session JWT
// OIDC_ISSUER - Authentik issuer URL
// OIDC_CLIENT_ID - from Authentik provider config
// OIDC_CLIENT_SECRET - from Authentik provider config
// OIDC_REDIRECT_URI - callback URL

app.use("/api/*", oidcAuthMiddleware());

Auth flow:

User visits app -> no session cookie
  -> Redirect to Authentik login page
  -> User logs in / registers at Authentik
  -> Authentik redirects back with authorization code
  -> @hono/oidc-auth exchanges code for tokens
  -> Middleware sets signed JWT cookie (stateless session)
  -> Subsequent requests: middleware validates JWT, sets oidc-user in context

What gets removed:

  • src/server/services/auth.service.ts -- password hashing, session CRUD (gutted; API key management stays)
  • src/server/routes/auth.ts -- login, setup, password change routes (replaced with OIDC callbacks)
  • requireAuth middleware -- replaced by oidcAuthMiddleware()
  • users.passwordHash column -- removed
  • sessions table -- removed entirely

What stays:

  • API key system -- still needed for MCP and programmatic access. API keys bypass OIDC and authenticate directly. The apiKeys table gains a userId column.
  • Rate limiting middleware on auth-adjacent routes.

Public read vs authenticated write: This pattern remains. OIDC middleware can be configured to not block GET requests:

// Custom wrapper: require auth only for write operations
app.use("/api/*", async (c, next) => {
  if (c.req.method === "GET") return next(); // public read
  return oidcAuthMiddleware()(c, next);       // authenticated write
});

User provisioning on first login: When the OIDC callback succeeds and no local user exists for the sub (subject) claim, create one:

async function getOrCreateUser(db: Db, sub: string, claims: OidcClaims) {
  let user = await db.select().from(users)
    .where(eq(users.externalId, sub)).limit(1).then(r => r[0]);

  if (!user) {
    user = await db.insert(users).values({
      externalId: sub,
      username: claims.preferred_username ?? claims.sub,
      displayName: claims.name ?? null,
      avatarUrl: claims.picture ?? null,
    }).returning().then(r => r[0]);

    // Seed default category for new user
    await db.insert(categories).values({
      name: "Uncategorized",
      icon: "package",
      userId: user.id,
    });
  }

  return user;
}

4. Global Item Database

What changes: A new globalItems table holds manufacturer/canonical gear data. User items can optionally link to a global item via globalItemId FK. Global items are not owned by any user -- they are platform-level data.

Schema:

export const globalItems = pgTable("global_items", {
  id: serial("id").primaryKey(),
  name: varchar("name", { length: 255 }).notNull(),
  brand: varchar("brand", { length: 255 }),
  weightGrams: real("weight_grams"),
  priceCents: integer("price_cents"),     // MSRP
  productUrl: text("product_url"),
  imageFilename: text("image_filename"),
  categorySlug: varchar("category_slug", { length: 100 }), // platform-level category
  description: text("description"),
  specs: jsonb("specs"),                  // flexible key-value for category-specific specs
  ownerCount: integer("owner_count").default(0).notNull(),  // denormalized count
  avgRating: real("avg_rating"),          // denormalized from reviews
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

// Index for search
// CREATE INDEX idx_global_items_name ON global_items USING gin(to_tsvector('english', name));

Relationship to user items:

export const items = pgTable("items", {
  // ... existing fields ...
  userId: integer("user_id").notNull().references(() => users.id),
  globalItemId: integer("global_item_id").references(() => globalItems.id), // nullable
});

When a user adds a global item to their collection, a user-owned items row is created with globalItemId pointing to the canonical entry. The user can override weight/price/notes locally. The global item's ownerCount is incremented (denormalized counter, updated via trigger or service logic).

Data flow for global item search:

User searches for gear -> GET /api/global-items?q=tent
  -> Full-text search on globalItems.name (Postgres ts_vector)
  -> Returns matches with ownerCount, avgRating
  -> User clicks "Add to Collection"
  -> POST /api/items { ...globalItemDefaults, globalItemId: 42 }
  -> Creates user item linked to global item
  -> Increment globalItems.ownerCount

New service: globalItem.service.ts

  • searchGlobalItems(db, query, limit, offset) -- full-text search
  • getGlobalItem(db, id) -- single item with aggregated stats
  • getGlobalItemOwners(db, globalItemId) -- users who own this item (public profiles only)
  • getGlobalItemSetups(db, globalItemId) -- public setups containing this item

New routes: /api/global-items

  • GET / -- search/browse (public)
  • GET /:id -- detail with stats (public)
  • GET /:id/owners -- owner list (public)
  • GET /:id/setups -- setups containing this item (public)

Seeding: Global items are initially seeded from manufacturer data (CSV import or manual entry). Users do not create global items directly -- they create user items that may or may not link to global items. A future admin interface could allow promoting frequently-created user items to global items.


5. Structured Reviews

What changes: Users can review global items with structured ratings -- no freeform text (as per project constraints).

Schema:

export const reviews = pgTable("reviews", {
  id: serial("id").primaryKey(),
  userId: integer("user_id").notNull().references(() => users.id),
  globalItemId: integer("global_item_id").notNull().references(() => globalItems.id),
  overallRating: integer("overall_rating").notNull(), // 1-5
  weightRating: integer("weight_rating"),              // 1-5: "lighter than expected" to "heavier"
  durabilityRating: integer("durability_rating"),      // 1-5
  valueRating: integer("value_rating"),                // 1-5: price vs quality
  ownershipMonths: integer("ownership_months"),        // how long they've owned it
  wouldRecommend: boolean("would_recommend"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
}, (table) => ({
  uniqueReview: unique().on(table.userId, table.globalItemId), // one review per user per item
}));

Why structured, not freeform: The project explicitly defers freeform text until moderation infrastructure exists. Structured ratings (integer scales, boolean) require no moderation, are trivially aggregatable, and provide the "crowd-verified data" value prop.

New service: review.service.ts

  • createReview(db, userId, globalItemId, data) -- insert or upsert
  • getReviewsForItem(db, globalItemId) -- all reviews with user info
  • getReviewStats(db, globalItemId) -- aggregated averages
  • getUserReview(db, userId, globalItemId) -- single user's review

Denormalization: When a review is created/updated, recompute globalItems.avgRating from the reviews table. This avoids a JOIN on every global item list query.


6. Public Profiles and Setup Sharing

What changes: Users get public profile pages showing their shared setups. Setups gain an isPublic boolean.

Schema changes:

export const setups = pgTable("setups", {
  // ... existing fields ...
  userId: integer("user_id").notNull().references(() => users.id),
  isPublic: boolean("is_public").default(false).notNull(),
  description: text("description"),  // optional setup description for public view
});

New routes:

GET /api/profiles/:username        -- public profile (user info + public setups)
GET /api/profiles/:username/setups -- public setups with totals

New client routes:

/profile/:username  -- public profile page

New service: profile.service.ts

  • getPublicProfile(db, username) -- user info + public setup count + item count
  • getPublicSetups(db, userId) -- setups where isPublic = true with totals

7. Discovery Feed

What changes: A new discovery page aggregates public content: recently shared setups, popular global items, new reviews.

New client route: /discover

New route: /api/discover

  • GET /feed -- mixed feed of recent public activity
  • GET /popular-items -- global items sorted by ownerCount
  • GET /recent-setups -- recently published public setups

New service: discover.service.ts

  • getDiscoveryFeed(db, cursor, limit) -- cursor-paginated feed
  • getPopularItems(db, limit) -- top global items by owner count
  • getRecentPublicSetups(db, cursor, limit) -- latest public setups

Query pattern: No separate "feed" table. Discovery queries are real-time aggregations over existing tables with appropriate indexes:

-- Popular items
SELECT * FROM global_items ORDER BY owner_count DESC LIMIT 20;

-- Recent public setups with user info
SELECT s.*, u.username, u.avatar_url
FROM setups s
JOIN users u ON s.user_id = u.id
WHERE s.is_public = true
ORDER BY s.updated_at DESC
LIMIT 20;

Indexes needed:

  • globalItems.ownerCount DESC for popular items
  • setups.isPublic + setups.updatedAt DESC for recent public setups
  • reviews.globalItemId for review aggregation

8. Image Storage: Local to S3-Compatible (MinIO)

What changes: Images move from local filesystem (./uploads/) to S3-compatible object storage (MinIO, self-hosted). The image service changes from file write to S3 PUT, and image URLs change from /uploads/filename.jpg to presigned S3 URLs or a proxy endpoint.

Why MinIO: Self-hosted, S3-compatible API (use standard @aws-sdk/client-s3), handles multi-instance deployment, no vendor lock-in. Runs as a single Docker container alongside the app.

New image flow:

Upload:
  Client -> POST /api/images (multipart) -> image.service.ts
    -> S3 PutObject to MinIO bucket
    -> Return { filename: "uuid.jpg" }

Serve:
  Option A: Presigned GET URL (generated on demand, expires in 1h)
    -> Client receives presigned URL in item response
    -> Browser fetches directly from MinIO

  Option B: Proxy through app server
    -> GET /api/images/:filename -> S3 GetObject -> stream to client
    -> Simpler CORS, but adds server load

Recommendation: Start with Option B (proxy) for simplicity. The existing pattern of returning imageFilename in item responses stays the same; only the serving endpoint changes from serveStatic("/uploads/*") to a dynamic handler that streams from S3. Migrate to presigned URLs later if performance demands it.

image.service.ts changes:

// CURRENT
await Bun.write(join(uploadsDir, filename), buffer);

// TARGET
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({
  endpoint: process.env.S3_ENDPOINT,      // e.g., http://minio:9000
  region: "us-east-1",
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY!,
    secretAccessKey: process.env.S3_SECRET_KEY!,
  },
  forcePathStyle: true, // required for MinIO
});

await s3.send(new PutObjectCommand({
  Bucket: "gearbox-images",
  Key: filename,
  Body: Buffer.from(buffer),
  ContentType: contentType,
}));

Migration of existing images: One-time script uploads all files from ./uploads/ to the MinIO bucket.


New vs Modified Components -- Complete Inventory

New Files

File Purpose
src/db/schema.ts Full rewrite from sqlite-core to pg-core
src/db/index.ts Postgres connection via postgres.js
src/server/middleware/oidcAuth.ts OIDC auth middleware wrapping @hono/oidc-auth
src/server/middleware/extractUser.ts Resolves OIDC subject to local userId, sets in context
src/server/services/globalItem.service.ts Global item CRUD, search, stats
src/server/services/review.service.ts Structured review CRUD, aggregation
src/server/services/profile.service.ts Public profile queries
src/server/services/discover.service.ts Discovery feed queries
src/server/routes/globalItems.ts Global item endpoints
src/server/routes/reviews.ts Review endpoints
src/server/routes/profiles.ts Public profile endpoints
src/server/routes/discover.ts Discovery feed endpoints
src/client/routes/discover.tsx Discovery page
src/client/routes/profile/$username.tsx Public profile page
src/client/routes/items/$id.tsx Global item detail page
src/client/hooks/useGlobalItems.ts Global item search/detail hooks
src/client/hooks/useReviews.ts Review hooks
src/client/hooks/useProfile.ts Profile hooks
src/client/hooks/useDiscover.ts Discovery feed hooks
src/shared/schemas.ts Review schemas, global item schemas (extend existing)
tests/helpers/db.ts Rewrite to use PGlite
e2e/seed.ts Rewrite for Postgres + new schema
Migration script One-time SQLite-to-Postgres data migration
Image migration script One-time upload of ./uploads/ to MinIO

Modified Files (Every Service + Route)

File What Changes
Every service file (*.service.ts) All functions become async, gain userId parameter, queries scoped by userId
Every route file (routes/*.ts) Extract userId from context, pass to services, await async service calls
src/server/index.ts Replace auth middleware chain, add new route registrations, remove serveStatic for uploads
src/shared/schemas.ts Add review/globalItem/profile schemas
src/shared/types.ts Add new types from new schemas + Drizzle tables
src/server/services/auth.service.ts Remove password/session management, keep API key management (add userId scoping)
src/server/routes/auth.ts Replace login/setup with OIDC callback handling
src/server/services/image.service.ts Replace filesystem writes with S3 operations
src/server/routes/images.ts Add proxy endpoint for S3 reads, remove serveStatic
src/client/hooks/useAuth.ts Adapt to OIDC-based auth state
src/client/routes/login.tsx Redirect to OIDC provider instead of local login form
src/server/mcp/ All MCP tools gain userId scoping
All test files Switch to PGlite, update for async services, add userId to test data

Removed Files

File Reason
sessions table in schema OIDC handles sessions
Password hashing logic in auth.service OIDC handles passwords

Data Flow Changes

Existing Flows (Modified)

CURRENT:
  useItems() -> GET /api/items -> getAllItems(db) -> all items in db

TARGET:
  useItems() -> GET /api/items -> getAllItems(db, userId) -> items WHERE userId = X

This pattern applies uniformly to items, categories, threads, setups, settings, and API keys.

New Flows

Global Item Search:
  User types in search box on Discover page
    -> useGlobalItems(query) -> GET /api/global-items?q=tent
    -> searchGlobalItems(db, "tent")
    -> Postgres full-text search on global_items
    -> Return results with ownerCount, avgRating

Add Global Item to Collection:
  User clicks "Add to Collection" on global item
    -> POST /api/items { name, weightGrams, ..., globalItemId: 42 }
    -> createItem(db, userId, data) with globalItemId
    -> Increment globalItems.ownerCount
    -> Item appears in user's collection

Submit Review:
  User clicks "Rate" on a global item they own
    -> POST /api/reviews { globalItemId, overallRating, ... }
    -> createReview(db, userId, data)
    -> Recompute globalItems.avgRating
    -> Review appears on item detail page

View Public Setup:
  Visitor navigates to /profile/username
    -> useProfile(username) -> GET /api/profiles/username
    -> getPublicProfile(db, username)
    -> Return user info + public setups list
    -> Click setup -> full setup detail with items and totals

Discovery Feed:
  User visits /discover
    -> useDiscover() -> GET /api/discover/feed
    -> getDiscoveryFeed(db, cursor, limit)
    -> Mixed content: popular items, recent setups, trending reviews
    -> Cursor-based pagination for infinite scroll

Build Order (Dependency-Aware)

The transformation has a strict dependency chain. Each phase must complete before the next can begin.

Phase 1: Database Migration (SQLite -> Postgres)
  ├── Rewrite src/db/schema.ts from sqliteTable to pgTable
  ├── Rewrite src/db/index.ts for postgres.js connection
  ├── Rewrite tests/helpers/db.ts for PGlite
  ├── Make all service functions async
  ├── Update all route handlers to await service calls
  ├── Generate fresh Drizzle migrations
  ├── Update e2e/seed.ts for Postgres
  ├── Fix all tests (async assertions, PGlite)
  ├── Docker compose: add Postgres container
  └── Data migration script (SQLite -> Postgres, one-time)
  DEPENDENCY: None. This is the foundation.
  RISK: Highest risk phase. Touches every file. Must be done first and fully.

Phase 2: Multi-User Data Model (userId on all tables)
  ├── Add userId column to items, categories, threads, setups, settings, apiKeys
  ├── Update users table (add externalId, remove passwordHash)
  ├── Add userId parameter to every service function
  ├── Update every route to extract userId from context and pass to services
  ├── Add user creation/lookup service (getOrCreateUser)
  ├── Seed "Uncategorized" category per user
  ├── Update all tests with userId in test data
  ├── Update MCP tools with userId scoping
  └── Drizzle migration for new columns + FK constraints
  DEPENDENCY: Phase 1 (Postgres schema must be in place)
  RISK: Medium. Mechanical but broad. Every query changes.

Phase 3: External Auth (OIDC)
  ├── Install @hono/oidc-auth
  ├── Create OIDC middleware wrapper
  ├── Create extractUser middleware
  ├── Replace requireAuth with OIDC auth chain
  ├── Remove password-based auth routes (login, setup, password change)
  ├── Remove sessions table
  ├── Update API key auth to work alongside OIDC (bypass for X-API-Key)
  ├── Update client login page to redirect to OIDC provider
  ├── Update useAuth hook for OIDC-based state
  ├── Deploy Authentik (Docker compose)
  └── Configure Authentik provider (OIDC client)
  DEPENDENCY: Phase 2 (users table with externalId must exist)
  RISK: Medium. External dependency on Authentik. Test OIDC flow thoroughly.

Phase 4: Image Storage (S3/MinIO)
  ├── Install @aws-sdk/client-s3
  ├── Update image.service.ts (S3 put instead of file write)
  ├── Add image proxy route (GET /api/images/:filename -> S3 get)
  ├── Remove serveStatic("/uploads/*")
  ├── Deploy MinIO container (Docker compose)
  ├── Migrate existing images to MinIO bucket
  └── Update image deletion to S3 DeleteObject
  DEPENDENCY: None technically, but deploy alongside Phase 1-3 infra.
  RISK: Low. Well-understood pattern. S3 SDK is mature.

Phase 5: Global Items + Reviews
  ├── Create globalItems table
  ├── Add globalItemId FK to items table
  ├── Create reviews table
  ├── Create globalItem.service.ts
  ├── Create review.service.ts
  ├── Create routes/globalItems.ts
  ├── Create routes/reviews.ts
  ├── Add Postgres full-text search index on globalItems.name
  ├── Create client hooks (useGlobalItems, useReviews)
  ├── Create global item detail page (/items/:id)
  ├── Add "Add to Collection" flow
  ├── Add review submission UI
  └── Seed initial global items (CSV import)
  DEPENDENCY: Phases 1-3 (Postgres, multi-user, auth)
  RISK: Medium. Full-text search needs Postgres-specific config.

Phase 6: Public Profiles + Discovery
  ├── Add isPublic to setups table
  ├── Create profile.service.ts
  ├── Create discover.service.ts
  ├── Create routes/profiles.ts
  ├── Create routes/discover.ts
  ├── Create /profile/:username client route
  ├── Create /discover client route
  ├── Add setup sharing toggle UI
  ├── Add discovery feed with cursor pagination
  └── Add indexes for discovery queries
  DEPENDENCY: Phase 5 (global items provide content for discovery)
  RISK: Low. Standard CRUD + queries. Design-heavy more than technically complex.

Phase ordering rationale:

  1. Postgres must come first because everything else depends on it (OIDC needs async queries, multi-tenancy needs FK constraints on userId, global items need full-text search).
  2. Multi-user data model before auth because the schema must support userId before auth can populate it.
  3. Auth before platform features because platform features require knowing who the user is.
  4. Image storage is independent but bundles well with infrastructure setup.
  5. Global items before discovery because the discovery feed aggregates global item data.

Patterns to Follow

Pattern 1: userId Injection via Middleware -> Context -> Service

What: Middleware resolves the authenticated user and stores userId in Hono context. Routes extract it and pass to services. Services never access auth state directly.

Why: Maintains the existing testable service pattern. Services remain pure functions of (db, userId, data) with no HTTP awareness.

// Middleware sets
c.set("userId", user.id);

// Route extracts
const userId = c.get("userId");
const items = await getAllItems(db, userId);

// Service uses
export async function getAllItems(db: Db, userId: number) { ... }

Pattern 2: Dual Auth Path (OIDC + API Key)

What: OIDC handles browser sessions. API keys bypass OIDC for programmatic access (MCP, scripts). Both resolve to a userId.

app.use("/api/*", async (c, next) => {
  if (c.req.method === "GET") return next(); // public read

  // API key takes priority (no OIDC redirect for API clients)
  const apiKey = c.req.header("X-API-Key");
  if (apiKey) {
    const userId = await verifyApiKey(db, apiKey);
    if (!userId) return c.json({ error: "Invalid API key" }, 401);
    c.set("userId", userId);
    return next();
  }

  // Fall through to OIDC
  return oidcAuthMiddleware()(c, next);
});

Pattern 3: Global Item Linking (User Item -> Global Item)

What: User items optionally reference a global item. The user's local copy can override fields. The global item tracks aggregate stats (ownerCount, avgRating).

Why: Separates user-customizable data from canonical data. A user might note a different weight for their specific unit, or a sale price. The global item maintains the manufacturer spec.

Pattern 4: Cursor-Based Pagination for Discovery

What: Discovery feed uses cursor-based pagination (?cursor=<last_id>&limit=20) instead of offset-based (?page=2). The cursor is the ID or timestamp of the last returned item.

Why: Offset pagination breaks when new content is inserted (items shift between pages). Cursor pagination is stable, performant (uses indexed seek), and natural for infinite scroll.

export async function getRecentPublicSetups(db: Db, cursor?: number, limit = 20) {
  let query = db.select(...).from(setups)
    .where(eq(setups.isPublic, true))
    .orderBy(desc(setups.updatedAt))
    .limit(limit);

  if (cursor) {
    query = query.where(and(eq(setups.isPublic, true), lt(setups.id, cursor)));
  }

  return query;
}

Anti-Patterns to Avoid

Anti-Pattern 1: Shared Global Categories

What people do: Make categories global (shared across all users) to avoid duplication. Why bad: Category naming is personal. One user's "Shelter" is another's "Tents". Shared categories create namespace conflicts and force consensus. Global items use a separate categorySlug for platform-level categorization. Do instead: User-scoped categories. Each user creates their own. Seed "Uncategorized" per user.

Anti-Pattern 2: Row-Level Security in Application Code

What people do: Add complex permission checks in every query (can this user see this item? is this setup public?). Why bad: Scattered authorization logic is hard to audit and easy to miss. Do instead: Consistent pattern: private data always filtered by WHERE userId = X. Public data (profiles, shared setups, global items) has dedicated read-only endpoints. No mixed-permission queries.

Anti-Pattern 3: Storing OIDC Tokens in the Database

What people do: Save access/refresh tokens from the OIDC provider in a local sessions table. Why bad: @hono/oidc-auth already handles this via signed JWT cookies. Storing tokens adds a session table, cleanup logic, and token refresh management that the middleware handles automatically. Do instead: Let the OIDC middleware manage session state. Only store the externalId (OIDC subject) in the users table for local user lookup.

Anti-Pattern 4: Migrating SQLite Data via Drizzle Migrations

What people do: Try to use drizzle-kit push or drizzle-kit migrate to move data from SQLite to Postgres. Why bad: Drizzle migrations are DDL (schema changes), not DML (data transfer). They cannot move data between databases. The schema syntax is dialect-specific. Do instead: Write a one-time migration script: read from SQLite, transform, insert into Postgres. Use Drizzle's query builder for both reads and writes.

Anti-Pattern 5: Making All Queries Public-Aware from Day 1

What people do: Add isPublic checks to every query from the start, even before discovery features exist. Why bad: Adds complexity to every query before it's needed. Multi-tenancy (userId scoping) is sufficient for phases 1-4. Do instead: Add isPublic only when implementing profiles/discovery (Phase 6). Until then, all data is private-by-default (scoped by userId).


Scalability Considerations

Concern At 10 users At 1K users At 10K users
Database Single Postgres, fine Single Postgres, fine Connection pooling, read replicas
Image storage MinIO single instance MinIO, fine to 1TB+ CDN in front of MinIO
Global item search Basic LIKE query Postgres full-text search Add search index, consider pg_trgm
Discovery feed Direct queries Add query caching (Redis) Materialized views
Auth Authentik single instance Authentik, fine Authentik HA setup

Sources


Architecture research for: GearBox v2.0 Platform Foundation Researched: 2026-04-03