@govcore/schema 0.2.0 → 0.3.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 DELETED
@@ -1,311 +0,0 @@
1
- // @govcore/schema — platform table definitions (identity, tenancy, auth, audit).
2
- //
3
- // Edge-safe: imports only `drizzle-orm/pg-core`, no DB client. The app imports
4
- // these for type-safe queries and to declare foreign keys *to* platform tables.
5
- // The govcore-migrate runner (./migrate) is a separate, non-edge entrypoint.
6
- //
7
- // All platform tables live in a dedicated `govcore` Postgres schema (design
8
- // §13.4) so they never collide with an app's `public` tables and ownership is
9
- // obvious. The authored DDL lives in ../migrations and must stay in sync.
10
-
11
- import {
12
- type AnyPgColumn,
13
- boolean,
14
- integer,
15
- jsonb,
16
- pgSchema,
17
- text,
18
- timestamp,
19
- unique,
20
- uniqueIndex,
21
- uuid,
22
- } from 'drizzle-orm/pg-core'
23
-
24
- /** The Postgres schema every GovCore platform table belongs to. */
25
- export const govcore = pgSchema('govcore')
26
-
27
- /** Content/federation visibility. Used by the federation package (later phase). */
28
- export const visibility = govcore.enum('visibility', ['org', 'connections', 'instance'])
29
-
30
- /** Generic workflow status shared by federation connections and links. */
31
- export const federationStatus = govcore.enum('federation_status', ['pending', 'active', 'rejected'])
32
-
33
- // ── Tenancy root ────────────────────────────────────────────────────────────
34
-
35
- export const organizations = govcore.table(
36
- 'organizations',
37
- {
38
- id: uuid('id').primaryKey().defaultRandom(),
39
- name: text('name').notNull(),
40
- slug: text('slug').notNull(),
41
- /** App-extensible bag for org settings GovCore itself doesn't model. */
42
- metadata: jsonb('metadata').notNull().default({}),
43
- createdAt: timestamp('created_at').notNull().defaultNow(),
44
- updatedAt: timestamp('updated_at').notNull().defaultNow(),
45
- },
46
- (t) => [uniqueIndex('organizations_slug_unique').on(t.slug)],
47
- )
48
-
49
- /**
50
- * The tenancy column contract (design §5): every tenant-scoped table — core's
51
- * and the app's own domain tables — carries `organization_id` referencing
52
- * `organizations`. RLS policies (see ../migrations/0001) key off this column.
53
- *
54
- * @example
55
- * export const permits = pgTable('permits', { ...orgScoped(organizations), title: text('title') })
56
- */
57
- export const orgScoped = (orgs: typeof organizations) => ({
58
- organizationId: uuid('organization_id')
59
- .notNull()
60
- .references((): AnyPgColumn => orgs.id, { onDelete: 'cascade' }),
61
- })
62
-
63
- // ── Identity ────────────────────────────────────────────────────────────────
64
-
65
- export const users = govcore.table(
66
- 'users',
67
- {
68
- id: uuid('id').primaryKey().defaultRandom(),
69
- organizationId: uuid('organization_id')
70
- .notNull()
71
- .references(() => organizations.id, { onDelete: 'cascade' }),
72
- /** Last-selected active org, honored first by active-membership resolution. */
73
- lastActiveOrganizationId: uuid('last_active_organization_id').references(
74
- (): AnyPgColumn => organizations.id,
75
- { onDelete: 'set null' },
76
- ),
77
- name: text('name'),
78
- email: text('email').notNull(),
79
- emailVerified: timestamp('email_verified'),
80
- image: text('image'),
81
- passwordHash: text('password_hash'),
82
- /**
83
- * App-defined role (see @govcore/rbac `createRbac`) — stored as `text`, not
84
- * a fixed enum, so GovCore ships no role vocabulary of its own. Denormalized
85
- * cache of the active membership's role; nullable so the Auth.js adapter can
86
- * create a user before a membership is assigned.
87
- */
88
- role: text('role'),
89
- /** Platform-level role (e.g. instance/platform admin), separate from per-org role. */
90
- instanceRole: text('instance_role'),
91
- isActive: boolean('is_active').notNull().default(true),
92
- // Account-lockout + password-expiry state; the policy itself lives app-side.
93
- failedLoginAttempts: integer('failed_login_attempts').notNull().default(0),
94
- lockoutUntil: timestamp('lockout_until'),
95
- lastPasswordChangedAt: timestamp('last_password_changed_at').notNull().defaultNow(),
96
- createdAt: timestamp('created_at').notNull().defaultNow(),
97
- updatedAt: timestamp('updated_at').notNull().defaultNow(),
98
- },
99
- // One identity per email across all orgs — unambiguous auth/SSO lookups.
100
- (t) => [uniqueIndex('users_email_unique').on(t.email)],
101
- )
102
-
103
- // Auth.js (drizzle-adapter) tables. Not org-scoped; read during auth flows
104
- // before a tenant context exists, so they are intentionally outside RLS.
105
-
106
- export const accounts = govcore.table('accounts', {
107
- id: uuid('id').primaryKey().defaultRandom(),
108
- userId: uuid('user_id')
109
- .notNull()
110
- .references(() => users.id, { onDelete: 'cascade' }),
111
- type: text('type').notNull(),
112
- provider: text('provider').notNull(),
113
- providerAccountId: text('provider_account_id').notNull(),
114
- refreshToken: text('refresh_token'),
115
- accessToken: text('access_token'),
116
- expiresAt: timestamp('expires_at'),
117
- tokenType: text('token_type'),
118
- scope: text('scope'),
119
- idToken: text('id_token'),
120
- sessionState: text('session_state'),
121
- })
122
-
123
- export const sessions = govcore.table('sessions', {
124
- id: uuid('id').primaryKey().defaultRandom(),
125
- sessionToken: text('session_token').notNull().unique(),
126
- userId: uuid('user_id')
127
- .notNull()
128
- .references(() => users.id, { onDelete: 'cascade' }),
129
- expires: timestamp('expires').notNull(),
130
- })
131
-
132
- export const verificationTokens = govcore.table('verification_tokens', {
133
- identifier: text('identifier').notNull(),
134
- token: text('token').notNull(),
135
- expires: timestamp('expires').notNull(),
136
- })
137
-
138
- // ── Membership model (the heart of tenancy) ─────────────────────────────────
139
-
140
- export const userOrganizationMemberships = govcore.table(
141
- 'user_organization_memberships',
142
- {
143
- id: uuid('id').primaryKey().defaultRandom(),
144
- userId: uuid('user_id')
145
- .notNull()
146
- .references(() => users.id, { onDelete: 'cascade' }),
147
- organizationId: uuid('organization_id')
148
- .notNull()
149
- .references(() => organizations.id, { onDelete: 'cascade' }),
150
- /** App-defined role for this membership (text, not an enum — see `users.role`). */
151
- role: text('role').notNull(),
152
- /** Revocation soft-deactivates so the historical row survives for audit. */
153
- isActive: boolean('is_active').notNull().default(true),
154
- isPrimary: boolean('is_primary').notNull().default(false),
155
- createdAt: timestamp('created_at').notNull().defaultNow(),
156
- updatedAt: timestamp('updated_at').notNull().defaultNow(),
157
- },
158
- (t) => [uniqueIndex('user_org_membership_unique').on(t.userId, t.organizationId)],
159
- )
160
-
161
- // ── Audit (append-only; immutability + RLS in ../migrations/0001) ───────────
162
-
163
- export const auditLog = govcore.table('audit_log', {
164
- id: uuid('id').primaryKey().defaultRandom(),
165
- organizationId: uuid('organization_id'), // historical UUID — no FK on purpose
166
- userId: uuid('user_id'), // historical UUID — no FK on purpose
167
- action: text('action').notNull(),
168
- entityType: text('entity_type').notNull(),
169
- entityId: uuid('entity_id'),
170
- before: jsonb('before'),
171
- after: jsonb('after'),
172
- metadata: jsonb('metadata'),
173
- createdAt: timestamp('created_at').notNull().defaultNow(),
174
- })
175
-
176
- // ── Federation (cross-organization connections and content links) ──────────
177
-
178
- /** Explicit bilateral connection between two organizations. */
179
- export const orgConnections = govcore.table(
180
- 'org_connections',
181
- {
182
- id: uuid('id').primaryKey().defaultRandom(),
183
- fromOrgId: uuid('from_org_id')
184
- .notNull()
185
- .references(() => organizations.id, { onDelete: 'cascade' }),
186
- toOrgId: uuid('to_org_id')
187
- .notNull()
188
- .references(() => organizations.id, { onDelete: 'cascade' }),
189
- status: federationStatus('status').notNull().default('pending'),
190
- createdBy: uuid('created_by').references(() => users.id),
191
- createdAt: timestamp('created_at').notNull().defaultNow(),
192
- updatedAt: timestamp('updated_at').notNull().defaultNow(),
193
- },
194
- (t) => [unique('unique_org_connection').on(t.fromOrgId, t.toOrgId)],
195
- )
196
-
197
- /** Approved relationship between content items across orgs. No FK on entity ids
198
- * (they cross org boundaries); link semantics are app-defined (`link_type` text). */
199
- export const crossOrgLinks = govcore.table('cross_org_links', {
200
- id: uuid('id').primaryKey().defaultRandom(),
201
- sourceOrgId: uuid('source_org_id')
202
- .notNull()
203
- .references(() => organizations.id, { onDelete: 'cascade' }),
204
- sourceEntityType: text('source_entity_type').notNull(),
205
- sourceEntityId: uuid('source_entity_id').notNull(),
206
- targetOrgId: uuid('target_org_id')
207
- .notNull()
208
- .references(() => organizations.id, { onDelete: 'cascade' }),
209
- targetEntityType: text('target_entity_type').notNull(),
210
- targetEntityId: uuid('target_entity_id').notNull(),
211
- linkType: text('link_type').notNull(),
212
- status: federationStatus('status').notNull().default('pending'),
213
- rejectionReason: text('rejection_reason'),
214
- flaggedForReview: boolean('flagged_for_review').notNull().default(false),
215
- flagReason: text('flag_reason'),
216
- createdBy: uuid('created_by').references(() => users.id),
217
- createdAt: timestamp('created_at').notNull().defaultNow(),
218
- updatedAt: timestamp('updated_at').notNull().defaultNow(),
219
- })
220
-
221
- // ── Support access (break-glass + act-as) ──────────────────────────────────
222
- // Instance-operator constructs that deliberately cross the tenant boundary, so
223
- // they are NOT under the org-GUC RLS (the actor is an instance admin operating
224
- // across orgs). Authorization is enforced in @govcore/support (design §6.6).
225
-
226
- export const breakGlassSessions = govcore.table('break_glass_sessions', {
227
- id: uuid('id').primaryKey().defaultRandom(),
228
- instanceAdminId: uuid('instance_admin_id')
229
- .notNull()
230
- .references(() => users.id),
231
- targetOrgId: uuid('target_org_id')
232
- .notNull()
233
- .references(() => organizations.id),
234
- reason: text('reason').notNull(),
235
- grantedAt: timestamp('granted_at').notNull().defaultNow(),
236
- expiresAt: timestamp('expires_at').notNull(),
237
- requiresApproval: boolean('requires_approval').notNull().default(false),
238
- approvedAt: timestamp('approved_at'),
239
- approvedBy: uuid('approved_by').references(() => users.id),
240
- revokedAt: timestamp('revoked_at'),
241
- revokedBy: uuid('revoked_by').references(() => users.id),
242
- })
243
-
244
- export const actAsSessions = govcore.table('act_as_sessions', {
245
- id: uuid('id').primaryKey().defaultRandom(),
246
- breakGlassSessionId: uuid('break_glass_session_id')
247
- .notNull()
248
- .references(() => breakGlassSessions.id),
249
- instanceAdminId: uuid('instance_admin_id')
250
- .notNull()
251
- .references(() => users.id),
252
- targetOrgId: uuid('target_org_id')
253
- .notNull()
254
- .references(() => organizations.id),
255
- startedAt: timestamp('started_at').notNull().defaultNow(),
256
- expiresAt: timestamp('expires_at').notNull(),
257
- endedAt: timestamp('ended_at'),
258
- endReason: text('end_reason'),
259
- })
260
-
261
- export const ACT_AS_DEFAULT_TTL_MINUTES = 30
262
- export const ACT_AS_END_REASONS = [
263
- 'admin_ended',
264
- 'expired',
265
- 'parent_revoked',
266
- 'parent_expired',
267
- ] as const
268
- export type ActAsEndReason = (typeof ACT_AS_END_REASONS)[number]
269
-
270
- // ── Instance / platform configuration (singletons; not org-scoped) ─────────
271
-
272
- export const instanceSettings = govcore.table('instance_settings', {
273
- id: uuid('id').primaryKey().defaultRandom(),
274
- disabledModules: jsonb('disabled_modules').$type<Record<string, boolean>>().notNull().default({}),
275
- createdAt: timestamp('created_at').notNull().defaultNow(),
276
- updatedAt: timestamp('updated_at').notNull().defaultNow(),
277
- })
278
-
279
- export const platformConfig = govcore.table('platform_config', {
280
- id: text('id').primaryKey().default('singleton'),
281
- instanceName: text('instance_name').notNull().default('GovCore'),
282
- defaultTheme: text('default_theme').notNull().default('base'),
283
- allowLocalAuth: boolean('allow_local_auth').notNull().default(true),
284
- /** Stamped onto new orgs at provisioning time. Null means no tier. */
285
- defaultSupportTier: text('default_support_tier'),
286
- updatedAt: timestamp('updated_at').notNull().defaultNow(),
287
- updatedBy: uuid('updated_by').references(() => users.id, { onDelete: 'set null' }),
288
- })
289
-
290
- // ── Inferred types ──────────────────────────────────────────────────────────
291
-
292
- export type Organization = typeof organizations.$inferSelect
293
- export type NewOrganization = typeof organizations.$inferInsert
294
- export type User = typeof users.$inferSelect
295
- export type NewUser = typeof users.$inferInsert
296
- export type UserOrganizationMembership = typeof userOrganizationMemberships.$inferSelect
297
- export type NewUserOrganizationMembership = typeof userOrganizationMemberships.$inferInsert
298
- export type AuditEntry = typeof auditLog.$inferSelect
299
- export type NewAuditEntry = typeof auditLog.$inferInsert
300
- export type OrgConnection = typeof orgConnections.$inferSelect
301
- export type NewOrgConnection = typeof orgConnections.$inferInsert
302
- export type CrossOrgLink = typeof crossOrgLinks.$inferSelect
303
- export type NewCrossOrgLink = typeof crossOrgLinks.$inferInsert
304
- export type BreakGlassSession = typeof breakGlassSessions.$inferSelect
305
- export type NewBreakGlassSession = typeof breakGlassSessions.$inferInsert
306
- export type ActAsSession = typeof actAsSessions.$inferSelect
307
- export type NewActAsSession = typeof actAsSessions.$inferInsert
308
- export type InstanceSettings = typeof instanceSettings.$inferSelect
309
- export type NewInstanceSettings = typeof instanceSettings.$inferInsert
310
- export type PlatformConfig = typeof platformConfig.$inferSelect
311
- export type NewPlatformConfig = typeof platformConfig.$inferInsert