[READ-ONLY] Mirror of https://github.com/mrgnw/yetki. Entitlements and access grants for apps on Cloudflare D1. Companion to d1dash. POC.
0

Configure Feed

Select the types of activity you want to include in your feed.

Add bun test suite

- resolver.test.ts: pure unit tests for computeAccess covering defaults,
flag derivation, internal/all_access overrides to Infinity, extra_language
stacking, plan_limits, custom default_limits, pass-through grant types,
and role projection (11 cases)
- db/d1.test.ts: adapter integration tests run against a bun:sqlite-backed
D1 shim — init creates all tables, registers yetki in _d1dash_modules,
is idempotent, honors tablePrefix; getActiveGrants filters revoked/expired;
getActiveSubscription ignores canceled; round-trips users+roles (8 cases)
- src/_test/d1-from-sqlite.ts: thin shim wrapping bun:sqlite in D1's
prepare/bind/first/all/run interface for test use only

19 passing. Runs with `bun test`. tsconfig types updated for @types/bun.

Note: user asked for "bunwright" — that's a WebView automation CLI, not a
test runner, and yetki is headless. Using bun test (Bun's native runner)
as the pragmatic read of the intent.

+388 -2
+2
package.json
··· 15 15 "scripts": { 16 16 "build": "tsc", 17 17 "check": "tsc --noEmit", 18 + "test": "bun test", 18 19 "prepublishOnly": "pnpm build" 19 20 }, 20 21 "main": "./dist/index.js", ··· 38 39 "!dist/**/*.test.*" 39 40 ], 40 41 "devDependencies": { 42 + "@types/bun": "^1.1.0", 41 43 "typescript": "^5.7.0" 42 44 } 43 45 }
+29
pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 devDependencies: 11 + '@types/bun': 12 + specifier: ^1.1.0 13 + version: 1.3.12 11 14 typescript: 12 15 specifier: ^5.7.0 13 16 version: 5.9.3 14 17 15 18 packages: 16 19 20 + '@types/bun@1.3.12': 21 + resolution: {integrity: sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A==} 22 + 23 + '@types/node@25.6.0': 24 + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} 25 + 26 + bun-types@1.3.12: 27 + resolution: {integrity: sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA==} 28 + 17 29 typescript@5.9.3: 18 30 resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} 19 31 engines: {node: '>=14.17'} 20 32 hasBin: true 33 + 34 + undici-types@7.19.2: 35 + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} 21 36 22 37 snapshots: 23 38 39 + '@types/bun@1.3.12': 40 + dependencies: 41 + bun-types: 1.3.12 42 + 43 + '@types/node@25.6.0': 44 + dependencies: 45 + undici-types: 7.19.2 46 + 47 + bun-types@1.3.12: 48 + dependencies: 49 + '@types/node': 25.6.0 50 + 24 51 typescript@5.9.3: {} 52 + 53 + undici-types@7.19.2: {}
+44
src/_test/d1-from-sqlite.ts
··· 1 + import type { Database } from "bun:sqlite"; 2 + 3 + interface D1Statement { 4 + bind(...values: unknown[]): D1Statement; 5 + first<T = Record<string, unknown>>(): Promise<T | null>; 6 + all<T = Record<string, unknown>>(): Promise<{ results: T[] }>; 7 + run(): Promise<{ meta: { changes: number } }>; 8 + } 9 + 10 + interface D1Like { 11 + prepare(sql: string): D1Statement; 12 + exec(sql: string): Promise<unknown>; 13 + } 14 + 15 + export function d1FromSqlite(db: Database): D1Like { 16 + return { 17 + prepare(sql: string): D1Statement { 18 + const stmt = db.prepare(sql); 19 + let bound: unknown[] = []; 20 + const wrap: D1Statement = { 21 + bind(...values: unknown[]) { 22 + bound = values; 23 + return wrap; 24 + }, 25 + async first<T>() { 26 + const row = stmt.get(...(bound as [])) as T | null; 27 + return row ?? null; 28 + }, 29 + async all<T>() { 30 + const results = stmt.all(...(bound as [])) as T[]; 31 + return { results }; 32 + }, 33 + async run() { 34 + const r = stmt.run(...(bound as [])) as { changes: number }; 35 + return { meta: { changes: r.changes ?? 0 } }; 36 + }, 37 + }; 38 + return wrap; 39 + }, 40 + async exec(sql: string) { 41 + db.run(sql); 42 + }, 43 + }; 44 + }
+193
src/db/d1.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import { Database } from "bun:sqlite"; 3 + import { d1Adapter } from "./d1.js"; 4 + import { d1FromSqlite } from "../_test/d1-from-sqlite.js"; 5 + 6 + function setup(prefix = "") { 7 + const sqlite = new Database(":memory:"); 8 + const d1 = d1FromSqlite(sqlite); 9 + const adapter = d1Adapter(d1, { tablePrefix: prefix }); 10 + return { sqlite, d1, adapter }; 11 + } 12 + 13 + describe("d1Adapter.init()", () => { 14 + it("creates all yetki tables", async () => { 15 + const { sqlite, adapter } = setup(); 16 + await adapter.init(); 17 + 18 + const rows = sqlite 19 + .query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") 20 + .all() as { name: string }[]; 21 + const names = rows.map((r) => r.name); 22 + 23 + expect(names).toContain("users"); 24 + expect(names).toContain("user_roles"); 25 + expect(names).toContain("user_subscriptions"); 26 + expect(names).toContain("user_purchases"); 27 + expect(names).toContain("user_access_grants"); 28 + }); 29 + 30 + it("creates the d1dash registry table and registers yetki", async () => { 31 + const { sqlite, adapter } = setup(); 32 + await adapter.init(); 33 + 34 + const mod = sqlite 35 + .query("SELECT * FROM _d1dash_modules WHERE name = 'yetki'") 36 + .get() as { 37 + name: string; 38 + version: string; 39 + prefix: string; 40 + descriptor_json: string; 41 + } | null; 42 + 43 + expect(mod).not.toBeNull(); 44 + expect(mod?.name).toBe("yetki"); 45 + expect(mod?.prefix).toBe(""); 46 + 47 + const descriptor = JSON.parse(mod!.descriptor_json); 48 + expect(descriptor.name).toBe("yetki"); 49 + expect(descriptor.entity.table).toBe("users"); 50 + expect(descriptor.actions.some((a: { id: string }) => a.id === "mark_internal")).toBe(true); 51 + }); 52 + 53 + it("is idempotent — init twice does not error and upserts descriptor", async () => { 54 + const { sqlite, adapter } = setup(); 55 + await adapter.init(); 56 + await adapter.init(); 57 + 58 + const rows = sqlite 59 + .query("SELECT COUNT(*) AS c FROM _d1dash_modules WHERE name='yetki'") 60 + .get() as { c: number }; 61 + expect(rows.c).toBe(1); 62 + }); 63 + 64 + it("honors tablePrefix for both yetki tables and the descriptor row", async () => { 65 + const { sqlite, adapter } = setup("app_"); 66 + await adapter.init(); 67 + 68 + const rows = sqlite 69 + .query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") 70 + .all() as { name: string }[]; 71 + const names = rows.map((r) => r.name); 72 + expect(names).toContain("app_users"); 73 + expect(names).toContain("app_user_access_grants"); 74 + expect(names).not.toContain("users"); 75 + 76 + const mod = sqlite 77 + .query("SELECT prefix, descriptor_json FROM _d1dash_modules WHERE name='yetki'") 78 + .get() as { prefix: string; descriptor_json: string }; 79 + expect(mod.prefix).toBe("app_"); 80 + const descriptor = JSON.parse(mod.descriptor_json); 81 + expect(descriptor.prefix).toBe("app_"); 82 + }); 83 + }); 84 + 85 + describe("d1Adapter.getActiveGrants()", () => { 86 + async function seedUser(sqlite: Database, userId: string) { 87 + sqlite 88 + .prepare("INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)") 89 + .run(userId, `${userId}@example.com`, 0); 90 + } 91 + 92 + function insertGrant( 93 + sqlite: Database, 94 + g: { 95 + id: string; 96 + user_id: string; 97 + type: string; 98 + expires_at?: number | null; 99 + revoked_at?: number | null; 100 + }, 101 + ) { 102 + sqlite 103 + .prepare( 104 + "INSERT INTO user_access_grants (id, user_id, type, granted_by, reason, expires_at, revoked_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 105 + ) 106 + .run( 107 + g.id, 108 + g.user_id, 109 + g.type, 110 + "admin@test", 111 + null, 112 + g.expires_at ?? null, 113 + g.revoked_at ?? null, 114 + 0, 115 + ); 116 + } 117 + 118 + it("returns only non-revoked, non-expired grants for the user", async () => { 119 + const { sqlite, adapter } = setup(); 120 + await adapter.init(); 121 + await seedUser(sqlite, "u1"); 122 + await seedUser(sqlite, "u2"); 123 + 124 + const past = Math.floor(Date.now() / 1000) - 60; 125 + insertGrant(sqlite, { id: "a", user_id: "u1", type: "internal" }); 126 + insertGrant(sqlite, { id: "b", user_id: "u1", type: "all_access", revoked_at: 1 }); 127 + insertGrant(sqlite, { id: "c", user_id: "u1", type: "extra_language", expires_at: past }); 128 + insertGrant(sqlite, { id: "d", user_id: "u2", type: "internal" }); 129 + 130 + const grants = await adapter.getActiveGrants("u1"); 131 + expect(grants.length).toBe(1); 132 + expect(grants[0].id).toBe("a"); 133 + }); 134 + }); 135 + 136 + describe("d1Adapter.getActiveSubscription()", () => { 137 + it("returns the active subscription if present, null otherwise", async () => { 138 + const { sqlite, adapter } = setup(); 139 + await adapter.init(); 140 + sqlite 141 + .prepare("INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)") 142 + .run("u1", "u1@test", 0); 143 + 144 + expect(await adapter.getActiveSubscription("u1")).toBeNull(); 145 + 146 + sqlite 147 + .prepare( 148 + "INSERT INTO user_subscriptions (id, user_id, plan, status, started_at, ends_at) VALUES (?, ?, ?, ?, ?, ?)", 149 + ) 150 + .run("s1", "u1", "pro", "active", 100, null); 151 + 152 + const sub = await adapter.getActiveSubscription("u1"); 153 + expect(sub?.plan).toBe("pro"); 154 + expect(sub?.status).toBe("active"); 155 + }); 156 + 157 + it("ignores canceled subscriptions", async () => { 158 + const { sqlite, adapter } = setup(); 159 + await adapter.init(); 160 + sqlite 161 + .prepare("INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)") 162 + .run("u1", "u1@test", 0); 163 + sqlite 164 + .prepare( 165 + "INSERT INTO user_subscriptions (id, user_id, plan, status, started_at, ends_at) VALUES (?, ?, ?, ?, ?, ?)", 166 + ) 167 + .run("s1", "u1", "pro", "canceled", 100, null); 168 + 169 + expect(await adapter.getActiveSubscription("u1")).toBeNull(); 170 + }); 171 + }); 172 + 173 + describe("d1Adapter.getRoles() and .getUser()", () => { 174 + it("round-trips roles and users", async () => { 175 + const { sqlite, adapter } = setup(); 176 + await adapter.init(); 177 + sqlite 178 + .prepare("INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)") 179 + .run("u1", "u1@test", 100); 180 + sqlite 181 + .prepare("INSERT INTO user_roles (user_id, role, assigned_at) VALUES (?, ?, ?)") 182 + .run("u1", "admin", 101); 183 + sqlite 184 + .prepare("INSERT INTO user_roles (user_id, role, assigned_at) VALUES (?, ?, ?)") 185 + .run("u1", "user", 101); 186 + 187 + const user = await adapter.getUser("u1"); 188 + expect(user?.email).toBe("u1@test"); 189 + 190 + const roles = await adapter.getRoles("u1"); 191 + expect(roles.map((r) => r.role).sort()).toEqual(["admin", "user"]); 192 + }); 193 + });
+117
src/resolver.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import { computeAccess } from "./resolver.js"; 3 + import type { AccessGrant, UserRole, UserSubscription } from "./types.js"; 4 + 5 + function grant(overrides: Partial<AccessGrant> = {}): AccessGrant { 6 + return { 7 + id: overrides.id ?? "g1", 8 + user_id: "u1", 9 + type: overrides.type ?? "internal", 10 + granted_by: "admin@example.com", 11 + reason: null, 12 + expires_at: null, 13 + revoked_at: null, 14 + created_at: 0, 15 + ...overrides, 16 + }; 17 + } 18 + 19 + describe("computeAccess", () => { 20 + it("returns defaults for a user with nothing", () => { 21 + const r = computeAccess([], null, []); 22 + expect(r.flags.internal).toBe(false); 23 + expect(r.flags.all_access).toBe(false); 24 + expect(r.limits.target_languages_max).toBe(1); 25 + expect(r.roles).toEqual([]); 26 + expect(r.plan).toBeNull(); 27 + }); 28 + 29 + it("sets flags.internal when an internal grant is present", () => { 30 + const r = computeAccess([], null, [grant({ type: "internal" })]); 31 + expect(r.flags.internal).toBe(true); 32 + expect(r.reasons.some((s) => s.startsWith("grant:internal"))).toBe(true); 33 + }); 34 + 35 + it("internal grant overrides target_languages_max to Infinity", () => { 36 + const r = computeAccess([], null, [grant({ type: "internal" })]); 37 + expect(r.limits.target_languages_max).toBe(Number.POSITIVE_INFINITY); 38 + expect(r.reasons).toContain("override:internal"); 39 + }); 40 + 41 + it("all_access grant also overrides to Infinity", () => { 42 + const r = computeAccess([], null, [grant({ type: "all_access" })]); 43 + expect(r.flags.all_access).toBe(true); 44 + expect(r.limits.target_languages_max).toBe(Number.POSITIVE_INFINITY); 45 + }); 46 + 47 + it("stacks extra_language grants additively on top of the default", () => { 48 + const r = computeAccess([], null, [ 49 + grant({ id: "a", type: "extra_language" }), 50 + grant({ id: "b", type: "extra_language" }), 51 + grant({ id: "c", type: "extra_language" }), 52 + ]); 53 + expect(r.limits.target_languages_max).toBe(4); 54 + expect(r.reasons).toContain("grants:extra_language*3"); 55 + }); 56 + 57 + it("extra_language grants do not exceed the internal override", () => { 58 + const r = computeAccess([], null, [ 59 + grant({ id: "a", type: "extra_language" }), 60 + grant({ id: "b", type: "internal" }), 61 + ]); 62 + expect(r.limits.target_languages_max).toBe(Number.POSITIVE_INFINITY); 63 + }); 64 + 65 + it("applies plan_limits from policy when the user has that plan", () => { 66 + const sub: UserSubscription = { 67 + id: "s1", 68 + user_id: "u1", 69 + plan: "pro", 70 + status: "active", 71 + started_at: 0, 72 + ends_at: null, 73 + }; 74 + const r = computeAccess([], sub, [], { 75 + plan_limits: { pro: { target_languages_max: 5 } }, 76 + }); 77 + expect(r.limits.target_languages_max).toBe(5); 78 + expect(r.plan).toEqual({ name: "pro", status: "active" }); 79 + expect(r.reasons).toContain("plan:pro.target_languages_max=5"); 80 + }); 81 + 82 + it("respects custom default_limits", () => { 83 + const r = computeAccess([], null, [], { 84 + default_limits: { target_languages_max: 3 }, 85 + }); 86 + expect(r.limits.target_languages_max).toBe(3); 87 + }); 88 + 89 + it("extra_language stacks on top of plan_limits", () => { 90 + const sub: UserSubscription = { 91 + id: "s1", 92 + user_id: "u1", 93 + plan: "pro", 94 + status: "active", 95 + started_at: 0, 96 + ends_at: null, 97 + }; 98 + const r = computeAccess([], sub, [grant({ type: "extra_language" })], { 99 + plan_limits: { pro: { target_languages_max: 5 } }, 100 + }); 101 + expect(r.limits.target_languages_max).toBe(6); 102 + }); 103 + 104 + it("passes through unknown grant types as flags", () => { 105 + const r = computeAccess([], null, [grant({ type: "beta_program" })]); 106 + expect(r.flags.beta_program).toBe(true); 107 + }); 108 + 109 + it("projects roles to a flat array", () => { 110 + const roles: UserRole[] = [ 111 + { user_id: "u1", role: "user", assigned_at: 0 }, 112 + { user_id: "u1", role: "admin", assigned_at: 0 }, 113 + ]; 114 + const r = computeAccess(roles, null, []); 115 + expect(r.roles).toEqual(["user", "admin"]); 116 + }); 117 + });
+3 -2
tsconfig.json
··· 15 15 "skipLibCheck": true, 16 16 "forceConsistentCasingInFileNames": true, 17 17 "isolatedModules": true, 18 - "verbatimModuleSyntax": true 18 + "verbatimModuleSyntax": true, 19 + "types": ["bun"] 19 20 }, 20 21 "include": ["src/**/*"], 21 - "exclude": ["node_modules", "dist", "**/*.test.ts"] 22 + "exclude": ["node_modules", "dist", "**/*.test.ts", "src/_test/**"] 22 23 }