@antasphere/chassis-db 0.0.0-stage → 1.0.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.
package/src/schema.ts ADDED
@@ -0,0 +1,734 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import {
3
+ bigint,
4
+ boolean,
5
+ check,
6
+ doublePrecision,
7
+ index,
8
+ integer,
9
+ jsonb,
10
+ pgTable,
11
+ primaryKey,
12
+ smallint,
13
+ text,
14
+ timestamp,
15
+ uniqueIndex,
16
+ uuid
17
+ } from 'drizzle-orm/pg-core';
18
+ import { account, user } from './auth-schema.js';
19
+
20
+ // Better Auth owns the shape of user/session/account/verification (generated
21
+ // via @better-auth/cli, snapshot-checked in CI); everything below is domain.
22
+ export * from './auth-schema.js';
23
+
24
+ /**
25
+ * Singleton instance row. Exactly one row (id = 'instance') exists after
26
+ * first-boot setup; its presence is what makes POST /api/v1/setup return 410.
27
+ * Inserted by the setup endpoint with ON CONFLICT DO NOTHING so concurrent
28
+ * setup calls cannot both win.
29
+ */
30
+ export const instanceSettings = pgTable('instance_settings', {
31
+ id: text('id').primaryKey(), // always 'instance'
32
+ instanceId: text('instance_id').notNull(), // ULID minted at setup; used in usage events + discovery
33
+ name: text('name').notNull(),
34
+ setupCompletedAt: timestamp('setup_completed_at', { withTimezone: true }).notNull().defaultNow(),
35
+ schemaVersion: integer('schema_version').notNull().default(1),
36
+ telemetryEnabled: boolean('telemetry_enabled').notNull().default(false),
37
+ /**
38
+ * The EDITION this instance was set up as (R7 boot guard,
39
+ * internal/federation.md). Boot refuses an env EDITION that differs unless
40
+ * EDITION_CHANGE_ALLOWED=true re-stamps it — an edition flip under
41
+ * existing users/workspaces silently changes identity semantics. The
42
+ * default covers pre-column rows honestly: everything so far is oss.
43
+ */
44
+ edition: text('edition').notNull().default('oss'),
45
+ /**
46
+ * The user setup minted as the instance operator (CLOUD-3, PRDCT-1356).
47
+ * On cloud the operator holds NO membership (every cloud workspace is a
48
+ * hub projection), which made them structurally an orphan: the purge
49
+ * deleted the only local credential and the only break-glass identity.
50
+ * A durable record here excludes them from the sweep by construction —
51
+ * not by an env allowlist the operator may never have set. NULL on
52
+ * instances set up before this column existed.
53
+ */
54
+ operatorUserId: text('operator_user_id')
55
+ });
56
+
57
+ /**
58
+ * Multi-workspace schema AND runtime (ADR 014): setup creates the FIRST
59
+ * workspace, the cloud edition creates later ones through the registry's
60
+ * WorkspaceService, and every request is scoped to exactly ONE workspace —
61
+ * the one named by the presented credential. Every domain table carries a
62
+ * workspace_id and every read/write filters on the principal's.
63
+ */
64
+ export const workspaceHubStatuses = ['active', 'suspended'] as const;
65
+ export type WorkspaceHubStatus = (typeof workspaceHubStatuses)[number];
66
+
67
+ export const workspaces = pgTable(
68
+ 'workspaces',
69
+ {
70
+ id: uuid('id').primaryKey().defaultRandom(),
71
+ name: text('name').notNull(),
72
+ // Central-rail seam: a projected workspace names the ONE hub org it
73
+ // mirrors (cloud edition SSO, internal/federation.md); NULL = locally owned.
74
+ centralAccountId: text('central_account_id'),
75
+ /**
76
+ * The hub-asserted org status, materialized locally so suspension is
77
+ * readable inside the resolvers (visible-but-blocked). Written ONLY by
78
+ * the cloud edition's hub reconcile; local workspaces and every oss row
79
+ * stay 'active' forever.
80
+ */
81
+ hubStatus: text('hub_status', { enum: workspaceHubStatuses }).notNull().default('active'),
82
+ /**
83
+ * The workspace's look (PRDCT-2439 made it per workspace, in the browser;
84
+ * the settings pass of 2026-09-19 made it a fact of the workspace): one
85
+ * of the brand's theme keys and one pattern key of its library, as the
86
+ * dashboard names them. NULL = the dashboard's default for this
87
+ * workspace (the paper theme, a form dealt from the id). Validated as
88
+ * short slugs server-side; the dashboard falls back to its defaults on
89
+ * a key it does not know, so a retired theme never breaks a workspace.
90
+ */
91
+ lookTheme: text('look_theme'),
92
+ lookPattern: text('look_pattern'),
93
+ /** 0..1: how much of the field's gradient shows, how much grain sits on it. NULL = the brand's constant. */
94
+ lookField: doublePrecision('look_field'),
95
+ lookGrain: doublePrecision('look_grain'),
96
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
97
+ },
98
+ (t) => [
99
+ // A hub org projects into AT MOST ONE workspace (migration 0021): two
100
+ // concurrent first-logins into a fresh org race their inserts, and the
101
+ // loser must land on the winner's row (ON CONFLICT + re-select in the
102
+ // SSO projection) — never on a second projection of the same org.
103
+ uniqueIndex('workspaces_central_account_uniq')
104
+ .on(t.centralAccountId)
105
+ .where(sql`${t.centralAccountId} IS NOT NULL`),
106
+ check('workspaces_hub_status_check', sql`${t.hubStatus} IN ('active', 'suspended')`)
107
+ ]
108
+ );
109
+
110
+ export const workspaceRoles = ['owner', 'admin', 'member'] as const;
111
+ export type WorkspaceRole = (typeof workspaceRoles)[number];
112
+
113
+ /**
114
+ * Where a membership row came from — who its source of truth is (G2,
115
+ * the cloud-binding plan §5). Pure data in Phase 1 (no enforcement):
116
+ *
117
+ * - 'local': created by setup, workspace invitations, or admin surfaces —
118
+ * this instance owns it.
119
+ * - 'hub': created/updated ONLY by the cloud edition's SSO JIT projection
120
+ * and re-asserted against the hub (Phase 3+); local surfaces never mint it.
121
+ * - 'guest': created by the collaborator claim path — an external party
122
+ * invited to ONE resource whose membership exists only because principal
123
+ * resolution requires one. Excluded from hub re-assertion; guest
124
+ * capability limits arrive in Phase 6.
125
+ *
126
+ * No backfill needed: pre-launch, no production data — the default covers
127
+ * every existing row's honest origin.
128
+ */
129
+ export const workspaceMemberOrigins = ['local', 'hub', 'guest'] as const;
130
+ export type WorkspaceMemberOrigin = (typeof workspaceMemberOrigins)[number];
131
+
132
+ export const workspaceMembers = pgTable(
133
+ 'workspace_members',
134
+ {
135
+ id: uuid('id').primaryKey().defaultRandom(),
136
+ workspaceId: uuid('workspace_id')
137
+ .notNull()
138
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
139
+ userId: text('user_id')
140
+ .notNull()
141
+ .references(() => user.id, { onDelete: 'cascade' }),
142
+ role: text('role', { enum: workspaceRoles }).notNull().default('member'),
143
+ origin: text('origin', { enum: workspaceMemberOrigins }).notNull().default('local'),
144
+ isActive: boolean('is_active').notNull().default(true),
145
+ /**
146
+ * The user's DEFAULT workspace marker (user-scoped credential model): a
147
+ * request that names no workspace resolves here first, then falls back
148
+ * to the deterministic oldest-active ordering. At most one row per user
149
+ * (partial unique index). Nothing sets it locally yet — on cloud the hub
150
+ * reconcile materializes the hub-level per-user default org into it; on
151
+ * oss it stays false, so resolution is byte-identical to the pre-column
152
+ * behavior.
153
+ */
154
+ isDefault: boolean('is_default').notNull().default(false),
155
+ invitedBy: text('invited_by').references(() => user.id, { onDelete: 'set null' }),
156
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
157
+ lastSeenAt: timestamp('last_seen_at', { withTimezone: true })
158
+ },
159
+ (t) => [
160
+ uniqueIndex('workspace_members_workspace_user_uniq').on(t.workspaceId, t.userId),
161
+ index('workspace_members_user_idx').on(t.userId),
162
+ // Serves the admin API's keyset pagination (workspace_id, created_at DESC, id DESC).
163
+ index('workspace_members_workspace_created_id_idx').on(t.workspaceId, t.createdAt, t.id),
164
+ // ONE default workspace per user, enforced by the database.
165
+ uniqueIndex('workspace_members_user_default_uniq')
166
+ .on(t.userId)
167
+ .where(sql`${t.isDefault}`)
168
+ ]
169
+ );
170
+
171
+ /**
172
+ * Token-bearing invitations. Tokens are never stored — only their sha256 —
173
+ * and the acceptance link always works without SMTP (copyable link).
174
+ *
175
+ * Two tokens per invitation, one row: `token_hash` is the admin-visible
176
+ * copyable link (always returned by the create response), `email_token_hash`
177
+ * is a SECOND token that appears ONLY in the invitation email. Accepting with
178
+ * the emailed token proves control of the invited address (the admin never
179
+ * saw it), so that accept path may honestly set `emailVerified`; accepting
180
+ * with the copyable link proves nothing about the address. Nullable because
181
+ * pre-existing invitations (and rows from products that disable the email
182
+ * leg) have no emailed token.
183
+ */
184
+ export const invitations = pgTable(
185
+ 'invitations',
186
+ {
187
+ id: uuid('id').primaryKey().defaultRandom(),
188
+ workspaceId: uuid('workspace_id')
189
+ .notNull()
190
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
191
+ email: text('email').notNull(),
192
+ role: text('role', { enum: workspaceRoles }).notNull().default('member'),
193
+ tokenHash: text('token_hash').notNull(),
194
+ emailTokenHash: text('email_token_hash'),
195
+ invitedBy: text('invited_by')
196
+ .notNull()
197
+ .references(() => user.id, { onDelete: 'cascade' }),
198
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
199
+ acceptedAt: timestamp('accepted_at', { withTimezone: true }),
200
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
201
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
202
+ },
203
+ (t) => [
204
+ uniqueIndex('invitations_token_hash_uniq').on(t.tokenHash),
205
+ uniqueIndex('invitations_email_token_hash_uniq').on(t.emailTokenHash),
206
+ // Serves the admin API's keyset pagination (workspace_id, created_at DESC, id DESC).
207
+ index('invitations_workspace_created_id_idx').on(t.workspaceId, t.createdAt, t.id),
208
+ index('invitations_email_idx').on(t.email)
209
+ ]
210
+ );
211
+
212
+ /**
213
+ * Demo passes (the demo pass spec, section 3): a link an owner mints so that
214
+ * ONE member of the workspace is signed in without a password, for a
215
+ * demonstration. Only while DEMO_SIGN_IN is on, on the self-hosted edition.
216
+ * The secret is sign-in-equivalent, so it is stored nowhere: `secret_hash`
217
+ * is its sha256 hex, looked up through the unique index at redeem, the same
218
+ * shape as the invitation tokens. A pass is revoked, never deleted by a
219
+ * route; it goes with its workspace or its person.
220
+ */
221
+ export const demoPasses = pgTable(
222
+ 'demo_passes',
223
+ {
224
+ id: uuid('id').primaryKey().defaultRandom(),
225
+ workspaceId: uuid('workspace_id')
226
+ .notNull()
227
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
228
+ /** The person the pass signs in. */
229
+ userId: text('user_id')
230
+ .notNull()
231
+ .references(() => user.id, { onDelete: 'cascade' }),
232
+ /** The page the link lands on (checked by isSafeDemoPath at mint). */
233
+ targetPath: text('target_path').notNull().default('/'),
234
+ secretHash: text('secret_hash').notNull(),
235
+ createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
236
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
237
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
238
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
239
+ lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
240
+ useCount: integer('use_count').notNull().default(0)
241
+ },
242
+ (t) => [
243
+ uniqueIndex('demo_passes_secret_hash_uniq').on(t.secretHash),
244
+ // The owner's list: one workspace's passes, newest first.
245
+ index('demo_passes_workspace_created_idx').on(t.workspaceId, t.createdAt)
246
+ ]
247
+ );
248
+
249
+ /**
250
+ * One row per session a demo pass opened (`session_id` is the Better Auth
251
+ * `session.id`). It is what marks the audit rows written under such a
252
+ * session with `metadata.demoPassId`, so a workspace's trail tells what was
253
+ * done through a demo link from what the person did themselves.
254
+ */
255
+ export const demoPassSessions = pgTable('demo_pass_sessions', {
256
+ sessionId: text('session_id').primaryKey(),
257
+ passId: uuid('pass_id')
258
+ .notNull()
259
+ .references(() => demoPasses.id, { onDelete: 'cascade' }),
260
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
261
+ });
262
+
263
+ /**
264
+ * API keys: `<prefix>_<keyId8>_<secret>`. keyId gives O(1) indexed lookup;
265
+ * the secret is stored as sha256(secret + server pepper) and compared in
266
+ * constant time. Minted by sessions only — a key never mints a key.
267
+ *
268
+ * A key is a USER credential (user-scoped credential model): it acts as its
269
+ * creator, and the target workspace is chosen per request (X-Workspace-Id /
270
+ * the user's default) against the creator's LIVE memberships. `workspace_id`
271
+ * is an OPTIONAL PIN, not the identity: NULL (the mint default) = the key
272
+ * reaches any workspace its creator is an active member of; a value pins the
273
+ * key to that ONE workspace forever (least privilege — and the grandfathered
274
+ * behavior of every key minted before the model change, which stays exactly
275
+ * as issued).
276
+ */
277
+ export const apiKeys = pgTable(
278
+ 'api_keys',
279
+ {
280
+ id: uuid('id').primaryKey().defaultRandom(),
281
+ workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }),
282
+ keyId: text('key_id').notNull(),
283
+ secretHash: text('secret_hash').notNull(),
284
+ // Which pepper version hashed this key's secret (apps/server
285
+ // apikeys/peppers.ts). Version 1 = the AUTH_SECRET-derived pepper that
286
+ // hashed every key before versioning existed (hence the backfilling
287
+ // default); higher versions come from API_KEY_PEPPERS. Resolution fails
288
+ // closed on a version it cannot map.
289
+ pepperVersion: smallint('pepper_version').notNull().default(1),
290
+ name: text('name').notNull(),
291
+ scopes: text('scopes').array().notNull(),
292
+ createdBy: text('created_by')
293
+ .notNull()
294
+ .references(() => user.id, { onDelete: 'cascade' }),
295
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
296
+ lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
297
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
298
+ // null = never expires; enforced at resolution, normalized to revoked_at
299
+ // by the nightly sweep. Deliberately unindexed: the sweep seq-scans a
300
+ // tiny table once a night, and the hot path rides api_keys_key_id_uniq.
301
+ expiresAt: timestamp('expires_at', { withTimezone: true })
302
+ },
303
+ (t) => [
304
+ uniqueIndex('api_keys_key_id_uniq').on(t.keyId),
305
+ // Serves the admin API's keyset pagination (workspace_id, created_at DESC, id DESC).
306
+ index('api_keys_workspace_created_id_idx').on(t.workspaceId, t.createdAt, t.id),
307
+ // Serves the own-keys listing (keys are user credentials: created_by,
308
+ // created_at DESC, id DESC).
309
+ index('api_keys_created_by_created_id_idx').on(t.createdBy, t.createdAt, t.id)
310
+ ]
311
+ );
312
+
313
+ /**
314
+ * Idempotency-Key replay store for the non-idempotent create POSTs. One row
315
+ * per (workspace, user, client key): claimed (insert-first) before the
316
+ * handler runs, then filled with the response so a retried create replays
317
+ * instead of double-creating. The body is stored AES-256-GCM encrypted
318
+ * (see apps/server middleware/idempotency.ts) because these responses carry
319
+ * one-shot secrets — the full API key, the invitation accept token, the
320
+ * reset link — whose plaintext is deliberately never persisted anywhere
321
+ * else. Rows live 24h; a nightly purge deletes expired ones.
322
+ */
323
+ export const idempotencyKeys = pgTable(
324
+ 'idempotency_keys',
325
+ {
326
+ id: uuid('id').primaryKey().defaultRandom(),
327
+ workspaceId: uuid('workspace_id')
328
+ .notNull()
329
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
330
+ userId: text('user_id')
331
+ .notNull()
332
+ .references(() => user.id, { onDelete: 'cascade' }),
333
+ key: text('key').notNull(),
334
+ requestHash: text('request_hash').notNull(),
335
+ // Both null while the first request is in flight; filled best-effort
336
+ // once the handler's 2xx response is known.
337
+ responseStatus: integer('response_status'),
338
+ responseBodyEnc: text('response_body_enc'),
339
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
340
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull()
341
+ },
342
+ (t) => [
343
+ uniqueIndex('idempotency_keys_ws_user_key_uniq').on(t.workspaceId, t.userId, t.key),
344
+ // Serves the nightly purge (expires_at range scan).
345
+ index('idempotency_keys_expires_idx').on(t.expiresAt)
346
+ ]
347
+ );
348
+
349
+ /**
350
+ * One-time-use ledger for hub→tool exchange JWTs (`POST /sso/cli-connect`,
351
+ * cloud edition only — internal/federation.md P5). The hub mints every 120 s
352
+ * exchange token with a unique `jti`; consuming a token INSERTs its jti
353
+ * here first, and the primary-key conflict IS the replay detection — a
354
+ * replayed token inside its TTL loses the insert and is rejected before it
355
+ * can mint a second key. Postgres-backed so the guard is MULTI-REPLICA
356
+ * SAFE by construction (every replica races the same table), unlike any
357
+ * in-memory set. Rows are inert once `expires_at` passes (the token itself
358
+ * is expired then — the hub never re-mints a jti) and are swept
359
+ * best-effort on later exchanges; the table stays tiny (rows live ~2 min,
360
+ * arrivals are rate-limited), so the sweep needs no index.
361
+ */
362
+ export const ssoConnectJtis = pgTable('sso_connect_jtis', {
363
+ jti: text('jti').primaryKey(),
364
+ /** The token's own `exp` — after this the row only documents history. */
365
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
366
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
367
+ });
368
+
369
+ /**
370
+ * The hub-grant PRESENTATION marker (PRDCT-1370, internal/federation.md
371
+ * "Ambiguous refresh outcomes"): written by HubGrantService immediately
372
+ * BEFORE it presents a user's refresh token to the hub's token endpoint,
373
+ * cleared once the hub's answer is known (a rotation, or a definitive
374
+ * refusal). A row that survives therefore means "this exact token was
375
+ * presented and its answer never arrived" — a client-side timeout, a lost
376
+ * response, a socket reset after the hub committed. The hub rotates BEFORE
377
+ * it answers and cannot roll back, so blindly re-presenting that token
378
+ * would trip the hub's RFC 9700 reuse detection and tear down the user's
379
+ * whole (client, user) grant family, the CLI grant included. With the
380
+ * marker in place the next refresh PROBES the token through the hub's
381
+ * RFC 7662 introspection endpoint first (read-only: it never rotates and
382
+ * never tears down) and only presents a token the hub still calls active.
383
+ *
384
+ * Keyed by the `account` row (one hub link per user); `refresh_token` is
385
+ * the ciphertext presented, so a browser re-login that re-seeds the row
386
+ * mid-flight invalidates the marker by construction (the ciphertext no
387
+ * longer matches). Postgres-backed so every replica sees it — the refresh
388
+ * lock is cross-replica, and so must the memory of an unanswered
389
+ * presentation be.
390
+ */
391
+ export const hubGrantPresentations = pgTable('hub_grant_presentations', {
392
+ accountId: text('account_id')
393
+ .primaryKey()
394
+ .references(() => account.id, { onDelete: 'cascade' }),
395
+ /** The refresh-token CIPHERTEXT that was presented (matches account.refresh_token). */
396
+ refreshToken: text('refresh_token').notNull(),
397
+ presentedAt: timestamp('presented_at', { withTimezone: true }).notNull().defaultNow()
398
+ });
399
+
400
+ /**
401
+ * TOOL-LOCAL first-run onboarding state (SL-6; written on the cloud
402
+ * edition's SSO login path, read by /me). An APP domain table, not a
403
+ * Better Auth column — no drift-guard involvement. The welcome-banner
404
+ * decision reads ONLY this table and is RETRY-SAFE BY CONSTRUCTION:
405
+ * `firstRunPending := NOT EXISTS (row WHERE dismissed_at IS NOT NULL)` —
406
+ * the absence of a DISMISSAL means the welcome is still owed, so a
407
+ * transiently-failed first-login insert self-heals on the next login and
408
+ * only an explicit dismissal ever hides the banner. The hub's advisory
409
+ * `tool_first_login` id_token claim is deliberately ignored for behavior
410
+ * (the hub's user_tool_usage table stays the ECOSYSTEM analytics truth;
411
+ * this table owns the banner). The deploy-time backfill migration inserts
412
+ * DISMISSED rows for every pre-existing user — nobody gets a retroactive
413
+ * welcome.
414
+ */
415
+ export const userOnboarding = pgTable('user_onboarding', {
416
+ userId: text('user_id')
417
+ .primaryKey()
418
+ .references(() => user.id, { onDelete: 'cascade' }),
419
+ /** First observed login (informational; the banner keys on dismissed_at). */
420
+ firstLoginAt: timestamp('first_login_at', { withTimezone: true }).notNull().defaultNow(),
421
+ /** NULL = welcome still owed; set once by POST /me/onboarding/dismiss (or the backfill). */
422
+ dismissedAt: timestamp('dismissed_at', { withTimezone: true })
423
+ });
424
+
425
+ /**
426
+ * Audit log: who did what to which resource, when, with which credential.
427
+ * Append-only; bigint identity keeps inserts cheap on the hot path.
428
+ */
429
+ export const auditLog = pgTable(
430
+ 'audit_log',
431
+ {
432
+ id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
433
+ // NULL = INSTANCE-attributed: the event belongs to no workspace (ADR 014)
434
+ // — e.g. the orphan-user purge, whose subjects have no workspace home.
435
+ workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }),
436
+ actorUserId: text('actor_user_id').references(() => user.id, { onDelete: 'set null' }),
437
+ actorVia: text('actor_via', { enum: ['session', 'api_key', 'oauth', 'system'] }).notNull(),
438
+ apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }),
439
+ action: text('action').notNull(),
440
+ resourceType: text('resource_type').notNull(),
441
+ resourceId: text('resource_id'),
442
+ requestId: text('request_id'),
443
+ ip: text('ip'),
444
+ metadata: jsonb('metadata'),
445
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
446
+ },
447
+ (t) => [
448
+ index('audit_log_workspace_created_idx').on(t.workspaceId, t.createdAt),
449
+ // Serves the admin API's keyset pagination (workspace_id, id DESC).
450
+ index('audit_log_workspace_id_idx').on(t.workspaceId, t.id),
451
+ // Serves the nightly retention purge (created_at range scan across all
452
+ // workspaces); the workspace-leading index above cannot.
453
+ index('audit_log_created_idx').on(t.createdAt)
454
+ ]
455
+ );
456
+
457
+ /**
458
+ * File metadata over the content-addressed StorageDriver. Rows are
459
+ * workspace-scoped; blobs are stored once per (workspace, sha256).
460
+ *
461
+ * `created_by` is nullable with `set null` on user delete (migration 0008):
462
+ * files are WORKSPACE data, not the uploader's personal data — deleting the
463
+ * account anonymizes the uploader (like audit_log.actor_user_id) while the
464
+ * file stays with the workspace (ADR 006).
465
+ */
466
+ export const files = pgTable(
467
+ 'files',
468
+ {
469
+ id: uuid('id').primaryKey().defaultRandom(),
470
+ workspaceId: uuid('workspace_id')
471
+ .notNull()
472
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
473
+ sha256: text('sha256').notNull(),
474
+ sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
475
+ contentType: text('content_type').notNull(),
476
+ originalName: text('original_name').notNull(),
477
+ createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
478
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
479
+ deletedAt: timestamp('deleted_at', { withTimezone: true })
480
+ },
481
+ (t) => [
482
+ uniqueIndex('files_workspace_sha_uniq').on(t.workspaceId, t.sha256),
483
+ // Serves the admin API's keyset pagination (workspace_id, created_at DESC, id DESC).
484
+ index('files_workspace_created_id_idx').on(t.workspaceId, t.createdAt, t.id)
485
+ ]
486
+ );
487
+
488
+ /**
489
+ * Proof of possession: every user who has PUSHED these exact bytes into the
490
+ * workspace, not just the first one (SL-B1 / ADR 013 on the blob surface).
491
+ *
492
+ * `files` is content-addressed and unique per (workspace, sha256), so a
493
+ * second uploader of identical bytes deduplicates onto the FIRST uploader's
494
+ * row and `files.created_by` keeps naming that first uploader. Once blob
495
+ * reads are per-resource authorized, that single column is no longer a truthful
496
+ * answer to "may this principal see these bytes": a member who uploaded a
497
+ * shared logo that another member had uploaded first would be locked out of
498
+ * a blob they demonstrably hold — and their version commit, which must only
499
+ * bind shas they may read, would be refused. This table records each
500
+ * distinct uploader so possession survives deduplication.
501
+ *
502
+ * `on delete cascade` on both sides: attribution is personal data (it dies
503
+ * with the account, like the anonymized `files.created_by`) and carries no
504
+ * value once the file row is gone.
505
+ */
506
+ export const fileUploaders = pgTable(
507
+ 'file_uploaders',
508
+ {
509
+ fileId: uuid('file_id')
510
+ .notNull()
511
+ .references(() => files.id, { onDelete: 'cascade' }),
512
+ userId: text('user_id')
513
+ .notNull()
514
+ .references(() => user.id, { onDelete: 'cascade' }),
515
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
516
+ },
517
+ (t) => [
518
+ primaryKey({ columns: [t.fileId, t.userId] }),
519
+ // Serves the per-principal blob visibility probe (file_id + user_id is
520
+ // the PK, so the lookup is the PK index; this one serves the reverse
521
+ // "everything this user uploaded" direction the cascade delete walks).
522
+ index('file_uploaders_user_idx').on(t.userId)
523
+ ]
524
+ );
525
+
526
+ /**
527
+ * A PROJECT is a subgroup of a workspace: a name, a description, members with
528
+ * a role, and whatever the tool links to it through a table of ITS OWN. The
529
+ * chassis owns the concept; it knows none of the tool's resources, and no
530
+ * tool ever needs a column here: `metadata` is the opaque, owner-defined seam
531
+ * (the same shape and size rules as the tool's own resource metadata).
532
+ *
533
+ * A project is ARCHIVED, never deleted: there is no delete route and no
534
+ * `deleted_at`. `archived_at` takes it out of the default list and makes it
535
+ * read-only; nothing cascades onto what is linked to it.
536
+ */
537
+ export const projects = pgTable(
538
+ 'projects',
539
+ {
540
+ id: uuid('id').primaryKey().defaultRandom(),
541
+ workspaceId: uuid('workspace_id')
542
+ .notNull()
543
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
544
+ name: text('name').notNull(),
545
+ description: text('description'),
546
+ metadata: jsonb('metadata').$type<Record<string, unknown>>().notNull().default({}),
547
+ archivedAt: timestamp('archived_at', { withTimezone: true }),
548
+ createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
549
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
550
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
551
+ },
552
+ (t) => [
553
+ // Serves the keyset list (workspace_id, created_at DESC, id DESC).
554
+ index('projects_workspace_created_id_idx').on(t.workspaceId, t.createdAt, t.id)
555
+ ]
556
+ );
557
+
558
+ /**
559
+ * The three project roles. A SECURITY vocabulary read by SQL predicates
560
+ * (`projects/access.ts`), so a stray value must be impossible: text + CHECK,
561
+ * never a free string.
562
+ */
563
+ export const projectRoles = ['manager', 'editor', 'viewer'] as const;
564
+ export type ProjectRole = (typeof projectRoles)[number];
565
+
566
+ /**
567
+ * One row per (project, person). **The grant rides on the workspace
568
+ * membership and dies with it**: `member_id` references the
569
+ * `workspace_members` row with ON DELETE CASCADE, so a removed member (a
570
+ * local delete, or the hub reconcile sweeping a membership) loses every
571
+ * project grant, and a later re-invite starts with none. The person and the
572
+ * workspace are READ THROUGH that row, never copied beside it: a copy could
573
+ * drift, and every predicate joins the membership anyway for its two live
574
+ * facts (`is_active`, `origin <> 'guest'`).
575
+ */
576
+ export const projectMembers = pgTable(
577
+ 'project_members',
578
+ {
579
+ id: uuid('id').primaryKey().defaultRandom(),
580
+ projectId: uuid('project_id')
581
+ .notNull()
582
+ .references(() => projects.id, { onDelete: 'cascade' }),
583
+ memberId: uuid('member_id')
584
+ .notNull()
585
+ .references(() => workspaceMembers.id, { onDelete: 'cascade' }),
586
+ role: text('role', { enum: projectRoles }).notNull(),
587
+ addedBy: text('added_by').references(() => user.id, { onDelete: 'set null' }),
588
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
589
+ },
590
+ (t) => [
591
+ uniqueIndex('project_members_project_member_uniq').on(t.projectId, t.memberId),
592
+ // The reverse direction: "every project of this membership" (the list's
593
+ // WHERE for a plain member, and the cascade's walk).
594
+ index('project_members_member_idx').on(t.memberId),
595
+ // Serves the members list's keyset pagination.
596
+ index('project_members_project_created_id_idx').on(t.projectId, t.createdAt, t.id),
597
+ check('project_members_role_check', sql`${t.role} IN ('manager', 'editor', 'viewer')`)
598
+ ]
599
+ );
600
+
601
+ /**
602
+ * Teams: named groups of the workspace's people, a concept of the chassis on
603
+ * BOTH editions with the same tables, routes and screens; only the SOURCE
604
+ * differs (Romain's rule of 27 September 2026, PRDCT-2813).
605
+ *
606
+ * - On the self-hosted edition, and in a cloud-LOCAL workspace, a team is
607
+ * the tool's own: `hub_team_id` is NULL, owners and admins create, rename
608
+ * and delete it and seat members through `api/teams.ts`. Its slug is
609
+ * unique within the workspace (the partial index below).
610
+ * - In a hub-origin workspace (cloud) a team is the hub's, projected:
611
+ * `hub_team_id` carries the hub's team id (unique: a team belongs to one
612
+ * hub organization, so it projects into one workspace), and its slug and
613
+ * name follow the hub on drift. The reconciler (`identity/hub-reconcile.ts`)
614
+ * writes the organization's whole team list as the signed-in person reads
615
+ * it, and the person's own seats from `GET /orgs`; the routes refuse every
616
+ * local write there (`hub_managed`, the members' pattern). Its slug is
617
+ * unique at the hub, never re-checked here: two projections read at
618
+ * different moments may hold the same slug for an instant.
619
+ *
620
+ * A team is never a principal: it holds no credential and no role of its
621
+ * own. What it does hold is a place on a project (`project_teams`, ADR 026),
622
+ * which its members inherit through the one access rule.
623
+ */
624
+ export const workspaceTeams = pgTable(
625
+ 'workspace_teams',
626
+ {
627
+ id: uuid('id').primaryKey().defaultRandom(),
628
+ workspaceId: uuid('workspace_id')
629
+ .notNull()
630
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
631
+ /** The hub's team id for a projected team; NULL for the tool's own. */
632
+ hubTeamId: text('hub_team_id'),
633
+ slug: text('slug').notNull(),
634
+ name: text('name').notNull(),
635
+ createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
636
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
637
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
638
+ },
639
+ (t) => [
640
+ uniqueIndex('workspace_teams_hub_team_uniq').on(t.hubTeamId),
641
+ // A local team's slug is unique in its workspace; a projected one is the hub's.
642
+ uniqueIndex('workspace_teams_workspace_slug_local_uniq')
643
+ .on(t.workspaceId, t.slug)
644
+ .where(sql`${t.hubTeamId} IS NULL`),
645
+ index('workspace_teams_workspace_idx').on(t.workspaceId)
646
+ ]
647
+ );
648
+
649
+ /**
650
+ * One row per (team, person): a seat. **The seat rides on the workspace
651
+ * membership**, like a project grant: `member_id` references the
652
+ * `workspace_members` row with ON DELETE CASCADE, so a team can only hold
653
+ * its own workspace's people and a membership that goes takes every seat
654
+ * with it. A deactivated membership keeps its seats (a pause; the access
655
+ * predicates refuse it anyway); only an active one can be seated.
656
+ *
657
+ * On a local team the routes write the seats (an owner or admin, `added_by`
658
+ * set). On a projected team the reconciler writes the PERSON's own seats as
659
+ * the hub asserted them at their last pass, and deletes the seats of a team
660
+ * the hub no longer lists for them; the hub sweep deletes the seats with the
661
+ * membership it deactivates (a removal is a removal). A projected roster is
662
+ * therefore the seats of the people who have signed in here, never the
663
+ * hub's whole one.
664
+ */
665
+ export const workspaceTeamMembers = pgTable(
666
+ 'workspace_team_members',
667
+ {
668
+ id: uuid('id').primaryKey().defaultRandom(),
669
+ teamId: uuid('team_id')
670
+ .notNull()
671
+ .references(() => workspaceTeams.id, { onDelete: 'cascade' }),
672
+ memberId: uuid('member_id')
673
+ .notNull()
674
+ .references(() => workspaceMembers.id, { onDelete: 'cascade' }),
675
+ addedBy: text('added_by').references(() => user.id, { onDelete: 'set null' }),
676
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
677
+ },
678
+ (t) => [
679
+ uniqueIndex('workspace_team_members_team_member_uniq').on(t.teamId, t.memberId),
680
+ // "Every seat of this membership" (the sweep's and the pass's walk).
681
+ index('workspace_team_members_member_idx').on(t.memberId)
682
+ ]
683
+ );
684
+
685
+ /**
686
+ * One row per (project, team): a team's place on a project, with a role, like
687
+ * a person's (PRDCT-2794, ADR 026). Every active non-guest member of the
688
+ * team holds the role through it, resolved live by the one access rule
689
+ * (`projects/access.ts`), so a person seated after the grant reads the
690
+ * project at once and a person unseated loses it at once; the effective role
691
+ * is the highest of the person's direct grant and their teams' grants. Both
692
+ * ends cascade: a deleted team, or an archived-then-deleted project, takes
693
+ * the row. The same on both editions: on cloud the team is the hub's
694
+ * projection, on self-hosted the tool's own.
695
+ */
696
+ export const projectTeams = pgTable(
697
+ 'project_teams',
698
+ {
699
+ id: uuid('id').primaryKey().defaultRandom(),
700
+ projectId: uuid('project_id')
701
+ .notNull()
702
+ .references(() => projects.id, { onDelete: 'cascade' }),
703
+ teamId: uuid('team_id')
704
+ .notNull()
705
+ .references(() => workspaceTeams.id, { onDelete: 'cascade' }),
706
+ role: text('role', { enum: projectRoles }).notNull(),
707
+ addedBy: text('added_by').references(() => user.id, { onDelete: 'set null' }),
708
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
709
+ },
710
+ (t) => [
711
+ uniqueIndex('project_teams_project_team_uniq').on(t.projectId, t.teamId),
712
+ // The reverse direction: "every project of this team" (the predicate's walk).
713
+ index('project_teams_team_idx').on(t.teamId),
714
+ // Serves the members list's keyset pagination (people and teams in one order).
715
+ index('project_teams_project_created_id_idx').on(t.projectId, t.createdAt, t.id),
716
+ check('project_teams_role_check', sql`${t.role} IN ('manager', 'editor', 'viewer')`)
717
+ ]
718
+ );
719
+
720
+ export type InstanceSettings = typeof instanceSettings.$inferSelect;
721
+ export type Workspace = typeof workspaces.$inferSelect;
722
+ export type WorkspaceMember = typeof workspaceMembers.$inferSelect;
723
+ export type Invitation = typeof invitations.$inferSelect;
724
+ export type DemoPassRow = typeof demoPasses.$inferSelect;
725
+ export type ApiKey = typeof apiKeys.$inferSelect;
726
+ export type IdempotencyKey = typeof idempotencyKeys.$inferSelect;
727
+ export type AuditEntry = typeof auditLog.$inferSelect;
728
+ export type FileRow = typeof files.$inferSelect;
729
+ export type FileUploaderRow = typeof fileUploaders.$inferSelect;
730
+ export type ProjectRow = typeof projects.$inferSelect;
731
+ export type ProjectMemberRow = typeof projectMembers.$inferSelect;
732
+ export type WorkspaceTeamRow = typeof workspaceTeams.$inferSelect;
733
+ export type WorkspaceTeamMemberRow = typeof workspaceTeamMembers.$inferSelect;
734
+ export type ProjectTeamRow = typeof projectTeams.$inferSelect;