1tube 0.1.5 → 0.1.7

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
@@ -35,9 +35,30 @@ Deno does not run npm `postinstall` / build scripts unless you allow them. `deno
35
35
 
36
36
  | Path | Description |
37
37
  |---|---|
38
+ | `GET /` | Liveness probe (`{"status":"ok"}`) — intentionally minimal so unauthenticated callers don't see the function map. |
38
39
  | `POST /functions/v1/:name` | Invoke an edge function |
39
- | `GET /health` | Health check + loaded function list |
40
- | `GET /metrics` | Prometheus-compatible metrics |
40
+ | `GET /health` | Auth-gated health (`Authorization: Bearer $INTERNAL_KEY`); without auth returns the same minimal `{"status":"ok"}`. |
41
+ | `GET /metrics` | Auth-gated Prometheus exposition (same scheme). |
42
+
43
+ ## Configuration
44
+
45
+ All knobs default to safe-but-backwards-compatible values. The TS gateway (`src/server.ts`) reads:
46
+
47
+ | Env / flag | Default | Notes |
48
+ |---|---|---|
49
+ | `--port` / `PORT` | `3100` | Listen port |
50
+ | `--host` / `1TUBE_HOST` | `127.0.0.1` | Loopback by default — pass `--host 0.0.0.0` to expose. |
51
+ | `--functions` / `FUNCTIONS_PATH` | `./supabase/functions` | Functions root |
52
+ | `--timeout` / `FUNCTION_TIMEOUT_MS` | `150000` | Per-request wall-clock cap (also overridable per function) |
53
+ | `--dev` / `1TUBE_DEV` | off | Applies the well-known local Supabase JWT/secrets. **Refuses to start in prod when JWT_SECRET / SUPABASE_SERVICE_ROLE_KEY are missing or are the public dev defaults.** |
54
+ | `--hmr` / `1TUBE_HMR` | off | File-watch + per-function reload (dev only). |
55
+ | `1TUBE_BODY_LIMIT_MB` | `30` | Hono `bodyLimit`; matches Supabase. Returns 413 before the handler runs. |
56
+ | `1TUBE_BODY_READ_MS` | `30000` | Slow-loris guard. Max idle gap (ms) between body chunks before the request is aborted with **408**. NOT a total body-read deadline — large but fast uploads pass through. Set `0` to disable. |
57
+ | `1TUBE_CORS_ORIGIN` | `*` (dev only) | Comma-separated allowlist or `*`. In prod, leaving unset disables CORS. |
58
+ | `1TUBE_TRUSTED_PROXIES` | empty | Comma-separated list of remote IPs whose `X-Forwarded-For` is honored. Anything else uses the raw socket address — XFF spoofing no longer mints fresh rate-limit buckets. |
59
+ | `1TUBE_SHUTDOWN_GRACE_MS` | `10000` | SIGINT/SIGTERM drain budget. |
60
+ | `INTERNAL_KEY` | unset | Required to read detailed `/health` and `/metrics`. Header-only: `Authorization: Bearer $INTERNAL_KEY`. |
61
+ ```
41
62
 
42
63
  ## .NET integration
43
64
 
package/deno.json CHANGED
@@ -5,10 +5,13 @@
5
5
  },
6
6
  "tasks": {
7
7
  "dev": "deno run --allow-all --watch src/server.ts",
8
- "serve": "deno run --allow-all src/server.ts"
8
+ "serve": "deno run --allow-all src/server.ts",
9
+ "play": "deno run --allow-all --watch src/server.ts --dev --hmr --functions ./playground --port 3199",
10
+ "test": "deno test --allow-all tests/"
9
11
  },
10
12
  "imports": {
11
13
  "@std/path": "jsr:@std/path@^1",
14
+ "@std/assert": "jsr:@std/assert@^1",
12
15
  "hono": "npm:hono@4"
13
16
  },
14
17
  "nodeModulesDir": "auto",
package/deno.lock CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "version": "5",
3
3
  "specifiers": {
4
+ "jsr:@std/assert@1": "1.0.19",
4
5
  "jsr:@std/internal@^1.0.12": "1.0.12",
5
6
  "jsr:@std/path@1": "1.1.4",
6
7
  "npm:@ai-sdk/mcp@0": "0.0.15_zod@3.25.76",
@@ -12,6 +13,12 @@
12
13
  "npm:zod@3": "3.25.76"
13
14
  },
14
15
  "jsr": {
16
+ "@std/assert@1.0.19": {
17
+ "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
18
+ "dependencies": [
19
+ "jsr:@std/internal"
20
+ ]
21
+ },
15
22
  "@std/internal@1.0.12": {
16
23
  "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
17
24
  },
@@ -494,6 +501,7 @@
494
501
  },
495
502
  "workspace": {
496
503
  "dependencies": [
504
+ "jsr:@std/assert@1",
497
505
  "jsr:@std/path@1",
498
506
  "npm:hono@4"
499
507
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "private": false,
5
5
  "description": "Self-hosted Supabase Edge Functions gateway for Deno, plus an ESLint plugin for consumers",
6
6
  "license": "MIT",
package/src/discovery.ts CHANGED
@@ -3,72 +3,194 @@
3
3
  *
4
4
  * Scans `functionsDir` for subdirectories containing an `index.ts`.
5
5
  * Skips directories starting with `_` or ending with `_shared`.
6
- * Each discovered module is dynamically imported — the top-level `serve()` call
7
- * in the module registers the handler via the global FunctionRegistry.
6
+ * Each discovered module is dynamically imported in parallel — the top-level
7
+ * `serve()` call in the module registers the handler via the global
8
+ * FunctionRegistry. Concurrent imports are kept registration-safe via the
9
+ * registry's async-context binding.
8
10
  */
9
11
 
10
- import { join, toFileUrl } from "jsr:@std/path@^1";
12
+ // node: specifiers so this module loads cleanly from node_modules in any
13
+ // host Deno project (no shared import-map entry required).
14
+ import { join } from "node:path";
15
+ import { pathToFileURL } from "node:url";
11
16
  import type { FunctionRegistry } from "./registry.ts";
17
+ import { loadManifest } from "./manifest.ts";
18
+
19
+ export interface DiscoveryProgress {
20
+ name: string;
21
+ index: number;
22
+ total: number;
23
+ durationMs: number;
24
+ ok: boolean;
25
+ error?: string;
26
+ }
27
+
28
+ export interface DiscoveryOptions {
29
+ /** When set, appended as `?v=` to module URLs to force re-transpilation. */
30
+ cacheBust?: string;
31
+ /** Restrict loading to this set of function names (others are skipped entirely). */
32
+ only?: ReadonlySet<string>;
33
+ /** Max number of concurrent dynamic imports. Defaults to 8. */
34
+ concurrency?: number;
35
+ /** Invoked once per function as it finishes (success or failure). */
36
+ onProgress?: (p: DiscoveryProgress) => void;
37
+ /**
38
+ * When true, candidates are registered for lazy import on first dispatch.
39
+ * Functions whose manifest sets `warm: true` are still imported eagerly
40
+ * (in parallel, alongside other warm functions). Significantly cuts
41
+ * startup wall-clock and resident memory when many functions exist.
42
+ */
43
+ lazy?: boolean;
44
+ }
45
+
46
+ export interface DiscoveryResult {
47
+ loaded: string[];
48
+ skipped: string[];
49
+ errors: Array<{ name: string; error: string }>;
50
+ /** Function names whose `index.ts` no longer exists (caller should evict from registry). */
51
+ removed: string[];
52
+ /** Names registered as lazy candidates (only populated when `options.lazy` is true). */
53
+ deferred: string[];
54
+ }
12
55
 
13
56
  export async function discoverAndLoad(
14
57
  functionsDir: string,
15
58
  registry: FunctionRegistry,
16
- options?: { cacheBust?: string },
17
- ): Promise<{ loaded: string[]; skipped: string[]; errors: Array<{ name: string; error: string }> }> {
59
+ options?: DiscoveryOptions,
60
+ ): Promise<DiscoveryResult> {
18
61
  const loaded: string[] = [];
19
62
  const skipped: string[] = [];
63
+ const removed: string[] = [];
64
+ const deferred: string[] = [];
20
65
  const errors: Array<{ name: string; error: string }> = [];
21
66
 
22
67
  const resolvedDir = await Deno.realPath(functionsDir);
23
68
 
24
- const entries: string[] = [];
69
+ const candidates: string[] = [];
25
70
  for await (const entry of Deno.readDir(resolvedDir)) {
26
71
  if (!entry.isDirectory) continue;
27
72
  if (entry.name.startsWith("_") || entry.name.endsWith("_shared")) {
28
73
  skipped.push(entry.name);
29
74
  continue;
30
75
  }
31
- entries.push(entry.name);
76
+ candidates.push(entry.name);
32
77
  }
78
+ candidates.sort();
33
79
 
34
- entries.sort();
80
+ // Build the work list: exists check + `only` filter.
81
+ const work: string[] = [];
82
+ for (const name of candidates) {
83
+ if (options?.only && !options.only.has(name)) continue;
35
84
 
36
- for (const name of entries) {
37
85
  const indexPath = join(resolvedDir, name, "index.ts");
38
-
39
86
  try {
40
87
  await Deno.stat(indexPath);
88
+ work.push(name);
41
89
  } catch {
42
- skipped.push(name);
43
- continue;
90
+ // No index.ts. If we were asked to reload this name explicitly, treat as
91
+ // a deletion so the caller can evict it. Otherwise just skip.
92
+ if (options?.only?.has(name)) {
93
+ removed.push(name);
94
+ } else {
95
+ skipped.push(name);
96
+ }
97
+ }
98
+ }
99
+
100
+ // Also detect deletions from `only` that didn't appear as candidates at all
101
+ // (e.g. the whole directory was removed).
102
+ if (options?.only) {
103
+ const present = new Set(candidates);
104
+ for (const name of options.only) {
105
+ if (!present.has(name) && !removed.includes(name)) {
106
+ removed.push(name);
107
+ }
44
108
  }
109
+ }
45
110
 
46
- registry.setCurrentFunction(name);
111
+ const total = work.length;
112
+ let completed = 0;
47
113
 
114
+ const loadOne = async (name: string): Promise<void> => {
115
+ const indexPath = join(resolvedDir, name, "index.ts");
116
+ // pathToFileURL is the node:url equivalent of std/path's toFileUrl;
117
+ // both produce a `file://` URL whose searchParams we mutate below for
118
+ // HMR cache-busting.
119
+ const moduleUrl = pathToFileURL(indexPath);
120
+ if (options?.cacheBust) {
121
+ moduleUrl.searchParams.set("v", options.cacheBust);
122
+ }
123
+
124
+ const start = performance.now();
48
125
  try {
49
- const moduleUrl = toFileUrl(indexPath);
50
- if (options?.cacheBust) {
51
- moduleUrl.searchParams.set("v", options.cacheBust);
126
+ // Load the manifest first so it is attached to the registry before the
127
+ // module's top-level `serve()` call captures the handler. Manifest
128
+ // failures degrade to defaults inside loadManifest().
129
+ const manifest = await loadManifest(resolvedDir, name);
130
+
131
+ // Lazy mode: skip the dynamic import unless the manifest opts in via
132
+ // `warm: true`. Register a candidate so the first dispatch can import
133
+ // on demand. This is the single biggest startup-time win for sites
134
+ // with many rarely-called functions.
135
+ if (options?.lazy && !manifest.warm) {
136
+ registry.registerCandidate({ name, moduleUrl: moduleUrl.href, manifest });
137
+ deferred.push(name);
138
+ const durationMs = performance.now() - start;
139
+ options?.onProgress?.({
140
+ name,
141
+ index: ++completed,
142
+ total,
143
+ durationMs,
144
+ ok: true,
145
+ });
146
+ return;
52
147
  }
53
- await import(moduleUrl.href);
148
+
149
+ registry.attachManifest(name, manifest);
150
+ // Even in lazy mode, eagerly-warm functions still register a candidate
151
+ // so HMR reloads can re-import via the same URL.
152
+ if (options?.lazy && manifest.warm) {
153
+ registry.registerCandidate({ name, moduleUrl: moduleUrl.href, manifest });
154
+ }
155
+ await registry.runWithCurrentFunction(name, async () => {
156
+ await import(moduleUrl.href);
157
+ });
54
158
  loaded.push(name);
159
+ const durationMs = performance.now() - start;
160
+ options?.onProgress?.({
161
+ name,
162
+ index: ++completed,
163
+ total,
164
+ durationMs,
165
+ ok: true,
166
+ });
55
167
  } catch (err) {
56
168
  const msg = err instanceof Error ? err.message : String(err);
57
169
  errors.push({ name, error: msg });
170
+ const durationMs = performance.now() - start;
171
+ options?.onProgress?.({
172
+ name,
173
+ index: ++completed,
174
+ total,
175
+ durationMs,
176
+ ok: false,
177
+ error: msg,
178
+ });
179
+ }
180
+ };
58
181
 
59
- const isEnvIssue =
60
- msg.includes("is required") ||
61
- msg.includes("must be set") ||
62
- msg.includes("API key") ||
63
- msg.includes("undefined");
64
-
65
- if (isEnvIssue) {
66
- console.error(`[1tube] Failed to load "${name}" (missing env var): ${msg}`);
67
- } else {
68
- console.error(`[1tube] Failed to load "${name}": ${msg}`);
69
- }
182
+ // Bounded parallel loader. Deno's module loader serializes some FS work
183
+ // internally, but we still benefit from overlapping transpile + JSR fetches.
184
+ const concurrency = Math.max(1, options?.concurrency ?? 8);
185
+ let cursor = 0;
186
+ const workers = Array.from({ length: Math.min(concurrency, work.length) }, async () => {
187
+ while (true) {
188
+ const i = cursor++;
189
+ if (i >= work.length) return;
190
+ await loadOne(work[i]);
70
191
  }
71
- }
192
+ });
193
+ await Promise.all(workers);
72
194
 
73
- return { loaded, skipped, errors };
195
+ return { loaded, skipped, errors, removed, deferred };
74
196
  }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Per-function env-var scoping.
3
+ *
4
+ * When `1TUBE_ENFORCE_MANIFEST=1`, `installEnvScope()` replaces `Deno.env` with
5
+ * a wrapper that filters reads (`get`, `has`, `toObject`) against the active
6
+ * function's manifest `permissions.env` allowlist, using AsyncLocalStorage to
7
+ * know which function is currently executing. Outside any function context
8
+ * (e.g. server bootstrap, gateway middleware) all env access passes through
9
+ * unchanged so the gateway itself never gets sandboxed.
10
+ *
11
+ * Writes (`set`, `delete`) are always rejected from inside a function context
12
+ * — functions should not be able to mutate process env on each other.
13
+ *
14
+ * `1TUBE_ALLOW_ALL=1` short-circuits the scope and is the documented escape
15
+ * hatch for legacy functions whose env usage hasn't been audited yet.
16
+ *
17
+ * This is opt-in, monitored on bootstrap, and idempotent.
18
+ */
19
+
20
+ import { AsyncLocalStorage } from "node:async_hooks";
21
+
22
+ export interface EnvScopeContext {
23
+ functionName: string;
24
+ /** Allowlisted env var names (manifest.permissions.env). */
25
+ allow: ReadonlySet<string>;
26
+ }
27
+
28
+ export const envScopeStorage = new AsyncLocalStorage<EnvScopeContext>();
29
+
30
+ let _installed = false;
31
+
32
+ /**
33
+ * Replace Deno.env with a filtering wrapper. Safe to call multiple times;
34
+ * only the first call has an effect.
35
+ */
36
+ export function installEnvScope(): void {
37
+ if (_installed) return;
38
+ _installed = true;
39
+
40
+ const original = Deno.env;
41
+ const allowAll = (Deno.env.get("1TUBE_ALLOW_ALL") ?? "") === "1";
42
+
43
+ const wrap: typeof Deno.env = {
44
+ get(key: string) {
45
+ const ctx = envScopeStorage.getStore();
46
+ if (!ctx || allowAll) return original.get(key);
47
+ if (ctx.allow.has(key)) return original.get(key);
48
+ return undefined;
49
+ },
50
+ has(key: string) {
51
+ const ctx = envScopeStorage.getStore();
52
+ if (!ctx || allowAll) return original.has(key);
53
+ if (!ctx.allow.has(key)) return false;
54
+ return original.has(key);
55
+ },
56
+ set(key: string, value: string) {
57
+ const ctx = envScopeStorage.getStore();
58
+ if (ctx && !allowAll) {
59
+ throw new Deno.errors.PermissionDenied(
60
+ `Function "${ctx.functionName}" attempted Deno.env.set(${JSON.stringify(key)}); ` +
61
+ `env writes are not permitted from function code.`,
62
+ );
63
+ }
64
+ original.set(key, value);
65
+ },
66
+ delete(key: string) {
67
+ const ctx = envScopeStorage.getStore();
68
+ if (ctx && !allowAll) {
69
+ throw new Deno.errors.PermissionDenied(
70
+ `Function "${ctx.functionName}" attempted Deno.env.delete(${JSON.stringify(key)}); ` +
71
+ `env writes are not permitted from function code.`,
72
+ );
73
+ }
74
+ original.delete(key);
75
+ },
76
+ toObject() {
77
+ const ctx = envScopeStorage.getStore();
78
+ if (!ctx || allowAll) return original.toObject();
79
+ const all = original.toObject();
80
+ const filtered: Record<string, string> = {};
81
+ for (const k of ctx.allow) {
82
+ if (k in all) filtered[k] = all[k];
83
+ }
84
+ return filtered;
85
+ },
86
+ };
87
+
88
+ try {
89
+ Object.defineProperty(Deno, "env", {
90
+ value: wrap,
91
+ writable: false,
92
+ configurable: true,
93
+ });
94
+ } catch {
95
+ // Deno.env property is locked down on this runtime — patch the methods on
96
+ // the existing object instead. This still gives us the same scoping
97
+ // behaviour because the wrapper closes over `original`.
98
+ (original as { get: typeof wrap.get }).get = wrap.get;
99
+ (original as { has: typeof wrap.has }).has = wrap.has;
100
+ (original as { set: typeof wrap.set }).set = wrap.set;
101
+ (original as { delete: typeof wrap.delete }).delete = wrap.delete;
102
+ (original as { toObject: typeof wrap.toObject }).toObject = wrap.toObject;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Run `fn` with the env-scope context bound to `name` + `allow`. Any nested
108
+ * `Deno.env.*` call sees only the allowlisted variables.
109
+ */
110
+ export function runWithEnvScope<T>(
111
+ ctx: EnvScopeContext,
112
+ fn: () => T | Promise<T>,
113
+ ): T | Promise<T> {
114
+ return envScopeStorage.run(ctx, fn);
115
+ }
116
+
117
+ /** Test-only: allow re-installation in unit tests. */
118
+ export function _resetEnvScopeForTests(): void {
119
+ _installed = false;
120
+ }
@@ -3,25 +3,71 @@
3
3
  *
4
4
  * Validates HMAC-SHA256 JWTs using the same secret as Supabase,
5
5
  * producing tokens fully compatible with Supabase RLS policies.
6
+ *
7
+ * The JWT secret is read lazily on first use so that env defaults applied
8
+ * by the server bootstrap (e.g. dev mode) take effect even though this
9
+ * module is imported transitively before bootstrap runs.
10
+ *
11
+ * Verified payloads are cached in a bounded LRU keyed by the raw token
12
+ * string. Cache hits skip the SubtleCrypto verify (~50–200µs each on a
13
+ * realistic workload) and the JSON.parse, which is the single biggest
14
+ * single-process throughput win on the hot path.
15
+ *
16
+ * - Cache is invalidated implicitly when JWT_SECRET rotates
17
+ * (see `getSecret()`), so a stale entry can never validate against
18
+ * a new key.
19
+ * - Each entry stores the token's `exp` so we never serve a payload
20
+ * past its TTL.
21
+ * - The map is bounded; oldest insertions are evicted first
22
+ * (insertion-order LRU; bumped on each hit).
6
23
  */
7
24
 
8
25
  import type { AuthContext, JWTPayload } from "../registry.ts";
9
26
 
10
- const JWT_SECRET = Deno.env.get("JWT_SECRET") || "";
11
- if (!JWT_SECRET) {
12
- console.warn("[1tube] JWT_SECRET not set — authenticated endpoints will reject all requests");
13
- }
14
-
15
27
  const encoder = new TextEncoder();
16
28
  const decoder = new TextDecoder();
17
29
 
18
30
  let _cachedKey: CryptoKey | null = null;
31
+ let _cachedSecret: string | null = null;
32
+ let _warnedNoSecret = false;
33
+
34
+ const VERIFY_CACHE_DEFAULT_MAX = 5_000;
35
+ let _verifyCacheMax = VERIFY_CACHE_DEFAULT_MAX;
36
+
37
+ interface VerifiedEntry {
38
+ payload: JWTPayload;
39
+ expiresAtMs: number;
40
+ }
19
41
 
20
- async function getKey(): Promise<CryptoKey> {
42
+ const verifyCache = new Map<string, VerifiedEntry>();
43
+ let _verifyCacheHits = 0;
44
+ let _verifyCacheMisses = 0;
45
+
46
+ function getSecret(): string {
47
+ const current = Deno.env.get("JWT_SECRET") || "";
48
+ if (current !== _cachedSecret) {
49
+ _cachedSecret = current;
50
+ _cachedKey = null;
51
+ // Any cached verifications were authorised against the previous key;
52
+ // drop them so a rotated secret cannot accidentally accept old tokens.
53
+ verifyCache.clear();
54
+ if (!current && !_warnedNoSecret) {
55
+ console.warn(
56
+ "[1tube] JWT_SECRET not set — authenticated endpoints will reject all requests",
57
+ );
58
+ _warnedNoSecret = true;
59
+ }
60
+ }
61
+ return current;
62
+ }
63
+
64
+ async function getKey(): Promise<CryptoKey | null> {
65
+ const secret = getSecret();
66
+ if (!secret) return null;
21
67
  if (_cachedKey) return _cachedKey;
22
68
  _cachedKey = await crypto.subtle.importKey(
23
69
  "raw",
24
- encoder.encode(JWT_SECRET),
70
+ encoder.encode(secret),
25
71
  { name: "HMAC", hash: "SHA-256" },
26
72
  false,
27
73
  ["verify"],
@@ -42,13 +88,54 @@ function parseBearer(header: string | null): string | null {
42
88
  return match ? match[1] : null;
43
89
  }
44
90
 
91
+ function cacheGet(token: string, nowMs: number): JWTPayload | null {
92
+ const hit = verifyCache.get(token);
93
+ if (!hit) return null;
94
+ if (hit.expiresAtMs <= nowMs) {
95
+ verifyCache.delete(token);
96
+ return null;
97
+ }
98
+ // LRU bump: re-insert so this entry becomes the most recently used.
99
+ verifyCache.delete(token);
100
+ verifyCache.set(token, hit);
101
+ _verifyCacheHits++;
102
+ return hit.payload;
103
+ }
104
+
105
+ function cacheSet(token: string, payload: JWTPayload): void {
106
+ // Evict insertion-oldest while we're over capacity. Loop because the cap
107
+ // can be lowered at runtime via `_setVerifyCacheMaxForTests()`.
108
+ while (verifyCache.size >= _verifyCacheMax) {
109
+ const oldest = verifyCache.keys().next().value;
110
+ if (oldest === undefined) break;
111
+ verifyCache.delete(oldest);
112
+ }
113
+ verifyCache.set(token, {
114
+ payload,
115
+ expiresAtMs: payload.exp * 1000,
116
+ });
117
+ }
118
+
45
119
  async function verifyToken(token: string): Promise<JWTPayload | null> {
120
+ // Resolve the secret BEFORE consulting the cache. getSecret() detects
121
+ // rotation and clears stale entries; if we read the cache first, a token
122
+ // signed with the previous key would be returned to the caller before the
123
+ // rotation took effect.
124
+ const secret = getSecret();
125
+ if (!secret) return null;
126
+
127
+ const nowMs = Date.now();
128
+ const cached = cacheGet(token, nowMs);
129
+ if (cached) return cached;
130
+
131
+ _verifyCacheMisses++;
46
132
  try {
47
133
  const parts = token.split(".");
48
134
  if (parts.length !== 3) return null;
49
135
 
50
136
  const [headerB64, payloadB64, signatureB64] = parts;
51
137
  const key = await getKey();
138
+ if (!key) return null;
52
139
  const sigBytes = base64UrlDecode(signatureB64);
53
140
 
54
141
  const isValid = await crypto.subtle.verify(
@@ -61,8 +148,15 @@ async function verifyToken(token: string): Promise<JWTPayload | null> {
61
148
 
62
149
  const payload = JSON.parse(decoder.decode(base64UrlDecode(payloadB64))) as JWTPayload;
63
150
 
64
- if (payload.exp < Math.floor(Date.now() / 1000)) return null;
151
+ // `exp` is a NumericDate (seconds since epoch). Per RFC 7519 the token
152
+ // MUST NOT be accepted past `exp`; we treat the second the token expires
153
+ // as "past" so a 1-second TTL token can't be replayed within the same
154
+ // wall-clock second.
155
+ if (typeof payload.exp !== "number" || payload.exp * 1000 <= nowMs) {
156
+ return null;
157
+ }
65
158
 
159
+ cacheSet(token, payload);
66
160
  return payload;
67
161
  } catch {
68
162
  return null;
@@ -86,3 +180,44 @@ export async function validateRequest(req: Request): Promise<AuthContext | null>
86
180
  rawToken: token,
87
181
  };
88
182
  }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Diagnostics & test hooks
186
+ // ---------------------------------------------------------------------------
187
+
188
+ export interface JwtVerifyCacheStats {
189
+ size: number;
190
+ capacity: number;
191
+ hits: number;
192
+ misses: number;
193
+ }
194
+
195
+ export function getVerifyCacheStats(): JwtVerifyCacheStats {
196
+ return {
197
+ size: verifyCache.size,
198
+ capacity: _verifyCacheMax,
199
+ hits: _verifyCacheHits,
200
+ misses: _verifyCacheMisses,
201
+ };
202
+ }
203
+
204
+ /** Test-only: clear the cache + counters and reset key/secret memoisation. */
205
+ export function _resetAuthForTests(): void {
206
+ verifyCache.clear();
207
+ _verifyCacheHits = 0;
208
+ _verifyCacheMisses = 0;
209
+ _cachedKey = null;
210
+ _cachedSecret = null;
211
+ _warnedNoSecret = false;
212
+ _verifyCacheMax = VERIFY_CACHE_DEFAULT_MAX;
213
+ }
214
+
215
+ /** Test-only: shrink the cache so eviction is observable in unit tests. */
216
+ export function _setVerifyCacheMaxForTests(max: number): void {
217
+ _verifyCacheMax = Math.max(1, Math.floor(max));
218
+ while (verifyCache.size > _verifyCacheMax) {
219
+ const oldest = verifyCache.keys().next().value;
220
+ if (oldest === undefined) break;
221
+ verifyCache.delete(oldest);
222
+ }
223
+ }