@betterportal/framework 10.1.42 → 10.1.44
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/lib/adapters/h3.d.ts.map +1 -1
- package/lib/adapters/h3.js +165 -112
- package/lib/adapters/h3.js.map +1 -1
- package/lib/cli/bp.js +2 -2
- package/lib/cli/bp.js.map +1 -1
- package/lib/codegen/cli.js +11 -5
- package/lib/codegen/cli.js.map +1 -1
- package/lib/codegen/emitter.d.ts.map +1 -1
- package/lib/codegen/emitter.js +78 -55
- package/lib/codegen/emitter.js.map +1 -1
- package/lib/codegen/scanner.d.ts +14 -7
- package/lib/codegen/scanner.d.ts.map +1 -1
- package/lib/codegen/scanner.js +53 -35
- package/lib/codegen/scanner.js.map +1 -1
- package/lib/codegen/validate.d.ts.map +1 -1
- package/lib/codegen/validate.js +31 -14
- package/lib/codegen/validate.js.map +1 -1
- package/lib/contracts/binding.d.ts +3 -1
- package/lib/contracts/binding.d.ts.map +1 -1
- package/lib/contracts/binding.js +1 -1
- package/lib/contracts/binding.js.map +1 -1
- package/lib/contracts/controlPlane.d.ts +3 -3
- package/lib/contracts/controlPlane.d.ts.map +1 -1
- package/lib/contracts/m2m.d.ts +6 -0
- package/lib/contracts/m2m.d.ts.map +1 -1
- package/lib/contracts/m2m.js +5 -1
- package/lib/contracts/m2m.js.map +1 -1
- package/lib/contracts/manifest.d.ts +58 -8
- package/lib/contracts/manifest.d.ts.map +1 -1
- package/lib/contracts/manifest.js +16 -3
- package/lib/contracts/manifest.js.map +1 -1
- package/lib/contracts/platformConfig.d.ts +129 -4
- package/lib/contracts/platformConfig.d.ts.map +1 -1
- package/lib/contracts/platformConfig.js +30 -2
- package/lib/contracts/platformConfig.js.map +1 -1
- package/lib/contracts/registry.d.ts +59 -19
- package/lib/contracts/registry.d.ts.map +1 -1
- package/lib/contracts/route.d.ts +14 -5
- package/lib/contracts/route.d.ts.map +1 -1
- package/lib/contracts/route.js +2 -0
- package/lib/contracts/route.js.map +1 -1
- package/lib/contracts/streaming.d.ts +2 -2
- package/lib/contracts/view.d.ts +6 -4
- package/lib/contracts/view.d.ts.map +1 -1
- package/lib/contracts/view.js +2 -2
- package/lib/contracts/view.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/runtime/auth/serviceToken.d.ts +3 -1
- package/lib/runtime/auth/serviceToken.d.ts.map +1 -1
- package/lib/runtime/auth/serviceToken.js +3 -0
- package/lib/runtime/auth/serviceToken.js.map +1 -1
- package/lib/runtime/configProvider.d.ts +5 -0
- package/lib/runtime/configProvider.d.ts.map +1 -1
- package/lib/runtime/configProvider.js +30 -1
- package/lib/runtime/configProvider.js.map +1 -1
- package/lib/runtime/configStore.d.ts +1 -1
- package/lib/runtime/configStore.d.ts.map +1 -1
- package/lib/runtime/configStore.js +53 -21
- package/lib/runtime/configStore.js.map +1 -1
- package/lib/runtime/element.d.ts +13 -0
- package/lib/runtime/element.d.ts.map +1 -0
- package/lib/runtime/element.js +39 -0
- package/lib/runtime/element.js.map +1 -0
- package/lib/runtime/llms.js +1 -1
- package/lib/runtime/media.d.ts +0 -1
- package/lib/runtime/media.d.ts.map +1 -1
- package/lib/runtime/media.js +0 -1
- package/lib/runtime/media.js.map +1 -1
- package/lib/runtime/registry.d.ts +8 -7
- package/lib/runtime/registry.d.ts.map +1 -1
- package/lib/runtime/registry.js +55 -34
- package/lib/runtime/registry.js.map +1 -1
- package/lib/runtime/statusViews.d.ts +3 -3
- package/lib/runtime/statusViews.d.ts.map +1 -1
- package/lib/runtime/statusViews.js +5 -5
- package/lib/runtime/statusViews.js.map +1 -1
- package/lib/runtime/view.d.ts +1 -1
- package/lib/runtime/view.d.ts.map +1 -1
- package/lib/runtime/view.js +7 -13
- package/lib/runtime/view.js.map +1 -1
- package/package.json +1 -1
package/lib/adapters/h3.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"h3.d.ts","sourceRoot":"","sources":["../../src/adapters/h3.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC/E,OAAO,KAAK,EAAE,yBAAyB,EAAE,uBAAuB,EAAE,MAAM,+BAA+B,CAAC;AACxG,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"h3.d.ts","sourceRoot":"","sources":["../../src/adapters/h3.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC/E,OAAO,KAAK,EAAE,yBAAyB,EAAE,uBAAuB,EAAE,MAAM,+BAA+B,CAAC;AACxG,OAAO,KAAK,EAAsB,oBAAoB,EAAE,eAAe,EAAwD,MAAM,0BAA0B,CAAC;AAChK,OAAO,KAAK,EAGV,WAAW,EAIX,mBAAmB,EACnB,oBAAoB,EAIrB,MAAM,uBAAuB,CAAC;AAI/B,OAAO,KAAK,EAAE,aAAa,EAAa,MAAM,sBAAsB,CAAC;AAErE,OAAO,EAKL,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACvB,MAAM,kBAAkB,CAAC;AA0B1B,MAAM,WAAW,4BAA4B;IAC3C,0BAA0B,CAAC,EAAE,CAC3B,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,uBAAuB,KAChC,yBAAyB,CAAC;IAC/B,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yGAAyG;IACzG,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,iBAAiB,EAAE,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC,GAAG,aAAa,GAAG,SAAS,CAAC;IACnI;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,sBAAsB,EAAE,mBAAmB,CAAC,GAAG,OAAO,sBAAsB,EAAE,mBAAmB,CAAC;IAC1K,qEAAqE;IACrE,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,iBAAiB,EAAE,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAC,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;CAC7I;AAOD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,WAAW,CAAC;IAChC,QAAQ,CAAC,eAAe,CAAC,EAAE,oBAAoB,CAAC;IAChD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC7D,QAAQ,CAAC,YAAY,CAAC,EAAE;QACtB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;CACH;AA0GD,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAKvE;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,aAAa,CAAC,eAAe,CAAC,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAExG;AA2CD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,oBAAoB,EAC9B,GAAG,EAAE,iBAAiB,EACtB,OAAO,GAAE,4BAAiC,GACzC,IAAI,CAmGN;AA8wCD;;GAEG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,iBAAiB,EACtB,QAAQ,EAAE,cAAc,EACxB,QAAQ,EAAE,cAAc,EACxB,OAAO,GAAE;IACP,MAAM,CAAC,EAAE,MAAM,QAAQ,GAAG,SAAS,CAAC;CAChC,GACL,IAAI,CAyCN"}
|
package/lib/adapters/h3.js
CHANGED
|
@@ -145,8 +145,11 @@ function chromeContentTypeParams(chrome) {
|
|
|
145
145
|
}
|
|
146
146
|
return params.length ? `; ${params.join("; ")}` : "";
|
|
147
147
|
}
|
|
148
|
-
function htmlContentType(
|
|
149
|
-
return `text/html;
|
|
148
|
+
function htmlContentType(mode, chrome) {
|
|
149
|
+
return `text/html; mode=${mode}${chromeContentTypeParams(chrome)}`;
|
|
150
|
+
}
|
|
151
|
+
function rendererFromEvent(event) {
|
|
152
|
+
return event.__bpApp?.shell?.renderer;
|
|
150
153
|
}
|
|
151
154
|
// -- Router registration ----------------------------------------------
|
|
152
155
|
/**
|
|
@@ -162,7 +165,7 @@ export function createH3Router(registry, app, options = {}) {
|
|
|
162
165
|
for (const method of route.methods) {
|
|
163
166
|
const register = methodRegistrar(app, method);
|
|
164
167
|
register(route.path, async (event) => {
|
|
165
|
-
return withRequestObservability(event, route, method, options, (obs) => handleRouteRequest(registry.routes, route, method, event, obs, options));
|
|
168
|
+
return withRequestObservability(event, route, method, options, (obs) => handleRouteRequest(registry.routes, registry.dependencies ?? {}, route, method, event, obs, options));
|
|
166
169
|
});
|
|
167
170
|
}
|
|
168
171
|
// Streaming routes (createStreamHandler) expose their frame stream at
|
|
@@ -198,40 +201,17 @@ export function createH3Router(registry, app, options = {}) {
|
|
|
198
201
|
}
|
|
199
202
|
// Generator path: framework drives the stream.
|
|
200
203
|
if (typeof result === "object" && result !== null && Symbol.asyncIterator in result) {
|
|
201
|
-
//
|
|
202
|
-
// disambiguated - with multiple themes registered, picking the first
|
|
203
|
-
// match would silently render another theme's fragment. Prefer the
|
|
204
|
-
// theme resolved from request context (__bpThemeId), then an explicit
|
|
205
|
-
// `?_theme=` pin; only fall back to a cross-theme scan when exactly one
|
|
206
|
-
// theme provides the fragment.
|
|
204
|
+
// Renderer identity comes only from the server-resolved app shell.
|
|
207
205
|
const fragmentKey = rawQuery._f ?? undefined;
|
|
208
206
|
let sseRender;
|
|
209
207
|
if (fragmentKey) {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const resolved = resolveRenderer(route, themeId, "fragment", "GET", undefined, fragmentKey);
|
|
208
|
+
const renderer = rendererFromEvent(event);
|
|
209
|
+
if (renderer) {
|
|
210
|
+
const resolved = resolveRenderer(route, renderer, "fragment", "GET", undefined, fragmentKey);
|
|
214
211
|
if (resolved?.renderer.sseRender) {
|
|
215
212
|
sseRender = resolved.renderer.sseRender;
|
|
216
213
|
}
|
|
217
214
|
}
|
|
218
|
-
else {
|
|
219
|
-
// No theme context. Only render if the match is unambiguous across
|
|
220
|
-
// themes; otherwise leave it to the JSON passthrough rather than guess.
|
|
221
|
-
const matches = [];
|
|
222
|
-
for (const candidateThemeId of Object.keys(route.themeRenderers)) {
|
|
223
|
-
const resolved = resolveRenderer(route, candidateThemeId, "fragment", "GET", undefined, fragmentKey);
|
|
224
|
-
if (resolved?.renderer.sseRender) {
|
|
225
|
-
matches.push(resolved.renderer.sseRender);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
if (matches.length === 1) {
|
|
229
|
-
sseRender = matches[0];
|
|
230
|
-
}
|
|
231
|
-
else if (matches.length > 1) {
|
|
232
|
-
obs?.logger.warn("BP SSE: ambiguous fragment '{fragmentKey}' across {count} themes and no theme context; sending raw ticks", { fragmentKey, count: matches.length });
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
215
|
}
|
|
236
216
|
const stream = createEventStream(event);
|
|
237
217
|
const iterable = result;
|
|
@@ -322,7 +302,7 @@ function routePathsMatch(left, right) {
|
|
|
322
302
|
return false;
|
|
323
303
|
return a.every((segment, index) => {
|
|
324
304
|
const other = b[index];
|
|
325
|
-
return segment === other || segment
|
|
305
|
+
return segment === other || pathParamName(segment) !== null || pathParamName(other) !== null;
|
|
326
306
|
});
|
|
327
307
|
}
|
|
328
308
|
function routeMountServicePath(routeMount) {
|
|
@@ -431,7 +411,45 @@ function createRouteUiAttributes(url, options = {}, form = false) {
|
|
|
431
411
|
attrs["hx-push-url"] = String(options.push);
|
|
432
412
|
return attrs;
|
|
433
413
|
}
|
|
434
|
-
function
|
|
414
|
+
function createElementResolver(extraContext, dependencyAliases) {
|
|
415
|
+
return (reference) => {
|
|
416
|
+
if (!reference.fragment.trim())
|
|
417
|
+
return { unavailable: "fragment_required" };
|
|
418
|
+
if (reference.service === "shell") {
|
|
419
|
+
const shellServiceId = extraContext.app.shell?.serviceId;
|
|
420
|
+
const origin = shellServiceId ? serviceOrigin(extraContext, shellServiceId) : null;
|
|
421
|
+
if (!shellServiceId || !origin)
|
|
422
|
+
return { unavailable: "shell_unavailable" };
|
|
423
|
+
const path = `/.well-known/bp/shell/fragment/${encodeURIComponent(reference.fragment)}`;
|
|
424
|
+
return { serviceId: shellServiceId, url: appendQuery(path, reference.args?.query, origin) };
|
|
425
|
+
}
|
|
426
|
+
if (!reference.path?.startsWith("/"))
|
|
427
|
+
return { unavailable: "service_path_required" };
|
|
428
|
+
const pluginId = dependencyAliases[reference.service] ?? reference.service;
|
|
429
|
+
const serviceIds = new Set(extraContext.tenant.services
|
|
430
|
+
.filter((service) => service.enabled && (service.serviceId === pluginId || service.id === pluginId))
|
|
431
|
+
.map((service) => service.id));
|
|
432
|
+
const mounts = extraContext.app.routes.filter((mount) => {
|
|
433
|
+
const servicePath = routeMountServicePath(mount);
|
|
434
|
+
return mount.enabled !== false
|
|
435
|
+
&& serviceIds.has(mount.serviceId)
|
|
436
|
+
&& Boolean(servicePath)
|
|
437
|
+
&& routePathsMatch(servicePath, reference.path);
|
|
438
|
+
});
|
|
439
|
+
if (mounts.length !== 1)
|
|
440
|
+
return { unavailable: mounts.length ? "ambiguous_provider" : "service_unavailable" };
|
|
441
|
+
const mount = mounts[0];
|
|
442
|
+
const origin = serviceOrigin(extraContext, mount.serviceId);
|
|
443
|
+
if (!origin)
|
|
444
|
+
return { unavailable: "service_unavailable" };
|
|
445
|
+
const servicePath = fillAppPath(routeMountServicePath(mount), reference.args?.params);
|
|
446
|
+
return {
|
|
447
|
+
serviceId: mount.serviceId,
|
|
448
|
+
url: renderUrl(servicePath, { absolute: true, origin, query: reference.args?.query, fragment: reference.fragment })
|
|
449
|
+
};
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
function createViewRenderContext(route, ctx, dependencyAliases, renderer, mode, kind, key, status) {
|
|
435
453
|
const current = (options = {}) => renderUrl(ctx.path, options);
|
|
436
454
|
const path = (value, options = {}) => renderUrl(value, options);
|
|
437
455
|
const routeUrl = (viewId, options) => ctx.routeUrl?.(viewId, options) ?? null;
|
|
@@ -440,14 +458,15 @@ function createViewRenderContext(route, ctx, theme, mode, kind, key, status) {
|
|
|
440
458
|
const form = (url, options) => createRouteUiAttributes(url, options, true);
|
|
441
459
|
return {
|
|
442
460
|
request: { method: ctx.method, path: ctx.path, params: ctx.params, query: ctx.query },
|
|
443
|
-
route: { viewId: route.viewId, path: route.path,
|
|
461
|
+
route: { viewId: route.viewId, path: route.path, renderer, mode, kind, key, status },
|
|
444
462
|
url: { current, path, route: routeUrl, uiRoute },
|
|
445
463
|
routeUi: {
|
|
446
464
|
link: ui,
|
|
447
465
|
current: (options = {}) => ui(current(options), options),
|
|
448
466
|
fragment: ui,
|
|
449
467
|
form
|
|
450
|
-
}
|
|
468
|
+
},
|
|
469
|
+
element: createElementResolver(ctx, dependencyAliases)
|
|
451
470
|
};
|
|
452
471
|
}
|
|
453
472
|
function createServiceRouteUrlBuilder(routes, extraContext, currentServiceId) {
|
|
@@ -570,7 +589,7 @@ async function withSpan(obs, name, attributes, handler) {
|
|
|
570
589
|
throw error;
|
|
571
590
|
}
|
|
572
591
|
}
|
|
573
|
-
async function handleRouteRequest(registryRoutes, route, method, event, obs, routerOptions = {}) {
|
|
592
|
+
async function handleRouteRequest(registryRoutes, dependencyAliases, route, method, event, obs, routerOptions = {}) {
|
|
574
593
|
const methodRoute = route.methodRoutes?.[method];
|
|
575
594
|
const handler = methodRoute?.handler ?? route.handlers[method];
|
|
576
595
|
const schemas = methodRoute?.schemas ?? route.schemas;
|
|
@@ -673,6 +692,7 @@ async function handleRouteRequest(registryRoutes, route, method, event, obs, rou
|
|
|
673
692
|
rawEvent: event,
|
|
674
693
|
user: authResult.user,
|
|
675
694
|
serviceCaller: authResult.serviceCaller,
|
|
695
|
+
callerMode: authResult.callerMode,
|
|
676
696
|
...extraContext,
|
|
677
697
|
bpHeaders,
|
|
678
698
|
responseHeaders: event.res.headers,
|
|
@@ -726,7 +746,7 @@ async function handleRouteRequest(registryRoutes, route, method, event, obs, rou
|
|
|
726
746
|
description: route.description,
|
|
727
747
|
path: route.path,
|
|
728
748
|
methods: [...route.methods],
|
|
729
|
-
auth: route.auth,
|
|
749
|
+
auth: { ...route.auth, callers: [...(route.auth.callers ?? ["user"])] },
|
|
730
750
|
cacheHints: route.cacheHints
|
|
731
751
|
}, 200, {
|
|
732
752
|
"content-type": "application/vnd.betterportal.metadata+json; charset=utf-8"
|
|
@@ -755,33 +775,31 @@ async function handleRouteRequest(registryRoutes, route, method, event, obs, rou
|
|
|
755
775
|
if (representation.kind === "json") {
|
|
756
776
|
return jsonResponse(data, handlerStatus);
|
|
757
777
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
if (!themeId) {
|
|
762
|
-
logNegotiationFailure(obs, route, method, "theme_not_resolved", {
|
|
778
|
+
const renderer = rendererFromEvent(event);
|
|
779
|
+
if (!renderer) {
|
|
780
|
+
logNegotiationFailure(obs, route, method, "renderer_not_resolved", {
|
|
763
781
|
"http.request.accept": acceptHeader ?? "",
|
|
764
782
|
"bp.representation.kind": representation.kind
|
|
765
783
|
});
|
|
766
|
-
return jsonResponse({ error: "
|
|
784
|
+
return jsonResponse({ error: "Renderer could not be resolved from the app shell" }, 406);
|
|
767
785
|
}
|
|
768
786
|
// Determine the renderer kind requested
|
|
769
787
|
const fragmentKey = url.searchParams.get("_f") ?? fragmentFromAcceptHeader(acceptHeader);
|
|
770
788
|
const componentId = url.searchParams.get("_c");
|
|
771
789
|
const requestedKind = fragmentKey ? "fragment" : componentId ? "component" : "page";
|
|
772
790
|
const requestedKey = fragmentKey ?? componentId ?? undefined;
|
|
773
|
-
const renderContext = createViewRenderContext(route, ctx,
|
|
791
|
+
const renderContext = createViewRenderContext(route, ctx, dependencyAliases, renderer, representation.mode ?? "page", requestedKind, requestedKey, handlerStatus);
|
|
774
792
|
// Status-specific renderer lookup (any non-undefined status code)
|
|
775
793
|
if (handlerStatus !== 200) {
|
|
776
|
-
const statusRenderer = resolveStatusRenderer(route,
|
|
794
|
+
const statusRenderer = resolveStatusRenderer(route, renderer, handlerStatus, requestedKind, requestedKey, method);
|
|
777
795
|
if (statusRenderer) {
|
|
778
796
|
const html = await withSpan(obs, "bp.view.render", {
|
|
779
797
|
"bp.route.view_id": route.viewId,
|
|
780
|
-
"bp.view.
|
|
798
|
+
"bp.view.renderer": renderer,
|
|
781
799
|
"bp.view.kind": requestedKind,
|
|
782
800
|
"bp.view.status": handlerStatus
|
|
783
801
|
}, () => statusRenderer.render(data, renderContext));
|
|
784
|
-
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), handlerStatus, htmlContentType(
|
|
802
|
+
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), handlerStatus, htmlContentType("status", route.chrome));
|
|
785
803
|
}
|
|
786
804
|
// No specific renderer found.
|
|
787
805
|
if (!shouldFallThroughToDefaultRenderer(handlerStatus)) {
|
|
@@ -792,67 +810,67 @@ async function handleRouteRequest(registryRoutes, route, method, event, obs, rou
|
|
|
792
810
|
}
|
|
793
811
|
// Fragment request via `_f` query param or Accept header
|
|
794
812
|
if (fragmentKey) {
|
|
795
|
-
const resolved = resolveRenderer(route,
|
|
813
|
+
const resolved = resolveRenderer(route, renderer, "fragment", method, undefined, fragmentKey);
|
|
796
814
|
if (!resolved) {
|
|
797
815
|
logNegotiationFailure(obs, route, method, "fragment_renderer_not_found", {
|
|
798
816
|
"http.request.accept": acceptHeader ?? "",
|
|
799
|
-
"bp.view.
|
|
817
|
+
"bp.view.renderer": renderer,
|
|
800
818
|
"bp.view.kind": "fragment",
|
|
801
819
|
"bp.view.key": fragmentKey
|
|
802
820
|
});
|
|
803
821
|
return jsonResponse({
|
|
804
|
-
error: `No fragment renderer found for fragment="${fragmentKey}" in
|
|
822
|
+
error: `No fragment renderer found for fragment="${fragmentKey}" in renderer "${renderer}"`
|
|
805
823
|
}, 406);
|
|
806
824
|
}
|
|
807
825
|
const html = await withSpan(obs, "bp.view.render", {
|
|
808
826
|
"bp.route.view_id": route.viewId,
|
|
809
|
-
"bp.view.
|
|
827
|
+
"bp.view.renderer": renderer,
|
|
810
828
|
"bp.view.kind": "fragment",
|
|
811
829
|
"bp.view.key": fragmentKey
|
|
812
830
|
}, () => resolved.renderer.render(data, renderContext));
|
|
813
|
-
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), handlerStatus, htmlContentType(
|
|
831
|
+
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), handlerStatus, htmlContentType("fragment", route.chrome));
|
|
814
832
|
}
|
|
815
833
|
// Component request via `_c` query param
|
|
816
834
|
if (componentId) {
|
|
817
|
-
const resolved = resolveRenderer(route,
|
|
835
|
+
const resolved = resolveRenderer(route, renderer, "component", method, componentId);
|
|
818
836
|
if (!resolved) {
|
|
819
837
|
logNegotiationFailure(obs, route, method, "component_renderer_not_found", {
|
|
820
838
|
"http.request.accept": acceptHeader ?? "",
|
|
821
|
-
"bp.view.
|
|
839
|
+
"bp.view.renderer": renderer,
|
|
822
840
|
"bp.view.kind": "component",
|
|
823
841
|
"bp.view.key": componentId
|
|
824
842
|
});
|
|
825
843
|
return jsonResponse({
|
|
826
|
-
error: `No component renderer found for _c="${componentId}" in
|
|
844
|
+
error: `No component renderer found for _c="${componentId}" in renderer "${renderer}"`
|
|
827
845
|
}, 406);
|
|
828
846
|
}
|
|
829
847
|
const html = await withSpan(obs, "bp.view.render", {
|
|
830
848
|
"bp.route.view_id": route.viewId,
|
|
831
|
-
"bp.view.
|
|
849
|
+
"bp.view.renderer": renderer,
|
|
832
850
|
"bp.view.kind": "component",
|
|
833
851
|
"bp.view.key": componentId
|
|
834
852
|
}, () => resolved.renderer.render(data, renderContext));
|
|
835
|
-
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), handlerStatus, htmlContentType(
|
|
853
|
+
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), handlerStatus, htmlContentType("fragment", route.chrome));
|
|
836
854
|
}
|
|
837
855
|
// Page request - only page renderers allowed
|
|
838
|
-
const resolved = resolveRenderer(route,
|
|
856
|
+
const resolved = resolveRenderer(route, renderer, "page", method);
|
|
839
857
|
if (!resolved) {
|
|
840
858
|
logNegotiationFailure(obs, route, method, "page_renderer_not_found", {
|
|
841
859
|
"http.request.accept": acceptHeader ?? "",
|
|
842
|
-
"bp.view.
|
|
860
|
+
"bp.view.renderer": renderer,
|
|
843
861
|
"bp.view.kind": "page"
|
|
844
862
|
});
|
|
845
863
|
return jsonResponse({
|
|
846
|
-
error: `No page renderer found for
|
|
864
|
+
error: `No page renderer found for renderer "${renderer}"`
|
|
847
865
|
}, 406);
|
|
848
866
|
}
|
|
849
867
|
const html = await withSpan(obs, "bp.view.render", {
|
|
850
868
|
"bp.route.view_id": route.viewId,
|
|
851
|
-
"bp.view.
|
|
869
|
+
"bp.view.renderer": renderer,
|
|
852
870
|
"bp.view.kind": "page"
|
|
853
871
|
}, () => resolved.renderer.render(data, renderContext));
|
|
854
872
|
const mode = representation.mode ?? "page";
|
|
855
|
-
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), handlerStatus, htmlContentType(
|
|
873
|
+
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), handlerStatus, htmlContentType(mode, route.chrome));
|
|
856
874
|
}
|
|
857
875
|
/**
|
|
858
876
|
* Handle representations that stream, returning null for buffered ones so the
|
|
@@ -869,16 +887,15 @@ async function handleStreamRepresentation(route, handler, ctx, event, url, metho
|
|
|
869
887
|
// Fragment/component selectors render over the buffered data set.
|
|
870
888
|
if (url.searchParams.get("_f") || url.searchParams.get("_c"))
|
|
871
889
|
return null;
|
|
872
|
-
const
|
|
873
|
-
|
|
874
|
-
if (!themeId)
|
|
890
|
+
const renderer = rendererFromEvent(event);
|
|
891
|
+
if (!renderer)
|
|
875
892
|
return null;
|
|
876
|
-
const streamSet = route.
|
|
893
|
+
const streamSet = route.renderers[renderer]?.stream;
|
|
877
894
|
if (!streamSet)
|
|
878
895
|
return null;
|
|
879
896
|
// Full-page request with a page renderer available -> buffered render of the
|
|
880
897
|
// complete data set (crawlers, no-SSE clients). Fragment swaps stream.
|
|
881
|
-
if (representation.mode === "page" && resolveRenderer(route,
|
|
898
|
+
if (representation.mode === "page" && resolveRenderer(route, renderer, "page", method)) {
|
|
882
899
|
return null;
|
|
883
900
|
}
|
|
884
901
|
const shellCtx = {
|
|
@@ -888,10 +905,10 @@ async function handleStreamRepresentation(route, handler, ctx, event, url, metho
|
|
|
888
905
|
};
|
|
889
906
|
const html = await withSpan(obs, "bp.view.render", {
|
|
890
907
|
"bp.route.view_id": route.viewId,
|
|
891
|
-
"bp.view.
|
|
908
|
+
"bp.view.renderer": renderer,
|
|
892
909
|
"bp.view.kind": "stream-shell"
|
|
893
910
|
}, () => streamSet.renderShell(shellCtx));
|
|
894
|
-
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), 200, htmlContentType(
|
|
911
|
+
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), 200, htmlContentType("fragment", route.chrome));
|
|
895
912
|
}
|
|
896
913
|
/**
|
|
897
914
|
* SSE delivery of the frame stream at `{path}/__sse`. With a theme context and
|
|
@@ -926,6 +943,7 @@ async function handleStreamSse(registryRoutes, route, handler, event, obs, route
|
|
|
926
943
|
rawEvent: event,
|
|
927
944
|
user: authResult.user,
|
|
928
945
|
serviceCaller: authResult.serviceCaller,
|
|
946
|
+
callerMode: authResult.callerMode,
|
|
929
947
|
...extraContext,
|
|
930
948
|
serviceId: routerOptions.serviceId,
|
|
931
949
|
routeUrl: createServiceRouteUrlBuilder(registryRoutes, extraContext, routerOptions.serviceId),
|
|
@@ -934,9 +952,8 @@ async function handleStreamSse(registryRoutes, route, handler, event, obs, route
|
|
|
934
952
|
file: fileResponseHelper,
|
|
935
953
|
...(obs ? { obs } : {})
|
|
936
954
|
};
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
const streamSet = themeId ? route.themeRenderers[themeId]?.stream : undefined;
|
|
955
|
+
const renderer = rendererFromEvent(event);
|
|
956
|
+
const streamSet = renderer ? route.renderers[renderer]?.stream : undefined;
|
|
940
957
|
const stream = createEventStream(event);
|
|
941
958
|
(async () => {
|
|
942
959
|
try {
|
|
@@ -998,45 +1015,87 @@ async function loadAuthContext(event, route, routerOptions, obs) {
|
|
|
998
1015
|
*/
|
|
999
1016
|
async function resolveRequestAuth(apiAuth, event, authContext, route, method, obs) {
|
|
1000
1017
|
const required = apiAuth.required;
|
|
1001
|
-
const
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
|
|
1018
|
+
const primaryHeader = event.req.headers.get("authorization");
|
|
1019
|
+
const secondaryHeader = event.req.headers.get("x-bp-service-authorization");
|
|
1020
|
+
const primary = primaryHeader?.startsWith("Bearer ") ? primaryHeader.slice(7) : null;
|
|
1021
|
+
const secondary = secondaryHeader?.startsWith("Bearer ") ? secondaryHeader.slice(7) : null;
|
|
1022
|
+
const callerMode = secondaryHeader !== null
|
|
1023
|
+
? "delegated"
|
|
1024
|
+
: primary && isServiceToken(primary)
|
|
1025
|
+
? "service"
|
|
1026
|
+
: primary
|
|
1027
|
+
? "user"
|
|
1028
|
+
: undefined;
|
|
1029
|
+
if (!primary || !callerMode) {
|
|
1005
1030
|
if (required)
|
|
1006
1031
|
return { status: 401, error: "Authentication required" };
|
|
1007
1032
|
return { status: 200 };
|
|
1008
1033
|
}
|
|
1034
|
+
const allowedCallers = apiAuth.callers ?? ["user"];
|
|
1035
|
+
if (!allowedCallers.includes(callerMode)) {
|
|
1036
|
+
if (required)
|
|
1037
|
+
return { status: 403, error: `${callerMode} callers are not allowed` };
|
|
1038
|
+
return { status: 200 };
|
|
1039
|
+
}
|
|
1009
1040
|
if (!authContext) {
|
|
1010
1041
|
if (required)
|
|
1011
1042
|
return { status: 503, error: "Auth context unavailable" };
|
|
1012
1043
|
return { status: 200 };
|
|
1013
1044
|
}
|
|
1014
|
-
if (
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1045
|
+
if (callerMode === "user") {
|
|
1046
|
+
return resolveUserRequestAuth(primary, apiAuth, authContext, obs);
|
|
1047
|
+
}
|
|
1048
|
+
const sourceServiceId = event.req.headers.get("x-bp-service-id");
|
|
1049
|
+
const tenantId = event.req.headers.get("x-bp-tenant-id");
|
|
1050
|
+
const appId = event.req.headers.get("x-bp-app-id");
|
|
1051
|
+
if (!sourceServiceId || !tenantId || !appId) {
|
|
1052
|
+
return { status: 401, error: "Service, tenant, and app headers are required for S2S calls" };
|
|
1053
|
+
}
|
|
1054
|
+
if (tenantId !== authContext.tenantId || appId !== authContext.appId) {
|
|
1055
|
+
return { status: 401, error: "S2S headers do not match the resolved tenant/app" };
|
|
1056
|
+
}
|
|
1057
|
+
if (!authContext.serviceVerifier) {
|
|
1058
|
+
if (required)
|
|
1059
|
+
return { status: 503, error: "Service auth context unavailable" };
|
|
1060
|
+
return { status: 200 };
|
|
1061
|
+
}
|
|
1062
|
+
const verifyService = async (token, mode) => {
|
|
1020
1063
|
try {
|
|
1021
|
-
|
|
1064
|
+
return await authContext.serviceVerifier.verify(token, {
|
|
1022
1065
|
tenantId: authContext.tenantId,
|
|
1023
1066
|
appId: authContext.appId,
|
|
1024
1067
|
viewId: route.viewId,
|
|
1025
1068
|
method,
|
|
1069
|
+
mode,
|
|
1070
|
+
sourceServiceId,
|
|
1026
1071
|
requiredPermissions: apiAuth.permissions.flatMap((requirement) => requirement.permissions)
|
|
1027
1072
|
});
|
|
1028
|
-
return { status: 200, serviceCaller: claims };
|
|
1029
1073
|
}
|
|
1030
1074
|
catch (err) {
|
|
1031
1075
|
obs?.logger.warn("Service token verification failed: {msg}", { msg: err.message });
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
return { status, error: status === 403 ? "Service access denied" : "Invalid service token" };
|
|
1035
|
-
}
|
|
1036
|
-
return { status: 200 };
|
|
1076
|
+
const status = err.status === 403 ? 403 : 401;
|
|
1077
|
+
return { status, error: status === 403 ? "Service access denied" : "Invalid service token" };
|
|
1037
1078
|
}
|
|
1079
|
+
};
|
|
1080
|
+
if (callerMode === "service") {
|
|
1081
|
+
const serviceCaller = await verifyService(primary, "service");
|
|
1082
|
+
if ("status" in serviceCaller)
|
|
1083
|
+
return required ? serviceCaller : { status: 200 };
|
|
1084
|
+
return { status: 200, serviceCaller, callerMode };
|
|
1038
1085
|
}
|
|
1039
|
-
|
|
1086
|
+
if (!secondary || isServiceToken(primary)) {
|
|
1087
|
+
return { status: 401, error: "Delegated calls require a BP user token and a service token" };
|
|
1088
|
+
}
|
|
1089
|
+
const userResult = await resolveUserRequestAuth(primary, apiAuth, authContext, obs);
|
|
1090
|
+
if (userResult.error || !userResult.user)
|
|
1091
|
+
return userResult.error ? userResult : { status: 401, error: "Valid BP user token required" };
|
|
1092
|
+
const serviceCaller = await verifyService(secondary, "delegated");
|
|
1093
|
+
if ("status" in serviceCaller)
|
|
1094
|
+
return required ? serviceCaller : { status: 200 };
|
|
1095
|
+
return { status: 200, user: userResult.user, serviceCaller, callerMode };
|
|
1096
|
+
}
|
|
1097
|
+
async function resolveUserRequestAuth(bearer, apiAuth, authContext, obs) {
|
|
1098
|
+
const required = apiAuth.required;
|
|
1040
1099
|
const verifier = authContext.verifier;
|
|
1041
1100
|
if (!verifier) {
|
|
1042
1101
|
if (required)
|
|
@@ -1060,7 +1119,6 @@ async function resolveRequestAuth(apiAuth, event, authContext, route, method, ob
|
|
|
1060
1119
|
return { status: 401, error: "Invalid token" };
|
|
1061
1120
|
return { status: 200 };
|
|
1062
1121
|
}
|
|
1063
|
-
// Step 5: tenant binding
|
|
1064
1122
|
if (claims.tenantId !== authContext.tenantId) {
|
|
1065
1123
|
obs?.logger.warn("JWT tenantId mismatch: token={t1} request={t2}", {
|
|
1066
1124
|
t1: claims.tenantId,
|
|
@@ -1070,7 +1128,6 @@ async function resolveRequestAuth(apiAuth, event, authContext, route, method, ob
|
|
|
1070
1128
|
return { status: 401, error: "Token bound to a different tenant" };
|
|
1071
1129
|
return { status: 200 };
|
|
1072
1130
|
}
|
|
1073
|
-
// Step 6: app binding
|
|
1074
1131
|
if (claims.appId !== authContext.appId) {
|
|
1075
1132
|
obs?.logger.warn("JWT appId mismatch: token={a1} request={a2}", {
|
|
1076
1133
|
a1: claims.appId,
|
|
@@ -1080,7 +1137,6 @@ async function resolveRequestAuth(apiAuth, event, authContext, route, method, ob
|
|
|
1080
1137
|
return { status: 401, error: "Token bound to a different app" };
|
|
1081
1138
|
return { status: 200 };
|
|
1082
1139
|
}
|
|
1083
|
-
// Step 7: permission check against app.auth.roles
|
|
1084
1140
|
if (apiAuth.permissions.length > 0) {
|
|
1085
1141
|
const hasPlatformRootRole = claims.roles.includes(PLATFORM_ROOT_PERMISSION_ROLE_ID);
|
|
1086
1142
|
if (hasPlatformRootRole) {
|
|
@@ -1089,7 +1145,7 @@ async function resolveRequestAuth(apiAuth, event, authContext, route, method, ob
|
|
|
1089
1145
|
&& claims.tenantId === authContext.platformRoot.tenantId
|
|
1090
1146
|
&& claims.appId === authContext.platformRoot.appId;
|
|
1091
1147
|
if (rootMatches) {
|
|
1092
|
-
return { status: 200, user: claims };
|
|
1148
|
+
return { status: 200, user: claims, callerMode: "user" };
|
|
1093
1149
|
}
|
|
1094
1150
|
obs?.logger.error("Reserved platform-root permission role used outside management app: tenant={tenantId} app={appId} rootTenant={rootTenantId} rootApp={rootAppId}", {
|
|
1095
1151
|
tenantId: claims.tenantId,
|
|
@@ -1099,15 +1155,13 @@ async function resolveRequestAuth(apiAuth, event, authContext, route, method, ob
|
|
|
1099
1155
|
});
|
|
1100
1156
|
}
|
|
1101
1157
|
const granted = expandRolesToPermissions(claims.roles, authContext.appAuthConfig);
|
|
1102
|
-
// Grants reference tenant service-instance ids; route requirements are
|
|
1103
|
-
// authored against pluginIds. Treat them as equal via the alias map.
|
|
1104
1158
|
const aliases = authContext.serviceIdAliases ?? {};
|
|
1105
|
-
const serviceIdsMatch = (grantServiceId, requiredServiceId) => grantServiceId === requiredServiceId
|
|
1106
|
-
aliases[grantServiceId] === requiredServiceId
|
|
1107
|
-
aliases[requiredServiceId] === grantServiceId;
|
|
1108
|
-
const ok = apiAuth.permissions.every((requirement) => requirement.permissions.every((action) => granted.some((grant) => serviceIdsMatch(grant.serviceId, requirement.serviceId)
|
|
1109
|
-
grant.viewId === requirement.viewId
|
|
1110
|
-
grant.permissions.includes(action))));
|
|
1159
|
+
const serviceIdsMatch = (grantServiceId, requiredServiceId) => grantServiceId === requiredServiceId
|
|
1160
|
+
|| aliases[grantServiceId] === requiredServiceId
|
|
1161
|
+
|| aliases[requiredServiceId] === grantServiceId;
|
|
1162
|
+
const ok = apiAuth.permissions.every((requirement) => requirement.permissions.every((action) => granted.some((grant) => serviceIdsMatch(grant.serviceId, requirement.serviceId)
|
|
1163
|
+
&& grant.viewId === requirement.viewId
|
|
1164
|
+
&& grant.permissions.includes(action))));
|
|
1111
1165
|
if (!ok) {
|
|
1112
1166
|
if (required) {
|
|
1113
1167
|
return {
|
|
@@ -1119,8 +1173,7 @@ async function resolveRequestAuth(apiAuth, event, authContext, route, method, ob
|
|
|
1119
1173
|
return { status: 200 };
|
|
1120
1174
|
}
|
|
1121
1175
|
}
|
|
1122
|
-
|
|
1123
|
-
return { status: 200, user: claims };
|
|
1176
|
+
return { status: 200, user: claims, callerMode: "user" };
|
|
1124
1177
|
}
|
|
1125
1178
|
function expandRolesToPermissions(roleIds, appAuthConfig) {
|
|
1126
1179
|
if (!appAuthConfig)
|
|
@@ -1171,7 +1224,7 @@ function formatRequiredPermissions(requiredPermissions = []) {
|
|
|
1171
1224
|
.join("; ");
|
|
1172
1225
|
}
|
|
1173
1226
|
function renderAuthError(route, event, status, message, requiredPermissions = []) {
|
|
1174
|
-
const
|
|
1227
|
+
const renderer = rendererFromEvent(event);
|
|
1175
1228
|
const acceptHeader = acceptHeaderFromEvent(event);
|
|
1176
1229
|
const representation = resolveRequestedRepresentation(acceptHeader);
|
|
1177
1230
|
const corsHeaders = corsHeadersFromEvent(event);
|
|
@@ -1183,14 +1236,14 @@ function renderAuthError(route, event, status, message, requiredPermissions = []
|
|
|
1183
1236
|
// app.auth config and redirects on seeing this 401. Services just report status.
|
|
1184
1237
|
// Prefer a route/theme status view so the body swaps cleanly into the htmx
|
|
1185
1238
|
// target as a fragment rather than replacing the shell.
|
|
1186
|
-
if (
|
|
1187
|
-
const statusRenderer = resolveStatusRenderer(route,
|
|
1239
|
+
if (renderer && (representation.kind === "html")) {
|
|
1240
|
+
const statusRenderer = resolveStatusRenderer(route, renderer, status, "page", undefined, "GET");
|
|
1188
1241
|
if (statusRenderer) {
|
|
1189
1242
|
try {
|
|
1190
1243
|
const html = statusRenderer.render({ error: message, status, requiredPermissions });
|
|
1191
1244
|
return new Response(toHtmlString(html), {
|
|
1192
1245
|
status,
|
|
1193
|
-
headers: { ...corsHeaders, "content-type": htmlContentType(
|
|
1246
|
+
headers: { ...corsHeaders, "content-type": htmlContentType("status", route.chrome) }
|
|
1194
1247
|
});
|
|
1195
1248
|
}
|
|
1196
1249
|
catch {
|
|
@@ -1211,7 +1264,7 @@ function renderAuthError(route, event, status, message, requiredPermissions = []
|
|
|
1211
1264
|
`;
|
|
1212
1265
|
return new Response(html, {
|
|
1213
1266
|
status,
|
|
1214
|
-
headers: { ...corsHeaders, "content-type":
|
|
1267
|
+
headers: { ...corsHeaders, "content-type": renderer ? htmlContentType("status", route.chrome) : "text/html; charset=utf-8" }
|
|
1215
1268
|
});
|
|
1216
1269
|
}
|
|
1217
1270
|
return jsonResponse({ error: message, status, requiredPermissions }, status, corsHeaders);
|
|
@@ -1223,7 +1276,7 @@ function readTenantAppFromEvent(event) {
|
|
|
1223
1276
|
return { tenantId: ctx.__bpTenantId, appId: ctx.__bpAppId };
|
|
1224
1277
|
}
|
|
1225
1278
|
function renderUpgradeRequired(route, event, validation) {
|
|
1226
|
-
const
|
|
1279
|
+
const renderer = rendererFromEvent(event);
|
|
1227
1280
|
const acceptHeader = acceptHeaderFromEvent(event);
|
|
1228
1281
|
const representation = resolveRequestedRepresentation(acceptHeader);
|
|
1229
1282
|
const status = 426;
|
|
@@ -1232,8 +1285,8 @@ function renderUpgradeRequired(route, event, validation) {
|
|
|
1232
1285
|
if (validation.retryAfterSeconds) {
|
|
1233
1286
|
extraHeaders["retry-after"] = String(validation.retryAfterSeconds);
|
|
1234
1287
|
}
|
|
1235
|
-
if (
|
|
1236
|
-
const statusRenderer = resolveStatusRenderer(route,
|
|
1288
|
+
if (renderer && representation.kind === "html") {
|
|
1289
|
+
const statusRenderer = resolveStatusRenderer(route, renderer, status, "page", undefined, "GET");
|
|
1237
1290
|
if (statusRenderer) {
|
|
1238
1291
|
try {
|
|
1239
1292
|
const html = statusRenderer.render({
|
|
@@ -1241,7 +1294,7 @@ function renderUpgradeRequired(route, event, validation) {
|
|
|
1241
1294
|
reason: validation.reason,
|
|
1242
1295
|
upgradeUrl: validation.upgradeUrl
|
|
1243
1296
|
});
|
|
1244
|
-
return htmlResponse(toHtmlString(html), status, htmlContentType(
|
|
1297
|
+
return htmlResponse(toHtmlString(html), status, htmlContentType("status", route.chrome));
|
|
1245
1298
|
}
|
|
1246
1299
|
catch {
|
|
1247
1300
|
// fall through to JSON
|