@12-apps/prisma 1.2.0 → 1.4.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 +12 -6
- 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_entity_lifecycle_tables/migration.sql +141 -0
- package/prisma/plugin-migrations.json +4 -1
- 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.4.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-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-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,11 @@
|
|
|
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"
|
|
43
47
|
},
|
|
44
48
|
"dependencies": {
|
|
45
49
|
"@electric-sql/pglite": "0.2.17",
|
|
@@ -50,12 +54,14 @@
|
|
|
50
54
|
},
|
|
51
55
|
"devDependencies": {
|
|
52
56
|
"@12-apps/entitlements": "^1.20.1",
|
|
53
|
-
"@12-apps/entity-lifecycle": "^2.
|
|
57
|
+
"@12-apps/entity-lifecycle": "^2.1.0",
|
|
54
58
|
"@12-apps/eslint-config": "^1.20.0",
|
|
55
59
|
"@12-apps/jobs": "^2.0.0",
|
|
60
|
+
"@12-apps/mcp": "^1.20.0",
|
|
61
|
+
"@12-apps/onboarding": "^1.20.0",
|
|
56
62
|
"@12-apps/payments-backend": "^2.0.0",
|
|
57
63
|
"@12-apps/product-research": "^2.0.0",
|
|
58
|
-
"@12-apps/rbac": "^1.
|
|
64
|
+
"@12-apps/rbac": "^1.20.0",
|
|
59
65
|
"@12-apps/report-builder": "^3.0.0",
|
|
60
66
|
"@12-apps/shift": "^2.0.0",
|
|
61
67
|
"@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,141 @@
|
|
|
1
|
+
-- @12-apps/entity-lifecycle (12-17): the four generic lifecycle tables, owned
|
|
2
|
+
-- by the package and copied into a host's migrations folder by its
|
|
3
|
+
-- plugin-migration sync. Deliberately NO foreign keys into host tables (the
|
|
4
|
+
-- payments-backend doctrine): `client_id`, `entity_id` and the actor ids are
|
|
5
|
+
-- by-value scalars, and the host's own migration may add FK constraints
|
|
6
|
+
-- (recommended: client_id ON DELETE CASCADE). The tree relation INTERNAL to
|
|
7
|
+
-- recycle_bin_entries IS constrained, with a cascade, so a removed root can
|
|
8
|
+
-- never leave orphan children. The CHECK constraints ARE here: kind/status/
|
|
9
|
+
-- action are the library's own closed sets, not host vocabulary.
|
|
10
|
+
--
|
|
11
|
+
-- Runs identically on PostgreSQL + PGlite.
|
|
12
|
+
|
|
13
|
+
-- ---------------------------------------------------------------------------
|
|
14
|
+
-- Version history. v1 (and any retention-compaction base) is a FULL snapshot;
|
|
15
|
+
-- other rows are DELTAS (changed top-level fields + removed field names). The
|
|
16
|
+
-- state at version N is rebuilt by replaying rows 1..N. Append-only.
|
|
17
|
+
-- ---------------------------------------------------------------------------
|
|
18
|
+
CREATE TABLE "entity_versions" (
|
|
19
|
+
"id" TEXT NOT NULL,
|
|
20
|
+
"client_id" TEXT NOT NULL,
|
|
21
|
+
"entity_type" TEXT NOT NULL,
|
|
22
|
+
"entity_id" TEXT NOT NULL,
|
|
23
|
+
"version" INTEGER NOT NULL,
|
|
24
|
+
"kind" TEXT NOT NULL,
|
|
25
|
+
"is_snapshot" BOOLEAN NOT NULL,
|
|
26
|
+
"data" JSONB NOT NULL,
|
|
27
|
+
"removed_fields" JSONB NOT NULL DEFAULT '[]',
|
|
28
|
+
"actor_id" TEXT,
|
|
29
|
+
"restored_from" INTEGER,
|
|
30
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
31
|
+
|
|
32
|
+
CONSTRAINT "entity_versions_pkey" PRIMARY KEY ("id")
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE UNIQUE INDEX "entity_versions_client_id_entity_type_entity_id_version_key"
|
|
36
|
+
ON "entity_versions"("client_id", "entity_type", "entity_id", "version");
|
|
37
|
+
CREATE INDEX "entity_versions_client_id_entity_type_entity_id_idx"
|
|
38
|
+
ON "entity_versions"("client_id", "entity_type", "entity_id");
|
|
39
|
+
-- Serves the retention sweep ("versions older than N days").
|
|
40
|
+
CREATE INDEX "entity_versions_created_at_idx" ON "entity_versions"("created_at");
|
|
41
|
+
|
|
42
|
+
ALTER TABLE "entity_versions" ADD CONSTRAINT "entity_versions_kind_check"
|
|
43
|
+
CHECK ("kind" IN ('CREATE', 'UPDATE', 'RESTORE'));
|
|
44
|
+
ALTER TABLE "entity_versions" ADD CONSTRAINT "entity_versions_version_positive"
|
|
45
|
+
CHECK ("version" >= 1);
|
|
46
|
+
|
|
47
|
+
-- ---------------------------------------------------------------------------
|
|
48
|
+
-- Recycle bin. A deletion records a TREE (root + one child per dependent
|
|
49
|
+
-- record, linked via parent_entry_id). Restore/purge keep the row as an audit
|
|
50
|
+
-- trail (status flip, never a delete).
|
|
51
|
+
-- ---------------------------------------------------------------------------
|
|
52
|
+
CREATE TABLE "recycle_bin_entries" (
|
|
53
|
+
"id" TEXT NOT NULL,
|
|
54
|
+
"client_id" TEXT NOT NULL,
|
|
55
|
+
"entity_type" TEXT NOT NULL,
|
|
56
|
+
"entity_id" TEXT NOT NULL,
|
|
57
|
+
"parent_entry_id" TEXT,
|
|
58
|
+
"label" TEXT NOT NULL,
|
|
59
|
+
"snapshot" JSONB NOT NULL,
|
|
60
|
+
"status" TEXT NOT NULL DEFAULT 'DELETED',
|
|
61
|
+
"deleted_by" TEXT,
|
|
62
|
+
"deleted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
63
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
64
|
+
|
|
65
|
+
CONSTRAINT "recycle_bin_entries_pkey" PRIMARY KEY ("id")
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
CREATE INDEX "recycle_bin_entries_client_id_status_deleted_at_idx"
|
|
69
|
+
ON "recycle_bin_entries"("client_id", "status", "deleted_at");
|
|
70
|
+
CREATE INDEX "recycle_bin_entries_client_id_entity_type_entity_id_idx"
|
|
71
|
+
ON "recycle_bin_entries"("client_id", "entity_type", "entity_id");
|
|
72
|
+
CREATE INDEX "recycle_bin_entries_parent_entry_id_idx"
|
|
73
|
+
ON "recycle_bin_entries"("parent_entry_id");
|
|
74
|
+
|
|
75
|
+
ALTER TABLE "recycle_bin_entries" ADD CONSTRAINT "recycle_bin_entries_status_check"
|
|
76
|
+
CHECK ("status" IN ('DELETED', 'RESTORED', 'PURGED'));
|
|
77
|
+
ALTER TABLE "recycle_bin_entries" ADD CONSTRAINT "recycle_bin_entries_parent_entry_id_fkey"
|
|
78
|
+
FOREIGN KEY ("parent_entry_id") REFERENCES "recycle_bin_entries"("id")
|
|
79
|
+
ON DELETE CASCADE ON UPDATE CASCADE;
|
|
80
|
+
|
|
81
|
+
-- ---------------------------------------------------------------------------
|
|
82
|
+
-- Drafts. entity_id NULL = draft of a brand-new item. At most one OPEN draft
|
|
83
|
+
-- per (tenant, type, entity) — a partial unique index, which Prisma cannot
|
|
84
|
+
-- express in the schema (hence raw SQL here).
|
|
85
|
+
-- ---------------------------------------------------------------------------
|
|
86
|
+
CREATE TABLE "entity_drafts" (
|
|
87
|
+
"id" TEXT NOT NULL,
|
|
88
|
+
"client_id" TEXT NOT NULL,
|
|
89
|
+
"entity_type" TEXT NOT NULL,
|
|
90
|
+
"entity_id" TEXT,
|
|
91
|
+
"data" JSONB NOT NULL,
|
|
92
|
+
"status" TEXT NOT NULL DEFAULT 'OPEN',
|
|
93
|
+
"created_by" TEXT,
|
|
94
|
+
"updated_by" TEXT,
|
|
95
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
96
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
97
|
+
|
|
98
|
+
CONSTRAINT "entity_drafts_pkey" PRIMARY KEY ("id")
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
CREATE INDEX "entity_drafts_client_id_entity_type_status_idx"
|
|
102
|
+
ON "entity_drafts"("client_id", "entity_type", "status");
|
|
103
|
+
CREATE INDEX "entity_drafts_client_id_entity_type_entity_id_idx"
|
|
104
|
+
ON "entity_drafts"("client_id", "entity_type", "entity_id");
|
|
105
|
+
CREATE UNIQUE INDEX "entity_drafts_open_entity_key"
|
|
106
|
+
ON "entity_drafts"("client_id", "entity_type", "entity_id")
|
|
107
|
+
WHERE "status" = 'OPEN' AND "entity_id" IS NOT NULL;
|
|
108
|
+
|
|
109
|
+
ALTER TABLE "entity_drafts" ADD CONSTRAINT "entity_drafts_status_check"
|
|
110
|
+
CHECK ("status" IN ('OPEN', 'PUBLISHED', 'DISCARDED'));
|
|
111
|
+
|
|
112
|
+
-- ---------------------------------------------------------------------------
|
|
113
|
+
-- Change approvals. Writes by non-approvers park here; deciders apply/reject.
|
|
114
|
+
-- Decided rows are kept as an audit trail.
|
|
115
|
+
-- ---------------------------------------------------------------------------
|
|
116
|
+
CREATE TABLE "change_requests" (
|
|
117
|
+
"id" TEXT NOT NULL,
|
|
118
|
+
"client_id" TEXT NOT NULL,
|
|
119
|
+
"entity_type" TEXT NOT NULL,
|
|
120
|
+
"entity_id" TEXT,
|
|
121
|
+
"action" TEXT NOT NULL,
|
|
122
|
+
"payload" JSONB NOT NULL,
|
|
123
|
+
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
|
124
|
+
"requested_by" TEXT,
|
|
125
|
+
"requested_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
126
|
+
"decided_by" TEXT,
|
|
127
|
+
"decided_at" TIMESTAMP(3),
|
|
128
|
+
"decision_note" TEXT,
|
|
129
|
+
|
|
130
|
+
CONSTRAINT "change_requests_pkey" PRIMARY KEY ("id")
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
CREATE INDEX "change_requests_client_id_status_requested_at_idx"
|
|
134
|
+
ON "change_requests"("client_id", "status", "requested_at");
|
|
135
|
+
CREATE INDEX "change_requests_client_id_entity_type_entity_id_idx"
|
|
136
|
+
ON "change_requests"("client_id", "entity_type", "entity_id");
|
|
137
|
+
|
|
138
|
+
ALTER TABLE "change_requests" ADD CONSTRAINT "change_requests_action_check"
|
|
139
|
+
CHECK ("action" IN ('CREATE', 'UPDATE', 'DELETE'));
|
|
140
|
+
ALTER TABLE "change_requests" ADD CONSTRAINT "change_requests_status_check"
|
|
141
|
+
CHECK ("status" IN ('PENDING', 'APPROVED', 'REJECTED'));
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
"20260810160000_report_default_range_month",
|
|
26
26
|
"20260810180000_add_report_working_copy",
|
|
27
27
|
"20260812120000_add_rbac_tables",
|
|
28
|
-
"20260812120000_add_retention_watermarks"
|
|
28
|
+
"20260812120000_add_retention_watermarks",
|
|
29
|
+
"20260812150000_add_mcp_oauth_tables",
|
|
30
|
+
"20260812150000_add_onboarding_states",
|
|
31
|
+
"20260813120000_add_entity_lifecycle_tables"
|
|
29
32
|
]
|
|
30
33
|
}
|
|
@@ -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
|
+
}
|