@fungi.computer/ear-tag

中文文档
0.1.0发布于 2026/10/23MITTypeScriptES Module近 30 天下载 5673

Worker-safe bearer-key generation and hashing primitives

原始使用文档
文档目录8 节
  1. Install or set up
  2. Quick start
  3. Core concepts
  4. Grants and the key store
  5. API reference
  6. Examples and checks
  7. Limits and errors
  8. Contribute and license

Ear Tag (@fungi.computer/ear-tag)

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.

Install or set up

npm install @fungi.computer/ear-tag

The package ships ESM only and depends on Cairn for identifiers and random strings, and on zod for its schemas. Hashing uses the global Web Crypto API (crypto.subtle.digest), available in Node 19+ and in Workers runtimes. It has four entrypoints: @fungi.computer/ear-tag (the crypto primitives), @fungi.computer/ear-tag/grants (the grant vocabulary), @fungi.computer/ear-tag/keys (the key store, free of any Node-specific type) and @fungi.computer/ear-tag/keys/node (the node:sqlite adapter, so a Worker consumer of ./keys never needs @types/node to typecheck).

Quick start

This helper creates a prefixed key, stores its digest, then hands the plaintext to the host's authorized delivery function. The host supplies both operations; neither the key nor the digest is logged:

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);
}

The existing tests verify that a generated digest matches the plaintext key in test/crypto.test.ts.

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

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

API reference

See the generated API reference for the crypto primitives, ./grants, ./keys and ./keys/node, or start from the index.

Examples and checks

The crypto tests cover hashing, prefixed generation, and the key/digest pair. The grants and key-store tests cover issuance, verification, compare-and-swap grant edits and replay, authority-change fencing, a stale captured principal, revocation, optional expiry, owner suspension, the per-key rate limit, bounded sub-issuance and bounded grant edits, and admitUse on an expired or suspended key, run through the exact same assertions against two backends: a real Cloudflare Durable Object (pnpm run test:cloudflare, real state.storage.sql and transactionSync, not an adapter standing in for them) and the package's own node:sqlite adapter (pnpm run test:node). Run everything with:

pnpm --filter @fungi.computer/ear-tag test

Limits and errors

A bearer key is a secret: anyone who obtains it can present it to the system that accepts it. Do not log or persist plaintext keys. Retain the digest and give the plaintext only to its intended recipient.

hashKey requires Web Crypto's crypto.subtle.digest. Key length follows Cairn's raw nanoid sizing behavior. Callers should supply a bounded nonnegative integer rather than untrusted sizing input.

The key store's rate limit is counted in memory, not persisted; it resets if the host recreates the store (for example a Durable Object waking from eviction). A KeyPrincipal returned by verify is a plain, unsealed data object: current and admitUse accept one back to resume a session cheaply, but they re-read the live key row and its grant version on every call, so a fabricated or stale principal is refused rather than trusted.

Contribute and license

Ear Tag is MIT licensed. See LICENSE.

版本与发布记录

@fungi.computer/ear-tag 共发布 4 个版本,首次发布于 2026/10/15;过去 12 个月发布了 4 个版本。

版本发布日期距今
0.1.0latest2026/10/2323 天前
0.0.22026/10/1729 天前
0.0.12026/10/151 个月前
0.0.0-stage2026/10/151 个月前

关于 @fungi.computer/ear-tag 的常见问题

@fungi.computer/ear-tag 是什么?

Worker-safe bearer-key generation and hashing primitives

如何安装 @fungi.computer/ear-tag?

在项目目录运行 npm install @fungi.computer/ear-tag,或使用 pnpm add / yarn add / bun add。

@fungi.computer/ear-tag 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@fungi.computer/ear-tag 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@fungi.computer/ear-tag 可以商用吗?

@fungi.computer/ear-tag 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@fungi.computer/ear-tag 的下载量有多少?

根据 npm 官方下载统计,@fungi.computer/ear-tag 在 2026/10/16 – 2026/11/14 这 30 天内被下载 5,673 次,日均约 189 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@fungi.computer/ear-tag 还在维护吗?

最新版本 0.1.0 发布于 2026/10/23(23 天前);至今共发布 4 个版本,其中过去 12 个月发布了 4 个;首次发布于 2026/10/15。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。