Issue API keys and invite codes in TypeScript
Generate API keys and invite codes in TypeScript with Web Crypto. Store SHA-256 digests and manage grants, expiry and revocation in SQLite.
To generate API keys in TypeScript, use Ear Tag to create a random bearer secret and its SHA-256 digest. Show the secret once and store the digest. For keys you need to edit or revoke, its tenant-scoped SQLite store manages grants, expiry and revocation. Your host still decides who can issue a key and what it permits.
The same Web Crypto helpers work as an invite code generator on Cloudflare Workers. They create the secret and digest, not an invitation workflow. If you just want a key to call Fungi’s model API, use Team keys instead of building a key store.
Generate a secret and retain only its digest
Section titled “Generate a secret and retain only its digest”npm install @fungi.computer/ear-tagThe package is ESM-only. Hashing uses Web Crypto’s crypto.subtle.digest,
available in Node 19+ and Workers. Ear Tag uses Cairn for random strings and
identifiers; an identifier is not a credential or an authorization decision.
Illustrative: the host supplies durable digest storage and an authorized delivery channel. Neither function should log the secret.
import { generateKeyAndHash } from "@fungi.computer/ear-tag";
export async function issueInvite( storeDigest: (digest: string) => Promise<void>, deliverKey: (key: string) => Promise<void>,): Promise<void> { const { key, digest } = await generateKeyAndHash({ prefix: "invite" }); await storeDigest(digest); await deliverKey(key);}generateKeyAndHash returns { key, digest }. The key has an optional public
prefix followed by an underscore and a random portion. hashKey(key) computes a
lowercase hexadecimal SHA-256 digest for a presented secret.
Show the plaintext only once, to its intended recipient. Do not persist it or put it in logs. The helper does not implement invite redemption, single-use consumption, membership or expiry: those are host decisions. Keep any caller-controlled key length bounded to a nonnegative integer.
Use the key store for API credentials
Section titled “Use the key store for API credentials”@fungi.computer/ear-tag/grants defines your capability vocabulary.
@fungi.computer/ear-tag/keys owns issuance, verification and lifecycle in
SQLite. It accepts Durable Object SQLite’s exec(...).toArray() interface
directly. The Node adapter lives at /keys/node, separate from the
Worker-compatible entrypoint.
Illustrative: this Node SQLite example uses an in-memory database and permits one known issuer. A real host must supply persistent storage, an atomic commit and its own issuer authorization.
import { DatabaseSync } from "node:sqlite";import { defineGrants } from "@fungi.computer/ear-tag/grants";import { createKeyStore } from "@fungi.computer/ear-tag/keys";import { fromNodeSqlite } from "@fungi.computer/ear-tag/keys/node";
const vocabulary = defineGrants({ capabilities: ["files.read", "files.write"] as const,});const store = createKeyStore({ sql: fromNodeSqlite(new DatabaseSync(":memory:")), commit: (body) => Promise.resolve(body()), vocabulary, prefix: "demo", authorizeIssuer: async (actorId) => actorId === "owner-1" ? { kind: "unrestricted" } : null,});
const issued = await store.issue("owner-1", { name: "ci reader", grants: [{ capability: "files.read", targets: { kind: "all" } }], expiresAt: Date.now() + 60 * 60 * 1000, rateLimit: { limit: 60, windowSeconds: 60 },});
if (issued.kind === "ok") { // Deliver issued.value.key once through your authorized channel. // Keep issued.value.record for management, not the plaintext.}One store instance represents one tenant. Your storage or table boundary separates tenants. Never use an always-unrestricted issuer callback as public endpoint authorization.
Verify, edit and revoke
Section titled “Verify, edit and revoke”Each store operation returns { kind: "ok", value } or
{ kind: "error", error }. Handle the closed refusal codes: invalid,
denied, conflict, expired, rate_limited and unavailable.
verify(secret)returns the current principal and consumes a per-key rate slot. The counter is in memory: recreating the store resets it. It is not a durable cross-restart quota.updateGrants(actorId, input)replaces grants with compare-and-swap: supplykeyId,expectedGrantVersion, replacementgrantsand a stablerequestId. A stale version conflicts; an exact safe retry replays its outcome.revoke(actorId, { keyId, requestId })tombstones a key.suspendOwner(owner, requestId)permanently suspends that owner’s keys. The host must authorize these management doors.live({ keyId, grantVersion })rechecks the issuer and durable row without spending a rate slot.admitUse(principal, input)rechecks live authority and records an exact use receipt. Do not treat a captured principal as permanent permission.
A bounded issuer authority narrows which grants an actor may assign. It does not restrict that actor to managing only keys they issued: a non-null issuer authority permits management of every key in the tenant. Design the host’s management authorization accordingly.
For the complete store and grant contract, read Ear Tag. For opaque IDs rather than bearer secrets, read Cairn.