@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 +14 -5
- package/prisma/migrations/20260812150000_add_mcp_oauth_tables/migration.sql +152 -0
- package/prisma/migrations/20260812150000_add_onboarding_states/migration.sql +61 -0
- package/prisma/migrations/20260813120000_add_audit_log/migration.sql +87 -0
- package/prisma/plugin-migrations.json +3 -0
- package/prisma/schema/audit.prisma +68 -0
- package/prisma/schema/mcp.prisma +108 -0
- package/prisma/schema/onboarding.prisma +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/prisma",
|
|
3
|
-
"version": "1.
|
|
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": "^
|
|
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
|
+
}
|