@betterportal/framework 10.1.56 → 10.1.58
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 +156 -68
- package/lib/adapters/h3.js.map +1 -1
- package/lib/contracts/observability.d.ts +6 -0
- package/lib/contracts/observability.d.ts.map +1 -1
- package/lib/contracts/observability.js.map +1 -1
- package/lib/contracts/route.d.ts +3 -1
- package/lib/contracts/route.d.ts.map +1 -1
- package/lib/contracts/route.js.map +1 -1
- package/lib/runtime/h3.d.ts +6 -1
- package/lib/runtime/h3.d.ts.map +1 -1
- package/lib/runtime/h3.js +222 -5
- package/lib/runtime/h3.js.map +1 -1
- package/lib/runtime/llms.d.ts.map +1 -1
- package/lib/runtime/llms.js +1 -0
- package/lib/runtime/llms.js.map +1 -1
- package/lib/runtime/serviceConfig.d.ts.map +1 -1
- package/lib/runtime/serviceConfig.js +20 -17
- package/lib/runtime/serviceConfig.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,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,
|
|
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,EASL,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;AAQD,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;AA+KD,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,CAqGN;AAyhDD;;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,CAuDN"}
|
package/lib/adapters/h3.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createEventStream, getRequestIP, getRequestURL } from "h3";
|
|
2
2
|
import { isStreamHandler } from "../contracts/streaming.js";
|
|
3
3
|
import { driveStream, driveStreamBuffered, ndjsonStreamResponse } from "../runtime/stream.js";
|
|
4
|
-
import { acceptHeaderFromEvent, eventObservability, htmlResponse, jsonResponse } from "../runtime/h3.js";
|
|
4
|
+
import { acceptHeaderFromEvent, annotateCoreHttpOutcome, annotateHttpOutcome, ensureCoreHttpOutcome, eventObservability, htmlResponse, jsonResponse, withCoreHttpOutcome } from "../runtime/h3.js";
|
|
5
5
|
import { buildHostCandidates, hostFromHeaderValue, toHtmlString } from "../runtime/http.js";
|
|
6
6
|
import { parseAcceptHeader, resolveRequestedRepresentation } from "../runtime/media.js";
|
|
7
7
|
import { resolveRenderer } from "../runtime/registry.js";
|
|
@@ -44,7 +44,7 @@ function headersFromEvent(event) {
|
|
|
44
44
|
function parseRouteParams(rawParams, schema) {
|
|
45
45
|
for (const [name, value] of Object.entries(rawParams)) {
|
|
46
46
|
if (!value || value.length > 100) {
|
|
47
|
-
return
|
|
47
|
+
return coreJsonResponse({ error: `Invalid path parameter: ${name}` }, 400, "request.params.invalid", `Invalid path parameter: ${name}`, { "bp.request.parameter": name });
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
if (!schema)
|
|
@@ -53,10 +53,11 @@ function parseRouteParams(rawParams, schema) {
|
|
|
53
53
|
return schema.parse(rawParams);
|
|
54
54
|
}
|
|
55
55
|
catch (error) {
|
|
56
|
-
|
|
56
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
57
|
+
return coreJsonResponse({
|
|
57
58
|
error: "Invalid path parameters",
|
|
58
|
-
detail:
|
|
59
|
-
}, 400);
|
|
59
|
+
detail: reason
|
|
60
|
+
}, 400, "request.params.invalid", reason);
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
function escapeContentDispositionValue(value) {
|
|
@@ -65,6 +66,23 @@ function escapeContentDispositionValue(value) {
|
|
|
65
66
|
function responseHelper(body = null, init = {}) {
|
|
66
67
|
return new Response(body, init);
|
|
67
68
|
}
|
|
69
|
+
function coreResponse(response, code, reason, attributes) {
|
|
70
|
+
return withCoreHttpOutcome(response, { code, reason, attributes });
|
|
71
|
+
}
|
|
72
|
+
function coreJsonResponse(body, status, code, reason, attributes, headers) {
|
|
73
|
+
return coreResponse(jsonResponse(body, status, headers), code, reason, attributes);
|
|
74
|
+
}
|
|
75
|
+
function parseRequestValue(schema, value, code, target) {
|
|
76
|
+
try {
|
|
77
|
+
return { value: schema.parse(value) };
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
81
|
+
return {
|
|
82
|
+
response: coreJsonResponse({ error: `Invalid ${target}`, detail: reason }, 400, code, reason, { "bp.request.validation_target": target })
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
68
86
|
function fileResponseHelper(body, options = {}) {
|
|
69
87
|
const headers = new Headers(options.headers);
|
|
70
88
|
if (options.contentType && !headers.has("content-type"))
|
|
@@ -636,7 +654,11 @@ function rejectUnallowedAppRoute(obs, route, method, extraContext, reason) {
|
|
|
636
654
|
"bp.tenant.id": extraContext.tenant.id,
|
|
637
655
|
"bp.route_allowlist.reason": reason
|
|
638
656
|
});
|
|
639
|
-
return
|
|
657
|
+
return coreJsonResponse({ error: "Route not found" }, 404, "route.not_mounted", reason, {
|
|
658
|
+
"bp.route.view_id": route.viewId,
|
|
659
|
+
"bp.route.path": route.path,
|
|
660
|
+
"bp.route_allowlist.reason": reason
|
|
661
|
+
});
|
|
640
662
|
}
|
|
641
663
|
async function withRequestObservability(event, route, method, options, handler, extraAttributes = {}) {
|
|
642
664
|
const startedAt = performance.now();
|
|
@@ -698,7 +720,7 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
698
720
|
params: methodRoute?.schemas.params ?? route.schemas.params
|
|
699
721
|
};
|
|
700
722
|
if (!handler) {
|
|
701
|
-
return
|
|
723
|
+
return coreJsonResponse({ error: `No handler for ${method} ${route.path}` }, 405, "route.handler_missing", `No handler for ${method} ${route.path}`);
|
|
702
724
|
}
|
|
703
725
|
// -- Parse inputs -------------------------------------------------
|
|
704
726
|
const url = getRequestURL(event);
|
|
@@ -718,7 +740,7 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
718
740
|
}
|
|
719
741
|
catch (err) {
|
|
720
742
|
if (err instanceof MultipartTooLargeError) {
|
|
721
|
-
return
|
|
743
|
+
return coreJsonResponse({ error: "Multipart payload too large" }, 413, "request.multipart.too_large", err.message);
|
|
722
744
|
}
|
|
723
745
|
rawBody = {};
|
|
724
746
|
}
|
|
@@ -734,14 +756,30 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
734
756
|
// RequestSchema is only enforced for methods that carry a body. GET/DELETE/OPTIONS
|
|
735
757
|
// pass rawBody (empty {}) through unparsed so routes with both GET + POST handlers
|
|
736
758
|
// don't fail GET because POST's RequestSchema has required fields.
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
759
|
+
const queryResult = schemas.query
|
|
760
|
+
? parseRequestValue(schemas.query, rawQuery, "request.query.invalid", "query parameters")
|
|
761
|
+
: { value: rawQuery };
|
|
762
|
+
if ("response" in queryResult)
|
|
763
|
+
return queryResult.response;
|
|
764
|
+
const query = queryResult.value;
|
|
765
|
+
const headersResult = schemas.headers
|
|
766
|
+
? parseRequestValue(schemas.headers, rawHeaders, "request.headers.invalid", "request headers")
|
|
767
|
+
: { value: rawHeaders };
|
|
768
|
+
if ("response" in headersResult)
|
|
769
|
+
return headersResult.response;
|
|
770
|
+
const headers = headersResult.value;
|
|
771
|
+
const requestResult = schemas.request && METHOD_WRITE_BODY.has(method)
|
|
772
|
+
? parseRequestValue(schemas.request, rawBody, "request.body.invalid", "request body")
|
|
773
|
+
: { value: rawBody };
|
|
774
|
+
if ("response" in requestResult)
|
|
775
|
+
return requestResult.response;
|
|
776
|
+
const request = requestResult.value;
|
|
777
|
+
const multipartResult = schemas.multipart
|
|
778
|
+
? parseRequestValue(schemas.multipart, rawMultipart ?? { fields: {}, files: {} }, "request.multipart.invalid", "multipart request")
|
|
779
|
+
: { value: undefined };
|
|
780
|
+
if ("response" in multipartResult)
|
|
781
|
+
return multipartResult.response;
|
|
782
|
+
const multipart = multipartResult.value;
|
|
745
783
|
// Path params - H3 populates event.context.params for `:paramName` routes
|
|
746
784
|
const rawParams = event.context?.params ?? {};
|
|
747
785
|
const params = parseRouteParams(rawParams, schemas.params);
|
|
@@ -749,7 +787,7 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
749
787
|
return params;
|
|
750
788
|
const extraContext = await resolveRequiredHandlerContext(event, routerOptions, route);
|
|
751
789
|
if (!extraContext) {
|
|
752
|
-
return
|
|
790
|
+
return coreJsonResponse({ error: "BetterPortal tenant/app context required" }, 400, "route.context_unresolved", "BetterPortal tenant/app context required");
|
|
753
791
|
}
|
|
754
792
|
const routeAllowlistAcceptHeader = acceptHeaderFromEvent(event);
|
|
755
793
|
const routeAllowance = appAllowsRoute(extraContext.app, route, method, url, routeAllowlistAcceptHeader);
|
|
@@ -776,7 +814,7 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
776
814
|
const authResolved = await loadAuthContext(event, route, routerOptions, obs);
|
|
777
815
|
const authResult = await resolveRequestAuth(apiAuth, event, authResolved, route, method, obs);
|
|
778
816
|
if (authResult.error) {
|
|
779
|
-
return renderAuthError(route, event, authResult.status, authResult.error, authResult.requiredPermissions, earlyRenderContext(authResult.status));
|
|
817
|
+
return renderAuthError(route, event, authResult.status, authResult.code ?? "auth.unclassified", authResult.error, authResult.requiredPermissions, earlyRenderContext(authResult.status));
|
|
780
818
|
}
|
|
781
819
|
// -- Tenant/app activation check (validateTenantApp hook -> 426) -----
|
|
782
820
|
const tenantApp = readTenantAppFromEvent(event);
|
|
@@ -819,6 +857,7 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
819
857
|
bpHeaders,
|
|
820
858
|
responseHeaders: event.res.headers,
|
|
821
859
|
setStatus: (status) => { event.res.status = status; },
|
|
860
|
+
diagnostic: (diagnostic) => annotateHttpOutcome(event, diagnostic),
|
|
822
861
|
serviceId: routerOptions.serviceId,
|
|
823
862
|
routeUrl,
|
|
824
863
|
uiRouteUrl,
|
|
@@ -836,19 +875,19 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
836
875
|
applyBpHeadersToEvent(event, bpHeaders);
|
|
837
876
|
return streamed;
|
|
838
877
|
}
|
|
839
|
-
rawData = await withSpan(obs, "bp.route.handler", {
|
|
878
|
+
rawData = await withCoreFailure(event, "handler.exception", () => withSpan(obs, "bp.route.handler", {
|
|
840
879
|
"bp.route.view_id": route.viewId,
|
|
841
880
|
"bp.route.path": route.path,
|
|
842
881
|
"http.request.method": method,
|
|
843
882
|
"bp.route.stream_buffered": true
|
|
844
|
-
}, () => driveStreamBuffered(handler, ctx));
|
|
883
|
+
}, () => driveStreamBuffered(handler, ctx)));
|
|
845
884
|
}
|
|
846
885
|
else {
|
|
847
|
-
rawData = await withSpan(obs, "bp.route.handler", {
|
|
886
|
+
rawData = await withCoreFailure(event, "handler.exception", () => withSpan(obs, "bp.route.handler", {
|
|
848
887
|
"bp.route.view_id": route.viewId,
|
|
849
888
|
"bp.route.path": route.path,
|
|
850
889
|
"http.request.method": method
|
|
851
|
-
}, () => handler(ctx));
|
|
890
|
+
}, () => handler(ctx)));
|
|
852
891
|
}
|
|
853
892
|
// -- Emit BP-managed headers -------------------------------------
|
|
854
893
|
applyBpHeadersToEvent(event, bpHeaders);
|
|
@@ -857,6 +896,12 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
857
896
|
}
|
|
858
897
|
// -- Status decision ---------------------------------------------
|
|
859
898
|
const handlerStatus = event.res.status && event.res.status !== 0 ? event.res.status : 200;
|
|
899
|
+
if (handlerStatus < 200 || handlerStatus >= 400) {
|
|
900
|
+
ensureCoreHttpOutcome(event, {
|
|
901
|
+
code: "response.status_unclassified",
|
|
902
|
+
reason: `Handler returned HTTP ${handlerStatus} without an explicit diagnostic`
|
|
903
|
+
});
|
|
904
|
+
}
|
|
860
905
|
// -- Content negotiation ------------------------------------------
|
|
861
906
|
const acceptHeader = acceptHeaderFromEvent(event);
|
|
862
907
|
const representation = resolveRequestedRepresentation(acceptHeader);
|
|
@@ -881,9 +926,17 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
881
926
|
// -- Validate response against schema (all representations) ------
|
|
882
927
|
// Skipped when status indicates no body is expected.
|
|
883
928
|
if (!schemas.response) {
|
|
884
|
-
|
|
929
|
+
const reason = `Route "${route.viewId}" has no ResponseSchema and did not return a raw Response`;
|
|
930
|
+
return coreJsonResponse({ error: reason }, 500, "response.schema_missing", reason);
|
|
931
|
+
}
|
|
932
|
+
let data;
|
|
933
|
+
try {
|
|
934
|
+
data = schemas.response.parse(rawData);
|
|
935
|
+
}
|
|
936
|
+
catch (error) {
|
|
937
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
938
|
+
return coreJsonResponse({ error: "Response schema validation failed", detail: reason }, 500, "response.schema_invalid", reason);
|
|
885
939
|
}
|
|
886
|
-
const data = schemas.response.parse(rawData);
|
|
887
940
|
// NDJSON only exists for streaming views; those were handled before
|
|
888
941
|
// negotiation, so reaching here means the view does not stream.
|
|
889
942
|
if (representation.kind === "ndjson") {
|
|
@@ -891,7 +944,7 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
891
944
|
"http.request.accept": acceptHeader ?? "",
|
|
892
945
|
"bp.representation.kind": representation.kind
|
|
893
946
|
});
|
|
894
|
-
return
|
|
947
|
+
return coreJsonResponse({ error: "NDJSON streaming is not supported by this view" }, 406, "representation.ndjson_not_supported", "NDJSON streaming is not supported by this view");
|
|
895
948
|
}
|
|
896
949
|
// JSON - already validated above, no redundant parse
|
|
897
950
|
if (representation.kind === "json") {
|
|
@@ -903,7 +956,7 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
903
956
|
"http.request.accept": acceptHeader ?? "",
|
|
904
957
|
"bp.representation.kind": representation.kind
|
|
905
958
|
});
|
|
906
|
-
return
|
|
959
|
+
return coreJsonResponse({ error: "Renderer could not be resolved from the app shell" }, 406, "representation.renderer_unresolved", "Renderer could not be resolved from the app shell");
|
|
907
960
|
}
|
|
908
961
|
// Determine the renderer kind requested
|
|
909
962
|
const fragmentKey = url.searchParams.get("_f") ?? fragmentFromAcceptHeader(acceptHeader);
|
|
@@ -915,17 +968,27 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
915
968
|
if (handlerStatus !== 200) {
|
|
916
969
|
const statusRenderer = resolveStatusRenderer(route, renderer, handlerStatus, requestedKind, requestedKey, method);
|
|
917
970
|
if (statusRenderer) {
|
|
918
|
-
|
|
971
|
+
if (handlerStatus < 200 || handlerStatus >= 400) {
|
|
972
|
+
ensureCoreHttpOutcome(event, {
|
|
973
|
+
code: "response.status_unclassified",
|
|
974
|
+
reason: `Handler returned HTTP ${handlerStatus} without an explicit diagnostic`
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
const html = await withCoreFailure(event, "render.status_failed", () => withSpan(obs, "bp.view.render", {
|
|
919
978
|
"bp.route.view_id": route.viewId,
|
|
920
979
|
"bp.view.renderer": renderer,
|
|
921
980
|
"bp.view.kind": requestedKind,
|
|
922
981
|
"bp.view.status": handlerStatus
|
|
923
|
-
}, () => statusRenderer.render(data, renderContext));
|
|
982
|
+
}, () => statusRenderer.render(data, renderContext)));
|
|
924
983
|
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), handlerStatus, htmlContentType("status", route.chrome));
|
|
925
984
|
}
|
|
926
985
|
// No specific renderer found.
|
|
927
986
|
if (!shouldFallThroughToDefaultRenderer(handlerStatus)) {
|
|
928
987
|
// 4xx/5xx without a specific renderer -> empty body with status.
|
|
988
|
+
ensureCoreHttpOutcome(event, {
|
|
989
|
+
code: "response.status_unclassified",
|
|
990
|
+
reason: `Handler returned HTTP ${handlerStatus} without an explicit diagnostic`
|
|
991
|
+
});
|
|
929
992
|
return new Response(null, { status: handlerStatus });
|
|
930
993
|
}
|
|
931
994
|
// 2xx without specific -> fall through to default renderer, but keep handlerStatus.
|
|
@@ -940,16 +1003,16 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
940
1003
|
"bp.view.kind": "fragment",
|
|
941
1004
|
"bp.view.key": fragmentKey
|
|
942
1005
|
});
|
|
943
|
-
return
|
|
1006
|
+
return coreJsonResponse({
|
|
944
1007
|
error: `No fragment renderer found for fragment="${fragmentKey}" in renderer "${renderer}"`
|
|
945
|
-
}, 406);
|
|
1008
|
+
}, 406, "representation.fragment_not_found", `Fragment renderer "${fragmentKey}" was not found`);
|
|
946
1009
|
}
|
|
947
|
-
const html = await withSpan(obs, "bp.view.render", {
|
|
1010
|
+
const html = await withCoreFailure(event, "render.fragment_failed", () => withSpan(obs, "bp.view.render", {
|
|
948
1011
|
"bp.route.view_id": route.viewId,
|
|
949
1012
|
"bp.view.renderer": renderer,
|
|
950
1013
|
"bp.view.kind": "fragment",
|
|
951
1014
|
"bp.view.key": fragmentKey
|
|
952
|
-
}, () => resolved.renderer.render(data, renderContext));
|
|
1015
|
+
}, () => resolved.renderer.render(data, renderContext)));
|
|
953
1016
|
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), handlerStatus, htmlContentType("fragment", route.chrome));
|
|
954
1017
|
}
|
|
955
1018
|
// Component request via `_c` query param
|
|
@@ -962,16 +1025,16 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
962
1025
|
"bp.view.kind": "component",
|
|
963
1026
|
"bp.view.key": componentId
|
|
964
1027
|
});
|
|
965
|
-
return
|
|
1028
|
+
return coreJsonResponse({
|
|
966
1029
|
error: `No component renderer found for _c="${componentId}" in renderer "${renderer}"`
|
|
967
|
-
}, 406);
|
|
1030
|
+
}, 406, "representation.component_not_found", `Component renderer "${componentId}" was not found`);
|
|
968
1031
|
}
|
|
969
|
-
const html = await withSpan(obs, "bp.view.render", {
|
|
1032
|
+
const html = await withCoreFailure(event, "render.component_failed", () => withSpan(obs, "bp.view.render", {
|
|
970
1033
|
"bp.route.view_id": route.viewId,
|
|
971
1034
|
"bp.view.renderer": renderer,
|
|
972
1035
|
"bp.view.kind": "component",
|
|
973
1036
|
"bp.view.key": componentId
|
|
974
|
-
}, () => resolved.renderer.render(data, renderContext));
|
|
1037
|
+
}, () => resolved.renderer.render(data, renderContext)));
|
|
975
1038
|
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), handlerStatus, htmlContentType("fragment", route.chrome));
|
|
976
1039
|
}
|
|
977
1040
|
// Page request - only page renderers allowed
|
|
@@ -982,15 +1045,15 @@ async function handleRouteRequest(registryRoutes, dependencyAliases, route, meth
|
|
|
982
1045
|
"bp.view.renderer": renderer,
|
|
983
1046
|
"bp.view.kind": "page"
|
|
984
1047
|
});
|
|
985
|
-
return
|
|
1048
|
+
return coreJsonResponse({
|
|
986
1049
|
error: `No page renderer found for renderer "${renderer}"`
|
|
987
|
-
}, 406);
|
|
1050
|
+
}, 406, "representation.page_not_found", `Page renderer "${renderer}" was not found`);
|
|
988
1051
|
}
|
|
989
|
-
const html = await withSpan(obs, "bp.view.render", {
|
|
1052
|
+
const html = await withCoreFailure(event, "render.page_failed", () => withSpan(obs, "bp.view.render", {
|
|
990
1053
|
"bp.route.view_id": route.viewId,
|
|
991
1054
|
"bp.view.renderer": renderer,
|
|
992
1055
|
"bp.view.kind": "page"
|
|
993
|
-
}, () => resolved.renderer.render(data, renderContext));
|
|
1056
|
+
}, () => resolved.renderer.render(data, renderContext)));
|
|
994
1057
|
const mode = representation.mode ?? "page";
|
|
995
1058
|
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), handlerStatus, htmlContentType(mode, route.chrome));
|
|
996
1059
|
}
|
|
@@ -1025,11 +1088,11 @@ async function handleStreamRepresentation(route, handler, ctx, event, url, metho
|
|
|
1025
1088
|
params: ctx.params,
|
|
1026
1089
|
query: ctx.query
|
|
1027
1090
|
};
|
|
1028
|
-
const html = await withSpan(obs, "bp.view.render", {
|
|
1091
|
+
const html = await withCoreFailure(event, "render.stream_shell_failed", () => withSpan(obs, "bp.view.render", {
|
|
1029
1092
|
"bp.route.view_id": route.viewId,
|
|
1030
1093
|
"bp.view.renderer": renderer,
|
|
1031
1094
|
"bp.view.kind": "stream-shell"
|
|
1032
|
-
}, () => streamSet.renderShell(shellCtx));
|
|
1095
|
+
}, () => streamSet.renderShell(shellCtx)));
|
|
1033
1096
|
return htmlResponse(rewriteServiceRouteTokens(toHtmlString(html), ctx.routeUrl, obs), 200, htmlContentType("fragment", route.chrome));
|
|
1034
1097
|
}
|
|
1035
1098
|
/**
|
|
@@ -1057,11 +1120,11 @@ async function handleStreamSse(registryRoutes, dependencyAliases, route, handler
|
|
|
1057
1120
|
const authResolved = await loadAuthContext(event, route, routerOptions, obs);
|
|
1058
1121
|
const authResult = await resolveRequestAuth(route.auth, event, authResolved, route, "GET", obs);
|
|
1059
1122
|
if (authResult.error) {
|
|
1060
|
-
return
|
|
1123
|
+
return coreJsonResponse({ error: authResult.error, status: authResult.status }, authResult.status, authResult.code ?? "auth.unclassified", authResult.error);
|
|
1061
1124
|
}
|
|
1062
1125
|
const extraContext = await resolveRequiredHandlerContext(event, routerOptions, route);
|
|
1063
1126
|
if (!extraContext) {
|
|
1064
|
-
return
|
|
1127
|
+
return coreJsonResponse({ error: "BetterPortal tenant/app context required" }, 400, "stream.context_unresolved", "BetterPortal tenant/app context required");
|
|
1065
1128
|
}
|
|
1066
1129
|
const ctx = {
|
|
1067
1130
|
params,
|
|
@@ -1078,6 +1141,7 @@ async function handleStreamSse(registryRoutes, dependencyAliases, route, handler
|
|
|
1078
1141
|
serviceId: routerOptions.serviceId,
|
|
1079
1142
|
routeUrl: createServiceRouteUrlBuilder(registryRoutes, extraContext, dependencyAliases, routerOptions.serviceId),
|
|
1080
1143
|
uiRouteUrl: createUiRouteUrlBuilder(event, extraContext, dependencyAliases, routerOptions.serviceId),
|
|
1144
|
+
diagnostic: (diagnostic) => annotateHttpOutcome(event, diagnostic),
|
|
1081
1145
|
response: responseHelper,
|
|
1082
1146
|
file: fileResponseHelper,
|
|
1083
1147
|
...(obs ? { obs } : {})
|
|
@@ -1158,18 +1222,18 @@ async function resolveRequestAuth(apiAuth, event, authContext, route, method, ob
|
|
|
1158
1222
|
: undefined;
|
|
1159
1223
|
if (!primary || !callerMode) {
|
|
1160
1224
|
if (required)
|
|
1161
|
-
return { status: 401, error: "Authentication required" };
|
|
1225
|
+
return { status: 401, error: "Authentication required", code: "auth.required" };
|
|
1162
1226
|
return { status: 200 };
|
|
1163
1227
|
}
|
|
1164
1228
|
const allowedCallers = apiAuth.callers ?? ["user"];
|
|
1165
1229
|
if (!allowedCallers.includes(callerMode)) {
|
|
1166
1230
|
if (required)
|
|
1167
|
-
return { status: 403, error: `${callerMode} callers are not allowed
|
|
1231
|
+
return { status: 403, error: `${callerMode} callers are not allowed`, code: "auth.caller_not_allowed" };
|
|
1168
1232
|
return { status: 200 };
|
|
1169
1233
|
}
|
|
1170
1234
|
if (!authContext) {
|
|
1171
1235
|
if (required)
|
|
1172
|
-
return { status: 503, error: "Auth context unavailable" };
|
|
1236
|
+
return { status: 503, error: "Auth context unavailable", code: "auth.context_unavailable" };
|
|
1173
1237
|
return { status: 200 };
|
|
1174
1238
|
}
|
|
1175
1239
|
if (callerMode === "user") {
|
|
@@ -1179,14 +1243,14 @@ async function resolveRequestAuth(apiAuth, event, authContext, route, method, ob
|
|
|
1179
1243
|
const tenantId = event.req.headers.get("x-bp-tenant-id");
|
|
1180
1244
|
const appId = event.req.headers.get("x-bp-app-id");
|
|
1181
1245
|
if (!sourceServiceId || !tenantId || !appId) {
|
|
1182
|
-
return { status: 401, error: "Service, tenant, and app headers are required for S2S calls" };
|
|
1246
|
+
return { status: 401, error: "Service, tenant, and app headers are required for S2S calls", code: "s2s.headers_missing" };
|
|
1183
1247
|
}
|
|
1184
1248
|
if (tenantId !== authContext.tenantId || appId !== authContext.appId) {
|
|
1185
|
-
return { status: 401, error: "S2S headers do not match the resolved tenant/app" };
|
|
1249
|
+
return { status: 401, error: "S2S headers do not match the resolved tenant/app", code: "s2s.context_mismatch" };
|
|
1186
1250
|
}
|
|
1187
1251
|
if (!authContext.serviceVerifier) {
|
|
1188
1252
|
if (required)
|
|
1189
|
-
return { status: 503, error: "Service auth context unavailable" };
|
|
1253
|
+
return { status: 503, error: "Service auth context unavailable", code: "s2s.verifier_unavailable" };
|
|
1190
1254
|
return { status: 200 };
|
|
1191
1255
|
}
|
|
1192
1256
|
const verifyService = async (token, mode) => {
|
|
@@ -1204,7 +1268,11 @@ async function resolveRequestAuth(apiAuth, event, authContext, route, method, ob
|
|
|
1204
1268
|
catch (err) {
|
|
1205
1269
|
obs?.logger.warn("Service token verification failed: {msg}", { msg: err.message });
|
|
1206
1270
|
const status = err.status === 403 ? 403 : 401;
|
|
1207
|
-
return {
|
|
1271
|
+
return {
|
|
1272
|
+
status,
|
|
1273
|
+
error: status === 403 ? "Service access denied" : "Invalid service token",
|
|
1274
|
+
code: status === 403 ? "s2s.access_denied" : "s2s.token_invalid"
|
|
1275
|
+
};
|
|
1208
1276
|
}
|
|
1209
1277
|
};
|
|
1210
1278
|
if (callerMode === "service") {
|
|
@@ -1214,11 +1282,17 @@ async function resolveRequestAuth(apiAuth, event, authContext, route, method, ob
|
|
|
1214
1282
|
return { status: 200, serviceCaller, callerMode };
|
|
1215
1283
|
}
|
|
1216
1284
|
if (!secondary || isServiceToken(primary)) {
|
|
1217
|
-
return {
|
|
1285
|
+
return {
|
|
1286
|
+
status: 401,
|
|
1287
|
+
error: "Delegated calls require a BP user token and a service token",
|
|
1288
|
+
code: "s2s.delegated_token_invalid"
|
|
1289
|
+
};
|
|
1218
1290
|
}
|
|
1219
1291
|
const userResult = await resolveUserRequestAuth(primary, apiAuth, authContext, obs);
|
|
1220
1292
|
if (userResult.error || !userResult.user)
|
|
1221
|
-
return userResult.error
|
|
1293
|
+
return userResult.error
|
|
1294
|
+
? userResult
|
|
1295
|
+
: { status: 401, error: "Valid BP user token required", code: "s2s.delegated_token_invalid" };
|
|
1222
1296
|
const serviceCaller = await verifyService(secondary, "delegated");
|
|
1223
1297
|
if ("status" in serviceCaller)
|
|
1224
1298
|
return required ? serviceCaller : { status: 200 };
|
|
@@ -1229,7 +1303,7 @@ async function resolveUserRequestAuth(bearer, apiAuth, authContext, obs) {
|
|
|
1229
1303
|
const verifier = authContext.verifier;
|
|
1230
1304
|
if (!verifier) {
|
|
1231
1305
|
if (required)
|
|
1232
|
-
return { status: 503, error: "Auth context unavailable" };
|
|
1306
|
+
return { status: 503, error: "Auth context unavailable", code: "auth.context_unavailable" };
|
|
1233
1307
|
return { status: 200 };
|
|
1234
1308
|
}
|
|
1235
1309
|
let claims;
|
|
@@ -1246,7 +1320,7 @@ async function resolveUserRequestAuth(bearer, apiAuth, authContext, obs) {
|
|
|
1246
1320
|
catch (err) {
|
|
1247
1321
|
obs?.logger.warn("JWT verification failed: {msg}", { msg: err.message });
|
|
1248
1322
|
if (required)
|
|
1249
|
-
return { status: 401, error: "Invalid token" };
|
|
1323
|
+
return { status: 401, error: "Invalid token", code: "auth.token_invalid" };
|
|
1250
1324
|
return { status: 200 };
|
|
1251
1325
|
}
|
|
1252
1326
|
if (claims.tenantId !== authContext.tenantId) {
|
|
@@ -1255,7 +1329,7 @@ async function resolveUserRequestAuth(bearer, apiAuth, authContext, obs) {
|
|
|
1255
1329
|
t2: authContext.tenantId
|
|
1256
1330
|
});
|
|
1257
1331
|
if (required)
|
|
1258
|
-
return { status: 401, error: "Token bound to a different tenant" };
|
|
1332
|
+
return { status: 401, error: "Token bound to a different tenant", code: "auth.token_tenant_mismatch" };
|
|
1259
1333
|
return { status: 200 };
|
|
1260
1334
|
}
|
|
1261
1335
|
if (claims.appId !== authContext.appId) {
|
|
@@ -1264,7 +1338,7 @@ async function resolveUserRequestAuth(bearer, apiAuth, authContext, obs) {
|
|
|
1264
1338
|
a2: authContext.appId
|
|
1265
1339
|
});
|
|
1266
1340
|
if (required)
|
|
1267
|
-
return { status: 401, error: "Token bound to a different app" };
|
|
1341
|
+
return { status: 401, error: "Token bound to a different app", code: "auth.token_app_mismatch" };
|
|
1268
1342
|
return { status: 200 };
|
|
1269
1343
|
}
|
|
1270
1344
|
if (apiAuth.permissions.length > 0) {
|
|
@@ -1297,6 +1371,7 @@ async function resolveUserRequestAuth(bearer, apiAuth, authContext, obs) {
|
|
|
1297
1371
|
return {
|
|
1298
1372
|
status: 403,
|
|
1299
1373
|
error: "Insufficient permissions",
|
|
1374
|
+
code: "auth.permissions_insufficient",
|
|
1300
1375
|
requiredPermissions: apiAuth.permissions
|
|
1301
1376
|
};
|
|
1302
1377
|
}
|
|
@@ -1353,7 +1428,7 @@ function formatRequiredPermissions(requiredPermissions = []) {
|
|
|
1353
1428
|
.map((requirement) => `${requirement.serviceId} / ${requirement.viewId} / ${requirement.permissions.join("+")}`)
|
|
1354
1429
|
.join("; ");
|
|
1355
1430
|
}
|
|
1356
|
-
function renderAuthError(route, event, status, message, requiredPermissions = [], context) {
|
|
1431
|
+
function renderAuthError(route, event, status, code, message, requiredPermissions = [], context) {
|
|
1357
1432
|
const renderer = rendererFromEvent(event);
|
|
1358
1433
|
const acceptHeader = acceptHeaderFromEvent(event);
|
|
1359
1434
|
const representation = resolveRequestedRepresentation(acceptHeader);
|
|
@@ -1371,10 +1446,10 @@ function renderAuthError(route, event, status, message, requiredPermissions = []
|
|
|
1371
1446
|
if (statusRenderer && context) {
|
|
1372
1447
|
try {
|
|
1373
1448
|
const html = statusRenderer.render({ error: message, status, requiredPermissions }, context);
|
|
1374
|
-
return new Response(toHtmlString(html), {
|
|
1449
|
+
return coreResponse(new Response(toHtmlString(html), {
|
|
1375
1450
|
status,
|
|
1376
1451
|
headers: { ...corsHeaders, "content-type": htmlContentType("status", route.chrome) }
|
|
1377
|
-
});
|
|
1452
|
+
}), code, message);
|
|
1378
1453
|
}
|
|
1379
1454
|
catch {
|
|
1380
1455
|
// fall through to JSON
|
|
@@ -1392,12 +1467,24 @@ function renderAuthError(route, event, status, message, requiredPermissions = []
|
|
|
1392
1467
|
</div>
|
|
1393
1468
|
</section>
|
|
1394
1469
|
`;
|
|
1395
|
-
return new Response(html, {
|
|
1470
|
+
return coreResponse(new Response(html, {
|
|
1396
1471
|
status,
|
|
1397
1472
|
headers: { ...corsHeaders, "content-type": renderer ? htmlContentType("status", route.chrome) : "text/html; charset=utf-8" }
|
|
1473
|
+
}), code, message);
|
|
1474
|
+
}
|
|
1475
|
+
return coreJsonResponse({ error: message, status, requiredPermissions }, status, code, message, undefined, corsHeaders);
|
|
1476
|
+
}
|
|
1477
|
+
async function withCoreFailure(event, code, handler) {
|
|
1478
|
+
try {
|
|
1479
|
+
return await handler();
|
|
1480
|
+
}
|
|
1481
|
+
catch (error) {
|
|
1482
|
+
annotateCoreHttpOutcome(event, {
|
|
1483
|
+
code,
|
|
1484
|
+
reason: (error instanceof Error ? error.message : String(error)).slice(0, 2048)
|
|
1398
1485
|
});
|
|
1486
|
+
throw error;
|
|
1399
1487
|
}
|
|
1400
|
-
return jsonResponse({ error: message, status, requiredPermissions }, status, corsHeaders);
|
|
1401
1488
|
}
|
|
1402
1489
|
function readTenantAppFromEvent(event) {
|
|
1403
1490
|
const ctx = event;
|
|
@@ -1424,19 +1511,19 @@ function renderUpgradeRequired(route, event, validation, context) {
|
|
|
1424
1511
|
reason: validation.reason,
|
|
1425
1512
|
upgradeUrl: validation.upgradeUrl
|
|
1426
1513
|
}, context);
|
|
1427
|
-
return htmlResponse(toHtmlString(html), status, htmlContentType("status", route.chrome));
|
|
1514
|
+
return coreResponse(htmlResponse(toHtmlString(html), status, htmlContentType("status", route.chrome)), "route.tenant_app_unavailable", validation.reason ?? "Tenant/app is not available for this service");
|
|
1428
1515
|
}
|
|
1429
1516
|
catch {
|
|
1430
1517
|
// fall through to JSON
|
|
1431
1518
|
}
|
|
1432
1519
|
}
|
|
1433
1520
|
}
|
|
1434
|
-
return
|
|
1521
|
+
return coreJsonResponse({
|
|
1435
1522
|
status,
|
|
1436
1523
|
error: "Upgrade Required",
|
|
1437
1524
|
reason: validation.reason,
|
|
1438
1525
|
upgradeUrl: validation.upgradeUrl
|
|
1439
|
-
}, status, extraHeaders);
|
|
1526
|
+
}, status, "route.tenant_app_unavailable", validation.reason ?? "Upgrade required", undefined, extraHeaders);
|
|
1440
1527
|
}
|
|
1441
1528
|
function applyBpHeadersToEvent(event, collector) {
|
|
1442
1529
|
const { setHeaders, removeHeaders } = collector.emit();
|
|
@@ -1481,11 +1568,12 @@ export function registerBpWellKnownRoutes(app, manifest, bpSchema, options = {})
|
|
|
1481
1568
|
id = decodeURIComponent(encodedId);
|
|
1482
1569
|
}
|
|
1483
1570
|
catch {
|
|
1484
|
-
return
|
|
1571
|
+
return coreJsonResponse({ error: "invalid_resource_id" }, 400, "discovery.resource_id_invalid", "Developer resource id is not valid URL encoding");
|
|
1485
1572
|
}
|
|
1486
1573
|
const resource = manifest.developerResources.find((candidate) => candidate.id === id);
|
|
1487
|
-
if (!resource)
|
|
1488
|
-
return
|
|
1574
|
+
if (!resource) {
|
|
1575
|
+
return coreJsonResponse({ error: "resource_not_found" }, 404, "discovery.resource_not_found", `Developer resource "${id}" was not found`);
|
|
1576
|
+
}
|
|
1489
1577
|
return new Response(resource.content, {
|
|
1490
1578
|
headers: {
|
|
1491
1579
|
"content-type": resource.mediaType,
|