@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.
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { RealUser } from "@checkstack/backend-api";
3
+ import { narrowedPrincipalFromSession } from "./oauth-branch";
4
+
5
+ const CATALOG = [
6
+ "incident.incident.read",
7
+ "incident.incident.manage",
8
+ "anomaly.anomaly.read",
9
+ ];
10
+
11
+ function liveUser(accessRules: string[]): RealUser {
12
+ return {
13
+ type: "user",
14
+ id: "u1",
15
+ email: "u1@example.com",
16
+ name: "User One",
17
+ roles: ["users"],
18
+ accessRules,
19
+ teamIds: ["team-a", "team-b"],
20
+ };
21
+ }
22
+
23
+ describe("narrowedPrincipalFromSession (§6.3)", () => {
24
+ test("accessRules = granted ∩ liveRules (NOT the full live rules)", () => {
25
+ const principal = narrowedPrincipalFromSession({
26
+ session: {
27
+ userId: "u1",
28
+ scopes: "incident.incident.read incident.incident.manage",
29
+ },
30
+ principal: {
31
+ base: liveUser(["incident.incident.read", "anomaly.anomaly.read"]),
32
+ catalog: CATALOG,
33
+ },
34
+ });
35
+ // manage granted but not held -> dropped; anomaly held but not granted -> absent.
36
+ expect(principal?.accessRules).toEqual(["incident.incident.read"]);
37
+ });
38
+
39
+ test("teamIds are inherited verbatim from the live enrichment", () => {
40
+ const principal = narrowedPrincipalFromSession({
41
+ session: { userId: "u1", scopes: "incident.incident.read" },
42
+ principal: {
43
+ base: liveUser(["incident.incident.read"]),
44
+ catalog: CATALOG,
45
+ },
46
+ });
47
+ expect(principal?.teamIds).toEqual(["team-a", "team-b"]);
48
+ expect(principal?.type).toBe("user");
49
+ });
50
+
51
+ test("a rule the principal has since LOST is dropped (live narrowing)", () => {
52
+ const principal = narrowedPrincipalFromSession({
53
+ session: { userId: "u1", scopes: "incident.incident.manage" },
54
+ principal: {
55
+ // The live user no longer has manage.
56
+ base: liveUser(["incident.incident.read"]),
57
+ catalog: CATALOG,
58
+ },
59
+ });
60
+ expect(principal?.accessRules).toEqual([]);
61
+ });
62
+
63
+ test("a token with no granted scopes is treated as unauthenticated", () => {
64
+ const principal = narrowedPrincipalFromSession({
65
+ session: { userId: "u1", scopes: "" },
66
+ principal: { base: liveUser(["incident.incident.read"]), catalog: CATALOG },
67
+ });
68
+ expect(principal).toBeUndefined();
69
+ });
70
+
71
+ test("admin token carries the concrete granted set, never the bare wildcard", () => {
72
+ const principal = narrowedPrincipalFromSession({
73
+ session: { userId: "u1", scopes: "checkstack:write" },
74
+ principal: { base: liveUser(["*"]), catalog: CATALOG },
75
+ });
76
+ expect(principal?.accessRules?.sort()).toEqual([...CATALOG].sort());
77
+ expect(principal?.accessRules).not.toContain("*");
78
+ });
79
+ });
@@ -0,0 +1,99 @@
1
+ import { eq } from "drizzle-orm";
2
+ import type { RealUser, SafeDatabase } from "@checkstack/backend-api";
3
+ import { narrowScopes } from "./scope-narrowing";
4
+ import * as schema from "./schema";
5
+
6
+ // Re-export the shared opaque-bearer extractor so existing import sites in this
7
+ // plugin (index.ts) keep working without changing their import path.
8
+ export { opaqueBearerToken } from "@checkstack/backend-api";
9
+
10
+ /**
11
+ * The introspected OAuth session — the subset of better-auth's
12
+ * `OAuthAccessToken` row this branch needs. Tokens are OPAQUE (decision §11):
13
+ * this is the result of a DB introspection, not a decoded JWT.
14
+ */
15
+ export interface IntrospectedOAuthSession {
16
+ /** The bound principal's user id (`OAuthAccessToken.userId`). */
17
+ userId: string;
18
+ /** Space-delimited granted scopes (`OAuthAccessToken.scopes`). */
19
+ scopes: string;
20
+ }
21
+
22
+ /**
23
+ * Introspect an OPAQUE OAuth access token against the oidcProvider-owned
24
+ * `oauthAccessToken` table (decision §11 — tokens are not JWTs, so this is a DB
25
+ * lookup, the same `findOne` the mcp plugin's `getMcpSession` performs, plus an
26
+ * explicit expiry check which `getMcpSession` does NOT do). Returns the session
27
+ * subset, or `undefined` when the token is unknown or expired.
28
+ */
29
+ export async function introspectOpaqueToken({
30
+ db,
31
+ token,
32
+ now = new Date(),
33
+ }: {
34
+ db: SafeDatabase<typeof schema>;
35
+ token: string;
36
+ now?: Date;
37
+ }): Promise<IntrospectedOAuthSession | undefined> {
38
+ const rows = await db
39
+ .select({
40
+ userId: schema.oauthAccessToken.userId,
41
+ scopes: schema.oauthAccessToken.scopes,
42
+ expiresAt: schema.oauthAccessToken.accessTokenExpiresAt,
43
+ })
44
+ .from(schema.oauthAccessToken)
45
+ .where(eq(schema.oauthAccessToken.accessToken, token))
46
+ .limit(1);
47
+
48
+ const row = rows[0];
49
+ if (!row || !row.userId || !row.scopes) return undefined;
50
+ if (row.expiresAt && new Date(row.expiresAt).getTime() <= now.getTime()) {
51
+ return undefined; // expired
52
+ }
53
+ return { userId: row.userId, scopes: row.scopes };
54
+ }
55
+
56
+ /**
57
+ * The principal's LIVE rules + teams, resolved by the same machinery the UI
58
+ * uses (`enrichUser`). Passed in so this module stays pure and DB-agnostic.
59
+ */
60
+ export interface LivePrincipal {
61
+ base: RealUser;
62
+ /** All currently-registered qualified access-rule IDs (for bundle/admin expansion). */
63
+ catalog: string[];
64
+ }
65
+
66
+ /**
67
+ * Build a NARROWED principal from an introspected opaque OAuth token.
68
+ *
69
+ * The crux (decision 4, §6.3): enrich the bound user to their CURRENT full
70
+ * access rules, then intersect with the token's granted scopes. Narrowing runs
71
+ * live on every call, so a rule the principal has since lost is dropped on the
72
+ * next call — strictly stronger than freezing claims into a JWT at mint.
73
+ *
74
+ * Returns `undefined` when the token has no granted scopes (it can do nothing,
75
+ * so it is treated as unauthenticated rather than as a zero-rule principal).
76
+ */
77
+ export function narrowedPrincipalFromSession({
78
+ session,
79
+ principal,
80
+ }: {
81
+ session: IntrospectedOAuthSession;
82
+ principal: LivePrincipal;
83
+ }): RealUser | undefined {
84
+ const granted = session.scopes.split(" ").filter(Boolean);
85
+ if (granted.length === 0) return undefined;
86
+
87
+ const narrowedRules = narrowScopes({
88
+ requested: granted,
89
+ principalRules: principal.base.accessRules ?? [],
90
+ catalog: principal.catalog,
91
+ });
92
+
93
+ // Narrow-only: accessRules is replaced by the (subset) narrowed set; teamIds
94
+ // are inherited verbatim from the live enrichment.
95
+ return {
96
+ ...principal.base,
97
+ accessRules: narrowedRules,
98
+ };
99
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { windowStartFor } from "./rate-limit";
3
+
4
+ describe("windowStartFor", () => {
5
+ test("floors a timestamp to the start of its fixed window", () => {
6
+ const now = new Date("2026-06-02T10:17:42.000Z");
7
+ const start = windowStartFor({ now, windowSeconds: 3600 });
8
+ expect(start.toISOString()).toBe("2026-06-02T10:00:00.000Z");
9
+ });
10
+
11
+ test("two timestamps in the same window share a window start (one counter)", () => {
12
+ const a = windowStartFor({
13
+ now: new Date("2026-06-02T10:00:01.000Z"),
14
+ windowSeconds: 3600,
15
+ });
16
+ const b = windowStartFor({
17
+ now: new Date("2026-06-02T10:59:59.000Z"),
18
+ windowSeconds: 3600,
19
+ });
20
+ expect(a.getTime()).toBe(b.getTime());
21
+ });
22
+
23
+ test("the next window gets a distinct start (counter resets)", () => {
24
+ const a = windowStartFor({
25
+ now: new Date("2026-06-02T10:59:59.000Z"),
26
+ windowSeconds: 3600,
27
+ });
28
+ const b = windowStartFor({
29
+ now: new Date("2026-06-02T11:00:00.000Z"),
30
+ windowSeconds: 3600,
31
+ });
32
+ expect(b.getTime()).toBeGreaterThan(a.getTime());
33
+ });
34
+ });
@@ -0,0 +1,73 @@
1
+ import { sql } from "drizzle-orm";
2
+ import type { SafeDatabase } from "@checkstack/backend-api";
3
+ import * as schema from "./schema";
4
+
5
+ /**
6
+ * Shared-Postgres fixed-window rate limiter (state-and-scale §14.5).
7
+ *
8
+ * The counter lives in the `ai_rate_limit` table, so the cap holds across ALL
9
+ * pods — an in-memory per-pod limiter would let N pods each allow the cap, i.e.
10
+ * N x the intended limit, which a single-process test would never catch. This
11
+ * is LOCKED: the limiter MUST be Postgres-backed, never in-memory.
12
+ *
13
+ * Each call atomically bumps the counter for `(key, windowStart)` and returns
14
+ * the post-increment count, so the increment + read is a single round-trip with
15
+ * no read-modify-write race between pods.
16
+ */
17
+
18
+ /** Floor a timestamp to the start of its fixed window. */
19
+ export function windowStartFor({
20
+ now,
21
+ windowSeconds,
22
+ }: {
23
+ now: Date;
24
+ windowSeconds: number;
25
+ }): Date {
26
+ const windowMs = windowSeconds * 1000;
27
+ return new Date(Math.floor(now.getTime() / windowMs) * windowMs);
28
+ }
29
+
30
+ export interface RateLimitResult {
31
+ /** Count AFTER this call's increment. */
32
+ count: number;
33
+ /** Whether this call is within the limit (`count <= max`). */
34
+ allowed: boolean;
35
+ /** The window this call was counted against. */
36
+ windowStart: Date;
37
+ }
38
+
39
+ /**
40
+ * Atomically increment and check a fixed-window counter.
41
+ *
42
+ * Returns `{ allowed: false }` when the post-increment count exceeds `max`.
43
+ * Callers decide the response (e.g. 429 for the DCR endpoint).
44
+ */
45
+ export async function checkRateLimit({
46
+ db,
47
+ key,
48
+ max,
49
+ windowSeconds,
50
+ now = new Date(),
51
+ }: {
52
+ db: SafeDatabase<typeof schema>;
53
+ key: string;
54
+ max: number;
55
+ windowSeconds: number;
56
+ now?: Date;
57
+ }): Promise<RateLimitResult> {
58
+ const windowStart = windowStartFor({ now, windowSeconds });
59
+
60
+ // INSERT ... ON CONFLICT (key, window_start) DO UPDATE SET count = count + 1
61
+ // RETURNING count. Single atomic statement: no read-modify-write race.
62
+ const rows = await db
63
+ .insert(schema.aiRateLimit)
64
+ .values({ key, windowStart, count: 1 })
65
+ .onConflictDoUpdate({
66
+ target: [schema.aiRateLimit.key, schema.aiRateLimit.windowStart],
67
+ set: { count: sql`${schema.aiRateLimit.count} + 1` },
68
+ })
69
+ .returning({ count: schema.aiRateLimit.count });
70
+
71
+ const count = rows[0]?.count ?? 1;
72
+ return { count, allowed: count <= max, windowStart };
73
+ }
package/src/router.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  passwordSchema,
15
15
  authAccess,
16
16
  pluginMetadata,
17
+ isApplicationBindable,
17
18
  } from "@checkstack/auth-common";
18
19
  import { qualifyAccessRuleId } from "@checkstack/common";
19
20
  import { hashPassword } from "better-auth/crypto";
@@ -21,6 +22,7 @@ import * as schema from "./schema";
21
22
  import { eq, inArray, and } from "drizzle-orm";
22
23
  import type { SafeDatabase } from "@checkstack/backend-api";
23
24
  import { authHooks } from "./hooks";
25
+ import { enrichApplicationPrincipal as resolveApplicationPrincipal } from "./utils/user";
24
26
 
25
27
  /**
26
28
  * Type guard to check if user is a RealUser (not a service).
@@ -37,6 +39,11 @@ 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";
40
47
 
41
48
  export const ADMIN_ROLE_ID = "admin";
42
49
  export const USERS_ROLE_ID = "users";
@@ -663,6 +670,41 @@ export const createAuthRouter = (
663
670
  },
664
671
  );
665
672
 
673
+ const getMcpOAuthSettings = os.getMcpOAuthSettings.handler(async () => {
674
+ const cfg = await configService.get(
675
+ MCP_OAUTH_CONFIG_ID,
676
+ mcpOAuthConfigV1,
677
+ MCP_OAUTH_CONFIG_VERSION,
678
+ );
679
+ // Defaults mirror the schema (off by default).
680
+ return {
681
+ enabled: cfg?.enabled ?? false,
682
+ allowDynamicClientRegistration:
683
+ cfg?.allowDynamicClientRegistration ?? false,
684
+ dcrRateLimitMax: cfg?.dcrRateLimitMax ?? 5,
685
+ dcrRateLimitWindowSeconds: cfg?.dcrRateLimitWindowSeconds ?? 3600,
686
+ };
687
+ });
688
+
689
+ const setMcpOAuthSettings = os.setMcpOAuthSettings.handler(
690
+ async ({ input }) => {
691
+ await configService.set(
692
+ MCP_OAUTH_CONFIG_ID,
693
+ mcpOAuthConfigV1,
694
+ MCP_OAUTH_CONFIG_VERSION,
695
+ {
696
+ enabled: input.enabled,
697
+ allowDynamicClientRegistration: input.allowDynamicClientRegistration,
698
+ dcrRateLimitMax: input.dcrRateLimitMax,
699
+ dcrRateLimitWindowSeconds: input.dcrRateLimitWindowSeconds,
700
+ },
701
+ );
702
+ // Enabling/disabling the plugins requires re-initializing better-auth.
703
+ await reloadAuthFn();
704
+ return { success: true };
705
+ },
706
+ );
707
+
666
708
  // ==========================================================================
667
709
  // ONBOARDING ENDPOINTS
668
710
  // ==========================================================================
@@ -1441,6 +1483,62 @@ export const createAuthRouter = (
1441
1483
  },
1442
1484
  );
1443
1485
 
1486
+ // S2S: resolve an application principal live for the app-principal token path.
1487
+ const enrichApplicationPrincipal =
1488
+ os.enrichApplicationPrincipal.handler(async ({ input }) => {
1489
+ const enriched = await resolveApplicationPrincipal(
1490
+ input.applicationId,
1491
+ internalDb,
1492
+ );
1493
+ return enriched ?? null;
1494
+ });
1495
+
1496
+ // List applications the caller may bind as an automation's service account.
1497
+ // An app is bindable only when its access rules are a subset of the caller's
1498
+ // (no privilege escalation); `*`-holders may bind anything.
1499
+ const getBindableApplications = os.getBindableApplications.handler(
1500
+ async ({ context }) => {
1501
+ const callerRules = isRealUser(context.user)
1502
+ ? (context.user.accessRules ?? [])
1503
+ : [];
1504
+
1505
+ const apps = await internalDb.select().from(schema.application);
1506
+ const bindable: {
1507
+ id: string;
1508
+ name: string;
1509
+ description: string | null;
1510
+ }[] = [];
1511
+
1512
+ const callerIsAdmin = callerRules.includes("*");
1513
+ for (const app of apps) {
1514
+ // Admins bind anything without resolving rules; others need the
1515
+ // per-app subset check.
1516
+ if (!callerIsAdmin) {
1517
+ const enriched = await resolveApplicationPrincipal(
1518
+ app.id,
1519
+ internalDb,
1520
+ );
1521
+ if (!enriched) continue;
1522
+ if (
1523
+ !isApplicationBindable({
1524
+ appAccessRules: enriched.accessRules,
1525
+ callerAccessRules: callerRules,
1526
+ })
1527
+ ) {
1528
+ continue;
1529
+ }
1530
+ }
1531
+ bindable.push({
1532
+ id: app.id,
1533
+ name: app.name,
1534
+ description: app.description,
1535
+ });
1536
+ }
1537
+
1538
+ return bindable;
1539
+ },
1540
+ );
1541
+
1444
1542
  // ==========================================================================
1445
1543
  // TEAM MANAGEMENT HANDLERS
1446
1544
  // ==========================================================================
@@ -1962,6 +2060,8 @@ export const createAuthRouter = (
1962
2060
  getRegistrationSchema,
1963
2061
  getRegistrationStatus,
1964
2062
  setRegistrationStatus,
2063
+ getMcpOAuthSettings,
2064
+ setMcpOAuthSettings,
1965
2065
  getOnboardingStatus,
1966
2066
  completeOnboarding,
1967
2067
  validateResetToken,
@@ -1979,6 +2079,8 @@ export const createAuthRouter = (
1979
2079
  updateApplication,
1980
2080
  deleteApplication,
1981
2081
  regenerateApplicationSecret,
2082
+ enrichApplicationPrincipal,
2083
+ getBindableApplications,
1982
2084
  getOwnStrategyConfig,
1983
2085
  // Teams
1984
2086
  getTeams,
package/src/schema.ts CHANGED
@@ -4,6 +4,8 @@ import {
4
4
  boolean,
5
5
  timestamp,
6
6
  primaryKey,
7
+ integer,
8
+ index,
7
9
  } from "drizzle-orm/pg-core";
8
10
 
9
11
  // --- Better Auth Schema ---
@@ -278,3 +280,73 @@ export const resourceTeamAccess = pgTable(
278
280
  pk: primaryKey({ columns: [t.resourceType, t.resourceId, t.teamId] }),
279
281
  })
280
282
  );
283
+
284
+ // --- AI platform: OAuth Authorization Server (better-auth oidcProvider/mcp) ---
285
+ //
286
+ // These three tables are OWNED by the better-auth `oidcProvider` + `mcp`
287
+ // plugins (Phase 2). The plugins' Drizzle adapter resolves them by the camelCase
288
+ // model keys below (`oauthApplication`, `oauthAccessToken`, `oauthConsent`) and
289
+ // the camelCase field keys; column names are snake_case to match repo
290
+ // convention. Field shapes mirror the plugin schema exactly (see
291
+ // SPIKE-findings.md). Access tokens are OPAQUE random strings persisted here —
292
+ // validation is an introspection lookup, not a JWKS verify (decision §11).
293
+
294
+ /** Registered OAuth clients (incl. dynamically-registered MCP clients). */
295
+ export const oauthApplication = pgTable("oauth_application", {
296
+ id: text("id").primaryKey(),
297
+ name: text("name"),
298
+ icon: text("icon"),
299
+ metadata: text("metadata"),
300
+ clientId: text("client_id").unique(),
301
+ clientSecret: text("client_secret"),
302
+ redirectUrls: text("redirect_urls"),
303
+ type: text("type"),
304
+ disabled: boolean("disabled").default(false),
305
+ userId: text("user_id"),
306
+ createdAt: timestamp("created_at"),
307
+ updatedAt: timestamp("updated_at"),
308
+ });
309
+
310
+ /** Issued opaque access/refresh tokens + their granted scopes. */
311
+ export const oauthAccessToken = pgTable("oauth_access_token", {
312
+ id: text("id").primaryKey(),
313
+ accessToken: text("access_token").unique(),
314
+ refreshToken: text("refresh_token").unique(),
315
+ accessTokenExpiresAt: timestamp("access_token_expires_at"),
316
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
317
+ clientId: text("client_id"),
318
+ userId: text("user_id"),
319
+ scopes: text("scopes"),
320
+ createdAt: timestamp("created_at"),
321
+ updatedAt: timestamp("updated_at"),
322
+ });
323
+
324
+ /** Per-(client,user) consent record for the consent screen. */
325
+ export const oauthConsent = pgTable("oauth_consent", {
326
+ id: text("id").primaryKey(),
327
+ clientId: text("client_id"),
328
+ userId: text("user_id"),
329
+ scopes: text("scopes"),
330
+ createdAt: timestamp("created_at"),
331
+ updatedAt: timestamp("updated_at"),
332
+ consentGiven: boolean("consent_given"),
333
+ });
334
+
335
+ // --- AI platform: shared-Postgres rate-limit counter (state-and-scale §14.5) ---
336
+ //
337
+ // Fixed-window counter so a limit holds across ALL pods (an in-memory per-pod
338
+ // limiter would let N pods each allow the cap = N x the intended limit). Used
339
+ // today for the DCR endpoint throttle (`dcr:<ip>`); per-principal tool budgets
340
+ // (Phase 3) reuse the same table with a different key.
341
+ export const aiRateLimit = pgTable(
342
+ "ai_rate_limit",
343
+ {
344
+ key: text("key").notNull(),
345
+ windowStart: timestamp("window_start").notNull(),
346
+ count: integer("count").notNull().default(0),
347
+ },
348
+ (t) => ({
349
+ pk: primaryKey({ columns: [t.key, t.windowStart] }),
350
+ keyIdx: index("ai_rate_limit_key_idx").on(t.key, t.windowStart),
351
+ }),
352
+ );
@@ -0,0 +1,157 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ SCOPE_BUNDLE,
4
+ expandBundles,
5
+ narrowScopes,
6
+ } from "./scope-narrowing";
7
+
8
+ const CATALOG = [
9
+ "incident.incident.read",
10
+ "incident.incident.manage",
11
+ "healthcheck.config.read",
12
+ "healthcheck.config.manage",
13
+ "anomaly.anomaly.read",
14
+ "ai.tools.manage",
15
+ ];
16
+
17
+ describe("expandBundles", () => {
18
+ test("checkstack:read expands to every *.read rule", () => {
19
+ expect(
20
+ expandBundles({ requested: [SCOPE_BUNDLE.read], catalog: CATALOG }).sort(),
21
+ ).toEqual(
22
+ [
23
+ "incident.incident.read",
24
+ "healthcheck.config.read",
25
+ "anomaly.anomaly.read",
26
+ ].sort(),
27
+ );
28
+ });
29
+
30
+ test("checkstack:write expands to every *.read + *.manage rule", () => {
31
+ expect(
32
+ expandBundles({
33
+ requested: [SCOPE_BUNDLE.write],
34
+ catalog: CATALOG,
35
+ }).sort(),
36
+ ).toEqual([...CATALOG].sort());
37
+ });
38
+
39
+ test("raw rule IDs pass through unchanged", () => {
40
+ expect(
41
+ expandBundles({
42
+ requested: ["incident.incident.read"],
43
+ catalog: CATALOG,
44
+ }),
45
+ ).toEqual(["incident.incident.read"]);
46
+ });
47
+
48
+ test("unknown bundle-like strings are kept verbatim (intersection drops them)", () => {
49
+ // Not a known bundle: kept as a raw id, then dropped by narrowScopes.
50
+ expect(
51
+ expandBundles({ requested: ["checkstack:everything"], catalog: CATALOG }),
52
+ ).toEqual(["checkstack:everything"]);
53
+ });
54
+ });
55
+
56
+ describe("narrowScopes", () => {
57
+ test("intersects granted scopes with the principal's live rules", () => {
58
+ const narrowed = narrowScopes({
59
+ requested: ["incident.incident.read", "incident.incident.manage"],
60
+ principalRules: ["incident.incident.read", "anomaly.anomaly.read"],
61
+ catalog: CATALOG,
62
+ });
63
+ // manage was granted but the principal lacks it -> dropped.
64
+ expect(narrowed).toEqual(["incident.incident.read"]);
65
+ });
66
+
67
+ test("checkstack:write narrows to only what the principal actually has", () => {
68
+ const narrowed = narrowScopes({
69
+ requested: [SCOPE_BUNDLE.write],
70
+ principalRules: ["incident.incident.read"],
71
+ catalog: CATALOG,
72
+ });
73
+ expect(narrowed).toEqual(["incident.incident.read"]);
74
+ });
75
+
76
+ test("admin (*) can carry any granted rule but NEVER the bare wildcard", () => {
77
+ const narrowed = narrowScopes({
78
+ requested: [SCOPE_BUNDLE.write],
79
+ principalRules: ["*"],
80
+ catalog: CATALOG,
81
+ });
82
+ expect(narrowed.sort()).toEqual([...CATALOG].sort());
83
+ expect(narrowed).not.toContain("*");
84
+ });
85
+
86
+ test("a rule the principal has since LOST is dropped (live narrowing)", () => {
87
+ // Token granted incident.manage, but the principal no longer holds it.
88
+ const narrowed = narrowScopes({
89
+ requested: ["incident.incident.manage"],
90
+ principalRules: ["incident.incident.read"],
91
+ catalog: CATALOG,
92
+ });
93
+ expect(narrowed).toEqual([]);
94
+ });
95
+
96
+ // Matrix #5 (Phase 5 hardening, named): the narrow-only invariant is the
97
+ // single most important OAuth property — a token can only ever narrow, never
98
+ // widen, what its bound principal could already do. We restate it as an
99
+ // explicit, named hardening assertion (the fuzz below is the exhaustive
100
+ // backstop) so the security suite carries it as a first-class regression
101
+ // guard, not buried in a generic property test.
102
+ test("HARDENING: scope narrowing can NEVER widen a principal", () => {
103
+ // A token that requests MORE than the principal holds gains nothing extra.
104
+ const escalation = narrowScopes({
105
+ requested: [
106
+ "incident.incident.read",
107
+ "incident.incident.manage", // not held
108
+ "healthcheck.config.manage", // not held
109
+ SCOPE_BUNDLE.write, // a bundle that would expand to manage rules
110
+ ],
111
+ principalRules: ["incident.incident.read"],
112
+ catalog: CATALOG,
113
+ });
114
+ expect(escalation).toEqual(["incident.incident.read"]);
115
+ // A leaked admin token is NOT a god-token: it carries only the granted set,
116
+ // never the bare wildcard.
117
+ const adminGranted = narrowScopes({
118
+ requested: ["incident.incident.read"],
119
+ principalRules: ["*"],
120
+ catalog: CATALOG,
121
+ });
122
+ expect(adminGranted).toEqual(["incident.incident.read"]);
123
+ expect(adminGranted).not.toContain("*");
124
+ });
125
+
126
+ // Matrix #5: property/fuzz — narrowing can NEVER widen.
127
+ test("PROPERTY: narrowed is always a subset of the principal's effective rules", () => {
128
+ const universe = [...CATALOG, "*", SCOPE_BUNDLE.read, SCOPE_BUNDLE.write];
129
+ const rand = (n: number) => Math.floor(Math.random() * n);
130
+ const pick = (pool: string[]) =>
131
+ pool.filter(() => Math.random() < 0.5);
132
+
133
+ for (let i = 0; i < 1000; i++) {
134
+ const requested = pick(universe);
135
+ const principalRules = pick(universe).filter(
136
+ (r) => r !== SCOPE_BUNDLE.read && r !== SCOPE_BUNDLE.write,
137
+ );
138
+ // Occasionally include the admin wildcard.
139
+ if (rand(5) === 0) principalRules.push("*");
140
+
141
+ const narrowed = narrowScopes({
142
+ requested,
143
+ principalRules,
144
+ catalog: CATALOG,
145
+ });
146
+
147
+ const isAdmin = principalRules.includes("*");
148
+ const effective = new Set(isAdmin ? CATALOG : principalRules);
149
+ for (const rule of narrowed) {
150
+ // Never widens: every narrowed rule is within the effective ceiling.
151
+ expect(effective.has(rule)).toBe(true);
152
+ // Never the bare god-scope.
153
+ expect(rule).not.toBe("*");
154
+ }
155
+ }
156
+ });
157
+ });