@steve31415/baselib 3.26.0 → 3.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ What it provides and why: `docs/SPEC.md`. How it's put together:
5
5
  `docs/IMPL.md`. Full design rationale with alternatives:
6
6
  `~/migration/research/base-services-design.md`.
7
7
 
8
- Server subpath exports: `config`, `log`, `auth`, `s2s`, `db`, `http`, `sync`,
8
+ Server subpath exports: `config`, `log`, `auth`, `oauth-resource`, `s2s`, `db`, `http`, `sync`,
9
9
  `app-update`, `llm`, `email`, and `twitter`. Browser exports: `log-browser`, `rum`,
10
10
  `slow-fetch`, `sync-browser`, and `app-update-browser`.
11
11
 
package/dist/http.d.ts CHANGED
@@ -5,6 +5,16 @@ import type { Logger } from './log-core.js';
5
5
  * identity); /health excluded. Entry-point detail beyond this (key
6
6
  * parameters, outcomes) is the handler's job per OPERATIONS.md. */
7
7
  export declare function requestLogging(logger: Logger): import("hono").MiddlewareHandler<any, string, {}, Response>;
8
+ /** The address of the client that connected to Google's edge, read from
9
+ * X-Forwarded-For — for per-source rate limits, never for authorization.
10
+ * Google appends to whatever the client sent, so only the tail can be
11
+ * trusted: through the fleet load balancer the header ends
12
+ * `<client>,<load balancer>` (Google's Application Load Balancer docs: it
13
+ * "does not verify any IP addresses that precede" those two), and a request
14
+ * made straight to the service's run.app URL ends `<client>`. `canonicalHost`
15
+ * is the app's public host: a request for any other host did not come
16
+ * through the load balancer. */
17
+ export declare function forwardedClientIp(c: Context, canonicalHost: string): string;
8
18
  export declare function addHealth(app: Hono): void;
9
19
  /** app.onError handler: log with full context, return a plain 500. */
10
20
  export declare function errorHandler(logger: Logger): (err: Error, c: Context) => (Response & import("hono").TypedResponse<{
package/dist/http.js CHANGED
@@ -25,6 +25,23 @@ export function requestLogging(logger) {
25
25
  });
26
26
  });
27
27
  }
28
+ /** The address of the client that connected to Google's edge, read from
29
+ * X-Forwarded-For — for per-source rate limits, never for authorization.
30
+ * Google appends to whatever the client sent, so only the tail can be
31
+ * trusted: through the fleet load balancer the header ends
32
+ * `<client>,<load balancer>` (Google's Application Load Balancer docs: it
33
+ * "does not verify any IP addresses that precede" those two), and a request
34
+ * made straight to the service's run.app URL ends `<client>`. `canonicalHost`
35
+ * is the app's public host: a request for any other host did not come
36
+ * through the load balancer. */
37
+ export function forwardedClientIp(c, canonicalHost) {
38
+ const hops = (c.req.header('x-forwarded-for') ?? '')
39
+ .split(',')
40
+ .map((hop) => hop.trim())
41
+ .filter(Boolean);
42
+ const viaLoadBalancer = (c.req.header('host') ?? '').toLowerCase() === canonicalHost.toLowerCase();
43
+ return hops.at(viaLoadBalancer ? -2 : -1) ?? 'unknown';
44
+ }
28
45
  export function addHealth(app) {
29
46
  app.get('/health', (c) => c.json({
30
47
  ok: true,
@@ -1,4 +1,11 @@
1
- import type { LLMRequest, LLMResponse } from './types.js';
1
+ import type { LLMRequest, LLMResponse, Effort } from './types.js';
2
+ import type { ModelEntry } from './models.js';
3
+ /**
4
+ * The effort level to send a model: none when the model has no effort
5
+ * control, otherwise the requested level clamped to the nearest one the
6
+ * model accepts (e.g. `minimal` → `low` on models without a `minimal`).
7
+ */
8
+ export declare function resolveEffort(entry: ModelEntry, effort: Effort | undefined): Effort | undefined;
2
9
  /**
3
10
  * Call an LLM with automatic failover to alternative providers.
4
11
  *
@@ -1,9 +1,25 @@
1
- import { LLMError } from './types.js';
1
+ import { LLMError, EFFORT_LEVELS } from './types.js';
2
2
  import { MODEL_REGISTRY, THINKING_HEADROOM_WARN_TOKENS } from './models.js';
3
3
  import { providers } from './providers/index.js';
4
4
  const DEFAULT_MAX_TOKENS = 2048;
5
5
  const DEFAULT_TEMPERATURE = 0.3;
6
6
  const DEFAULT_TIMEOUT_MS = 60_000;
7
+ const DEFAULT_THINKING_LEVELS = ['low', 'medium', 'high'];
8
+ /**
9
+ * The effort level to send a model: none when the model has no effort
10
+ * control, otherwise the requested level clamped to the nearest one the
11
+ * model accepts (e.g. `minimal` → `low` on models without a `minimal`).
12
+ */
13
+ export function resolveEffort(entry, effort) {
14
+ if (!entry.supportsEffort || effort === undefined)
15
+ return undefined;
16
+ const levels = entry.thinkingLevels ?? DEFAULT_THINKING_LEVELS;
17
+ if (levels.includes(effort))
18
+ return effort;
19
+ const rank = (level) => EFFORT_LEVELS.indexOf(level);
20
+ const wanted = rank(effort);
21
+ return [...levels].sort((a, b) => Math.abs(rank(a) - wanted) - Math.abs(rank(b) - wanted))[0];
22
+ }
7
23
  /**
8
24
  * Call an LLM with automatic failover to alternative providers.
9
25
  *
@@ -42,6 +58,16 @@ export async function llmComplete(request) {
42
58
  ...(request.callSite ? { callSite: request.callSite } : {}),
43
59
  });
44
60
  }
61
+ // Google is retiring the Gemini thinking budget in favor of levels (see
62
+ // LLMRequest.thinkingBudget). Flag every caller still passing one so the
63
+ // migration to `effort` is visible in the logs, not discovered by a 400.
64
+ if (request.thinkingBudget !== undefined) {
65
+ request.logger?.warn('llm.thinking_budget_deprecated', {
66
+ model: request.model,
67
+ thinkingBudget: request.thinkingBudget,
68
+ ...(request.callSite ? { callSite: request.callSite } : {}),
69
+ });
70
+ }
45
71
  // Build the ordered list of models to try: primary + fallbacks
46
72
  const modelsToTry = [request.model, ...entry.fallbacks];
47
73
  const failedAttempts = [];
@@ -67,7 +93,7 @@ export async function llmComplete(request) {
67
93
  supportsTemperature: modelEntry.supportsTemperature ?? true,
68
94
  supportsThinkingBudgetZero: modelEntry.supportsThinkingBudgetZero ?? true,
69
95
  supportsForcedToolChoice: modelEntry.supportsForcedToolChoice ?? true,
70
- effort: modelEntry.supportsEffort ? request.effort : undefined,
96
+ effort: resolveEffort(modelEntry, request.effort),
71
97
  jsonSchema: request.jsonSchema,
72
98
  apiKey,
73
99
  logger: request.logger,
@@ -1,4 +1,4 @@
1
1
  export { llmComplete } from './complete.js';
2
2
  export { MODEL_REGISTRY } from './models.js';
3
3
  export type { ModelEntry } from './models.js';
4
- export { LLMError, type LLMRequest, type LLMResponse, type FailedAttempt, type FinishCategory, type ProviderName, type ApiKeys, type JsonSchema, } from './types.js';
4
+ export { LLMError, EFFORT_LEVELS, type Effort, type LLMRequest, type LLMResponse, type FailedAttempt, type FinishCategory, type ProviderName, type ApiKeys, type JsonSchema, } from './types.js';
package/dist/llm/index.js CHANGED
@@ -7,4 +7,4 @@
7
7
  // Public API
8
8
  export { llmComplete } from './complete.js';
9
9
  export { MODEL_REGISTRY } from './models.js';
10
- export { LLMError, } from './types.js';
10
+ export { LLMError, EFFORT_LEVELS, } from './types.js';
@@ -1,4 +1,4 @@
1
- import type { ProviderName } from './types.js';
1
+ import type { ProviderName, Effort } from './types.js';
2
2
  export interface ModelEntry {
3
3
  /** Provider that serves this model. */
4
4
  provider: ProviderName;
@@ -22,12 +22,22 @@ export interface ModelEntry {
22
22
  */
23
23
  supportsThinkingBudgetZero?: boolean;
24
24
  /**
25
- * Whether this model accepts Anthropic's `output_config.effort` parameter
26
- * (verified on Sonnet 5 / Sonnet 4.6 2026-08-10; documented GA on Opus
27
- * 4.6+/5). Defaults to `false`; the effort option is dropped for models
28
- * without it.
25
+ * Whether this model accepts a reasoning-effort level: Anthropic's
26
+ * `output_config.effort` (verified on Sonnet 5 / Sonnet 4.6 2026-08-10;
27
+ * documented GA on Opus 4.6+/5) or Gemini's `thinkingConfig.thinkingLevel`
28
+ * (every Gemini 3.x model; verified live 2026-10-06). Defaults to `false`;
29
+ * the effort option is dropped for models without it.
29
30
  */
30
31
  supportsEffort?: boolean;
32
+ /**
33
+ * The effort levels this model accepts, for `supportsEffort` models whose
34
+ * set differs from the default `low | medium | high`. A requested level
35
+ * outside the set is clamped to the nearest one in it (`minimal` → `low`).
36
+ * Gemini 3.5 Flash-Lite, 3.6 Flash and 3 Flash preview also take `minimal`
37
+ * ("no hidden reasoning"); 3.1 Pro preview does not (Google's per-model
38
+ * table, 2026-10-06).
39
+ */
40
+ thinkingLevels?: readonly Effort[];
31
41
  /**
32
42
  * Whether this model performs hidden reasoning when the request omits any
33
43
  * thinking configuration (which this library always does). Defaults to
@@ -11,7 +11,12 @@ export const THINKING_HEADROOM_WARN_TOKENS = 4096;
11
11
  export const MODEL_REGISTRY = {
12
12
  // 2026-08 suite refresh: current models are claude-sonnet-5 / claude-opus-5
13
13
  // (Anthropic; claude-opus-5-5 succeeded claude-opus-5 on 2026-09-22), the gpt-5.6 sol/terra/luna family (OpenAI), and the Gemini
14
- // 3.5/3.6 GA line plus gemini-3.1-pro-preview (Google). Older names stay
14
+ // 3.5/3.6 GA line plus gemini-3.1-pro-preview (Google). Google's 2026-10
15
+ // deprecation notice: `temperature`/`topP`/`topK` have had no effect since
16
+ // 3.6 Flash and, like `thinkingBudget`, will 400 on models after 3.8 Flash
17
+ // — hence `supportsTemperature: false` from 3.6 Flash on and `effort`
18
+ // (thinking level) as the reasoning control on every Gemini 3.x entry.
19
+ // Older names stay
15
20
  // registered so existing callers keep working, but no fallback chain routes
16
21
  // through a retiring model: gemini-2.5-pro and gemini-2.5-flash shut down
17
22
  // 2026-10-16, and the gpt-4o snapshots retire from 2026-10 onward.
@@ -30,6 +35,8 @@ export const MODEL_REGISTRY = {
30
35
  // so no `thinksByDefault` headroom warning.
31
36
  // Rejects thinkingBudget 0 (400, verified 2026-08-09).
32
37
  supportsThinkingBudgetZero: false,
38
+ supportsEffort: true,
39
+ thinkingLevels: ['minimal', 'low', 'medium', 'high'],
33
40
  },
34
41
  'gemini-3.6-flash': {
35
42
  provider: 'gemini',
@@ -40,6 +47,11 @@ export const MODEL_REGISTRY = {
40
47
  thinksByDefault: true,
41
48
  // Rejects thinkingBudget 0 (400, verified 2026-08-09).
42
49
  supportsThinkingBudgetZero: false,
50
+ supportsEffort: true,
51
+ thinkingLevels: ['minimal', 'low', 'medium', 'high'],
52
+ // Sampling parameters are fixed at their defaults from this model on
53
+ // (Google, 2026-10); still accepted today, so omitting changes nothing.
54
+ supportsTemperature: false,
43
55
  },
44
56
  'gpt-5.6-luna': {
45
57
  provider: 'openai',
@@ -63,11 +75,14 @@ export const MODEL_REGISTRY = {
63
75
  provider: 'gemini',
64
76
  apiModelId: 'gemini-3.1-flash-lite',
65
77
  fallbacks: ['claude-sonnet-4-6'],
78
+ supportsEffort: true,
66
79
  },
67
80
  'gemini-3-flash-preview': {
68
81
  provider: 'gemini',
69
82
  apiModelId: 'gemini-3-flash-preview',
70
83
  fallbacks: ['claude-haiku-4-5', 'gpt-5.6-luna'],
84
+ supportsEffort: true,
85
+ thinkingLevels: ['minimal', 'low', 'medium', 'high'],
71
86
  },
72
87
  'gpt-4o-mini': {
73
88
  provider: 'openai',
@@ -181,6 +196,8 @@ export const MODEL_REGISTRY = {
181
196
  thinksByDefault: true,
182
197
  // Rejects thinkingBudget 0 (400, verified 2026-08-09).
183
198
  supportsThinkingBudgetZero: false,
199
+ // Levels low|medium|high only (no `minimal`) — the default set.
200
+ supportsEffort: true,
184
201
  },
185
202
  // Retiring 2026-10-16 — migrate callers to gemini-3.1-pro-preview.
186
203
  'gemini-2.5-pro': {
@@ -53,13 +53,18 @@ export const geminiProvider = {
53
53
  generationConfig.responseSchema = stripUnsupportedSchemaFields(request.jsonSchema.schema);
54
54
  }
55
55
  // Gemini thinking models reason before answering; the reasoning tokens
56
- // count against maxOutputTokens. Capping the thinking budget reserves
57
- // headroom for the visible response so a long reasoning pass can't
58
- // truncate it. Models with a thinking floor (registry
56
+ // count against maxOutputTokens. `effort` (pre-gated and clamped in
57
+ // complete.ts) is the current control, sent as a thinking level — not a
58
+ // token cap, so callers size maxOutputTokens for it. The deprecated
59
+ // thinkingBudget is still forwarded when no level is set (the API rejects
60
+ // both together). Models with a thinking floor (registry
59
61
  // `supportsThinkingBudgetZero: false`) reject an explicit 0 budget with a
60
62
  // 400 — for those a 0 means "as little thinking as possible", which their
61
63
  // default already is, so the config is omitted entirely.
62
- if (request.thinkingBudget !== undefined
64
+ if (request.effort) {
65
+ generationConfig.thinkingConfig = { thinkingLevel: request.effort };
66
+ }
67
+ else if (request.thinkingBudget !== undefined
63
68
  && (request.thinkingBudget !== 0 || request.supportsThinkingBudgetZero)) {
64
69
  generationConfig.thinkingConfig = { thinkingBudget: request.thinkingBudget };
65
70
  }
@@ -35,21 +35,25 @@ export interface LLMRequest {
35
35
  /** Timeout per provider attempt, including response body download, in ms. Default: 60000. */
36
36
  timeoutMs?: number;
37
37
  /**
38
- * Cap on reasoning/thinking tokens for models that support hidden reasoning
39
- * (currently Gemini 2.5 family). Reasoning tokens are charged against the
40
- * same budget as output tokens, so when thinking is unbounded it can starve
41
- * the visible response and cause mid-string truncation. Setting this
42
- * reserves headroom for the response. Ignored by providers that don't
43
- * expose a thinking budget.
38
+ * @deprecated Use `effort`. Gemini's token cap on hidden reasoning. Gemini
39
+ * 3.x already remaps a budget to a thinking *level* (so it is no longer a
40
+ * cap), and Google has announced that models after 3.8 Flash reject it
41
+ * outright (400). Still forwarded to Gemini when `effort` is absent, with
42
+ * WARN `llm.thinking_budget_deprecated` per call; ignored by Anthropic and
43
+ * OpenAI, and ignored by Gemini when `effort` is also set.
44
44
  */
45
45
  thinkingBudget?: number;
46
46
  /**
47
- * Output-effort level for models that support Anthropic's `output_config.
48
- * effort` (registry `supportsEffort`). Lower effort trades thoroughness for
49
- * latency/cost. Currently honored on Anthropic models only; when a call
50
- * fails over to another provider the fallback runs at its own defaults.
47
+ * Reasoning-effort level for models that expose one (registry
48
+ * `supportsEffort`): Anthropic `output_config.effort`, Gemini
49
+ * `thinkingConfig.thinkingLevel`. Lower effort trades thoroughness for
50
+ * latency and cost. A level the target model lacks is clamped to its
51
+ * nearest supported level (registry `thinkingLevels`; `minimal` → `low` on
52
+ * Anthropic). Dropped for models without the control, so a failed-over
53
+ * call runs at the fallback's defaults. Levels are not token caps: size
54
+ * `maxTokens` for the reasoning the level will do.
51
55
  */
52
- effort?: 'low' | 'medium' | 'high';
56
+ effort?: Effort;
53
57
  /**
54
58
  * Optional short identifier for the calling feature (e.g. 'video_classify',
55
59
  * 'message_prioritize', 'log_summary'). Included in llm.request and
@@ -80,6 +84,14 @@ export interface LLMRequest {
80
84
  * provider-specific vocabulary local to the adapter that owns it.
81
85
  */
82
86
  export type FinishCategory = 'complete' | 'truncated' | 'declined' | 'other';
87
+ /**
88
+ * Reasoning-effort levels, ordered from least to most. Each provider adapter
89
+ * maps a level to its native control (Anthropic `output_config.effort`,
90
+ * Gemini `thinkingConfig.thinkingLevel`); `minimal` exists only on some
91
+ * Gemini models and means "no hidden reasoning".
92
+ */
93
+ export declare const EFFORT_LEVELS: readonly ["minimal", "low", "medium", "high"];
94
+ export type Effort = (typeof EFFORT_LEVELS)[number];
83
95
  /** Successful response from llmComplete(). */
84
96
  export interface LLMResponse {
85
97
  /** The text output (or JSON stringified if jsonSchema was used). */
@@ -188,8 +200,11 @@ export interface ProviderCallRequest {
188
200
  logger?: Logger;
189
201
  timeoutMs: number;
190
202
  thinkingBudget?: number;
191
- /** Effort level, pre-gated by the registry's `supportsEffort` (absent when unsupported). */
192
- effort?: 'low' | 'medium' | 'high';
203
+ /**
204
+ * Effort level, pre-gated by the registry's `supportsEffort` (absent when
205
+ * unsupported) and clamped to the model's `thinkingLevels`.
206
+ */
207
+ effort?: Effort;
193
208
  callSite?: string;
194
209
  cacheSystemPrompt?: boolean;
195
210
  }
package/dist/llm/types.js CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Reasoning-effort levels, ordered from least to most. Each provider adapter
3
+ * maps a level to its native control (Anthropic `output_config.effort`,
4
+ * Gemini `thinkingConfig.thinkingLevel`); `minimal` exists only on some
5
+ * Gemini models and means "no hidden reasoning".
6
+ */
7
+ export const EFFORT_LEVELS = ['minimal', 'low', 'medium', 'high'];
1
8
  /** Error thrown when an LLM provider call fails. Its message may contain private input; do not log verbatim. */
2
9
  export class LLMError extends Error {
3
10
  provider;
@@ -0,0 +1,85 @@
1
+ import type { Context } from 'hono';
2
+ import type { UserAccessPolicy, UserRole } from './auth.js';
3
+ import type { Logger } from './log-core.js';
4
+ import { serviceFetch } from './s2s.js';
5
+ /** The exact shape of an access token: the prefix plus 32 random bytes in
6
+ * base64url. Anything else is refused before any cache or network work. */
7
+ export declare const ACCESS_TOKEN_PATTERN: RegExp;
8
+ /** A user acting through a connector, limited to the scopes they approved. */
9
+ export interface ConnectorUser {
10
+ type: 'connector';
11
+ email: string;
12
+ name?: string;
13
+ userId?: string;
14
+ role: UserRole;
15
+ scopes: ReadonlySet<string>;
16
+ /** The OAuth client the user approved (e.g. Claude's client ID URL). */
17
+ clientId: string;
18
+ /** The consent this token descends from; revoking it kills the token. */
19
+ grantId: string;
20
+ }
21
+ declare module 'hono' {
22
+ interface ContextVariableMap {
23
+ connector: ConnectorUser;
24
+ }
25
+ }
26
+ export interface ConnectorResourceOptions {
27
+ /** This endpoint's exact public URL, e.g. https://notes.apps.snewman.net/mcp
28
+ * — the OAuth resource tokens must have been issued for. */
29
+ resource: string;
30
+ /** Every scope this resource defines (advertised in metadata and
31
+ * challenges). */
32
+ scopes: readonly string[];
33
+ /** Base URL of the auth service (the authorization server). */
34
+ authUrl?: string;
35
+ }
36
+ export interface RequireConnectorUserOptions extends ConnectorResourceOptions {
37
+ /** Which users this app admits — the same policy as requireIdentity. */
38
+ userAccess?: UserAccessPolicy;
39
+ logger?: Logger;
40
+ /** Per-user request ceiling (default 600/min). */
41
+ rateLimitPerMin?: number;
42
+ /** Injectable for tests: performs the introspection call for one token. */
43
+ introspect?: (token: string) => Promise<Response>;
44
+ cache?: {
45
+ hitMs?: number;
46
+ missMs?: number;
47
+ maxEntries?: number;
48
+ };
49
+ /** Uncached tokens one source address may have introspected per minute
50
+ * (default 60). A connector's own traffic needs one per token per cache
51
+ * window, so this only ever binds on a stream of unknown tokens. */
52
+ introspectionsPerSourcePerMin?: number;
53
+ now?: () => number;
54
+ }
55
+ /** The introspection call itself: s2s OIDC to the auth service, bounded in
56
+ * time including the ID-token mint. */
57
+ export declare function introspectAtAuth(authUrl: string, token: string, fetcher?: typeof serviceFetch): Promise<Response>;
58
+ /** Where the RFC 9728 document for `resource` is served: the well-known
59
+ * prefix inserted before the resource's path. Built from configuration,
60
+ * never from the request URL (which is http:// behind the load balancer). */
61
+ export declare function resourceMetadataUrl(resource: string): string;
62
+ /** The RFC 9728 protected-resource metadata document. */
63
+ export declare function protectedResourceMetadata(opts: ConnectorResourceOptions): {
64
+ resource: string;
65
+ authorization_servers: string[];
66
+ scopes_supported: string[];
67
+ bearer_methods_supported: string[];
68
+ };
69
+ /** 403 that asks the client to re-run consent for more scope. The challenge
70
+ * names every scope of the resource, because a client does not reliably
71
+ * carry earlier scopes into a step-up. */
72
+ export declare function insufficientScope(c: Context, opts: ConnectorResourceOptions): Response & import("hono").TypedResponse<{
73
+ error: string;
74
+ }, 403, "json">;
75
+ export declare function requireConnectorUser(opts: RequireConnectorUserOptions): import("hono").MiddlewareHandler<any, string, {}, (Response & import("hono").TypedResponse<{
76
+ error: string;
77
+ }, 401, "json">) | (Response & import("hono").TypedResponse<{
78
+ error: string;
79
+ code: string;
80
+ email: string;
81
+ }, 403, "json">) | (Response & import("hono").TypedResponse<{
82
+ error: string;
83
+ }, 429, "json">) | (Response & import("hono").TypedResponse<{
84
+ error: string;
85
+ }, 503, "json">)>;
@@ -0,0 +1,257 @@
1
+ // Resource-server side of the fleet's OAuth access tokens — the credential a
2
+ // connector (claude.ai's MCP client) presents on a user's behalf.
3
+ //
4
+ // The auth service is the authorization server: it shows the consent page and
5
+ // issues opaque `pwat_…` access tokens bound to exactly one resource (an
6
+ // app's MCP endpoint URL) and a scope list. An app that serves such an
7
+ // endpoint mounts requireConnectorUser() on that route ONLY. It is
8
+ // deliberately separate from requireIdentity(): a connector token must never
9
+ // be admitted on an app's ordinary user routes, so it produces its own
10
+ // context variable (`connector`) and never sets `identity` or `user`.
11
+ //
12
+ // Resolution, in order:
13
+ // 1. Test bypass (AUTH_MODE=test): X-Test-Connector-User (+ -Role, -Scope).
14
+ // Credential verification is bypassed; the access policy and the app's
15
+ // scope checks still run.
16
+ // 2. Authorization: Bearer pwat_… → introspection at the auth service over
17
+ // s2s OIDC, cached in-instance (5 min hits, capped by the token's own
18
+ // expiry / 30 s misses, bounded LRU). Introspections are budgeted per
19
+ // source address, so a stream of junk tokens costs the auth service a
20
+ // bounded number of calls and costs other callers nothing.
21
+ //
22
+ // Failure mapping matters to the client. A 401 makes it discard its token and
23
+ // re-run sign-in, so 401 is reserved for "this token is not valid here".
24
+ // Anything that is OUR fault or the auth service's (unreachable, slow, any
25
+ // non-200, a malformed answer) is 503 and is never cached.
26
+ import { createHash } from 'node:crypto';
27
+ import { createMiddleware } from 'hono/factory';
28
+ import { ACCESS_DENIED_CODE, DEFAULT_AUTH_URL, userAllowed } from './auth.js';
29
+ import { authMode } from './config.js';
30
+ import { forwardedClientIp } from './http.js';
31
+ import { RateLimiter, serviceFetch } from './s2s.js';
32
+ /** The exact shape of an access token: the prefix plus 32 random bytes in
33
+ * base64url. Anything else is refused before any cache or network work. */
34
+ export const ACCESS_TOKEN_PATTERN = /^pwat_[A-Za-z0-9_-]{43}$/;
35
+ const INTROSPECT_TIMEOUT_MS = 5_000;
36
+ /** The introspection call itself: s2s OIDC to the auth service, bounded in
37
+ * time including the ID-token mint. */
38
+ export function introspectAtAuth(authUrl, token, fetcher = serviceFetch) {
39
+ let timer;
40
+ const timeout = new Promise((_, reject) => {
41
+ timer = setTimeout(() => reject(new Error('introspection timed out')), INTROSPECT_TIMEOUT_MS);
42
+ });
43
+ const call = fetcher(`${authUrl}/api/oauth/introspect`, {
44
+ audience: authUrl,
45
+ method: 'POST',
46
+ headers: { 'content-type': 'application/json' },
47
+ body: JSON.stringify({ token }),
48
+ signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS),
49
+ });
50
+ return Promise.race([call, timeout]).finally(() => clearTimeout(timer));
51
+ }
52
+ function authBase(opts) {
53
+ return (opts.authUrl ?? DEFAULT_AUTH_URL).replace(/\/$/, '');
54
+ }
55
+ /** Where the RFC 9728 document for `resource` is served: the well-known
56
+ * prefix inserted before the resource's path. Built from configuration,
57
+ * never from the request URL (which is http:// behind the load balancer). */
58
+ export function resourceMetadataUrl(resource) {
59
+ const url = new URL(resource);
60
+ const path = url.pathname === '/' ? '' : url.pathname;
61
+ return `${url.origin}/.well-known/oauth-protected-resource${path}`;
62
+ }
63
+ /** The RFC 9728 protected-resource metadata document. */
64
+ export function protectedResourceMetadata(opts) {
65
+ return {
66
+ resource: opts.resource,
67
+ authorization_servers: [authBase(opts)],
68
+ scopes_supported: [...opts.scopes],
69
+ bearer_methods_supported: ['header'],
70
+ };
71
+ }
72
+ function challenge(opts, error) {
73
+ return [
74
+ ...(error ? [`error="${error}"`] : []),
75
+ `resource_metadata="${resourceMetadataUrl(opts.resource)}"`,
76
+ `scope="${opts.scopes.join(' ')}"`,
77
+ ].join(', ');
78
+ }
79
+ function unauthorized(c, opts, error) {
80
+ c.header('www-authenticate', `Bearer ${challenge(opts, error)}`);
81
+ c.header('cache-control', 'no-store');
82
+ return c.json({ error: error ?? 'unauthorized' }, 401);
83
+ }
84
+ /** 403 that asks the client to re-run consent for more scope. The challenge
85
+ * names every scope of the resource, because a client does not reliably
86
+ * carry earlier scopes into a step-up. */
87
+ export function insufficientScope(c, opts) {
88
+ c.header('www-authenticate', `Bearer ${challenge(opts, 'insufficient_scope')}`);
89
+ c.header('cache-control', 'no-store');
90
+ return c.json({ error: 'insufficient_scope' }, 403);
91
+ }
92
+ /** A Map in insertion order doubles as an LRU: a hit is re-inserted, and the
93
+ * oldest key is dropped when full. Unauthenticated callers choose the keys,
94
+ * so the bound is what keeps them from growing the process. */
95
+ class LruCache {
96
+ maxEntries;
97
+ entries = new Map();
98
+ constructor(maxEntries) {
99
+ this.maxEntries = maxEntries;
100
+ }
101
+ get(key, now) {
102
+ const entry = this.entries.get(key);
103
+ if (!entry)
104
+ return undefined;
105
+ this.entries.delete(key);
106
+ if (entry.expires <= now)
107
+ return undefined;
108
+ this.entries.set(key, entry);
109
+ return entry;
110
+ }
111
+ set(key, entry) {
112
+ this.entries.delete(key);
113
+ this.entries.set(key, entry);
114
+ if (this.entries.size > this.maxEntries) {
115
+ this.entries.delete(this.entries.keys().next().value);
116
+ }
117
+ }
118
+ }
119
+ function parseRole(value) {
120
+ return value === 'owner' || value === 'invited' ? value : undefined;
121
+ }
122
+ function parseScopes(value) {
123
+ return new Set(typeof value === 'string' ? value.split(' ').filter(Boolean) : []);
124
+ }
125
+ function nonEmptyString(value) {
126
+ return typeof value === 'string' && value.length > 0;
127
+ }
128
+ export function requireConnectorUser(opts) {
129
+ const policy = opts.userAccess ?? 'owner';
130
+ const hitMs = opts.cache?.hitMs ?? 300_000;
131
+ const missMs = opts.cache?.missMs ?? 30_000;
132
+ const cache = new LruCache(opts.cache?.maxEntries ?? 1_000);
133
+ const now = opts.now ?? Date.now;
134
+ const limiter = new RateLimiter(opts.rateLimitPerMin ?? 600);
135
+ const sourceBudget = new RateLimiter(opts.introspectionsPerSourcePerMin ?? 60);
136
+ const resourceHost = new URL(opts.resource).host;
137
+ const introspect = opts.introspect ?? ((token) => introspectAtAuth(authBase(opts), token));
138
+ const unavailable = (c) => {
139
+ c.header('cache-control', 'no-store');
140
+ return c.json({ error: 'auth service unavailable' }, 503);
141
+ };
142
+ const rateLimited = (c) => {
143
+ c.header('cache-control', 'no-store');
144
+ return c.json({ error: 'rate limit exceeded' }, 429);
145
+ };
146
+ return createMiddleware(async (c, next) => {
147
+ const admit = (user) => {
148
+ if (!userAllowed(policy, user)) {
149
+ opts.logger?.warn('connector user access denied', {
150
+ email: user.email,
151
+ policy,
152
+ path: c.req.path,
153
+ });
154
+ c.header('cache-control', 'no-store');
155
+ return c.json({ error: 'not enabled for this account', code: ACCESS_DENIED_CODE, email: user.email }, 403);
156
+ }
157
+ if (!limiter.allow(user.email)) {
158
+ opts.logger?.warn('connector rate limit exceeded', { email: user.email, path: c.req.path });
159
+ return rateLimited(c);
160
+ }
161
+ // A copy per request: the cached identity is shared, and a Set typed
162
+ // read-only is still mutable at runtime.
163
+ c.set('connector', { ...user, scopes: new Set(user.scopes) });
164
+ return next();
165
+ };
166
+ if (authMode() === 'test') {
167
+ const email = c.req.header('x-test-connector-user');
168
+ if (!email)
169
+ return unauthorized(c, opts);
170
+ const scope = c.req.header('x-test-connector-scope');
171
+ return admit({
172
+ type: 'connector',
173
+ email,
174
+ role: parseRole(c.req.header('x-test-connector-role')) ?? 'invited',
175
+ scopes: scope === undefined ? new Set(opts.scopes) : parseScopes(scope),
176
+ clientId: 'test-client',
177
+ grantId: 'test-grant',
178
+ });
179
+ }
180
+ const header = c.req.header('authorization');
181
+ if (!header)
182
+ return unauthorized(c, opts);
183
+ const token = header.match(/^Bearer\s+(\S+)\s*$/i)?.[1];
184
+ // A bearer of any other shape — including a service's OIDC token — is not
185
+ // ours to verify and is never forwarded or logged.
186
+ if (!token || !ACCESS_TOKEN_PATTERN.test(token))
187
+ return unauthorized(c, opts, 'invalid_token');
188
+ const key = createHash('sha256').update(token).digest('hex');
189
+ const cached = cache.get(key, now());
190
+ if (cached)
191
+ return cached.user ? admit(cached.user) : unauthorized(c, opts, 'invalid_token');
192
+ const reject = () => {
193
+ cache.set(key, { expires: now() + missMs });
194
+ return unauthorized(c, opts, 'invalid_token');
195
+ };
196
+ // Spent before the call, on every uncached token whatever the answer, so
197
+ // concurrent junk cannot overshoot it. Over budget is a 429, never a 401:
198
+ // a client holding a good token must not be told to discard it.
199
+ if (!sourceBudget.allow(forwardedClientIp(c, resourceHost))) {
200
+ opts.logger?.warn('connector introspection budget exhausted', { path: c.req.path });
201
+ return rateLimited(c);
202
+ }
203
+ let res;
204
+ try {
205
+ res = await introspect(token);
206
+ }
207
+ catch (err) {
208
+ opts.logger?.error('auth service unreachable for introspection', { error: err });
209
+ return unavailable(c);
210
+ }
211
+ if (res.status !== 200) {
212
+ opts.logger?.error('connector token introspection failed', { meta_status: res.status });
213
+ return unavailable(c);
214
+ }
215
+ const data = (await res.json().catch(() => null));
216
+ // Only an explicit `active: false` means "not valid". Any other answer
217
+ // without `active: true` is a protocol fault: fail closed, loudly, and
218
+ // without caching, so a broken auth service cannot sign every user out.
219
+ if (data?.active === false)
220
+ return reject();
221
+ if (data?.active !== true || Array.isArray(data)) {
222
+ opts.logger?.error('connector introspection answer is malformed');
223
+ return unavailable(c);
224
+ }
225
+ if (data.resource !== opts.resource) {
226
+ // The auth service only answers `active` to the resource's own service
227
+ // account, so this is reachable only by the always-admitted agent
228
+ // identity in development. Not valid here either way.
229
+ opts.logger?.warn('connector token is for another resource', { resource: data.resource });
230
+ return reject();
231
+ }
232
+ const role = parseRole(data.role);
233
+ const expMs = typeof data.exp === 'number' ? data.exp * 1000 : NaN;
234
+ if (!role ||
235
+ !nonEmptyString(data.email) ||
236
+ !nonEmptyString(data.client_id) ||
237
+ !nonEmptyString(data.grant_id) ||
238
+ !Number.isFinite(expMs)) {
239
+ opts.logger?.error('connector introspection answer is malformed');
240
+ return unavailable(c);
241
+ }
242
+ if (expMs <= now())
243
+ return reject();
244
+ const user = {
245
+ type: 'connector',
246
+ email: data.email,
247
+ name: typeof data.name === 'string' ? data.name : undefined,
248
+ userId: typeof data.user_id === 'string' ? data.user_id : undefined,
249
+ role,
250
+ scopes: parseScopes(data.scope),
251
+ clientId: data.client_id,
252
+ grantId: data.grant_id,
253
+ };
254
+ cache.set(key, { expires: Math.min(now() + hitMs, expMs), user });
255
+ return admit(user);
256
+ });
257
+ }
package/dist/s2s.d.ts CHANGED
@@ -25,6 +25,7 @@ export declare class RateLimiter {
25
25
  private windows;
26
26
  constructor(perMinute: number);
27
27
  allow(key: string): boolean;
28
+ private evict;
28
29
  }
29
30
  export interface S2sOptions {
30
31
  /** This service's canonical URL, e.g. https://auth.apps.snewman.net — the
package/dist/s2s.js CHANGED
@@ -47,6 +47,8 @@ export function grantAllows(grants, email, method, path) {
47
47
  // loops and mischief, not a precision tool (per-instance state only).
48
48
  export class RateLimiter {
49
49
  perMinute;
50
+ // Insertion order is window-start order (a restarted window is re-inserted),
51
+ // so the oldest window is always first.
50
52
  windows = new Map();
51
53
  constructor(perMinute) {
52
54
  this.perMinute = perMinute;
@@ -55,13 +57,31 @@ export class RateLimiter {
55
57
  const now = Date.now();
56
58
  const w = this.windows.get(key);
57
59
  if (!w || now - w.start >= 60_000) {
60
+ this.windows.delete(key);
61
+ this.evict(now);
58
62
  this.windows.set(key, { start: now, count: 1 });
59
- return true;
63
+ return this.perMinute >= 1;
60
64
  }
61
65
  w.count++;
62
66
  return w.count <= this.perMinute;
63
67
  }
68
+ // Keys can be chosen by unauthenticated callers (a per-IP limiter on a
69
+ // public endpoint), so finished windows are dropped as new ones arrive and
70
+ // the map never exceeds RATE_LIMITER_MAX_KEYS: past that the oldest live
71
+ // window is forgotten, which only ever relaxes the limit for that key.
72
+ evict(now) {
73
+ for (const [key, w] of this.windows) {
74
+ if (now - w.start < 60_000 && this.windows.size < RATE_LIMITER_MAX_KEYS)
75
+ return;
76
+ this.windows.delete(key);
77
+ }
78
+ }
64
79
  }
80
+ const RATE_LIMITER_MAX_KEYS = 10_000;
81
+ /** The auth service's OAuth token families (access, refresh, authorization
82
+ * code). They are opaque, never Google ID tokens, and are admitted only by
83
+ * oauth-resource.ts's middleware on an app's connector route. */
84
+ const CONNECTOR_TOKEN = /^pw(?:at|rt|ac)_/;
65
85
  /** `message` with every occurrence of `token` — and of each of its
66
86
  * dot-separated segments of 8+ chars, since some verifier messages quote a
67
87
  * single segment — replaced by `<token>`, then truncated. */
@@ -85,6 +105,12 @@ export async function resolveS2sCaller(opts, headers, method, path) {
85
105
  const m = headers.authorization?.match(/^Bearer (.+)$/i);
86
106
  if (!m)
87
107
  return { failure: { status: 401, error: 'missing bearer token' } };
108
+ if (CONNECTOR_TOKEN.test(m[1])) {
109
+ // A connector token on a route that takes service callers: refused here
110
+ // so it never reaches the verifier, whose errors quote the token.
111
+ opts.logger?.warn('connector token presented on a service route', { path });
112
+ return { failure: { status: 401, error: 'invalid token' } };
113
+ }
88
114
  try {
89
115
  const client = opts.verifyClient ?? defaultVerifyClient();
90
116
  const ticket = await client.verifyIdToken({ idToken: m[1], audience: opts.audience });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.26.0",
3
+ "version": "3.28.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,6 +37,10 @@
37
37
  "types": "./dist/auth.d.ts",
38
38
  "default": "./dist/auth.js"
39
39
  },
40
+ "./oauth-resource": {
41
+ "types": "./dist/oauth-resource.d.ts",
42
+ "default": "./dist/oauth-resource.js"
43
+ },
40
44
  "./db": {
41
45
  "types": "./dist/db.d.ts",
42
46
  "default": "./dist/db.js"