@12-apps/prisma 1.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/prisma",
3
- "version": "1.3.0",
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",
@@ -53,9 +57,11 @@
53
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.19.0",
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 $$;
@@ -26,6 +26,8 @@
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",
29
31
  "20260813120000_add_entity_lifecycle_tables"
30
32
  ]
31
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
+ }