@12-apps/prisma 1.3.0 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/prisma",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Prisma host: the multi-file schema folder, the plugin migration seam, and the shared PrismaClient singleton with its audit / append-only extensions",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,7 +12,7 @@
12
12
  }
13
13
  },
14
14
  "scripts": {
15
- "build": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate && tsc",
15
+ "build": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-audit-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate && tsc",
16
16
  "clean": "rm -rf dist node_modules coverage",
17
17
  "test": "node ../../scripts/vitest-with-teardown.mjs run",
18
18
  "test:watch": "vitest watch",
@@ -20,7 +20,7 @@
20
20
  "lint": "pnpm run lint:files .",
21
21
  "lint:fix": "bash -c 'eslint \"${@:-.}\" --max-warnings 0 --fix' _",
22
22
  "typecheck": "tsc --noEmit",
23
- "prisma:generate": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate",
23
+ "prisma:generate": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-audit-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate",
24
24
  "prisma:migrate": "prisma migrate dev",
25
25
  "prisma:push": "prisma db push",
26
26
  "prisma:studio": "prisma studio",
@@ -39,7 +39,13 @@
39
39
  "prisma:sync-plugins": "node scripts/sync-prisma-plugins.mjs",
40
40
  "prisma:sync-plugins:check": "node scripts/sync-prisma-plugins.mjs --check",
41
41
  "prisma:sync-rbac": "node scripts/sync-rbac-schema.mjs",
42
- "prisma:sync-rbac:check": "node scripts/sync-rbac-schema.mjs --check"
42
+ "prisma:sync-rbac:check": "node scripts/sync-rbac-schema.mjs --check",
43
+ "prisma:sync-onboarding": "node scripts/sync-onboarding-schema.mjs",
44
+ "prisma:sync-onboarding:check": "node scripts/sync-onboarding-schema.mjs --check",
45
+ "prisma:sync-mcp": "node scripts/sync-mcp-schema.mjs",
46
+ "prisma:sync-mcp:check": "node scripts/sync-mcp-schema.mjs --check",
47
+ "prisma:sync-audit": "node scripts/sync-audit-schema.mjs",
48
+ "prisma:sync-audit:check": "node scripts/sync-audit-schema.mjs --check"
43
49
  },
44
50
  "dependencies": {
45
51
  "@electric-sql/pglite": "0.2.17",
@@ -49,13 +55,16 @@
49
55
  "pglite-prisma-adapter": "0.7.2"
50
56
  },
51
57
  "devDependencies": {
58
+ "@12-apps/audit": "^1.0.0",
52
59
  "@12-apps/entitlements": "^1.20.1",
53
60
  "@12-apps/entity-lifecycle": "^2.1.0",
54
61
  "@12-apps/eslint-config": "^1.20.0",
55
62
  "@12-apps/jobs": "^2.0.0",
63
+ "@12-apps/mcp": "^1.20.0",
64
+ "@12-apps/onboarding": "^1.20.0",
56
65
  "@12-apps/payments-backend": "^2.0.0",
57
66
  "@12-apps/product-research": "^2.0.0",
58
- "@12-apps/rbac": "^1.19.0",
67
+ "@12-apps/rbac": "^2.0.0",
59
68
  "@12-apps/report-builder": "^3.0.0",
60
69
  "@12-apps/shift": "^2.0.0",
61
70
  "@12-apps/typescript-config": "^1.20.0",
@@ -0,0 +1,152 @@
1
+ -- @12-apps/mcp (12-23): the three tables behind an MCP surface's OAuth 2.1
2
+ -- authorization server, owned by the package and copied into a host's migrations
3
+ -- folder by its plugin-migration sync.
4
+ --
5
+ -- * oauth_clients — a registered external host (a Claude.ai / ChatGPT
6
+ -- connector) from RFC 7591 dynamic client
7
+ -- registration or a static registration. NOT a
8
+ -- multi-tenant customer table — namespaced to avoid
9
+ -- that collision; no FK to users (clients are apps).
10
+ -- * oauth_refresh_tokens — a rotating refresh token bound to a user email +
11
+ -- client, stored HASHED (SHA-256), rotated on use
12
+ -- with `rotated_from` lineage and a `revoked_at`
13
+ -- revoke path.
14
+ -- * mcp_connections — which AI host a user has connected, and how
15
+ -- recently it was active. Per-USER, because an MCP
16
+ -- bearer is auth-passthrough and not tenant-scoped.
17
+ --
18
+ -- Authorization codes are deliberately absent: they are STATELESS signed blobs,
19
+ -- so there is no table to create and nothing to sweep.
20
+ --
21
+ -- The columns, defaults, indexes and CHECK are future-pay's
22
+ -- `20260713120000_add_oauth_client_refresh`,
23
+ -- `20260715180000_add_onboarding_state_mcp_connection` (the mcp_connections half
24
+ -- — the onboarding half belongs to @12-apps/onboarding) and
25
+ -- `20260720120000_add_mcp_connection_host` verbatim, minus the FK to `users`:
26
+ -- this package cannot know the name of a host's user table, and a host that has
27
+ -- one keeps its own constraint (future-pay's is ON DELETE CASCADE).
28
+ --
29
+ -- EVERY statement is guarded (`IF NOT EXISTS`, and a conrelid-scoped DO block for
30
+ -- the CHECK, which has no IF NOT EXISTS form). That is what makes adoption by a
31
+ -- host that ALREADY has these tables a no-op instead of a failed deploy — and
32
+ -- what lets the PGlite provisioner replay it into an existing schema.
33
+ --
34
+ -- Guarding every STATEMENT is not the same as guarding every COLUMN, though, and
35
+ -- the difference bites exactly the host this file is written for: `CREATE TABLE IF
36
+ -- NOT EXISTS` skips the whole table, columns included, so a host holding an OLDER
37
+ -- shape of one of these tables silently keeps it. Each table below is therefore
38
+ -- followed by a guarded `ADD COLUMN` for every column that reached future-pay in a
39
+ -- LATER migration than its own CREATE. The full audit: `oauth_refresh_tokens
40
+ -- .user_sub` (`20260713150000_add_oauth_refresh_user_sub`) and `mcp_connections
41
+ -- .host` (`20260720120000_add_mcp_connection_host`). `oauth_clients` needs none —
42
+ -- it arrived complete, CHECK and all, and was never altered afterwards. A column
43
+ -- added to this file later needs the same treatment.
44
+
45
+ -- Registered OAuth client (host app). Array columns are Postgres TEXT[]:
46
+ -- redirect_uris is the exact-match allowlist for open-redirect prevention;
47
+ -- grant_types / scopes are the DCR metadata.
48
+ CREATE TABLE IF NOT EXISTS "oauth_clients" (
49
+ "id" TEXT NOT NULL,
50
+ "client_id" TEXT NOT NULL,
51
+ "client_secret_hash" TEXT,
52
+ "redirect_uris" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
53
+ "client_name" TEXT,
54
+ "token_endpoint_auth_method" TEXT NOT NULL DEFAULT 'none',
55
+ "grant_types" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
56
+ "scopes" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
57
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
58
+ "updated_at" TIMESTAMP(3) NOT NULL,
59
+
60
+ CONSTRAINT "oauth_clients_pkey" PRIMARY KEY ("id")
61
+ );
62
+
63
+ -- Public identifier a token/authorize request presents; unique across clients.
64
+ CREATE UNIQUE INDEX IF NOT EXISTS "oauth_clients_client_id_key"
65
+ ON "oauth_clients"("client_id");
66
+
67
+ -- token_endpoint_auth_method domain guard (String+CHECK house style). Scoped to
68
+ -- conrelid because constraint names are unique only per table.
69
+ DO $$
70
+ BEGIN
71
+ IF NOT EXISTS (
72
+ SELECT 1 FROM pg_constraint
73
+ WHERE conname = 'oauth_clients_token_endpoint_auth_method_valid'
74
+ AND conrelid = 'oauth_clients'::regclass
75
+ ) THEN
76
+ ALTER TABLE "oauth_clients"
77
+ ADD CONSTRAINT "oauth_clients_token_endpoint_auth_method_valid"
78
+ CHECK ("token_endpoint_auth_method" IN ('none', 'client_secret_basic'));
79
+ END IF;
80
+ END $$;
81
+
82
+ -- Rotating refresh token. token_hash is the SHA-256 of the opaque token (never
83
+ -- plaintext); user_email is the bound identity; user_sub is the original OAuth
84
+ -- subject, kept stable across every rotation; client_id is a by-value link to
85
+ -- oauth_clients.client_id; rotated_from carries the prior token's hash for
86
+ -- rotation lineage / replay detection; revoked_at is the explicit revoke path.
87
+ CREATE TABLE IF NOT EXISTS "oauth_refresh_tokens" (
88
+ "id" TEXT NOT NULL,
89
+ "token_hash" TEXT NOT NULL,
90
+ "user_email" TEXT NOT NULL,
91
+ "user_sub" TEXT NOT NULL,
92
+ "client_id" TEXT NOT NULL,
93
+ "scopes" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
94
+ "expires_at" TIMESTAMP(3) NOT NULL,
95
+ "rotated_from" TEXT,
96
+ "revoked_at" TIMESTAMP(3),
97
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
98
+
99
+ CONSTRAINT "oauth_refresh_tokens_pkey" PRIMARY KEY ("id")
100
+ );
101
+
102
+ -- Lookup-on-presentation: hash the incoming token, find its row. Unique so a
103
+ -- duplicate insert is a DB-level error rather than a silent second live token.
104
+ CREATE UNIQUE INDEX IF NOT EXISTS "oauth_refresh_tokens_token_hash_key"
105
+ ON "oauth_refresh_tokens"("token_hash");
106
+
107
+ -- Per-user/per-client enumeration + bulk revoke (the lineage walk's input, and
108
+ -- the disconnect path).
109
+ CREATE INDEX IF NOT EXISTS "oauth_refresh_tokens_user_email_client_id_idx"
110
+ ON "oauth_refresh_tokens"("user_email", "client_id");
111
+
112
+ -- `CREATE TABLE IF NOT EXISTS` skips the WHOLE table, so a host that already holds
113
+ -- `oauth_refresh_tokens` in an OLDER SHAPE gets none of the columns declared above
114
+ -- — statement-level guarding is not the same as column-level guarding. That is
115
+ -- precisely how future-pay's own history ran: `user_sub` arrived in a SECOND
116
+ -- migration (FUT-105, `20260713150000_add_oauth_refresh_user_sub`), so a host
117
+ -- frozen before it would adopt this file, skip the CREATE, never get the column,
118
+ -- and then fail on every refresh the package serves. Mirror future-pay's pair
119
+ -- verbatim — guarded add with a backfill default to satisfy NOT NULL, then drop
120
+ -- the default so the column matches the Prisma schema (`String`, no default).
121
+ -- Both statements are no-ops on a fresh host and on a replay.
122
+ ALTER TABLE "oauth_refresh_tokens"
123
+ ADD COLUMN IF NOT EXISTS "user_sub" TEXT NOT NULL DEFAULT '';
124
+ ALTER TABLE "oauth_refresh_tokens"
125
+ ALTER COLUMN "user_sub" DROP DEFAULT;
126
+
127
+ -- A user's live AI connections. `host` is nullable: a connection can exist before
128
+ -- any provider attribution is derivable (a CLI callback with no public domain).
129
+ CREATE TABLE IF NOT EXISTS "mcp_connections" (
130
+ "id" TEXT NOT NULL,
131
+ "user_id" TEXT NOT NULL,
132
+ "oauth_client_id" TEXT NOT NULL,
133
+ "client_name" TEXT,
134
+ "host" TEXT,
135
+ "connected_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
136
+ "last_active_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
137
+ "revoked_at" TIMESTAMP(3),
138
+
139
+ CONSTRAINT "mcp_connections_pkey" PRIMARY KEY ("id")
140
+ );
141
+
142
+ -- One connection row per (user, OAuth client) — the upsert key for liveness.
143
+ CREATE UNIQUE INDEX IF NOT EXISTS "mcp_connections_user_id_oauth_client_id_key"
144
+ ON "mcp_connections"("user_id", "oauth_client_id");
145
+ CREATE INDEX IF NOT EXISTS "mcp_connections_user_id_idx" ON "mcp_connections"("user_id");
146
+ CREATE INDEX IF NOT EXISTS "mcp_connections_last_active_at_idx"
147
+ ON "mcp_connections"("last_active_at");
148
+
149
+ -- A host adopting this migration where `mcp_connections` predates the `host`
150
+ -- column (future-pay added it in a later migration) gets it here; a fresh host
151
+ -- already has it from the CREATE above, so the guard makes both cases a no-op.
152
+ ALTER TABLE "mcp_connections" ADD COLUMN IF NOT EXISTS "host" TEXT;
@@ -0,0 +1,61 @@
1
+ -- @12-apps/onboarding (12-23): the guided-onboarding progress table, owned by
2
+ -- the package and copied into a host's migrations folder by its
3
+ -- plugin-migration sync.
4
+ --
5
+ -- The columns, defaults, indexes and the status CHECK are future-pay's
6
+ -- `20260715180000_add_onboarding_state_mcp_connection` verbatim, minus two
7
+ -- things that are the HOST's vocabulary rather than the package's:
8
+ --
9
+ -- * the FKs to `users` / `clients` — this package cannot know the name of a
10
+ -- host's user or tenant table. A host that has them keeps its own
11
+ -- constraints (future-pay's are ON DELETE CASCADE) and they stay
12
+ -- compatible with everything the package writes;
13
+ -- * the `mcp_connections` half of that migration, which belongs to
14
+ -- @12-apps/mcp and ships in ITS folder.
15
+ --
16
+ -- EVERY statement is guarded (`IF NOT EXISTS`, and a conrelid-scoped DO block
17
+ -- for the CHECK, which has no IF NOT EXISTS form). That is what makes adoption
18
+ -- by a host that ALREADY has the table a no-op instead of a failed deploy —
19
+ -- future-pay applies this and nothing changes, no `prisma migrate resolve`
20
+ -- dance required. It is also what lets the PGlite provisioner replay it into an
21
+ -- existing schema, which is how the harness and the integration suites run.
22
+
23
+ CREATE TABLE IF NOT EXISTS "onboarding_states" (
24
+ "id" TEXT NOT NULL,
25
+ "user_id" TEXT NOT NULL,
26
+ "client_id" TEXT NOT NULL,
27
+ "feature_key" TEXT NOT NULL,
28
+ "status" TEXT NOT NULL DEFAULT 'not_started',
29
+ "step" TEXT,
30
+ "data" JSONB NOT NULL DEFAULT '{}',
31
+ "started_at" TIMESTAMP(3),
32
+ "completed_at" TIMESTAMP(3),
33
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
34
+ "updated_at" TIMESTAMP(3) NOT NULL,
35
+
36
+ CONSTRAINT "onboarding_states_pkey" PRIMARY KEY ("id")
37
+ );
38
+
39
+ -- One progress row per (user, tenant, feature) — the upsert key.
40
+ CREATE UNIQUE INDEX IF NOT EXISTS "onboarding_states_user_id_client_id_feature_key_key"
41
+ ON "onboarding_states"("user_id", "client_id", "feature_key");
42
+
43
+ -- The "who is mid-integration" reach-out query (per tenant, per feature).
44
+ CREATE INDEX IF NOT EXISTS "onboarding_states_client_id_feature_key_status_idx"
45
+ ON "onboarding_states"("client_id", "feature_key", "status");
46
+
47
+ -- DB-enforced status domain: the package's own `OnboardingStatus` union, so the
48
+ -- closed set is the package's contract rather than a host's preference. Scoped
49
+ -- to conrelid because constraint names are unique only per table.
50
+ DO $$
51
+ BEGIN
52
+ IF NOT EXISTS (
53
+ SELECT 1 FROM pg_constraint
54
+ WHERE conname = 'onboarding_states_status_valid'
55
+ AND conrelid = 'onboarding_states'::regclass
56
+ ) THEN
57
+ ALTER TABLE "onboarding_states"
58
+ ADD CONSTRAINT "onboarding_states_status_valid"
59
+ CHECK ("status" IN ('not_started', 'in_progress', 'completed', 'dismissed'));
60
+ END IF;
61
+ END $$;
@@ -0,0 +1,87 @@
1
+ -- @12-apps/audit (12-14): the append-only action audit log, owned by the
2
+ -- package and copied into a host's migrations folder by its plugin-migration
3
+ -- sync. Runs identically on PostgreSQL and PGlite.
4
+ --
5
+ -- Append-only is enforced at the CLIENT layer (update/upsert/delete on the
6
+ -- model throw — see src/server/append-only-extension.ts), not by a DB trigger,
7
+ -- matching the house pattern. The only sanctioned removal is the retention
8
+ -- sweep, which goes through raw SQL over the `created_at` index below. Raw SQL
9
+ -- therefore BYPASSES the guard entirely — that is the documented blind spot,
10
+ -- and it is what makes the sweep possible at all.
11
+ --
12
+ -- ── REPLAY-SAFE ON PURPOSE ────────────────────────────────────────────────
13
+ -- Every statement here is idempotent (`IF NOT EXISTS`), because the first host
14
+ -- to adopt this package already HAS an `audit_logs` table created by its own,
15
+ -- earlier migration. A package migration is applied by name order, so this one
16
+ -- sorts AFTER the host's — and a bare `CREATE TABLE` would then fail
17
+ -- `prisma migrate deploy` on an existing database AND on a fresh one built from
18
+ -- the full folder. `prisma migrate resolve --applied` can only paper over the
19
+ -- first case, by hand, once per database; three PRs in this series have already
20
+ -- hit that wall (one deferred adoption entirely, one had to teach the plugin
21
+ -- sync a name-keyed carve-out). So the migration adopts an existing table
22
+ -- instead of demanding a baseline:
23
+ --
24
+ -- * a fresh host gets the table, the column and the five indexes;
25
+ -- * a host that already has the table gets only what it is missing;
26
+ -- * replaying the whole folder is a no-op.
27
+ --
28
+ -- What it deliberately does NOT do is reconcile a DIFFERENT shape. If a host's
29
+ -- pre-existing `audit_logs` is missing a column this package writes (other than
30
+ -- `on_behalf_of_user_id`, handled below) or types one differently, that is a
31
+ -- real divergence and belongs in the host's own migration — silently altering
32
+ -- columns under an append-only trail is not something a package should do.
33
+
34
+ CREATE TABLE IF NOT EXISTS "audit_logs" (
35
+ "id" TEXT NOT NULL,
36
+ "client_id" TEXT NOT NULL,
37
+ "actor_user_id" TEXT,
38
+ "actor_role" TEXT,
39
+ "scope" TEXT,
40
+ "on_behalf_of_user_id" TEXT,
41
+ "action" TEXT NOT NULL,
42
+ "resource_type" TEXT NOT NULL,
43
+ "resource_id" TEXT NOT NULL,
44
+ "before" JSONB NOT NULL DEFAULT '{}',
45
+ "after" JSONB NOT NULL DEFAULT '{}',
46
+ "request_id" TEXT,
47
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
48
+
49
+ CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
50
+ );
51
+
52
+ -- The impersonation half of the attribution pair, as a separate ADD COLUMN and
53
+ -- not only as a column of the CREATE above: a host that adopted an audit table
54
+ -- before impersonation existed has the table but not the column, and the
55
+ -- CREATE is a no-op for it. Nullable, no default, no backfill — NULL is the
56
+ -- honest and permanent value for every write made outside a session, and this
57
+ -- table is append-only, so the value is written at INSERT or never.
58
+ -- ADD COLUMN of a nullable column with no default is metadata-only in
59
+ -- PostgreSQL (no rewrite, no scan), so it is safe on a large trail.
60
+ ALTER TABLE "audit_logs" ADD COLUMN IF NOT EXISTS "on_behalf_of_user_id" TEXT;
61
+
62
+ -- The viewer's tenant-scoped filters: date (the default newest-first listing),
63
+ -- actor, resource.
64
+ CREATE INDEX IF NOT EXISTS "audit_logs_client_id_created_at_idx"
65
+ ON "audit_logs"("client_id", "created_at");
66
+ CREATE INDEX IF NOT EXISTS "audit_logs_client_id_actor_user_id_created_at_idx"
67
+ ON "audit_logs"("client_id", "actor_user_id", "created_at");
68
+ CREATE INDEX IF NOT EXISTS "audit_logs_client_id_resource_type_resource_id_idx"
69
+ ON "audit_logs"("client_id", "resource_type", "resource_id");
70
+ -- Action-pinned reads: the equality columns first, then the ORDER BY column.
71
+ CREATE INDEX IF NOT EXISTS "audit_logs_client_id_action_resource_type_created_at_idx"
72
+ ON "audit_logs"("client_id", "action", "resource_type", "created_at");
73
+ -- Serves the retention sweep.
74
+ CREATE INDEX IF NOT EXISTS "audit_logs_created_at_idx" ON "audit_logs"("created_at");
75
+
76
+ -- ⚠️ NOT CONCURRENTLY, and on a large existing trail that is a LOCK to plan
77
+ -- for rather than a footnote: `prisma migrate deploy` wraps each migration in a
78
+ -- transaction and `CREATE INDEX CONCURRENTLY` cannot run inside one, so each
79
+ -- build above takes a SHARE lock on `audit_logs` — and the writer inserts into
80
+ -- that table INSIDE every audited transaction, so those transactions block for
81
+ -- as long as the build takes.
82
+ --
83
+ -- RUNBOOK for a large deployment: build them out of band FIRST, against the
84
+ -- live database, before releasing — `CREATE INDEX CONCURRENTLY IF NOT EXISTS
85
+ -- <name> ON "audit_logs"(…)` with the names above — and every statement here
86
+ -- then finds its index present and is a no-op. On a fresh or small database,
87
+ -- just let the migration build them.
@@ -26,6 +26,9 @@
26
26
  "20260810180000_add_report_working_copy",
27
27
  "20260812120000_add_rbac_tables",
28
28
  "20260812120000_add_retention_watermarks",
29
+ "20260812150000_add_mcp_oauth_tables",
30
+ "20260812150000_add_onboarding_states",
31
+ "20260813120000_add_audit_log",
29
32
  "20260813120000_add_entity_lifecycle_tables"
30
33
  ]
31
34
  }
@@ -0,0 +1,68 @@
1
+ // @12-apps/audit (12-14) — the append-only action audit log, OWNED by the
2
+ // package. A host adopts it with `node scripts/sync-audit-schema.mjs`, which
3
+ // COPIES this file into the host's schema folder (never a symlink — Prisma
4
+ // lstats a migrations folder, so a symlinked migration is silently skipped,
5
+ // and `turbo prune` drops a partial whose owner is not a declared dependency).
6
+ //
7
+ // Deliberately NO foreign key into any host table (the payments/rbac doctrine):
8
+ // `client_id`, `actor_user_id` and `on_behalf_of_user_id` are by-value scalars,
9
+ // so the partial applies to a host whose tenant table is called anything at
10
+ // all. A host that wants referential integrity adds the constraint in its own
11
+ // migration — see ADOPTING.md.
12
+ //
13
+ // `action` and `resource_type` carry no CHECK constraint: the vocabulary is
14
+ // HOST CONFIG (`AuditVocabulary`), validated in TypeScript at the write site,
15
+ // so a newly instrumented feature adds an action without a migration.
16
+
17
+ model AuditLog {
18
+ id String @id @default(uuid())
19
+ clientId String @map("client_id")
20
+
21
+ /// The real human whose credentials authorized the write. NULL = a system
22
+ /// write (a provider webhook, a job). NEVER the identity an impersonated
23
+ /// session was rendering as — that is `onBehalfOfUserId`, below.
24
+ actorUserId String? @map("actor_user_id")
25
+ /// The role the request was AUTHORIZED under, and the scope the decision was
26
+ /// made in. Both describe the authorization the NAMED ACTOR used, so both are
27
+ /// NULL on a system write.
28
+ actorRole String? @map("actor_role")
29
+ scope String?
30
+
31
+ /// The identity the actor was ACTING AS (impersonation / "view as"), never
32
+ /// the actor themselves. Stored ALONGSIDE `actorUserId` rather than instead
33
+ /// of it: the trail has to answer "who really did this" and "who did the
34
+ /// screen claim to be" INDEPENDENTLY, and one column cannot. Conflating them
35
+ /// is unrecoverable after the fact — an entry that reads as though the
36
+ /// customer placed the order cannot later be told apart from one they really
37
+ /// placed, and this table is append-only, so nothing can correct it.
38
+ /// NULL for every ordinary write, which is why no backfill is possible.
39
+ onBehalfOfUserId String? @map("on_behalf_of_user_id")
40
+
41
+ action String
42
+ resourceType String @map("resource_type")
43
+ resourceId String @map("resource_id")
44
+
45
+ /// Allowlist-redacted state around the mutation: only the fields the
46
+ /// vocabulary names for this `resourceType` survive, and only flat JSON
47
+ /// scalars. `{}` for a pure create (no `before`).
48
+ before Json @default("{}")
49
+ after Json @default("{}")
50
+
51
+ requestId String? @map("request_id")
52
+ createdAt DateTime @default(now()) @map("created_at")
53
+
54
+ // The viewer's filters, always tenant-scoped: date (the default listing,
55
+ // newest first), actor, and resource.
56
+ @@index([clientId, createdAt])
57
+ @@index([clientId, actorUserId, createdAt])
58
+ @@index([clientId, resourceType, resourceId])
59
+ // Action-pinned reads (an `action_in` pill, a host's own reconciliation
60
+ // list): the three equality columns, then the sort column, so one index
61
+ // serves the page AND its count with no sort step. A rare action is the case
62
+ // that needs it — without the index the filtered scan reads the whole tenant
63
+ // partition to return an empty page, and pays it twice (findMany + count).
64
+ @@index([clientId, action, resourceType, createdAt])
65
+ // Serves the retention sweep ("entries older than N days").
66
+ @@index([createdAt])
67
+ @@map("audit_logs")
68
+ }
@@ -0,0 +1,108 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @12-apps/mcp — CANONICAL Prisma model partial (plug-and-play).
3
+ //
4
+ // The three tables behind an MCP surface's authorization server. A host project
5
+ // does NOT copy these models into its main schema by hand: it uses Prisma's
6
+ // multi-file schema folder and SYNCS this file into it with this package's
7
+ // `prisma:sync` script (a byte-for-byte COPY — never a symlink; see
8
+ // scripts/sync-mcp-schema.mjs for why). The migration ships alongside, in
9
+ // prisma/migrations/, and is copied into the host's migrations folder by the
10
+ // host's plugin-migration sync.
11
+ //
12
+ // Host-agnostic by design (the entity-lifecycle / rbac doctrine): `user_id` is a
13
+ // by-value scalar with NO relation, because this package cannot know the name of
14
+ // the host's user model. The host's own migration may add the FK (future-pay's
15
+ // is ON DELETE CASCADE). Note there is deliberately no `oauth_codes` table:
16
+ // authorization codes are STATELESS signed blobs, so there is nothing to store
17
+ // and nothing to sweep.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ // A registered external OAuth host — a Claude.ai / ChatGPT connector produced by
21
+ // RFC 7591 dynamic client registration, or a static registration an operator
22
+ // created out of band. This is the OAuth *client application*, NOT a
23
+ // multi-tenant customer: namespaced `OAuth*` to avoid that collision, and with
24
+ // no relation to any user (clients are apps, not people).
25
+ //
26
+ // `clientId` is the public, unique identifier; `clientSecretHash` is null for
27
+ // public PKCE clients (which authenticate via `token_endpoint_auth_method =
28
+ // "none"`) and holds a SHA-256 hash for confidential ones — the plaintext secret
29
+ // is returned exactly once at registration and never persisted. `redirectUris`
30
+ // is the EXACT-MATCH allowlist the authorization endpoint validates against for
31
+ // open-redirect prevention. Array columns are Postgres `TEXT[]`.
32
+ model OAuthClient {
33
+ id String @id @default(uuid())
34
+ clientId String @unique @map("client_id")
35
+ clientSecretHash String? @map("client_secret_hash")
36
+ redirectUris String[] @map("redirect_uris")
37
+ clientName String? @map("client_name")
38
+ // "none" (public PKCE) | "client_secret_basic" (confidential). String + CHECK
39
+ // (the house convention), not a Prisma enum; validated at the register route.
40
+ tokenEndpointAuthMethod String @default("none") @map("token_endpoint_auth_method")
41
+ grantTypes String[] @map("grant_types")
42
+ scopes String[]
43
+ createdAt DateTime @default(now()) @map("created_at")
44
+ updatedAt DateTime @updatedAt @map("updated_at")
45
+
46
+ @@map("oauth_clients")
47
+ }
48
+
49
+ // A rotating OAuth refresh token bound to a signed-in user + a registered
50
+ // client. Stored HASHED (`tokenHash` = SHA-256 of the opaque token, never
51
+ // plaintext) and rotated on every use. Identity is the user's EMAIL: the AS binds
52
+ // to email and a host's guards resolve their user row by it, so this is a scalar
53
+ // rather than a FK and the token store stays decoupled from the user table.
54
+ // `userSub` is the original OAuth subject, carried so a rotated successor mints
55
+ // an access token with the SAME stable `sub` as the initial one (RFC 6749 §5.1 /
56
+ // OIDC §2) — without it a refreshed token's `sub` would fall back to the email
57
+ // and diverge, breaking identity correlation after the first refresh.
58
+ // `clientId` is a by-value link to `OAuthClient.clientId`. Rotation lineage:
59
+ // `rotatedFrom` holds the prior token's hash so reuse of a rotated token is
60
+ // detectable and revokes the whole lineage; `revokedAt` is the explicit revoke
61
+ // path.
62
+ model OAuthRefreshToken {
63
+ id String @id @default(uuid())
64
+ tokenHash String @unique @map("token_hash")
65
+ userEmail String @map("user_email")
66
+ userSub String @map("user_sub")
67
+ clientId String @map("client_id")
68
+ scopes String[]
69
+ expiresAt DateTime @map("expires_at")
70
+ rotatedFrom String? @map("rotated_from")
71
+ revokedAt DateTime? @map("revoked_at")
72
+ createdAt DateTime @default(now()) @map("created_at")
73
+
74
+ // `tokenHash` is already indexed by its `@unique` constraint — the
75
+ // lookup-on-presentation path — so no separate `@@index([tokenHash])` is added
76
+ // (it would be redundant). The composite index serves per-user/per-client
77
+ // enumeration and bulk revoke.
78
+ @@index([userEmail, clientId])
79
+ @@map("oauth_refresh_tokens")
80
+ }
81
+
82
+ // A live AI-assistant (MCP) connection a user has authorized. MCP auth is
83
+ // auth-passthrough / per-user (a token is not tenant-scoped), so this is keyed by
84
+ // the host's user id + the registered OAuth client — NOT by tenant. `clientName`
85
+ // snapshots the client's display name for the UI (a registration can change it).
86
+ // `lastActiveAt` is bumped (throttled, best-effort) on each token grant so a
87
+ // config page can show "connected via Claude · active 2 min ago" and an operator
88
+ // panel can tell an active connection from a stalled one. `host` is the AI
89
+ // provider the connection is attributed to (claude / chatgpt / codex), derived
90
+ // from the client's redirect URIs at grant time and confirmable by a self-report
91
+ // tool; null for a pre-attribution connection. `revokedAt` is the explicit
92
+ // disconnect path — and ending the connection is only half of a disconnect, since
93
+ // a host holding a live refresh token would simply rotate its way back in.
94
+ model McpConnection {
95
+ id String @id @default(uuid())
96
+ userId String @map("user_id")
97
+ oauthClientId String @map("oauth_client_id")
98
+ clientName String? @map("client_name")
99
+ host String? @map("host")
100
+ connectedAt DateTime @default(now()) @map("connected_at")
101
+ lastActiveAt DateTime @default(now()) @map("last_active_at")
102
+ revokedAt DateTime? @map("revoked_at")
103
+
104
+ @@unique([userId, oauthClientId])
105
+ @@index([userId])
106
+ @@index([lastActiveAt])
107
+ @@map("mcp_connections")
108
+ }
@@ -0,0 +1,46 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @12-apps/onboarding — CANONICAL Prisma model partial (plug-and-play).
3
+ //
4
+ // This file is the single source of truth for the guided-onboarding progress
5
+ // table. A host project does NOT copy the model into its main schema by hand:
6
+ // it uses Prisma's multi-file schema folder and SYNCS this file into it with
7
+ // this package's `prisma:sync` script (a byte-for-byte COPY — never a symlink;
8
+ // see scripts/sync-onboarding-schema.mjs for why). The migration ships
9
+ // alongside, in prisma/migrations/, and is copied into the host's migrations
10
+ // folder by the host's plugin-migration sync.
11
+ //
12
+ // Host-agnostic by design (the entity-lifecycle / report-builder doctrine):
13
+ // - `user_id` and `client_id` are by-value scalars, NOT relations — this
14
+ // package cannot know the name of the host's user or tenant model. The
15
+ // host's own migration may add the FK constraints (future-pay has both,
16
+ // ON DELETE CASCADE).
17
+ // - `status` is a String with a DB CHECK rather than a Prisma enum (the
18
+ // house String+CHECK pattern); the four values are the package's own
19
+ // `OnboardingStatus` union, so the closed set IS the package's contract.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ // Reusable guided-onboarding progress for ANY feature, one row per
23
+ // (user, tenant, featureKey). `featureKey` namespaces the flow
24
+ // ("ai_integration" | "payments" | ...) so a single table backs every guided
25
+ // setup. `step` is the resume point (which step the user stopped on); `data` is
26
+ // an opaque per-feature JSON payload (e.g. the selected AI host) so features
27
+ // evolve their own state with no schema change. Scoped to the host's DB user id
28
+ // and the tenant. Indexed on (client, feature, status) for the "who is
29
+ // mid-integration" reach-out query.
30
+ model OnboardingState {
31
+ id String @id @default(uuid())
32
+ userId String @map("user_id")
33
+ clientId String @map("client_id")
34
+ featureKey String @map("feature_key")
35
+ status String @default("not_started")
36
+ step String?
37
+ data Json @default("{}")
38
+ startedAt DateTime? @map("started_at")
39
+ completedAt DateTime? @map("completed_at")
40
+ createdAt DateTime @default(now()) @map("created_at")
41
+ updatedAt DateTime @updatedAt @map("updated_at")
42
+
43
+ @@unique([userId, clientId, featureKey])
44
+ @@index([clientId, featureKey, status])
45
+ @@map("onboarding_states")
46
+ }