@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,34 @@
1
+ /** Structural shape of the supabase-js `createClient` we depend on (injection seam for tests). */
2
+ export type SupabaseCreateClient = (url: string, key: string, options?: {
3
+ auth?: {
4
+ persistSession?: boolean;
5
+ autoRefreshToken?: boolean;
6
+ };
7
+ global?: {
8
+ headers?: Record<string, string>;
9
+ };
10
+ }) => unknown;
11
+ export interface SupabaseDataGatewayEnv {
12
+ /** Project URL (SUPABASE_URL / VITE_SUPABASE_URL). */
13
+ url: string;
14
+ /** Anon/publishable key — used for actor (RLS-bound) clients. */
15
+ anonKey: string;
16
+ /** Service-role key — used for elevated (RLS-bypassing) clients. */
17
+ serviceRoleKey: string;
18
+ }
19
+ /** Read the gateway env using the same precedence the inline sites use today. */
20
+ export declare function readSupabaseDataGatewayEnv(env: Record<string, string | undefined>): SupabaseDataGatewayEnv | null;
21
+ /** Read env for actor-only gateway consumers. Does not require the elevated service-role key. */
22
+ export declare function readSupabaseActorDataGatewayEnv(env: Record<string, string | undefined>): SupabaseDataGatewayEnv | null;
23
+ /**
24
+ * Build an elevated (service-role) supabase client — byte-identical to the inline
25
+ * `createClient(env.url, env.serviceRoleKey, { auth: { persistSession: false, autoRefreshToken: false } })`
26
+ * shape the BFF handlers use today.
27
+ */
28
+ export declare function createServiceClient(env: SupabaseDataGatewayEnv, factory: SupabaseCreateClient): unknown;
29
+ /**
30
+ * Build an actor (RLS-bound) supabase client — anon key + the actor's Bearer access token, matching
31
+ * the inline `createAdminAuthClient` / `createCustomerClient` shape. When no token is supplied the
32
+ * global Authorization header is omitted (identical to the inline `accessToken ? {...} : {}`).
33
+ */
34
+ export declare function createActorClient(env: SupabaseDataGatewayEnv, accessToken: string | null, factory: SupabaseCreateClient): unknown;
@@ -0,0 +1,56 @@
1
+ // Supabase client factory for the DataGatewayPort.
2
+ //
3
+ // ONE canonical definition of the two supabase-js client shapes BFF handlers need:
4
+ // - SERVICE-ROLE client (elevated, RLS-bypassing) — `createClient(url, serviceRoleKey, {auth:{...}})`
5
+ // - ACTOR client (RLS-bound via a Bearer access token) — anon key + `Authorization: Bearer <jwt>`
6
+ // Both use one exact option shape
7
+ // (`auth: { persistSession: false, autoRefreshToken: false }`, plus the actor's global Authorization
8
+ // header). The supabase-js `createClient`
9
+ // is always supplied by the application's composition root, so this package carries no SDK import
10
+ // and the adapter stays unit-testable without network.
11
+ /** Read the gateway env using the same precedence the inline sites use today. */
12
+ export function readSupabaseDataGatewayEnv(env) {
13
+ const url = env.SUPABASE_URL ?? env.VITE_SUPABASE_URL;
14
+ const anonKey = env.VITE_SUPABASE_PUBLISHABLE_KEY ??
15
+ env.VITE_SUPABASE_ANON_KEY ??
16
+ env.SUPABASE_ANON_KEY;
17
+ const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY;
18
+ // anonKey is only needed for actor clients; service-role work needs only url + serviceRoleKey.
19
+ // We require url + serviceRoleKey (the elevated path the default bundle uses); anonKey may be
20
+ // empty for service-only deployments and is validated lazily when an actor client is requested.
21
+ return url && serviceRoleKey ? { url, anonKey: anonKey ?? "", serviceRoleKey } : null;
22
+ }
23
+ /** Read env for actor-only gateway consumers. Does not require the elevated service-role key. */
24
+ export function readSupabaseActorDataGatewayEnv(env) {
25
+ const url = env.SUPABASE_URL ?? env.VITE_SUPABASE_URL;
26
+ const anonKey = env.VITE_SUPABASE_PUBLISHABLE_KEY ??
27
+ env.VITE_SUPABASE_ANON_KEY ??
28
+ env.SUPABASE_ANON_KEY;
29
+ return url && anonKey ? { url, anonKey, serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY ?? "" } : null;
30
+ }
31
+ /**
32
+ * Build an elevated (service-role) supabase client — byte-identical to the inline
33
+ * `createClient(env.url, env.serviceRoleKey, { auth: { persistSession: false, autoRefreshToken: false } })`
34
+ * shape the BFF handlers use today.
35
+ */
36
+ export function createServiceClient(env, factory) {
37
+ return factory(env.url, env.serviceRoleKey, {
38
+ auth: { persistSession: false, autoRefreshToken: false },
39
+ });
40
+ }
41
+ /**
42
+ * Build an actor (RLS-bound) supabase client — anon key + the actor's Bearer access token, matching
43
+ * the inline `createAdminAuthClient` / `createCustomerClient` shape. When no token is supplied the
44
+ * global Authorization header is omitted (identical to the inline `accessToken ? {...} : {}`).
45
+ */
46
+ export function createActorClient(env, accessToken, factory) {
47
+ if (!env.anonKey) {
48
+ throw new Error("supabase_data_gateway_actor_requires_anon_key");
49
+ }
50
+ return factory(env.url, env.anonKey, {
51
+ auth: { persistSession: false, autoRefreshToken: false },
52
+ global: {
53
+ headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
54
+ },
55
+ });
56
+ }
@@ -0,0 +1,22 @@
1
+ import type { DataGatewayPort } from "@openlup/core/platform-runtime";
2
+ import { type SupabaseCreateClient, type SupabaseDataGatewayEnv } from "./supabaseClientFactory.js";
3
+ type ActorClaims = {
4
+ sub?: string;
5
+ role?: string;
6
+ } | null;
7
+ export interface SupabaseDataGatewayOptions {
8
+ /** The supabase-js `createClient` (tests pass a stub). */
9
+ createClientImpl: SupabaseCreateClient;
10
+ /**
11
+ * Resolve an actor's Bearer access token from their claims. The port contract carries only
12
+ * `{ sub, role }`, but RLS needs the raw JWT; call sites that have the request token bind it here.
13
+ * Returns null → anon (RLS-bound but unauthenticated) client.
14
+ */
15
+ resolveAccessToken?: (claims: ActorClaims) => string | null;
16
+ }
17
+ /**
18
+ * Build a supabase-backed DataGatewayPort from resolved env. Pure: no client is created until a
19
+ * port method runs work (matches the per-request creation the inline sites do today).
20
+ */
21
+ export declare function createSupabaseDataGateway(env: SupabaseDataGatewayEnv, options: SupabaseDataGatewayOptions): DataGatewayPort;
22
+ export {};
@@ -0,0 +1,49 @@
1
+ // Supabase adapter for the platform-runtime DataGatewayPort.
2
+ //
3
+ // Centralizes the two persistence access modes the BFF handlers build inline today:
4
+ // - asService(work): elevated, RLS-bypassing — a service-role client (the default bundle's path).
5
+ // - asActor(claims, work): RLS-bound — an anon client carrying the actor's Bearer access token, so
6
+ // RLS evaluates the request as that principal (UUID-keep: claims.sub IS the supabase auth uuid).
7
+ // The work callback receives the concrete supabase client (typed `unknown` per the port contract;
8
+ // consumers cast to their narrow read/write surface). Behavior is byte-identical to the inline
9
+ // `createClient(...)` sites this replaces — same option shape, same Authorization header — so the
10
+ // default vercel-supabase bundle stays unchanged (golden-master byte-identical).
11
+ //
12
+ // POOLING SEMANTICS (documented, no behavior change):
13
+ // - Serverless (vercel-supabase): each invocation is short-lived; supabase-js talks to PostgREST
14
+ // over HTTP (PgBouncer in transaction-mode behind the scenes), so there is no long-lived pool to
15
+ // manage — a fresh client per asActor/asService call matches the current per-request creation and
16
+ // is correct for transaction-pooled connections (no session-level state assumed).
17
+ // - Long-running Node (node-supabase): the same supabase-js HTTP path applies; a direct-PG gateway
18
+ // (node-postgres) would instead hold a standard `pg.Pool` and run asActor work inside a
19
+ // `BEGIN; SET LOCAL request.jwt.claims = ...; ... COMMIT` tx-per-request for RLS. That is the
20
+ // MigrationRunner/postgres band's concern; this supabase adapter stays HTTP-pooled either way.
21
+ //
22
+ // asActor honors the access token supplied via `options.resolveAccessToken(claims)` (defaults to the
23
+ // claims' own bearer if present) so RLS is enforced; when no token resolves it falls back to an anon
24
+ // client (RLS still applies, just unauthenticated) rather than silently elevating — fail-safe.
25
+ import { createActorClient, createServiceClient, } from "./supabaseClientFactory.js";
26
+ /**
27
+ * Build a supabase-backed DataGatewayPort from resolved env. Pure: no client is created until a
28
+ * port method runs work (matches the per-request creation the inline sites do today).
29
+ */
30
+ export function createSupabaseDataGateway(env, options) {
31
+ const resolveAccessToken = options.resolveAccessToken ?? defaultResolveAccessToken;
32
+ return {
33
+ async asService(work) {
34
+ const client = createServiceClient(env, options.createClientImpl);
35
+ return work(client);
36
+ },
37
+ async asActor(claims, work) {
38
+ const accessToken = resolveAccessToken(claims);
39
+ const client = createActorClient(env, accessToken, options.createClientImpl);
40
+ return work(client);
41
+ },
42
+ };
43
+ }
44
+ // Default token resolver: the port's claims shape doesn't carry a raw JWT, so without an injected
45
+ // resolver an actor request maps to an anon (unauthenticated, still RLS-bound) client. Call sites
46
+ // that hold the request bearer inject `resolveAccessToken` to make RLS see the real principal.
47
+ function defaultResolveAccessToken(_claims) {
48
+ return null;
49
+ }
@@ -0,0 +1,20 @@
1
+ import type { HttpRequest, HttpResponse } from "./types.js";
2
+ export declare const NODE_HTTP_TIMEOUT_CODE = "NODE_HTTP_TIMEOUT";
3
+ export interface HttpDeadlineOptions {
4
+ req: HttpRequest;
5
+ res: HttpResponse;
6
+ durationMs: number;
7
+ run: () => Promise<unknown> | unknown;
8
+ onLateError?: (error: unknown) => void;
9
+ }
10
+ /**
11
+ * Run one Node HTTP admission from the instant before lazy loading/adaptation.
12
+ *
13
+ * A timeout before headers commit gets one minimal JSON 504. After headers are
14
+ * committed we cannot change the status safely, so the response is destroyed.
15
+ * The invocation promise remains observed, preventing a late rejection from
16
+ * becoming an unhandled rejection or a second response.
17
+ */
18
+ export declare function runWithHttpDeadline(options: HttpDeadlineOptions): Promise<void>;
19
+ /** Send a response before closing its request input, so the status is deliverable. */
20
+ export declare function closeInputAfterResponse(req: HttpRequest, res: HttpResponse): void;
@@ -0,0 +1,93 @@
1
+ // Bounded request/response lifecycle for the Node HTTP adapter.
2
+ //
3
+ // This owns transport cancellation only. It gives handlers a cooperative
4
+ // AbortSignal, but cannot roll back downstream work that ignores it.
5
+ export const NODE_HTTP_TIMEOUT_CODE = "NODE_HTTP_TIMEOUT";
6
+ /**
7
+ * Run one Node HTTP admission from the instant before lazy loading/adaptation.
8
+ *
9
+ * A timeout before headers commit gets one minimal JSON 504. After headers are
10
+ * committed we cannot change the status safely, so the response is destroyed.
11
+ * The invocation promise remains observed, preventing a late rejection from
12
+ * becoming an unhandled rejection or a second response.
13
+ */
14
+ export async function runWithHttpDeadline(options) {
15
+ const { req, res, durationMs, run, onLateError } = options;
16
+ if (!Number.isSafeInteger(durationMs) || durationMs <= 0 || durationMs > 2_147_483_647) {
17
+ throw new Error("invalid_node_http_deadline");
18
+ }
19
+ const controller = new AbortController();
20
+ // Node 24 exposes IncomingMessage.signal as a getter without a setter. Keep
21
+ // that host-native property untouched and use the portable adapter seam.
22
+ req.abortSignal = controller.signal;
23
+ const eventedReq = req;
24
+ const eventedRes = res;
25
+ const abortForClose = () => controller.abort();
26
+ eventedReq.once?.("aborted", abortForClose);
27
+ eventedRes.once?.("close", abortForClose);
28
+ let expired = false;
29
+ let finishTimeout;
30
+ const timeout = new Promise((resolve) => { finishTimeout = resolve; });
31
+ const timer = setTimeout(() => {
32
+ expired = true;
33
+ controller.abort();
34
+ if (res.destroyed || res.writableEnded) {
35
+ destroyInput(req);
36
+ return finishTimeout?.();
37
+ }
38
+ if (!res.headersSent && !res.writableEnded) {
39
+ closeInputAfterResponse(req, res);
40
+ res.statusCode = 504;
41
+ res.setHeader("content-type", "application/json; charset=utf-8");
42
+ res.end(JSON.stringify({ ok: false, error: { code: NODE_HTTP_TIMEOUT_CODE, message: "Request timed out" } }));
43
+ sealLateWrites(res);
44
+ }
45
+ else if (!res.writableEnded) {
46
+ sealLateWrites(res);
47
+ res.destroy?.();
48
+ destroyInput(req);
49
+ }
50
+ finishTimeout?.();
51
+ }, durationMs);
52
+ const invocation = Promise.resolve().then(run);
53
+ // Keep a late completion/rejection observed after timeout. It must never get a
54
+ // chance to write a second envelope, and its raw value is not client output.
55
+ void invocation.catch((error) => {
56
+ if (expired)
57
+ onLateError?.(error);
58
+ });
59
+ try {
60
+ await Promise.race([invocation.then(() => undefined), timeout]);
61
+ }
62
+ finally {
63
+ clearTimeout(timer);
64
+ eventedReq.removeListener?.("aborted", abortForClose);
65
+ eventedRes.removeListener?.("close", abortForClose);
66
+ }
67
+ }
68
+ /** Send a response before closing its request input, so the status is deliverable. */
69
+ export function closeInputAfterResponse(req, res) {
70
+ const evented = res;
71
+ let closed = false;
72
+ const destroy = () => {
73
+ if (closed)
74
+ return;
75
+ closed = true;
76
+ evented.removeListener?.("finish", destroy);
77
+ evented.removeListener?.("close", destroy);
78
+ destroyInput(req);
79
+ };
80
+ evented.once?.("finish", destroy);
81
+ evented.once?.("close", destroy);
82
+ }
83
+ function destroyInput(req) {
84
+ req.destroy?.();
85
+ }
86
+ /** Refuse late handler writes after a deadline response has committed. */
87
+ function sealLateWrites(res) {
88
+ const mutable = res;
89
+ mutable.write = () => false;
90
+ mutable.writeHead = () => mutable;
91
+ mutable.end = () => mutable;
92
+ mutable.setHeader = () => mutable;
93
+ }
@@ -0,0 +1,6 @@
1
+ export type { EntrypointLoader, HttpHandler, HttpRequest, HttpResponse } from "./types.js";
2
+ export { DEFAULT_NODE_BUFFERED_BODY_LIMIT_BYTES, NodeRequestAbortedError, NodeRequestBodyTooLargeError, adaptRequest, decorateRequest, decorateResponse, isRawStreamEntrypoint, isWebhookPath, pathnameOf, } from "./requestAdapter.js";
3
+ export { NODE_HTTP_TIMEOUT_CODE, closeInputAfterResponse, runWithHttpDeadline, type HttpDeadlineOptions, } from "./deadline.js";
4
+ export { applySecurityHeaders, contentTypeFor, looksLikeAsset, serveSpaStatic, type NodeSecurityHeaderPolicy, type StaticAssetReader, } from "./security.js";
5
+ export { createDistStaticReader } from "./staticAssets.js";
6
+ export { createNodeHttpRuntime, createRequestHandler, type HttpEntrypointRegistration, type HttpPathEntrypoint, type NodeCsrFallback, type NodeHttpRuntimeOptions, type NodeLegacyRedirect, } from "./runtime.js";
@@ -0,0 +1,5 @@
1
+ export { DEFAULT_NODE_BUFFERED_BODY_LIMIT_BYTES, NodeRequestAbortedError, NodeRequestBodyTooLargeError, adaptRequest, decorateRequest, decorateResponse, isRawStreamEntrypoint, isWebhookPath, pathnameOf, } from "./requestAdapter.js";
2
+ export { NODE_HTTP_TIMEOUT_CODE, closeInputAfterResponse, runWithHttpDeadline, } from "./deadline.js";
3
+ export { applySecurityHeaders, contentTypeFor, looksLikeAsset, serveSpaStatic, } from "./security.js";
4
+ export { createDistStaticReader } from "./staticAssets.js";
5
+ export { createNodeHttpRuntime, createRequestHandler, } from "./runtime.js";
@@ -0,0 +1,38 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { HttpRequest, HttpResponse } from "./types.js";
3
+ export declare const DEFAULT_NODE_BUFFERED_BODY_LIMIT_BYTES = 1048576;
4
+ export declare class NodeRequestBodyTooLargeError extends Error {
5
+ constructor();
6
+ }
7
+ export declare class NodeRequestAbortedError extends Error {
8
+ constructor();
9
+ }
10
+ /** A path is a webhook iff it contains a `/webhooks/` segment. */
11
+ export declare function isWebhookPath(pathname: string): boolean;
12
+ /**
13
+ * The PLATFORM's stream-first standalone entrypoints: they read the raw stream
14
+ * themselves, so the runtime must never pre-parse their body.
15
+ *
16
+ * A deployment's own stream-first endpoint is NOT listed here. It declares
17
+ * `rawStream: true` on its `HttpEntrypointRegistration` and the runtime passes that
18
+ * decision to `adaptRequest` directly — which is what stops this publishable list
19
+ * from having to name a path only that deployment owns.
20
+ */
21
+ export declare function isRawStreamEntrypoint(pathname: string): boolean;
22
+ export declare function pathnameOf(req: IncomingMessage): string;
23
+ /**
24
+ * Decorate a Node response with Vercel's status/json/send/redirect sugar (productionized from the
25
+ * dev shim's decorateResponse — identical semantics).
26
+ */
27
+ export declare function decorateResponse(res: ServerResponse): HttpResponse;
28
+ /**
29
+ * Adapt a Node IncomingMessage into the Vercel request shape, honoring the raw-body invariant:
30
+ * - raw-stream entrypoints (process/csp/feedback): stream left intact, NO req.body;
31
+ * - everything else: raw bytes buffered + re-exposed as async-iterable (so readRawBody sees the
32
+ * EXACT bytes for any signature-verifying route, webhook-segment or not) AND req.body = parsed JSON.
33
+ */
34
+ export declare function adaptRequest(req: IncomingMessage, pathname: string,
35
+ /** Explicit override for a caller that knows the entrypoint's body contract (see above). */
36
+ rawStream?: boolean, bufferedBodyLimitBytes?: number): Promise<HttpRequest>;
37
+ /** Narrow a raw IncomingMessage to HttpRequest at the server boundary (query/cookies set in adapt). */
38
+ export declare function decorateRequest(req: IncomingMessage): HttpRequest;
@@ -0,0 +1,203 @@
1
+ // Node request/response adaptation to the hosted-function request shape for the HttpRuntimePort.
2
+ //
3
+ // Owns the RAW-BODY INVARIANT: every buffered request gets the exact bytes re-exposed as an
4
+ // async-iterable (so raw-body signature verification works for ANY route, including ones outside a
5
+ // `/webhooks/` segment) while still setting a parsed JSON req.body; stream-first standalone
6
+ // entrypoints keep their live stream and NO req.body.
7
+ export const DEFAULT_NODE_BUFFERED_BODY_LIMIT_BYTES = 1_048_576;
8
+ export class NodeRequestBodyTooLargeError extends Error {
9
+ constructor() {
10
+ super("node_request_body_too_large");
11
+ }
12
+ }
13
+ export class NodeRequestAbortedError extends Error {
14
+ constructor() {
15
+ super("node_request_aborted");
16
+ }
17
+ }
18
+ /** A path is a webhook iff it contains a `/webhooks/` segment. */
19
+ export function isWebhookPath(pathname) {
20
+ return pathname.includes("/webhooks/");
21
+ }
22
+ /**
23
+ * The PLATFORM's stream-first standalone entrypoints: they read the raw stream
24
+ * themselves, so the runtime must never pre-parse their body.
25
+ *
26
+ * A deployment's own stream-first endpoint is NOT listed here. It declares
27
+ * `rawStream: true` on its `HttpEntrypointRegistration` and the runtime passes that
28
+ * decision to `adaptRequest` directly — which is what stops this publishable list
29
+ * from having to name a path only that deployment owns.
30
+ */
31
+ export function isRawStreamEntrypoint(pathname) {
32
+ return pathname === "/api/csp-report";
33
+ }
34
+ export function pathnameOf(req) {
35
+ return new URL(req.url ?? "/", "http://node.local").pathname;
36
+ }
37
+ function parseQuery(req) {
38
+ const url = new URL(req.url ?? "/", "http://node.local");
39
+ const query = {};
40
+ for (const key of url.searchParams.keys()) {
41
+ if (key in query)
42
+ continue;
43
+ const all = url.searchParams.getAll(key);
44
+ query[key] = all.length > 1 ? all : all[0];
45
+ }
46
+ return query;
47
+ }
48
+ function parseCookies(req) {
49
+ const header = req.headers.cookie;
50
+ if (!header)
51
+ return {};
52
+ const out = {};
53
+ for (const part of header.split(";")) {
54
+ const idx = part.indexOf("=");
55
+ if (idx < 0)
56
+ continue;
57
+ const key = part.slice(0, idx).trim();
58
+ const value = part.slice(idx + 1).trim();
59
+ if (key)
60
+ out[key] = decodeURIComponent(value);
61
+ }
62
+ return out;
63
+ }
64
+ /** Buffer the request stream once into a single Buffer (GET/HEAD => empty). */
65
+ async function bufferBody(req, maxBytes) {
66
+ const method = (req.method ?? "GET").toUpperCase();
67
+ if (method === "GET" || method === "HEAD")
68
+ return Buffer.alloc(0);
69
+ return new Promise((resolve, reject) => {
70
+ const chunks = [];
71
+ let bytes = 0;
72
+ let settled = false;
73
+ const finish = (result) => {
74
+ if (settled)
75
+ return;
76
+ settled = true;
77
+ req.removeListener("data", onData);
78
+ req.removeListener("end", onEnd);
79
+ req.removeListener("error", onError);
80
+ req.abortSignal?.removeEventListener("abort", onAbort);
81
+ if (result instanceof Error)
82
+ reject(result);
83
+ else
84
+ resolve(result);
85
+ };
86
+ const onData = (chunk) => {
87
+ const bytesChunk = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
88
+ bytes += bytesChunk.length;
89
+ if (bytes > maxBytes) {
90
+ finish(new NodeRequestBodyTooLargeError());
91
+ return;
92
+ }
93
+ chunks.push(bytesChunk);
94
+ };
95
+ const onEnd = () => finish(Buffer.concat(chunks));
96
+ const onError = (error) => finish(error);
97
+ const onAbort = () => finish(new NodeRequestAbortedError());
98
+ if (req.abortSignal?.aborted)
99
+ return onAbort();
100
+ req.on("data", onData);
101
+ req.once("end", onEnd);
102
+ req.once("error", onError);
103
+ req.abortSignal?.addEventListener("abort", onAbort, { once: true });
104
+ });
105
+ }
106
+ /** Re-expose a fixed Buffer as the request's async-iterable so readRawBody sees the EXACT bytes. */
107
+ function reexposeRawStream(req, raw) {
108
+ req[Symbol.asyncIterator] =
109
+ async function* () {
110
+ yield raw;
111
+ };
112
+ }
113
+ /** Best-effort JSON parse of a buffered body for handlers that read `req.body`. */
114
+ function parseJsonBody(raw, contentType) {
115
+ if (raw.length === 0)
116
+ return undefined;
117
+ const text = raw.toString("utf8").trim();
118
+ if (!text)
119
+ return undefined;
120
+ if (contentType.includes("application/json") || text.startsWith("{") || text.startsWith("[")) {
121
+ try {
122
+ return JSON.parse(text);
123
+ }
124
+ catch {
125
+ return text;
126
+ }
127
+ }
128
+ return text;
129
+ }
130
+ /**
131
+ * Decorate a Node response with Vercel's status/json/send/redirect sugar (productionized from the
132
+ * dev shim's decorateResponse — identical semantics).
133
+ */
134
+ export function decorateResponse(res) {
135
+ const vres = res;
136
+ vres.status = function status(code) {
137
+ res.statusCode = code;
138
+ return vres;
139
+ };
140
+ vres.json = function json(body) {
141
+ if (!res.hasHeader("content-type")) {
142
+ res.setHeader("content-type", "application/json; charset=utf-8");
143
+ }
144
+ res.end(JSON.stringify(body));
145
+ return vres;
146
+ };
147
+ vres.send = function send(body) {
148
+ if (Buffer.isBuffer(body) || typeof body === "string") {
149
+ res.end(body);
150
+ return vres;
151
+ }
152
+ if (!res.hasHeader("content-type")) {
153
+ res.setHeader("content-type", "application/json; charset=utf-8");
154
+ }
155
+ res.end(JSON.stringify(body));
156
+ return vres;
157
+ };
158
+ vres.redirect = function redirect(statusOrUrl, maybeUrl) {
159
+ const code = typeof statusOrUrl === "number" ? statusOrUrl : 302;
160
+ const location = typeof statusOrUrl === "number" ? maybeUrl ?? "/" : statusOrUrl;
161
+ res.statusCode = code;
162
+ res.setHeader("location", location);
163
+ res.end();
164
+ return vres;
165
+ };
166
+ return vres;
167
+ }
168
+ /**
169
+ * Adapt a Node IncomingMessage into the Vercel request shape, honoring the raw-body invariant:
170
+ * - raw-stream entrypoints (process/csp/feedback): stream left intact, NO req.body;
171
+ * - everything else: raw bytes buffered + re-exposed as async-iterable (so readRawBody sees the
172
+ * EXACT bytes for any signature-verifying route, webhook-segment or not) AND req.body = parsed JSON.
173
+ */
174
+ export async function adaptRequest(req, pathname,
175
+ /** Explicit override for a caller that knows the entrypoint's body contract (see above). */
176
+ rawStream = isRawStreamEntrypoint(pathname), bufferedBodyLimitBytes = DEFAULT_NODE_BUFFERED_BODY_LIMIT_BYTES) {
177
+ const vreq = req;
178
+ vreq.query = parseQuery(req);
179
+ vreq.cookies = parseCookies(req);
180
+ if (rawStream) {
181
+ // Leave the live stream untouched; the handler reads it itself (formidable / custom readBody).
182
+ return vreq;
183
+ }
184
+ const raw = await bufferBody(vreq, bufferedBodyLimitBytes);
185
+ const contentType = String(req.headers["content-type"] ?? "");
186
+ // Re-expose the EXACT bytes as an async-iterable for EVERY buffered request, not only
187
+ // `/webhooks/` paths. Some signature-verifying routes live outside that segment — e.g.
188
+ // `/api/bff/communications/integrations/events` calls readRawBody for HMAC verification.
189
+ // readRawBody is stream-first (isAsyncIterable), so any handler that needs the raw bytes
190
+ // works; handlers that read req.body still get parsed JSON. The re-exposed iterator is
191
+ // simply never consumed for routes that don't call readRawBody, so this is a safe superset
192
+ // of the old `/webhooks/`-only behavior (and not dependent on a fragile path convention).
193
+ reexposeRawStream(req, raw);
194
+ vreq.body = parseJsonBody(raw, contentType);
195
+ return vreq;
196
+ }
197
+ /** Narrow a raw IncomingMessage to HttpRequest at the server boundary (query/cookies set in adapt). */
198
+ export function decorateRequest(req) {
199
+ const vreq = req;
200
+ if (!vreq.query)
201
+ vreq.query = {};
202
+ return vreq;
203
+ }
@@ -0,0 +1,83 @@
1
+ import { type Server } from "node:http";
2
+ import type { HttpRuntimePort } from "@openlup/core/platform-runtime";
3
+ import type { EntrypointLoader } from "./types.js";
4
+ import type { BffDispatch, RouteEntry } from "../bff/index.js";
5
+ import { type NodeSecurityHeaderPolicy, type StaticAssetReader } from "./security.js";
6
+ /** One deployment-specific standalone entrypoint, matched by its exact path. */
7
+ export interface HttpEntrypointRegistration {
8
+ path: string;
9
+ rawStream?: boolean;
10
+ maxDurationMs?: number;
11
+ load: EntrypointLoader;
12
+ }
13
+ /**
14
+ * One deployment-specific entrypoint matched by a path rule. `match` returns the query values the
15
+ * entrypoint receives for this path, or null when the path is not its own. The body is buffered.
16
+ */
17
+ export interface HttpPathEntrypoint {
18
+ match(pathname: string): Record<string, string> | null;
19
+ load: EntrypointLoader;
20
+ /** Request deadline; defaults to 10 seconds. */
21
+ maxDurationMs?: number;
22
+ }
23
+ export interface NodeLegacyRedirect {
24
+ source: string;
25
+ destination: string;
26
+ permanent: boolean;
27
+ }
28
+ export interface NodeCsrFallback {
29
+ exactPaths: readonly string[];
30
+ routeFamilies: readonly string[];
31
+ }
32
+ export interface NodeHttpRuntimeOptions {
33
+ /** The deployment's BFF route table. */
34
+ routes: readonly RouteEntry[];
35
+ /** The dispatcher that matches `routes` (see `createBffDispatcher`). */
36
+ dispatch: BffDispatch;
37
+ /** Validated public-route contract supplied by the host composition root. */
38
+ routing?: {
39
+ legacyRedirects: readonly NodeLegacyRedirect[];
40
+ ssgPaths: readonly string[];
41
+ csrFallback: NodeCsrFallback;
42
+ };
43
+ /** Deployment header policy, including the content security policy. */
44
+ security: NodeSecurityHeaderPolicy;
45
+ /** robots.txt body for a request host; without it `/robots.txt` is served like any other path. */
46
+ robotsTxt?: (host: string | undefined) => string;
47
+ /**
48
+ * Platform-path standalone handlers: the CSP report sink at `/api/csp-report` and the hosted
49
+ * BFF router at `/api/bff-router`. A path without a handler is treated like any other path.
50
+ */
51
+ entrypoints?: {
52
+ cspReport?: EntrypointLoader;
53
+ bffRouter?: EntrypointLoader;
54
+ };
55
+ /**
56
+ * Deployment exact-path handlers, checked after the CSP report entrypoint and before the BFF
57
+ * router; a later registration replaces an earlier one with the same path.
58
+ */
59
+ extraEntrypoints?: readonly HttpEntrypointRegistration[];
60
+ /** Deployment path-rule handlers checked before the BFF dispatcher, first match wins. */
61
+ pathEntrypoints?: readonly HttpPathEntrypoint[];
62
+ /** Deployment path-rule handlers checked after legacy redirects and before static documents. */
63
+ fallbackPathEntrypoints?: readonly HttpPathEntrypoint[];
64
+ /** Registry-derived allowlist; undeclared job ids must resolve null. */
65
+ scheduledEntrypoint?: (jobId: string) => EntrypointLoader | null;
66
+ /** Explicit host deadlines; missing cells use the bounded defaults below. */
67
+ durations?: {
68
+ bff?: number;
69
+ cspReport?: number;
70
+ bffRouter?: number;
71
+ scheduled?: (jobId: string) => number;
72
+ };
73
+ /** Static SPA reader; without one, unmatched non-API paths answer 404. */
74
+ staticReader?: StaticAssetReader;
75
+ /** Structured error sink (defaults to console.error). */
76
+ onError?: (context: string, error: unknown) => void;
77
+ }
78
+ /** Build the runtime's request handler (exported for unit tests; wraps every dispatch path). */
79
+ export declare function createRequestHandler(options: NodeHttpRuntimeOptions): HttpRuntimePort["handle"];
80
+ /** Create the Node HttpRuntimePort: `handle` for one request, `listen` to bind the server. */
81
+ export declare function createNodeHttpRuntime(options: NodeHttpRuntimeOptions): HttpRuntimePort & {
82
+ server: Server | null;
83
+ };