@orthacms/media-provider-vercel-blob 0.4.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ortha CMS contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # @orthacms/media-provider-vercel-blob
2
+
3
+ Part of [Ortha CMS](https://github.com/ortha-source/ortha-cms).
4
+
5
+ ```sh
6
+ npm install @orthacms/media-provider-vercel-blob
7
+ ```
@@ -0,0 +1,3 @@
1
+ export { createVercelBlobStorageProvider } from './lib/vercel-blob-storage-provider';
2
+ export type { VercelBlobApi, VercelBlobStorageConfig } from './lib/vercel-blob-storage-provider';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,+BAA+B,EAAE,MAAM,oCAAoC,CAAC;AACrF,YAAY,EACR,aAAa,EACb,uBAAuB,EAC1B,MAAM,oCAAoC,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createVercelBlobStorageProvider = void 0;
4
+ var vercel_blob_storage_provider_1 = require("./lib/vercel-blob-storage-provider");
5
+ Object.defineProperty(exports, "createVercelBlobStorageProvider", { enumerable: true, get: function () { return vercel_blob_storage_provider_1.createVercelBlobStorageProvider; } });
@@ -0,0 +1,35 @@
1
+ import type { VercelBlobApi } from './vercel-blob-storage-provider';
2
+ /** One blob, as the fake holds it. */
3
+ interface FakeBlob {
4
+ body: Buffer;
5
+ contentType?: string;
6
+ }
7
+ /**
8
+ * A stand-in for the three `@vercel/blob` functions this adapter calls, plus a
9
+ * `fetch` that serves what they stored.
10
+ *
11
+ * The SDK exports free functions rather than a client, so the seam is the small
12
+ * `VercelBlobApi` interface the provider already accepts — the same one a
13
+ * caller could substitute to route through their own transport.
14
+ *
15
+ * It proves this adapter's logic and nothing about Vercel Blob. A real store is
16
+ * the acceptance step, and `AGENTS.md` says so.
17
+ */
18
+ export declare class FakeBlobApi {
19
+ readonly blobs: Map<string, FakeBlob>;
20
+ /** Base the fake's public URLs hang off. */
21
+ readonly host = "https://fake.public.blob.vercel-storage.com";
22
+ /** Make the next `put` fail, to exercise the cleanup path. */
23
+ failNextPut: boolean;
24
+ /** Pathnames `del` was called for, in order. */
25
+ readonly deleted: string[];
26
+ /** Raised by `head` when set — anything that is not "missing". */
27
+ headError: Error | undefined;
28
+ readonly api: VercelBlobApi;
29
+ /** Serves the fake's blobs, so `get` can be exercised end to end. */
30
+ fetch: (input: string | URL) => Promise<Response>;
31
+ /** Pathnames currently held, sorted. */
32
+ keys(): string[];
33
+ }
34
+ export {};
35
+ //# sourceMappingURL=fake-blob-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fake-blob-api.d.ts","sourceRoot":"","sources":["../../src/lib/fake-blob-api.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AAEpE,sCAAsC;AACtC,UAAU,QAAQ;IACd,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AAaD;;;;;;;;;;GAUG;AACH,qBAAa,WAAW;IACpB,QAAQ,CAAC,KAAK,wBAA+B;IAC7C,4CAA4C;IAC5C,QAAQ,CAAC,IAAI,iDAAiD;IAC9D,8DAA8D;IAC9D,WAAW,UAAS;IACpB,gDAAgD;IAChD,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,CAAM;IAChC,kEAAkE;IAClE,SAAS,EAAE,KAAK,GAAG,SAAS,CAAC;IAE7B,QAAQ,CAAC,GAAG,EAAE,aAAa,CA0CzB;IAEF,qEAAqE;IACrE,KAAK,GAAI,OAAO,MAAM,GAAG,GAAG,KAAG,OAAO,CAAC,QAAQ,CAAC,CAU9C;IAEF,wCAAwC;IACxC,IAAI,IAAI,MAAM,EAAE;CAGnB"}
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FakeBlobApi = void 0;
4
+ const blob_1 = require("@vercel/blob");
5
+ /** Drains an upload body into one buffer. */
6
+ async function collect(body) {
7
+ if (Buffer.isBuffer(body))
8
+ return body;
9
+ if (typeof body === 'string')
10
+ return Buffer.from(body);
11
+ const chunks = [];
12
+ for await (const chunk of body) {
13
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
14
+ }
15
+ return Buffer.concat(chunks);
16
+ }
17
+ /**
18
+ * A stand-in for the three `@vercel/blob` functions this adapter calls, plus a
19
+ * `fetch` that serves what they stored.
20
+ *
21
+ * The SDK exports free functions rather than a client, so the seam is the small
22
+ * `VercelBlobApi` interface the provider already accepts — the same one a
23
+ * caller could substitute to route through their own transport.
24
+ *
25
+ * It proves this adapter's logic and nothing about Vercel Blob. A real store is
26
+ * the acceptance step, and `AGENTS.md` says so.
27
+ */
28
+ class FakeBlobApi {
29
+ blobs = new Map();
30
+ /** Base the fake's public URLs hang off. */
31
+ host = 'https://fake.public.blob.vercel-storage.com';
32
+ /** Make the next `put` fail, to exercise the cleanup path. */
33
+ failNextPut = false;
34
+ /** Pathnames `del` was called for, in order. */
35
+ deleted = [];
36
+ /** Raised by `head` when set — anything that is not "missing". */
37
+ headError;
38
+ api = {
39
+ put: (async (pathname, body, options) => {
40
+ const bytes = await collect(body);
41
+ if (this.failNextPut) {
42
+ this.failNextPut = false;
43
+ throw new Error('put failed');
44
+ }
45
+ this.blobs.set(pathname, {
46
+ body: bytes,
47
+ contentType: options?.contentType
48
+ });
49
+ return {
50
+ url: `${this.host}/${pathname}`,
51
+ pathname,
52
+ contentType: options?.contentType
53
+ };
54
+ }),
55
+ head: (async (pathname) => {
56
+ if (this.headError)
57
+ throw this.headError;
58
+ const blob = this.blobs.get(pathname);
59
+ if (!blob) {
60
+ throw new blob_1.BlobNotFoundError();
61
+ }
62
+ return {
63
+ url: `${this.host}/${pathname}`,
64
+ pathname,
65
+ size: blob.body.byteLength,
66
+ contentType: blob.contentType
67
+ };
68
+ }),
69
+ del: (async (pathname) => {
70
+ for (const one of Array.isArray(pathname) ? pathname : [pathname]) {
71
+ this.deleted.push(one);
72
+ this.blobs.delete(one);
73
+ }
74
+ })
75
+ };
76
+ /** Serves the fake's blobs, so `get` can be exercised end to end. */
77
+ fetch = (input) => {
78
+ const url = typeof input === 'string' ? input : input.toString();
79
+ const pathname = url.slice(this.host.length + 1);
80
+ const blob = this.blobs.get(pathname);
81
+ if (!blob) {
82
+ return Promise.resolve(new Response(null, { status: 404 }));
83
+ }
84
+ return Promise.resolve(new Response(new Uint8Array(blob.body), { status: 200 }));
85
+ };
86
+ /** Pathnames currently held, sorted. */
87
+ keys() {
88
+ return [...this.blobs.keys()].sort();
89
+ }
90
+ }
91
+ exports.FakeBlobApi = FakeBlobApi;
@@ -0,0 +1,50 @@
1
+ import { del, head, put } from '@vercel/blob';
2
+ import type { StorageProvider } from '@orthacms/media-server';
3
+ /**
4
+ * The three calls this adapter makes, as an injectable seam.
5
+ *
6
+ * `@vercel/blob` exports free functions rather than a client, so there is no
7
+ * object to hand in — this is the shape the tests substitute, and the shape a
8
+ * caller could substitute to route through their own fetch stack.
9
+ */
10
+ export interface VercelBlobApi {
11
+ put: typeof put;
12
+ head: typeof head;
13
+ del: typeof del;
14
+ }
15
+ /** Settings for Vercel Blob. */
16
+ export interface VercelBlobStorageConfig {
17
+ /**
18
+ * Read-write token. Omit on Vercel itself, where `BLOB_READ_WRITE_TOKEN` is
19
+ * injected into the environment and the SDK reads it.
20
+ */
21
+ token?: string;
22
+ /** Prefix every pathname with this, e.g. to share a store. */
23
+ keyPrefix?: string;
24
+ /** The SDK functions, for tests or a custom transport. */
25
+ api?: VercelBlobApi;
26
+ }
27
+ /**
28
+ * Vercel Blob.
29
+ *
30
+ * **Read this before choosing it: every blob is world-readable.** Vercel Blob
31
+ * has one access mode, `public`, and the URL it returns is a permanent,
32
+ * unguessable, unauthenticated link to the bytes. Anyone who obtains that URL —
33
+ * from a copied `<img src>`, a browser extension, a proxy log, a forwarded
34
+ * email — can fetch the asset forever, with no reference to who they are.
35
+ *
36
+ * The Media Library is otherwise **private by default**: `GET
37
+ * /media/assets/:id/raw` checks workspace membership on every request, and a
38
+ * non-member gets the same 404 as a missing asset. This backend cannot uphold
39
+ * that for anyone holding the underlying URL. The app keeps enforcing its own
40
+ * rules — the API never returns the blob URL, only `/media/assets/:id/raw` —
41
+ * but the second copy of the bytes is public and that cannot be revoked short
42
+ * of deleting the blob.
43
+ *
44
+ * Fine for a marketing site's images. Not fine for a workspace whose media is
45
+ * confidential. That is a deployment decision, not something this adapter can
46
+ * paper over — so it is stated here, in `AGENTS.md`, and in the scaffolder's
47
+ * own hint.
48
+ */
49
+ export declare function createVercelBlobStorageProvider(config?: VercelBlobStorageConfig): StorageProvider;
50
+ //# sourceMappingURL=vercel-blob-storage-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vercel-blob-storage-provider.d.ts","sourceRoot":"","sources":["../../src/lib/vercel-blob-storage-provider.ts"],"names":[],"mappings":"AAGA,OAAO,EAAqB,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,cAAc,CAAC;AAEjE,OAAO,KAAK,EAER,eAAe,EAElB,MAAM,wBAAwB,CAAC;AAEhC;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC1B,GAAG,EAAE,OAAO,GAAG,CAAC;IAChB,IAAI,EAAE,OAAO,IAAI,CAAC;IAClB,GAAG,EAAE,OAAO,GAAG,CAAC;CACnB;AAED,gCAAgC;AAChC,MAAM,WAAW,uBAAuB;IACpC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0DAA0D;IAC1D,GAAG,CAAC,EAAE,aAAa,CAAC;CACvB;AAkBD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,+BAA+B,CAC3C,MAAM,GAAE,uBAA4B,GACrC,eAAe,CA4HjB"}
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createVercelBlobStorageProvider = createVercelBlobStorageProvider;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const node_stream_1 = require("node:stream");
6
+ const promises_1 = require("node:stream/promises");
7
+ const blob_1 = require("@vercel/blob");
8
+ const media_server_1 = require("@orthacms/media-server");
9
+ /** Reduces a file name to one safe key segment. Mirrors the other providers. */
10
+ function sanitize(fileName) {
11
+ const cleaned = fileName.replace(/[^A-Za-z0-9_.-]+/g, '_');
12
+ return cleaned === '.' || cleaned === '..' ? `_${cleaned}` : cleaned;
13
+ }
14
+ /** True for the shapes the SDK and its CDN use to say "no such blob". */
15
+ function isMissing(error) {
16
+ const candidate = error;
17
+ return (error instanceof blob_1.BlobNotFoundError ||
18
+ candidate?.name === 'BlobNotFoundError' ||
19
+ candidate?.status === 404);
20
+ }
21
+ /**
22
+ * Vercel Blob.
23
+ *
24
+ * **Read this before choosing it: every blob is world-readable.** Vercel Blob
25
+ * has one access mode, `public`, and the URL it returns is a permanent,
26
+ * unguessable, unauthenticated link to the bytes. Anyone who obtains that URL —
27
+ * from a copied `<img src>`, a browser extension, a proxy log, a forwarded
28
+ * email — can fetch the asset forever, with no reference to who they are.
29
+ *
30
+ * The Media Library is otherwise **private by default**: `GET
31
+ * /media/assets/:id/raw` checks workspace membership on every request, and a
32
+ * non-member gets the same 404 as a missing asset. This backend cannot uphold
33
+ * that for anyone holding the underlying URL. The app keeps enforcing its own
34
+ * rules — the API never returns the blob URL, only `/media/assets/:id/raw` —
35
+ * but the second copy of the bytes is public and that cannot be revoked short
36
+ * of deleting the blob.
37
+ *
38
+ * Fine for a marketing site's images. Not fine for a workspace whose media is
39
+ * confidential. That is a deployment decision, not something this adapter can
40
+ * paper over — so it is stated here, in `AGENTS.md`, and in the scaffolder's
41
+ * own hint.
42
+ */
43
+ function createVercelBlobStorageProvider(config = {}) {
44
+ const api = config.api ?? { put: blob_1.put, head: blob_1.head, del: blob_1.del };
45
+ const prefix = config.keyPrefix?.replace(/^\/+|\/+$/g, '');
46
+ const token = config.token ? { token: config.token } : {};
47
+ /** The public URL behind a stored pathname, via the metadata call. */
48
+ const urlFor = async (storageKey) => {
49
+ try {
50
+ const meta = await api.head(storageKey, token);
51
+ return meta.url;
52
+ }
53
+ catch (error) {
54
+ if (isMissing(error)) {
55
+ throw new media_server_1.ObjectNotFoundError(storageKey, error);
56
+ }
57
+ throw error;
58
+ }
59
+ };
60
+ return {
61
+ id: 'vercel-blob',
62
+ capabilities: {
63
+ // The store's URL is public and **permanent** — it neither expires
64
+ // nor carries a disposition we can pin per request. The port's
65
+ // `directUrl` promises both, so declaring it here would be a lie
66
+ // that hands out an unrevocable link and serves an uploaded `.html`
67
+ // inline from the blob host. Downloads stay proxied, which is also
68
+ // what keeps the app's membership check on the request path.
69
+ directUrl: false,
70
+ contentTypeMetadata: true,
71
+ streamingPut: true
72
+ },
73
+ async put(object) {
74
+ const storageKey = [
75
+ prefix,
76
+ object.workspaceId,
77
+ object.assetId,
78
+ object.isVariant ? 'variants' : undefined,
79
+ sanitize(object.fileName)
80
+ ]
81
+ .filter(Boolean)
82
+ .join('/');
83
+ const hash = (0, node_crypto_1.createHash)('sha256');
84
+ let size = 0;
85
+ const meter = new node_stream_1.PassThrough();
86
+ meter.on('data', (chunk) => {
87
+ hash.update(chunk);
88
+ size += chunk.byteLength;
89
+ });
90
+ // `pipeline` so a source that dies destroys the meter too, rather
91
+ // than leaving the upload waiting on a stream that never ends.
92
+ //
93
+ // Awaited **together** with the upload, not after it: whichever
94
+ // fails first, both promises already carry a handler. Awaiting them
95
+ // in sequence meant that when the upload rejected first, the
96
+ // pipeline's own rejection had nobody attached yet — an unhandled
97
+ // rejection, which in Node is a killed process rather than a failed
98
+ // request.
99
+ try {
100
+ await Promise.all([
101
+ api.put(storageKey, meter, {
102
+ access: 'public',
103
+ contentType: object.contentType,
104
+ // The pathname we computed **is** the storage key. With
105
+ // the default suffix on, the stored blob would live
106
+ // somewhere this adapter cannot name, and `get` /
107
+ // `remove` would miss every object they were handed.
108
+ addRandomSuffix: false,
109
+ ...token
110
+ }),
111
+ (0, promises_1.pipeline)(object.body, meter)
112
+ ]);
113
+ }
114
+ catch (error) {
115
+ await api.del(storageKey, token).catch(() => undefined);
116
+ throw error;
117
+ }
118
+ return { storageKey, size, checksum: hash.digest('hex') };
119
+ },
120
+ async get(storageKey) {
121
+ // Two steps, and the first is the important one: `head` turns a
122
+ // missing blob into a rejection *before* any stream exists, which
123
+ // is what the port promises and what lets the route answer 404
124
+ // rather than a streaming 200 it cannot take back.
125
+ const url = await urlFor(storageKey);
126
+ const response = await fetch(url);
127
+ if (!response.ok || !response.body) {
128
+ if (response.status === 404) {
129
+ throw new media_server_1.ObjectNotFoundError(storageKey);
130
+ }
131
+ throw new Error(`Vercel Blob refused to serve ${storageKey}: ${response.status} ${response.statusText}`);
132
+ }
133
+ return node_stream_1.Readable.fromWeb(response.body);
134
+ },
135
+ async remove(storageKey) {
136
+ try {
137
+ await api.del(storageKey, token);
138
+ }
139
+ catch (error) {
140
+ // Reclaim is post-commit and best-effort, so a blob that is
141
+ // already gone is a success, not something to report.
142
+ if (!isMissing(error))
143
+ throw error;
144
+ }
145
+ },
146
+ async verify() {
147
+ // There is no store-level metadata call, so this asks about a
148
+ // pathname that will not exist: a missing blob proves the token and
149
+ // the store are good, while a bad token raises something else and
150
+ // fails the boot.
151
+ try {
152
+ await api.head('__ortha_verify__/does-not-exist', token);
153
+ }
154
+ catch (error) {
155
+ if (isMissing(error))
156
+ return;
157
+ throw error;
158
+ }
159
+ }
160
+ };
161
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@orthacms/media-provider-vercel-blob",
3
+ "version": "0.4.0",
4
+ "description": "@orthacms/media-provider-vercel-blob — part of Ortha CMS.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/media/provider-vercel-blob",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ortha-source/ortha-cms.git",
10
+ "directory": "packages/media/provider-vercel-blob"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/ortha-source/ortha-cms/issues"
14
+ },
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "dependencies": {
28
+ "@orthacms/media-server": "^0.4.0",
29
+ "@vercel/blob": "^0.27.0",
30
+ "tslib": "^2.3.0"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }