@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.
- package/README.md +13 -0
- package/lib/adapters/h3.d.ts +51 -0
- package/lib/adapters/h3.d.ts.map +1 -0
- package/lib/adapters/h3.js +1120 -0
- package/lib/adapters/h3.js.map +1 -0
- package/lib/codegen/cli.d.ts +3 -0
- package/lib/codegen/cli.d.ts.map +1 -0
- package/lib/codegen/cli.js +82 -0
- package/lib/codegen/cli.js.map +1 -0
- package/lib/codegen/emitter.d.ts +7 -0
- package/lib/codegen/emitter.d.ts.map +1 -0
- package/lib/codegen/emitter.js +453 -0
- package/lib/codegen/emitter.js.map +1 -0
- package/lib/codegen/init.d.ts +3 -0
- package/lib/codegen/init.d.ts.map +1 -0
- package/lib/codegen/init.js +90 -0
- package/lib/codegen/init.js.map +1 -0
- package/lib/codegen/scanner.d.ts +56 -0
- package/lib/codegen/scanner.d.ts.map +1 -0
- package/lib/codegen/scanner.js +484 -0
- package/lib/codegen/scanner.js.map +1 -0
- package/lib/codegen/validate.d.ts +14 -0
- package/lib/codegen/validate.d.ts.map +1 -0
- package/lib/codegen/validate.js +166 -0
- package/lib/codegen/validate.js.map +1 -0
- package/lib/contracts/auth.d.ts +160 -0
- package/lib/contracts/auth.d.ts.map +1 -0
- package/lib/contracts/auth.js +123 -0
- package/lib/contracts/auth.js.map +1 -0
- package/lib/contracts/binding.d.ts +169 -0
- package/lib/contracts/binding.d.ts.map +1 -0
- package/lib/contracts/binding.js +69 -0
- package/lib/contracts/binding.js.map +1 -0
- package/lib/contracts/common.d.ts +23 -0
- package/lib/contracts/common.d.ts.map +1 -0
- package/lib/contracts/common.js +18 -0
- package/lib/contracts/common.js.map +1 -0
- package/lib/contracts/config.d.ts +93 -0
- package/lib/contracts/config.d.ts.map +1 -0
- package/lib/contracts/config.js +62 -0
- package/lib/contracts/config.js.map +1 -0
- package/lib/contracts/controlPlane.d.ts +63 -0
- package/lib/contracts/controlPlane.d.ts.map +1 -0
- package/lib/contracts/controlPlane.js +2 -0
- package/lib/contracts/controlPlane.js.map +1 -0
- package/lib/contracts/json.d.ts +9 -0
- package/lib/contracts/json.d.ts.map +1 -0
- package/lib/contracts/json.js +6 -0
- package/lib/contracts/json.js.map +1 -0
- package/lib/contracts/manifest.d.ts +158 -0
- package/lib/contracts/manifest.d.ts.map +1 -0
- package/lib/contracts/manifest.js +40 -0
- package/lib/contracts/manifest.js.map +1 -0
- package/lib/contracts/observability.d.ts +77 -0
- package/lib/contracts/observability.d.ts.map +1 -0
- package/lib/contracts/observability.js +99 -0
- package/lib/contracts/observability.js.map +1 -0
- package/lib/contracts/platformConfig.d.ts +635 -0
- package/lib/contracts/platformConfig.d.ts.map +1 -0
- package/lib/contracts/platformConfig.js +256 -0
- package/lib/contracts/platformConfig.js.map +1 -0
- package/lib/contracts/registry.d.ts +104 -0
- package/lib/contracts/registry.d.ts.map +1 -0
- package/lib/contracts/registry.js +2 -0
- package/lib/contracts/registry.js.map +1 -0
- package/lib/contracts/route.d.ts +199 -0
- package/lib/contracts/route.d.ts.map +1 -0
- package/lib/contracts/route.js +26 -0
- package/lib/contracts/route.js.map +1 -0
- package/lib/contracts/serviceConfig.d.ts +88 -0
- package/lib/contracts/serviceConfig.d.ts.map +1 -0
- package/lib/contracts/serviceConfig.js +45 -0
- package/lib/contracts/serviceConfig.js.map +1 -0
- package/lib/contracts/streaming.d.ts +76 -0
- package/lib/contracts/streaming.d.ts.map +1 -0
- package/lib/contracts/streaming.js +31 -0
- package/lib/contracts/streaming.js.map +1 -0
- package/lib/contracts/view.d.ts +149 -0
- package/lib/contracts/view.d.ts.map +1 -0
- package/lib/contracts/view.js +82 -0
- package/lib/contracts/view.js.map +1 -0
- package/lib/controlPlane/store.d.ts +24 -0
- package/lib/controlPlane/store.d.ts.map +1 -0
- package/lib/controlPlane/store.js +70 -0
- package/lib/controlPlane/store.js.map +1 -0
- package/lib/controlPlane/sync.d.ts +8 -0
- package/lib/controlPlane/sync.d.ts.map +1 -0
- package/lib/controlPlane/sync.js +24 -0
- package/lib/controlPlane/sync.js.map +1 -0
- package/lib/controlPlane/types.d.ts +15 -0
- package/lib/controlPlane/types.d.ts.map +1 -0
- package/lib/controlPlane/types.js +2 -0
- package/lib/controlPlane/types.js.map +1 -0
- package/lib/index.d.ts +40 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +40 -0
- package/lib/index.js.map +1 -0
- package/lib/runtime/auth/envelope.d.ts +32 -0
- package/lib/runtime/auth/envelope.d.ts.map +1 -0
- package/lib/runtime/auth/envelope.js +123 -0
- package/lib/runtime/auth/envelope.js.map +1 -0
- package/lib/runtime/auth/issuer.d.ts +44 -0
- package/lib/runtime/auth/issuer.d.ts.map +1 -0
- package/lib/runtime/auth/issuer.js +82 -0
- package/lib/runtime/auth/issuer.js.map +1 -0
- package/lib/runtime/auth/jwks.d.ts +7 -0
- package/lib/runtime/auth/jwks.d.ts.map +1 -0
- package/lib/runtime/auth/jwks.js +69 -0
- package/lib/runtime/auth/jwks.js.map +1 -0
- package/lib/runtime/auth/keypair.d.ts +21 -0
- package/lib/runtime/auth/keypair.d.ts.map +1 -0
- package/lib/runtime/auth/keypair.js +50 -0
- package/lib/runtime/auth/keypair.js.map +1 -0
- package/lib/runtime/auth/tokens.d.ts +25 -0
- package/lib/runtime/auth/tokens.d.ts.map +1 -0
- package/lib/runtime/auth/tokens.js +137 -0
- package/lib/runtime/auth/tokens.js.map +1 -0
- package/lib/runtime/auth/verifier.d.ts +45 -0
- package/lib/runtime/auth/verifier.d.ts.map +1 -0
- package/lib/runtime/auth/verifier.js +76 -0
- package/lib/runtime/auth/verifier.js.map +1 -0
- package/lib/runtime/bpHeaders.d.ts +10 -0
- package/lib/runtime/bpHeaders.d.ts.map +1 -0
- package/lib/runtime/bpHeaders.js +53 -0
- package/lib/runtime/bpHeaders.js.map +1 -0
- package/lib/runtime/configProvider.d.ts +41 -0
- package/lib/runtime/configProvider.d.ts.map +1 -0
- package/lib/runtime/configProvider.js +232 -0
- package/lib/runtime/configProvider.js.map +1 -0
- package/lib/runtime/configStore.d.ts +34 -0
- package/lib/runtime/configStore.d.ts.map +1 -0
- package/lib/runtime/configStore.js +197 -0
- package/lib/runtime/configStore.js.map +1 -0
- package/lib/runtime/configTicket.d.ts +49 -0
- package/lib/runtime/configTicket.d.ts.map +1 -0
- package/lib/runtime/configTicket.js +168 -0
- package/lib/runtime/configTicket.js.map +1 -0
- package/lib/runtime/h3.d.ts +28 -0
- package/lib/runtime/h3.d.ts.map +1 -0
- package/lib/runtime/h3.js +199 -0
- package/lib/runtime/h3.js.map +1 -0
- package/lib/runtime/handler.d.ts +55 -0
- package/lib/runtime/handler.d.ts.map +1 -0
- package/lib/runtime/handler.js +51 -0
- package/lib/runtime/handler.js.map +1 -0
- package/lib/runtime/http.d.ts +13 -0
- package/lib/runtime/http.d.ts.map +1 -0
- package/lib/runtime/http.js +114 -0
- package/lib/runtime/http.js.map +1 -0
- package/lib/runtime/jsonSchema.d.ts +4 -0
- package/lib/runtime/jsonSchema.d.ts.map +1 -0
- package/lib/runtime/jsonSchema.js +28 -0
- package/lib/runtime/jsonSchema.js.map +1 -0
- package/lib/runtime/manifest.d.ts +3 -0
- package/lib/runtime/manifest.d.ts.map +1 -0
- package/lib/runtime/manifest.js +5 -0
- package/lib/runtime/manifest.js.map +1 -0
- package/lib/runtime/media.d.ts +20 -0
- package/lib/runtime/media.d.ts.map +1 -0
- package/lib/runtime/media.js +70 -0
- package/lib/runtime/media.js.map +1 -0
- package/lib/runtime/registry.d.ts +67 -0
- package/lib/runtime/registry.d.ts.map +1 -0
- package/lib/runtime/registry.js +290 -0
- package/lib/runtime/registry.js.map +1 -0
- package/lib/runtime/serviceConfig.d.ts +38 -0
- package/lib/runtime/serviceConfig.d.ts.map +1 -0
- package/lib/runtime/serviceConfig.js +152 -0
- package/lib/runtime/serviceConfig.js.map +1 -0
- package/lib/runtime/statusViews.d.ts +23 -0
- package/lib/runtime/statusViews.d.ts.map +1 -0
- package/lib/runtime/statusViews.js +48 -0
- package/lib/runtime/statusViews.js.map +1 -0
- package/lib/runtime/stream.d.ts +41 -0
- package/lib/runtime/stream.d.ts.map +1 -0
- package/lib/runtime/stream.js +92 -0
- package/lib/runtime/stream.js.map +1 -0
- package/lib/runtime/streamHandler.d.ts +48 -0
- package/lib/runtime/streamHandler.d.ts.map +1 -0
- package/lib/runtime/streamHandler.js +49 -0
- package/lib/runtime/streamHandler.js.map +1 -0
- package/lib/runtime/tenantResolution.d.ts +4 -0
- package/lib/runtime/tenantResolution.d.ts.map +1 -0
- package/lib/runtime/tenantResolution.js +19 -0
- package/lib/runtime/tenantResolution.js.map +1 -0
- package/lib/runtime/uuid.d.ts +6 -0
- package/lib/runtime/uuid.d.ts.map +1 -0
- package/lib/runtime/uuid.js +27 -0
- package/lib/runtime/uuid.js.map +1 -0
- package/lib/runtime/view.d.ts +48 -0
- package/lib/runtime/view.d.ts.map +1 -0
- package/lib/runtime/view.js +111 -0
- package/lib/runtime/view.js.map +1 -0
- 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
|