@govcore/schema 0.1.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/migrate.d.ts +11 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +64 -0
- package/dist/schema.d.ts +2024 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +231 -0
- package/migrations/0000_platform_init.sql +97 -0
- package/migrations/0001_platform_security.sql +59 -0
- package/migrations/0002_platform_federation_support.sql +103 -0
- package/package.json +35 -0
- package/src/index.ts +21 -0
- package/src/migrate.ts +85 -0
- package/src/schema.ts +311 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
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
|