managarten/packages/shared-hono/src/tier.ts
Till JS 76d11a84ee feat(auth): server-side tier gating via requireTier middleware
The JWT already carried a `tier` claim but nothing on the server read it
— AuthGate enforcement was client-only, so a valid JWT could hit paid
LLM/research endpoints regardless of the user's access tier.

- shared-hono authMiddleware now extracts `tier` into `c.userTier`,
  defaulting unknown/missing claims to `public` (never silently grants
  higher access).
- New `requireTier(minTier)` middleware + `hasTier`/`getTierLevel`
  helpers. Tier hierarchy (guest < public < beta < alpha < founder) is
  mirrored locally to avoid pulling the Svelte-facing shared-branding
  package into Bun services.
- Applied `requireTier('beta')` as defense-in-depth on resource-heavy
  apps/api modules (chat, context, food, guides, news-research, picture,
  plants, research, traces, who) and the MCP endpoint. Pure CRUD modules
  stay auth-only — access there is gated by ownership, not tier.
- DEV_BYPASS_AUTH now injects `userTier` (defaults to founder, override
  via DEV_USER_TIER).
- Authentication guideline documents the pattern + test suite covers
  hierarchy, passes-at-minimum, and rejection paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:38:06 +02:00

59 lines
1.5 KiB
TypeScript

/**
* Access tier helpers for server-side gating.
*
* Mirrors the hierarchy in @mana/shared-branding/mana-apps.ts. Kept here so
* Bun/Hono services don't need to pull in a Svelte-facing package.
*
* If you change the hierarchy, update BOTH files.
*/
import type { Context, Next } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { AccessTier } from './types';
const TIER_LEVELS: Record<AccessTier, number> = {
guest: 0,
public: 1,
beta: 2,
alpha: 3,
founder: 4,
};
function normalizeTier(value: unknown): AccessTier {
if (typeof value === 'string' && value in TIER_LEVELS) {
return value as AccessTier;
}
return 'public';
}
export function getTierLevel(tier: string | undefined): number {
if (!tier) return 0;
return TIER_LEVELS[tier as AccessTier] ?? 0;
}
export function hasTier(userTier: string | undefined, minTier: AccessTier): boolean {
return getTierLevel(userTier) >= TIER_LEVELS[minTier];
}
/**
* Require a minimum access tier on a Hono route.
*
* Must run AFTER `authMiddleware()` so `userTier` is set on the context.
*
* Usage:
* ```ts
* app.use('/api/*', authMiddleware());
* app.post('/api/v1/ai/generate', requireTier('alpha'), handler);
* ```
*/
export function requireTier(minTier: AccessTier) {
return async (c: Context, next: Next) => {
const userTier = normalizeTier(c.get('userTier'));
if (!hasTier(userTier, minTier)) {
throw new HTTPException(403, {
message: `Requires access tier '${minTier}' — current tier '${userTier}' is insufficient.`,
});
}
return next();
};
}