Concepts
Worker-safe bearer-key generation and hashing primitives
Ear Tag is an independent, modular key server for TypeScript hosts. Its base
entrypoint creates cryptographically random bearer keys and SHA-256 digests, so
a host can issue a secret like an invite code or an API key without inventing
its own random-string and hashing logic. ./grants builds a typed
capability-grant vocabulary, and ./keys runs a tenant-scoped key store on top
of it: issuance, verification, compare-and-swap grant edits, revocation,
optional expiry, owner suspension and a per-key rate limit. The owner decides
scope, storage and policy; Ear Tag owns the mechanics.
Core concepts
Section titled “Core concepts”generateKey(options) returns a random bearer string. An optional prefix adds
<prefix>_ before the random portion. length controls the random portion’s
length. hashKey(key) returns its SHA-256 digest as lowercase hexadecimal.
generateKeyAndHash(options) creates both values and returns { key, digest }.
Grants and the key store
Section titled “Grants and the key store”./grants declares one host’s capability vocabulary and returns the schemas and
operations closed over it:
import { defineGrants } from "@fungi.computer/ear-tag/grants";
const vocabulary = defineGrants({ capabilities: ["files.read", "files.write"] as const,});./keys runs a tenant-scoped key store over that vocabulary. sql is the
exec(statement, ...bindings) -> { toArray() } shape Cloudflare Durable Object
SQLite already provides; ./keys/node’s fromNodeSqlite adapts a node:sqlite
database for hosts that run outside a Durable Object:
import { createKeyStore } from "@fungi.computer/ear-tag/keys";import { fromNodeSqlite } from "@fungi.computer/ear-tag/keys/node";import { DatabaseSync } from "node:sqlite";
const store = createKeyStore({ sql: fromNodeSqlite(new DatabaseSync(":memory:")), commit: (body) => Promise.resolve(body()), vocabulary, prefix: "demo", authorizeIssuer: async () => ({ kind: "unrestricted" }),});
const issued = await store.issue("owner-1", { name: "ci bot", grants: [{ capability: "files.read", targets: { kind: "all" } }],});Every operation returns a KeyOutcome: { kind: "ok", value } or
{ kind: "error", error: { code, retryAfterSeconds? } }, with code one of
invalid (the caller’s own request failed validation), denied (well-formed
but refused on its merits), conflict, expired, rate_limited or
unavailable (the store’s own state could not be read back; always logged). One
store instance is one tenant; namespacing between tenants is the host’s own
table or storage boundary.
authorizeIssuer’s "bounded" only narrows which grants an actor may assign
through issue or updateGrants. It does not narrow which keys that actor may
list, revoke or edit: any actor it resolves to a non-null authority for may
manage every key in the tenant, including keys another actor issued. This
matches Hub’s own authorizeManage. implies is one level deep, not
transitive; both grantsAllow and grantsList honor it without widening a
selected grant’s targets.
Hosts may enable delegated issuance with delegation: { manage }. Passing a
parent principal to issue or updateGrants requires that key to be live,
owned by the same human, hold the management capability, and cover every grant
being assigned. A child cannot outlive its parent at issuance. Revoking the
parent later does not revoke an already issued child; the human’s suspension
still applies to both.
live({ keyId, grantVersion? }) rechecks the human issuer and the durable key
row without spending a rate slot. A version pin refuses a stale grant set.
current(principal) is synchronous for use inside a host resource transaction;
it checks the row and version, not asynchronous human membership.
verify(secret) consumes a rate slot; admitUse rechecks live authority and
records an exact use receipt. Metadata reads never select the digest.
An optional epoch: { begin, finish, abort } fences grant edits and revokes
against an external admission owner. Each method receives the same decision id.
A local durable receipt distinguishes an aborted commit from a lost commit
acknowledgement; exact retries finish the committed decision without mutating
again. Aborted decisions are terminal: retrying an aborted request does not
reopen it. Recovery after an abort requires a new requestId, which names a new
epoch decision. A failed finish of a committed request instead retries with the
original requestId and only finishes that decision. onAuthorityChange runs
inside the local commit and carries the transition kind and
throughGrantVersion: the replaced version for a grant edit, the current
version for revocation, suspension or closure.
close(closedAt) is synchronous and runs in the host’s own transaction. It
permanently closes the tenant and revokes its keys; reopening the store does not
permit later issuance or grant edits. Hosts must check its outcome before
committing their enclosing transaction.
keyWire(vocabulary) provides the shared issue, updateGrants and revoke
request decoders plus toSummary(record). Summaries contain grants and
lifecycle metadata, never a digest or human owner id. Hosts retain their name,
identity, expiry, rate-policy and transport authorization rules.