Skip to content

tapedb/worker

Everything here is stable unless marked experimental; see API stability.

declare function defineTapeDB(options: DefineTapeDBOptions): TapeDBWorker;

Signs the compact HS256 claim the engine verifier accepts.

declare function signRoleClaim(secret: string, input: MintClaimInput, now?: number): Promise<MintedClaim>;

Verifies a compact JWS against the frozen RoleClaim contract.

Throws ClaimError for malformed tokens, bad signatures, expiry, workspace mismatches and invalid claim values. A missing server secret throws a plain Error: that is a deployment problem, not a client failure.

declare function verifyRoleClaim(token: string, env: ClaimVerificationEnv, expectedWorkspaceId: string): Promise<RoleClaim>;

Typed verification failure; code is safe to return to the caller.

declare class ClaimError extends Error {
readonly code: ClaimErrorCode;
constructor(code: ClaimErrorCode, message: string);
}

The two methods a deployment implements, and an optional rate limit. Everything else about identity (passwords, magic codes, OAuth, cookies) stays in the host application.

interface ClaimIdentity {
/** Resolve the caller's subject from the request. Null means "not signed in". */
getSubject(headers: Headers): Promise<{
sub: string;
} | null>;
/** Resolve the caller's role in a workspace. Null means "not a member". */
getRole(workspaceId: string, sub: string): Promise<WorkspaceRole | null>;
/**
* Optional per-caller limit for the claim route, checked before anything
* else. `key` is `claim:<cf-connecting-ip>`. Return false to refuse with
* 429 `rate_limited`; a throw refuses with 500 `rate_limit_unavailable`.
* Back it with a Cloudflare rate-limit binding when the route is public.
*/
allowRequest?(key: string): Promise<boolean>;
/**
* Built-in limits, on by default: exact, global token buckets per caller
* IP (before the session lookup) and per subject (after it). Defaults: 300
* requests per IP and 30 per subject, refilling over 60 seconds. Set a
* limit to `false`, or `limits: false`, to turn it off.
*/
limits?: ClaimLimits | false;
}

Built-in limits for the claim route: exact token buckets, global per key.

Each bucket lives in one of LIMITER_SHARDS Durable Objects of the workspace class, addressed by a name that is not a workspace id, so a deployment needs no extra binding or migration. A Durable Object is one place in the world and runs one request at a time, so a count is exact and shared by every location, unlike Cloudflare’s rate-limit binding, which counts per location and approximately.

Keys are HMACs of the caller’s IP or subject under the claim secret: the limiter never stores an address or a user id. A bucket that has refilled is indistinguishable from none and is deleted.

interface ClaimLimit {
/** Requests a caller may make in a burst; the bucket's size. */
requests: number;
/** Seconds the bucket takes to refill from empty. */
perSeconds: number;
}

Limits on POST /_tapedb/claim. ip is checked before the session lookup (it protects your identity system from floods), subject after it. false turns one off.

interface ClaimLimits {
ip?: ClaimLimit | false;
subject?: ClaimLimit | false;
}

experimental topics

interface DefineTapeDBOptions {
schema: TapeDBSchema;
/**
* The app's committed migrations, from `tapedb/migrations.gen.ts`. Omit
* only before the first `tapedb migrate dev`: the schema's generated SQL
* is used, which cannot evolve.
*/
migrations?: readonly AppMigration[];
/**
* Identity adapter. Implement `getSubject` and `getRole` to serve
* `POST /_tapedb/claim`. Without it that route returns 501 and the consumer
* must supply tokens to the client itself.
*/
identity?: ClaimIdentity;
/**
* Development-only issuer: mints an OWNER claim for any workspace with no
* session. Always refused when `ENVIRONMENT` is `production`.
*/
devClaims?: boolean;
/**
* Broadcast topics. `authorize` decides whether a caller may join a topic
* or send to it; without it any member of the workspace may, as with
* presence.
*
* @experimental May change in a minor release.
*/
topics?: {
authorize?: TopicAuthorizer;
};
/**
* How long deleted or overwritten values may remain in the workspace's
* history (R2 log segments, older snapshots, the local replay log).
* Defaults to 30 days; at least 2.
*/
retention?: RetentionOptions;
}

Worker bindings and vars. tapedb init writes exactly these.

Three resources: one Durable Object namespace, two R2 buckets. There is no D1 and no identity database: the engine consumes a verified claim and never reads a session.

interface Env {
/** The database room. One instance per workspace. */
WORKSPACE: DurableObjectNamespace<WorkspaceRoom>;
/** Transaction log segments and snapshots. */
DATA: R2Bucket;
/** Uploaded files. Optional unless `db.$files` is used. */
FILES?: R2Bucket;
ENVIRONMENT?: string;
/**
* Restricts every workspace's Durable Object to a Cloudflare jurisdiction
* ("eu" or "fedramp"): it runs and stores data only there. Set it before
* the first deploy: an object's id depends on its jurisdiction, so changing
* it later addresses new, empty workspaces. `tapedb deploy` checks that the
* R2 buckets are declared in the same jurisdiction.
*/
TAPEDB_JURISDICTION?: string;
/** Shared HS256 secret used to verify role claims. */
TAPEDB_CLAIM_SECRET: string;
/** Optional expected `iss` claim; checked only when set. */
TAPEDB_CLAIM_ISSUER?: string;
/** Optional expected `aud` claim; checked only when set. */
TAPEDB_CLAIM_AUDIENCE?: string;
/**
* Enables the development claim issuer. Set to `"true"` by `tapedb dev` on
* the command line and never written to `wrangler.jsonc`, so a plain
* `wrangler deploy` has no dev claim route.
*/
TAPEDB_DEV_CLAIMS?: string;
/** Requires this token (x-tapedb-dev-token) on the development claim route; for deployed test Workers. */
TAPEDB_DEV_CLAIMS_TOKEN?: string;
}
interface TapeDBWorker {
/** The database room class. Export it from the Worker entry. */
WorkspaceRoom: typeof WorkspaceRoom;
worker: {
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
};
}

One app migration, as tapedb migrate bundles it.

type AppMigration = BundledWorkspaceMigration;
type TopicAction = "join" | "send";

Decides whether a caller may join a topic or send to it. Return false (or throw) to refuse; the socket gets a topic:error and nothing else happens.

type TopicAuthorizer = (context: {
auth: {
sub: string;
role: number;
workspaceId: string;
};
topic: string;
action: TopicAction;
}) => boolean;

The most one upload request carries: a whole small file in one PUT, or one part of a multipart upload. A file of any size goes up as parts (R2 keeps objects up to 5 TB), so this bounds a request, not a file; it stays well inside the request body size every Workers plan accepts.

declare const MAX_UPLOAD_BYTES: number;

Shared role-claim contract between the Worker (JWT verification) and the workspace engine (permission checks). consumes this shape.

declare const WORKSPACE_ROLE_LEVELS: {
readonly GUEST: 0;
readonly USER: 1;
readonly MEMBER: 2;
readonly EDITOR: 3;
readonly MODERATOR: 4;
readonly ADMIN: 5;
readonly OWNER: 6;
readonly FOUNDER: 7;
readonly DEVELOPER: 8;
};