@bevel-software/platform-core-backend 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +13 -0
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts.map +1 -1
  5. package/dist/core/create-core-services.js +23 -2
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/core-config.d.ts +36 -1
  8. package/dist/core-config.d.ts.map +1 -1
  9. package/dist/core-config.js +56 -2
  10. package/dist/core-config.js.map +1 -1
  11. package/dist/modules/admin/admin-access.service.d.ts +17 -1
  12. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  13. package/dist/modules/admin/admin-access.service.js +14 -1
  14. package/dist/modules/admin/admin-access.service.js.map +1 -1
  15. package/dist/modules/auth/account.routes.d.ts +17 -0
  16. package/dist/modules/auth/account.routes.d.ts.map +1 -0
  17. package/dist/modules/auth/account.routes.js +47 -0
  18. package/dist/modules/auth/account.routes.js.map +1 -0
  19. package/dist/modules/auth/auth.routes.d.ts +13 -7
  20. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  21. package/dist/modules/auth/auth.routes.js +51 -3
  22. package/dist/modules/auth/auth.routes.js.map +1 -1
  23. package/dist/modules/auth/auth.service.d.ts +44 -8
  24. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  25. package/dist/modules/auth/auth.service.js +129 -32
  26. package/dist/modules/auth/auth.service.js.map +1 -1
  27. package/dist/modules/auth/oidc-auth-provider.d.ts +51 -0
  28. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -0
  29. package/dist/modules/auth/oidc-auth-provider.js +189 -0
  30. package/dist/modules/auth/oidc-auth-provider.js.map +1 -0
  31. package/dist/modules/auth/password-hash.d.ts +12 -0
  32. package/dist/modules/auth/password-hash.d.ts.map +1 -0
  33. package/dist/modules/auth/password-hash.js +67 -0
  34. package/dist/modules/auth/password-hash.js.map +1 -0
  35. package/dist/modules/auth/rate-limit.d.ts +32 -0
  36. package/dist/modules/auth/rate-limit.d.ts.map +1 -0
  37. package/dist/modules/auth/rate-limit.js +49 -0
  38. package/dist/modules/auth/rate-limit.js.map +1 -0
  39. package/dist/modules/database/core-schema.d.ts +17 -0
  40. package/dist/modules/database/core-schema.d.ts.map +1 -1
  41. package/dist/modules/database/core-schema.js +6 -0
  42. package/dist/modules/database/core-schema.js.map +1 -1
  43. package/migrations/0001_user_password_hash.sql +4 -0
  44. package/migrations/meta/_journal.json +20 -13
  45. package/package.json +2 -2
  46. package/src/core/create-core-server.ts +352 -338
  47. package/src/core/create-core-services.ts +542 -514
  48. package/src/core-config.ts +55 -2
  49. package/src/modules/admin/admin-access.service.ts +10 -0
  50. package/src/modules/auth/__tests__/account.routes.test.ts +98 -0
  51. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -0
  52. package/src/modules/auth/__tests__/auth.service.test.ts +217 -0
  53. package/src/modules/auth/__tests__/oidc-auth-provider.test.ts +174 -0
  54. package/src/modules/auth/__tests__/password-hash.test.ts +53 -0
  55. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -0
  56. package/src/modules/auth/account.routes.ts +59 -0
  57. package/src/modules/auth/auth.routes.ts +68 -10
  58. package/src/modules/auth/auth.service.ts +151 -32
  59. package/src/modules/auth/oidc-auth-provider.ts +229 -0
  60. package/src/modules/auth/password-hash.ts +80 -0
  61. package/src/modules/auth/rate-limit.ts +45 -0
  62. package/src/modules/database/core-schema.ts +437 -431
@@ -1,431 +1,437 @@
1
- /**
2
- * CORE platform tables — the git-backed workspace/workflow, auth, access,
3
- * change requests, locks, the pending-commits queue, the ontology-session
4
- * touched-set, connection keys, MCP OAuth, and the Secrets Vault. A core-only
5
- * deployment migrates and runs on exactly these tables.
6
- *
7
- * Enterprise-only tables (chat, routines, watchlist, connectors, LLM config,
8
- * SharePoint/Atlassian links, feedback, upload, kb-revalidation) live in
9
- * `enterprise-schema.ts`, which imports the FK targets (`users`,
10
- * `externalApiKeys`) from here. `schema.ts` re-exports both, so existing
11
- * imports keep working unchanged.
12
- */
13
- import { sql } from 'drizzle-orm';
14
- import { boolean, check, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
15
-
16
- export const users = pgTable('users', {
17
- id: uuid('id').defaultRandom().primaryKey(),
18
- email: text('email').notNull().unique(),
19
- name: text('name').notNull(),
20
- avatarUrl: text('avatar_url'),
21
- createdAt: timestamp('created_at').defaultNow().notNull(),
22
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
23
- });
24
-
25
- /**
26
- * Per-file owner approvals on a PR. A PR is mergeable-in-Bevel when every
27
- * touched path has a non-stale approval from its owner. Force-pushes invalidate
28
- * approvals automatically because `head_sha` is part of the uniqueness key —
29
- * an approval row for a superseded SHA stays in the DB (audit) but doesn't
30
- * count toward the merge gate.
31
- *
32
- * Self-approval (approver == PR author) is allowed and flagged downstream in
33
- * the assembled `FileApprovalState` — enforcement of "PR-author-can't-self-
34
- * approve" belongs in the service layer, not the DB.
35
- */
36
- export const prFileApprovals = pgTable('pr_file_approvals', {
37
- id: uuid('id').defaultRandom().primaryKey(),
38
- prNumber: integer('pr_number').notNull(),
39
- path: text('path').notNull(),
40
- approverEmail: text('approver_email').notNull(), // lowercased at insert
41
- approverName: text('approver_name').notNull(),
42
- headSha: text('head_sha').notNull(),
43
- approvedAt: timestamp('approved_at').defaultNow().notNull(),
44
- }, (t) => ({
45
- // Idempotency: approving the same path on the same SHA twice must be a no-op,
46
- // not a duplicate row. Unique on the (PR, path, approver, headSha) tuple.
47
- unq: uniqueIndex('pr_file_approvals_unq')
48
- .on(t.prNumber, t.path, t.approverEmail, t.headSha),
49
- byPr: index('pr_file_approvals_by_pr').on(t.prNumber),
50
- }));
51
-
52
- /**
53
- * Audit log for change-request merges the app executed. Captures the real Bevel
54
- * user who clicked Merge, the SHA we merged, and whether the local merge+push
55
- * succeeded. `pr_number` holds the change_requests number. One row per attempt —
56
- * both success and failure paths insert so crash-loop scenarios are visible
57
- * after the fact.
58
- */
59
- export const prMergeLog = pgTable('pr_merge_log', {
60
- id: uuid('id').defaultRandom().primaryKey(),
61
- prNumber: integer('pr_number').notNull(),
62
- triggeredByEmail: text('triggered_by_email').notNull(),
63
- triggeredByName: text('triggered_by_name').notNull(),
64
- headShaAtMerge: text('head_sha_at_merge').notNull(),
65
- mergeMethod: text('merge_method').notNull(),
66
- succeeded: boolean('succeeded').notNull(),
67
- error: text('error'),
68
- startedAt: timestamp('started_at').defaultNow().notNull(),
69
- completedAt: timestamp('completed_at'),
70
- }, (t) => ({
71
- byPr: index('pr_merge_log_by_pr').on(t.prNumber),
72
- }));
73
-
74
- /**
75
- * In-app PR comments. GitHub is authoritative for the diff + PR metadata;
76
- * Bevel owns the review conversation. Comments never round-trip to GitHub.
77
- *
78
- * Shape encodes three comment kinds:
79
- * • General PR comment — path null, line null
80
- * • File-level comment — path set, line null
81
- * • Inline (line-anchored) — path set, line set
82
- *
83
- * Threading: replies point at their root via `parentId`. Root comments have
84
- * `parentId = null`. We use a self-FK-less column (no DB-side constraint) to
85
- * keep the parent row independently deletable — the service layer tolerates
86
- * orphan replies rather than cascading deletes and losing discussion context.
87
- */
88
- export const prComments = pgTable('pr_comments', {
89
- id: uuid('id').defaultRandom().primaryKey(),
90
- prNumber: integer('pr_number').notNull(),
91
- authorEmail: text('author_email').notNull(),
92
- authorName: text('author_name').notNull(),
93
- path: text('path'),
94
- line: integer('line'),
95
- headSha: text('head_sha').notNull(),
96
- body: text('body').notNull(),
97
- parentId: uuid('parent_id'),
98
- createdAt: timestamp('created_at').defaultNow().notNull(),
99
- updatedAt: timestamp('updated_at'),
100
- }, (t) => ({
101
- byPr: index('pr_comments_by_pr').on(t.prNumber),
102
- byThread: index('pr_comments_by_thread').on(t.prNumber, t.path, t.line),
103
- }));
104
-
105
- /**
106
- * Change requests (the app's own "pull request" model). The app used to lean on
107
- * GitHub PRs via the `gh` CLI; now a change request is a plain DB row plus git
108
- * branches, so ANY git host (GitHub, GitLab, Bitbucket, Azure DevOps, self-
109
- * hosted) works — the remote only stores commits/branches.
110
- *
111
- * `number` is the human-facing id and the join key the sibling tables
112
- * (`pr_file_approvals`, `pr_comments`, `pr_merge_log`) reference in their
113
- * `pr_number` column — those columns now hold THIS number, not a GitHub PR
114
- * number. It's a DB-assigned identity so allocation is race-free.
115
- *
116
- * The diff, head/base SHAs, and mergeability are computed live from git (not
117
- * stored) so a force-push or a new commit is always reflected; only durable
118
- * facts live here — the pairing, title/body, author, lifecycle state.
119
- */
120
- export const changeRequests = pgTable('change_requests', {
121
- id: uuid('id').defaultRandom().primaryKey(),
122
- number: integer('number').generatedAlwaysAsIdentity(),
123
- sourceBranch: text('source_branch').notNull(),
124
- targetBranch: text('target_branch').notNull(),
125
- title: text('title').notNull(),
126
- body: text('body').notNull().default(''),
127
- authorEmail: text('author_email').notNull(), // lowercased at insert
128
- authorName: text('author_name').notNull(),
129
- state: text('state').notNull().default('open'), // 'open' | 'merged' | 'closed'
130
- mergedSha: text('merged_sha'),
131
- createdAt: timestamp('created_at').defaultNow().notNull(),
132
- updatedAt: timestamp('updated_at'),
133
- closedAt: timestamp('closed_at'),
134
- }, (t) => ({
135
- numberUnq: uniqueIndex('change_requests_number_unq').on(t.number),
136
- // Enforce the "A→B blocks A→B while open" uniqueness rule at the DB level —
137
- // a partial unique index on open rows only. B→A in parallel is still allowed
138
- // because the pair differs. Replaces the previous race-prone list scan.
139
- openPairUnq: uniqueIndex('change_requests_open_pair_unq')
140
- .on(t.sourceBranch, t.targetBranch)
141
- .where(sql`${t.state} = 'open'`),
142
- byState: index('change_requests_by_state').on(t.state),
143
- stateCheck: check(
144
- 'change_requests_state_check',
145
- sql`${t.state} IN ('open', 'merged', 'closed')`,
146
- ),
147
- }));
148
-
149
- /**
150
- * Long-lived API tokens that authenticate remote agents (Claude Code and
151
- * similar MCP clients) as a specific Bevel user. Plaintext token is shown
152
- * once at mint time; we store only the SHA-256 hash. Verification is a hash
153
- * lookup, so the unique index on `token_hash` is what makes lookup O(1)
154
- * rather than a table scan.
155
- *
156
- * Tokens are revoked, not deleted — keeping the row preserves the audit
157
- * trail for `last_used_at` and lets an operator see when a leaked key was
158
- * actually used. A revoked row is rejected on verify because the service
159
- * filters on `revoked_at IS NULL`; the unique index still applies, so a
160
- * revoked token cannot be re-issued.
161
- */
162
- export const externalApiKeys = pgTable('api_tokens', {
163
- id: uuid('id').defaultRandom().primaryKey(),
164
- userId: uuid('user_id').notNull().references(() => users.id),
165
- tokenHash: text('token_hash').notNull().unique(),
166
- label: text('label').notNull(),
167
- createdAt: timestamp('created_at').defaultNow().notNull(),
168
- lastUsedAt: timestamp('last_used_at'),
169
- revokedAt: timestamp('revoked_at'),
170
- }, (t) => ({
171
- byUser: index('api_tokens_by_user').on(t.userId),
172
- }));
173
-
174
- /**
175
- * Per-(workspace, branch, path) edit locks. While the lock is held by a user,
176
- * other users see the file as locked and can't edit it. The client pings
177
- * `last_heartbeat_at` periodically; when `expires_at` passes without a
178
- * heartbeat the lock is considered stale and may be reclaimed by another
179
- * caller — prevents a disconnected client from holding a file hostage.
180
- *
181
- * Composite primary key on (workspace, branch, path) — one lock per file
182
- * per branch. The same physical path on another branch is independently
183
- * lockable; the spec calls this out explicitly.
184
- *
185
- * Holder identity carries both the user id (FK into `users` for referential
186
- * integrity) and a denormalised display name so consumers can render
187
- * "Locked by Alice" without a JOIN on the hot read path.
188
- */
189
- export const fileLocks = pgTable('file_locks', {
190
- workspaceId: text('workspace_id').notNull(),
191
- branch: text('branch').notNull(),
192
- path: text('path').notNull(),
193
- holderUserId: uuid('holder_user_id').notNull().references(() => users.id),
194
- holderName: text('holder_name').notNull(),
195
- acquiredAt: timestamp('acquired_at').defaultNow().notNull(),
196
- lastHeartbeatAt: timestamp('last_heartbeat_at').defaultNow().notNull(),
197
- expiresAt: timestamp('expires_at').notNull(),
198
- }, (t) => ({
199
- pk: primaryKey({ columns: [t.workspaceId, t.branch, t.path] }),
200
- // Sweep stale locks via `expires_at < now()` — an index avoids a full
201
- // table scan once the lock count grows.
202
- byExpiry: index('file_locks_by_expiry').on(t.expiresAt),
203
- byHolder: index('file_locks_by_holder').on(t.holderUserId),
204
- }));
205
-
206
- /**
207
- * Background commit queue. Every successful lock release enqueues a row
208
- * here; the queue worker drains it by running `commitFile` + `pushBranch`
209
- * (with the existing `pushWithRecovery` cooperative pull-rebase).
210
- *
211
- * Why a table and not an in-memory queue: the prior in-line lock-release
212
- * commit path stranded files on disk whenever the process died (or git
213
- * blipped) between disk-write and commit. A persistent queue means a
214
- * process crash leaves the work resumable — the next worker pass picks
215
- * it up. Schema choices follow from that:
216
- *
217
- * - `id` UUID PK so the worker can `UPDATE ... WHERE id = ...` atomically
218
- * without locking the queue. Read order is `(workspace_id, queued_at)`
219
- * so per-workspace work stays FIFO.
220
- * - `(workspace_id, branch, path)` is NOT unique. Two consecutive saves
221
- * of the same file produce two rows; the worker collapses them by
222
- * committing once and deleting both (commitFile is a no-op on a
223
- * clean path, so the duplicate becomes a free deletion).
224
- * - Author identity is denormalised (email + name) at enqueue time, like
225
- * `file_locks.holder_name`. We don't FK into `users` because the
226
- * startup sweep enqueues orphans with synthetic `system-recovery`
227
- * attribution and there's no row for that user.
228
- * - `attempts` + `last_error` drive the worker's transient-retry budget
229
- * (1s/5s/30s backoff before handing off to the recovery agent) and
230
- * the agent's own retry ceiling (3 runs before `status = needs_attention`).
231
- * - `status` is a small enum (`pending` | `running` | `needs_attention`).
232
- * Workers claim a row by flipping `pending → running` in a single
233
- * UPDATE; terminal failure flips to `needs_attention` and the row
234
- * stays for admin / feedback review.
235
- * - `last_attempted_at` gates backoff scheduling so the worker doesn't
236
- * re-pop a row that just failed.
237
- */
238
- export const pendingCommits = pgTable('pending_commits', {
239
- id: uuid('id').defaultRandom().primaryKey(),
240
- workspaceId: text('workspace_id').notNull(),
241
- branch: text('branch').notNull(),
242
- path: text('path').notNull(),
243
- // Author attribution captured at enqueue. The denormalised name/email pair
244
- // lands on the eventual `git commit --author=` so the commit history shows
245
- // the human who triggered the save, not the worker.
246
- authorEmail: text('author_email').notNull(), // lowercased at insert
247
- authorName: text('author_name').notNull(),
248
- queuedAt: timestamp('queued_at').defaultNow().notNull(),
249
- // `running` is set while the worker is mid-commit so a second worker
250
- // (e.g. after a restart with another instance still draining) doesn't
251
- // double-process the row. Set back to `pending` on transient failure;
252
- // set to `needs_attention` after the recovery-agent retry ceiling.
253
- status: text('status').notNull().default('pending'),
254
- attempts: integer('attempts').notNull().default(0),
255
- // Tracks how many full recovery-agent runs have fired for this row.
256
- // Bounded so a genuinely-broken commit eventually escalates to
257
- // `needs_attention` + feedback notice instead of looping forever.
258
- recoveryAgentRuns: integer('recovery_agent_runs').notNull().default(0),
259
- lastAttemptedAt: timestamp('last_attempted_at'),
260
- lastError: text('last_error'),
261
- }, (t) => ({
262
- // Primary read pattern: drain in queued order, scoped to a workspace.
263
- byWorkspaceQueued: index('pending_commits_by_workspace_queued').on(t.workspaceId, t.queuedAt),
264
- // Sweep: find rows ready to retry (status='pending' AND last_attempted_at < now() - backoff).
265
- byStatus: index('pending_commits_by_status').on(t.status, t.lastAttemptedAt),
266
- // Admin surface: enumerate everything stuck in needs_attention.
267
- byStatusOnly: index('pending_commits_by_status_only').on(t.status),
268
- statusCheck: check(
269
- 'pending_commits_status',
270
- sql`${t.status} IN ('pending', 'running', 'needs_attention')`,
271
- ),
272
- }));
273
-
274
- /**
275
- * Secrets Vault — the per-user store of credentials that back UTCP tool
276
- * variables (`${FOO_API_KEY}`). Distinct from `vault_sources` (which holds
277
- * connector+URL POINTERS and no credentials): this table holds the actual
278
- * secret values, encrypted at rest.
279
- *
280
- * Two kinds:
281
- * - `static` — a value the user pasted (an API key). `value_encrypted` is the
282
- * AES-256-GCM ciphertext of the raw value.
283
- * - `oauth` — an authorization-code credential. `value_encrypted` is the
284
- * ciphertext of a JSON blob `{ clientSecret?, tokens: { access_token,
285
- * refresh_token?, expires_at?, token_type? } }`; the non-secret
286
- * provider config (auth/token URLs, client id, scopes) lives in
287
- * `oauth_meta`. `resolve()` refreshes the access token on demand.
288
- *
289
- * `key` is the UTCP variable name a `.tool` manual references (`<manual>_<VAR>`).
290
- * Two provisioning tiers share this table:
291
- * - `user_id` set — a PER-USER secret (private to that user, cascade-deleted
292
- * with them), unique per `(user_id, key)`.
293
- * - `user_id NULL` — a SHARED/admin secret, set by a writer of the `.tool` file
294
- * and read by every invoker of that tool. Unique per `key`
295
- * via the partial index below (a plain `(user_id, key)` index
296
- * treats NULLs as distinct, so it can't enforce one shared
297
- * row per key on its own).
298
- * `kind` is validated in the service, not a DB CHECK.
299
- */
300
- export const secrets = pgTable('secrets', {
301
- id: uuid('id').defaultRandom().primaryKey(),
302
- userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
303
- /** The UTCP variable name this secret resolves (e.g. `FOO_API_KEY`). */
304
- key: text('key').notNull(),
305
- /** `static` | `oauth`. */
306
- kind: text('kind').notNull().default('static'),
307
- /** Operator-chosen display name. */
308
- label: text('label'),
309
- /** AES-256-GCM ciphertext (`iv:tag:ct`) — the value (static) or token JSON (oauth). */
310
- valueEncrypted: text('value_encrypted').notNull(),
311
- /** Non-secret OAuth provider config for `oauth` kind; null for `static`. */
312
- oauthMeta: jsonb('oauth_meta').$type<Record<string, unknown>>(),
313
- createdAt: timestamp('created_at').defaultNow().notNull(),
314
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
315
- }, (t) => ({
316
- byUser: index('secrets_by_user').on(t.userId),
317
- userKeyUnq: uniqueIndex('secrets_user_key_unq').on(t.userId, t.key),
318
- // One shared (admin) row per key: enforce uniqueness only across NULL-user rows.
319
- sharedKeyUnq: uniqueIndex('secrets_shared_key_unq').on(t.key).where(sql`${t.userId} is null`),
320
- }));
321
-
322
- /**
323
- * Ontology-session boundary — the per-session log of which named ontologies a
324
- * run has TOUCHED (one row per `(session_id, ontology)`). Each agent operation
325
- * that resolves to a named ontology records a row here (idempotent on the PK).
326
- *
327
- * The rule the gate enforces from this set:
328
- * - READS are always allowed; they just record the ontology they touched.
329
- * - WRITES are allowed only while the session's touched set is a SINGLE named
330
- * ontology equal to the write target. Once a session has touched two or
331
- * more ontologies (e.g. by reading across them), every write is blocked —
332
- * "read across ontologies → you can't write anymore at all."
333
- *
334
- * Postgres is the durable source of truth so the boundary holds across a
335
- * backend restart (an in-memory set would reset and silently un-poison a run).
336
- * A write-through in-process cache fronts this table for the chatty read path;
337
- * a cache miss falls back here.
338
- *
339
- * - PK is `(session_id, ontology)` — a set, idempotent on repeat touches.
340
- * - `ontology` is the resolved ontology id (e.g. `KnowledgeBase/Product`).
341
- * Neutral touches don't record, so they never write a row.
342
- * - `touched_at` drives the abandoned-run `sweepOlderThan` backstop; the
343
- * primary reclamation is an explicit delete of all of a session's rows at
344
- * run end.
345
- */
346
- /**
347
- * OAuth clients dynamically registered by MCP clients (RFC 7591 DCR). When an
348
- * MCP client (claude.ai, Claude Code) connects to `/api/mcp` without a
349
- * connection key, it registers itself here via `POST /register`, then runs the
350
- * PKCE authorization-code flow against our authorize/token endpoints.
351
- *
352
- * `client_secret_encrypted` is AES-256-GCM ciphertext (`iv:tag:ct`), NOT a
353
- * hash: the MCP SDK's token-endpoint client-auth middleware plaintext-compares
354
- * `client.client_secret`, so `getClient` must return the original value. In
355
- * practice MCP clients register as PUBLIC clients (`token_endpoint_auth_method
356
- * 'none'`, PKCE only) and the column stays NULL. `metadata` snapshots the full
357
- * registration object so `getClient` can rebuild it without lossy re-mapping.
358
- */
359
- export const oauthClients = pgTable('oauth_clients', {
360
- clientId: text('client_id').primaryKey(),
361
- clientSecretEncrypted: text('client_secret_encrypted'),
362
- clientSecretExpiresAt: timestamp('client_secret_expires_at'),
363
- redirectUris: jsonb('redirect_uris').notNull().$type<string[]>().default(sql`'[]'::jsonb`),
364
- tokenEndpointAuthMethod: text('token_endpoint_auth_method').notNull().default('none'),
365
- clientName: text('client_name'),
366
- metadata: jsonb('metadata').notNull().$type<Record<string, unknown>>().default(sql`'{}'::jsonb`),
367
- createdAt: timestamp('created_at').defaultNow().notNull(),
368
- });
369
-
370
- /**
371
- * One-time OAuth authorization codes, issued when a user clicks Finish on the
372
- * `/connect` consent page and exchanged at `/token` within seconds. Stored as
373
- * a SHA-256 hash like every other bearer secret here. The row binds the code
374
- * to (user, client, redirect_uri, PKCE challenge) so the token exchange can
375
- * verify all four; single-use is enforced by the atomic consume
376
- * `UPDATE … SET consumed_at = now() WHERE consumed_at IS NULL` (same pattern
377
- * as `upload_tokens.used_at`).
378
- */
379
- export const oauthAuthCodes = pgTable('oauth_auth_codes', {
380
- id: uuid('id').defaultRandom().primaryKey(),
381
- codeHash: text('code_hash').notNull().unique(),
382
- clientId: text('client_id').notNull().references(() => oauthClients.clientId),
383
- userId: uuid('user_id').notNull().references(() => users.id),
384
- redirectUri: text('redirect_uri').notNull(),
385
- codeChallenge: text('code_challenge').notNull(),
386
- scope: text('scope'),
387
- resource: text('resource'),
388
- expiresAt: timestamp('expires_at').notNull(),
389
- consumedAt: timestamp('consumed_at'),
390
- createdAt: timestamp('created_at').defaultNow().notNull(),
391
- }, (t) => ({
392
- // Sweep expired/spent rows without a full scan.
393
- byExpiry: index('oauth_auth_codes_by_expiry').on(t.expiresAt),
394
- }));
395
-
396
- /**
397
- * OAuth access/refresh token pairs minted by the token endpoint. Like
398
- * `api_tokens`: plaintext shown only in the token response, SHA-256 hashes
399
- * stored, revoked-not-deleted so `last_used_at` keeps its audit value and a
400
- * revoked token can't be re-issued. One row per pair; refresh rotation
401
- * revokes the old row and inserts a new one.
402
- */
403
- export const oauthTokens = pgTable('oauth_tokens', {
404
- id: uuid('id').defaultRandom().primaryKey(),
405
- accessTokenHash: text('access_token_hash').notNull().unique(),
406
- refreshTokenHash: text('refresh_token_hash').unique(),
407
- clientId: text('client_id').notNull().references(() => oauthClients.clientId),
408
- userId: uuid('user_id').notNull().references(() => users.id),
409
- scope: text('scope'),
410
- resource: text('resource'),
411
- expiresAt: timestamp('expires_at').notNull(),
412
- refreshExpiresAt: timestamp('refresh_expires_at'),
413
- revokedAt: timestamp('revoked_at'),
414
- lastUsedAt: timestamp('last_used_at'),
415
- createdAt: timestamp('created_at').defaultNow().notNull(),
416
- }, (t) => ({
417
- byUser: index('oauth_tokens_by_user').on(t.userId),
418
- byExpiry: index('oauth_tokens_by_expiry').on(t.expiresAt),
419
- }));
420
-
421
- export const sessionOntologyTouches = pgTable('session_ontology_touches', {
422
- sessionId: text('session_id').notNull(),
423
- ontology: text('ontology').notNull(),
424
- touchedAt: timestamp('touched_at').defaultNow().notNull(),
425
- }, (t) => ({
426
- // The composite PK leads with `session_id`, so a load-by-session SELECT is
427
- // already served by the PK index — no separate `by_session` index needed.
428
- pk: primaryKey({ columns: [t.sessionId, t.ontology] }),
429
- // Backstop sweep of abandoned runs via `touched_at < cutoff`.
430
- byTouchedAt: index('session_ontology_touches_by_touched_at').on(t.touchedAt),
431
- }));
1
+ /**
2
+ * CORE platform tables — the git-backed workspace/workflow, auth, access,
3
+ * change requests, locks, the pending-commits queue, the ontology-session
4
+ * touched-set, connection keys, MCP OAuth, and the Secrets Vault. A core-only
5
+ * deployment migrates and runs on exactly these tables.
6
+ *
7
+ * Enterprise-only tables (chat, routines, watchlist, connectors, LLM config,
8
+ * SharePoint/Atlassian links, feedback, upload, kb-revalidation) live in
9
+ * `enterprise-schema.ts`, which imports the FK targets (`users`,
10
+ * `externalApiKeys`) from here. `schema.ts` re-exports both, so existing
11
+ * imports keep working unchanged.
12
+ */
13
+ import { sql } from 'drizzle-orm';
14
+ import { boolean, check, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
15
+
16
+ export const users = pgTable('users', {
17
+ id: uuid('id').defaultRandom().primaryKey(),
18
+ email: text('email').notNull().unique(),
19
+ name: text('name').notNull(),
20
+ avatarUrl: text('avatar_url'),
21
+ /**
22
+ * scrypt hash for password login (see auth/password-hash.ts). NULL for
23
+ * accounts that only ever signed in via SSO — password login refuses them
24
+ * until an admin (or the user, from their Account page) sets one.
25
+ */
26
+ passwordHash: text('password_hash'),
27
+ createdAt: timestamp('created_at').defaultNow().notNull(),
28
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
29
+ });
30
+
31
+ /**
32
+ * Per-file owner approvals on a PR. A PR is mergeable-in-Bevel when every
33
+ * touched path has a non-stale approval from its owner. Force-pushes invalidate
34
+ * approvals automatically because `head_sha` is part of the uniqueness key —
35
+ * an approval row for a superseded SHA stays in the DB (audit) but doesn't
36
+ * count toward the merge gate.
37
+ *
38
+ * Self-approval (approver == PR author) is allowed and flagged downstream in
39
+ * the assembled `FileApprovalState` — enforcement of "PR-author-can't-self-
40
+ * approve" belongs in the service layer, not the DB.
41
+ */
42
+ export const prFileApprovals = pgTable('pr_file_approvals', {
43
+ id: uuid('id').defaultRandom().primaryKey(),
44
+ prNumber: integer('pr_number').notNull(),
45
+ path: text('path').notNull(),
46
+ approverEmail: text('approver_email').notNull(), // lowercased at insert
47
+ approverName: text('approver_name').notNull(),
48
+ headSha: text('head_sha').notNull(),
49
+ approvedAt: timestamp('approved_at').defaultNow().notNull(),
50
+ }, (t) => ({
51
+ // Idempotency: approving the same path on the same SHA twice must be a no-op,
52
+ // not a duplicate row. Unique on the (PR, path, approver, headSha) tuple.
53
+ unq: uniqueIndex('pr_file_approvals_unq')
54
+ .on(t.prNumber, t.path, t.approverEmail, t.headSha),
55
+ byPr: index('pr_file_approvals_by_pr').on(t.prNumber),
56
+ }));
57
+
58
+ /**
59
+ * Audit log for change-request merges the app executed. Captures the real Bevel
60
+ * user who clicked Merge, the SHA we merged, and whether the local merge+push
61
+ * succeeded. `pr_number` holds the change_requests number. One row per attempt —
62
+ * both success and failure paths insert so crash-loop scenarios are visible
63
+ * after the fact.
64
+ */
65
+ export const prMergeLog = pgTable('pr_merge_log', {
66
+ id: uuid('id').defaultRandom().primaryKey(),
67
+ prNumber: integer('pr_number').notNull(),
68
+ triggeredByEmail: text('triggered_by_email').notNull(),
69
+ triggeredByName: text('triggered_by_name').notNull(),
70
+ headShaAtMerge: text('head_sha_at_merge').notNull(),
71
+ mergeMethod: text('merge_method').notNull(),
72
+ succeeded: boolean('succeeded').notNull(),
73
+ error: text('error'),
74
+ startedAt: timestamp('started_at').defaultNow().notNull(),
75
+ completedAt: timestamp('completed_at'),
76
+ }, (t) => ({
77
+ byPr: index('pr_merge_log_by_pr').on(t.prNumber),
78
+ }));
79
+
80
+ /**
81
+ * In-app PR comments. GitHub is authoritative for the diff + PR metadata;
82
+ * Bevel owns the review conversation. Comments never round-trip to GitHub.
83
+ *
84
+ * Shape encodes three comment kinds:
85
+ * • General PR comment — path null, line null
86
+ * • File-level comment — path set, line null
87
+ * • Inline (line-anchored) — path set, line set
88
+ *
89
+ * Threading: replies point at their root via `parentId`. Root comments have
90
+ * `parentId = null`. We use a self-FK-less column (no DB-side constraint) to
91
+ * keep the parent row independently deletable — the service layer tolerates
92
+ * orphan replies rather than cascading deletes and losing discussion context.
93
+ */
94
+ export const prComments = pgTable('pr_comments', {
95
+ id: uuid('id').defaultRandom().primaryKey(),
96
+ prNumber: integer('pr_number').notNull(),
97
+ authorEmail: text('author_email').notNull(),
98
+ authorName: text('author_name').notNull(),
99
+ path: text('path'),
100
+ line: integer('line'),
101
+ headSha: text('head_sha').notNull(),
102
+ body: text('body').notNull(),
103
+ parentId: uuid('parent_id'),
104
+ createdAt: timestamp('created_at').defaultNow().notNull(),
105
+ updatedAt: timestamp('updated_at'),
106
+ }, (t) => ({
107
+ byPr: index('pr_comments_by_pr').on(t.prNumber),
108
+ byThread: index('pr_comments_by_thread').on(t.prNumber, t.path, t.line),
109
+ }));
110
+
111
+ /**
112
+ * Change requests (the app's own "pull request" model). The app used to lean on
113
+ * GitHub PRs via the `gh` CLI; now a change request is a plain DB row plus git
114
+ * branches, so ANY git host (GitHub, GitLab, Bitbucket, Azure DevOps, self-
115
+ * hosted) works — the remote only stores commits/branches.
116
+ *
117
+ * `number` is the human-facing id and the join key the sibling tables
118
+ * (`pr_file_approvals`, `pr_comments`, `pr_merge_log`) reference in their
119
+ * `pr_number` column — those columns now hold THIS number, not a GitHub PR
120
+ * number. It's a DB-assigned identity so allocation is race-free.
121
+ *
122
+ * The diff, head/base SHAs, and mergeability are computed live from git (not
123
+ * stored) so a force-push or a new commit is always reflected; only durable
124
+ * facts live here — the pairing, title/body, author, lifecycle state.
125
+ */
126
+ export const changeRequests = pgTable('change_requests', {
127
+ id: uuid('id').defaultRandom().primaryKey(),
128
+ number: integer('number').generatedAlwaysAsIdentity(),
129
+ sourceBranch: text('source_branch').notNull(),
130
+ targetBranch: text('target_branch').notNull(),
131
+ title: text('title').notNull(),
132
+ body: text('body').notNull().default(''),
133
+ authorEmail: text('author_email').notNull(), // lowercased at insert
134
+ authorName: text('author_name').notNull(),
135
+ state: text('state').notNull().default('open'), // 'open' | 'merged' | 'closed'
136
+ mergedSha: text('merged_sha'),
137
+ createdAt: timestamp('created_at').defaultNow().notNull(),
138
+ updatedAt: timestamp('updated_at'),
139
+ closedAt: timestamp('closed_at'),
140
+ }, (t) => ({
141
+ numberUnq: uniqueIndex('change_requests_number_unq').on(t.number),
142
+ // Enforce the "A→B blocks A→B while open" uniqueness rule at the DB level —
143
+ // a partial unique index on open rows only. B→A in parallel is still allowed
144
+ // because the pair differs. Replaces the previous race-prone list scan.
145
+ openPairUnq: uniqueIndex('change_requests_open_pair_unq')
146
+ .on(t.sourceBranch, t.targetBranch)
147
+ .where(sql`${t.state} = 'open'`),
148
+ byState: index('change_requests_by_state').on(t.state),
149
+ stateCheck: check(
150
+ 'change_requests_state_check',
151
+ sql`${t.state} IN ('open', 'merged', 'closed')`,
152
+ ),
153
+ }));
154
+
155
+ /**
156
+ * Long-lived API tokens that authenticate remote agents (Claude Code and
157
+ * similar MCP clients) as a specific Bevel user. Plaintext token is shown
158
+ * once at mint time; we store only the SHA-256 hash. Verification is a hash
159
+ * lookup, so the unique index on `token_hash` is what makes lookup O(1)
160
+ * rather than a table scan.
161
+ *
162
+ * Tokens are revoked, not deleted — keeping the row preserves the audit
163
+ * trail for `last_used_at` and lets an operator see when a leaked key was
164
+ * actually used. A revoked row is rejected on verify because the service
165
+ * filters on `revoked_at IS NULL`; the unique index still applies, so a
166
+ * revoked token cannot be re-issued.
167
+ */
168
+ export const externalApiKeys = pgTable('api_tokens', {
169
+ id: uuid('id').defaultRandom().primaryKey(),
170
+ userId: uuid('user_id').notNull().references(() => users.id),
171
+ tokenHash: text('token_hash').notNull().unique(),
172
+ label: text('label').notNull(),
173
+ createdAt: timestamp('created_at').defaultNow().notNull(),
174
+ lastUsedAt: timestamp('last_used_at'),
175
+ revokedAt: timestamp('revoked_at'),
176
+ }, (t) => ({
177
+ byUser: index('api_tokens_by_user').on(t.userId),
178
+ }));
179
+
180
+ /**
181
+ * Per-(workspace, branch, path) edit locks. While the lock is held by a user,
182
+ * other users see the file as locked and can't edit it. The client pings
183
+ * `last_heartbeat_at` periodically; when `expires_at` passes without a
184
+ * heartbeat the lock is considered stale and may be reclaimed by another
185
+ * caller — prevents a disconnected client from holding a file hostage.
186
+ *
187
+ * Composite primary key on (workspace, branch, path) — one lock per file
188
+ * per branch. The same physical path on another branch is independently
189
+ * lockable; the spec calls this out explicitly.
190
+ *
191
+ * Holder identity carries both the user id (FK into `users` for referential
192
+ * integrity) and a denormalised display name so consumers can render
193
+ * "Locked by Alice" without a JOIN on the hot read path.
194
+ */
195
+ export const fileLocks = pgTable('file_locks', {
196
+ workspaceId: text('workspace_id').notNull(),
197
+ branch: text('branch').notNull(),
198
+ path: text('path').notNull(),
199
+ holderUserId: uuid('holder_user_id').notNull().references(() => users.id),
200
+ holderName: text('holder_name').notNull(),
201
+ acquiredAt: timestamp('acquired_at').defaultNow().notNull(),
202
+ lastHeartbeatAt: timestamp('last_heartbeat_at').defaultNow().notNull(),
203
+ expiresAt: timestamp('expires_at').notNull(),
204
+ }, (t) => ({
205
+ pk: primaryKey({ columns: [t.workspaceId, t.branch, t.path] }),
206
+ // Sweep stale locks via `expires_at < now()` — an index avoids a full
207
+ // table scan once the lock count grows.
208
+ byExpiry: index('file_locks_by_expiry').on(t.expiresAt),
209
+ byHolder: index('file_locks_by_holder').on(t.holderUserId),
210
+ }));
211
+
212
+ /**
213
+ * Background commit queue. Every successful lock release enqueues a row
214
+ * here; the queue worker drains it by running `commitFile` + `pushBranch`
215
+ * (with the existing `pushWithRecovery` cooperative pull-rebase).
216
+ *
217
+ * Why a table and not an in-memory queue: the prior in-line lock-release
218
+ * commit path stranded files on disk whenever the process died (or git
219
+ * blipped) between disk-write and commit. A persistent queue means a
220
+ * process crash leaves the work resumable — the next worker pass picks
221
+ * it up. Schema choices follow from that:
222
+ *
223
+ * - `id` UUID PK so the worker can `UPDATE ... WHERE id = ...` atomically
224
+ * without locking the queue. Read order is `(workspace_id, queued_at)`
225
+ * so per-workspace work stays FIFO.
226
+ * - `(workspace_id, branch, path)` is NOT unique. Two consecutive saves
227
+ * of the same file produce two rows; the worker collapses them by
228
+ * committing once and deleting both (commitFile is a no-op on a
229
+ * clean path, so the duplicate becomes a free deletion).
230
+ * - Author identity is denormalised (email + name) at enqueue time, like
231
+ * `file_locks.holder_name`. We don't FK into `users` because the
232
+ * startup sweep enqueues orphans with synthetic `system-recovery`
233
+ * attribution and there's no row for that user.
234
+ * - `attempts` + `last_error` drive the worker's transient-retry budget
235
+ * (1s/5s/30s backoff before handing off to the recovery agent) and
236
+ * the agent's own retry ceiling (3 runs before `status = needs_attention`).
237
+ * - `status` is a small enum (`pending` | `running` | `needs_attention`).
238
+ * Workers claim a row by flipping `pending → running` in a single
239
+ * UPDATE; terminal failure flips to `needs_attention` and the row
240
+ * stays for admin / feedback review.
241
+ * - `last_attempted_at` gates backoff scheduling so the worker doesn't
242
+ * re-pop a row that just failed.
243
+ */
244
+ export const pendingCommits = pgTable('pending_commits', {
245
+ id: uuid('id').defaultRandom().primaryKey(),
246
+ workspaceId: text('workspace_id').notNull(),
247
+ branch: text('branch').notNull(),
248
+ path: text('path').notNull(),
249
+ // Author attribution captured at enqueue. The denormalised name/email pair
250
+ // lands on the eventual `git commit --author=` so the commit history shows
251
+ // the human who triggered the save, not the worker.
252
+ authorEmail: text('author_email').notNull(), // lowercased at insert
253
+ authorName: text('author_name').notNull(),
254
+ queuedAt: timestamp('queued_at').defaultNow().notNull(),
255
+ // `running` is set while the worker is mid-commit so a second worker
256
+ // (e.g. after a restart with another instance still draining) doesn't
257
+ // double-process the row. Set back to `pending` on transient failure;
258
+ // set to `needs_attention` after the recovery-agent retry ceiling.
259
+ status: text('status').notNull().default('pending'),
260
+ attempts: integer('attempts').notNull().default(0),
261
+ // Tracks how many full recovery-agent runs have fired for this row.
262
+ // Bounded so a genuinely-broken commit eventually escalates to
263
+ // `needs_attention` + feedback notice instead of looping forever.
264
+ recoveryAgentRuns: integer('recovery_agent_runs').notNull().default(0),
265
+ lastAttemptedAt: timestamp('last_attempted_at'),
266
+ lastError: text('last_error'),
267
+ }, (t) => ({
268
+ // Primary read pattern: drain in queued order, scoped to a workspace.
269
+ byWorkspaceQueued: index('pending_commits_by_workspace_queued').on(t.workspaceId, t.queuedAt),
270
+ // Sweep: find rows ready to retry (status='pending' AND last_attempted_at < now() - backoff).
271
+ byStatus: index('pending_commits_by_status').on(t.status, t.lastAttemptedAt),
272
+ // Admin surface: enumerate everything stuck in needs_attention.
273
+ byStatusOnly: index('pending_commits_by_status_only').on(t.status),
274
+ statusCheck: check(
275
+ 'pending_commits_status',
276
+ sql`${t.status} IN ('pending', 'running', 'needs_attention')`,
277
+ ),
278
+ }));
279
+
280
+ /**
281
+ * Secrets Vault — the per-user store of credentials that back UTCP tool
282
+ * variables (`${FOO_API_KEY}`). Distinct from `vault_sources` (which holds
283
+ * connector+URL POINTERS and no credentials): this table holds the actual
284
+ * secret values, encrypted at rest.
285
+ *
286
+ * Two kinds:
287
+ * - `static` — a value the user pasted (an API key). `value_encrypted` is the
288
+ * AES-256-GCM ciphertext of the raw value.
289
+ * - `oauth` — an authorization-code credential. `value_encrypted` is the
290
+ * ciphertext of a JSON blob `{ clientSecret?, tokens: { access_token,
291
+ * refresh_token?, expires_at?, token_type? } }`; the non-secret
292
+ * provider config (auth/token URLs, client id, scopes) lives in
293
+ * `oauth_meta`. `resolve()` refreshes the access token on demand.
294
+ *
295
+ * `key` is the UTCP variable name a `.tool` manual references (`<manual>_<VAR>`).
296
+ * Two provisioning tiers share this table:
297
+ * - `user_id` set — a PER-USER secret (private to that user, cascade-deleted
298
+ * with them), unique per `(user_id, key)`.
299
+ * - `user_id NULL` — a SHARED/admin secret, set by a writer of the `.tool` file
300
+ * and read by every invoker of that tool. Unique per `key`
301
+ * via the partial index below (a plain `(user_id, key)` index
302
+ * treats NULLs as distinct, so it can't enforce one shared
303
+ * row per key on its own).
304
+ * `kind` is validated in the service, not a DB CHECK.
305
+ */
306
+ export const secrets = pgTable('secrets', {
307
+ id: uuid('id').defaultRandom().primaryKey(),
308
+ userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
309
+ /** The UTCP variable name this secret resolves (e.g. `FOO_API_KEY`). */
310
+ key: text('key').notNull(),
311
+ /** `static` | `oauth`. */
312
+ kind: text('kind').notNull().default('static'),
313
+ /** Operator-chosen display name. */
314
+ label: text('label'),
315
+ /** AES-256-GCM ciphertext (`iv:tag:ct`) — the value (static) or token JSON (oauth). */
316
+ valueEncrypted: text('value_encrypted').notNull(),
317
+ /** Non-secret OAuth provider config for `oauth` kind; null for `static`. */
318
+ oauthMeta: jsonb('oauth_meta').$type<Record<string, unknown>>(),
319
+ createdAt: timestamp('created_at').defaultNow().notNull(),
320
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
321
+ }, (t) => ({
322
+ byUser: index('secrets_by_user').on(t.userId),
323
+ userKeyUnq: uniqueIndex('secrets_user_key_unq').on(t.userId, t.key),
324
+ // One shared (admin) row per key: enforce uniqueness only across NULL-user rows.
325
+ sharedKeyUnq: uniqueIndex('secrets_shared_key_unq').on(t.key).where(sql`${t.userId} is null`),
326
+ }));
327
+
328
+ /**
329
+ * Ontology-session boundary — the per-session log of which named ontologies a
330
+ * run has TOUCHED (one row per `(session_id, ontology)`). Each agent operation
331
+ * that resolves to a named ontology records a row here (idempotent on the PK).
332
+ *
333
+ * The rule the gate enforces from this set:
334
+ * - READS are always allowed; they just record the ontology they touched.
335
+ * - WRITES are allowed only while the session's touched set is a SINGLE named
336
+ * ontology equal to the write target. Once a session has touched two or
337
+ * more ontologies (e.g. by reading across them), every write is blocked —
338
+ * "read across ontologies → you can't write anymore at all."
339
+ *
340
+ * Postgres is the durable source of truth so the boundary holds across a
341
+ * backend restart (an in-memory set would reset and silently un-poison a run).
342
+ * A write-through in-process cache fronts this table for the chatty read path;
343
+ * a cache miss falls back here.
344
+ *
345
+ * - PK is `(session_id, ontology)` — a set, idempotent on repeat touches.
346
+ * - `ontology` is the resolved ontology id (e.g. `KnowledgeBase/Product`).
347
+ * Neutral touches don't record, so they never write a row.
348
+ * - `touched_at` drives the abandoned-run `sweepOlderThan` backstop; the
349
+ * primary reclamation is an explicit delete of all of a session's rows at
350
+ * run end.
351
+ */
352
+ /**
353
+ * OAuth clients dynamically registered by MCP clients (RFC 7591 DCR). When an
354
+ * MCP client (claude.ai, Claude Code) connects to `/api/mcp` without a
355
+ * connection key, it registers itself here via `POST /register`, then runs the
356
+ * PKCE authorization-code flow against our authorize/token endpoints.
357
+ *
358
+ * `client_secret_encrypted` is AES-256-GCM ciphertext (`iv:tag:ct`), NOT a
359
+ * hash: the MCP SDK's token-endpoint client-auth middleware plaintext-compares
360
+ * `client.client_secret`, so `getClient` must return the original value. In
361
+ * practice MCP clients register as PUBLIC clients (`token_endpoint_auth_method
362
+ * 'none'`, PKCE only) and the column stays NULL. `metadata` snapshots the full
363
+ * registration object so `getClient` can rebuild it without lossy re-mapping.
364
+ */
365
+ export const oauthClients = pgTable('oauth_clients', {
366
+ clientId: text('client_id').primaryKey(),
367
+ clientSecretEncrypted: text('client_secret_encrypted'),
368
+ clientSecretExpiresAt: timestamp('client_secret_expires_at'),
369
+ redirectUris: jsonb('redirect_uris').notNull().$type<string[]>().default(sql`'[]'::jsonb`),
370
+ tokenEndpointAuthMethod: text('token_endpoint_auth_method').notNull().default('none'),
371
+ clientName: text('client_name'),
372
+ metadata: jsonb('metadata').notNull().$type<Record<string, unknown>>().default(sql`'{}'::jsonb`),
373
+ createdAt: timestamp('created_at').defaultNow().notNull(),
374
+ });
375
+
376
+ /**
377
+ * One-time OAuth authorization codes, issued when a user clicks Finish on the
378
+ * `/connect` consent page and exchanged at `/token` within seconds. Stored as
379
+ * a SHA-256 hash like every other bearer secret here. The row binds the code
380
+ * to (user, client, redirect_uri, PKCE challenge) so the token exchange can
381
+ * verify all four; single-use is enforced by the atomic consume
382
+ * `UPDATE … SET consumed_at = now() WHERE consumed_at IS NULL` (same pattern
383
+ * as `upload_tokens.used_at`).
384
+ */
385
+ export const oauthAuthCodes = pgTable('oauth_auth_codes', {
386
+ id: uuid('id').defaultRandom().primaryKey(),
387
+ codeHash: text('code_hash').notNull().unique(),
388
+ clientId: text('client_id').notNull().references(() => oauthClients.clientId),
389
+ userId: uuid('user_id').notNull().references(() => users.id),
390
+ redirectUri: text('redirect_uri').notNull(),
391
+ codeChallenge: text('code_challenge').notNull(),
392
+ scope: text('scope'),
393
+ resource: text('resource'),
394
+ expiresAt: timestamp('expires_at').notNull(),
395
+ consumedAt: timestamp('consumed_at'),
396
+ createdAt: timestamp('created_at').defaultNow().notNull(),
397
+ }, (t) => ({
398
+ // Sweep expired/spent rows without a full scan.
399
+ byExpiry: index('oauth_auth_codes_by_expiry').on(t.expiresAt),
400
+ }));
401
+
402
+ /**
403
+ * OAuth access/refresh token pairs minted by the token endpoint. Like
404
+ * `api_tokens`: plaintext shown only in the token response, SHA-256 hashes
405
+ * stored, revoked-not-deleted so `last_used_at` keeps its audit value and a
406
+ * revoked token can't be re-issued. One row per pair; refresh rotation
407
+ * revokes the old row and inserts a new one.
408
+ */
409
+ export const oauthTokens = pgTable('oauth_tokens', {
410
+ id: uuid('id').defaultRandom().primaryKey(),
411
+ accessTokenHash: text('access_token_hash').notNull().unique(),
412
+ refreshTokenHash: text('refresh_token_hash').unique(),
413
+ clientId: text('client_id').notNull().references(() => oauthClients.clientId),
414
+ userId: uuid('user_id').notNull().references(() => users.id),
415
+ scope: text('scope'),
416
+ resource: text('resource'),
417
+ expiresAt: timestamp('expires_at').notNull(),
418
+ refreshExpiresAt: timestamp('refresh_expires_at'),
419
+ revokedAt: timestamp('revoked_at'),
420
+ lastUsedAt: timestamp('last_used_at'),
421
+ createdAt: timestamp('created_at').defaultNow().notNull(),
422
+ }, (t) => ({
423
+ byUser: index('oauth_tokens_by_user').on(t.userId),
424
+ byExpiry: index('oauth_tokens_by_expiry').on(t.expiresAt),
425
+ }));
426
+
427
+ export const sessionOntologyTouches = pgTable('session_ontology_touches', {
428
+ sessionId: text('session_id').notNull(),
429
+ ontology: text('ontology').notNull(),
430
+ touchedAt: timestamp('touched_at').defaultNow().notNull(),
431
+ }, (t) => ({
432
+ // The composite PK leads with `session_id`, so a load-by-session SELECT is
433
+ // already served by the PK index — no separate `by_session` index needed.
434
+ pk: primaryKey({ columns: [t.sessionId, t.ontology] }),
435
+ // Backstop sweep of abandoned runs via `touched_at < cutoff`.
436
+ byTouchedAt: index('session_ontology_touches_by_touched_at').on(t.touchedAt),
437
+ }));