veryfront 0.1.811 → 0.1.813

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 (106) hide show
  1. package/esm/deno.js +1 -1
  2. package/esm/src/agent/conversation/durable-contracts.d.ts +204 -0
  3. package/esm/src/agent/conversation/durable-contracts.d.ts.map +1 -0
  4. package/esm/src/agent/conversation/durable-contracts.js +168 -0
  5. package/esm/src/agent/conversation/durable.d.ts +3 -198
  6. package/esm/src/agent/conversation/durable.d.ts.map +1 -1
  7. package/esm/src/agent/conversation/durable.js +2 -167
  8. package/esm/src/agent/runtime/agent-runtime-step.d.ts +44 -0
  9. package/esm/src/agent/runtime/agent-runtime-step.d.ts.map +1 -0
  10. package/esm/src/agent/runtime/agent-runtime-step.js +23 -0
  11. package/esm/src/agent/runtime/index.d.ts.map +1 -1
  12. package/esm/src/agent/runtime/index.js +40 -39
  13. package/esm/src/agent/runtime/model-transport.d.ts +23 -0
  14. package/esm/src/agent/runtime/model-transport.d.ts.map +1 -0
  15. package/esm/src/agent/runtime/model-transport.js +26 -0
  16. package/esm/src/cache/keys/builders/render.d.ts.map +1 -1
  17. package/esm/src/cache/keys/builders/render.js +3 -2
  18. package/esm/src/cache/request-cacheability.d.ts +3 -0
  19. package/esm/src/cache/request-cacheability.d.ts.map +1 -0
  20. package/esm/src/cache/request-cacheability.js +26 -0
  21. package/esm/src/html/hydration-script-builder/hydration-data-generator.d.ts.map +1 -1
  22. package/esm/src/html/hydration-script-builder/hydration-data-generator.js +1 -10
  23. package/esm/src/html/hydration-script-builder/templates/router.d.ts.map +1 -1
  24. package/esm/src/html/hydration-script-builder/templates/router.js +41 -10
  25. package/esm/src/modules/import-map/loader.d.ts.map +1 -1
  26. package/esm/src/modules/import-map/loader.js +2 -1
  27. package/esm/src/modules/manifest/route-module-manifest.d.ts.map +1 -1
  28. package/esm/src/modules/manifest/route-module-manifest.js +2 -0
  29. package/esm/src/modules/server/module-server.d.ts.map +1 -1
  30. package/esm/src/modules/server/module-server.js +27 -9
  31. package/esm/src/observability/request-profiler.d.ts.map +1 -1
  32. package/esm/src/observability/request-profiler.js +1 -0
  33. package/esm/src/observability/simple-metrics/index.d.ts +6 -3
  34. package/esm/src/observability/simple-metrics/index.d.ts.map +1 -1
  35. package/esm/src/observability/simple-metrics/index.js +5 -2
  36. package/esm/src/observability/simple-metrics/metrics-recorder.d.ts +4 -0
  37. package/esm/src/observability/simple-metrics/metrics-recorder.d.ts.map +1 -1
  38. package/esm/src/observability/simple-metrics/metrics-recorder.js +34 -0
  39. package/esm/src/observability/simple-metrics/metrics-state.d.ts.map +1 -1
  40. package/esm/src/observability/simple-metrics/metrics-state.js +24 -0
  41. package/esm/src/observability/simple-metrics/otel-instruments.d.ts.map +1 -1
  42. package/esm/src/observability/simple-metrics/otel-instruments.js +13 -0
  43. package/esm/src/observability/simple-metrics/types.d.ts +12 -0
  44. package/esm/src/observability/simple-metrics/types.d.ts.map +1 -1
  45. package/esm/src/platform/adapters/runtime/shared/shared-watcher.d.ts.map +1 -1
  46. package/esm/src/platform/adapters/runtime/shared/shared-watcher.js +1 -1
  47. package/esm/src/platform/compat/http/native-response.d.ts +10 -1
  48. package/esm/src/platform/compat/http/native-response.d.ts.map +1 -1
  49. package/esm/src/platform/compat/http/native-response.js +20 -14
  50. package/esm/src/proxy/handler.d.ts +1 -7
  51. package/esm/src/proxy/handler.d.ts.map +1 -1
  52. package/esm/src/proxy/handler.js +63 -189
  53. package/esm/src/proxy/main.js +21 -20
  54. package/esm/src/proxy/proxy-access-control.d.ts +41 -0
  55. package/esm/src/proxy/proxy-access-control.d.ts.map +1 -0
  56. package/esm/src/proxy/proxy-access-control.js +151 -0
  57. package/esm/src/proxy/proxy-error-context.d.ts +20 -0
  58. package/esm/src/proxy/proxy-error-context.d.ts.map +1 -0
  59. package/esm/src/proxy/proxy-error-context.js +35 -0
  60. package/esm/src/proxy/request-lifecycle.d.ts +19 -0
  61. package/esm/src/proxy/request-lifecycle.d.ts.map +1 -0
  62. package/esm/src/proxy/request-lifecycle.js +27 -0
  63. package/esm/src/proxy/upstream-error-response.d.ts +5 -0
  64. package/esm/src/proxy/upstream-error-response.d.ts.map +1 -0
  65. package/esm/src/proxy/upstream-error-response.js +15 -0
  66. package/esm/src/react/components/chat/chat/composition/chat-composer.d.ts.map +1 -1
  67. package/esm/src/react/components/chat/chat/composition/chat-composer.js +3 -1
  68. package/esm/src/release-assets/client-module-map.d.ts +3 -0
  69. package/esm/src/release-assets/client-module-map.d.ts.map +1 -0
  70. package/esm/src/release-assets/client-module-map.js +10 -0
  71. package/esm/src/rendering/cache/cache-coordinator.d.ts.map +1 -1
  72. package/esm/src/rendering/cache/cache-coordinator.js +1 -0
  73. package/esm/src/rendering/cache/stores/memory-store.d.ts +2 -0
  74. package/esm/src/rendering/cache/stores/memory-store.d.ts.map +1 -1
  75. package/esm/src/rendering/cache/stores/memory-store.js +2 -1
  76. package/esm/src/rendering/orchestrator/lifecycle.d.ts.map +1 -1
  77. package/esm/src/rendering/orchestrator/lifecycle.js +1 -0
  78. package/esm/src/rendering/orchestrator/module-loader/index.d.ts.map +1 -1
  79. package/esm/src/rendering/orchestrator/module-loader/index.js +13 -49
  80. package/esm/src/rendering/orchestrator/module-loader/module-cache-lookup.d.ts +25 -0
  81. package/esm/src/rendering/orchestrator/module-loader/module-cache-lookup.d.ts.map +1 -0
  82. package/esm/src/rendering/orchestrator/module-loader/module-cache-lookup.js +69 -0
  83. package/esm/src/rendering/orchestrator/pipeline.d.ts.map +1 -1
  84. package/esm/src/rendering/orchestrator/pipeline.js +22 -35
  85. package/esm/src/rendering/orchestrator/render-result-assembly.d.ts +27 -0
  86. package/esm/src/rendering/orchestrator/render-result-assembly.d.ts.map +1 -0
  87. package/esm/src/rendering/orchestrator/render-result-assembly.js +28 -0
  88. package/esm/src/rendering/orchestrator/types.d.ts +2 -0
  89. package/esm/src/rendering/orchestrator/types.d.ts.map +1 -1
  90. package/esm/src/rendering/renderer.d.ts.map +1 -1
  91. package/esm/src/rendering/renderer.js +24 -19
  92. package/esm/src/rendering/shared/context-aware-cache.d.ts.map +1 -1
  93. package/esm/src/rendering/shared/context-aware-cache.js +1 -0
  94. package/esm/src/server/handlers/dev/dashboard/api.d.ts +3 -0
  95. package/esm/src/server/handlers/dev/dashboard/api.d.ts.map +1 -1
  96. package/esm/src/server/handlers/dev/dashboard/api.js +41 -57
  97. package/esm/src/server/handlers/dev/framework-candidates.generated.d.ts.map +1 -1
  98. package/esm/src/server/handlers/dev/framework-candidates.generated.js +11 -1
  99. package/esm/src/server/handlers/request/module/page-data-endpoint-handler.d.ts +1 -0
  100. package/esm/src/server/handlers/request/module/page-data-endpoint-handler.d.ts.map +1 -1
  101. package/esm/src/server/handlers/request/module/page-data-endpoint-handler.js +98 -8
  102. package/esm/src/transforms/esm/specifier-resolver.d.ts.map +1 -1
  103. package/esm/src/transforms/esm/specifier-resolver.js +14 -0
  104. package/esm/src/utils/version-constant.d.ts +1 -1
  105. package/esm/src/utils/version-constant.js +1 -1
  106. package/package.json +1 -1
@@ -1,46 +1,12 @@
1
1
  import { TokenManager } from "./token-manager.js";
2
2
  import { parseProjectDomain } from "../server/utils/domain-parser.js";
3
- import { getEnv } from "../platform/compat/process.js";
4
3
  import { injectContext, ProxySpanNames, withSpan } from "./tracing.js";
5
4
  import { computeContentSourceId } from "../cache/keys.js";
6
- import { resolve as resolveContract } from "../extensions/contracts.js";
5
+ import { checkProtectedProxyAccess } from "./proxy-access-control.js";
7
6
  import { createLocalProjectResolver } from "./local-project-resolver.js";
8
7
  import { isMissingCustomDomainProjectError, resolveProxyRequestToken, } from "./proxy-token-resolution.js";
9
- /**
10
- * Cache the resolved AuthProvider at module scope so the proxy does not pay
11
- * the registry lookup on every request. The cache is cleared implicitly when
12
- * `ExtensionLoader.teardownAll()` clears the registry — the next call
13
- * re-resolves (or surfaces the "install ext-auth-jwt" hint if the extension was
14
- * removed).
15
- */
16
- let cachedAuthProvider;
17
- function getAuthProvider() {
18
- if (cachedAuthProvider)
19
- return cachedAuthProvider;
20
- try {
21
- cachedAuthProvider = resolveContract("AuthProvider");
22
- return cachedAuthProvider;
23
- }
24
- catch (err) {
25
- // resolve() already throws with a helpful "Recommended: @veryfront/ext-auth-jwt"
26
- // message, but the proxy is a load-bearing code path — append a concrete
27
- // remediation hint that names the project-root extension directory so
28
- // the user knows exactly what's missing.
29
- const base = err instanceof Error ? err.message : String(err);
30
- throw new Error(`${base}\nTo enable JWT verification in the proxy, install ext-auth-jwt ` +
31
- `(scaffold with \`deno task cli extension init ext-auth-jwt\` or add the ` +
32
- `npm package @veryfront/ext-auth-jwt).`, { cause: err });
33
- }
34
- }
35
- /**
36
- * Reset the cached AuthProvider. Intended for tests that `register()` a mock
37
- * after the handler module has been imported.
38
- *
39
- * @internal
40
- */
41
- export function __resetCachedAuthProviderForTests() {
42
- cachedAuthProvider = undefined;
43
- }
8
+ import { createProjectNotFoundProxyContext, createProxyErrorContext, createReleaseNotFoundProxyContext, } from "./proxy-error-context.js";
9
+ export { __resetCachedAuthProviderForTests } from "./proxy-access-control.js";
44
10
  export const INTERNAL_PROXY_HEADERS = [
45
11
  "x-token",
46
12
  "x-project-slug",
@@ -75,18 +41,6 @@ function isSignedInternalControlPlaneRequest(req, url) {
75
41
  }
76
42
  return !!req.headers.get("x-token");
77
43
  }
78
- function resolveApiJwksUrl(apiBaseUrl, logger) {
79
- try {
80
- const normalizedBaseUrl = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
81
- return new URL(".well-known/jwks.json", normalizedBaseUrl).toString();
82
- }
83
- catch (error) {
84
- logger?.error("Invalid API base URL for JWKS lookup", error, {
85
- apiBaseUrl,
86
- });
87
- return undefined;
88
- }
89
- }
90
44
  async function lookupProjectByDomain(domain, apiBaseUrl, token, logger) {
91
45
  return withSpan(ProxySpanNames.PROXY_DOMAIN_LOOKUP, async () => {
92
46
  const domainWithoutPort = domain.replace(/:\d+$/, "");
@@ -141,59 +95,6 @@ function parseStatusFromError(error) {
141
95
  const match = message.match(/failed: (\d+)/);
142
96
  return match ? Number(match[1]) : null;
143
97
  }
144
- async function extractUserIdFromToken(token, apiBaseUrl, log) {
145
- const auth = getAuthProvider();
146
- const header = auth.decode(token);
147
- if (!header) {
148
- log?.debug("Failed to decode JWT header");
149
- return undefined;
150
- }
151
- const algorithm = header.alg;
152
- if (algorithm === "RS256") {
153
- const jwksUrl = resolveApiJwksUrl(apiBaseUrl, log);
154
- if (!jwksUrl)
155
- return undefined;
156
- try {
157
- const payload = await auth.verifyWithJwks(token, jwksUrl, {
158
- algorithms: ["RS256"],
159
- });
160
- return payload.userId;
161
- }
162
- catch (error) {
163
- log?.debug("RS256 JWT verification failed", {
164
- error: error instanceof Error ? error.message : String(error),
165
- });
166
- return undefined;
167
- }
168
- }
169
- if (algorithm !== "HS256") {
170
- log?.debug("Unsupported JWT algorithm", { algorithm: algorithm ?? null });
171
- return undefined;
172
- }
173
- const jwtSecret = getEnv("JWT_SECRET");
174
- if (!jwtSecret) {
175
- log?.warn("JWT_SECRET not configured — cannot verify user token");
176
- return undefined;
177
- }
178
- try {
179
- // ext-auth-jwt reads JWT_SECRET from the environment when no `secret` was
180
- // passed to the extension factory; the explicit env check above is kept
181
- // so callers can warn once before we attempt verification.
182
- const payload = await auth.verify(token, { algorithms: ["HS256"] });
183
- return payload.userId;
184
- }
185
- catch (error) {
186
- log?.debug("JWT verification failed", {
187
- error: error instanceof Error ? error.message : String(error),
188
- });
189
- return undefined;
190
- }
191
- }
192
- function isProjectMember(users, userId) {
193
- if (!users || !userId)
194
- return false;
195
- return users.some((u) => u.id === userId);
196
- }
197
98
  export function createProxyHandler(options) {
198
99
  const { config, cache, logger } = options;
199
100
  const localProjects = config.localProjects ?? {};
@@ -213,85 +114,22 @@ export function createProxyHandler(options) {
213
114
  missing.push("VERYFRONT_PROXY_API_CLIENT_SECRET");
214
115
  return missing;
215
116
  }
216
- function makeErrorContext(base, status, message, token, redirectUrl, slug) {
217
- return {
218
- token,
219
- projectSlug: undefined,
220
- projectId: undefined,
221
- environment: base.scope,
222
- contentSourceId: "error",
223
- localPath: undefined,
224
- host: base.host,
225
- parsedDomain: base.parsedDomain,
226
- isLocalProject: false,
227
- error: { status, message, redirectUrl, slug },
228
- };
229
- }
230
- function makeAuthRedirectUrl(url) {
231
- // Collapse leading slashes to prevent protocol-relative open redirects (e.g. "//evil.com/path")
232
- const safePath = url.pathname.replace(/^\/\/+/, "/");
233
- let returnPath = safePath + url.search;
234
- // Ensure the return path stays within the application and is not an absolute URL.
235
- // - It must start with "/".
236
- // - It must not contain a scheme delimiter ("://").
237
- // If it fails validation, fall back to the root path.
238
- if (!returnPath.startsWith("/") || returnPath.includes("://")) {
239
- returnPath = "/";
240
- }
241
- const isHostedProductionDeployment = url.hostname.endsWith(".production.veryfront.org") ||
242
- url.hostname.endsWith(".production.veryfront.com");
243
- const returnTarget = isHostedProductionDeployment ? url.toString() : returnPath;
244
- return `https://veryfront.com/sign-in?from=${encodeURIComponent(returnTarget)}`;
245
- }
246
- function makeProjectNotFoundContext(base, message, token) {
247
- return makeErrorContext(base, 404, message, token, undefined, "project-not-found");
248
- }
249
- async function checkProtectedAccess(req, url, matchingEnv, userToken, users, logContext) {
250
- if (!matchingEnv?.protected)
251
- return null;
252
- if (isSignedInternalControlPlaneRequest(req, url)) {
253
- logger?.debug("Allowing signed internal control-plane request through protected environment", {
254
- ...logContext,
255
- environmentName: matchingEnv.name,
256
- pathname: url.pathname,
257
- });
258
- return null;
259
- }
260
- if (!userToken) {
261
- const redirectUrl = makeAuthRedirectUrl(url);
262
- logger?.info("Protected environment requires authentication", {
263
- ...logContext,
264
- environmentName: matchingEnv.name,
265
- redirectUrl,
266
- });
267
- return { status: 302, message: "Authentication required", redirectUrl };
268
- }
269
- const userId = await extractUserIdFromToken(userToken, config.apiBaseUrl, logger);
270
- if (!userId) {
271
- const redirectUrl = makeAuthRedirectUrl(url);
272
- logger?.info("Could not extract userId from token", {
273
- ...logContext,
274
- environmentName: matchingEnv.name,
275
- redirectUrl,
276
- });
277
- return { status: 302, message: "Authentication required", redirectUrl };
278
- }
279
- if (!isProjectMember(users, userId)) {
280
- logger?.info("User is not a member of the project", {
281
- ...logContext,
282
- environmentName: matchingEnv.name,
283
- userId,
284
- });
285
- return { status: 403, message: "Access denied" };
286
- }
287
- return null;
288
- }
289
117
  async function resolveReleaseAndProtection(req, url, token, userToken, lookupKey, envMatcher, logContext) {
290
118
  const lookupResult = await lookupProjectByDomain(lookupKey, config.apiBaseUrl, token, logger);
291
119
  if (!lookupResult)
292
120
  return { projectId: undefined, releaseId: undefined };
293
121
  const matchingEnv = lookupResult.environments?.find(envMatcher);
294
- const protectionError = await checkProtectedAccess(req, url, matchingEnv, userToken, lookupResult.users, logContext);
122
+ const protectionError = await checkProtectedProxyAccess({
123
+ req,
124
+ url,
125
+ matchingEnv,
126
+ userToken,
127
+ users: lookupResult.users,
128
+ apiBaseUrl: config.apiBaseUrl,
129
+ logger,
130
+ logContext,
131
+ isSignedInternalControlPlaneRequest: isSignedInternalControlPlaneRequest(req, url),
132
+ });
295
133
  if (protectionError)
296
134
  return { error: protectionError };
297
135
  return {
@@ -359,10 +197,10 @@ export function createProxyHandler(options) {
359
197
  if (status === 404) {
360
198
  if (scope === "preview") {
361
199
  logger?.info("Preview project not found", { projectSlug, host });
362
- return makeProjectNotFoundContext(base, "Preview project not found");
200
+ return createProjectNotFoundProxyContext(base, "Preview project not found");
363
201
  }
364
202
  logger?.info("Project not found", { projectSlug, host, scope });
365
- return makeProjectNotFoundContext(base, "Project not found");
203
+ return createProjectNotFoundProxyContext(base, "Project not found");
366
204
  }
367
205
  const message = scope === "preview"
368
206
  ? "Failed to authenticate preview request"
@@ -374,7 +212,7 @@ export function createProxyHandler(options) {
374
212
  hadUserToken: !!userToken,
375
213
  hadTokenFetchError: !!tokenFetchError,
376
214
  });
377
- return makeErrorContext(base, 502, message);
215
+ return createProxyErrorContext(base, { status: 502, message });
378
216
  }
379
217
  if (isCustomDomain && !projectSlug) {
380
218
  if (!token) {
@@ -382,15 +220,26 @@ export function createProxyHandler(options) {
382
220
  logger?.info("Custom domain project not found during token fetch", {
383
221
  domain: host,
384
222
  });
385
- return makeErrorContext(base, 404, `No project configured for domain: ${host}`);
223
+ return createProxyErrorContext(base, {
224
+ status: 404,
225
+ message: `No project configured for domain: ${host}`,
226
+ });
386
227
  }
387
228
  logger?.error("Cannot process custom domain without token", undefined, { domain: host });
388
- return makeErrorContext(base, 502, `Failed to authenticate for domain: ${host}`, token);
229
+ return createProxyErrorContext(base, {
230
+ status: 502,
231
+ message: `Failed to authenticate for domain: ${host}`,
232
+ token,
233
+ });
389
234
  }
390
235
  const lookupResult = await lookupProjectByDomain(host, config.apiBaseUrl, token, logger);
391
236
  if (!lookupResult) {
392
237
  logger?.info("Custom domain not found", { domain: host });
393
- return makeErrorContext(base, 404, `No project configured for domain: ${host}`, token);
238
+ return createProxyErrorContext(base, {
239
+ status: 404,
240
+ message: `No project configured for domain: ${host}`,
241
+ token,
242
+ });
394
243
  }
395
244
  projectSlug = lookupResult.slug;
396
245
  projectId = lookupResult.id;
@@ -398,9 +247,24 @@ export function createProxyHandler(options) {
398
247
  const matchingEnv = lookupResult.environments?.find((env) => env.domains?.some((d) => d.toLowerCase() === normalizedHost));
399
248
  releaseId = matchingEnv?.active_release_id ?? undefined;
400
249
  environmentId = matchingEnv?.id;
401
- const protectionError = await checkProtectedAccess(req, url, matchingEnv, userToken, lookupResult.users, { domain: host });
250
+ const protectionError = await checkProtectedProxyAccess({
251
+ req,
252
+ url,
253
+ matchingEnv,
254
+ userToken,
255
+ users: lookupResult.users,
256
+ apiBaseUrl: config.apiBaseUrl,
257
+ logger,
258
+ logContext: { domain: host },
259
+ isSignedInternalControlPlaneRequest: isSignedInternalControlPlaneRequest(req, url),
260
+ });
402
261
  if (protectionError) {
403
- return makeErrorContext(base, protectionError.status, protectionError.message, token, protectionError.redirectUrl);
262
+ return createProxyErrorContext(base, {
263
+ status: protectionError.status,
264
+ message: protectionError.message,
265
+ token,
266
+ redirectUrl: protectionError.redirectUrl,
267
+ });
404
268
  }
405
269
  logger?.info("Resolved custom domain to project", {
406
270
  domain: host,
@@ -414,7 +278,12 @@ export function createProxyHandler(options) {
414
278
  const targetEnv = parsedDomain.environment.toLowerCase();
415
279
  const resolved = await resolveReleaseAndProtection(req, url, token, userToken, projectSlug, (env) => env.name.toLowerCase() === targetEnv, { projectSlug });
416
280
  if ("error" in resolved) {
417
- return makeErrorContext(base, resolved.error.status, resolved.error.message, token, resolved.error.redirectUrl);
281
+ return createProxyErrorContext(base, {
282
+ status: resolved.error.status,
283
+ message: resolved.error.message,
284
+ token,
285
+ redirectUrl: resolved.error.redirectUrl,
286
+ });
418
287
  }
419
288
  if (!resolved.projectId) {
420
289
  logger?.info("Project not found after lookup", {
@@ -423,7 +292,7 @@ export function createProxyHandler(options) {
423
292
  scope,
424
293
  targetEnvName: parsedDomain.environment,
425
294
  });
426
- return makeProjectNotFoundContext(base, "Project not found", token);
295
+ return createProjectNotFoundProxyContext(base, "Project not found", token);
427
296
  }
428
297
  projectId = resolved.projectId;
429
298
  releaseId = resolved.releaseId;
@@ -441,11 +310,16 @@ export function createProxyHandler(options) {
441
310
  // still enforce the environment's `protected` flag like other scopes.
442
311
  const resolved = await resolveReleaseAndProtection(req, url, token, userToken, projectSlug, (env) => env.name.toLowerCase() === "preview", { projectSlug });
443
312
  if ("error" in resolved) {
444
- return makeErrorContext(base, resolved.error.status, resolved.error.message, token, resolved.error.redirectUrl);
313
+ return createProxyErrorContext(base, {
314
+ status: resolved.error.status,
315
+ message: resolved.error.message,
316
+ token,
317
+ redirectUrl: resolved.error.redirectUrl,
318
+ });
445
319
  }
446
320
  if (!resolved.projectId) {
447
321
  logger?.info("Preview project not found after lookup", { projectSlug, host });
448
- return makeProjectNotFoundContext(base, "Preview project not found", token);
322
+ return createProjectNotFoundProxyContext(base, "Preview project not found", token);
449
323
  }
450
324
  projectId = resolved.projectId;
451
325
  environmentId = resolved.environmentId;
@@ -465,7 +339,7 @@ export function createProxyHandler(options) {
465
339
  host,
466
340
  environment: scope,
467
341
  });
468
- return makeErrorContext({ scope, host, parsedDomain }, 404, "No active release found", token, undefined, "release-not-found");
342
+ return createReleaseNotFoundProxyContext({ scope, host, parsedDomain }, token);
469
343
  }
470
344
  const contentSourceId = computeContentSourceId(isLocalProject, scope, parsedDomain.branch, releaseId);
471
345
  return {
@@ -35,6 +35,8 @@ import { exit, getEnv, onSignal } from "../platform/compat/process.js";
35
35
  import { createHttpServer, upgradeWebSocket } from "../platform/compat/http/index.js";
36
36
  import { createProxyErrorResponse, jsonErrorResponse } from "./error-response.js";
37
37
  import { handleReleaseAssetRequest, isReleaseAssetPath } from "./asset-handler.js";
38
+ import { runProxyRequestLifecycle } from "./request-lifecycle.js";
39
+ import { createUpstreamFailureResponse, createUpstreamTimeoutResponse, UPSTREAM_FAILURE_STATUS, UPSTREAM_TIMEOUT_STATUS, } from "./upstream-error-response.js";
38
40
  function getLocalProjects() {
39
41
  const raw = getEnv("LOCAL_PROJECTS");
40
42
  return raw ? JSON.parse(raw) : {};
@@ -231,9 +233,7 @@ function forwardToServer(req, url) {
231
233
  const startTime = performance.now();
232
234
  const requestId = dntShim.crypto.randomUUID();
233
235
  const host = req.headers.get("host") || "";
234
- const parentContext = extractContext(req.headers);
235
- const spanInfo = startServerSpan(req.method, url.pathname, parentContext);
236
- const execute = async () => {
236
+ const execute = async (lifecycle) => {
237
237
  try {
238
238
  const ctx = await proxyHandler.processRequest(req, { url });
239
239
  return runWithProxyRequestContext({
@@ -250,7 +250,7 @@ function forwardToServer(req, url) {
250
250
  const ms = Math.round(performance.now() - startTime);
251
251
  const logLevel = getProxyFailureLogLevel(ctx.error.status, req.method, url.pathname);
252
252
  proxyLogger[logLevel](`${ctx.error.status} ${req.method} ${url.pathname}`, { ms });
253
- endSpan(spanInfo?.span, ctx.error.status);
253
+ lifecycle.end(ctx.error.status);
254
254
  return createProxyErrorResponse(ctx.error);
255
255
  }
256
256
  const reqLogger = proxyLogger.child({
@@ -332,7 +332,6 @@ function forwardToServer(req, url) {
332
332
  else {
333
333
  reqLogger.info(`${response.status} ${req.method} ${url.pathname}`, { ms });
334
334
  }
335
- endSpan(spanInfo?.span, response.status);
336
335
  return new Response(response.body, {
337
336
  status: response.status,
338
337
  statusText: response.statusText,
@@ -344,15 +343,12 @@ function forwardToServer(req, url) {
344
343
  lastError = error;
345
344
  if (error instanceof Error && error.name === "AbortError") {
346
345
  const ms = Math.round(performance.now() - startTime);
347
- proxyLogger.error(`504 ${req.method} ${url.pathname}`, {
346
+ proxyLogger.error(`${UPSTREAM_TIMEOUT_STATUS} ${req.method} ${url.pathname}`, {
348
347
  ms,
349
348
  timeoutMs: VERYFRONT_SERVER_REQUEST_TIMEOUT_MS,
350
349
  });
351
- endSpan(spanInfo?.span, 504, error);
352
- return jsonErrorResponse(504, {
353
- error: "Gateway Timeout",
354
- message: `Server request timed out after ${VERYFRONT_SERVER_REQUEST_TIMEOUT_MS}ms`,
355
- });
350
+ lifecycle.end(UPSTREAM_TIMEOUT_STATUS, error);
351
+ return createUpstreamTimeoutResponse(VERYFRONT_SERVER_REQUEST_TIMEOUT_MS);
356
352
  }
357
353
  // Check if this is a retryable error and we have retries left
358
354
  if (isRetryableConnectionError(error) && attempt < maxRetries) {
@@ -379,26 +375,31 @@ function forwardToServer(req, url) {
379
375
  }
380
376
  // All retries exhausted or non-retryable error
381
377
  const ms = Math.round(performance.now() - startTime);
382
- const logLevel = getProxyFailureLogLevel(502, req.method, url.pathname);
383
- proxyLogger[logLevel](`502 ${req.method} ${url.pathname}`, { ms }, lastError);
384
- endSpan(spanInfo?.span, 502, lastError);
385
- return jsonErrorResponse(502, {
386
- error: "Proxy Error",
387
- message: lastError instanceof Error ? lastError.message : "Unknown error",
388
- });
378
+ const logLevel = getProxyFailureLogLevel(UPSTREAM_FAILURE_STATUS, req.method, url.pathname);
379
+ proxyLogger[logLevel](`${UPSTREAM_FAILURE_STATUS} ${req.method} ${url.pathname}`, { ms }, lastError);
380
+ lifecycle.end(UPSTREAM_FAILURE_STATUS, lastError);
381
+ return createUpstreamFailureResponse(lastError);
389
382
  });
390
383
  }
391
384
  catch (error) {
392
385
  const ms = Math.round(performance.now() - startTime);
393
386
  proxyLogger.error(`500 ${req.method} ${url.pathname}`, { ms }, error);
394
- endSpan(spanInfo?.span, 500, error);
387
+ lifecycle.end(500, error);
395
388
  return jsonErrorResponse(500, {
396
389
  error: "Internal Proxy Error",
397
390
  message: error instanceof Error ? error.message : "Unknown error",
398
391
  });
399
392
  }
400
393
  };
401
- return spanInfo?.context ? withContext(spanInfo.context, execute) : execute();
394
+ return runProxyRequestLifecycle({
395
+ req,
396
+ url,
397
+ startServerSpan,
398
+ endSpan,
399
+ extractContext,
400
+ withContext,
401
+ handle: execute,
402
+ });
402
403
  }
403
404
  /**
404
405
  * Handle stats endpoint for monitoring.
@@ -0,0 +1,41 @@
1
+ export interface ProxyAccessControlLogger {
2
+ debug: (msg: string, extra?: Record<string, unknown>) => void;
3
+ info: (msg: string, extra?: Record<string, unknown>) => void;
4
+ warn: (msg: string, extra?: Record<string, unknown>) => void;
5
+ error: (msg: string, error?: Error, extra?: Record<string, unknown>) => void;
6
+ }
7
+ export interface ProtectedProxyEnvironment {
8
+ name: string;
9
+ protected?: boolean;
10
+ }
11
+ export interface ProtectedProxyProjectUser {
12
+ id: string;
13
+ }
14
+ export interface ProxyAccessError {
15
+ status: number;
16
+ message: string;
17
+ redirectUrl?: string;
18
+ }
19
+ /**
20
+ * Reset the cached AuthProvider. Intended for tests that `register()` a mock
21
+ * after the handler module has been imported.
22
+ *
23
+ * @internal
24
+ */
25
+ export declare function __resetCachedAuthProviderForTests(): void;
26
+ export declare function extractUserIdFromToken(token: string, apiBaseUrl: string, log?: ProxyAccessControlLogger): Promise<string | undefined>;
27
+ export declare function buildProxyAuthRedirectUrl(url: URL): string;
28
+ export declare function isProjectMember(users: ProtectedProxyProjectUser[] | undefined, userId: string | undefined): boolean;
29
+ export declare function checkProtectedProxyAccess(input: {
30
+ req: Request;
31
+ url: URL;
32
+ matchingEnv: ProtectedProxyEnvironment | undefined;
33
+ userToken: string | undefined;
34
+ users: ProtectedProxyProjectUser[] | undefined;
35
+ apiBaseUrl: string;
36
+ logger?: ProxyAccessControlLogger;
37
+ logContext?: Record<string, unknown>;
38
+ isSignedInternalControlPlaneRequest: boolean;
39
+ extractUserIdFromToken?: (token: string, apiBaseUrl: string, log?: ProxyAccessControlLogger) => Promise<string | undefined>;
40
+ }): Promise<ProxyAccessError | null>;
41
+ //# sourceMappingURL=proxy-access-control.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy-access-control.d.ts","sourceRoot":"","sources":["../../../src/src/proxy/proxy-access-control.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAC9D,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAC7D,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAC7D,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;CAC9E;AAED,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AA2BD;;;;;GAKG;AACH,wBAAgB,iCAAiC,IAAI,IAAI,CAExD;AAiBD,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,EAClB,GAAG,CAAC,EAAE,wBAAwB,GAC7B,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAoD7B;AAED,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAa1D;AAED,wBAAgB,eAAe,CAC7B,KAAK,EAAE,yBAAyB,EAAE,GAAG,SAAS,EAC9C,MAAM,EAAE,MAAM,GAAG,SAAS,GACzB,OAAO,CAGT;AAED,wBAAsB,yBAAyB,CAAC,KAAK,EAAE;IACrD,GAAG,EAAE,OAAO,CAAC;IACb,GAAG,EAAE,GAAG,CAAC;IACT,WAAW,EAAE,yBAAyB,GAAG,SAAS,CAAC;IACnD,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,KAAK,EAAE,yBAAyB,EAAE,GAAG,SAAS,CAAC;IAC/C,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,wBAAwB,CAAC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,mCAAmC,EAAE,OAAO,CAAC;IAC7C,sBAAsB,CAAC,EAAE,CACvB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,EAClB,GAAG,CAAC,EAAE,wBAAwB,KAC3B,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;CAClC,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CA4DnC"}
@@ -0,0 +1,151 @@
1
+ import { getEnv } from "../platform/compat/process.js";
2
+ import { resolve as resolveContract } from "../extensions/contracts.js";
3
+ /**
4
+ * Cache the resolved AuthProvider at module scope so the proxy does not pay
5
+ * the registry lookup on every request. The cache is cleared implicitly when
6
+ * `ExtensionLoader.teardownAll()` clears the registry. The next call re-resolves
7
+ * or surfaces the install hint if the extension was removed.
8
+ */
9
+ let cachedAuthProvider;
10
+ function getAuthProvider() {
11
+ if (cachedAuthProvider)
12
+ return cachedAuthProvider;
13
+ try {
14
+ cachedAuthProvider = resolveContract("AuthProvider");
15
+ return cachedAuthProvider;
16
+ }
17
+ catch (err) {
18
+ const base = err instanceof Error ? err.message : String(err);
19
+ throw new Error(`${base}\nTo enable JWT verification in the proxy, install ext-auth-jwt ` +
20
+ `(scaffold with \`deno task cli extension init ext-auth-jwt\` or add the ` +
21
+ `npm package @veryfront/ext-auth-jwt).`, { cause: err });
22
+ }
23
+ }
24
+ /**
25
+ * Reset the cached AuthProvider. Intended for tests that `register()` a mock
26
+ * after the handler module has been imported.
27
+ *
28
+ * @internal
29
+ */
30
+ export function __resetCachedAuthProviderForTests() {
31
+ cachedAuthProvider = undefined;
32
+ }
33
+ function resolveApiJwksUrl(apiBaseUrl, logger) {
34
+ try {
35
+ const normalizedBaseUrl = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
36
+ return new URL(".well-known/jwks.json", normalizedBaseUrl).toString();
37
+ }
38
+ catch (error) {
39
+ logger?.error("Invalid API base URL for JWKS lookup", error, {
40
+ apiBaseUrl,
41
+ });
42
+ return undefined;
43
+ }
44
+ }
45
+ export async function extractUserIdFromToken(token, apiBaseUrl, log) {
46
+ const auth = getAuthProvider();
47
+ const header = auth.decode(token);
48
+ if (!header) {
49
+ log?.debug("Failed to decode JWT header");
50
+ return undefined;
51
+ }
52
+ const algorithm = header.alg;
53
+ if (algorithm === "RS256") {
54
+ const jwksUrl = resolveApiJwksUrl(apiBaseUrl, log);
55
+ if (!jwksUrl)
56
+ return undefined;
57
+ try {
58
+ const payload = await auth.verifyWithJwks(token, jwksUrl, {
59
+ algorithms: ["RS256"],
60
+ });
61
+ return payload.userId;
62
+ }
63
+ catch (error) {
64
+ log?.debug("RS256 JWT verification failed", {
65
+ error: error instanceof Error ? error.message : String(error),
66
+ });
67
+ return undefined;
68
+ }
69
+ }
70
+ if (algorithm !== "HS256") {
71
+ log?.debug("Unsupported JWT algorithm", { algorithm: algorithm ?? null });
72
+ return undefined;
73
+ }
74
+ const jwtSecret = getEnv("JWT_SECRET");
75
+ if (!jwtSecret) {
76
+ log?.warn("JWT_SECRET not configured - cannot verify user token");
77
+ return undefined;
78
+ }
79
+ try {
80
+ // ext-auth-jwt reads JWT_SECRET from the environment when no `secret` was
81
+ // passed to the extension factory; the explicit env check above is kept
82
+ // so callers can warn once before attempting verification.
83
+ const payload = await auth.verify(token, { algorithms: ["HS256"] });
84
+ return payload.userId;
85
+ }
86
+ catch (error) {
87
+ log?.debug("JWT verification failed", {
88
+ error: error instanceof Error ? error.message : String(error),
89
+ });
90
+ return undefined;
91
+ }
92
+ }
93
+ export function buildProxyAuthRedirectUrl(url) {
94
+ const safePath = url.pathname.replace(/^\/\/+/, "/");
95
+ let returnPath = safePath + url.search;
96
+ if (!returnPath.startsWith("/") || returnPath.includes("://")) {
97
+ returnPath = "/";
98
+ }
99
+ const isHostedProductionDeployment = url.hostname.endsWith(".production.veryfront.org") ||
100
+ url.hostname.endsWith(".production.veryfront.com");
101
+ const returnTarget = isHostedProductionDeployment ? url.toString() : returnPath;
102
+ return `https://veryfront.com/sign-in?from=${encodeURIComponent(returnTarget)}`;
103
+ }
104
+ export function isProjectMember(users, userId) {
105
+ if (!users || !userId)
106
+ return false;
107
+ return users.some((u) => u.id === userId);
108
+ }
109
+ export async function checkProtectedProxyAccess(input) {
110
+ const { apiBaseUrl, logger, matchingEnv, url, userToken, users, } = input;
111
+ const logContext = input.logContext ?? {};
112
+ if (!matchingEnv?.protected)
113
+ return null;
114
+ if (input.isSignedInternalControlPlaneRequest) {
115
+ logger?.debug("Allowing signed internal control-plane request through protected environment", {
116
+ ...logContext,
117
+ environmentName: matchingEnv.name,
118
+ pathname: url.pathname,
119
+ });
120
+ return null;
121
+ }
122
+ if (!userToken) {
123
+ const redirectUrl = buildProxyAuthRedirectUrl(url);
124
+ logger?.info("Protected environment requires authentication", {
125
+ ...logContext,
126
+ environmentName: matchingEnv.name,
127
+ redirectUrl,
128
+ });
129
+ return { status: 302, message: "Authentication required", redirectUrl };
130
+ }
131
+ const resolveUserId = input.extractUserIdFromToken ?? extractUserIdFromToken;
132
+ const userId = await resolveUserId(userToken, apiBaseUrl, logger);
133
+ if (!userId) {
134
+ const redirectUrl = buildProxyAuthRedirectUrl(url);
135
+ logger?.info("Could not extract userId from token", {
136
+ ...logContext,
137
+ environmentName: matchingEnv.name,
138
+ redirectUrl,
139
+ });
140
+ return { status: 302, message: "Authentication required", redirectUrl };
141
+ }
142
+ if (!isProjectMember(users, userId)) {
143
+ logger?.info("User is not a member of the project", {
144
+ ...logContext,
145
+ environmentName: matchingEnv.name,
146
+ userId,
147
+ });
148
+ return { status: 403, message: "Access denied" };
149
+ }
150
+ return null;
151
+ }
@@ -0,0 +1,20 @@
1
+ import type { ParsedDomain } from "../server/utils/domain-parser.js";
2
+ import type { TokenScope } from "./token-manager.js";
3
+ import type { ProxyContext } from "./handler.js";
4
+ export type ProxyErrorSlug = "authentication-failed" | "project-not-found" | "release-not-found";
5
+ export interface ProxyErrorContextBase {
6
+ scope: TokenScope;
7
+ host: string;
8
+ parsedDomain: ParsedDomain;
9
+ }
10
+ export interface ProxyErrorContextOptions {
11
+ status: number;
12
+ message: string;
13
+ token?: string;
14
+ redirectUrl?: string;
15
+ slug?: ProxyErrorSlug;
16
+ }
17
+ export declare function createProxyErrorContext(base: ProxyErrorContextBase, options: ProxyErrorContextOptions): ProxyContext;
18
+ export declare function createProjectNotFoundProxyContext(base: ProxyErrorContextBase, message: "Preview project not found" | "Project not found", token?: string): ProxyContext;
19
+ export declare function createReleaseNotFoundProxyContext(base: ProxyErrorContextBase, token?: string): ProxyContext;
20
+ //# sourceMappingURL=proxy-error-context.d.ts.map