@broberg/apikey

中文文档
0.3.1发布于 2026/08/07MITTypeScriptESM + CJS零依赖构建来源已验证近 30 天下载 7472

Framework-agnostic inbound API-key primitives for the broberg.ai fleet: mint prefixed keys, timing-safe verify (hashed or plaintext), sliding-window rate-limit over a pluggable store, a Cloudflare-style authorization cascade (permission × resource-filter

原始使用文档
文档目录9 节
  1. Core ( @broberg/apikey )
  2. Rate limit — pluggable store
  3. Authorization cascade ( @broberg/apikey/authorize )
  4. Tenant selector (trail's selector-not-grant)
  5. Adapters
  6. Your error contract, not ours (v0.2.0)
  7. Different caps per route, one limiter (v0.3.0)
  8. What reaches your handler (v0.3.0)
  9. Boundaries (what it deliberately does NOT do)

@broberg/apikey

Framework-agnostic inbound API-key primitives for the broberg.ai fleet. It owns the dangerous-to-get-wrong bits — minting, constant-time verification, rate-limiting, and a Cloudflare-style authorization cascade — and leaves storage, tenancy, and request-context resolution to you. Bring your own lookup.

Designed from a 9-repo fleet survey (trail · cardmem · cms · upmetrics · vn): the package never forces hashing, a tenancy model, a fixed prefix, or a rate-limit backend.

npm i @broberg/apikey      # exact-pin for prod-auth deps

Core (@broberg/apikey)

import { generateKey, hashKey, verifyKey, makeKeyPreview, hasScope } from "@broberg/apikey";

const raw = generateKey("trail");           // "trail_<64 hex>"  — show ONCE
const stored = hashKey(raw);                // sha256 — store this (hash-at-rest)
const preview = makeKeyPreview(raw);         // "trail_0a1b2c3d" — display/grep anchor

// On each request — YOU do the DB read; the package does the constant-time compare:
verifyKey(presented, stored);               // hashed (default): timingSafeEqual(sha256(presented), stored)
verifyKey(presented, stored, { hashed: false }); // plaintext-revealable (upmetrics-style)

hasScope(["content:*"], ["content:write"]); // true — exact / `*` / `area:*`

timingSafeEqual(a, b) is exported too — the length-checked constant-time compare that replaces unsafe a !== b token checks.

Rate limit — pluggable store

In-memory by default (single-machine). For a stateless multi-machine fleet, pass a shared store so the window doesn't leak per machine:

import { SlidingWindowRateLimiter, type RateLimitStore } from "@broberg/apikey";

const limiter = new SlidingWindowRateLimiter({ windowMs: 60_000, max: 100 });
const { allowed, remaining, resetAt } = await limiter.check(clientKey);

// One limiter, per-key caps — pass a per-check `max` override (v0.1.1):
await limiter.check(clientKey, { max: keyRecord.rateLimitPerHour });

// Shared backend (Turso/Redis): implement one method.
const turso: RateLimitStore = {
  async hit(key, now, windowMs) { /* … */ return { count, oldest }; },
};
new SlidingWindowRateLimiter({ windowMs: 60_000, max: 100, store: turso });

Authorization cascade (@broberg/apikey/authorize)

The optional rich tier — permission × resource-filter × CIDR × TTL (modelled on cms F134). Simple adopters skip this and use hasScope.

import { evaluateToken, type TokenGrant } from "@broberg/apikey/authorize";

const grant: TokenGrant = {
  permissions: ["deploy:trigger"],
  resources: [{ scope: "site", effect: "include", targets: ["fysiodk"] }],
  ipFilters: [{ mode: "in", cidrs: ["203.0.113.0/24"] }],
  notBefore: Date.parse("2026-01-01"),
  notAfter: Date.parse("2027-01-01"),
};

const decision = evaluateToken(grant, {
  permission: "deploy:trigger",
  resource: { scope: "site", target: "fysiodk" },
  ip: "203.0.113.5",
});
// → { allowed: true } | { allowed: false, reason: "expired" | "permission_denied" | "resource_denied" | "ip_denied" }

Cascade order: TTL → permission → resource (exclude wins) → CIDR (IPv4 + IPv6, zero-dep). A scope with no filter is unconstrained.

Tenant selector (trail's selector-not-grant)

import { selectTenant, TenantAccessError } from "@broberg/apikey/authorize";

// A `spansAll` key lets the owner pick any tenant they belong to via a header.
// A non-member slug is a HARD refuse — never a silent fall-back to home.
try {
  const tenant = selectTenant({ requestedSlug, homeTenant, spansAll: true, isMember });
} catch (e) {
  if (e instanceof TenantAccessError) return new Response(null, { status: 401 });
}

Adapters

// Stack B — Hono
import { honoApiKeyMiddleware, honoRateLimit } from "@broberg/apikey/hono";
app.use("/api/*", honoApiKeyMiddleware({ lookup, authorize }));   // 401/403; c.get("apiKey")
app.use("/api/*", honoRateLimit(limiter));                         // 429 + Retry-After

// Stack A — Next.js (Web-standard Request/Response, edge-safe, no `next` dep)
import { withApiKeyAuth, nextRateLimit } from "@broberg/apikey/next";
export const POST = withApiKeyAuth(async (req, record) => Response.json({ ok: true }), { lookup });

Your error contract, not ours (v0.2.0)

The default bodies are { error: "missing_api_key" } etc. — a string. If your API answers something else (e.g. { error: { code, message } }, which a machine client can switch on), render it yourself instead of rewriting the middleware:

app.use("/api/*", honoApiKeyMiddleware({
  lookup,
  authorize,
  onUnauthorized: (c, reason) =>            // reason: "missing" | "invalid"
    c.json({ error: { code: `unauthorized_${reason}` } }, 401),
  onForbidden: (c, record) =>
    c.json({ error: { code: "forbidden", at: record.id } }, 403),
}));

app.use("/api/*", honoRateLimit(limiter, keyFn, {
  onLimited: (c, r) => c.json({ error: { code: "rate_limited", retryAt: r.resetAt } }, 429),
}));

The middleware still decides what happened; the hook only decides how it is written down. Status codes and the X-RateLimit-Remaining / Retry-After headers are unchanged. Omit the hooks and the responses are byte-identical to v0.1.1.

reason is worth acting on: 401 means "I don't know who you are" (no token, unknown token, just-revoked) and is fixed by fetching a token; 403 means "I know exactly who you are, and this isn't yours" and is fixed by requesting a different role. The response is the only thing that tells the caller which.

Rate-limit bucket key. With no keyFn, the limiter keys on x-forwarded-for, falling back to "unknown" when the header is absent. On a loopback-bound service with no proxy in front that means every caller shares one bucket — pass a keyFn that keys on the token/record id instead.

Different caps per route, one limiter (v0.3.0)

keyFn may return { key, max } instead of a bare string, so a single limiter enforces a different cap per route — you do not need a limiter instance per cap:

app.use("/admin/*", honoRateLimit(limiter, (c) => ({ key: tokenId(c), max: 600 })));
app.use("/write/*", honoRateLimit(limiter, (c) => ({ key: tokenId(c), max: 300 })));

Both adapters set X-RateLimit-Limit as well as X-RateLimit-Remaining — Limit reports the effective cap for that request (the per-route max when one applies), because a remaining count you can't interpret is not much use. RateLimitResult.limit carries the same number if you drive the limiter directly.

What reaches your handler (v0.3.0)

By default the whole looked-up record is what c.set(contextKey, …) stores (Hono) and what arrives as the handler's 2nd argument (Next) — including whatever your storage row carries, such as a hash column. A hash is not a usable credential, so this is not a credential leak, but it is more than a handler needs, and one c.json(caller) later it becomes a response body.

Pass project to choose the caller shape yourself:

honoApiKeyMiddleware({ lookup, project: (r) => ({ id: r.id, role: r.role }) });
withApiKeyAuth(handler, { lookup, project: (r) => ({ id: r.id, role: r.role }) });

Adapter asymmetry, stated plainly: the onUnauthorized / onForbidden / onLimited hooks above exist on the Hono adapter only. The Next adapter still emits the built-in string bodies. Filing an issue is the right move if you need them there.

If you render your own 429 body, quote the same result. The headers come from the middleware and the body comes from your onLimited, so they are produced in two places. A caller told "1 per 60s" in the body while X-RateLimit-Limit says 600 cannot tell which to believe. Use result.limit / result.resetAt in the body rather than a constant you hold separately — the package cannot assert this for you, so it is worth a test on your side.

Retry-After is floored at 1 (since v0.3.1): with a shared remote store whose round-trip outlasts the remaining window, the raw computation could go negative and emit Retry-After: 0 — telling a client to retry immediately while still limited.

lookup(presented) => record | null is yours: hash + DB/filesystem read, your storage, your tenancy. The package never sees your store.

Boundaries (what it deliberately does NOT do)

  • No storage — no DB/CRUD layer; you own the schema (Drizzle / libSQL / JSON).
  • No request→tenant resolution — that's your proxy/router; feed the result into selectTenant.
  • No bundled Redis/Turso — ships the RateLimitStore interface + in-memory only.
  • Core crypto is Node/Bun (node:crypto). At the edge, hash via Web Crypto inside your lookup; the adapters themselves are edge-safe.

MIT · part of the broberg.ai shared inventory.

版本与发布记录

@broberg/apikey 共发布 5 个版本,首次发布于 2026/06/16;过去 12 个月发布了 5 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.3.1latest2026/08/073 个月前
0.3.02026/08/073 个月前
0.2.02026/08/073 个月前
0.1.12026/06/164 个月前
0.1.02026/06/164 个月前

关于 @broberg/apikey 的常见问题

@broberg/apikey 是什么?

Framework-agnostic inbound API-key primitives for the broberg.ai fleet: mint prefixed keys, timing-safe verify (hashed or plaintext), sliding-window rate-limit over a pluggable store, a Cloudflare-style authorization cascade (permission × resource-filter

如何安装 @broberg/apikey?

在项目目录运行 npm install @broberg/apikey,或使用 pnpm add / yarn add / bun add。

@broberg/apikey 支持 TypeScript 吗?

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

@broberg/apikey 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@broberg/apikey 可以商用吗?

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

@broberg/apikey 的下载量有多少?

根据 npm 官方下载统计,@broberg/apikey 在 2026/10/09 – 2026/11/07 这 30 天内被下载 7,472 次,日均约 249 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@broberg/apikey 还在维护吗?

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