@betterportal/framework 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +13 -0
  2. package/lib/adapters/h3.d.ts +51 -0
  3. package/lib/adapters/h3.d.ts.map +1 -0
  4. package/lib/adapters/h3.js +1120 -0
  5. package/lib/adapters/h3.js.map +1 -0
  6. package/lib/codegen/cli.d.ts +3 -0
  7. package/lib/codegen/cli.d.ts.map +1 -0
  8. package/lib/codegen/cli.js +82 -0
  9. package/lib/codegen/cli.js.map +1 -0
  10. package/lib/codegen/emitter.d.ts +7 -0
  11. package/lib/codegen/emitter.d.ts.map +1 -0
  12. package/lib/codegen/emitter.js +453 -0
  13. package/lib/codegen/emitter.js.map +1 -0
  14. package/lib/codegen/init.d.ts +3 -0
  15. package/lib/codegen/init.d.ts.map +1 -0
  16. package/lib/codegen/init.js +90 -0
  17. package/lib/codegen/init.js.map +1 -0
  18. package/lib/codegen/scanner.d.ts +56 -0
  19. package/lib/codegen/scanner.d.ts.map +1 -0
  20. package/lib/codegen/scanner.js +484 -0
  21. package/lib/codegen/scanner.js.map +1 -0
  22. package/lib/codegen/validate.d.ts +14 -0
  23. package/lib/codegen/validate.d.ts.map +1 -0
  24. package/lib/codegen/validate.js +166 -0
  25. package/lib/codegen/validate.js.map +1 -0
  26. package/lib/contracts/auth.d.ts +160 -0
  27. package/lib/contracts/auth.d.ts.map +1 -0
  28. package/lib/contracts/auth.js +123 -0
  29. package/lib/contracts/auth.js.map +1 -0
  30. package/lib/contracts/binding.d.ts +169 -0
  31. package/lib/contracts/binding.d.ts.map +1 -0
  32. package/lib/contracts/binding.js +69 -0
  33. package/lib/contracts/binding.js.map +1 -0
  34. package/lib/contracts/common.d.ts +23 -0
  35. package/lib/contracts/common.d.ts.map +1 -0
  36. package/lib/contracts/common.js +18 -0
  37. package/lib/contracts/common.js.map +1 -0
  38. package/lib/contracts/config.d.ts +93 -0
  39. package/lib/contracts/config.d.ts.map +1 -0
  40. package/lib/contracts/config.js +62 -0
  41. package/lib/contracts/config.js.map +1 -0
  42. package/lib/contracts/controlPlane.d.ts +63 -0
  43. package/lib/contracts/controlPlane.d.ts.map +1 -0
  44. package/lib/contracts/controlPlane.js +2 -0
  45. package/lib/contracts/controlPlane.js.map +1 -0
  46. package/lib/contracts/json.d.ts +9 -0
  47. package/lib/contracts/json.d.ts.map +1 -0
  48. package/lib/contracts/json.js +6 -0
  49. package/lib/contracts/json.js.map +1 -0
  50. package/lib/contracts/manifest.d.ts +158 -0
  51. package/lib/contracts/manifest.d.ts.map +1 -0
  52. package/lib/contracts/manifest.js +40 -0
  53. package/lib/contracts/manifest.js.map +1 -0
  54. package/lib/contracts/observability.d.ts +77 -0
  55. package/lib/contracts/observability.d.ts.map +1 -0
  56. package/lib/contracts/observability.js +99 -0
  57. package/lib/contracts/observability.js.map +1 -0
  58. package/lib/contracts/platformConfig.d.ts +635 -0
  59. package/lib/contracts/platformConfig.d.ts.map +1 -0
  60. package/lib/contracts/platformConfig.js +256 -0
  61. package/lib/contracts/platformConfig.js.map +1 -0
  62. package/lib/contracts/registry.d.ts +104 -0
  63. package/lib/contracts/registry.d.ts.map +1 -0
  64. package/lib/contracts/registry.js +2 -0
  65. package/lib/contracts/registry.js.map +1 -0
  66. package/lib/contracts/route.d.ts +199 -0
  67. package/lib/contracts/route.d.ts.map +1 -0
  68. package/lib/contracts/route.js +26 -0
  69. package/lib/contracts/route.js.map +1 -0
  70. package/lib/contracts/serviceConfig.d.ts +88 -0
  71. package/lib/contracts/serviceConfig.d.ts.map +1 -0
  72. package/lib/contracts/serviceConfig.js +45 -0
  73. package/lib/contracts/serviceConfig.js.map +1 -0
  74. package/lib/contracts/streaming.d.ts +76 -0
  75. package/lib/contracts/streaming.d.ts.map +1 -0
  76. package/lib/contracts/streaming.js +31 -0
  77. package/lib/contracts/streaming.js.map +1 -0
  78. package/lib/contracts/view.d.ts +149 -0
  79. package/lib/contracts/view.d.ts.map +1 -0
  80. package/lib/contracts/view.js +82 -0
  81. package/lib/contracts/view.js.map +1 -0
  82. package/lib/controlPlane/store.d.ts +24 -0
  83. package/lib/controlPlane/store.d.ts.map +1 -0
  84. package/lib/controlPlane/store.js +70 -0
  85. package/lib/controlPlane/store.js.map +1 -0
  86. package/lib/controlPlane/sync.d.ts +8 -0
  87. package/lib/controlPlane/sync.d.ts.map +1 -0
  88. package/lib/controlPlane/sync.js +24 -0
  89. package/lib/controlPlane/sync.js.map +1 -0
  90. package/lib/controlPlane/types.d.ts +15 -0
  91. package/lib/controlPlane/types.d.ts.map +1 -0
  92. package/lib/controlPlane/types.js +2 -0
  93. package/lib/controlPlane/types.js.map +1 -0
  94. package/lib/index.d.ts +40 -0
  95. package/lib/index.d.ts.map +1 -0
  96. package/lib/index.js +40 -0
  97. package/lib/index.js.map +1 -0
  98. package/lib/runtime/auth/envelope.d.ts +32 -0
  99. package/lib/runtime/auth/envelope.d.ts.map +1 -0
  100. package/lib/runtime/auth/envelope.js +123 -0
  101. package/lib/runtime/auth/envelope.js.map +1 -0
  102. package/lib/runtime/auth/issuer.d.ts +44 -0
  103. package/lib/runtime/auth/issuer.d.ts.map +1 -0
  104. package/lib/runtime/auth/issuer.js +82 -0
  105. package/lib/runtime/auth/issuer.js.map +1 -0
  106. package/lib/runtime/auth/jwks.d.ts +7 -0
  107. package/lib/runtime/auth/jwks.d.ts.map +1 -0
  108. package/lib/runtime/auth/jwks.js +69 -0
  109. package/lib/runtime/auth/jwks.js.map +1 -0
  110. package/lib/runtime/auth/keypair.d.ts +21 -0
  111. package/lib/runtime/auth/keypair.d.ts.map +1 -0
  112. package/lib/runtime/auth/keypair.js +50 -0
  113. package/lib/runtime/auth/keypair.js.map +1 -0
  114. package/lib/runtime/auth/tokens.d.ts +25 -0
  115. package/lib/runtime/auth/tokens.d.ts.map +1 -0
  116. package/lib/runtime/auth/tokens.js +137 -0
  117. package/lib/runtime/auth/tokens.js.map +1 -0
  118. package/lib/runtime/auth/verifier.d.ts +45 -0
  119. package/lib/runtime/auth/verifier.d.ts.map +1 -0
  120. package/lib/runtime/auth/verifier.js +76 -0
  121. package/lib/runtime/auth/verifier.js.map +1 -0
  122. package/lib/runtime/bpHeaders.d.ts +10 -0
  123. package/lib/runtime/bpHeaders.d.ts.map +1 -0
  124. package/lib/runtime/bpHeaders.js +53 -0
  125. package/lib/runtime/bpHeaders.js.map +1 -0
  126. package/lib/runtime/configProvider.d.ts +41 -0
  127. package/lib/runtime/configProvider.d.ts.map +1 -0
  128. package/lib/runtime/configProvider.js +232 -0
  129. package/lib/runtime/configProvider.js.map +1 -0
  130. package/lib/runtime/configStore.d.ts +34 -0
  131. package/lib/runtime/configStore.d.ts.map +1 -0
  132. package/lib/runtime/configStore.js +197 -0
  133. package/lib/runtime/configStore.js.map +1 -0
  134. package/lib/runtime/configTicket.d.ts +49 -0
  135. package/lib/runtime/configTicket.d.ts.map +1 -0
  136. package/lib/runtime/configTicket.js +168 -0
  137. package/lib/runtime/configTicket.js.map +1 -0
  138. package/lib/runtime/h3.d.ts +28 -0
  139. package/lib/runtime/h3.d.ts.map +1 -0
  140. package/lib/runtime/h3.js +199 -0
  141. package/lib/runtime/h3.js.map +1 -0
  142. package/lib/runtime/handler.d.ts +55 -0
  143. package/lib/runtime/handler.d.ts.map +1 -0
  144. package/lib/runtime/handler.js +51 -0
  145. package/lib/runtime/handler.js.map +1 -0
  146. package/lib/runtime/http.d.ts +13 -0
  147. package/lib/runtime/http.d.ts.map +1 -0
  148. package/lib/runtime/http.js +114 -0
  149. package/lib/runtime/http.js.map +1 -0
  150. package/lib/runtime/jsonSchema.d.ts +4 -0
  151. package/lib/runtime/jsonSchema.d.ts.map +1 -0
  152. package/lib/runtime/jsonSchema.js +28 -0
  153. package/lib/runtime/jsonSchema.js.map +1 -0
  154. package/lib/runtime/manifest.d.ts +3 -0
  155. package/lib/runtime/manifest.d.ts.map +1 -0
  156. package/lib/runtime/manifest.js +5 -0
  157. package/lib/runtime/manifest.js.map +1 -0
  158. package/lib/runtime/media.d.ts +20 -0
  159. package/lib/runtime/media.d.ts.map +1 -0
  160. package/lib/runtime/media.js +70 -0
  161. package/lib/runtime/media.js.map +1 -0
  162. package/lib/runtime/registry.d.ts +67 -0
  163. package/lib/runtime/registry.d.ts.map +1 -0
  164. package/lib/runtime/registry.js +290 -0
  165. package/lib/runtime/registry.js.map +1 -0
  166. package/lib/runtime/serviceConfig.d.ts +38 -0
  167. package/lib/runtime/serviceConfig.d.ts.map +1 -0
  168. package/lib/runtime/serviceConfig.js +152 -0
  169. package/lib/runtime/serviceConfig.js.map +1 -0
  170. package/lib/runtime/statusViews.d.ts +23 -0
  171. package/lib/runtime/statusViews.d.ts.map +1 -0
  172. package/lib/runtime/statusViews.js +48 -0
  173. package/lib/runtime/statusViews.js.map +1 -0
  174. package/lib/runtime/stream.d.ts +41 -0
  175. package/lib/runtime/stream.d.ts.map +1 -0
  176. package/lib/runtime/stream.js +92 -0
  177. package/lib/runtime/stream.js.map +1 -0
  178. package/lib/runtime/streamHandler.d.ts +48 -0
  179. package/lib/runtime/streamHandler.d.ts.map +1 -0
  180. package/lib/runtime/streamHandler.js +49 -0
  181. package/lib/runtime/streamHandler.js.map +1 -0
  182. package/lib/runtime/tenantResolution.d.ts +4 -0
  183. package/lib/runtime/tenantResolution.d.ts.map +1 -0
  184. package/lib/runtime/tenantResolution.js +19 -0
  185. package/lib/runtime/tenantResolution.js.map +1 -0
  186. package/lib/runtime/uuid.d.ts +6 -0
  187. package/lib/runtime/uuid.d.ts.map +1 -0
  188. package/lib/runtime/uuid.js +27 -0
  189. package/lib/runtime/uuid.js.map +1 -0
  190. package/lib/runtime/view.d.ts +48 -0
  191. package/lib/runtime/view.d.ts.map +1 -0
  192. package/lib/runtime/view.js +111 -0
  193. package/lib/runtime/view.js.map +1 -0
  194. package/package.json +56 -0
@@ -0,0 +1,1120 @@
1
+ import { createEventStream, getRequestIP, getRequestURL } from "h3";
2
+ import { isStreamHandler } from "../contracts/streaming.js";
3
+ import { driveStream, driveStreamBuffered, ndjsonStreamResponse } from "../runtime/stream.js";
4
+ import { acceptHeaderFromEvent, eventObservability, htmlResponse, jsonResponse } from "../runtime/h3.js";
5
+ import { toHtmlString } from "../runtime/http.js";
6
+ import { parseAcceptHeader, resolveRequestedRepresentation } from "../runtime/media.js";
7
+ import { resolveRenderer } from "../runtime/registry.js";
8
+ import { createBpHeadersCollector } from "../runtime/bpHeaders.js";
9
+ import { resolveStatusRenderer, shouldFallThroughToDefaultRenderer, statusForbidsBody } from "../runtime/statusViews.js";
10
+ const METHOD_WRITE_BODY = new Set(["POST", "PUT", "PATCH"]);
11
+ const MAX_BUFFERED_MULTIPART_BYTES = 25 * 1024 * 1024;
12
+ class MultipartTooLargeError extends Error {
13
+ constructor() {
14
+ super("Multipart payload exceeds buffered upload limit");
15
+ }
16
+ }
17
+ function methodRegistrar(app, method) {
18
+ switch (method) {
19
+ case "GET": return (p, h) => app.get(p, h);
20
+ case "POST": return (p, h) => app.post(p, h);
21
+ case "PUT": return (p, h) => app.put(p, h);
22
+ case "PATCH": return (p, h) => app.patch(p, h);
23
+ case "DELETE": return (p, h) => app.delete(p, h);
24
+ case "OPTIONS": return (p, h) => app.options(p, h);
25
+ }
26
+ }
27
+ function queryFromUrl(url) {
28
+ const result = {};
29
+ for (const [key, value] of url.searchParams.entries()) {
30
+ result[key] = value;
31
+ }
32
+ return result;
33
+ }
34
+ function headersFromEvent(event) {
35
+ const result = {};
36
+ const raw = event.req.headers;
37
+ if (raw instanceof Headers) {
38
+ raw.forEach((value, key) => { result[key] = value; });
39
+ }
40
+ return result;
41
+ }
42
+ function escapeContentDispositionValue(value) {
43
+ return value.replace(/["\\\r\n]/g, "_");
44
+ }
45
+ function responseHelper(body = null, init = {}) {
46
+ return new Response(body, init);
47
+ }
48
+ function fileResponseHelper(body, options = {}) {
49
+ const headers = new Headers(options.headers);
50
+ if (options.contentType && !headers.has("content-type"))
51
+ headers.set("content-type", options.contentType);
52
+ if (typeof options.size === "number" && !headers.has("content-length"))
53
+ headers.set("content-length", String(options.size));
54
+ if (options.filename && !headers.has("content-disposition")) {
55
+ headers.set("content-disposition", `${options.disposition ?? "attachment"}; filename="${escapeContentDispositionValue(options.filename)}"`);
56
+ }
57
+ return new Response(body, { status: options.status ?? 200, headers });
58
+ }
59
+ async function formDataToRequest(fd) {
60
+ const body = {};
61
+ const fields = {};
62
+ const files = {};
63
+ let totalFileBytes = 0;
64
+ const pushValue = (target, key, value) => {
65
+ const existing = target[key];
66
+ if (existing === undefined)
67
+ target[key] = value;
68
+ else if (Array.isArray(existing))
69
+ existing.push(value);
70
+ else
71
+ target[key] = [existing, value];
72
+ };
73
+ const pendingFiles = [];
74
+ fd.forEach((value, key) => {
75
+ if (typeof value === "string") {
76
+ body[key] = value;
77
+ pushValue(fields, key, value);
78
+ }
79
+ else {
80
+ body[key] = value.name;
81
+ pendingFiles.push((async () => {
82
+ totalFileBytes += value.size;
83
+ if (totalFileBytes > MAX_BUFFERED_MULTIPART_BYTES) {
84
+ throw new MultipartTooLargeError();
85
+ }
86
+ const file = {
87
+ fieldName: key,
88
+ filename: value.name,
89
+ contentType: value.type || "application/octet-stream",
90
+ size: value.size,
91
+ data: new Uint8Array(await value.arrayBuffer())
92
+ };
93
+ pushValue(files, key, file);
94
+ })());
95
+ }
96
+ });
97
+ await Promise.all(pendingFiles);
98
+ return { body, multipart: { fields, files } };
99
+ }
100
+ async function resolveRequiredHandlerContext(event, routerOptions) {
101
+ const extraContext = await routerOptions.resolveContext?.(event) ?? {};
102
+ return extraContext.tenant && extraContext.app
103
+ ? extraContext
104
+ : null;
105
+ }
106
+ /**
107
+ * Extract the `fragment` parameter from the Accept header.
108
+ * Format: `text/html; fragment=nav.profile`
109
+ */
110
+ function fragmentFromAcceptHeader(headerValue) {
111
+ const entries = parseAcceptHeader(headerValue);
112
+ for (const entry of entries) {
113
+ if (entry.mediaType === "text/html" && entry.parameters.fragment) {
114
+ return entry.parameters.fragment;
115
+ }
116
+ }
117
+ return undefined;
118
+ }
119
+ function chromeContentTypeParams(chrome) {
120
+ if (!chrome)
121
+ return "";
122
+ const params = [];
123
+ for (const [rawKey, value] of Object.entries(chrome)) {
124
+ if (!["string", "number", "boolean"].includes(typeof value))
125
+ continue;
126
+ if (typeof value === "number" && !Number.isFinite(value))
127
+ continue;
128
+ const key = rawKey
129
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
130
+ .replace(/[_\s]+/g, "-")
131
+ .toLowerCase();
132
+ if (!/^[a-z][a-z0-9-]*$/.test(key))
133
+ continue;
134
+ const stringValue = typeof value === "string" ? encodeURIComponent(value) : String(value);
135
+ params.push(`bp-chrome-${key}=${stringValue}`);
136
+ }
137
+ return params.length ? `; ${params.join("; ")}` : "";
138
+ }
139
+ function htmlContentType(themeId, mode, chrome) {
140
+ return `text/html; theme=${themeId}; mode=${mode}${chromeContentTypeParams(chrome)}`;
141
+ }
142
+ // -- Router registration ----------------------------------------------
143
+ /**
144
+ * Register all routes from a BetterPortalRegistry onto an H3 app.
145
+ *
146
+ * For each registered route and method, the adapter:
147
+ * 1. Parses and validates input (query, headers, body) against route schemas.
148
+ * 2. Calls the route handler to produce response data.
149
+ * 3. Content-negotiates the response (JSON, HTML page/fragment/component, or metadata).
150
+ */
151
+ export function createH3Router(registry, app, options = {}) {
152
+ for (const route of registry.routes) {
153
+ for (const method of route.methods) {
154
+ const register = methodRegistrar(app, method);
155
+ register(route.path, async (event) => {
156
+ const response = await withRequestObservability(event, route, method, options, (obs) => handleRouteRequest(registry.routes, route, method, event, obs, options));
157
+ // h3 only merges event.res.headers into 2xx responses - error responses
158
+ // would otherwise lose CORS and BP-SetHeader/RemoveHeader headers, which
159
+ // makes cross-origin 4xx unreadable by the browser entirely.
160
+ if (response instanceof Response && !response.ok) {
161
+ event.res.headers.forEach((value, name) => {
162
+ if (!response.headers.has(name))
163
+ response.headers.set(name, value);
164
+ });
165
+ }
166
+ return response;
167
+ });
168
+ }
169
+ // Streaming routes (createStreamHandler) expose their frame stream at
170
+ // `{path}/__sse` (spec/streaming.md section 2.3). A hand-written sse.ts wins if
171
+ // both exist.
172
+ const streamGetHandler = route.handlers.GET;
173
+ if (!route.sse && isStreamHandler(streamGetHandler)) {
174
+ app.get(`${route.path}/__sse`, async (event) => {
175
+ return withRequestObservability(event, route, "GET", options, (obs) => handleStreamSse(registry.routes, route, streamGetHandler, event, obs, options), { "bp.route.stream_sse": true });
176
+ });
177
+ }
178
+ if (route.sse) {
179
+ const sseHandler = route.sse.handler;
180
+ const tickSchema = route.sse.tickSchema;
181
+ app.get(`${route.path}/__sse`, async (event) => {
182
+ return withRequestObservability(event, route, "GET", options, async (obs) => {
183
+ const url = getRequestURL(event);
184
+ const rawQuery = queryFromUrl(url);
185
+ const query = route.schemas.query ? route.schemas.query.parse(rawQuery) : rawQuery;
186
+ const params = event.context?.params ?? {};
187
+ const result = sseHandler({
188
+ event,
189
+ params,
190
+ query: query,
191
+ ...(obs ? { obs } : {})
192
+ });
193
+ // Legacy path: handler manages its own stream -> returns Promise<BodyInit> | BodyInit
194
+ if (typeof result === "string"
195
+ || result instanceof ReadableStream
196
+ || result instanceof ArrayBuffer
197
+ || (typeof result === "object" && result !== null && typeof result.then === "function")) {
198
+ return result;
199
+ }
200
+ // Generator path: framework drives the stream.
201
+ if (typeof result === "object" && result !== null && Symbol.asyncIterator in result) {
202
+ // Resolve theme renderer if `?_f=loc.frag` provided. The theme MUST be
203
+ // disambiguated - with multiple themes registered, picking the first
204
+ // match would silently render another theme's fragment. Prefer the
205
+ // theme resolved from request context (__bpThemeId), then an explicit
206
+ // `?_theme=` pin; only fall back to a cross-theme scan when exactly one
207
+ // theme provides the fragment.
208
+ const fragmentKey = rawQuery._f ?? undefined;
209
+ let sseRender;
210
+ if (fragmentKey) {
211
+ const themeId = event.__bpThemeId
212
+ ?? rawQuery._theme;
213
+ if (themeId) {
214
+ const resolved = resolveRenderer(route, themeId, "fragment", undefined, undefined, fragmentKey);
215
+ if (resolved?.renderer.sseRender) {
216
+ sseRender = resolved.renderer.sseRender;
217
+ }
218
+ }
219
+ else {
220
+ // No theme context. Only render if the match is unambiguous across
221
+ // themes; otherwise leave it to the JSON passthrough rather than guess.
222
+ const matches = [];
223
+ for (const candidateThemeId of Object.keys(route.themeRenderers)) {
224
+ const resolved = resolveRenderer(route, candidateThemeId, "fragment", undefined, undefined, fragmentKey);
225
+ if (resolved?.renderer.sseRender) {
226
+ matches.push(resolved.renderer.sseRender);
227
+ }
228
+ }
229
+ if (matches.length === 1) {
230
+ sseRender = matches[0];
231
+ }
232
+ else if (matches.length > 1) {
233
+ obs?.logger.warn("BP SSE: ambiguous fragment '{fragmentKey}' across {count} themes and no theme context; sending raw ticks", { fragmentKey, count: matches.length });
234
+ }
235
+ }
236
+ }
237
+ const stream = createEventStream(event);
238
+ const iterable = result;
239
+ (async () => {
240
+ try {
241
+ for await (const raw of iterable) {
242
+ const data = tickSchema ? tickSchema.parse(raw) : raw;
243
+ const payload = sseRender
244
+ ? String(sseRender(data))
245
+ : typeof data === "string" ? data : JSON.stringify(data);
246
+ await stream.push({ data: payload });
247
+ }
248
+ }
249
+ catch {
250
+ // generator errored - close stream
251
+ }
252
+ await stream.close().catch(() => { });
253
+ })();
254
+ return stream.send();
255
+ }
256
+ // Unknown result shape - treat as legacy
257
+ return result;
258
+ }, { "bp.route.sse": true });
259
+ });
260
+ }
261
+ }
262
+ }
263
+ function requestAttributes(event, route, method, extra = {}) {
264
+ const requestUrl = getRequestURL(event);
265
+ const requestIp = getRequestIP(event, { xForwardedFor: true });
266
+ return {
267
+ "http.request.method": method,
268
+ "url.full": requestUrl.toString(),
269
+ "url.path": requestUrl.pathname,
270
+ "network.protocol.name": requestUrl.protocol.replace(":", ""),
271
+ "bp.route.path": route.path,
272
+ "bp.route.view_id": route.viewId,
273
+ ...(requestIp ? { "client.address": requestIp } : {}),
274
+ ...extra
275
+ };
276
+ }
277
+ function responseStatus(event, result) {
278
+ if (result instanceof Response)
279
+ return result.status;
280
+ return event.res.status || 200;
281
+ }
282
+ function roundedDuration(durationMs) {
283
+ return Math.round(durationMs * 100) / 100;
284
+ }
285
+ function logRequest(obs, route, method, status, durationMs) {
286
+ const attrs = {
287
+ method,
288
+ path: route.path,
289
+ status,
290
+ durationMs: roundedDuration(durationMs)
291
+ };
292
+ if (status >= 500) {
293
+ obs.logger.error("BetterPortal request failed: {method} {path} -> {status} in {durationMs}ms", attrs);
294
+ return;
295
+ }
296
+ if (status >= 400) {
297
+ obs.logger.warn("BetterPortal request completed: {method} {path} -> {status} in {durationMs}ms", attrs);
298
+ return;
299
+ }
300
+ obs.logger.info("BetterPortal request completed: {method} {path} -> {status} in {durationMs}ms", attrs);
301
+ }
302
+ function logNegotiationFailure(obs, route, method, reason, attributes = {}) {
303
+ if (!obs)
304
+ return;
305
+ obs.logger.warn("BetterPortal representation negotiation failed: {method} {path} -> {status} reason={reason}", {
306
+ method,
307
+ path: route.path,
308
+ status: 406,
309
+ reason,
310
+ "bp.route.view_id": route.viewId,
311
+ ...attributes
312
+ });
313
+ }
314
+ function normalizeRoutePath(path) {
315
+ const bare = path.split("?")[0]?.split("#")[0] ?? "/";
316
+ const normalized = `/${bare}`.replace(/\/+/g, "/").replace(/\/$/, "");
317
+ return normalized || "/";
318
+ }
319
+ function routePathsMatch(left, right) {
320
+ const a = normalizeRoutePath(left).split("/").filter(Boolean);
321
+ const b = normalizeRoutePath(right).split("/").filter(Boolean);
322
+ if (a.length !== b.length)
323
+ return false;
324
+ return a.every((segment, index) => {
325
+ const other = b[index];
326
+ return segment === other || segment.startsWith(":") || other.startsWith(":");
327
+ });
328
+ }
329
+ function routeMountServicePath(routeMount) {
330
+ return routeMount.resolvedServicePath ?? routeMount.targetPath;
331
+ }
332
+ function methodAllowed(methods, method) {
333
+ return (methods?.length ? methods : ["GET"]).some((candidate) => candidate.toUpperCase() === method);
334
+ }
335
+ function appAllowsRoute(app, route, method, url, acceptHeader) {
336
+ const appRoute = app.routes.find((candidate) => {
337
+ const servicePath = routeMountServicePath(candidate);
338
+ return candidate.enabled !== false
339
+ && candidate.viewId === route.viewId
340
+ && methodAllowed(candidate.methods, method)
341
+ && (!servicePath || routePathsMatch(servicePath, route.path));
342
+ });
343
+ if (appRoute)
344
+ return { allowed: true };
345
+ const fragmentKey = url.searchParams.get("_f") ?? fragmentFromAcceptHeader(acceptHeader);
346
+ if (method === "GET" && fragmentKey) {
347
+ const dot = fragmentKey.indexOf(".");
348
+ const location = dot > 0 ? fragmentKey.slice(0, dot) : "";
349
+ const fragmentId = dot > 0 ? fragmentKey.slice(dot + 1) : fragmentKey;
350
+ const fragment = (location ? app.fragments[location] ?? [] : Object.values(app.fragments).flat()).find((candidate) => candidate.enabled !== false
351
+ && candidate.fragmentId === fragmentId
352
+ && routePathsMatch(candidate.targetPath, route.path));
353
+ if (fragment)
354
+ return { allowed: true };
355
+ }
356
+ if (method === "GET") {
357
+ const slot = app.slots.find((candidate) => candidate.enabled !== false
358
+ && candidate.viewId === route.viewId);
359
+ if (slot)
360
+ return { allowed: true };
361
+ }
362
+ return { allowed: false, reason: "route_not_mounted_for_app" };
363
+ }
364
+ function pathParamName(segment) {
365
+ if (segment.startsWith(":") && segment.length > 1)
366
+ return segment.slice(1);
367
+ const match = segment.match(/^\{([A-Za-z_][A-Za-z0-9_]*)\}$/);
368
+ return match?.[1] ?? null;
369
+ }
370
+ function fillAppPath(path, params = {}) {
371
+ const [pathPart, queryPart] = path.split("?", 2);
372
+ const resolved = pathPart.split("/").map((segment) => {
373
+ const name = pathParamName(segment);
374
+ const value = name ? params[name] : undefined;
375
+ return name && value !== null && value !== undefined ? encodeURIComponent(String(value)) : segment;
376
+ }).join("/");
377
+ return queryPart ? `${resolved}?${queryPart}` : resolved;
378
+ }
379
+ function appOrigin(app, override) {
380
+ const raw = (override ?? app.hostnames[0] ?? "").replace(/\/+$/, "");
381
+ if (raw.startsWith("http://") || raw.startsWith("https://"))
382
+ return raw;
383
+ return `https://${raw}`;
384
+ }
385
+ function serviceOrigin(extraContext, serviceId, override) {
386
+ if (override)
387
+ return appOrigin(extraContext.app, override);
388
+ const service = extraContext.tenant.services.find((candidate) => candidate.enabled && (candidate.id === serviceId || candidate.serviceId === serviceId));
389
+ return service ? service.hostname.replace(/\/+$/, "") : null;
390
+ }
391
+ function appendQuery(path, query = {}, absoluteOrigin) {
392
+ const url = new URL(path, absoluteOrigin ?? "http://bp.local");
393
+ for (const [key, value] of Object.entries(query)) {
394
+ if (value !== null && value !== undefined)
395
+ url.searchParams.set(key, String(value));
396
+ }
397
+ return absoluteOrigin ? url.toString() : `${url.pathname}${url.search}`;
398
+ }
399
+ function createServiceRouteUrlBuilder(routes, extraContext, currentServiceId) {
400
+ return (viewId, options = {}) => {
401
+ const targetServiceId = options.serviceId ?? currentServiceId;
402
+ const route = routes.find((candidate) => candidate.viewId === viewId);
403
+ if (!route)
404
+ return null;
405
+ const origin = options.absolute && targetServiceId ? serviceOrigin(extraContext, targetServiceId, options.origin) : undefined;
406
+ if (options.absolute && !origin)
407
+ return null;
408
+ return appendQuery(fillAppPath(route.path, options.params), options.query, origin ?? undefined);
409
+ };
410
+ }
411
+ function createUiRouteUrlBuilder(extraContext, currentServiceId) {
412
+ return (viewId, options = {}) => {
413
+ const targetServiceId = options.serviceId ?? currentServiceId;
414
+ if (!targetServiceId)
415
+ return null;
416
+ const serviceIds = new Set([targetServiceId]);
417
+ for (const service of extraContext.tenant.services) {
418
+ if (service.enabled && (service.id === targetServiceId || service.serviceId === targetServiceId)) {
419
+ serviceIds.add(service.id);
420
+ }
421
+ }
422
+ const route = extraContext.app.routes.find((candidate) => candidate.enabled !== false
423
+ && candidate.viewId === viewId
424
+ && serviceIds.has(candidate.serviceId));
425
+ if (!route)
426
+ return null;
427
+ return appendQuery(fillAppPath(route.path, options.params), options.query, options.absolute ? appOrigin(extraContext.app, options.origin) : undefined);
428
+ };
429
+ }
430
+ function rejectUnallowedAppRoute(obs, route, method, extraContext, reason) {
431
+ obs?.logger.warn("BP route rejected by app allowlist: tenant={tenantId} app={appId} route={viewId} method={method} reason={reason}", {
432
+ tenantId: extraContext.tenant.id,
433
+ appId: extraContext.app.id,
434
+ viewId: route.viewId,
435
+ method,
436
+ reason,
437
+ "bp.route.view_id": route.viewId,
438
+ "bp.route.path": route.path,
439
+ "bp.app.id": extraContext.app.id,
440
+ "bp.tenant.id": extraContext.tenant.id,
441
+ "bp.route_allowlist.reason": reason
442
+ });
443
+ return jsonResponse({ error: "Route not found" }, 404);
444
+ }
445
+ async function withRequestObservability(event, route, method, options, handler, extraAttributes = {}) {
446
+ const startedAt = performance.now();
447
+ const eventObs = eventObservability(event);
448
+ const ownsObs = !eventObs;
449
+ const obs = eventObs ?? options.createRequestObservability?.("bp.http.request", requestAttributes(event, route, method, extraAttributes));
450
+ try {
451
+ const result = await handler(obs);
452
+ if (obs && ownsObs) {
453
+ const status = responseStatus(event, result);
454
+ const durationMs = performance.now() - startedAt;
455
+ logRequest(obs, route, method, status, durationMs);
456
+ obs.end({
457
+ "http.response.status_code": status,
458
+ "duration.ms": roundedDuration(durationMs)
459
+ });
460
+ }
461
+ return result;
462
+ }
463
+ catch (error) {
464
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
465
+ if (obs) {
466
+ const durationMs = performance.now() - startedAt;
467
+ obs.error(normalizedError, { "error.name": normalizedError.name });
468
+ if (ownsObs) {
469
+ logRequest(obs, route, method, event.res.status || 500, durationMs);
470
+ obs.end({
471
+ "http.response.status_code": event.res.status || 500,
472
+ "duration.ms": roundedDuration(durationMs)
473
+ });
474
+ }
475
+ }
476
+ throw error;
477
+ }
478
+ }
479
+ async function withSpan(obs, name, attributes, handler) {
480
+ if (!obs)
481
+ return handler();
482
+ const startedAt = performance.now();
483
+ const span = obs.startSpan(name, attributes);
484
+ try {
485
+ const result = await handler();
486
+ span.end({ "duration.ms": roundedDuration(performance.now() - startedAt) });
487
+ return result;
488
+ }
489
+ catch (error) {
490
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
491
+ span.error(normalizedError, { "error.name": normalizedError.name });
492
+ span.end({ "duration.ms": roundedDuration(performance.now() - startedAt) });
493
+ throw error;
494
+ }
495
+ }
496
+ async function handleRouteRequest(registryRoutes, route, method, event, obs, routerOptions = {}) {
497
+ const handler = route.handlers[method];
498
+ if (!handler) {
499
+ return jsonResponse({ error: `No handler for ${method} ${route.path}` }, 405);
500
+ }
501
+ // -- Parse inputs -------------------------------------------------
502
+ const url = getRequestURL(event);
503
+ const rawQuery = queryFromUrl(url);
504
+ const rawHeaders = headersFromEvent(event);
505
+ let rawBody = {};
506
+ let rawMultipart;
507
+ if (METHOD_WRITE_BODY.has(method)) {
508
+ const contentType = (event.req.headers.get("content-type") || "").toLowerCase();
509
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
510
+ // Standard HTML form submission (incl. plain hx-post). Parse into a flat object.
511
+ try {
512
+ const fd = await event.req.formData();
513
+ const parsedForm = await formDataToRequest(fd);
514
+ rawBody = parsedForm.body;
515
+ rawMultipart = parsedForm.multipart;
516
+ }
517
+ catch (err) {
518
+ if (err instanceof MultipartTooLargeError) {
519
+ return jsonResponse({ error: "Multipart payload too large" }, 413);
520
+ }
521
+ rawBody = {};
522
+ }
523
+ }
524
+ else {
525
+ const parsed = await event.req.json().catch(() => null);
526
+ rawBody = (parsed && typeof parsed === "object" && !Array.isArray(parsed))
527
+ ? parsed
528
+ : {};
529
+ }
530
+ }
531
+ // -- Validate against schemas -------------------------------------
532
+ // RequestSchema is only enforced for methods that carry a body. GET/DELETE/OPTIONS
533
+ // pass rawBody (empty {}) through unparsed so routes with both GET + POST handlers
534
+ // don't fail GET because POST's RequestSchema has required fields.
535
+ const query = route.schemas.query ? route.schemas.query.parse(rawQuery) : rawQuery;
536
+ const headers = route.schemas.headers ? route.schemas.headers.parse(rawHeaders) : rawHeaders;
537
+ const request = (route.schemas.request && METHOD_WRITE_BODY.has(method))
538
+ ? route.schemas.request.parse(rawBody)
539
+ : rawBody;
540
+ const multipart = route.schemas.multipart
541
+ ? route.schemas.multipart.parse(rawMultipart ?? { fields: {}, files: {} })
542
+ : undefined;
543
+ // Path params - H3 populates event.context.params for `:paramName` routes
544
+ const params = event.context?.params ?? {};
545
+ const extraContext = await resolveRequiredHandlerContext(event, routerOptions);
546
+ if (!extraContext) {
547
+ return jsonResponse({ error: "BetterPortal tenant/app context required" }, 400);
548
+ }
549
+ const routeAllowlistAcceptHeader = acceptHeaderFromEvent(event);
550
+ const routeAllowance = appAllowsRoute(extraContext.app, route, method, url, routeAllowlistAcceptHeader);
551
+ if (!routeAllowance.allowed) {
552
+ return rejectUnallowedAppRoute(obs, route, method, extraContext, routeAllowance.reason ?? "route_not_mounted_for_app");
553
+ }
554
+ // -- Auth resolution (per spec section 0.5) ----------------------
555
+ const apiAuth = route.auth;
556
+ const authResolved = await loadAuthContext(event, routerOptions, obs);
557
+ const authResult = await resolveRequestAuth(apiAuth, event, authResolved, obs);
558
+ if (authResult.error) {
559
+ return renderAuthError(route, event, authResult.status, authResult.error);
560
+ }
561
+ // -- Tenant/app activation check (validateTenantApp hook -> 426) -----
562
+ const tenantApp = readTenantAppFromEvent(event);
563
+ if (tenantApp && routerOptions.validateTenantApp) {
564
+ try {
565
+ const validation = await routerOptions.validateTenantApp(tenantApp.tenantId, tenantApp.appId);
566
+ if (!validation.allowed) {
567
+ obs?.logger.warn("Tenant-app validation rejected: tenant={tenantId} app={appId} reason={reason}", {
568
+ tenantId: tenantApp.tenantId,
569
+ appId: tenantApp.appId,
570
+ reason: validation.reason ?? "(unspecified)"
571
+ });
572
+ return renderUpgradeRequired(route, event, validation);
573
+ }
574
+ }
575
+ catch (err) {
576
+ obs?.logger.warn("validateTenantApp threw: {msg}", { msg: err.message });
577
+ // Fail-open: validation error treated as block.
578
+ return renderUpgradeRequired(route, event, {
579
+ allowed: false,
580
+ reason: "Tenant-app validation error"
581
+ });
582
+ }
583
+ }
584
+ // -- Build context and invoke handler -----------------------------
585
+ const bpHeaders = createBpHeadersCollector();
586
+ const ctx = {
587
+ params,
588
+ query: query,
589
+ headers: headers,
590
+ request: request,
591
+ multipart: multipart,
592
+ method,
593
+ path: url.pathname,
594
+ rawEvent: event,
595
+ user: authResult.user,
596
+ ...extraContext,
597
+ bpHeaders,
598
+ responseHeaders: event.res.headers,
599
+ setStatus: (status) => { event.res.status = status; },
600
+ serviceId: routerOptions.serviceId,
601
+ routeUrl: createServiceRouteUrlBuilder(registryRoutes, extraContext, routerOptions.serviceId),
602
+ uiRouteUrl: createUiRouteUrlBuilder(extraContext, routerOptions.serviceId),
603
+ response: responseHelper,
604
+ file: fileResponseHelper,
605
+ ...(obs ? { obs } : {})
606
+ };
607
+ let rawData;
608
+ if (isStreamHandler(handler)) {
609
+ // Streamed representations (NDJSON, themed streaming shell) respond
610
+ // directly; buffered representations fall through to the standard
611
+ // negotiation over the derived { items, summary? } shape.
612
+ const streamed = await handleStreamRepresentation(route, handler, ctx, event, url, method, obs);
613
+ if (streamed) {
614
+ applyBpHeadersToEvent(event, bpHeaders);
615
+ return streamed;
616
+ }
617
+ rawData = await withSpan(obs, "bp.route.handler", {
618
+ "bp.route.view_id": route.viewId,
619
+ "bp.route.path": route.path,
620
+ "http.request.method": method,
621
+ "bp.route.stream_buffered": true
622
+ }, () => driveStreamBuffered(handler, ctx));
623
+ }
624
+ else {
625
+ rawData = await withSpan(obs, "bp.route.handler", {
626
+ "bp.route.view_id": route.viewId,
627
+ "bp.route.path": route.path,
628
+ "http.request.method": method
629
+ }, () => handler(ctx));
630
+ }
631
+ // -- Emit BP-managed headers -------------------------------------
632
+ applyBpHeadersToEvent(event, bpHeaders);
633
+ if (rawData instanceof Response) {
634
+ return rawData;
635
+ }
636
+ // -- Status decision ---------------------------------------------
637
+ const handlerStatus = event.res.status && event.res.status !== 0 ? event.res.status : 200;
638
+ // -- Content negotiation ------------------------------------------
639
+ const acceptHeader = acceptHeaderFromEvent(event);
640
+ const representation = resolveRequestedRepresentation(acceptHeader);
641
+ // Metadata
642
+ if (representation.kind === "metadata") {
643
+ return jsonResponse({
644
+ viewId: route.viewId,
645
+ title: route.title,
646
+ description: route.description,
647
+ path: route.path,
648
+ methods: [...route.methods],
649
+ auth: route.auth,
650
+ cacheHints: route.cacheHints
651
+ }, 200, {
652
+ "content-type": "application/vnd.betterportal.metadata+json; charset=utf-8"
653
+ });
654
+ }
655
+ // For non-success status codes that forbid a body, return empty.
656
+ if (statusForbidsBody(handlerStatus)) {
657
+ return new Response(null, { status: handlerStatus });
658
+ }
659
+ // -- Validate response against schema (all representations) ------
660
+ // Skipped when status indicates no body is expected.
661
+ if (!route.schemas.response) {
662
+ return jsonResponse({ error: `Route "${route.viewId}" has no ResponseSchema and did not return a raw Response` }, 500);
663
+ }
664
+ const data = route.schemas.response.parse(rawData);
665
+ // NDJSON only exists for streaming views; those were handled before
666
+ // negotiation, so reaching here means the view does not stream.
667
+ if (representation.kind === "ndjson") {
668
+ logNegotiationFailure(obs, route, method, "ndjson_not_streaming", {
669
+ "http.request.accept": acceptHeader ?? "",
670
+ "bp.representation.kind": representation.kind
671
+ });
672
+ return jsonResponse({ error: "NDJSON streaming is not supported by this view" }, 406);
673
+ }
674
+ // JSON - already validated above, no redundant parse
675
+ if (representation.kind === "json") {
676
+ return jsonResponse(data, handlerStatus);
677
+ }
678
+ // HTML - resolve theme from request context (hostname -> app config), Accept header as fallback
679
+ const themeId = event.__bpThemeId
680
+ ?? representation.theme;
681
+ if (!themeId) {
682
+ logNegotiationFailure(obs, route, method, "theme_not_resolved", {
683
+ "http.request.accept": acceptHeader ?? "",
684
+ "bp.representation.kind": representation.kind
685
+ });
686
+ return jsonResponse({ error: "Theme could not be resolved from app config or request" }, 406);
687
+ }
688
+ // Determine the renderer kind requested
689
+ const fragmentKey = url.searchParams.get("_f") ?? fragmentFromAcceptHeader(acceptHeader);
690
+ const componentId = url.searchParams.get("_c");
691
+ const requestedKind = fragmentKey ? "fragment" : componentId ? "component" : "page";
692
+ const requestedKey = fragmentKey ?? componentId ?? undefined;
693
+ // Status-specific renderer lookup (any non-undefined status code)
694
+ if (handlerStatus !== 200) {
695
+ const statusRenderer = resolveStatusRenderer(route, themeId, handlerStatus, requestedKind, requestedKey);
696
+ if (statusRenderer) {
697
+ const html = await withSpan(obs, "bp.view.render", {
698
+ "bp.route.view_id": route.viewId,
699
+ "bp.view.theme_id": themeId,
700
+ "bp.view.kind": requestedKind,
701
+ "bp.view.status": handlerStatus
702
+ }, () => statusRenderer.render(data));
703
+ return htmlResponse(toHtmlString(html), handlerStatus, htmlContentType(themeId, "status", route.chrome));
704
+ }
705
+ // No specific renderer found.
706
+ if (!shouldFallThroughToDefaultRenderer(handlerStatus)) {
707
+ // 4xx/5xx without a specific renderer -> empty body with status.
708
+ return new Response(null, { status: handlerStatus });
709
+ }
710
+ // 2xx without specific -> fall through to default renderer, but keep handlerStatus.
711
+ }
712
+ // Fragment request via `_f` query param or Accept header
713
+ if (fragmentKey) {
714
+ const resolved = resolveRenderer(route, themeId, "fragment", method, undefined, fragmentKey);
715
+ if (!resolved) {
716
+ logNegotiationFailure(obs, route, method, "fragment_renderer_not_found", {
717
+ "http.request.accept": acceptHeader ?? "",
718
+ "bp.view.theme_id": themeId,
719
+ "bp.view.kind": "fragment",
720
+ "bp.view.key": fragmentKey
721
+ });
722
+ return jsonResponse({
723
+ error: `No fragment renderer found for fragment="${fragmentKey}" in theme "${themeId}"`
724
+ }, 406);
725
+ }
726
+ const html = await withSpan(obs, "bp.view.render", {
727
+ "bp.route.view_id": route.viewId,
728
+ "bp.view.theme_id": themeId,
729
+ "bp.view.kind": "fragment",
730
+ "bp.view.key": fragmentKey
731
+ }, () => resolved.renderer.render(data));
732
+ return htmlResponse(toHtmlString(html), handlerStatus, htmlContentType(themeId, "fragment", route.chrome));
733
+ }
734
+ // Component request via `_c` query param
735
+ if (componentId) {
736
+ const resolved = resolveRenderer(route, themeId, "component", method, componentId);
737
+ if (!resolved) {
738
+ logNegotiationFailure(obs, route, method, "component_renderer_not_found", {
739
+ "http.request.accept": acceptHeader ?? "",
740
+ "bp.view.theme_id": themeId,
741
+ "bp.view.kind": "component",
742
+ "bp.view.key": componentId
743
+ });
744
+ return jsonResponse({
745
+ error: `No component renderer found for _c="${componentId}" in theme "${themeId}"`
746
+ }, 406);
747
+ }
748
+ const html = await withSpan(obs, "bp.view.render", {
749
+ "bp.route.view_id": route.viewId,
750
+ "bp.view.theme_id": themeId,
751
+ "bp.view.kind": "component",
752
+ "bp.view.key": componentId
753
+ }, () => resolved.renderer.render(data));
754
+ return htmlResponse(toHtmlString(html), handlerStatus, htmlContentType(themeId, "fragment", route.chrome));
755
+ }
756
+ // Page request - only page renderers allowed
757
+ const resolved = resolveRenderer(route, themeId, "page", method);
758
+ if (!resolved) {
759
+ logNegotiationFailure(obs, route, method, "page_renderer_not_found", {
760
+ "http.request.accept": acceptHeader ?? "",
761
+ "bp.view.theme_id": themeId,
762
+ "bp.view.kind": "page"
763
+ });
764
+ return jsonResponse({
765
+ error: `No page renderer found for theme "${themeId}"`
766
+ }, 406);
767
+ }
768
+ const html = await withSpan(obs, "bp.view.render", {
769
+ "bp.route.view_id": route.viewId,
770
+ "bp.view.theme_id": themeId,
771
+ "bp.view.kind": "page"
772
+ }, () => resolved.renderer.render(data));
773
+ const mode = representation.mode ?? "page";
774
+ return htmlResponse(toHtmlString(html), handlerStatus, htmlContentType(themeId, mode, route.chrome));
775
+ }
776
+ /**
777
+ * Handle representations that stream, returning null for buffered ones so the
778
+ * caller falls through to standard negotiation over `{ items, summary? }`.
779
+ */
780
+ async function handleStreamRepresentation(route, handler, ctx, event, url, method, obs) {
781
+ const acceptHeader = acceptHeaderFromEvent(event);
782
+ const representation = resolveRequestedRepresentation(acceptHeader);
783
+ if (representation.kind === "ndjson") {
784
+ return ndjsonStreamResponse(handler, ctx);
785
+ }
786
+ if (representation.kind !== "html")
787
+ return null;
788
+ // Fragment/component selectors render over the buffered data set.
789
+ if (url.searchParams.get("_f") || url.searchParams.get("_c"))
790
+ return null;
791
+ const themeId = event.__bpThemeId
792
+ ?? representation.theme;
793
+ if (!themeId)
794
+ return null;
795
+ const streamSet = route.themeRenderers[themeId]?.stream;
796
+ if (!streamSet)
797
+ return null;
798
+ // Full-page request with a page renderer available -> buffered render of the
799
+ // complete data set (crawlers, no-SSE clients). Fragment swaps stream.
800
+ if (representation.mode === "page" && resolveRenderer(route, themeId, "page", method)) {
801
+ return null;
802
+ }
803
+ const shellCtx = {
804
+ sseConnectPath: `${url.pathname}/__sse${url.search}`,
805
+ params: ctx.params,
806
+ query: ctx.query
807
+ };
808
+ const html = await withSpan(obs, "bp.view.render", {
809
+ "bp.route.view_id": route.viewId,
810
+ "bp.view.theme_id": themeId,
811
+ "bp.view.kind": "stream-shell"
812
+ }, () => streamSet.renderShell(shellCtx));
813
+ return htmlResponse(toHtmlString(html), 200, htmlContentType(themeId, "fragment", route.chrome));
814
+ }
815
+ /**
816
+ * SSE delivery of the frame stream at `{path}/__sse`. With a theme context and
817
+ * stream renderers, event payloads are server-rendered HTML; otherwise frame
818
+ * JSON (spec/streaming.md section 2.3, section 4.1). Runs the generator itself - no stream
819
+ * state is shared with the shell request.
820
+ */
821
+ async function handleStreamSse(registryRoutes, route, handler, event, obs, routerOptions) {
822
+ const url = getRequestURL(event);
823
+ const rawQuery = queryFromUrl(url);
824
+ const query = route.schemas.query ? route.schemas.query.parse(rawQuery) : rawQuery;
825
+ const params = event.context?.params ?? {};
826
+ // The frame stream carries the same data as the view route - enforce the
827
+ // same auth requirement.
828
+ const authResolved = await loadAuthContext(event, routerOptions, obs);
829
+ const authResult = await resolveRequestAuth(route.auth, event, authResolved, obs);
830
+ if (authResult.error) {
831
+ return jsonResponse({ error: authResult.error, status: authResult.status }, authResult.status);
832
+ }
833
+ const extraContext = await resolveRequiredHandlerContext(event, routerOptions);
834
+ if (!extraContext) {
835
+ return jsonResponse({ error: "BetterPortal tenant/app context required" }, 400);
836
+ }
837
+ const ctx = {
838
+ params,
839
+ query: query,
840
+ headers: headersFromEvent(event),
841
+ request: {},
842
+ method: "GET",
843
+ path: url.pathname,
844
+ rawEvent: event,
845
+ user: authResult.user,
846
+ ...extraContext,
847
+ serviceId: routerOptions.serviceId,
848
+ routeUrl: createServiceRouteUrlBuilder(registryRoutes, extraContext, routerOptions.serviceId),
849
+ uiRouteUrl: createUiRouteUrlBuilder(extraContext, routerOptions.serviceId),
850
+ response: responseHelper,
851
+ file: fileResponseHelper,
852
+ ...(obs ? { obs } : {})
853
+ };
854
+ const themeId = event.__bpThemeId
855
+ ?? rawQuery._theme;
856
+ const streamSet = themeId ? route.themeRenderers[themeId]?.stream : undefined;
857
+ const stream = createEventStream(event);
858
+ (async () => {
859
+ try {
860
+ await driveStream(handler, ctx, {
861
+ onItem: async (item) => {
862
+ await stream.push({
863
+ event: "item",
864
+ data: streamSet
865
+ ? toHtmlString(streamSet.renderItem(item))
866
+ : JSON.stringify({ kind: "item", data: item })
867
+ });
868
+ },
869
+ onSummary: async (summary) => {
870
+ if (streamSet && !streamSet.renderSummary)
871
+ return;
872
+ await stream.push({
873
+ event: "summary",
874
+ data: streamSet?.renderSummary
875
+ ? toHtmlString(streamSet.renderSummary(summary))
876
+ : JSON.stringify({ kind: "summary", data: summary })
877
+ });
878
+ },
879
+ onError: async (frame) => {
880
+ await stream.push({
881
+ event: "error",
882
+ data: streamSet?.renderError
883
+ ? toHtmlString(streamSet.renderError(frame))
884
+ : JSON.stringify(frame)
885
+ });
886
+ },
887
+ onEnd: async (count) => {
888
+ await stream.push({
889
+ event: "end",
890
+ data: streamSet ? "" : JSON.stringify({ kind: "end", count })
891
+ });
892
+ }
893
+ });
894
+ }
895
+ catch (error) {
896
+ // client disconnected mid-stream or push failed - nothing left to report
897
+ obs?.logger.warn("BP stream SSE aborted: {msg}", { msg: error.message });
898
+ }
899
+ await stream.close().catch(() => { });
900
+ })();
901
+ return stream.send();
902
+ }
903
+ async function loadAuthContext(event, routerOptions, obs) {
904
+ try {
905
+ return await routerOptions.resolveAuth?.(event);
906
+ }
907
+ catch (err) {
908
+ obs?.logger.warn("Auth resolver threw: {msg}", { msg: err.message });
909
+ return undefined;
910
+ }
911
+ }
912
+ /**
913
+ * Resolve authentication and authorization per spec section 0.5.
914
+ * Returns either a validated user (or undefined for anonymous) or an error.
915
+ */
916
+ async function resolveRequestAuth(apiAuth, event, authContext, obs) {
917
+ const required = apiAuth.required;
918
+ const authHeader = event.req.headers.get("authorization");
919
+ const bearer = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
920
+ // Step 1: no token
921
+ if (!bearer) {
922
+ if (required)
923
+ return { status: 401, error: "Authentication required" };
924
+ return { status: 200 };
925
+ }
926
+ if (!authContext) {
927
+ if (required)
928
+ return { status: 503, error: "Auth context unavailable" };
929
+ return { status: 200 };
930
+ }
931
+ // Step 2-4: verify JWT (signature + double-verify happens inside verifier)
932
+ let claims;
933
+ try {
934
+ claims = await withSpan(obs, "bp.auth.verify_token", {
935
+ "bp.auth.required": required,
936
+ "bp.auth.tenant_id": authContext.tenantId,
937
+ "bp.auth.app_id": authContext.appId
938
+ }, () => authContext.verifier.verify(bearer, {
939
+ tenantId: authContext.tenantId,
940
+ appId: authContext.appId
941
+ }));
942
+ }
943
+ catch (err) {
944
+ obs?.logger.warn("JWT verification failed: {msg}", { msg: err.message });
945
+ if (required)
946
+ return { status: 401, error: "Invalid token" };
947
+ return { status: 200 };
948
+ }
949
+ // Step 5: tenant binding
950
+ if (claims.tenantId !== authContext.tenantId) {
951
+ obs?.logger.warn("JWT tenantId mismatch: token={t1} request={t2}", {
952
+ t1: claims.tenantId,
953
+ t2: authContext.tenantId
954
+ });
955
+ if (required)
956
+ return { status: 401, error: "Token bound to a different tenant" };
957
+ return { status: 200 };
958
+ }
959
+ // Step 6: app binding
960
+ if (claims.appId !== authContext.appId) {
961
+ obs?.logger.warn("JWT appId mismatch: token={a1} request={a2}", {
962
+ a1: claims.appId,
963
+ a2: authContext.appId
964
+ });
965
+ if (required)
966
+ return { status: 401, error: "Token bound to a different app" };
967
+ return { status: 200 };
968
+ }
969
+ // Step 7: permission check against app.auth.roles
970
+ if (apiAuth.permissions.length > 0) {
971
+ const granted = expandRolesToPermissions(claims.roles, authContext.appAuthConfig);
972
+ // Grants reference tenant service-instance ids; route requirements are
973
+ // authored against pluginIds. Treat them as equal via the alias map.
974
+ const aliases = authContext.serviceIdAliases ?? {};
975
+ const serviceIdsMatch = (grantServiceId, requiredServiceId) => grantServiceId === requiredServiceId ||
976
+ aliases[grantServiceId] === requiredServiceId ||
977
+ aliases[requiredServiceId] === grantServiceId;
978
+ const ok = apiAuth.permissions.every((requirement) => requirement.permissions.every((action) => granted.some((grant) => serviceIdsMatch(grant.serviceId, requirement.serviceId) &&
979
+ grant.viewId === requirement.viewId &&
980
+ grant.permissions.includes(action))));
981
+ if (!ok) {
982
+ if (required)
983
+ return { status: 403, error: "Insufficient permissions" };
984
+ return { status: 200 };
985
+ }
986
+ }
987
+ // Step 8: attach validated claims
988
+ return { status: 200, user: claims };
989
+ }
990
+ function expandRolesToPermissions(roleIds, appAuthConfig) {
991
+ if (!appAuthConfig)
992
+ return [];
993
+ const grants = [];
994
+ for (const role of appAuthConfig.roles) {
995
+ if (!roleIds.includes(role.id))
996
+ continue;
997
+ for (const grant of role.permissions) {
998
+ grants.push({
999
+ serviceId: grant.serviceId,
1000
+ viewId: grant.viewId,
1001
+ permissions: [...grant.permissions]
1002
+ });
1003
+ }
1004
+ }
1005
+ return grants;
1006
+ }
1007
+ function corsHeadersFromEvent(event) {
1008
+ const out = {};
1009
+ const ev = event;
1010
+ const headers = ev.res?.headers;
1011
+ if (!headers)
1012
+ return out;
1013
+ if (typeof headers.forEach === "function") {
1014
+ headers.forEach((value, name) => {
1015
+ if (name.toLowerCase().startsWith("access-control-") || name.toLowerCase() === "vary") {
1016
+ out[name] = value;
1017
+ }
1018
+ });
1019
+ }
1020
+ return out;
1021
+ }
1022
+ function renderAuthError(route, event, status, message) {
1023
+ const themeId = event.__bpThemeId;
1024
+ const acceptHeader = acceptHeaderFromEvent(event);
1025
+ const representation = resolveRequestedRepresentation(acceptHeader);
1026
+ const corsHeaders = corsHeadersFromEvent(event);
1027
+ // Auth errors NEVER emit navigation headers (HX-Location / HX-Redirect). A
1028
+ // service has no reliable knowledge of where the auth provider lives - it only
1029
+ // knows the JWKS for token *validation*, not a URL the browser should navigate
1030
+ // to - and letting it drive a whole-page redirect corrupts the host shell.
1031
+ // Login routing belongs to the theme, which resolves the auth service URL from
1032
+ // app.auth config and redirects on seeing this 401. Services just report status.
1033
+ // Prefer a route/theme status view so the body swaps cleanly into the htmx
1034
+ // target as a fragment rather than replacing the shell.
1035
+ if (themeId && (representation.kind === "html")) {
1036
+ const statusRenderer = resolveStatusRenderer(route, themeId, status, "page");
1037
+ if (statusRenderer) {
1038
+ try {
1039
+ const html = statusRenderer.render({ error: message, status });
1040
+ return new Response(toHtmlString(html), {
1041
+ status,
1042
+ headers: { ...corsHeaders, "content-type": htmlContentType(themeId, "status", route.chrome) }
1043
+ });
1044
+ }
1045
+ catch {
1046
+ // fall through to JSON
1047
+ }
1048
+ }
1049
+ }
1050
+ return jsonResponse({ error: message, status }, status, corsHeaders);
1051
+ }
1052
+ function readTenantAppFromEvent(event) {
1053
+ const ctx = event;
1054
+ if (!ctx.__bpTenantId || !ctx.__bpAppId)
1055
+ return undefined;
1056
+ return { tenantId: ctx.__bpTenantId, appId: ctx.__bpAppId };
1057
+ }
1058
+ function renderUpgradeRequired(route, event, validation) {
1059
+ const themeId = event.__bpThemeId;
1060
+ const acceptHeader = acceptHeaderFromEvent(event);
1061
+ const representation = resolveRequestedRepresentation(acceptHeader);
1062
+ const status = 426;
1063
+ // Honor Retry-After if requested
1064
+ const extraHeaders = {};
1065
+ if (validation.retryAfterSeconds) {
1066
+ extraHeaders["retry-after"] = String(validation.retryAfterSeconds);
1067
+ }
1068
+ if (themeId && representation.kind === "html") {
1069
+ const statusRenderer = resolveStatusRenderer(route, themeId, status, "page");
1070
+ if (statusRenderer) {
1071
+ try {
1072
+ const html = statusRenderer.render({
1073
+ status,
1074
+ reason: validation.reason,
1075
+ upgradeUrl: validation.upgradeUrl
1076
+ });
1077
+ return htmlResponse(toHtmlString(html), status, htmlContentType(themeId, "status", route.chrome));
1078
+ }
1079
+ catch {
1080
+ // fall through to JSON
1081
+ }
1082
+ }
1083
+ }
1084
+ return jsonResponse({
1085
+ status,
1086
+ error: "Upgrade Required",
1087
+ reason: validation.reason,
1088
+ upgradeUrl: validation.upgradeUrl
1089
+ }, status, extraHeaders);
1090
+ }
1091
+ function applyBpHeadersToEvent(event, collector) {
1092
+ const { setHeaders, removeHeaders } = collector.emit();
1093
+ for (const directive of setHeaders) {
1094
+ event.res.headers.append("BP-SetHeader", directive);
1095
+ }
1096
+ for (const name of removeHeaders) {
1097
+ event.res.headers.append("BP-RemoveHeader", name);
1098
+ }
1099
+ }
1100
+ // -- Well-known routes ------------------------------------------------
1101
+ /**
1102
+ * Register BetterPortal well-known discovery and health endpoints.
1103
+ */
1104
+ export function registerBpWellKnownRoutes(app, manifest, bpSchema, options = {}) {
1105
+ app.get("/.well-known/bp/schema.json", () => {
1106
+ return jsonResponse(bpSchema);
1107
+ });
1108
+ app.get("/.well-known/bp/health", () => {
1109
+ const health = options.health?.();
1110
+ if (health instanceof Response)
1111
+ return health;
1112
+ if (health !== undefined)
1113
+ return jsonResponse(health);
1114
+ return jsonResponse({ ok: true, pluginId: manifest.pluginId });
1115
+ });
1116
+ app.get("/.well-known/bp/manifest", () => {
1117
+ return jsonResponse(manifest);
1118
+ });
1119
+ }
1120
+ //# sourceMappingURL=h3.js.map