[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.

3 1 0

Clone this repository

https://git.vm.fail/xcc.es/yetki https://git.vm.fail/did:plc:hhbvpiupypeay6b75ys654hm
ssh://git@knot1.tangled.sh:2222/xcc.es/yetki ssh://git@knot1.tangled.sh:2222/did:plc:hhbvpiupypeay6b75ys654hm

For self-hosted knots, clone URLs may differ based on your setup.


README.md

@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_modules so 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_users by 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.

License#

MIT