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