@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.
@@ -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,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;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;AA8HD,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;AA+4CD;;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"}
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"}
@@ -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 jsonResponse({ error: `Invalid path parameter: ${name}` }, 400);
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
- return jsonResponse({
56
+ const reason = error instanceof Error ? error.message : String(error);
57
+ return coreJsonResponse({
57
58
  error: "Invalid path parameters",
58
- detail: error instanceof Error ? error.message : String(error)
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 jsonResponse({ error: "Route not found" }, 404);
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 jsonResponse({ error: `No handler for ${method} ${route.path}` }, 405);
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 jsonResponse({ error: "Multipart payload too large" }, 413);
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 query = schemas.query ? schemas.query.parse(rawQuery) : rawQuery;
738
- const headers = schemas.headers ? schemas.headers.parse(rawHeaders) : rawHeaders;
739
- const request = (schemas.request && METHOD_WRITE_BODY.has(method))
740
- ? schemas.request.parse(rawBody)
741
- : rawBody;
742
- const multipart = schemas.multipart
743
- ? schemas.multipart.parse(rawMultipart ?? { fields: {}, files: {} })
744
- : undefined;
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 jsonResponse({ error: "BetterPortal tenant/app context required" }, 400);
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
- return jsonResponse({ error: `Route "${route.viewId}" has no ResponseSchema and did not return a raw Response` }, 500);
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 jsonResponse({ error: "NDJSON streaming is not supported by this view" }, 406);
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 jsonResponse({ error: "Renderer could not be resolved from the app shell" }, 406);
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
- const html = await withSpan(obs, "bp.view.render", {
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 jsonResponse({
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 jsonResponse({
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 jsonResponse({
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 jsonResponse({ error: authResult.error, status: authResult.status }, authResult.status);
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 jsonResponse({ error: "BetterPortal tenant/app context required" }, 400);
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 { status, error: status === 403 ? "Service access denied" : "Invalid service token" };
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 { status: 401, error: "Delegated calls require a BP user token and a service token" };
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 ? userResult : { status: 401, error: "Valid BP user token required" };
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 jsonResponse({
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 jsonResponse({ error: "invalid_resource_id" }, 400);
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 jsonResponse({ error: "resource_not_found" }, 404);
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,