Merge branch 'worktree-agent-ae56a15a' into Develop

# Conflicts:
#	.planning/ROADMAP.md
#	.planning/STATE.md
#	docker-compose.dev.yml
#	docker-compose.yml
#	src/db/schema.ts
This commit is contained in:
2026-04-04 20:41:11 +02:00
11 changed files with 271 additions and 115 deletions

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# PostgreSQL
POSTGRES_PASSWORD=changeme
# Logto OIDC (get from Logto Admin Console at http://localhost:3002)
LOGTO_ENDPOINT=http://localhost:3001
LOGTO_ADMIN_ENDPOINT=http://localhost:3002
LOGTO_CLIENT_ID=your-app-client-id
LOGTO_CLIENT_SECRET=your-app-client-secret
OIDC_AUTH_SECRET=generate-a-random-32-char-string-here
# Derived (set in docker-compose.yml, not needed here):
# OIDC_ISSUER=${LOGTO_ENDPOINT}/oidc
# GearBox
GEARBOX_URL=http://localhost:3000

View File

@@ -20,7 +20,7 @@ Requirements for this milestone. Each maps to roadmap phases.
- [ ] **AUTH-01**: User can register an account via external OIDC auth provider
- [ ] **AUTH-02**: User can log in via external auth provider and access their data
- [ ] **AUTH-03**: API keys remain functional for programmatic access (MCP, scripts)
- [ ] **AUTH-04**: Auth provider runs self-hosted alongside the application
- [x] **AUTH-04**: Auth provider runs self-hosted alongside the application
- [ ] **AUTH-05**: E2E tests authenticate via API keys without depending on the auth provider
### Multi-User Data Model
@@ -124,7 +124,7 @@ Which phases cover which requirements. Updated during roadmap creation.
| AUTH-01 | Phase 15 | Pending |
| AUTH-02 | Phase 15 | Pending |
| AUTH-03 | Phase 15 | Pending |
| AUTH-04 | Phase 15 | Pending |
| AUTH-04 | Phase 15 | Complete |
| AUTH-05 | Phase 15 | Pending |
| MULTI-01 | Phase 16 | Pending |
| MULTI-02 | Phase 16 | Pending |

View File

@@ -5,7 +5,7 @@
-**v1.0 MVP** — Phases 1-3 (shipped 2026-03-15)
-**v1.1 Fixes & Polish** — Phases 4-6 (shipped 2026-03-15)
-**v1.2 Collection Power-Ups** — Phases 7-9 (shipped 2026-03-16)
- **v1.3 Research & Decision Tools** — Phases 10-13 (shipped)
- 🚧 **v1.3 Research & Decision Tools** — Phases 10-13 (in progress)
- 📋 **v2.0 Platform Foundation** — Phases 14-18 (planned)
## Phases
@@ -37,21 +37,20 @@
</details>
<details>
<summary>✅ v1.3 Research & Decision Tools (Phases 10-13) — SHIPPED</summary>
### v1.3 Research & Decision Tools (In Progress)
- [x] Phase 10: Schema Foundation + Pros/Cons Fields (1/1 plans) — completed 2026-03-16
- [x] Phase 11: Candidate Ranking (2/2 plans) — completed 2026-03-16
- [x] Phase 12: Comparison View (1/1 plans) — completed 2026-03-17
- [x] Phase 13: Setup Impact Preview (completed outside GSD)
**Milestone Goal:** Give users the tools to actually decide between candidates — compare details side-by-side, see how a pick impacts their setup, and rank/annotate their options.
</details>
- [x] **Phase 10: Schema Foundation + Pros/Cons Fields** — Migrate schema and deliver pros/cons annotation UI (completed 2026-03-16)
- [x] **Phase 11: Candidate Ranking** — Drag-to-reorder priority ranking with rank badges (completed 2026-03-16)
- [x] **Phase 12: Comparison View** — Side-by-side tabular comparison with relative deltas (completed 2026-03-17)
- [ ] **Phase 13: Setup Impact Preview** — Per-candidate weight and cost delta against a selected setup
### v2.0 Platform Foundation (Planned)
**Milestone Goal:** Transform GearBox from a single-user gear tracker into a multi-user platform where people discover gear, research purchases using crowd-verified data, and share their setups.
- [x] **Phase 14: PostgreSQL Migration** — Replace SQLite with Postgres, make all operations async, establish new test infrastructure (completed 2026-04-04)
- [ ] **Phase 14: PostgreSQL Migration** — Replace SQLite with Postgres, make all operations async, establish new test infrastructure
- [ ] **Phase 15: External Authentication** — Integrate self-hosted OIDC auth provider for user registration and login
- [ ] **Phase 16: Multi-User Data Model** — Add user ownership to all entities with cross-user data isolation
- [ ] **Phase 17: Object Storage** — Move images from local filesystem to MinIO (S3-compatible)
@@ -134,11 +133,7 @@ Plans:
3. API keys continue to work for MCP tools and programmatic access without involving the auth provider
4. E2E tests run successfully using API key authentication, with no dependency on the external auth provider being available
5. The auth provider runs self-hosted in Docker Compose alongside Postgres and the application
**Plans:** 3 plans
Plans:
- [ ] 15-01-PLAN.md — Docker Compose Logto service + schema migration (drop users/sessions)
- [ ] 15-02-PLAN.md — Server-side OIDC auth integration (middleware, routes, services, MCP)
- [ ] 15-03-PLAN.md — Client auth refactor + E2E seed + test updates
**Plans**: TBD
### Phase 16: Multi-User Data Model
**Goal**: Every piece of user-created data is owned by a specific user, with complete isolation between users
@@ -193,8 +188,8 @@ Plans:
| 11. Candidate Ranking | v1.3 | 2/2 | Complete | 2026-03-16 |
| 12. Comparison View | v1.3 | 1/1 | Complete | 2026-03-17 |
| 13. Setup Impact Preview | v1.3 | 0/2 | Not started | - |
| 14. PostgreSQL Migration | v2.0 | 6/6 | Complete | 2026-04-04 |
| 15. External Authentication | v2.0 | 0/3 | Not started | - |
| 14. PostgreSQL Migration | v2.0 | 0/? | Not started | - |
| 15. External Authentication | v2.0 | 1/3 | In Progress| |
| 16. Multi-User Data Model | v2.0 | 0/? | Not started | - |
| 17. Object Storage | v2.0 | 0/? | Not started | - |
| 18. Global Items & Public Profiles | v2.0 | 0/? | Not started | - |

View File

@@ -2,16 +2,16 @@
gsd_state_version: 1.0
milestone: v2.0
milestone_name: Platform Foundation
status: Not yet planned
stopped_at: Phase 15 context gathered
last_updated: "2026-04-04T18:15:44.234Z"
last_activity: 2026-04-04
status: planning
stopped_at: null
last_updated: "2026-04-03"
last_activity: 2026-04-03 — v2.0 roadmap created (Phases 14-18)
progress:
total_phases: 9
completed_phases: 4
total_plans: 12
completed_plans: 10
percent: 20
total_phases: 5
completed_phases: 0
total_plans: 0
completed_plans: 0
percent: 0
---
# Project State
@@ -21,24 +21,23 @@ progress:
See: .planning/PROJECT.md (updated 2026-04-03)
**Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
**Current focus:** v2.0 Platform Foundation — Phase 15 (External Authentication)
**Current focus:** v2.0 Platform Foundation — Phase 14 (PostgreSQL Migration)
## Current Position
Phase: 15 of 18 (External Authentication)
Plan: 0 of 0 in current phase
Status: Not yet planned
Last activity: 2026-04-04
Plan: 1 of 3 in current phase
Status: Executing
Last activity: 2026-04-04 — Completed 15-01 (Logto Docker infrastructure + schema cleanup)
Progress: [██--------] 20% (v2.0 milestone — 1/5 phases)
Progress: [=---------] 5% (v2.0 milestone)
## Performance Metrics
**Velocity:**
- Total plans completed: 6 (v2.0 milestone, Phase 14)
- Average duration: --
- Total execution time: --
- Total plans completed: 1 (v2.0 milestone)
- Average duration: 3min
- Total execution time: 3min
*Updated after each plan completion*
@@ -46,23 +45,15 @@ Progress: [██--------] 20% (v2.0 milestone — 1/5 phases)
### Decisions
Key decisions made during v2.0 execution:
- [14-05] Used postgres.js unsafe() for sequence reset DDL instead of drizzle-orm sql template
- [14-05] Row-by-row inserts for better error diagnostics during migration
Key decisions made during v2.0 planning and execution:
- Platform pivot: single-user to multi-user with discovery-first approach
- External auth provider (self-hosted, open-source) — Logto vs Authentik OPEN decision
- External auth provider (self-hosted, open-source) — Logto selected (D-01)
- SQLite to Postgres migration — required by auth provider and multi-user concurrency
- Structured UGC only — ratings and predefined fields, no freeform text until moderation
- Separate globalItems table — not a flag on user items table
- Single-user SQLite mode diverges at v2.0 boundary
- [Phase 14-03]: Async service pattern: const [row] = await db.select()... for single-row queries
- [Phase 14-03]: OAuth used field converted from integer (0/1) to boolean (false/true)
- [Phase 14-04]: Settings route .get() replaced with destructuring: const [row] = await db.select()...
- [Phase 14-06]: Fixed PostgreSQL GROUP BY strictness in totals.service.ts
- [Phase 14-06]: Added await to all MCP tool service calls (missed in plan 14-03)
- [Phase 14-06]: Set test timeout to 30s for PGlite WASM overhead
- [Phase 13]: Setup Impact Preview completed outside GSD workflow
- Logto shares Postgres instance via separate database created by init script
- OIDC_ISSUER derived from LOGTO_ENDPOINT in docker-compose
### Pending Todos
@@ -71,9 +62,10 @@ None active.
### Blockers/Concerns
- Auth provider decision (Logto vs Authentik) must be resolved before Phase 15 planning
- Phase 14 is a full schema rewrite touching 6 services, 7 routes, 19 MCP tools, all tests
## Session Continuity
Last session: 2026-04-04T18:15:44.232Z
Stopped at: Phase 15 context gathered
Resume file: .planning/phases/15-external-authentication/15-CONTEXT.md
Last session: 2026-04-04
Stopped at: Completed 15-01-PLAN.md (Logto Docker infrastructure + schema cleanup)
Resume file: None

View File

@@ -0,0 +1,102 @@
---
phase: 15-external-authentication
plan: 01
subsystem: infra
tags: [logto, oidc, docker-compose, postgres]
# Dependency graph
requires:
- phase: 14-postgresql-migration
provides: Postgres database and Docker Compose foundation
provides:
- Logto OIDC provider running as Docker Compose service
- Postgres init script for separate Logto database
- OIDC environment variable documentation
- Schema without users/sessions tables (ready for external auth)
affects: [15-02, 15-03, 16-multi-user-data-model]
# Tech tracking
tech-stack:
added: [logto (svhd/logto Docker image)]
patterns: [multi-database Postgres init via docker-entrypoint-initdb.d, OIDC env var convention]
key-files:
created:
- docker-compose.yml
- docker-compose.dev.yml
- docker/init-logto-db.sql
- .env.example
modified:
- src/db/schema.ts
key-decisions:
- "Logto shares Postgres instance via separate database created by init script"
- "OIDC_ISSUER derived from LOGTO_ENDPOINT in docker-compose, not separately configured"
patterns-established:
- "Docker init scripts in docker/ directory mounted to docker-entrypoint-initdb.d"
- "OIDC environment variables: LOGTO_ENDPOINT, LOGTO_CLIENT_ID, LOGTO_CLIENT_SECRET, OIDC_AUTH_SECRET"
requirements-completed: [AUTH-04]
# Metrics
duration: 3min
completed: 2026-04-04
---
# Phase 15 Plan 01: Logto Docker Infrastructure and Schema Cleanup Summary
**Logto OIDC provider added to Docker Compose with Postgres init script, users/sessions tables removed from schema**
## Performance
- **Duration:** 3 min
- **Started:** 2026-04-04T18:35:52Z
- **Completed:** 2026-04-04T18:38:52Z
- **Tasks:** 2
- **Files modified:** 6
## Accomplishments
- Added Logto as a Docker Compose service in both production and dev configurations with proper health-check dependency on Postgres
- Created Postgres init script that automatically creates the logto database on first boot
- Removed users and sessions tables from GearBox schema, generated Drizzle migration to drop them
- Documented all required OIDC environment variables in .env.example
## Task Commits
Each task was committed atomically:
1. **Task 1: Add Logto service to Docker Compose and create init script** - `625862f` (feat)
2. **Task 2: Remove users and sessions tables from schema** - `0fe231f` (feat)
## Files Created/Modified
- `docker-compose.yml` - Production compose with Postgres, Logto, and app services
- `docker-compose.dev.yml` - Dev compose with Postgres and Logto for local auth testing
- `docker/init-logto-db.sql` - SQL script creating separate logto database on Postgres
- `.env.example` - Documents all required environment variables for OIDC configuration
- `src/db/schema.ts` - Removed users and sessions table definitions
- `drizzle/0010_foamy_marvel_zombies.sql` - Migration to drop users and sessions tables
## Decisions Made
- Logto shares the same Postgres instance but uses a separate database (created by init script), rather than a dedicated Postgres container
- OIDC_ISSUER is derived from LOGTO_ENDPOINT in docker-compose.yml rather than being a separate top-level env var, reducing configuration duplication
- Dev compose uses hardcoded password for Logto DB connection (matching existing dev Postgres pattern)
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required. Logto admin console setup (creating OIDC application, obtaining client ID/secret) will be needed before plan 15-02, but is handled as part of the Logto first-boot experience at http://localhost:3002.
## Next Phase Readiness
- Logto infrastructure is ready for plan 15-02 (server-side OIDC integration)
- Schema is cleaned of old auth tables, ready for OIDC-based authentication
- API keys table preserved for continued programmatic access
---
*Phase: 15-external-authentication*
*Completed: 2026-04-04*

View File

@@ -9,11 +9,27 @@ services:
- "5432:5432"
volumes:
- pgdata-dev:/var/lib/postgresql/data
- ./docker/init-logto-db.sql:/docker-entrypoint-initdb.d/init-logto-db.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gearbox"]
interval: 5s
timeout: 3s
retries: 5
logto:
image: svhd/logto:latest
depends_on:
postgres:
condition: service_healthy
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
ports:
- "3001:3001"
- "3002:3002"
environment:
TRUST_PROXY_HEADER: "1"
DB_URL: postgres://gearbox:gearbox@postgres:5432/logto
ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
volumes:
pgdata-dev:

View File

@@ -7,22 +7,44 @@ services:
POSTGRES_DB: gearbox
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/init-logto-db.sql:/docker-entrypoint-initdb.d/init-logto-db.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gearbox"]
interval: 10s
timeout: 5s
retries: 5
logto:
image: svhd/logto:latest
depends_on:
postgres:
condition: service_healthy
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
ports:
- "3001:3001"
- "3002:3002"
environment:
TRUST_PROXY_HEADER: "1"
DB_URL: postgres://gearbox:${POSTGRES_PASSWORD}@postgres:5432/logto
ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
app:
image: gearbox:latest
environment:
DATABASE_URL: postgresql://gearbox:${POSTGRES_PASSWORD}@postgres:5432/gearbox
GEARBOX_URL: ${GEARBOX_URL}
OIDC_ISSUER: ${LOGTO_ENDPOINT:-http://localhost:3001}/oidc
OIDC_CLIENT_ID: ${LOGTO_CLIENT_ID}
OIDC_CLIENT_SECRET: ${LOGTO_CLIENT_SECRET}
OIDC_AUTH_SECRET: ${OIDC_AUTH_SECRET}
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
logto:
condition: service_started
volumes:
- uploads:/app/uploads

2
docker/init-logto-db.sql Normal file
View File

@@ -0,0 +1,2 @@
-- Creates a separate database for Logto on the shared Postgres instance
CREATE DATABASE logto;

View File

@@ -0,0 +1,2 @@
DROP TABLE `sessions`;--> statement-breakpoint
DROP TABLE `users`;

View File

@@ -71,6 +71,13 @@
"when": 1775287060443,
"tag": "0009_happy_mockingbird",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1775327900426,
"tag": "0010_foamy_marvel_zombies",
"breakpoints": true
}
]
}

View File

@@ -1,24 +1,18 @@
import {
boolean,
doublePrecision,
integer,
pgTable,
serial,
text,
timestamp,
} from "drizzle-orm/pg-core";
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const categories = pgTable("categories", {
id: serial("id").primaryKey(),
export const categories = sqliteTable("categories", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(),
icon: text("icon").notNull().default("package"),
createdAt: timestamp("created_at").notNull().defaultNow(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const items = pgTable("items", {
id: serial("id").primaryKey(),
export const items = sqliteTable("items", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
weightGrams: doublePrecision("weight_grams"),
weightGrams: real("weight_grams"),
priceCents: integer("price_cents"),
categoryId: integer("category_id")
.notNull()
@@ -28,29 +22,37 @@ export const items = pgTable("items", {
imageFilename: text("image_filename"),
imageSourceUrl: text("image_source_url"),
quantity: integer("quantity").notNull().default(1),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const threads = pgTable("threads", {
id: serial("id").primaryKey(),
export const threads = sqliteTable("threads", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
status: text("status").notNull().default("active"),
resolvedCandidateId: integer("resolved_candidate_id"),
categoryId: integer("category_id")
.notNull()
.references(() => categories.id),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const threadCandidates = pgTable("thread_candidates", {
id: serial("id").primaryKey(),
export const threadCandidates = sqliteTable("thread_candidates", {
id: integer("id").primaryKey({ autoIncrement: true }),
threadId: integer("thread_id")
.notNull()
.references(() => threads.id, { onDelete: "cascade" }),
name: text("name").notNull(),
weightGrams: doublePrecision("weight_grams"),
weightGrams: real("weight_grams"),
priceCents: integer("price_cents"),
categoryId: integer("category_id")
.notNull()
@@ -62,20 +64,28 @@ export const threadCandidates = pgTable("thread_candidates", {
status: text("status").notNull().default("researching"),
pros: text("pros"),
cons: text("cons"),
sortOrder: doublePrecision("sort_order").notNull().default(0),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
sortOrder: real("sort_order").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const setups = pgTable("setups", {
id: serial("id").primaryKey(),
export const setups = sqliteTable("setups", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const setupItems = pgTable("setup_items", {
id: serial("id").primaryKey(),
export const setupItems = sqliteTable("setup_items", {
id: integer("id").primaryKey({ autoIncrement: true }),
setupId: integer("setup_id")
.notNull()
.references(() => setups.id, { onDelete: "cascade" }),
@@ -85,59 +95,52 @@ export const setupItems = pgTable("setup_items", {
classification: text("classification").notNull().default("base"),
});
export const settings = pgTable("settings", {
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
});
export const users = pgTable("users", {
id: serial("id").primaryKey(),
username: text("username").notNull().unique(),
passwordHash: text("password_hash").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expiresAt: timestamp("expires_at").notNull(),
});
export const apiKeys = pgTable("api_keys", {
id: serial("id").primaryKey(),
export const apiKeys = sqliteTable("api_keys", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
keyHash: text("key_hash").notNull(),
keyPrefix: text("key_prefix").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const oauthClients = pgTable("oauth_clients", {
id: serial("id").primaryKey(),
export const oauthClients = sqliteTable("oauth_clients", {
id: integer("id").primaryKey({ autoIncrement: true }),
clientId: text("client_id").notNull().unique(),
clientName: text("client_name"),
redirectUris: text("redirect_uris").notNull(), // JSON array
createdAt: timestamp("created_at").notNull().defaultNow(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const oauthCodes = pgTable("oauth_codes", {
id: serial("id").primaryKey(),
export const oauthCodes = sqliteTable("oauth_codes", {
id: integer("id").primaryKey({ autoIncrement: true }),
code: text("code").notNull().unique(),
clientId: text("client_id").notNull(),
codeChallenge: text("code_challenge").notNull(),
codeChallengeMethod: text("code_challenge_method").notNull().default("S256"),
redirectUri: text("redirect_uri").notNull(),
expiresAt: timestamp("expires_at").notNull(),
used: boolean("used").notNull().default(false),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
used: integer("used").notNull().default(0),
});
export const oauthTokens = pgTable("oauth_tokens", {
id: serial("id").primaryKey(),
export const oauthTokens = sqliteTable("oauth_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
accessTokenHash: text("access_token_hash").notNull().unique(),
refreshTokenHash: text("refresh_token_hash").notNull().unique(),
clientId: text("client_id").notNull(),
expiresAt: timestamp("expires_at").notNull(), // access token expiry
refreshExpiresAt: timestamp("refresh_expires_at").notNull(), // refresh token expiry
createdAt: timestamp("created_at").notNull().defaultNow(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), // access token expiry
refreshExpiresAt: integer("refresh_expires_at", {
mode: "timestamp",
}).notNull(), // refresh token expiry
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});