@govcore/schema 0.1.0 → 0.2.1

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.
@@ -1 +0,0 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"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"}
package/dist/schema.js DELETED
@@ -1,231 +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
- import { boolean, integer, jsonb, pgSchema, text, timestamp, unique, uniqueIndex, uuid, } from 'drizzle-orm/pg-core';
11
- /** The Postgres schema every GovCore platform table belongs to. */
12
- export const govcore = pgSchema('govcore');
13
- /** Content/federation visibility. Used by the federation package (later phase). */
14
- export const visibility = govcore.enum('visibility', ['org', 'connections', 'instance']);
15
- /** Generic workflow status shared by federation connections and links. */
16
- export const federationStatus = govcore.enum('federation_status', ['pending', 'active', 'rejected']);
17
- // ── Tenancy root ────────────────────────────────────────────────────────────
18
- export const organizations = govcore.table('organizations', {
19
- id: uuid('id').primaryKey().defaultRandom(),
20
- name: text('name').notNull(),
21
- slug: text('slug').notNull(),
22
- /** App-extensible bag for org settings GovCore itself doesn't model. */
23
- metadata: jsonb('metadata').notNull().default({}),
24
- createdAt: timestamp('created_at').notNull().defaultNow(),
25
- updatedAt: timestamp('updated_at').notNull().defaultNow(),
26
- }, (t) => [uniqueIndex('organizations_slug_unique').on(t.slug)]);
27
- /**
28
- * The tenancy column contract (design §5): every tenant-scoped table — core's
29
- * and the app's own domain tables — carries `organization_id` referencing
30
- * `organizations`. RLS policies (see ../migrations/0001) key off this column.
31
- *
32
- * @example
33
- * export const permits = pgTable('permits', { ...orgScoped(organizations), title: text('title') })
34
- */
35
- export const orgScoped = (orgs) => ({
36
- organizationId: uuid('organization_id')
37
- .notNull()
38
- .references(() => orgs.id, { onDelete: 'cascade' }),
39
- });
40
- // ── Identity ────────────────────────────────────────────────────────────────
41
- export const users = govcore.table('users', {
42
- id: uuid('id').primaryKey().defaultRandom(),
43
- organizationId: uuid('organization_id')
44
- .notNull()
45
- .references(() => organizations.id, { onDelete: 'cascade' }),
46
- /** Last-selected active org, honored first by active-membership resolution. */
47
- lastActiveOrganizationId: uuid('last_active_organization_id').references(() => organizations.id, { onDelete: 'set null' }),
48
- name: text('name'),
49
- email: text('email').notNull(),
50
- emailVerified: timestamp('email_verified'),
51
- image: text('image'),
52
- passwordHash: text('password_hash'),
53
- /**
54
- * App-defined role (see @govcore/rbac `createRbac`) — stored as `text`, not
55
- * a fixed enum, so GovCore ships no role vocabulary of its own. Denormalized
56
- * cache of the active membership's role; nullable so the Auth.js adapter can
57
- * create a user before a membership is assigned.
58
- */
59
- role: text('role'),
60
- /** Platform-level role (e.g. instance/platform admin), separate from per-org role. */
61
- instanceRole: text('instance_role'),
62
- isActive: boolean('is_active').notNull().default(true),
63
- // Account-lockout + password-expiry state; the policy itself lives app-side.
64
- failedLoginAttempts: integer('failed_login_attempts').notNull().default(0),
65
- lockoutUntil: timestamp('lockout_until'),
66
- lastPasswordChangedAt: timestamp('last_password_changed_at').notNull().defaultNow(),
67
- createdAt: timestamp('created_at').notNull().defaultNow(),
68
- updatedAt: timestamp('updated_at').notNull().defaultNow(),
69
- },
70
- // One identity per email across all orgs — unambiguous auth/SSO lookups.
71
- (t) => [uniqueIndex('users_email_unique').on(t.email)]);
72
- // Auth.js (drizzle-adapter) tables. Not org-scoped; read during auth flows
73
- // before a tenant context exists, so they are intentionally outside RLS.
74
- export const accounts = govcore.table('accounts', {
75
- id: uuid('id').primaryKey().defaultRandom(),
76
- userId: uuid('user_id')
77
- .notNull()
78
- .references(() => users.id, { onDelete: 'cascade' }),
79
- type: text('type').notNull(),
80
- provider: text('provider').notNull(),
81
- providerAccountId: text('provider_account_id').notNull(),
82
- refreshToken: text('refresh_token'),
83
- accessToken: text('access_token'),
84
- expiresAt: timestamp('expires_at'),
85
- tokenType: text('token_type'),
86
- scope: text('scope'),
87
- idToken: text('id_token'),
88
- sessionState: text('session_state'),
89
- });
90
- export const sessions = govcore.table('sessions', {
91
- id: uuid('id').primaryKey().defaultRandom(),
92
- sessionToken: text('session_token').notNull().unique(),
93
- userId: uuid('user_id')
94
- .notNull()
95
- .references(() => users.id, { onDelete: 'cascade' }),
96
- expires: timestamp('expires').notNull(),
97
- });
98
- export const verificationTokens = govcore.table('verification_tokens', {
99
- identifier: text('identifier').notNull(),
100
- token: text('token').notNull(),
101
- expires: timestamp('expires').notNull(),
102
- });
103
- // ── Membership model (the heart of tenancy) ─────────────────────────────────
104
- export const userOrganizationMemberships = govcore.table('user_organization_memberships', {
105
- id: uuid('id').primaryKey().defaultRandom(),
106
- userId: uuid('user_id')
107
- .notNull()
108
- .references(() => users.id, { onDelete: 'cascade' }),
109
- organizationId: uuid('organization_id')
110
- .notNull()
111
- .references(() => organizations.id, { onDelete: 'cascade' }),
112
- /** App-defined role for this membership (text, not an enum — see `users.role`). */
113
- role: text('role').notNull(),
114
- /** Revocation soft-deactivates so the historical row survives for audit. */
115
- isActive: boolean('is_active').notNull().default(true),
116
- isPrimary: boolean('is_primary').notNull().default(false),
117
- createdAt: timestamp('created_at').notNull().defaultNow(),
118
- updatedAt: timestamp('updated_at').notNull().defaultNow(),
119
- }, (t) => [uniqueIndex('user_org_membership_unique').on(t.userId, t.organizationId)]);
120
- // ── Audit (append-only; immutability + RLS in ../migrations/0001) ───────────
121
- export const auditLog = govcore.table('audit_log', {
122
- id: uuid('id').primaryKey().defaultRandom(),
123
- organizationId: uuid('organization_id'), // historical UUID — no FK on purpose
124
- userId: uuid('user_id'), // historical UUID — no FK on purpose
125
- action: text('action').notNull(),
126
- entityType: text('entity_type').notNull(),
127
- entityId: uuid('entity_id'),
128
- before: jsonb('before'),
129
- after: jsonb('after'),
130
- metadata: jsonb('metadata'),
131
- createdAt: timestamp('created_at').notNull().defaultNow(),
132
- });
133
- // ── Federation (cross-organization connections and content links) ──────────
134
- /** Explicit bilateral connection between two organizations. */
135
- export const orgConnections = govcore.table('org_connections', {
136
- id: uuid('id').primaryKey().defaultRandom(),
137
- fromOrgId: uuid('from_org_id')
138
- .notNull()
139
- .references(() => organizations.id, { onDelete: 'cascade' }),
140
- toOrgId: uuid('to_org_id')
141
- .notNull()
142
- .references(() => organizations.id, { onDelete: 'cascade' }),
143
- status: federationStatus('status').notNull().default('pending'),
144
- createdBy: uuid('created_by').references(() => users.id),
145
- createdAt: timestamp('created_at').notNull().defaultNow(),
146
- updatedAt: timestamp('updated_at').notNull().defaultNow(),
147
- }, (t) => [unique('unique_org_connection').on(t.fromOrgId, t.toOrgId)]);
148
- /** Approved relationship between content items across orgs. No FK on entity ids
149
- * (they cross org boundaries); link semantics are app-defined (`link_type` text). */
150
- export const crossOrgLinks = govcore.table('cross_org_links', {
151
- id: uuid('id').primaryKey().defaultRandom(),
152
- sourceOrgId: uuid('source_org_id')
153
- .notNull()
154
- .references(() => organizations.id, { onDelete: 'cascade' }),
155
- sourceEntityType: text('source_entity_type').notNull(),
156
- sourceEntityId: uuid('source_entity_id').notNull(),
157
- targetOrgId: uuid('target_org_id')
158
- .notNull()
159
- .references(() => organizations.id, { onDelete: 'cascade' }),
160
- targetEntityType: text('target_entity_type').notNull(),
161
- targetEntityId: uuid('target_entity_id').notNull(),
162
- linkType: text('link_type').notNull(),
163
- status: federationStatus('status').notNull().default('pending'),
164
- rejectionReason: text('rejection_reason'),
165
- flaggedForReview: boolean('flagged_for_review').notNull().default(false),
166
- flagReason: text('flag_reason'),
167
- createdBy: uuid('created_by').references(() => users.id),
168
- createdAt: timestamp('created_at').notNull().defaultNow(),
169
- updatedAt: timestamp('updated_at').notNull().defaultNow(),
170
- });
171
- // ── Support access (break-glass + act-as) ──────────────────────────────────
172
- // Instance-operator constructs that deliberately cross the tenant boundary, so
173
- // they are NOT under the org-GUC RLS (the actor is an instance admin operating
174
- // across orgs). Authorization is enforced in @govcore/support (design §6.6).
175
- export const breakGlassSessions = govcore.table('break_glass_sessions', {
176
- id: uuid('id').primaryKey().defaultRandom(),
177
- instanceAdminId: uuid('instance_admin_id')
178
- .notNull()
179
- .references(() => users.id),
180
- targetOrgId: uuid('target_org_id')
181
- .notNull()
182
- .references(() => organizations.id),
183
- reason: text('reason').notNull(),
184
- grantedAt: timestamp('granted_at').notNull().defaultNow(),
185
- expiresAt: timestamp('expires_at').notNull(),
186
- requiresApproval: boolean('requires_approval').notNull().default(false),
187
- approvedAt: timestamp('approved_at'),
188
- approvedBy: uuid('approved_by').references(() => users.id),
189
- revokedAt: timestamp('revoked_at'),
190
- revokedBy: uuid('revoked_by').references(() => users.id),
191
- });
192
- export const actAsSessions = govcore.table('act_as_sessions', {
193
- id: uuid('id').primaryKey().defaultRandom(),
194
- breakGlassSessionId: uuid('break_glass_session_id')
195
- .notNull()
196
- .references(() => breakGlassSessions.id),
197
- instanceAdminId: uuid('instance_admin_id')
198
- .notNull()
199
- .references(() => users.id),
200
- targetOrgId: uuid('target_org_id')
201
- .notNull()
202
- .references(() => organizations.id),
203
- startedAt: timestamp('started_at').notNull().defaultNow(),
204
- expiresAt: timestamp('expires_at').notNull(),
205
- endedAt: timestamp('ended_at'),
206
- endReason: text('end_reason'),
207
- });
208
- export const ACT_AS_DEFAULT_TTL_MINUTES = 30;
209
- export const ACT_AS_END_REASONS = [
210
- 'admin_ended',
211
- 'expired',
212
- 'parent_revoked',
213
- 'parent_expired',
214
- ];
215
- // ── Instance / platform configuration (singletons; not org-scoped) ─────────
216
- export const instanceSettings = govcore.table('instance_settings', {
217
- id: uuid('id').primaryKey().defaultRandom(),
218
- disabledModules: jsonb('disabled_modules').$type().notNull().default({}),
219
- createdAt: timestamp('created_at').notNull().defaultNow(),
220
- updatedAt: timestamp('updated_at').notNull().defaultNow(),
221
- });
222
- export const platformConfig = govcore.table('platform_config', {
223
- id: text('id').primaryKey().default('singleton'),
224
- instanceName: text('instance_name').notNull().default('GovCore'),
225
- defaultTheme: text('default_theme').notNull().default('base'),
226
- allowLocalAuth: boolean('allow_local_auth').notNull().default(true),
227
- /** Stamped onto new orgs at provisioning time. Null means no tier. */
228
- defaultSupportTier: text('default_support_tier'),
229
- updatedAt: timestamp('updated_at').notNull().defaultNow(),
230
- updatedBy: uuid('updated_by').references(() => users.id, { onDelete: 'set null' }),
231
- });
package/src/index.ts DELETED
@@ -1,21 +0,0 @@
1
- // @govcore/schema — platform table definitions + schema version.
2
- // Edge-safe entrypoint (no DB client). The migrate runner is ./migrate.
3
-
4
- import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core'
5
-
6
- export * from './schema'
7
-
8
- /**
9
- * A Drizzle Postgres database **or** transaction handle. Functions in the
10
- * tenancy/audit packages accept this so callers inject their own client (the
11
- * app's `postgres-js` db, or a `tx` inside a transaction — which extends it).
12
- */
13
- export type GovcoreDb = PgDatabase<PgQueryResultHKT, any, any>
14
-
15
- /**
16
- * Bumped whenever the platform schema changes. Written to instance settings on
17
- * boot for observability ("this instance runs platform schema vN", design §5),
18
- * and stamped into backup archives (design §13.5). Not load-bearing for
19
- * correctness — the migrations are — but useful for diagnostics.
20
- */
21
- export const CORE_SCHEMA_VERSION = '0.0.0'
package/src/migrate.ts DELETED
@@ -1,85 +0,0 @@
1
- // @govcore/schema/migrate — the govcore-migrate runner.
2
- //
3
- // NOT edge-safe (uses node:fs + a postgres client). Applies the authored
4
- // platform migrations in ../migrations in lexical order, each in its own
5
- // transaction, tracked in `govcore.__govcore_migrations` — kept separate from
6
- // the app's own Drizzle journal so the two migration streams never overlap
7
- // (design §5).
8
- //
9
- // Runs as the OWNER / DDL role (design §13.2): set GOVCORE_MIGRATE_DATABASE_URL
10
- // to the owning role's connection string; falls back to DATABASE_URL. The app
11
- // runtime connects as a separate NON-OWNER role, which RLS binds.
12
-
13
- import { readdirSync, readFileSync } from 'node:fs'
14
- import { dirname, join } from 'node:path'
15
- import { fileURLToPath } from 'node:url'
16
- import postgres from 'postgres'
17
-
18
- const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
19
-
20
- export interface MigrateOptions {
21
- /** Connection string for the owner/DDL role. Defaults to env. */
22
- connectionString?: string
23
- /** Optional logger; defaults to console.log. */
24
- log?: (message: string) => void
25
- }
26
-
27
- /** Apply all pending platform migrations. Idempotent — already-applied files are skipped. */
28
- export async function migrate(options: MigrateOptions = {}): Promise<{ applied: string[] }> {
29
- const connectionString =
30
- options.connectionString ??
31
- process.env.GOVCORE_MIGRATE_DATABASE_URL ??
32
- process.env.DATABASE_URL
33
- if (!connectionString) {
34
- throw new Error(
35
- 'govcore-migrate: set GOVCORE_MIGRATE_DATABASE_URL (owner role) or DATABASE_URL',
36
- )
37
- }
38
- const log = options.log ?? ((m: string) => console.log(m))
39
- const sql = postgres(connectionString, { max: 1 })
40
- const applied: string[] = []
41
- try {
42
- await sql.unsafe('CREATE SCHEMA IF NOT EXISTS govcore')
43
- await sql.unsafe(
44
- `CREATE TABLE IF NOT EXISTS govcore.__govcore_migrations (
45
- name text PRIMARY KEY,
46
- applied_at timestamptz NOT NULL DEFAULT now()
47
- )`,
48
- )
49
-
50
- const done = new Set(
51
- (await sql`SELECT name FROM govcore.__govcore_migrations`).map((r) => r.name as string),
52
- )
53
- const files = readdirSync(MIGRATIONS_DIR)
54
- .filter((f) => f.endsWith('.sql'))
55
- .sort()
56
-
57
- for (const file of files) {
58
- if (done.has(file)) continue
59
- const ddl = readFileSync(join(MIGRATIONS_DIR, file), 'utf8')
60
- await sql.begin(async (tx) => {
61
- await tx.unsafe(ddl)
62
- await tx`INSERT INTO govcore.__govcore_migrations (name) VALUES (${file})`
63
- })
64
- applied.push(file)
65
- log(`govcore-migrate: applied ${file}`)
66
- }
67
-
68
- log(
69
- applied.length
70
- ? `govcore-migrate: applied ${applied.length} migration(s)`
71
- : 'govcore-migrate: already up to date',
72
- )
73
- return { applied }
74
- } finally {
75
- await sql.end()
76
- }
77
- }
78
-
79
- // Run when invoked as the `govcore-migrate` bin.
80
- if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
81
- migrate().catch((err) => {
82
- console.error(err)
83
- process.exit(1)
84
- })
85
- }
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