@checkstack/auth-backend 0.4.33 → 0.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.
@@ -36,6 +36,13 @@
36
36
  "when": 1768390244988,
37
37
  "tag": "0004_lucky_power_man",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "7",
43
+ "when": 1780358648193,
44
+ "tag": "0005_ambitious_oracle",
45
+ "breakpoints": true
39
46
  }
40
47
  ]
41
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/auth-backend",
3
- "version": "0.4.33",
3
+ "version": "0.5.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -16,24 +16,26 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@checkstack/auth-common": "0.7.2",
19
- "@checkstack/backend-api": "0.18.0",
19
+ "@checkstack/backend-api": "0.20.0",
20
20
  "@checkstack/notification-common": "1.2.1",
21
- "@checkstack/command-backend": "0.1.31",
22
- "better-auth": "^1.4.7",
21
+ "@checkstack/command-backend": "0.1.33",
22
+ "better-auth": "^1.6.13",
23
23
  "drizzle-orm": "^0.45.0",
24
- "hono": "^4.12.14",
24
+ "hono": "^4.12.23",
25
25
  "kysely": "^0.28.17",
26
- "jose": "^6.1.3",
26
+ "jose": "^6.2.3",
27
27
  "zod": "^4.2.1",
28
28
  "@checkstack/common": "0.12.0",
29
- "@orpc/server": "^1.13.2"
29
+ "@orpc/server": "^1.14.4"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@checkstack/drizzle-helper": "0.0.5",
33
33
  "@checkstack/scripts": "0.3.4",
34
34
  "@checkstack/tsconfig": "0.0.7",
35
35
  "@types/node": "^20.0.0",
36
+ "@types/pg": "^8.20.0",
36
37
  "drizzle-kit": "^0.31.10",
38
+ "pg": "^8.21.0",
37
39
  "typescript": "^5.0.0"
38
40
  }
39
41
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * DCR rate-limit shared-Postgres conformance (matrix #10).
3
+ *
4
+ * The DCR endpoint throttle MUST hold across pods, because an in-memory per-pod
5
+ * limiter would let N pods each allow the cap = N x the intended limit. This
6
+ * test simulates TWO pods (two independent pools to the SAME schema) hammering
7
+ * the same key and asserts the combined count respects the single shared cap.
8
+ *
9
+ * Gated behind `CHECKSTACK_IT=1`; connection from `CHECKSTACK_IT_PG_URL`. Runs
10
+ * in a freshly created, self-cleaning schema.
11
+ */
12
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
13
+ import { drizzle } from "drizzle-orm/node-postgres";
14
+ import { sql } from "drizzle-orm";
15
+ import { Pool } from "pg";
16
+ import type { SafeDatabase } from "@checkstack/backend-api";
17
+ import * as schema from "./schema";
18
+ import { checkRateLimit } from "./rate-limit";
19
+
20
+ const PG_URL =
21
+ process.env.CHECKSTACK_IT_PG_URL ??
22
+ "postgres://postgres:postgres@localhost:5432/postgres";
23
+
24
+ const SCHEMA = `it_dcr_ratelimit_${crypto.randomUUID().replace(/-/g, "")}`;
25
+
26
+ interface Pod {
27
+ pool: Pool;
28
+ db: SafeDatabase<typeof schema>;
29
+ end(): Promise<void>;
30
+ }
31
+
32
+ function makePod(): Pod {
33
+ const pool = new Pool({
34
+ connectionString: PG_URL,
35
+ options: `-c search_path=${SCHEMA}`,
36
+ });
37
+ const db = drizzle(pool, { schema }) as unknown as SafeDatabase<typeof schema>;
38
+ return { pool, db, end: () => pool.end() };
39
+ }
40
+
41
+ describe.skipIf(!process.env.CHECKSTACK_IT)("DCR rate-limit (shared Postgres)", () => {
42
+ let admin: Pool;
43
+ let podA: Pod;
44
+ let podB: Pod;
45
+
46
+ beforeAll(async () => {
47
+ admin = new Pool({ connectionString: PG_URL });
48
+ await admin.query(`CREATE SCHEMA "${SCHEMA}"`);
49
+ await admin.query(
50
+ `CREATE TABLE "${SCHEMA}".ai_rate_limit (
51
+ key text NOT NULL,
52
+ window_start timestamp NOT NULL,
53
+ count integer NOT NULL DEFAULT 0,
54
+ PRIMARY KEY (key, window_start)
55
+ )`,
56
+ );
57
+ podA = makePod();
58
+ podB = makePod();
59
+ });
60
+
61
+ afterAll(async () => {
62
+ await podA?.end();
63
+ await podB?.end();
64
+ await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
65
+ await admin.end();
66
+ });
67
+
68
+ it("enforces ONE shared cap across two pods (no N x cap leak)", async () => {
69
+ const key = `dcr:203.0.113.5`;
70
+ const max = 5;
71
+ const windowSeconds = 3600;
72
+ const now = new Date("2026-06-02T12:00:00.000Z");
73
+
74
+ // Alternate pods, sharing the same window. After `max` calls the limit is
75
+ // hit regardless of WHICH pod served the call.
76
+ const results = [];
77
+ for (let i = 0; i < 8; i++) {
78
+ const pod = i % 2 === 0 ? podA : podB;
79
+ results.push(
80
+ await checkRateLimit({ db: pod.db, key, max, windowSeconds, now }),
81
+ );
82
+ }
83
+
84
+ const allowedCount = results.filter((r) => r.allowed).length;
85
+ // Exactly `max` allowed across BOTH pods — not `max` per pod.
86
+ expect(allowedCount).toBe(max);
87
+ expect(results[results.length - 1].allowed).toBe(false);
88
+ expect(results[results.length - 1].count).toBe(8);
89
+ });
90
+
91
+ it("resets in the next window", async () => {
92
+ const key = `dcr:198.51.100.9`;
93
+ const first = await checkRateLimit({
94
+ db: podA.db,
95
+ key,
96
+ max: 1,
97
+ windowSeconds: 60,
98
+ now: new Date("2026-06-02T12:00:30.000Z"),
99
+ });
100
+ expect(first.allowed).toBe(true);
101
+ const sameWindow = await checkRateLimit({
102
+ db: podB.db,
103
+ key,
104
+ max: 1,
105
+ windowSeconds: 60,
106
+ now: new Date("2026-06-02T12:00:45.000Z"),
107
+ });
108
+ expect(sameWindow.allowed).toBe(false);
109
+ const nextWindow = await checkRateLimit({
110
+ db: podA.db,
111
+ key,
112
+ max: 1,
113
+ windowSeconds: 60,
114
+ now: new Date("2026-06-02T12:01:05.000Z"),
115
+ });
116
+ expect(nextWindow.allowed).toBe(true); // fresh window, counter reset
117
+ });
118
+
119
+ it("verifies the counter is visible from a second pod (durable, not pod-local)", async () => {
120
+ const key = `dcr:192.0.2.1`;
121
+ const now = new Date("2026-06-02T13:00:00.000Z");
122
+ await checkRateLimit({ db: podA.db, key, max: 10, windowSeconds: 3600, now });
123
+ // Pod B reads the SAME counter row written by pod A.
124
+ const windowStart = new Date("2026-06-02T13:00:00.000Z");
125
+ const rows = await podB.db
126
+ .select({ count: schema.aiRateLimit.count })
127
+ .from(schema.aiRateLimit)
128
+ .where(
129
+ sql`${schema.aiRateLimit.key} = ${key} AND ${schema.aiRateLimit.windowStart} = ${windowStart}`,
130
+ );
131
+ expect(rows[0]?.count).toBe(1);
132
+ });
133
+ });
package/src/index.ts CHANGED
@@ -3,12 +3,14 @@ import * as socialProviderFactories from "better-auth/social-providers";
3
3
  import { drizzleAdapter } from "better-auth/adapters/drizzle";
4
4
  import { APIError, createAuthEndpoint } from "better-auth/api";
5
5
  import { setSessionCookie } from "better-auth/cookies";
6
+ import { mcp } from "better-auth/plugins";
6
7
  import { z } from "zod";
7
8
  import {
8
9
  createBackendPlugin,
9
10
  coreServices,
10
11
  coreHooks,
11
12
  authenticationStrategyServiceRef,
13
+ assertMigrationChainFromV1,
12
14
  type AuthStrategy,
13
15
  } from "@checkstack/backend-api";
14
16
  import {
@@ -20,12 +22,12 @@ import {
20
22
  } from "@checkstack/auth-common";
21
23
  import { NotificationApi } from "@checkstack/notification-common";
22
24
  import * as schema from "./schema";
23
- import { eq, inArray } from "drizzle-orm";
25
+ import { eq } from "drizzle-orm";
24
26
  import { SafeDatabase } from "@checkstack/backend-api";
25
27
  import { BetterAuthOptions, User } from "better-auth/types";
26
28
  import { verifyPassword } from "better-auth/crypto";
27
29
  import { createExtensionPoint } from "@checkstack/backend-api";
28
- import { enrichUser } from "./utils/user";
30
+ import { enrichUser, enrichApplicationPrincipal } from "./utils/user";
29
31
  import { ADMIN_ROLE_ID, createAuthRouter } from "./router";
30
32
  import { validateStrategySchema } from "./utils/validate-schema";
31
33
  import {
@@ -37,9 +39,29 @@ import {
37
39
  PLATFORM_REGISTRATION_CONFIG_VERSION,
38
40
  PLATFORM_REGISTRATION_CONFIG_ID,
39
41
  } from "./platform-registration-config";
42
+ import {
43
+ mcpOAuthConfigV1,
44
+ MCP_OAUTH_CONFIG_VERSION,
45
+ MCP_OAUTH_CONFIG_ID,
46
+ } from "./mcp-oauth-config";
47
+ import {
48
+ narrowedPrincipalFromSession,
49
+ introspectOpaqueToken,
50
+ opaqueBearerToken,
51
+ } from "./oauth-branch";
52
+ import { checkRateLimit } from "./rate-limit";
40
53
  import { registerSearchProvider } from "@checkstack/command-backend";
41
54
  import { resolveRoute, extractErrorMessage} from "@checkstack/common";
42
55
 
56
+ /** Best-effort client IP for the DCR rate-limit key (proxy headers first). */
57
+ function clientIpOf(req: Request): string {
58
+ return (
59
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
60
+ req.headers.get("x-real-ip") ||
61
+ "unknown"
62
+ );
63
+ }
64
+
43
65
  export interface BetterAuthExtensionPoint {
44
66
  addStrategy(strategy: AuthStrategy<unknown>): void;
45
67
  }
@@ -316,6 +338,19 @@ export default createBackendPlugin({
316
338
  // Validate that the strategy schema doesn't have required fields without defaults
317
339
  try {
318
340
  validateStrategySchema(s.configSchema, s.id);
341
+ // Fail fast at registration (boot) if the strategy's
342
+ // v1->configVersion migration chain is incomplete or broken.
343
+ // Auth's read path migrates-then-validates via
344
+ // `configService.get(id, schema, configVersion, migrations)`, so a
345
+ // missing covering migration would otherwise only surface LAZILY
346
+ // on the first stale read. This guard surfaces it at boot for
347
+ // every registered strategy exactly once — `addStrategy` is the
348
+ // single canonical registration chokepoint (all read sites in
349
+ // index.ts/router.ts only consume the already-registered list).
350
+ assertMigrationChainFromV1({
351
+ version: s.configVersion,
352
+ migrations: s.migrations ?? [],
353
+ });
319
354
  } catch (error) {
320
355
  const message =
321
356
  extractErrorMessage(error);
@@ -388,48 +423,22 @@ export default createBackendPlugin({
388
423
  // Ignore errors from lastUsedAt update
389
424
  });
390
425
 
391
- // Fetch roles and compute access rules for the application
392
- const appRoles = await db
393
- .select({ roleId: schema.applicationRole.roleId })
394
- .from(schema.applicationRole)
395
- .where(
396
- eq(schema.applicationRole.applicationId, applicationId),
397
- );
398
-
399
- const roleIds = appRoles.map((r) => r.roleId);
400
-
401
- // Get access rules for these roles
402
- let accessRulesArray: string[] = [];
403
- if (roleIds.length > 0) {
404
- const rolePerms = await db
405
- .select({
406
- accessRuleId: schema.roleAccessRule.accessRuleId,
407
- })
408
- .from(schema.roleAccessRule)
409
- .where(inArray(schema.roleAccessRule.roleId, roleIds));
410
-
411
- accessRulesArray = [
412
- ...new Set(rolePerms.map((rp) => rp.accessRuleId)),
413
- ];
414
- }
415
-
416
- // Get team memberships for this application
417
- const appTeams = await db
418
- .select({ teamId: schema.applicationTeam.teamId })
419
- .from(schema.applicationTeam)
420
- .where(
421
- eq(schema.applicationTeam.applicationId, applicationId),
422
- );
423
- const teamIds = appTeams.map((t) => t.teamId);
426
+ // Resolve roles, access rules, and teams via the shared
427
+ // helper (same path as the app-principal token branch).
428
+ const enriched = await enrichApplicationPrincipal(
429
+ applicationId,
430
+ db,
431
+ );
432
+ if (!enriched) return;
424
433
 
425
434
  // Return ApplicationUser
426
435
  return {
427
436
  type: "application" as const,
428
- id: app.id,
429
- name: app.name,
430
- roles: roleIds,
431
- accessRules: accessRulesArray,
432
- teamIds,
437
+ id: enriched.id,
438
+ name: enriched.name,
439
+ roles: enriched.roles,
440
+ accessRules: enriched.accessRules,
441
+ teamIds: enriched.teamIds,
433
442
  };
434
443
  }
435
444
  }
@@ -438,6 +447,40 @@ export default createBackendPlugin({
438
447
  return; // Invalid API key
439
448
  }
440
449
 
450
+ // Bearer OAuth-access-token branch (AI platform MCP / OAuth AS).
451
+ //
452
+ // Tokens are OPAQUE (decision §11): introspect the token against the
453
+ // oidcProvider-owned token table, then build a principal whose access
454
+ // rules are the token's GRANTED scopes intersected with the bound
455
+ // user's LIVE access rules. Narrow-only, re-evaluated live every call.
456
+ // autoAuthMiddleware remains the single enforcement point; this branch
457
+ // only PRODUCES the narrowed principal. A miss falls through to session.
458
+ const opaqueToken = opaqueBearerToken(request);
459
+ if (opaqueToken) {
460
+ const session = await introspectOpaqueToken({
461
+ db,
462
+ token: opaqueToken,
463
+ });
464
+ if (session) {
465
+ const userRow = await db
466
+ .select()
467
+ .from(schema.user)
468
+ .where(eq(schema.user.id, session.userId))
469
+ .limit(1);
470
+ if (userRow.length > 0) {
471
+ const base = await enrichUser(userRow[0], db);
472
+ const catalogRows = await db
473
+ .select({ id: schema.accessRule.id })
474
+ .from(schema.accessRule);
475
+ const narrowed = narrowedPrincipalFromSession({
476
+ session,
477
+ principal: { base, catalog: catalogRows.map((r) => r.id) },
478
+ });
479
+ if (narrowed) return narrowed;
480
+ }
481
+ }
482
+ }
483
+
441
484
  // Fall back to session-based authentication (better-auth)
442
485
  if (!auth) {
443
486
  return; // Not initialized yet
@@ -628,6 +671,39 @@ export default createBackendPlugin({
628
671
  const registrationAllowed =
629
672
  platformRegistrationConfig?.allowRegistration ?? true;
630
673
 
674
+ // AI platform OAuth AS + MCP server settings (off by default).
675
+ const mcpOAuthConfig = await config.get(
676
+ MCP_OAUTH_CONFIG_ID,
677
+ mcpOAuthConfigV1,
678
+ MCP_OAUTH_CONFIG_VERSION,
679
+ );
680
+ const mcpEnabled = mcpOAuthConfig?.enabled ?? false;
681
+
682
+ // The OAuth AS + MCP plugin. Enabled only when an operator opts in.
683
+ //
684
+ // The `mcp` plugin internally instantiates `oidcProvider` from its
685
+ // `oidcConfig`, so we add ONLY `mcp` here (adding `oidcProvider`
686
+ // separately would double-register its endpoints). oidcProvider
687
+ // issues OPAQUE access tokens and owns the token / client / consent
688
+ // tables (added to the Drizzle schema). The DCR endpoint
689
+ // (`/mcp/register`) is gated by `allowDynamicClientRegistration`; the
690
+ // per-IP DCR rate-limit is a separate shared-Postgres counter
691
+ // enforced in the API route handler below.
692
+ const aiOAuthPlugins = mcpEnabled
693
+ ? [
694
+ mcp({
695
+ loginPage: "/auth/login",
696
+ resource: `${baseUrl}/api/ai/mcp`,
697
+ oidcConfig: {
698
+ loginPage: "/auth/login",
699
+ consentPage: "/auth/oauth-consent",
700
+ allowDynamicClientRegistration:
701
+ mcpOAuthConfig?.allowDynamicClientRegistration ?? false,
702
+ },
703
+ }),
704
+ ]
705
+ : [];
706
+
631
707
  logger.debug(
632
708
  `[auth-backend] Initializing Better Auth with ${
633
709
  Object.keys(socialProviders).length
@@ -716,7 +792,7 @@ export default createBackendPlugin({
716
792
  },
717
793
  },
718
794
  },
719
- plugins: [checkstackBridge],
795
+ plugins: [checkstackBridge, ...aiOAuthPlugins],
720
796
  };
721
797
 
722
798
  return betterAuth(authOptions);
@@ -808,8 +884,44 @@ export default createBackendPlugin({
808
884
  );
809
885
  rpc.registerRouter(authRouter, authContract);
810
886
 
811
- // 5. Register Better Auth native handler
812
- rpc.registerHttpHandler((req: Request) => auth!.handler(req));
887
+ // 5. Register Better Auth native handler.
888
+ //
889
+ // The Dynamic Client Registration endpoint (`/api/auth/mcp/register`)
890
+ // is throttled per client IP by a SHARED-POSTGRES fixed-window counter
891
+ // (state-and-scale §14.5 — never in-memory, so the cap holds across all
892
+ // pods) BEFORE delegating to better-auth. Every other auth route passes
893
+ // straight through.
894
+ rpc.registerHttpHandler(async (req: Request) => {
895
+ const url = new URL(req.url);
896
+ if (
897
+ req.method === "POST" &&
898
+ url.pathname.endsWith("/mcp/register")
899
+ ) {
900
+ const cfg = await config.get(
901
+ MCP_OAUTH_CONFIG_ID,
902
+ mcpOAuthConfigV1,
903
+ MCP_OAUTH_CONFIG_VERSION,
904
+ );
905
+ const ip = clientIpOf(req);
906
+ const result = await checkRateLimit({
907
+ db: database as SafeDatabase<typeof schema>,
908
+ key: `dcr:${ip}`,
909
+ max: cfg?.dcrRateLimitMax ?? 5,
910
+ windowSeconds: cfg?.dcrRateLimitWindowSeconds ?? 3600,
911
+ });
912
+ if (!result.allowed) {
913
+ return Response.json(
914
+ {
915
+ error: "rate_limit_exceeded",
916
+ error_description:
917
+ "Too many client registrations from this IP. Try again later.",
918
+ },
919
+ { status: 429 },
920
+ );
921
+ }
922
+ }
923
+ return auth!.handler(req);
924
+ });
813
925
 
814
926
  // All auth management endpoints are now via oRPC (see ./router.ts)
815
927
  // Note: Admin user seeding removed - handled via onboarding flow
@@ -943,3 +1055,30 @@ export * from "./utils/auth-error-redirect";
943
1055
 
944
1056
  // Re-export hooks for cross-plugin communication
945
1057
  export { authHooks } from "./hooks";
1058
+
1059
+ // AI platform OAuth AS surface: scope narrowing, the introspect-time branch,
1060
+ // and the shared-Postgres rate limiter (reused/tested by ai-backend + docs).
1061
+ export {
1062
+ narrowScopes,
1063
+ expandBundles,
1064
+ SCOPE_BUNDLE,
1065
+ type ScopeBundle,
1066
+ } from "./scope-narrowing";
1067
+ export {
1068
+ narrowedPrincipalFromSession,
1069
+ introspectOpaqueToken,
1070
+ opaqueBearerToken,
1071
+ type IntrospectedOAuthSession,
1072
+ type LivePrincipal,
1073
+ } from "./oauth-branch";
1074
+ export {
1075
+ checkRateLimit,
1076
+ windowStartFor,
1077
+ type RateLimitResult,
1078
+ } from "./rate-limit";
1079
+ export {
1080
+ mcpOAuthConfigV1,
1081
+ MCP_OAUTH_CONFIG_ID,
1082
+ MCP_OAUTH_CONFIG_VERSION,
1083
+ type McpOAuthConfig,
1084
+ } from "./mcp-oauth-config";
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Platform-level configuration for the AI platform OAuth Authorization Server
5
+ * and MCP server (Phase 2). Controls Dynamic Client Registration (DCR) and the
6
+ * per-IP DCR rate-limit. Token/consent/client state itself is owned by the
7
+ * better-auth `oidcProvider` tables; this is only the operator-facing toggle.
8
+ */
9
+ export const mcpOAuthConfigV1 = z.object({
10
+ /**
11
+ * Whether the OAuth Authorization Server + MCP server are enabled at all.
12
+ * Off by default so the AS surface only exists once an operator opts in.
13
+ */
14
+ enabled: z
15
+ .boolean()
16
+ .default(false)
17
+ .describe(
18
+ "When enabled, Checkstack acts as an OAuth Authorization Server and serves the MCP endpoint.",
19
+ ),
20
+ /**
21
+ * Whether Dynamic Client Registration (`POST /mcp/register`) is open. When
22
+ * false, MCP clients must be registered out-of-band by an admin.
23
+ */
24
+ allowDynamicClientRegistration: z
25
+ .boolean()
26
+ .default(false)
27
+ .describe(
28
+ "When enabled, MCP clients may self-register via Dynamic Client Registration.",
29
+ ),
30
+ /**
31
+ * Max DCR registrations allowed per client IP within the fixed window.
32
+ * Enforced by a shared-Postgres counter (never in-memory) so the cap holds
33
+ * across all pods.
34
+ */
35
+ dcrRateLimitMax: z
36
+ .number()
37
+ .int()
38
+ .positive()
39
+ .default(5)
40
+ .describe("Maximum DCR registrations per IP per window."),
41
+ /** DCR rate-limit window length, in seconds. */
42
+ dcrRateLimitWindowSeconds: z
43
+ .number()
44
+ .int()
45
+ .positive()
46
+ .default(3600)
47
+ .describe("DCR rate-limit fixed-window length in seconds."),
48
+ });
49
+
50
+ export type McpOAuthConfig = z.infer<typeof mcpOAuthConfigV1>;
51
+
52
+ export const MCP_OAUTH_CONFIG_VERSION = 1;
53
+ export const MCP_OAUTH_CONFIG_ID = "ai.mcp-oauth";
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Contract test: every auth strategy registered via the
3
+ * `betterAuthExtensionPoint.addStrategy` chokepoint MUST have a COMPLETE,
4
+ * contiguous migration chain from version 1 to its current `configVersion`.
5
+ *
6
+ * Auth strategies do NOT use `Versioned`; they expose a bespoke shape
7
+ * `{ configVersion, configSchema, migrations }` and the read path migrates-
8
+ * then-validates via `configService.get(id, schema, configVersion, migrations)`.
9
+ * A missing covering migration would therefore only surface LAZILY on the
10
+ * first stale read. The boot guard wired into `addStrategy` (see
11
+ * `index.ts`, `assertMigrationChainFromV1`) turns that into a registration-
12
+ * time (boot) failure, and this test pins the contract:
13
+ *
14
+ * - the structural guard accepts a representative strategy whose chain is
15
+ * complete (the happy path every concrete strategy must satisfy), and
16
+ * - the guard BITES on a deliberately-broken chain (so it can never be
17
+ * silently no-op'd).
18
+ *
19
+ * The concrete core strategies live in their own plugin packages
20
+ * (`auth-github-backend`, `auth-ldap-backend`, `auth-saml-backend`,
21
+ * `auth-credential-backend`) — auth-backend does not depend on them, so they
22
+ * are guarded at THEIR registration via the same shared `addStrategy` boot
23
+ * guard rather than re-enumerated here. This test owns the guard's contract;
24
+ * a pure STRUCTURAL check (no `migrate()` runs), so it carries zero per-
25
+ * strategy upkeep.
26
+ */
27
+ import { describe, expect, it } from "bun:test";
28
+ import { z } from "zod";
29
+ import {
30
+ assertMigrationChainFromV1,
31
+ type AuthStrategy,
32
+ type Migration,
33
+ } from "@checkstack/backend-api";
34
+
35
+ const passThrough = (fromVersion: number, toVersion: number): Migration => ({
36
+ fromVersion,
37
+ toVersion,
38
+ description: `pass-through ${fromVersion}->${toVersion}`,
39
+ migrate: (data) => data,
40
+ });
41
+
42
+ // Representative strategies mirroring the real shapes: a v1 no-migration
43
+ // strategy (like `credential`) and a v>1 strategy with a complete chain
44
+ // (like `ldap` at v3).
45
+ const v1Strategy: AuthStrategy<{ token: string }> = {
46
+ id: "fixture-v1",
47
+ displayName: "Fixture v1",
48
+ configVersion: 1,
49
+ configSchema: z.object({ token: z.string().default("") }),
50
+ requiresManualRegistration: true,
51
+ };
52
+
53
+ const v3Strategy: AuthStrategy<{ host: string }> = {
54
+ id: "fixture-v3",
55
+ displayName: "Fixture v3",
56
+ configVersion: 3,
57
+ configSchema: z.object({ host: z.string().default("") }),
58
+ requiresManualRegistration: false,
59
+ migrations: [passThrough(1, 2), passThrough(2, 3)],
60
+ };
61
+
62
+ describe("auth strategy migration-chain contract", () => {
63
+ it("the registration guard accepts strategies with a complete v1->version chain", () => {
64
+ for (const strategy of [v1Strategy, v3Strategy]) {
65
+ expect(
66
+ () =>
67
+ assertMigrationChainFromV1({
68
+ version: strategy.configVersion,
69
+ migrations: strategy.migrations ?? [],
70
+ }),
71
+ `Strategy "${strategy.id}" (configVersion ${strategy.configVersion}) should have a complete chain`,
72
+ ).not.toThrow();
73
+ }
74
+ });
75
+
76
+ it("the registration guard rejects a v>1 strategy missing its migrations", () => {
77
+ const broken: AuthStrategy<{ host: string }> = {
78
+ id: "fixture-broken-empty",
79
+ displayName: "Fixture broken",
80
+ configVersion: 3,
81
+ configSchema: z.object({ host: z.string().default("") }),
82
+ requiresManualRegistration: false,
83
+ };
84
+ expect(() =>
85
+ assertMigrationChainFromV1({
86
+ version: broken.configVersion,
87
+ migrations: broken.migrations ?? [],
88
+ }),
89
+ ).toThrow(/incomplete/);
90
+ });
91
+
92
+ it("the registration guard rejects a gapped chain", () => {
93
+ const gapped: AuthStrategy<{ host: string }> = {
94
+ id: "fixture-broken-gap",
95
+ displayName: "Fixture gapped",
96
+ configVersion: 3,
97
+ configSchema: z.object({ host: z.string().default("") }),
98
+ requiresManualRegistration: false,
99
+ migrations: [passThrough(1, 2)],
100
+ };
101
+ expect(() =>
102
+ assertMigrationChainFromV1({
103
+ version: gapped.configVersion,
104
+ migrations: gapped.migrations ?? [],
105
+ }),
106
+ ).toThrow(/incomplete: reaches version 2/);
107
+ });
108
+ });