@mrgnw/yetki#
Pre-alpha / Proof of concept. API and schema will change.
Entitlements, roles, and access grants for apps on Cloudflare D1. Companion to d1dash — yetki ships the schema and resolver your app uses at runtime; d1dash provides the admin UI for manual overrides.
"Yetki" = authority / permission (Turkish).
NOTE
This project was built with heavy LLM assistance and should be considered proof-of-concept. Contributions are welcome, but I can't guarantee the accuracy of the code or that I will continue to maintain it. If there are any mistakes in attribution or anything else, please let me know.
What you get#
- A small, opinionated schema prefixed by default:
yetki_roles,yetki_subscriptions,yetki_purchases,yetki_grants. - A resolver:
resolveAccess(adapter, userId) → { flags, limits, roles, plan, reasons }. - Self-registration with d1dash:
init()also inserts a row into_d1dash_modulesso d1dash detects yetki and exposes manual-override actions (mark internal, grant +1 language, grant all-access, etc.) without any extra config. - An override-able prefix (default
yetki_) for collision safety, à la anahtar.
Modularity: yetki does not own identity#
yetki does not create a users table. It stores user_id as a plain string, with no foreign-key constraint to any identity store. Your app (or your auth library, e.g. anahtar) owns the users / auth_users table.
Tell yetki where those users live so the d1dash admin UI can search them:
d1Adapter(db, {
tablePrefix: "yetki_", // default
usersTable: "auth_users", // default — anahtar-friendly
usersIdColumn: "id", // default
usersDisplayColumn: "email", // default
});
This only affects the descriptor that d1dash reads. yetki itself never reads or writes the users table.
Install#
pnpm add @mrgnw/yetki
Quick start (D1)#
// src/lib/server/access.ts
import { d1Adapter } from "@mrgnw/yetki/d1";
export function accessFor(db: D1Database) {
return d1Adapter(db);
}
On deploy (or during a migration step), run init() once:
import { accessFor } from "$lib/server/access";
export async function migrate(db: D1Database) {
const a = accessFor(db);
await a.init();
}
Use the resolver from your feature code:
import { resolveAccess } from "@mrgnw/yetki";
import { accessFor } from "$lib/server/access";
const access = await resolveAccess(accessFor(platform.env.DB), userId);
if (access.flags.internal) {
// unlimited everything
}
if (access.limits.target_languages_max > 1) {
// allow more than the free default
}
Schema (with default prefix yetki_)#
yetki_roles (user_id TEXT, role TEXT, assigned_at INTEGER, PRIMARY KEY (user_id, role))
yetki_subscriptions (id TEXT PK, user_id TEXT, plan TEXT, status TEXT, started_at INTEGER, ends_at INTEGER NULL)
yetki_purchases (id TEXT PK, user_id TEXT, sku TEXT, paid_amount INTEGER, currency TEXT, purchased_at INTEGER)
yetki_grants (id TEXT PK, user_id TEXT, type TEXT, granted_by TEXT, reason TEXT NULL, expires_at INTEGER NULL, revoked_at INTEGER NULL, created_at INTEGER)
init() is idempotent — CREATE TABLE IF NOT EXISTS. Adopting yetki in an existing database is safe. user_id is a plain TEXT column; no foreign keys — your identity store lives outside yetki.
Resolved access shape#
interface ResolvedAccess {
flags: { internal: boolean; all_access: boolean; [key: string]: boolean };
limits: { target_languages_max: number; [key: string]: number };
roles: string[];
plan: { name: string; status: string } | null;
reasons: string[]; // debug trail — which grants/subs/defaults produced each value
}
Policy is overridable:
const access = await resolveAccess(adapter, userId, {
default_limits: { target_languages_max: 1 },
plan_limits: { pro: { target_languages_max: 5 } },
});
Grant types#
yetki doesn't prescribe a fixed vocabulary, but the d1dash descriptor ships actions for these:
| type | effect |
|---|---|
internal |
flags.internal = true — unlimited override |
all_access |
flags.all_access = true |
extra_language |
+1 on limits.target_languages_max per active grant |
Add your own — the resolver passes them through as flags[<type>] = true so you can branch on custom grant types.
d1dash integration#
When init() runs, yetki inserts a descriptor into _d1dash_modules in the target DB. d1dash reads it and surfaces:
- Inline actions on your users table (
auth_usersby default): Mark internal, Grant all-access, +1 language forever. - Admin route at
/[db]/admin: per-user view showing active grants + action cards.
Every action writes an audit row to _d1dash_audit tagged with the operator's Cloudflare Access email.