@webstir-io/webstir-backend 0.1.15 → 0.1.16

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.
Files changed (123) hide show
  1. package/README.md +106 -79
  2. package/dist/add.d.ts +59 -0
  3. package/dist/add.js +626 -0
  4. package/dist/build/artifacts.d.ts +115 -1
  5. package/dist/build/artifacts.js +4 -4
  6. package/dist/build/entries.js +1 -1
  7. package/dist/build/pipeline.d.ts +33 -1
  8. package/dist/build/pipeline.js +307 -65
  9. package/dist/cache/diff.js +9 -8
  10. package/dist/cache/reporters.js +1 -1
  11. package/dist/deploy-cli.d.ts +2 -0
  12. package/dist/deploy-cli.js +86 -0
  13. package/dist/diagnostics/summary.js +2 -2
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +4 -0
  16. package/dist/manifest/pipeline.js +103 -32
  17. package/dist/provider.js +35 -17
  18. package/dist/runtime/bun.d.ts +51 -0
  19. package/dist/runtime/bun.js +499 -0
  20. package/dist/runtime/core.d.ts +141 -0
  21. package/dist/runtime/core.js +316 -0
  22. package/dist/runtime/deploy-backend.d.ts +20 -0
  23. package/dist/runtime/deploy-backend.js +175 -0
  24. package/dist/runtime/deploy-shared.d.ts +43 -0
  25. package/dist/runtime/deploy-shared.js +75 -0
  26. package/dist/runtime/deploy-static.d.ts +2 -0
  27. package/dist/runtime/deploy-static.js +161 -0
  28. package/dist/runtime/deploy.d.ts +3 -0
  29. package/dist/runtime/deploy.js +91 -0
  30. package/dist/runtime/forms.d.ts +73 -0
  31. package/dist/runtime/forms.js +236 -0
  32. package/dist/runtime/request-hooks.d.ts +47 -0
  33. package/dist/runtime/request-hooks.js +102 -0
  34. package/dist/runtime/session-metadata.d.ts +13 -0
  35. package/dist/runtime/session-metadata.js +98 -0
  36. package/dist/runtime/session-runtime.d.ts +28 -0
  37. package/dist/runtime/session-runtime.js +180 -0
  38. package/dist/runtime/session.d.ts +83 -0
  39. package/dist/runtime/session.js +396 -0
  40. package/dist/runtime/views.d.ts +74 -0
  41. package/dist/runtime/views.js +221 -0
  42. package/dist/scaffold/assets.js +25 -21
  43. package/dist/testing/context.js +1 -1
  44. package/dist/testing/index.d.ts +1 -1
  45. package/dist/testing/index.js +100 -56
  46. package/dist/utils/bun.d.ts +2 -0
  47. package/dist/utils/bun.js +13 -0
  48. package/dist/watch.d.ts +13 -1
  49. package/dist/watch.js +345 -97
  50. package/dist/workspace.d.ts +8 -0
  51. package/dist/workspace.js +44 -3
  52. package/package.json +49 -14
  53. package/scripts/publish.sh +2 -92
  54. package/scripts/smoke.mjs +282 -107
  55. package/scripts/update-contract.sh +12 -10
  56. package/src/add.ts +964 -0
  57. package/src/build/artifacts.ts +49 -46
  58. package/src/build/entries.ts +12 -12
  59. package/src/build/pipeline.ts +779 -403
  60. package/src/cache/diff.ts +111 -105
  61. package/src/cache/reporters.ts +26 -26
  62. package/src/deploy-cli.ts +111 -0
  63. package/src/diagnostics/summary.ts +28 -22
  64. package/src/index.ts +11 -0
  65. package/src/manifest/pipeline.ts +328 -215
  66. package/src/provider.ts +115 -98
  67. package/src/runtime/bun.ts +793 -0
  68. package/src/runtime/core.ts +598 -0
  69. package/src/runtime/deploy-backend.ts +239 -0
  70. package/src/runtime/deploy-shared.ts +136 -0
  71. package/src/runtime/deploy-static.ts +191 -0
  72. package/src/runtime/deploy.ts +143 -0
  73. package/src/runtime/forms.ts +364 -0
  74. package/src/runtime/request-hooks.ts +165 -0
  75. package/src/runtime/session-metadata.ts +135 -0
  76. package/src/runtime/session-runtime.ts +267 -0
  77. package/src/runtime/session.ts +642 -0
  78. package/src/runtime/views.ts +385 -0
  79. package/src/scaffold/assets.ts +77 -73
  80. package/src/testing/context.js +8 -9
  81. package/src/testing/context.ts +9 -9
  82. package/src/testing/index.d.ts +14 -3
  83. package/src/testing/index.js +254 -175
  84. package/src/testing/index.ts +298 -195
  85. package/src/testing/types.d.ts +18 -19
  86. package/src/testing/types.ts +18 -18
  87. package/src/utils/bun.ts +26 -0
  88. package/src/watch.ts +503 -99
  89. package/src/workspace.ts +59 -3
  90. package/templates/backend/.env.example +15 -0
  91. package/templates/backend/auth/adapter.ts +335 -36
  92. package/templates/backend/db/connection.ts +190 -65
  93. package/templates/backend/db/migrate.ts +149 -43
  94. package/templates/backend/db/types.d.ts +1 -1
  95. package/templates/backend/env.ts +132 -20
  96. package/templates/backend/functions/hello/index.ts +1 -2
  97. package/templates/backend/index.ts +15 -508
  98. package/templates/backend/jobs/nightly/index.ts +1 -1
  99. package/templates/backend/jobs/runtime.ts +24 -11
  100. package/templates/backend/jobs/scheduler.ts +208 -46
  101. package/templates/backend/module.ts +227 -13
  102. package/templates/backend/observability/logger.ts +2 -12
  103. package/templates/backend/observability/metrics.ts +8 -5
  104. package/templates/backend/session/sqlite.ts +152 -0
  105. package/templates/backend/session/store.ts +45 -0
  106. package/templates/backend/tsconfig.json +1 -1
  107. package/tests/add.test.js +327 -0
  108. package/tests/authAdapter.test.js +315 -0
  109. package/tests/bundlerParity.test.js +217 -0
  110. package/tests/cacheReporter.test.js +10 -10
  111. package/tests/dbConnection.test.js +209 -0
  112. package/tests/deploy.test.js +357 -0
  113. package/tests/envLoader.test.js +271 -17
  114. package/tests/integration.test.js +2432 -3
  115. package/tests/jobsScheduler.test.js +253 -0
  116. package/tests/manifest.test.js +287 -12
  117. package/tests/migrationRunner.test.js +249 -0
  118. package/tests/sessionScaffoldStore.test.js +752 -0
  119. package/tests/sessionStore.test.js +490 -0
  120. package/tests/testing.test.js +252 -0
  121. package/tests/watch.test.js +192 -32
  122. package/tsconfig.json +3 -10
  123. package/templates/backend/server/fastify.ts +0 -288
package/src/workspace.ts CHANGED
@@ -1,14 +1,51 @@
1
1
  import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
2
3
 
3
4
  import type { ResolvedModuleWorkspace } from '@webstir-io/module-contract';
4
5
 
5
6
  export type BackendBuildMode = 'build' | 'publish' | 'test';
6
7
 
8
+ interface ResolveWorkspaceRootOptions {
9
+ readonly workspaceRoot?: string;
10
+ readonly env?: Record<string, string | undefined>;
11
+ readonly cwd?: string;
12
+ readonly importMetaUrl?: string;
13
+ }
14
+
15
+ const WORKSPACE_ROOT_PATTERN = /^(.*)[/\\](?:src|build)[/\\]backend(?:[/\\].*)?$/;
16
+
17
+ export function resolveWorkspaceRoot(options?: string | ResolveWorkspaceRootOptions): string {
18
+ if (typeof options === 'string') {
19
+ return path.resolve(options);
20
+ }
21
+
22
+ const explicitRoot = options?.workspaceRoot?.trim();
23
+ if (explicitRoot) {
24
+ return path.resolve(explicitRoot);
25
+ }
26
+
27
+ const env = options?.env ?? process.env;
28
+ const envRoot = env.WORKSPACE_ROOT?.trim() || env.WEBSTIR_WORKSPACE_ROOT?.trim();
29
+ if (envRoot) {
30
+ return path.resolve(envRoot);
31
+ }
32
+
33
+ const inferredRoot = options?.importMetaUrl
34
+ ? inferWorkspaceRootFromImportMetaUrl(options.importMetaUrl)
35
+ : undefined;
36
+ if (inferredRoot) {
37
+ return inferredRoot;
38
+ }
39
+
40
+ return path.resolve(options?.cwd ?? process.cwd());
41
+ }
42
+
7
43
  export function resolveWorkspacePaths(workspaceRoot: string): ResolvedModuleWorkspace {
44
+ const resolvedWorkspaceRoot = resolveWorkspaceRoot(workspaceRoot);
8
45
  return {
9
- sourceRoot: path.join(workspaceRoot, 'src', 'backend'),
10
- buildRoot: path.join(workspaceRoot, 'build', 'backend'),
11
- testsRoot: path.join(workspaceRoot, 'src', 'backend', 'tests')
46
+ sourceRoot: path.join(resolvedWorkspaceRoot, 'src', 'backend'),
47
+ buildRoot: path.join(resolvedWorkspaceRoot, 'build', 'backend'),
48
+ testsRoot: path.join(resolvedWorkspaceRoot, 'src', 'backend', 'tests'),
12
49
  };
13
50
  }
14
51
 
@@ -20,3 +57,22 @@ export function normalizeMode(rawMode: unknown): BackendBuildMode {
20
57
  const normalized = rawMode.toLowerCase();
21
58
  return normalized === 'publish' || normalized === 'test' ? normalized : 'build';
22
59
  }
60
+
61
+ function inferWorkspaceRootFromImportMetaUrl(importMetaUrl: string): string | undefined {
62
+ try {
63
+ return inferWorkspaceRootFromFilePath(fileURLToPath(importMetaUrl));
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ function inferWorkspaceRootFromFilePath(filePath: string): string | undefined {
70
+ const normalizedFilePath = path.resolve(filePath);
71
+ const match = normalizedFilePath.match(WORKSPACE_ROOT_PATTERN);
72
+ if (!match) {
73
+ return undefined;
74
+ }
75
+
76
+ const inferredRoot = match[1];
77
+ return inferredRoot || path.parse(normalizedFilePath).root;
78
+ }
@@ -1,13 +1,28 @@
1
1
  NODE_ENV=development
2
2
  PORT=4000
3
3
  API_BASE_URL=http://localhost:4000
4
+ # Choose one JWT verification mode:
5
+ # - Shared secret: AUTH_JWT_SECRET
6
+ # - RSA public key file: AUTH_JWT_PUBLIC_KEY_FILE
7
+ # - JWKS endpoint: AUTH_JWKS_URL
4
8
  AUTH_JWT_SECRET=change-me
9
+ # AUTH_JWT_PUBLIC_KEY_FILE=./config/jwt-public-key.pem
10
+ # AUTH_JWKS_URL=https://example-idp/.well-known/jwks.json
5
11
  AUTH_JWT_ISSUER=https://example-idp/
6
12
  AUTH_JWT_AUDIENCE=webstir-dev
7
13
  AUTH_SERVICE_TOKENS=local-service-token
14
+ SESSION_SECRET=change-me-too
15
+ SESSION_COOKIE_NAME=webstir_session
16
+ SESSION_COOKIE_SECURE=off
17
+ SESSION_MAX_AGE=86400
18
+ # Development defaults to memory; production defaults to sqlite.
19
+ # SESSION_STORE_DRIVER=memory
20
+ SESSION_STORE_URL=file:./data/sessions.sqlite
8
21
  LOG_LEVEL=info
9
22
  LOG_SERVICE_NAME=backend-template
10
23
  METRICS_ENABLED=on
11
24
  METRICS_WINDOW=200
12
25
  DATABASE_URL=file:./data/dev.sqlite
13
26
  DATABASE_MIGRATIONS_TABLE=_webstir_migrations
27
+ REQUEST_BODY_MAX_BYTES=1048576
28
+ WEBSTIR_BACKEND_SERVER_RUNTIME=node
@@ -1,5 +1,4 @@
1
1
  import crypto from 'node:crypto';
2
- import type http from 'node:http';
3
2
 
4
3
  import type { AuthSecrets } from '../env.js';
5
4
 
@@ -14,18 +13,40 @@ export interface AuthContext {
14
13
  claims: Record<string, unknown>;
15
14
  }
16
15
 
17
- export function resolveRequestAuth(
18
- req: http.IncomingMessage,
16
+ interface JwtHeader extends Record<string, unknown> {
17
+ alg?: string;
18
+ kid?: string;
19
+ typ?: string;
20
+ }
21
+
22
+ interface CachedJwkKey {
23
+ kid?: string;
24
+ key: crypto.KeyObject;
25
+ }
26
+
27
+ interface CachedJwksEntry {
28
+ keys: readonly CachedJwkKey[];
29
+ expiresAt: number;
30
+ }
31
+
32
+ const DEFAULT_JWKS_CACHE_MS = 5 * 60 * 1000;
33
+ const JWKS_FETCH_TIMEOUT_MS = 5_000;
34
+ const jwksCache = new Map<string, CachedJwksEntry>();
35
+ const jwksFetches = new Map<string, Promise<readonly CachedJwkKey[]>>();
36
+ const publicKeyCache = new Map<string, crypto.KeyObject>();
37
+
38
+ export async function resolveRequestAuth(
39
+ request: Request,
19
40
  secrets: AuthSecrets,
20
- logger?: { warn?(message: string, metadata?: Record<string, unknown>): void }
21
- ): AuthContext | undefined {
22
- const bearer = getHeader(req, 'authorization');
41
+ logger?: { warn?(message: string, metadata?: Record<string, unknown>): void },
42
+ ): Promise<AuthContext | undefined> {
43
+ const bearer = getHeader(request, 'authorization');
23
44
  if (bearer?.startsWith('Bearer ')) {
24
- if (!secrets.jwtSecret) {
25
- logger?.warn?.('Authorization header provided but AUTH_JWT_SECRET is not configured.');
45
+ if (!hasJwtVerificationSecrets(secrets)) {
46
+ logger?.warn?.('Authorization header provided but no JWT verification config is set.');
26
47
  } else {
27
48
  const token = bearer.slice(7).trim();
28
- const context = verifyJwtToken(token, secrets);
49
+ const context = await verifyJwtToken(token, secrets);
29
50
  if (context) {
30
51
  return context;
31
52
  }
@@ -33,7 +54,7 @@ export function resolveRequestAuth(
33
54
  }
34
55
  }
35
56
 
36
- const serviceToken = getHeader(req, 'x-service-token') ?? getHeader(req, 'x-api-key');
57
+ const serviceToken = getHeader(request, 'x-service-token') ?? getHeader(request, 'x-api-key');
37
58
  if (serviceToken && secrets.serviceTokens.length > 0) {
38
59
  if (secrets.serviceTokens.includes(serviceToken)) {
39
60
  return {
@@ -44,7 +65,7 @@ export function resolveRequestAuth(
44
65
  claims: {},
45
66
  userId: undefined,
46
67
  email: undefined,
47
- name: undefined
68
+ name: undefined,
48
69
  };
49
70
  }
50
71
  logger?.warn?.('Service token did not match any allowed AUTH_SERVICE_TOKENS entry');
@@ -53,25 +74,44 @@ export function resolveRequestAuth(
53
74
  return undefined;
54
75
  }
55
76
 
56
- function verifyJwtToken(token: string, secrets: AuthSecrets): AuthContext | undefined {
57
- if (!secrets.jwtSecret) return undefined;
77
+ function hasJwtVerificationSecrets(secrets: AuthSecrets): boolean {
78
+ return Boolean(secrets.jwtSecret || secrets.jwtPublicKey || secrets.jwksUrl);
79
+ }
80
+
81
+ async function verifyJwtToken(
82
+ token: string,
83
+ secrets: AuthSecrets,
84
+ ): Promise<AuthContext | undefined> {
58
85
  const parts = token.split('.');
59
- if (parts.length !== 3) return undefined;
60
- const [encodedHeader, encodedPayload, signature] = parts;
86
+ if (parts.length !== 3) {
87
+ return undefined;
88
+ }
61
89
 
62
- const header = decodeSegment(encodedHeader);
63
- if (!header || header.alg !== 'HS256') {
90
+ const [encodedHeader, encodedPayload, signature] = parts;
91
+ const header = decodeSegment<JwtHeader>(encodedHeader);
92
+ if (!header?.alg) {
64
93
  return undefined;
65
94
  }
66
95
 
67
- const payload = decodeSegment(encodedPayload);
96
+ const payload = decodeSegment<Record<string, unknown>>(encodedPayload);
68
97
  if (!payload) {
69
98
  return undefined;
70
99
  }
71
100
 
72
101
  const signedContent = `${encodedHeader}.${encodedPayload}`;
73
- const expectedSignature = crypto.createHmac('sha256', secrets.jwtSecret).update(signedContent).digest('base64url');
74
- if (!timingSafeEqual(signature, expectedSignature)) {
102
+ const signatureBuffer = decodeBase64Url(signature);
103
+ if (!signatureBuffer) {
104
+ return undefined;
105
+ }
106
+
107
+ const verified =
108
+ header.alg === 'HS256'
109
+ ? verifyHmacSignature(signedContent, signature, secrets.jwtSecret)
110
+ : header.alg === 'RS256'
111
+ ? await verifyRsaSignature(signedContent, signatureBuffer, header, secrets)
112
+ : false;
113
+
114
+ if (!verified) {
75
115
  return undefined;
76
116
  }
77
117
 
@@ -83,8 +123,15 @@ function verifyJwtToken(token: string, secrets: AuthSecrets): AuthContext | unde
83
123
  return undefined;
84
124
  }
85
125
 
126
+ const now = Date.now() / 1000;
127
+ if (!isValidTimeClaims(payload, now)) {
128
+ return undefined;
129
+ }
130
+
86
131
  const scopes = normalizeScopes(payload.scope);
87
- const roles = normalizeRoles(payload.roles ?? payload.role ?? payload['https://schemas.webstir.dev/roles']);
132
+ const roles = normalizeRoles(
133
+ payload.roles ?? payload.role ?? payload['https://schemas.webstir.dev/roles'],
134
+ );
88
135
 
89
136
  return {
90
137
  source: 'jwt',
@@ -94,14 +141,235 @@ function verifyJwtToken(token: string, secrets: AuthSecrets): AuthContext | unde
94
141
  name: typeof payload.name === 'string' ? payload.name : undefined,
95
142
  scopes,
96
143
  roles,
97
- claims: payload as Record<string, unknown>
144
+ claims: payload,
98
145
  };
99
146
  }
100
147
 
101
- function decodeSegment(segment: string): Record<string, any> | undefined {
148
+ function verifyHmacSignature(signedContent: string, signature: string, secret?: string): boolean {
149
+ if (!secret) {
150
+ return false;
151
+ }
152
+
153
+ const expectedSignature = crypto
154
+ .createHmac('sha256', secret)
155
+ .update(signedContent)
156
+ .digest('base64url');
157
+ return timingSafeEqual(signature, expectedSignature);
158
+ }
159
+
160
+ async function verifyRsaSignature(
161
+ signedContent: string,
162
+ signature: Buffer,
163
+ header: JwtHeader,
164
+ secrets: AuthSecrets,
165
+ ): Promise<boolean> {
166
+ if (secrets.jwtPublicKey) {
167
+ const publicKey = getConfiguredPublicKey(secrets.jwtPublicKey);
168
+ if (publicKey && verifyWithPublicKey(signedContent, signature, publicKey)) {
169
+ return true;
170
+ }
171
+ }
172
+
173
+ if (!secrets.jwksUrl) {
174
+ return false;
175
+ }
176
+
177
+ const jwksKeys = await getJwksKeys(secrets.jwksUrl);
178
+ const initialCandidates = selectJwksCandidates(jwksKeys, header);
179
+ for (const candidate of initialCandidates) {
180
+ if (verifyWithPublicKey(signedContent, signature, candidate.key)) {
181
+ return true;
182
+ }
183
+ }
184
+
185
+ if (!header.kid) {
186
+ return false;
187
+ }
188
+
189
+ const refreshedKeys = await getJwksKeys(secrets.jwksUrl, { forceRefresh: true });
190
+ for (const candidate of selectJwksCandidates(refreshedKeys, header)) {
191
+ if (verifyWithPublicKey(signedContent, signature, candidate.key)) {
192
+ return true;
193
+ }
194
+ }
195
+
196
+ return false;
197
+ }
198
+
199
+ function verifyWithPublicKey(
200
+ signedContent: string,
201
+ signature: Buffer,
202
+ publicKey: crypto.KeyObject,
203
+ ): boolean {
204
+ try {
205
+ return crypto.verify('RSA-SHA256', Buffer.from(signedContent), publicKey, signature);
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ function getConfiguredPublicKey(value: string): crypto.KeyObject | undefined {
212
+ const cached = publicKeyCache.get(value);
213
+ if (cached) {
214
+ return cached;
215
+ }
216
+
102
217
  try {
103
- const json = Buffer.from(segment, 'base64url').toString('utf8');
104
- return JSON.parse(json) as Record<string, unknown>;
218
+ const trimmed = value.trim();
219
+ const key = trimmed.startsWith('{')
220
+ ? crypto.createPublicKey({ key: JSON.parse(trimmed) as crypto.JsonWebKey, format: 'jwk' })
221
+ : crypto.createPublicKey(trimmed);
222
+ publicKeyCache.set(value, key);
223
+ return key;
224
+ } catch {
225
+ return undefined;
226
+ }
227
+ }
228
+
229
+ function selectJwksCandidates(
230
+ keys: readonly CachedJwkKey[],
231
+ header: JwtHeader,
232
+ ): readonly CachedJwkKey[] {
233
+ if (header.kid) {
234
+ return keys.filter((key) => key.kid === header.kid);
235
+ }
236
+
237
+ return keys;
238
+ }
239
+
240
+ async function getJwksKeys(
241
+ url: string,
242
+ options: { forceRefresh?: boolean } = {},
243
+ ): Promise<readonly CachedJwkKey[]> {
244
+ if (options.forceRefresh) {
245
+ jwksCache.delete(url);
246
+ }
247
+
248
+ const cached = jwksCache.get(url);
249
+ const now = Date.now();
250
+ if (cached && cached.expiresAt > now) {
251
+ return cached.keys;
252
+ }
253
+
254
+ const pending = jwksFetches.get(url);
255
+ if (pending) {
256
+ return await pending;
257
+ }
258
+
259
+ const fetchPromise = fetchJwksKeys(url, cached?.keys ?? []).finally(() => {
260
+ jwksFetches.delete(url);
261
+ });
262
+ jwksFetches.set(url, fetchPromise);
263
+ return await fetchPromise;
264
+ }
265
+
266
+ async function fetchJwksKeys(
267
+ url: string,
268
+ fallbackKeys: readonly CachedJwkKey[],
269
+ ): Promise<readonly CachedJwkKey[]> {
270
+ const controller = new AbortController();
271
+ const timeout = setTimeout(() => controller.abort(), JWKS_FETCH_TIMEOUT_MS);
272
+ try {
273
+ const response = await fetch(url, { signal: controller.signal });
274
+ if (!response.ok) {
275
+ throw new Error(`JWKS request failed with status ${response.status}`);
276
+ }
277
+
278
+ const body = (await response.json()) as { keys?: unknown };
279
+ const keys = Array.isArray(body.keys) ? body.keys : [];
280
+ const resolvedKeys = keys
281
+ .map((key) => jwkToCachedKey(key))
282
+ .filter((key): key is CachedJwkKey => key !== undefined);
283
+
284
+ const ttlMs = resolveJwksCacheTtl(response.headers);
285
+ jwksCache.set(url, {
286
+ keys: resolvedKeys,
287
+ expiresAt: Date.now() + ttlMs,
288
+ });
289
+
290
+ return resolvedKeys;
291
+ } catch {
292
+ if (fallbackKeys.length > 0) {
293
+ jwksCache.set(url, {
294
+ keys: fallbackKeys,
295
+ expiresAt: Date.now() + DEFAULT_JWKS_CACHE_MS,
296
+ });
297
+ return fallbackKeys;
298
+ }
299
+ jwksCache.delete(url);
300
+ return [];
301
+ } finally {
302
+ clearTimeout(timeout);
303
+ }
304
+ }
305
+
306
+ function jwkToCachedKey(value: unknown): CachedJwkKey | undefined {
307
+ if (!value || typeof value !== 'object') {
308
+ return undefined;
309
+ }
310
+
311
+ try {
312
+ const jwk = value as crypto.JsonWebKey;
313
+ const key = crypto.createPublicKey({ key: jwk, format: 'jwk' });
314
+ return {
315
+ kid: typeof jwk.kid === 'string' ? jwk.kid : undefined,
316
+ key,
317
+ };
318
+ } catch {
319
+ return undefined;
320
+ }
321
+ }
322
+
323
+ function resolveJwksCacheTtl(headers: Headers): number {
324
+ const cacheControl = headers.get('cache-control');
325
+ if (cacheControl) {
326
+ const maxAgeMatch = cacheControl.match(/max-age=(\d+)/i);
327
+ if (maxAgeMatch) {
328
+ const maxAgeSeconds = Number(maxAgeMatch[1]);
329
+ if (Number.isFinite(maxAgeSeconds) && maxAgeSeconds > 0) {
330
+ return maxAgeSeconds * 1000;
331
+ }
332
+ }
333
+ if (/\bno-store\b/i.test(cacheControl)) {
334
+ return 0;
335
+ }
336
+ }
337
+
338
+ const expires = headers.get('expires');
339
+ if (expires) {
340
+ const expiresAt = Date.parse(expires);
341
+ if (Number.isFinite(expiresAt)) {
342
+ const ttl = expiresAt - Date.now();
343
+ if (ttl > 0) {
344
+ return ttl;
345
+ }
346
+ }
347
+ }
348
+
349
+ return DEFAULT_JWKS_CACHE_MS;
350
+ }
351
+
352
+ function decodeSegment<T extends Record<string, unknown>>(segment: string): T | undefined {
353
+ const decoded = decodeBase64Url(segment);
354
+ if (!decoded) {
355
+ return undefined;
356
+ }
357
+
358
+ try {
359
+ const json = decoded.toString('utf8');
360
+ return JSON.parse(json) as T;
361
+ } catch {
362
+ return undefined;
363
+ }
364
+ }
365
+
366
+ function decodeBase64Url(value: string): Buffer | undefined {
367
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) {
368
+ return undefined;
369
+ }
370
+
371
+ try {
372
+ return Buffer.from(value, 'base64url');
105
373
  } catch {
106
374
  return undefined;
107
375
  }
@@ -126,13 +394,47 @@ function audienceMatches(value: unknown, expected: string): boolean {
126
394
  return false;
127
395
  }
128
396
 
397
+ function isValidTimeClaims(payload: Record<string, unknown>, now: number): boolean {
398
+ if (payload.iat !== undefined && parseNumericDateClaim(payload.iat) === undefined) {
399
+ return false;
400
+ }
401
+
402
+ const notBefore = parseNumericDateClaim(payload.nbf);
403
+ if (payload.nbf !== undefined && notBefore === undefined) {
404
+ return false;
405
+ }
406
+ if (notBefore !== undefined && now < notBefore) {
407
+ return false;
408
+ }
409
+
410
+ const expiresAt = parseNumericDateClaim(payload.exp);
411
+ if (payload.exp !== undefined && expiresAt === undefined) {
412
+ return false;
413
+ }
414
+ if (expiresAt !== undefined && now >= expiresAt) {
415
+ return false;
416
+ }
417
+
418
+ return true;
419
+ }
420
+
421
+ function parseNumericDateClaim(value: unknown): number | undefined {
422
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
423
+ return undefined;
424
+ }
425
+ return value;
426
+ }
427
+
129
428
  function normalizeScopes(value: unknown): string[] {
130
429
  if (!value) return [];
131
430
  if (Array.isArray(value)) {
132
431
  return value.map((scope) => String(scope));
133
432
  }
134
433
  if (typeof value === 'string') {
135
- return value.split(' ').map((scope) => scope.trim()).filter((scope) => scope.length > 0);
434
+ return value
435
+ .split(' ')
436
+ .map((scope) => scope.trim())
437
+ .filter((scope) => scope.length > 0);
136
438
  }
137
439
  return [];
138
440
  }
@@ -143,18 +445,15 @@ function normalizeRoles(value: unknown): string[] {
143
445
  return value.map((role) => String(role));
144
446
  }
145
447
  if (typeof value === 'string') {
146
- return value.split(',').map((role) => role.trim()).filter((role) => role.length > 0);
448
+ return value
449
+ .split(',')
450
+ .map((role) => role.trim())
451
+ .filter((role) => role.length > 0);
147
452
  }
148
453
  return [];
149
454
  }
150
455
 
151
- function getHeader(req: http.IncomingMessage, name: string): string | undefined {
152
- const value = req.headers[name] ?? req.headers[name.toLowerCase()];
153
- if (typeof value === 'string') {
154
- return value;
155
- }
156
- if (Array.isArray(value)) {
157
- return value[0];
158
- }
159
- return undefined;
456
+ function getHeader(request: Pick<Request, 'headers'>, name: string): string | undefined {
457
+ const normalized = request.headers.get(name) ?? request.headers.get(name.toLowerCase());
458
+ return normalized ?? undefined;
160
459
  }