@openlup/platform 0.0.0-stage → 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 +125 -2
  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 +108 -4
  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,17 @@
1
+ import type { HttpRequest, HttpResponse } from "../http/index.js";
2
+ export interface ObservedRequestContext {
3
+ readonly requestId: string;
4
+ }
5
+ export interface RequestContextOptions {
6
+ /** Chooses this request's reference. Called once per request. */
7
+ readRequestId: (req: HttpRequest) => string;
8
+ }
9
+ export interface RequestContextStore {
10
+ /** Create (or reuse) the request's context and attach it to the response. */
11
+ install(req: HttpRequest, res: HttpResponse): ObservedRequestContext;
12
+ /** The request's context, created on first use. */
13
+ readOrCreate(req: HttpRequest): ObservedRequestContext;
14
+ /** The context attached to a response by `install`, if any. */
15
+ readResponse(res: HttpResponse): ObservedRequestContext | undefined;
16
+ }
17
+ export declare function createRequestContextStore(options: RequestContextOptions): RequestContextStore;
@@ -0,0 +1,26 @@
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
+ export function createRequestContextStore(options) {
7
+ const requestContexts = new WeakMap();
8
+ const responseContexts = new WeakMap();
9
+ function readOrCreate(req) {
10
+ const existing = requestContexts.get(req);
11
+ if (existing)
12
+ return existing;
13
+ const context = { requestId: options.readRequestId(req) };
14
+ requestContexts.set(req, context);
15
+ return context;
16
+ }
17
+ return {
18
+ install(req, res) {
19
+ const context = readOrCreate(req);
20
+ responseContexts.set(res, context);
21
+ return context;
22
+ },
23
+ readOrCreate,
24
+ readResponse: (res) => responseContexts.get(res),
25
+ };
26
+ }
@@ -0,0 +1,2 @@
1
+ import type { HttpRequest } from "../http/index.js";
2
+ export declare function rewriteBffRouterRequestUrl(req: HttpRequest): void;
@@ -0,0 +1,31 @@
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
+ const INTERNAL_BFF_PATH_QUERY = "__bffPath";
7
+ function firstQueryValue(value) {
8
+ if (Array.isArray(value))
9
+ return value[0] ?? null;
10
+ return value ?? null;
11
+ }
12
+ // The rewrite keeps only the path and query, which do not depend on the URL base; this host only
13
+ // completes a request that carries no Host header.
14
+ const FALLBACK_HOST = "bff.invalid";
15
+ export function rewriteBffRouterRequestUrl(req) {
16
+ const routedPath = firstQueryValue(req.query?.[INTERNAL_BFF_PATH_QUERY]);
17
+ if (!routedPath)
18
+ return;
19
+ const host = typeof req.headers?.host === "string" ? req.headers.host : FALLBACK_HOST;
20
+ const currentUrl = new URL(typeof req.url === "string" ? req.url : "/", `https://${host}`);
21
+ currentUrl.searchParams.delete(INTERNAL_BFF_PATH_QUERY);
22
+ currentUrl.searchParams.delete("path");
23
+ const normalizedPath = routedPath
24
+ .split("/")
25
+ .filter(Boolean)
26
+ .map((segment) => encodeURIComponent(decodeURIComponent(segment)))
27
+ .join("/");
28
+ req.url = `/api/bff/${normalizedPath}${currentUrl.search}`;
29
+ const { [INTERNAL_BFF_PATH_QUERY]: _internal, path: _hostPath, ...query } = req.query ?? {};
30
+ req.query = query;
31
+ }
@@ -0,0 +1,16 @@
1
+ import type { BlobStoragePort } from "@openlup/core/platform-runtime";
2
+ import { type S3ObjectClient } from "./s3.js";
3
+ import { type SupabaseStorageClient } from "./supabase.js";
4
+ import { type VercelBlobSdk } from "./vercel.js";
5
+ type Env = Record<string, string | undefined>;
6
+ export interface BlobBindingDeps {
7
+ /** The `@vercel/blob` module; without it the vercel-blob capability stays unbound. */
8
+ vercelBlob?: VercelBlobSdk;
9
+ /** S3 client factory; defaults to `createS3ClientFromEnv`. */
10
+ s3ClientFactory?: (env: Env) => S3ObjectClient;
11
+ /** Ready Supabase Storage client factory; without it the supabase capability stays unbound. */
12
+ supabaseStorageClient?: () => SupabaseStorageClient;
13
+ }
14
+ /** Resolve the BlobStoragePort for a blob capability, or null when it cannot be bound. */
15
+ export declare function bindBlobPort(blobKind: string, env: Env, deps?: BlobBindingDeps): BlobStoragePort | null;
16
+ export {};
@@ -0,0 +1,37 @@
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
+ import { createFilesystemBlobStorage } from "./filesystem.js";
9
+ import { createS3BlobStorage } from "./s3.js";
10
+ import { createS3ClientFromEnv } from "./s3Client.js";
11
+ import { createSupabaseBlobStorage } from "./supabase.js";
12
+ import { createVercelBlobStorage } from "./vercel.js";
13
+ /** Resolve the BlobStoragePort for a blob capability, or null when it cannot be bound. */
14
+ export function bindBlobPort(blobKind, env, deps = {}) {
15
+ switch (blobKind) {
16
+ case "vercel-blob":
17
+ return deps.vercelBlob ? createVercelBlobStorage({ env, sdk: deps.vercelBlob }) : null;
18
+ case "filesystem":
19
+ return env.BLOB_FS_ROOT && env.BLOB_FS_BASE_URL
20
+ ? createFilesystemBlobStorage({ root: env.BLOB_FS_ROOT, baseUrl: env.BLOB_FS_BASE_URL })
21
+ : null;
22
+ case "s3":
23
+ return env.BLOB_S3_BUCKET && env.BLOB_S3_BASE_URL
24
+ ? createS3BlobStorage((deps.s3ClientFactory ?? createS3ClientFromEnv)(env), {
25
+ bucket: env.BLOB_S3_BUCKET,
26
+ baseUrl: env.BLOB_S3_BASE_URL,
27
+ })
28
+ : null;
29
+ case "supabase": {
30
+ if (!deps.supabaseStorageClient || !env.BLOB_SUPABASE_BUCKET)
31
+ return null;
32
+ return createSupabaseBlobStorage(deps.supabaseStorageClient(), env.BLOB_SUPABASE_BUCKET);
33
+ }
34
+ default:
35
+ return null;
36
+ }
37
+ }
@@ -0,0 +1,8 @@
1
+ import type { BlobStoragePort } from "@openlup/core/platform-runtime";
2
+ export interface FilesystemBlobOptions {
3
+ /** Absolute root directory blobs are written under. */
4
+ root: string;
5
+ /** Base URL prefix for urlFor()/upload() result URLs. No trailing slash required. */
6
+ baseUrl: string;
7
+ }
8
+ export declare function createFilesystemBlobStorage(options: FilesystemBlobOptions): BlobStoragePort;
@@ -0,0 +1,71 @@
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
+ import { promises as fs } from "node:fs";
7
+ import { dirname, join, relative, resolve, sep } from "node:path";
8
+ export function createFilesystemBlobStorage(options) {
9
+ const root = resolve(options.root);
10
+ const baseUrl = options.baseUrl.replace(/\/$/, "");
11
+ const pathFor = (key) => {
12
+ const target = resolve(root, normalizeKey(key));
13
+ if (target !== root && !target.startsWith(root + sep)) {
14
+ throw new Error(`filesystem_blob_key_escapes_root:${key}`);
15
+ }
16
+ return target;
17
+ };
18
+ return {
19
+ urlFor(key) {
20
+ return `${baseUrl}/${normalizeKey(key)}`;
21
+ },
22
+ async upload(key, data, _opts) {
23
+ const filePath = pathFor(key);
24
+ await fs.mkdir(dirname(filePath), { recursive: true });
25
+ await fs.writeFile(filePath, data);
26
+ return { url: this.urlFor(key), pathname: normalizeKey(key) };
27
+ },
28
+ async exists(key) {
29
+ try {
30
+ await fs.access(pathFor(key));
31
+ return true;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ },
37
+ async delete(keyOrUrl) {
38
+ const key = stripBaseUrl(keyOrUrl, baseUrl);
39
+ await fs.rm(pathFor(key), { force: true });
40
+ },
41
+ async list(prefix) {
42
+ const dir = prefix ? pathFor(prefix) : root;
43
+ const keys = await walk(dir).catch((error) => {
44
+ if (error.code === "ENOENT")
45
+ return [];
46
+ throw error;
47
+ });
48
+ return {
49
+ keys: keys.map((absolute) => relative(root, absolute).split(sep).join("/")).sort(),
50
+ };
51
+ },
52
+ };
53
+ }
54
+ function normalizeKey(key) {
55
+ return key.replace(/^\/+/, "");
56
+ }
57
+ function stripBaseUrl(keyOrUrl, baseUrl) {
58
+ return keyOrUrl.startsWith(`${baseUrl}/`) ? keyOrUrl.slice(baseUrl.length + 1) : keyOrUrl;
59
+ }
60
+ async function walk(dir) {
61
+ const entries = await fs.readdir(dir, { withFileTypes: true });
62
+ const out = [];
63
+ for (const entry of entries) {
64
+ const full = join(dir, entry.name);
65
+ if (entry.isDirectory())
66
+ out.push(...(await walk(full)));
67
+ else
68
+ out.push(full);
69
+ }
70
+ return out;
71
+ }
@@ -0,0 +1,6 @@
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 { createSupabaseBlobStorage, type SupabaseStorageBucket, type SupabaseStorageClient, } from "./supabase.js";
5
+ export { createVercelBlobStorage, parseVercelStoreId, type VercelBlobAdapterOptions, type VercelBlobEntry, type VercelBlobSdk, type VercelBlobStorage, } from "./vercel.js";
6
+ export { bindBlobPort, type BlobBindingDeps } from "./binding.js";
@@ -0,0 +1,6 @@
1
+ export { createFilesystemBlobStorage } from "./filesystem.js";
2
+ export { createS3BlobStorage } from "./s3.js";
3
+ export { createS3ClientFromEnv } from "./s3Client.js";
4
+ export { createSupabaseBlobStorage, } from "./supabase.js";
5
+ export { createVercelBlobStorage, parseVercelStoreId, } from "./vercel.js";
6
+ export { bindBlobPort } from "./binding.js";
@@ -0,0 +1,32 @@
1
+ import type { BlobStoragePort } from "@openlup/core/platform-runtime";
2
+ export interface S3ObjectClient {
3
+ putObject(input: {
4
+ Bucket: string;
5
+ Key: string;
6
+ Body: Uint8Array | Buffer;
7
+ ContentType?: string;
8
+ }): Promise<unknown>;
9
+ headObject(input: {
10
+ Bucket: string;
11
+ Key: string;
12
+ }): Promise<unknown>;
13
+ deleteObject(input: {
14
+ Bucket: string;
15
+ Key: string;
16
+ }): Promise<unknown>;
17
+ listObjectsV2(input: {
18
+ Bucket: string;
19
+ Prefix?: string;
20
+ }): Promise<{
21
+ Contents?: Array<{
22
+ Key?: string;
23
+ }>;
24
+ }>;
25
+ }
26
+ export interface S3BlobOptions {
27
+ bucket: string;
28
+ /** Public base URL for objects, e.g. https://cdn.example.com or https://s3.region.amazonaws.com/bucket. */
29
+ baseUrl: string;
30
+ defaultContentType?: string;
31
+ }
32
+ export declare function createS3BlobStorage(client: S3ObjectClient, options: S3BlobOptions): BlobStoragePort;
@@ -0,0 +1,56 @@
1
+ // S3-compatible adapter for the platform-runtime BlobStoragePort.
2
+ //
3
+ // Targets any S3-compatible object store (AWS S3, MinIO, R2, Spaces) used by a self-hosted
4
+ // node-postgres bundle. To stay credential-free in CI and avoid pulling a new SDK dependency
5
+ // into this wave, it depends on a minimal STRUCTURAL client interface (PutObject/HeadObject/
6
+ // DeleteObject/ListObjectsV2). A thin factory over `@aws-sdk/client-s3` satisfies this shape;
7
+ // tests inject a fake. URLs are derived from a configurable public base (CDN or path-style).
8
+ export function createS3BlobStorage(client, options) {
9
+ const bucket = options.bucket;
10
+ const baseUrl = options.baseUrl.replace(/\/$/, "");
11
+ const defaultContentType = options.defaultContentType ?? "application/octet-stream";
12
+ return {
13
+ urlFor(key) {
14
+ return `${baseUrl}/${normalizeKey(key)}`;
15
+ },
16
+ async upload(key, data, opts) {
17
+ const objectKey = normalizeKey(key);
18
+ await client.putObject({
19
+ Bucket: bucket,
20
+ Key: objectKey,
21
+ Body: data,
22
+ ContentType: opts?.contentType ?? defaultContentType,
23
+ });
24
+ return { url: this.urlFor(objectKey), pathname: objectKey };
25
+ },
26
+ async exists(key) {
27
+ try {
28
+ await client.headObject({ Bucket: bucket, Key: normalizeKey(key) });
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ },
35
+ async delete(keyOrUrl) {
36
+ await client.deleteObject({ Bucket: bucket, Key: stripBaseUrl(keyOrUrl, baseUrl) });
37
+ },
38
+ async list(prefix) {
39
+ const result = await client.listObjectsV2({
40
+ Bucket: bucket,
41
+ Prefix: prefix ? normalizeKey(prefix) : undefined,
42
+ });
43
+ const keys = (result.Contents ?? [])
44
+ .map((entry) => entry.Key)
45
+ .filter((key) => typeof key === "string")
46
+ .sort();
47
+ return { keys };
48
+ },
49
+ };
50
+ }
51
+ function normalizeKey(key) {
52
+ return key.replace(/^\/+/, "");
53
+ }
54
+ function stripBaseUrl(keyOrUrl, baseUrl) {
55
+ return keyOrUrl.startsWith(`${baseUrl}/`) ? normalizeKey(keyOrUrl.slice(baseUrl.length + 1)) : normalizeKey(keyOrUrl);
56
+ }
@@ -0,0 +1,25 @@
1
+ import type { S3ObjectClient } from "./s3.js";
2
+ type Env = Record<string, string | undefined>;
3
+ export interface S3Sdk {
4
+ S3Client: new (config: {
5
+ region: string;
6
+ endpoint?: string;
7
+ forcePathStyle: boolean;
8
+ credentials?: {
9
+ accessKeyId: string;
10
+ secretAccessKey: string;
11
+ };
12
+ }) => {
13
+ send(command: unknown): ReturnType<S3ObjectClient["listObjectsV2"]>;
14
+ };
15
+ PutObjectCommand: new (input: Parameters<S3ObjectClient["putObject"]>[0]) => unknown;
16
+ HeadObjectCommand: new (input: Parameters<S3ObjectClient["headObject"]>[0]) => unknown;
17
+ DeleteObjectCommand: new (input: Parameters<S3ObjectClient["deleteObject"]>[0]) => unknown;
18
+ ListObjectsV2Command: new (input: Parameters<S3ObjectClient["listObjectsV2"]>[0]) => unknown;
19
+ }
20
+ /**
21
+ * Build an `S3ObjectClient` whose SDK client is created lazily from the given environment.
22
+ * `loadSdk` replaces the SDK loader (tests).
23
+ */
24
+ export declare function createS3ClientFromEnv(env: Env, loadSdk?: () => S3Sdk): S3ObjectClient;
25
+ export {};
@@ -0,0 +1,76 @@
1
+ // Default S3 client for the S3-compatible blob capability, built from the environment.
2
+ //
3
+ // `@aws-sdk/client-s3` is an optional peer: it is loaded on the first storage call, through Node's
4
+ // own module resolution, so an application that never selects S3 needs neither the SDK nor its
5
+ // settings. Settings:
6
+ // BLOB_S3_REGION (default us-east-1), BLOB_S3_ENDPOINT (an S3-compatible service; path-style
7
+ // addressing follows from BLOB_S3_FORCE_PATH_STYLE=true), and BLOB_S3_ACCESS_KEY_ID with
8
+ // BLOB_S3_SECRET_ACCESS_KEY (both, or neither to use the SDK's default credential chain).
9
+ import { createRequire } from "node:module";
10
+ function setting(env, name) {
11
+ const value = env[name]?.trim();
12
+ return value ? value : undefined;
13
+ }
14
+ function loadS3Sdk() {
15
+ return createRequire(import.meta.url)("@aws-sdk/client-s3");
16
+ }
17
+ /**
18
+ * Build an `S3ObjectClient` whose SDK client is created lazily from the given environment.
19
+ * `loadSdk` replaces the SDK loader (tests).
20
+ */
21
+ export function createS3ClientFromEnv(env, loadSdk = loadS3Sdk) {
22
+ const accessKeyId = setting(env, "BLOB_S3_ACCESS_KEY_ID");
23
+ const secretAccessKey = setting(env, "BLOB_S3_SECRET_ACCESS_KEY");
24
+ if ((accessKeyId === undefined) !== (secretAccessKey === undefined)) {
25
+ throw new Error("blob_s3_credentials_incomplete");
26
+ }
27
+ let loaded = null;
28
+ const connect = async () => {
29
+ if (loaded)
30
+ return loaded;
31
+ let sdk;
32
+ try {
33
+ sdk = loadSdk();
34
+ }
35
+ catch (cause) {
36
+ peerMissing(cause);
37
+ }
38
+ loaded = {
39
+ sdk,
40
+ client: new sdk.S3Client({
41
+ region: setting(env, "BLOB_S3_REGION") ?? "us-east-1",
42
+ endpoint: setting(env, "BLOB_S3_ENDPOINT"),
43
+ forcePathStyle: setting(env, "BLOB_S3_FORCE_PATH_STYLE") === "true",
44
+ credentials: accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined,
45
+ }),
46
+ };
47
+ return loaded;
48
+ };
49
+ return {
50
+ async putObject(input) {
51
+ const { sdk, client } = await connect();
52
+ return client.send(new sdk.PutObjectCommand(input));
53
+ },
54
+ async headObject(input) {
55
+ const { sdk, client } = await connect();
56
+ return client.send(new sdk.HeadObjectCommand(input));
57
+ },
58
+ async deleteObject(input) {
59
+ const { sdk, client } = await connect();
60
+ return client.send(new sdk.DeleteObjectCommand(input));
61
+ },
62
+ async listObjectsV2(input) {
63
+ const { sdk, client } = await connect();
64
+ return client.send(new sdk.ListObjectsV2Command(input));
65
+ },
66
+ };
67
+ }
68
+ /** Translate only absence of this optional peer; preserve broken SDKs and other failures. */
69
+ function peerMissing(cause) {
70
+ if (cause instanceof Error && "code" in cause
71
+ && (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND")
72
+ && /^Cannot find (?:package|module) ['"]@aws-sdk\/client-s3['"]/.test(cause.message)) {
73
+ throw new Error("OPENLUP_E_PEER_MISSING: @aws-sdk/client-s3 is required by the S3 blob capability; install it alongside @openlup/platform", { cause });
74
+ }
75
+ throw cause;
76
+ }
@@ -0,0 +1,40 @@
1
+ import type { BlobStoragePort } from "@openlup/core/platform-runtime";
2
+ /** Minimal structural shape of the Supabase Storage bucket API we depend on. */
3
+ export interface SupabaseStorageBucket {
4
+ upload(path: string, bytes: Uint8Array | Buffer, options?: {
5
+ contentType?: string;
6
+ upsert?: boolean;
7
+ }): PromiseLike<{
8
+ data: unknown;
9
+ error: {
10
+ message?: string;
11
+ } | null;
12
+ }>;
13
+ remove(paths: string[]): PromiseLike<{
14
+ data: unknown;
15
+ error: {
16
+ message?: string;
17
+ } | null;
18
+ }>;
19
+ list(prefix?: string): PromiseLike<{
20
+ data: Array<{
21
+ name: string;
22
+ }> | null;
23
+ error: {
24
+ message?: string;
25
+ } | null;
26
+ }>;
27
+ getPublicUrl(path: string): {
28
+ data: {
29
+ publicUrl: string;
30
+ };
31
+ };
32
+ }
33
+ export interface SupabaseStorageClient {
34
+ storage: {
35
+ from(bucket: string): SupabaseStorageBucket;
36
+ };
37
+ }
38
+ export declare function createSupabaseBlobStorage(client: SupabaseStorageClient, bucket: string, options?: {
39
+ defaultContentType?: string;
40
+ }): BlobStoragePort;
@@ -0,0 +1,59 @@
1
+ // Supabase Storage adapter for the platform-runtime BlobStoragePort.
2
+ //
3
+ // Wraps `client.storage.from(bucket)` 1:1 with the existing application storage usage:
4
+ // upsert uploads, public-URL resolution via getPublicUrl, and bucket-scoped list/remove. Keys
5
+ // are bucket-relative paths. Behavior matches the current inline storage calls so the default
6
+ // vercel-supabase bundle (which uses this for storage buckets) is byte-identical.
7
+ export function createSupabaseBlobStorage(client, bucket, options = {}) {
8
+ const from = () => client.storage.from(bucket);
9
+ const defaultContentType = options.defaultContentType ?? "application/octet-stream";
10
+ return {
11
+ urlFor(key) {
12
+ return from().getPublicUrl(key).data.publicUrl;
13
+ },
14
+ async upload(key, data, opts) {
15
+ const bucketApi = from();
16
+ const result = await bucketApi.upload(key, data, {
17
+ contentType: opts?.contentType ?? defaultContentType,
18
+ upsert: true,
19
+ });
20
+ if (result.error) {
21
+ throw new Error(`supabase_storage_upload_failed:${result.error.message ?? "unknown"}`);
22
+ }
23
+ return { url: bucketApi.getPublicUrl(key).data.publicUrl, pathname: key };
24
+ },
25
+ async exists(key) {
26
+ // Supabase has no head(); list the parent folder and match the leaf name.
27
+ const slash = key.lastIndexOf("/");
28
+ const folder = slash >= 0 ? key.slice(0, slash) : "";
29
+ const leaf = slash >= 0 ? key.slice(slash + 1) : key;
30
+ const result = await from().list(folder || undefined);
31
+ if (result.error)
32
+ return false;
33
+ return (result.data ?? []).some((entry) => entry.name === leaf);
34
+ },
35
+ async delete(keyOrUrl) {
36
+ const result = await from().remove([toBucketKey(keyOrUrl, bucket)]);
37
+ if (result.error) {
38
+ throw new Error(`supabase_storage_remove_failed:${result.error.message ?? "unknown"}`);
39
+ }
40
+ },
41
+ async list(prefix) {
42
+ const result = await from().list(prefix);
43
+ if (result.error) {
44
+ throw new Error(`supabase_storage_list_failed:${result.error.message ?? "unknown"}`);
45
+ }
46
+ const base = prefix ? `${prefix.replace(/\/$/, "")}/` : "";
47
+ return { keys: (result.data ?? []).map((entry) => `${base}${entry.name}`) };
48
+ },
49
+ };
50
+ }
51
+ // Accept either a bucket-relative key or a full public URL; strip everything up to and
52
+ // including the bucket segment so remove() always gets a bucket-relative path.
53
+ function toBucketKey(keyOrUrl, bucket) {
54
+ const marker = `/storage/v1/object/public/${bucket}/`;
55
+ const idx = keyOrUrl.indexOf(marker);
56
+ if (idx >= 0)
57
+ return keyOrUrl.slice(idx + marker.length);
58
+ return keyOrUrl;
59
+ }
@@ -0,0 +1,63 @@
1
+ import type { BlobStoragePort } from "@openlup/core/platform-runtime";
2
+ /**
3
+ * The `@vercel/blob` functions this adapter calls, supplied by the application (pass the module
4
+ * itself). The SDK reads its own credentials from the environment, as before.
5
+ */
6
+ export interface VercelBlobSdk {
7
+ put(pathname: string, body: Buffer, options: {
8
+ access: "public";
9
+ contentType: string;
10
+ addRandomSuffix: boolean;
11
+ cacheControlMaxAge: number;
12
+ }): Promise<{
13
+ url: string;
14
+ pathname: string;
15
+ }>;
16
+ head(url: string): Promise<{
17
+ url?: string;
18
+ } | null | undefined>;
19
+ del(url: string): Promise<unknown>;
20
+ list(options: {
21
+ prefix?: string;
22
+ }): Promise<{
23
+ blobs: Array<{
24
+ pathname: string;
25
+ url: string;
26
+ uploadedAt: Date;
27
+ }>;
28
+ }>;
29
+ }
30
+ export interface VercelBlobAdapterOptions {
31
+ /** The `@vercel/blob` module. */
32
+ sdk: VercelBlobSdk;
33
+ /** Environment whose BLOB_READ_WRITE_TOKEN is read at each call when `token` is absent. */
34
+ env: Record<string, string | undefined>;
35
+ /**
36
+ * Explicit token for deriving the public store URL (`urlFor`, `exists`); takes precedence over
37
+ * `env`. Upload, list and delete use the credentials the injected SDK reads itself, so `env` and
38
+ * `token` must name the same store as the SDK's own configuration.
39
+ */
40
+ token?: string;
41
+ /** Default content-type when an upload omits one (image/png when absent). */
42
+ defaultContentType?: string;
43
+ cacheControlMaxAge?: number;
44
+ }
45
+ /** A listed blob plus the Vercel-specific metadata (uploadedAt) used by TTL cleanup. */
46
+ export interface VercelBlobEntry {
47
+ pathname: string;
48
+ url: string;
49
+ uploadedAt: Date;
50
+ }
51
+ /**
52
+ * Vercel-specific extension over the neutral BlobStoragePort.list(): also surfaces uploadedAt
53
+ * so the TTL cron can prune by age. Not part of the port contract (other backends carry no
54
+ * equivalent metadata); consumers needing it depend on this adapter shape explicitly.
55
+ */
56
+ export interface VercelBlobStorage extends BlobStoragePort {
57
+ listDetailed(prefix?: string): Promise<{
58
+ blobs: VercelBlobEntry[];
59
+ }>;
60
+ }
61
+ /** Parse the public store id from a `vercel_blob_rw_{storeId}_{secret}` token. */
62
+ export declare function parseVercelStoreId(token: string | undefined): string;
63
+ export declare function createVercelBlobStorage(options: VercelBlobAdapterOptions): VercelBlobStorage;
@@ -0,0 +1,69 @@
1
+ // Vercel Blob adapter for the platform-runtime BlobStoragePort.
2
+ //
3
+ // Wraps the CURRENT `@vercel/blob` behavior 1:1 so the default vercel-supabase bundle is
4
+ // byte-identical: public access, image content-type pass-through, no random suffix, the same
5
+ // 30-day cache-control, O(1) head() existence checks, and the deterministic public CDN URL
6
+ // derived from the store id encoded in BLOB_READ_WRITE_TOKEN. Operates on FULL keys
7
+ // (pathnames); any key prefix or suffix stays in the consuming facade.
8
+ const DEFAULT_CACHE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days
9
+ /** Parse the public store id from a `vercel_blob_rw_{storeId}_{secret}` token. */
10
+ export function parseVercelStoreId(token) {
11
+ if (!token)
12
+ throw new Error("BLOB_READ_WRITE_TOKEN missing");
13
+ const m = token.match(/^vercel_blob_rw_([a-zA-Z0-9]+)_/);
14
+ if (!m)
15
+ throw new Error("invalid BLOB_READ_WRITE_TOKEN format");
16
+ return m[1].toLowerCase();
17
+ }
18
+ export function createVercelBlobStorage(options) {
19
+ const resolveToken = () => options.token ?? options.env.BLOB_READ_WRITE_TOKEN;
20
+ const cacheControlMaxAge = options.cacheControlMaxAge ?? DEFAULT_CACHE_MAX_AGE_SECONDS;
21
+ const defaultContentType = options.defaultContentType ?? "image/png";
22
+ return {
23
+ urlFor(key) {
24
+ const storeId = parseVercelStoreId(resolveToken());
25
+ return `https://${storeId}.public.blob.vercel-storage.com/${key}`;
26
+ },
27
+ async upload(key, data, opts) {
28
+ const result = await options.sdk.put(key, toUploadBody(data), {
29
+ access: "public",
30
+ contentType: opts?.contentType ?? defaultContentType,
31
+ addRandomSuffix: false,
32
+ cacheControlMaxAge,
33
+ });
34
+ return { url: result.url, pathname: result.pathname };
35
+ },
36
+ async exists(key) {
37
+ try {
38
+ const result = await options.sdk.head(this.urlFor(key));
39
+ return Boolean(result?.url);
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ },
45
+ async delete(keyOrUrl) {
46
+ // del() accepts a full blob URL or pathname; pass through unchanged.
47
+ await options.sdk.del(keyOrUrl);
48
+ },
49
+ async list(prefix) {
50
+ const result = await options.sdk.list(prefix ? { prefix } : {});
51
+ return { keys: result.blobs.map((blob) => blob.pathname) };
52
+ },
53
+ async listDetailed(prefix) {
54
+ const result = await options.sdk.list(prefix ? { prefix } : {});
55
+ return {
56
+ blobs: result.blobs.map((blob) => ({
57
+ pathname: blob.pathname,
58
+ url: blob.url,
59
+ uploadedAt: blob.uploadedAt,
60
+ })),
61
+ };
62
+ },
63
+ };
64
+ }
65
+ // @vercel/blob's put() accepts string | Buffer | Blob | ReadableStream etc. Buffer is a
66
+ // Uint8Array subclass, so both port input shapes pass straight through.
67
+ function toUploadBody(data) {
68
+ return Buffer.isBuffer(data) ? data : Buffer.from(data);
69
+ }