@cv/auth-bearer
Shared SHA-256-hashed bearer token registry for every CareerVector + JobCache worker that needs service / agent / CI authentication. Stateless, env-var backed, scope-gated. Designed to be the canonical service-auth primitive across the monorepo, replacing the four bearer-dialect implementations that existed before extraction.
Why this exists
Before extraction, four different bearer-auth implementations lived in the codebase:
| Where | Mechanism |
|---|---|
lib/qa-platform/src/api-core/middleware/auth.ts |
SHA-256-hashed registry, scopes array |
lib/qa-platform/src/mcp-core/middleware/auth.ts |
SHA-256-hashed registry, single scope per entry |
ops/api/src/lib/auth.ts |
CF-Access-JWT presence check + bearer + cookie |
lib/server/src/jobcache-control-auth.ts |
Plain string comparison |
jobcache/ingest/src/jobs/heavy-worker-app.ts |
Plain string comparison |
One library, one model.
Storage model
Each consumer worker has an env var (name varies — QA_TOKENS_JSON,
QA_MCP_TOKENS_JSON, etc.) holding a JSON object mapping the SHA-256 hex
digest of each issued token to its caller metadata:
{
"<64-char-sha256-hex>": { "label": "ci-ingest@circleci", "scopes": ["ci-ingest"] },
"<64-char-sha256-hex>": { "label": "admin@julian", "scopes": ["admin"] }
}
Tokens themselves never live on the worker side — only their hashes do. A leaked wrangler secret does not yield plaintext tokens.
For backward compatibility, the parser also accepts two legacy shapes that existed in qa-platform pre-extraction:
{ token_name, scopes: [...] }(api-core flavour){ label, scope: "..." }(mcp-core flavour, single-scope)
Both normalise to the canonical { label, scopes: [...] } shape internally,
so no env-var rotation is required when a consumer migrates.
Provisioning a token
# Generate a random opaque token (32 bytes base32):
node -e 'console.log("cv_" + crypto.randomBytes(20).toString("base32"))'
# → cv_abc123...
# Hash it (the value that goes into the env var):
node -e 'console.log(crypto.createHash("sha256").update(process.argv[1]).digest("hex"))' cv_abc123...
# Add to the registry JSON:
{ "<sha256-hex>": { "label": "my-agent@laptop", "scopes": ["mcp-read"] } }
# Set on the worker:
wrangler secret put QA_MCP_TOKENS_JSON --env production
# (paste the entire JSON object)
Give the plaintext token to the agent / CI runner; never store it unencrypted anywhere centrally. Treat as a credential.
API
Core (@cv/auth-bearer)
sha256Hex(input: string): Promise<string>
extractBearer(authHeader: string | null | undefined): string | null
parseRegistry(source: string | undefined, opts?: { validScopes?: ReadonlySet<string> }): Map<string, Caller>
scopeAllows(callerScopes: readonly string[], required: string): boolean
authenticate({ request, registrySource, validScopes? }): Promise<AuthResult>
interface Caller { label: string; scopes: string[] }
Use authenticate(...) for plain-Workers consumers that aren't on Hono.
Hono adapter (@cv/auth-bearer/hono)
makeRequireAuth<TEnv, TVars>({ getRegistrySource, validScopes?, contextKey? }): MiddlewareHandler
makeRequireScope<TEnv, TVars>(scope, opts?): MiddlewareHandler
makeRequireAuth returns a middleware that resolves the bearer and sets the
caller on the Hono context (default key 'caller'). makeRequireScope
enforces a scope on the already-resolved caller (apply after
makeRequireAuth in the chain).
Scope hierarchy
The built-in scopeAllows recognises two implications:
adminimplies every scopemcp-triggerimpliesmcp-read
Otherwise: flat equality match. Consumers with their own hierarchy needs can write their own check; nothing else in this library assumes hierarchy.
Security notes
- 401 responses are generic by design — never disclose whether the token was unknown, malformed, or scope-insufficient. Scope failures use 403, on a valid token only.
- Token hashes only — plaintext tokens never live on the worker side.
- Module-scope per-process cache keyed by the env-var string — we don't re-parse the JSON on every request. CF Workers can swap modules between invocations; the cache re-warms automatically.
- This library is for service / agent / CI authentication. Human users
authenticate via Better Auth + WebAuthn passkeys (see
wiki/content/architecture/PRIVACY-POSTURE.md).