@openlup/platform 0.0.0 → 0.15.0

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 (160) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +204 -0
  3. package/README.md +124 -1
  4. package/dist/bff/dispatch.d.ts +27 -0
  5. package/dist/bff/dispatch.js +47 -0
  6. package/dist/bff/envelope.d.ts +46 -0
  7. package/dist/bff/envelope.js +80 -0
  8. package/dist/bff/health.d.ts +24 -0
  9. package/dist/bff/health.js +19 -0
  10. package/dist/bff/index.d.ts +6 -0
  11. package/dist/bff/index.js +6 -0
  12. package/dist/bff/lazyRoute.d.ts +4 -0
  13. package/dist/bff/lazyRoute.js +13 -0
  14. package/dist/bff/requestContext.d.ts +17 -0
  15. package/dist/bff/requestContext.js +26 -0
  16. package/dist/bff/routerRewrite.d.ts +2 -0
  17. package/dist/bff/routerRewrite.js +31 -0
  18. package/dist/blob/binding.d.ts +16 -0
  19. package/dist/blob/binding.js +37 -0
  20. package/dist/blob/filesystem.d.ts +8 -0
  21. package/dist/blob/filesystem.js +71 -0
  22. package/dist/blob/index.d.ts +6 -0
  23. package/dist/blob/index.js +6 -0
  24. package/dist/blob/s3.d.ts +32 -0
  25. package/dist/blob/s3.js +56 -0
  26. package/dist/blob/s3Client.d.ts +25 -0
  27. package/dist/blob/s3Client.js +76 -0
  28. package/dist/blob/supabase.d.ts +40 -0
  29. package/dist/blob/supabase.js +59 -0
  30. package/dist/blob/vercel.d.ts +63 -0
  31. package/dist/blob/vercel.js +69 -0
  32. package/dist/composition/composeBundle.d.ts +47 -0
  33. package/dist/composition/composeBundle.js +45 -0
  34. package/dist/composition/httpBinding.d.ts +8 -0
  35. package/dist/composition/httpBinding.js +18 -0
  36. package/dist/composition/index.d.ts +3 -0
  37. package/dist/composition/index.js +3 -0
  38. package/dist/composition/nodeHost.d.ts +103 -0
  39. package/dist/composition/nodeHost.js +145 -0
  40. package/dist/data/binding.d.ts +34 -0
  41. package/dist/data/binding.js +92 -0
  42. package/dist/data/index.d.ts +5 -0
  43. package/dist/data/index.js +5 -0
  44. package/dist/data/postgresGateway.d.ts +42 -0
  45. package/dist/data/postgresGateway.js +135 -0
  46. package/dist/data/queryBuilder.d.ts +54 -0
  47. package/dist/data/queryBuilder.js +240 -0
  48. package/dist/data/queryBuilderSql.d.ts +76 -0
  49. package/dist/data/queryBuilderSql.js +145 -0
  50. package/dist/data/supabaseClientFactory.d.ts +34 -0
  51. package/dist/data/supabaseClientFactory.js +56 -0
  52. package/dist/data/supabaseGateway.d.ts +22 -0
  53. package/dist/data/supabaseGateway.js +49 -0
  54. package/dist/http/deadline.d.ts +20 -0
  55. package/dist/http/deadline.js +93 -0
  56. package/dist/http/index.d.ts +6 -0
  57. package/dist/http/index.js +5 -0
  58. package/dist/http/requestAdapter.d.ts +38 -0
  59. package/dist/http/requestAdapter.js +203 -0
  60. package/dist/http/runtime.d.ts +83 -0
  61. package/dist/http/runtime.js +199 -0
  62. package/dist/http/security.d.ts +44 -0
  63. package/dist/http/security.js +114 -0
  64. package/dist/http/staticAssets.d.ts +3 -0
  65. package/dist/http/staticAssets.js +42 -0
  66. package/dist/http/types.d.ts +23 -0
  67. package/dist/http/types.js +6 -0
  68. package/dist/job-ledger/index.d.ts +5 -0
  69. package/dist/job-ledger/index.js +3 -0
  70. package/dist/job-ledger/ports.d.ts +10 -0
  71. package/dist/job-ledger/ports.js +7 -0
  72. package/dist/job-ledger/postgres.d.ts +10 -0
  73. package/dist/job-ledger/postgres.js +101 -0
  74. package/dist/job-ledger/sql.d.ts +7 -0
  75. package/dist/job-ledger/sql.js +10 -0
  76. package/dist/job-ledger/supabase.d.ts +77 -0
  77. package/dist/job-ledger/supabase.js +202 -0
  78. package/dist/kernel/index.d.ts +1 -0
  79. package/dist/kernel/index.js +1 -0
  80. package/dist/kernel/platformKernel.d.ts +34 -0
  81. package/dist/kernel/platformKernel.js +115 -0
  82. package/dist/migrations/index.d.ts +1 -0
  83. package/dist/migrations/index.js +1 -0
  84. package/dist/migrations/postgresRunner.d.ts +50 -0
  85. package/dist/migrations/postgresRunner.js +176 -0
  86. package/dist/scheduler/advisoryLock.d.ts +44 -0
  87. package/dist/scheduler/advisoryLock.js +114 -0
  88. package/dist/scheduler/cronGuard.d.ts +10 -0
  89. package/dist/scheduler/cronGuard.js +19 -0
  90. package/dist/scheduler/cronSecret.d.ts +5 -0
  91. package/dist/scheduler/cronSecret.js +23 -0
  92. package/dist/scheduler/handlers.d.ts +9 -0
  93. package/dist/scheduler/handlers.js +10 -0
  94. package/dist/scheduler/httpEntry.d.ts +16 -0
  95. package/dist/scheduler/httpEntry.js +31 -0
  96. package/dist/scheduler/index.d.ts +9 -0
  97. package/dist/scheduler/index.js +9 -0
  98. package/dist/scheduler/jobRegistry.d.ts +21 -0
  99. package/dist/scheduler/jobRegistry.js +39 -0
  100. package/dist/scheduler/jobs.d.ts +48 -0
  101. package/dist/scheduler/jobs.js +92 -0
  102. package/dist/scheduler/nodeScheduler.d.ts +42 -0
  103. package/dist/scheduler/nodeScheduler.js +73 -0
  104. package/dist/scheduler/vercelScheduler.d.ts +29 -0
  105. package/dist/scheduler/vercelScheduler.js +51 -0
  106. package/package.json +106 -3
  107. package/release-gates.json +121 -0
  108. package/sql/20260809150000_job_run_ledger.sql +301 -0
  109. package/sql/20260809150001_job_run_ledger_v3.sql +70 -0
  110. package/src/bff/dispatch.ts +81 -0
  111. package/src/bff/envelope.ts +142 -0
  112. package/src/bff/health.ts +37 -0
  113. package/src/bff/index.ts +30 -0
  114. package/src/bff/lazyRoute.ts +28 -0
  115. package/src/bff/requestContext.ts +48 -0
  116. package/src/bff/routerRewrite.ts +38 -0
  117. package/src/blob/binding.ts +55 -0
  118. package/src/blob/filesystem.ts +86 -0
  119. package/src/blob/index.ts +17 -0
  120. package/src/blob/s3.ts +87 -0
  121. package/src/blob/s3Client.ts +91 -0
  122. package/src/blob/supabase.ts +88 -0
  123. package/src/blob/vercel.ts +127 -0
  124. package/src/composition/composeBundle.ts +109 -0
  125. package/src/composition/httpBinding.ts +30 -0
  126. package/src/composition/index.ts +17 -0
  127. package/src/composition/nodeHost.ts +289 -0
  128. package/src/data/binding.ts +157 -0
  129. package/src/data/index.ts +33 -0
  130. package/src/data/postgresGateway.ts +186 -0
  131. package/src/data/queryBuilder.ts +300 -0
  132. package/src/data/queryBuilderSql.ts +183 -0
  133. package/src/data/supabaseClientFactory.ts +92 -0
  134. package/src/data/supabaseGateway.ts +76 -0
  135. package/src/http/deadline.ts +117 -0
  136. package/src/http/index.ts +36 -0
  137. package/src/http/requestAdapter.ts +215 -0
  138. package/src/http/runtime.ts +319 -0
  139. package/src/http/security.ts +155 -0
  140. package/src/http/staticAssets.ts +42 -0
  141. package/src/http/types.ts +31 -0
  142. package/src/job-ledger/index.ts +25 -0
  143. package/src/job-ledger/ports.ts +27 -0
  144. package/src/job-ledger/postgres.ts +121 -0
  145. package/src/job-ledger/sql.ts +12 -0
  146. package/src/job-ledger/supabase.ts +294 -0
  147. package/src/kernel/index.ts +17 -0
  148. package/src/kernel/platformKernel.ts +153 -0
  149. package/src/migrations/index.ts +10 -0
  150. package/src/migrations/postgresRunner.ts +255 -0
  151. package/src/scheduler/advisoryLock.ts +145 -0
  152. package/src/scheduler/cronGuard.ts +30 -0
  153. package/src/scheduler/cronSecret.ts +26 -0
  154. package/src/scheduler/handlers.ts +24 -0
  155. package/src/scheduler/httpEntry.ts +49 -0
  156. package/src/scheduler/index.ts +47 -0
  157. package/src/scheduler/jobRegistry.ts +57 -0
  158. package/src/scheduler/jobs.ts +119 -0
  159. package/src/scheduler/nodeScheduler.ts +123 -0
  160. package/src/scheduler/vercelScheduler.ts +71 -0
@@ -0,0 +1,70 @@
1
+ -- Job-run ledger v3 entry points: invocation-aware claim and finish.
2
+ --
3
+ -- Applies after 20260809150000_job_run_ledger.sql, whose v2 claim and finish these delegate to.
4
+ -- The trigger kind and invocation source are evidence folded into the run metadata, never an
5
+ -- authorization control. CREATE OR REPLACE lets a database whose own migration chain already
6
+ -- defines these functions with the same bodies apply this file without change.
7
+
8
+ CREATE OR REPLACE FUNCTION public.platform_claim_job_run_v3(
9
+ p_job_name text,
10
+ p_trigger_kind text,
11
+ p_invocation_source text,
12
+ p_lease_seconds integer DEFAULT 900,
13
+ p_metadata jsonb DEFAULT '{}'::jsonb
14
+ )
15
+ RETURNS TABLE (
16
+ acquired boolean,
17
+ run_id uuid,
18
+ reason text,
19
+ lease_until timestamptz
20
+ )
21
+ LANGUAGE plpgsql
22
+ SET search_path = public, pg_catalog
23
+ AS $$
24
+ DECLARE
25
+ v_trigger_kind text := COALESCE(NULLIF(btrim(p_trigger_kind), ''), '');
26
+ v_invocation_source text := COALESCE(NULLIF(btrim(p_invocation_source), ''), '');
27
+ BEGIN
28
+ IF p_job_name IS NULL OR btrim(p_job_name) = '' THEN
29
+ RAISE EXCEPTION 'platform_job_name_required' USING ERRCODE = '22023';
30
+ END IF;
31
+
32
+ IF v_trigger_kind NOT IN ('worker', 'scheduler', 'operator') THEN
33
+ RAISE EXCEPTION 'platform_job_invalid_trigger_kind' USING ERRCODE = '22023';
34
+ END IF;
35
+
36
+ IF v_invocation_source = '' OR length(v_invocation_source) > 128 THEN
37
+ RAISE EXCEPTION 'platform_job_invocation_source_required' USING ERRCODE = '22023';
38
+ END IF;
39
+
40
+ -- The invocation source is evidence, never an authorization control, so it is folded into
41
+ -- the metadata this ledger stores and never interprets.
42
+ RETURN QUERY
43
+ SELECT claimed.acquired, claimed.run_id, claimed.reason, claimed.lease_until
44
+ FROM public.platform_claim_job_run(
45
+ p_job_name,
46
+ v_trigger_kind,
47
+ p_lease_seconds,
48
+ COALESCE(p_metadata, '{}'::jsonb) || jsonb_build_object(
49
+ 'triggerKind', v_trigger_kind, 'invocationSource', v_invocation_source)
50
+ ) AS claimed;
51
+ END;
52
+ $$;
53
+
54
+ CREATE OR REPLACE FUNCTION public.platform_finish_job_run_v3(
55
+ p_job_name text,
56
+ p_run_id uuid,
57
+ p_status text,
58
+ p_checked integer DEFAULT NULL,
59
+ p_updated integer DEFAULT NULL,
60
+ p_error text DEFAULT NULL,
61
+ p_support_code text DEFAULT NULL,
62
+ p_metadata jsonb DEFAULT '{}'::jsonb
63
+ )
64
+ RETURNS boolean
65
+ LANGUAGE sql
66
+ SET search_path = public, pg_catalog
67
+ AS $$
68
+ SELECT public.platform_finish_job_run_v2(
69
+ p_job_name, p_run_id, p_status, p_checked, p_updated, p_error, p_support_code, p_metadata);
70
+ $$;
@@ -0,0 +1,81 @@
1
+ // BFF dispatch mechanism.
2
+ //
3
+ // The route table and its handlers belong to the deployment. This module owns the
4
+ // platform-neutral matching loop, so every HttpRuntimePort adapter (a hosted function or the Node
5
+ // runtime of this package) mounts the same route table without duplicating dispatch logic.
6
+ //
7
+ // Per request, in order: the deployment's `beforeDispatch` hook, path normalization, the first
8
+ // matching route, its availability, its decoded path parameters, the deployment's `preflight`
9
+ // hook, then the handler. An unmatched or unavailable path goes to the deployment's `notFound`.
10
+
11
+ import type { HttpRequest, HttpResponse } from "../http/index.js";
12
+ import { getBundleDescriptor, resolveBundleId } from "../kernel/index.js";
13
+
14
+ export type RouteEntry = {
15
+ route: string;
16
+ pattern: RegExp;
17
+ params: string[];
18
+ handler: (req: HttpRequest, res: HttpResponse) => unknown;
19
+ /** Fail unsupported hosted bundles closed before auth without weakening direct-route auth. */
20
+ availability?: "direct-postgres";
21
+ };
22
+
23
+ /** Match `routes` against one request and run the handler, or answer not found. */
24
+ export type BffDispatch = (
25
+ routes: readonly RouteEntry[],
26
+ req: HttpRequest,
27
+ res: HttpResponse,
28
+ ) => Promise<unknown>;
29
+
30
+ export interface BffDispatcherOptions {
31
+ /** Environment read on each request to decide a route's bundle availability. */
32
+ env: () => Record<string, string | undefined>;
33
+ /** Writes the deployment's not-found response for an unmatched or unavailable path. */
34
+ notFound: (res: HttpResponse, pathname: string) => void;
35
+ /** Runs once per request before matching (for example, ambient request configuration). */
36
+ beforeDispatch?: () => void;
37
+ /**
38
+ * Runs after matching and before the handler, for example route authorization; resolving false
39
+ * means it already answered. Required, so no composition skips it by omission; a deployment
40
+ * without guarded routes passes `() => true`.
41
+ */
42
+ preflight: (req: HttpRequest, res: HttpResponse, route: string) => Promise<boolean> | boolean;
43
+ }
44
+
45
+ // Only the pathname is read, and it does not depend on the URL base; this host only completes a
46
+ // request that carries no Host header.
47
+ const FALLBACK_HOST = "bff.invalid";
48
+
49
+ function requestPath(req: HttpRequest): string {
50
+ const rawUrl = typeof req.url === "string" ? req.url : "/";
51
+ const host = typeof req.headers?.host === "string" ? req.headers.host : FALLBACK_HOST;
52
+ return new URL(rawUrl, "https://" + host).pathname.replace(/\/+$/, "") || "/";
53
+ }
54
+
55
+ /** Build the dispatcher a deployment mounts on its hosted and Node entrypoints alike. */
56
+ export function createBffDispatcher(options: BffDispatcherOptions): BffDispatch {
57
+ return async function dispatch(routes, req, res) {
58
+ options.beforeDispatch?.();
59
+ const pathname = requestPath(req);
60
+ for (const entry of routes) {
61
+ const match = entry.pattern.exec(pathname);
62
+ if (!match) continue;
63
+ if (entry.availability === "direct-postgres"
64
+ && getBundleDescriptor(resolveBundleId(options.env())).capabilities.data !== "postgres") {
65
+ options.notFound(res, pathname);
66
+ return undefined;
67
+ }
68
+ if (entry.params.length > 0) {
69
+ const query = { ...(req.query ?? {}) };
70
+ entry.params.forEach((param, index) => {
71
+ query[param] = decodeURIComponent(match[index + 1] ?? "");
72
+ });
73
+ req.query = query;
74
+ }
75
+ if (!(await options.preflight(req, res, entry.route))) return undefined;
76
+ return entry.handler(req, res);
77
+ }
78
+ options.notFound(res, pathname);
79
+ return undefined;
80
+ };
81
+ }
@@ -0,0 +1,142 @@
1
+ // BFF response envelope.
2
+ //
3
+ // Every BFF response is JSON `{ ok: true, data, meta? }` or `{ ok: false, error: { code, message,
4
+ // details? }, meta? }`. A private response carries the request reference in the `x-request-id`
5
+ // header and, for errors or responses with meta, in `meta.requestId`; a shared-cache response
6
+ // (public or s-maxage) carries neither. `undefined` members are stripped before serialization.
7
+
8
+ import type { HttpResponse } from "../http/index.js";
9
+
10
+ export const BFF_REQUEST_REFERENCE_HEADER = "x-request-id";
11
+
12
+ export const BFF_ERROR_CODES = [
13
+ "BAD_REQUEST",
14
+ "UNAUTHORIZED",
15
+ "FORBIDDEN",
16
+ "NOT_FOUND",
17
+ "METHOD_NOT_ALLOWED",
18
+ "CONFLICT",
19
+ "RATE_LIMITED",
20
+ "UPSTREAM_UNAVAILABLE",
21
+ "INTERNAL",
22
+ "INVALID_RESPONSE",
23
+ ] as const;
24
+
25
+ export type BffErrorCode = (typeof BFF_ERROR_CODES)[number];
26
+
27
+ export type BffMeta = { requestId?: string; contractVersion?: string; [key: string]: unknown };
28
+
29
+ export type BffError = { code: BffErrorCode; message: string; details?: unknown };
30
+
31
+ export type BffSuccessResponse<T> = { ok: true; data: T; meta?: BffMeta };
32
+
33
+ export type BffErrorResponse = { ok: false; error: BffError; meta?: BffMeta };
34
+
35
+ export type BffResponse<T> = BffSuccessResponse<T> | BffErrorResponse;
36
+
37
+ /** The HTTP status each error code answers with unless the caller names another. */
38
+ export const BFF_ERROR_STATUS: Readonly<Record<BffErrorCode, number>> = {
39
+ BAD_REQUEST: 400,
40
+ UNAUTHORIZED: 401,
41
+ FORBIDDEN: 403,
42
+ NOT_FOUND: 404,
43
+ METHOD_NOT_ALLOWED: 405,
44
+ CONFLICT: 409,
45
+ RATE_LIMITED: 429,
46
+ UPSTREAM_UNAVAILABLE: 503,
47
+ INTERNAL: 500,
48
+ INVALID_RESPONSE: 502,
49
+ };
50
+
51
+ export interface BffErrorOptions {
52
+ status?: number;
53
+ details?: unknown;
54
+ meta?: BffMeta;
55
+ }
56
+
57
+ export interface BffSuccessOptions {
58
+ cacheControl?: string;
59
+ }
60
+
61
+ export interface BffResponderOptions {
62
+ /** The request reference observed for this response, if the deployment records one. */
63
+ responseRequestId?: (res: HttpResponse) => string | undefined;
64
+ }
65
+
66
+ export interface BffResponder {
67
+ sendBffSuccess<T>(res: HttpResponse, data: T, meta?: BffMeta, options?: BffSuccessOptions): void;
68
+ sendBffError(res: HttpResponse, code: BffErrorCode, message: string, options?: BffErrorOptions): void;
69
+ sendMethodNotAllowed(res: HttpResponse, allowedMethods: readonly string[]): void;
70
+ }
71
+
72
+ /** Build the envelope writers a deployment's handlers share. */
73
+ export function createBffResponder(options: BffResponderOptions = {}): BffResponder {
74
+ function sendBffSuccess<T>(
75
+ res: HttpResponse,
76
+ data: T,
77
+ meta?: BffMeta,
78
+ successOptions: BffSuccessOptions = {},
79
+ ): void {
80
+ sendJson(res, 200, { ok: true, data, meta }, successOptions.cacheControl);
81
+ }
82
+
83
+ function sendBffError(
84
+ res: HttpResponse,
85
+ code: BffErrorCode,
86
+ message: string,
87
+ errorOptions: BffErrorOptions = {},
88
+ ): void {
89
+ sendJson(res, errorOptions.status ?? BFF_ERROR_STATUS[code], {
90
+ ok: false,
91
+ error: { code, message, details: errorOptions.details },
92
+ meta: errorOptions.meta,
93
+ });
94
+ }
95
+
96
+ function sendMethodNotAllowed(res: HttpResponse, allowedMethods: readonly string[]): void {
97
+ res.setHeader("Allow", allowedMethods.join(", "));
98
+ sendBffError(res, "METHOD_NOT_ALLOWED", "Method not allowed", { status: 405 });
99
+ }
100
+
101
+ function sendJson<T>(
102
+ res: HttpResponse,
103
+ status: number,
104
+ body: BffResponse<T>,
105
+ cacheControl = "no-store",
106
+ ): void {
107
+ const shared = /(?:^|,)\s*(?:public\b|s-maxage\s*=)/i.test(cacheControl);
108
+ const requestId = shared ? undefined : options.responseRequestId?.(res);
109
+ const responseBody = withRequestReference(body, requestId, shared);
110
+ res.setHeader("Content-Type", "application/json");
111
+ res.setHeader("Cache-Control", cacheControl);
112
+ if (shared && typeof res.removeHeader === "function") res.removeHeader(BFF_REQUEST_REFERENCE_HEADER);
113
+ else if (requestId) res.setHeader(BFF_REQUEST_REFERENCE_HEADER, requestId);
114
+ res.status(status).json(stripUndefined(responseBody));
115
+ }
116
+
117
+ return { sendBffSuccess, sendBffError, sendMethodNotAllowed };
118
+ }
119
+
120
+ function withRequestReference<T>(
121
+ body: BffResponse<T>,
122
+ requestId: string | undefined,
123
+ omitRequestId: boolean,
124
+ ): BffResponse<T> {
125
+ if (requestId && (body.ok === false || body.meta)) {
126
+ return { ...body, meta: { ...body.meta, requestId } };
127
+ }
128
+ if (!omitRequestId || !body.meta || !("requestId" in body.meta)) return body;
129
+ const { requestId: _omitted, ...meta } = body.meta;
130
+ return { ...body, meta: Object.keys(meta).length > 0 ? meta : undefined };
131
+ }
132
+
133
+ function stripUndefined(value: unknown): unknown {
134
+ if (Array.isArray(value)) return value.map(stripUndefined);
135
+ if (!value || typeof value !== "object") return value;
136
+
137
+ return Object.fromEntries(
138
+ Object.entries(value)
139
+ .filter(([, entryValue]) => entryValue !== undefined)
140
+ .map(([key, entryValue]) => [key, stripUndefined(entryValue)]),
141
+ );
142
+ }
@@ -0,0 +1,37 @@
1
+ // BFF health route.
2
+ //
3
+ // GET answers the deployment's health payload, validated by the deployment's strict contract, with
4
+ // the payload's contract version in `meta`; any other method answers 405 with `Allow: GET`. A
5
+ // payload that fails its own contract answers INTERNAL rather than an unchecked body.
6
+
7
+ import type { HttpRequest, HttpResponse } from "../http/index.js";
8
+ import type { BffResponder } from "./envelope.js";
9
+
10
+ export interface HealthContract<T extends { contractVersion: string }> {
11
+ safeParse(value: unknown): { success: true; data: T } | { success: false };
12
+ }
13
+
14
+ export interface HealthHandlerOptions<T extends { contractVersion: string }> {
15
+ responder: BffResponder;
16
+ /** The deployment's strict health contract. */
17
+ contract: HealthContract<T>;
18
+ /** Builds this request's health payload: service identity, release provenance and the rest. */
19
+ read: () => unknown;
20
+ }
21
+
22
+ export function createHealthHandler<T extends { contractVersion: string }>(
23
+ options: HealthHandlerOptions<T>,
24
+ ): (req: HttpRequest, res: HttpResponse) => void {
25
+ return function health(req, res) {
26
+ if (req.method !== "GET") {
27
+ options.responder.sendMethodNotAllowed(res, ["GET"]);
28
+ return;
29
+ }
30
+ const result = options.contract.safeParse(options.read());
31
+ if (!result.success) {
32
+ options.responder.sendBffError(res, "INTERNAL", "Health contract is invalid");
33
+ return;
34
+ }
35
+ options.responder.sendBffSuccess(res, result.data, { contractVersion: result.data.contractVersion });
36
+ };
37
+ }
@@ -0,0 +1,30 @@
1
+ export { createBffDispatcher, type BffDispatch, type BffDispatcherOptions, type RouteEntry } from "./dispatch.js";
2
+ export { lazyBffRoute } from "./lazyRoute.js";
3
+ export { rewriteBffRouterRequestUrl } from "./routerRewrite.js";
4
+ export {
5
+ BFF_ERROR_CODES,
6
+ BFF_ERROR_STATUS,
7
+ BFF_REQUEST_REFERENCE_HEADER,
8
+ createBffResponder,
9
+ type BffError,
10
+ type BffErrorCode,
11
+ type BffErrorOptions,
12
+ type BffErrorResponse,
13
+ type BffMeta,
14
+ type BffResponder,
15
+ type BffResponderOptions,
16
+ type BffResponse,
17
+ type BffSuccessOptions,
18
+ type BffSuccessResponse,
19
+ } from "./envelope.js";
20
+ export {
21
+ createHealthHandler,
22
+ type HealthContract,
23
+ type HealthHandlerOptions,
24
+ } from "./health.js";
25
+ export {
26
+ createRequestContextStore,
27
+ type ObservedRequestContext,
28
+ type RequestContextOptions,
29
+ type RequestContextStore,
30
+ } from "./requestContext.js";
@@ -0,0 +1,28 @@
1
+ // Loads a BFF route module on its first request instead of at cold start.
2
+ //
3
+ // A route table binds each route as `lazyBffRoute(() => import("<specifier>"))`, so the
4
+ // specifier stays a static literal that bundlers and inventories can read, and the load's type
5
+ // keeps every module's default export checked against `RouteEntry["handler"]`.
6
+ //
7
+ // Concurrent first calls share one load. A rejected load is not kept: the requests that awaited
8
+ // it reject as a throwing handler does, and the next request loads again. A module that threw
9
+ // during evaluation stays errored in Node's ESM loader; the retry helps only a failure before
10
+ // evaluation (a missing file or a read error). Once loaded, the module's handler is called
11
+ // synchronously and its result returned.
12
+ import type { RouteEntry } from "./dispatch.js";
13
+
14
+ export function lazyBffRoute(load: () => Promise<{ default: RouteEntry["handler"] }>): RouteEntry["handler"] {
15
+ let handler: RouteEntry["handler"] | undefined;
16
+ let loading: Promise<RouteEntry["handler"]> | undefined;
17
+ return (req, res) => {
18
+ if (handler) return handler(req, res);
19
+ loading ??= load().then(
20
+ (module) => (handler = module.default),
21
+ (error: unknown) => {
22
+ loading = undefined;
23
+ throw error;
24
+ },
25
+ );
26
+ return loading.then((loaded) => loaded(req, res));
27
+ };
28
+ }
@@ -0,0 +1,48 @@
1
+ // Observed request context.
2
+ //
3
+ // One context per request, created on first use and shared by everything that observes the same
4
+ // request or its response. The deployment decides how a request reference is chosen (for example,
5
+ // a host-assigned request ID or a generated one); the context only keeps the answer stable.
6
+
7
+ import type { HttpRequest, HttpResponse } from "../http/index.js";
8
+
9
+ export interface ObservedRequestContext {
10
+ readonly requestId: string;
11
+ }
12
+
13
+ export interface RequestContextOptions {
14
+ /** Chooses this request's reference. Called once per request. */
15
+ readRequestId: (req: HttpRequest) => string;
16
+ }
17
+
18
+ export interface RequestContextStore {
19
+ /** Create (or reuse) the request's context and attach it to the response. */
20
+ install(req: HttpRequest, res: HttpResponse): ObservedRequestContext;
21
+ /** The request's context, created on first use. */
22
+ readOrCreate(req: HttpRequest): ObservedRequestContext;
23
+ /** The context attached to a response by `install`, if any. */
24
+ readResponse(res: HttpResponse): ObservedRequestContext | undefined;
25
+ }
26
+
27
+ export function createRequestContextStore(options: RequestContextOptions): RequestContextStore {
28
+ const requestContexts = new WeakMap<HttpRequest, ObservedRequestContext>();
29
+ const responseContexts = new WeakMap<HttpResponse, ObservedRequestContext>();
30
+
31
+ function readOrCreate(req: HttpRequest): ObservedRequestContext {
32
+ const existing = requestContexts.get(req);
33
+ if (existing) return existing;
34
+ const context = { requestId: options.readRequestId(req) };
35
+ requestContexts.set(req, context);
36
+ return context;
37
+ }
38
+
39
+ return {
40
+ install(req, res) {
41
+ const context = readOrCreate(req);
42
+ responseContexts.set(res, context);
43
+ return context;
44
+ },
45
+ readOrCreate,
46
+ readResponse: (res) => responseContexts.get(res),
47
+ };
48
+ }
@@ -0,0 +1,38 @@
1
+ // URL rewrite for a hosted BFF router entrypoint.
2
+ //
3
+ // A host rewrite can deliver every `/api/bff/*` request to one function, passing the original
4
+ // path in the internal `__bffPath` query value. This restores the request URL and query the
5
+ // dispatcher expects before it matches a route.
6
+
7
+ import type { HttpRequest } from "../http/index.js";
8
+
9
+ const INTERNAL_BFF_PATH_QUERY = "__bffPath";
10
+
11
+ function firstQueryValue(value: string | string[] | undefined): string | null {
12
+ if (Array.isArray(value)) return value[0] ?? null;
13
+ return value ?? null;
14
+ }
15
+
16
+ // The rewrite keeps only the path and query, which do not depend on the URL base; this host only
17
+ // completes a request that carries no Host header.
18
+ const FALLBACK_HOST = "bff.invalid";
19
+
20
+ export function rewriteBffRouterRequestUrl(req: HttpRequest): void {
21
+ const routedPath = firstQueryValue(req.query?.[INTERNAL_BFF_PATH_QUERY]);
22
+ if (!routedPath) return;
23
+
24
+ const host = typeof req.headers?.host === "string" ? req.headers.host : FALLBACK_HOST;
25
+ const currentUrl = new URL(typeof req.url === "string" ? req.url : "/", `https://${host}`);
26
+ currentUrl.searchParams.delete(INTERNAL_BFF_PATH_QUERY);
27
+ currentUrl.searchParams.delete("path");
28
+
29
+ const normalizedPath = routedPath
30
+ .split("/")
31
+ .filter(Boolean)
32
+ .map((segment) => encodeURIComponent(decodeURIComponent(segment)))
33
+ .join("/");
34
+ req.url = `/api/bff/${normalizedPath}${currentUrl.search}`;
35
+
36
+ const { [INTERNAL_BFF_PATH_QUERY]: _internal, path: _hostPath, ...query } = req.query ?? {};
37
+ req.query = query;
38
+ }
@@ -0,0 +1,55 @@
1
+ // Blob port binding for bundle composition.
2
+ //
3
+ // Maps the selected blob capability to a concrete BlobStoragePort built from env: filesystem
4
+ // (the local development default), S3-compatible storage (the default client unless one is
5
+ // injected), Vercel Blob (with the injected `@vercel/blob` module) and Supabase Storage (a
6
+ // ready storage client supplied by the injected factory).
7
+ // A capability whose settings or client are missing stays unbound (null); readiness reports it.
8
+
9
+ import type { BlobStoragePort } from "@openlup/core/platform-runtime";
10
+
11
+ import { createFilesystemBlobStorage } from "./filesystem.js";
12
+ import { createS3BlobStorage, type S3ObjectClient } from "./s3.js";
13
+ import { createS3ClientFromEnv } from "./s3Client.js";
14
+ import { createSupabaseBlobStorage, type SupabaseStorageClient } from "./supabase.js";
15
+ import { createVercelBlobStorage, type VercelBlobSdk } from "./vercel.js";
16
+
17
+ type Env = Record<string, string | undefined>;
18
+
19
+ export interface BlobBindingDeps {
20
+ /** The `@vercel/blob` module; without it the vercel-blob capability stays unbound. */
21
+ vercelBlob?: VercelBlobSdk;
22
+ /** S3 client factory; defaults to `createS3ClientFromEnv`. */
23
+ s3ClientFactory?: (env: Env) => S3ObjectClient;
24
+ /** Ready Supabase Storage client factory; without it the supabase capability stays unbound. */
25
+ supabaseStorageClient?: () => SupabaseStorageClient;
26
+ }
27
+
28
+ /** Resolve the BlobStoragePort for a blob capability, or null when it cannot be bound. */
29
+ export function bindBlobPort(
30
+ blobKind: string,
31
+ env: Env,
32
+ deps: BlobBindingDeps = {},
33
+ ): BlobStoragePort | null {
34
+ switch (blobKind) {
35
+ case "vercel-blob":
36
+ return deps.vercelBlob ? createVercelBlobStorage({ env, sdk: deps.vercelBlob }) : null;
37
+ case "filesystem":
38
+ return env.BLOB_FS_ROOT && env.BLOB_FS_BASE_URL
39
+ ? createFilesystemBlobStorage({ root: env.BLOB_FS_ROOT, baseUrl: env.BLOB_FS_BASE_URL })
40
+ : null;
41
+ case "s3":
42
+ return env.BLOB_S3_BUCKET && env.BLOB_S3_BASE_URL
43
+ ? createS3BlobStorage((deps.s3ClientFactory ?? createS3ClientFromEnv)(env), {
44
+ bucket: env.BLOB_S3_BUCKET,
45
+ baseUrl: env.BLOB_S3_BASE_URL,
46
+ })
47
+ : null;
48
+ case "supabase": {
49
+ if (!deps.supabaseStorageClient || !env.BLOB_SUPABASE_BUCKET) return null;
50
+ return createSupabaseBlobStorage(deps.supabaseStorageClient(), env.BLOB_SUPABASE_BUCKET);
51
+ }
52
+ default:
53
+ return null;
54
+ }
55
+ }
@@ -0,0 +1,86 @@
1
+ // Filesystem adapter for the platform-runtime BlobStoragePort.
2
+ //
3
+ // Stores blobs as plain files under a root directory and serves them through a configurable
4
+ // public base URL (e.g. a static file server / nginx in a self-hosted node-postgres bundle).
5
+ // This is the credential-free adapter the per-adapter contract test runs against in CI.
6
+
7
+ import { promises as fs } from "node:fs";
8
+ import { dirname, join, relative, resolve, sep } from "node:path";
9
+ import type { BlobStoragePort } from "@openlup/core/platform-runtime";
10
+
11
+ export interface FilesystemBlobOptions {
12
+ /** Absolute root directory blobs are written under. */
13
+ root: string;
14
+ /** Base URL prefix for urlFor()/upload() result URLs. No trailing slash required. */
15
+ baseUrl: string;
16
+ }
17
+
18
+ export function createFilesystemBlobStorage(options: FilesystemBlobOptions): BlobStoragePort {
19
+ const root = resolve(options.root);
20
+ const baseUrl = options.baseUrl.replace(/\/$/, "");
21
+
22
+ const pathFor = (key: string): string => {
23
+ const target = resolve(root, normalizeKey(key));
24
+ if (target !== root && !target.startsWith(root + sep)) {
25
+ throw new Error(`filesystem_blob_key_escapes_root:${key}`);
26
+ }
27
+ return target;
28
+ };
29
+
30
+ return {
31
+ urlFor(key: string): string {
32
+ return `${baseUrl}/${normalizeKey(key)}`;
33
+ },
34
+
35
+ async upload(key, data, _opts) {
36
+ const filePath = pathFor(key);
37
+ await fs.mkdir(dirname(filePath), { recursive: true });
38
+ await fs.writeFile(filePath, data);
39
+ return { url: this.urlFor(key), pathname: normalizeKey(key) };
40
+ },
41
+
42
+ async exists(key) {
43
+ try {
44
+ await fs.access(pathFor(key));
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ },
50
+
51
+ async delete(keyOrUrl) {
52
+ const key = stripBaseUrl(keyOrUrl, baseUrl);
53
+ await fs.rm(pathFor(key), { force: true });
54
+ },
55
+
56
+ async list(prefix) {
57
+ const dir = prefix ? pathFor(prefix) : root;
58
+ const keys = await walk(dir).catch((error: NodeJS.ErrnoException) => {
59
+ if (error.code === "ENOENT") return [];
60
+ throw error;
61
+ });
62
+ return {
63
+ keys: keys.map((absolute) => relative(root, absolute).split(sep).join("/")).sort(),
64
+ };
65
+ },
66
+ };
67
+ }
68
+
69
+ function normalizeKey(key: string): string {
70
+ return key.replace(/^\/+/, "");
71
+ }
72
+
73
+ function stripBaseUrl(keyOrUrl: string, baseUrl: string): string {
74
+ return keyOrUrl.startsWith(`${baseUrl}/`) ? keyOrUrl.slice(baseUrl.length + 1) : keyOrUrl;
75
+ }
76
+
77
+ async function walk(dir: string): Promise<string[]> {
78
+ const entries = await fs.readdir(dir, { withFileTypes: true });
79
+ const out: string[] = [];
80
+ for (const entry of entries) {
81
+ const full = join(dir, entry.name);
82
+ if (entry.isDirectory()) out.push(...(await walk(full)));
83
+ else out.push(full);
84
+ }
85
+ return out;
86
+ }
@@ -0,0 +1,17 @@
1
+ export { createFilesystemBlobStorage, type FilesystemBlobOptions } from "./filesystem.js";
2
+ export { createS3BlobStorage, type S3BlobOptions, type S3ObjectClient } from "./s3.js";
3
+ export { createS3ClientFromEnv } from "./s3Client.js";
4
+ export {
5
+ createSupabaseBlobStorage,
6
+ type SupabaseStorageBucket,
7
+ type SupabaseStorageClient,
8
+ } from "./supabase.js";
9
+ export {
10
+ createVercelBlobStorage,
11
+ parseVercelStoreId,
12
+ type VercelBlobAdapterOptions,
13
+ type VercelBlobEntry,
14
+ type VercelBlobSdk,
15
+ type VercelBlobStorage,
16
+ } from "./vercel.js";
17
+ export { bindBlobPort, type BlobBindingDeps } from "./binding.js";