@firetail/finchly 0.0.10 → 0.0.11
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/CHANGELOG.md +11 -0
- package/README.md +29 -3
- package/dist/blobs.d.ts +32 -2
- package/dist/blobs.js +131 -12
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ All notable changes to `@firetail/finchly`. Versions follow semver.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.0.11 - 2026-11-12
|
|
8
|
+
|
|
9
|
+
- `blobs`: objects go straight to the object store and come straight from
|
|
10
|
+
it, at an address the platform signs for each read and write. An object
|
|
11
|
+
is no longer limited to 16 MiB. **Needs a platform that signs
|
|
12
|
+
addresses**; with one whose store signs none, objects pass through the
|
|
13
|
+
platform as before.
|
|
14
|
+
- `blobs`: `presignGet(key)` and `presignPut(key, { size })` give an
|
|
15
|
+
address to hand on, for a browser to fetch or send one object.
|
|
16
|
+
- `createBlobs` takes `fetch`, which is what reaches the object store.
|
|
17
|
+
|
|
7
18
|
## 0.0.10 - 2026-11-12
|
|
8
19
|
|
|
9
20
|
- `createBlobs({ service })`: objects kept with the platform for each
|
package/README.md
CHANGED
|
@@ -278,7 +278,9 @@ other refusal is `secrets_unavailable`.
|
|
|
278
278
|
|
|
279
279
|
A service whose manifest sets `permissions.blobs` keeps objects with the
|
|
280
280
|
platform, separately for each tenant: receipts, exports, files. The service
|
|
281
|
-
holds no credentials for the object store
|
|
281
|
+
holds no credentials for the object store. For each read and write the
|
|
282
|
+
platform signs an address for that one object, and the bytes go straight
|
|
283
|
+
to the store or come straight from it.
|
|
282
284
|
|
|
283
285
|
```js
|
|
284
286
|
import { createBlobs } from "@firetail/finchly";
|
|
@@ -300,7 +302,7 @@ await mine.delete(`receipts/${id}.pdf`);
|
|
|
300
302
|
- A key is 1 to 512 letters, digits, dots, dashes and underscores, in
|
|
301
303
|
segments divided by `/`. Do not use a key that is also the start of
|
|
302
304
|
another followed by `/`, such as `a` and `a/b`.
|
|
303
|
-
- An object is text or bytes, of
|
|
305
|
+
- An object is text or bytes, held in memory, of up to 5 GiB.
|
|
304
306
|
- A tenant's objects are reached with that tenant's id and no other. Take
|
|
305
307
|
the id from a verified identity, never from the request itself.
|
|
306
308
|
- A listing is in key order. When `next` is not undefined there are more:
|
|
@@ -322,10 +324,34 @@ const written = await mine.putIfMatch("state.json", JSON.stringify(next), state.
|
|
|
322
324
|
State that needs transactions or queries belongs in the service's
|
|
323
325
|
database, not in an object.
|
|
324
326
|
|
|
327
|
+
An address can be handed on, so that a file goes between a browser and the
|
|
328
|
+
store without passing through the service:
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
// To fetch one object with a plain GET.
|
|
332
|
+
const url = await mine.presignGet(`receipts/${id}.pdf`, { expiresSeconds: 60 });
|
|
333
|
+
|
|
334
|
+
// To send one object of a stated size: a PUT to `url` with exactly `headers`.
|
|
335
|
+
const { url, headers } = await mine.presignPut(`uploads/${id}`, { size: file.size, contentType: file.type, expiresSeconds: 300 });
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
- An address is for that one object and that one use. One made to send
|
|
339
|
+
takes exactly that content type and that many bytes.
|
|
340
|
+
- Whoever holds an address can use it until it runs out, with no sign-in:
|
|
341
|
+
1 to 3600 seconds, default 300. Ask for the shortest that will do, and do
|
|
342
|
+
not log one.
|
|
343
|
+
- The same addresses serve to stream an object too large to hold in
|
|
344
|
+
memory.
|
|
345
|
+
|
|
325
346
|
A manifest that does not ask for objects gets `blobs_not_granted`. Any
|
|
326
347
|
other refusal, a deployment with no object store among them, is
|
|
327
348
|
`blobs_unavailable`.
|
|
328
349
|
|
|
350
|
+
A deployment whose store signs no addresses (a development store) passes
|
|
351
|
+
objects through the platform instead: `get` and `put` work the same, up to
|
|
352
|
+
16 MiB an object, and `presignGet` and `presignPut` reject with
|
|
353
|
+
`blobs_unavailable`.
|
|
354
|
+
|
|
329
355
|
### The queue
|
|
330
356
|
|
|
331
357
|
A message belongs to a tenant. The manifest lists the topics the service
|
|
@@ -410,7 +436,7 @@ not leak them.
|
|
|
410
436
|
| `app` | Register with the platform, serve `/_platform/*`, renew keys, fetch settings | Self-registration and key renewal are done, through `service`. Serving `/_platform/*` and fetching settings are not |
|
|
411
437
|
| `service` | Call other services through the gateway, as the service or for a user | Done, for built-in services and for apps |
|
|
412
438
|
| `db` | PostgreSQL: queries, transactions, advisory locks, migrations | Done |
|
|
413
|
-
| `blobs` | Objects, with conditional writes
|
|
439
|
+
| `blobs` | Objects, with conditional writes and addresses to hand on | Done |
|
|
414
440
|
| `queue` | Publish, lease, ack, retry, dead-letter | Done |
|
|
415
441
|
| `secrets` | Secrets, and encryption under the tenant's key | Done |
|
|
416
442
|
| `llm` | Model calls through the platform | Planned |
|
package/dist/blobs.d.ts
CHANGED
|
@@ -26,6 +26,22 @@ export interface ListOptions {
|
|
|
26
26
|
/** The most objects to list. 1 to 1000, default 100. */
|
|
27
27
|
readonly limit?: number;
|
|
28
28
|
}
|
|
29
|
+
export interface AddressOptions {
|
|
30
|
+
/** How long the address lasts, in seconds. 1 to 3600, default 300. Ask for the shortest that will do. */
|
|
31
|
+
readonly expiresSeconds?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface PutAddressOptions extends AddressOptions {
|
|
34
|
+
/** The length of what will be sent, in bytes. The address takes exactly that many. */
|
|
35
|
+
readonly size: number;
|
|
36
|
+
/** Kept with the object, and must be sent as given. Default application/octet-stream. */
|
|
37
|
+
readonly contentType?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Where to send one object: a `PUT` to `url` with exactly these `headers`. */
|
|
40
|
+
export interface PutAddress {
|
|
41
|
+
readonly url: string;
|
|
42
|
+
readonly method: "PUT";
|
|
43
|
+
readonly headers: Record<string, string>;
|
|
44
|
+
}
|
|
29
45
|
export interface BlobPage {
|
|
30
46
|
/** In key order. */
|
|
31
47
|
readonly objects: BlobInfo[];
|
|
@@ -37,7 +53,7 @@ export interface TenantBlobs {
|
|
|
37
53
|
readonly tenantId: string;
|
|
38
54
|
/** The object, or undefined if there is none by that key. */
|
|
39
55
|
get(key: string): Promise<StoredBlob | undefined>;
|
|
40
|
-
/** Write an object, replacing any there was.
|
|
56
|
+
/** Write an object, replacing any there was. Resolves with its etag. */
|
|
41
57
|
put(key: string, data: string | Uint8Array, options?: PutOptions): Promise<string>;
|
|
42
58
|
/**
|
|
43
59
|
* Write an object only if there is none by that key. Resolves with its
|
|
@@ -55,6 +71,18 @@ export interface TenantBlobs {
|
|
|
55
71
|
delete(key: string): Promise<void>;
|
|
56
72
|
/** The service's objects in this tenant, a page at a time. */
|
|
57
73
|
list(options?: ListOptions): Promise<BlobPage>;
|
|
74
|
+
/**
|
|
75
|
+
* An address the object can be fetched from with a plain GET, by whoever
|
|
76
|
+
* holds it, until it runs out: to hand to a browser, or to stream a
|
|
77
|
+
* large object. It is for this one object. Do not log it.
|
|
78
|
+
*/
|
|
79
|
+
presignGet(key: string, options?: AddressOptions): Promise<string>;
|
|
80
|
+
/**
|
|
81
|
+
* An address one object of a stated size can be sent to, by whoever
|
|
82
|
+
* holds it, until it runs out: to let a browser send a file without it
|
|
83
|
+
* passing through the service. Do not log it.
|
|
84
|
+
*/
|
|
85
|
+
presignPut(key: string, options: PutAddressOptions): Promise<PutAddress>;
|
|
58
86
|
}
|
|
59
87
|
export interface Blobs {
|
|
60
88
|
/**
|
|
@@ -65,7 +93,9 @@ export interface Blobs {
|
|
|
65
93
|
forTenant(tenantId: string): TenantBlobs;
|
|
66
94
|
}
|
|
67
95
|
export interface BlobsOptions {
|
|
68
|
-
/** The service's client. Every call goes through it. */
|
|
96
|
+
/** The service's client. Every call to the platform goes through it. */
|
|
69
97
|
readonly service: Pick<ServiceClient, "call">;
|
|
98
|
+
/** What reaches the object store. Default: the global `fetch`. */
|
|
99
|
+
readonly fetch?: typeof fetch;
|
|
70
100
|
}
|
|
71
101
|
export declare function createBlobs(options: BlobsOptions): Blobs;
|
package/dist/blobs.js
CHANGED
|
@@ -4,14 +4,20 @@
|
|
|
4
4
|
// The manifest must set permissions.blobs. Everything here is for one
|
|
5
5
|
// tenant: the handle that reads and writes is made with `forTenant`. The
|
|
6
6
|
// service holds no credentials for the object store; the platform does.
|
|
7
|
+
//
|
|
8
|
+
// To read or write, the platform is asked for an address signed for that
|
|
9
|
+
// one object, and the bytes go straight to the store or come straight from
|
|
10
|
+
// it. Where the deployment's store signs no addresses (a development
|
|
11
|
+
// store), they pass through the platform instead, at most 16 MiB an object.
|
|
7
12
|
import { FinchlyError } from "./errors.js";
|
|
8
13
|
const TENANT_ID = /^t_[a-z2-7]{16}$/;
|
|
9
14
|
const KEY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
10
15
|
const PREFIX = /^[A-Za-z0-9._/-]*$/;
|
|
11
16
|
const MAX_KEY_LENGTH = 512;
|
|
12
|
-
/** The largest object the platform
|
|
13
|
-
const
|
|
17
|
+
/** The largest object that can pass through the platform, where the store signs no addresses. */
|
|
18
|
+
const MAX_THROUGH_PLATFORM = 16 * 1024 * 1024;
|
|
14
19
|
const BASE = "/api/apps/self/blobs";
|
|
20
|
+
const ADDRESSES = "/api/apps/self/blob-urls";
|
|
15
21
|
const unavailable = (message) => new FinchlyError("blobs_unavailable", message);
|
|
16
22
|
function isKey(key) {
|
|
17
23
|
return typeof key === "string" && key.length <= MAX_KEY_LENGTH && KEY.test(key) && !key.split("/").some((segment) => segment === "." || segment === "..");
|
|
@@ -35,11 +41,57 @@ async function failure(response, what) {
|
|
|
35
41
|
}
|
|
36
42
|
export function createBlobs(options) {
|
|
37
43
|
const { service } = options;
|
|
44
|
+
const doFetch = options.fetch ?? fetch;
|
|
45
|
+
// Whether the deployment's store signs addresses. Not known until asked;
|
|
46
|
+
// once it has said no, it is not asked again.
|
|
47
|
+
let signsAddresses = true;
|
|
38
48
|
return {
|
|
39
49
|
forTenant(tenantId) {
|
|
40
50
|
if (typeof tenantId !== "string" || !TENANT_ID.test(tenantId)) {
|
|
41
51
|
throw unavailable("forTenant needs a tenant id, as in caller.tenantId");
|
|
42
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Ask the platform for an address for one object. "none" if the store
|
|
55
|
+
* signs none; "unmet" if it is to write on a condition that already
|
|
56
|
+
* cannot hold.
|
|
57
|
+
*/
|
|
58
|
+
async function address(asked) {
|
|
59
|
+
const response = await service.call("core", ADDRESSES, {
|
|
60
|
+
tenantId,
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "content-type": "application/json" },
|
|
63
|
+
body: JSON.stringify(asked),
|
|
64
|
+
});
|
|
65
|
+
if (response.status === 501) {
|
|
66
|
+
await response.body?.cancel();
|
|
67
|
+
signsAddresses = false;
|
|
68
|
+
return "none";
|
|
69
|
+
}
|
|
70
|
+
if (response.status === 412) {
|
|
71
|
+
await response.body?.cancel();
|
|
72
|
+
return "unmet";
|
|
73
|
+
}
|
|
74
|
+
if (response.status !== 200)
|
|
75
|
+
throw await failure(response, "give an address for the object");
|
|
76
|
+
const { url, headers } = (await response.json());
|
|
77
|
+
return { url, headers };
|
|
78
|
+
}
|
|
79
|
+
/** Make a request to the store, at an address. The address itself is never part of an error. */
|
|
80
|
+
async function atStore(to, init) {
|
|
81
|
+
try {
|
|
82
|
+
return await doFetch(to.url, { ...init, headers: to.headers });
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
const cause = err.cause;
|
|
86
|
+
throw new FinchlyError("blobs_unavailable", "Cannot reach the object store", [
|
|
87
|
+
typeof cause?.code === "string" ? cause.code : "the request failed",
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const refusedByStore = async (response, what) => {
|
|
92
|
+
await response.body?.cancel();
|
|
93
|
+
return new FinchlyError("blobs_unavailable", `The object store did not ${what}`, [`it answered ${response.status}`]);
|
|
94
|
+
};
|
|
43
95
|
/** Write, with a condition or none. Undefined if the condition did not hold. */
|
|
44
96
|
async function write(key, data, condition, putOptions = {}) {
|
|
45
97
|
checkKey(key);
|
|
@@ -47,15 +99,39 @@ export function createBlobs(options) {
|
|
|
47
99
|
if (!text && !(data instanceof Uint8Array))
|
|
48
100
|
throw unavailable("An object is text or bytes");
|
|
49
101
|
const bytes = text ? Buffer.from(data, "utf8") : data;
|
|
50
|
-
if (bytes.byteLength > MAX_BYTES)
|
|
51
|
-
throw unavailable("An object is at most 16 MiB");
|
|
52
102
|
const contentType = putOptions.contentType ?? (text ? "text/plain; charset=utf-8" : "application/octet-stream");
|
|
53
103
|
if (typeof contentType !== "string" || !/^[\x20-\x7e]{1,200}$/.test(contentType))
|
|
54
104
|
throw unavailable("The content type is not one");
|
|
105
|
+
if (signsAddresses) {
|
|
106
|
+
const to = await address({ key, method: "PUT", contentType, size: bytes.byteLength, ...condition });
|
|
107
|
+
if (to === "unmet")
|
|
108
|
+
return undefined;
|
|
109
|
+
if (to !== "none") {
|
|
110
|
+
const response = await atStore(to, { method: "PUT", body: bytes });
|
|
111
|
+
const conditional = condition.ifAbsent === true || condition.ifMatch !== undefined;
|
|
112
|
+
// 412: the condition did not hold. 409: another write to the same key was under way.
|
|
113
|
+
if (conditional && (response.status === 412 || response.status === 409)) {
|
|
114
|
+
await response.body?.cancel();
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
if (response.status !== 200)
|
|
118
|
+
throw await refusedByStore(response, "take the object");
|
|
119
|
+
await response.body?.cancel();
|
|
120
|
+
return response.headers.get("etag") ?? "";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Through the platform: the store signs no addresses.
|
|
124
|
+
if (bytes.byteLength > MAX_THROUGH_PLATFORM) {
|
|
125
|
+
throw unavailable("This deployment's object store takes objects through the platform only, at most 16 MiB each");
|
|
126
|
+
}
|
|
55
127
|
const response = await service.call("core", `${BASE}/${key}`, {
|
|
56
128
|
tenantId,
|
|
57
129
|
method: "PUT",
|
|
58
|
-
headers: {
|
|
130
|
+
headers: {
|
|
131
|
+
"content-type": contentType,
|
|
132
|
+
...(condition.ifAbsent ? { "if-none-match": "*" } : {}),
|
|
133
|
+
...(condition.ifMatch !== undefined ? { "if-match": condition.ifMatch } : {}),
|
|
134
|
+
},
|
|
59
135
|
body: bytes,
|
|
60
136
|
});
|
|
61
137
|
if (response.status === 412) {
|
|
@@ -66,10 +142,43 @@ export function createBlobs(options) {
|
|
|
66
142
|
throw await failure(response, "keep the object");
|
|
67
143
|
return (await response.json()).etag;
|
|
68
144
|
}
|
|
145
|
+
const read = async (response) => ({
|
|
146
|
+
data: new Uint8Array(await response.arrayBuffer()),
|
|
147
|
+
etag: response.headers.get("etag") ?? "",
|
|
148
|
+
contentType: response.headers.get("content-type") ?? "application/octet-stream",
|
|
149
|
+
});
|
|
150
|
+
const seconds = (addressOptions) => {
|
|
151
|
+
const expiresSeconds = addressOptions?.expiresSeconds;
|
|
152
|
+
if (expiresSeconds === undefined)
|
|
153
|
+
return {};
|
|
154
|
+
if (!Number.isInteger(expiresSeconds) || expiresSeconds < 1 || expiresSeconds > 3600)
|
|
155
|
+
throw unavailable("expiresSeconds is 1 to 3600");
|
|
156
|
+
return { expiresSeconds };
|
|
157
|
+
};
|
|
158
|
+
/** An address to hand on. There is none where the store signs none. */
|
|
159
|
+
async function handOn(asked) {
|
|
160
|
+
const to = await address(asked);
|
|
161
|
+
if (to === "none" || to === "unmet")
|
|
162
|
+
throw unavailable("This deployment's object store gives no addresses");
|
|
163
|
+
return to;
|
|
164
|
+
}
|
|
69
165
|
return {
|
|
70
166
|
tenantId,
|
|
71
167
|
async get(key) {
|
|
72
168
|
checkKey(key);
|
|
169
|
+
if (signsAddresses) {
|
|
170
|
+
const from = await address({ key, method: "GET" });
|
|
171
|
+
if (from !== "none" && from !== "unmet") {
|
|
172
|
+
const response = await atStore(from, {});
|
|
173
|
+
if (response.status === 404) {
|
|
174
|
+
await response.body?.cancel();
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
if (response.status !== 200)
|
|
178
|
+
throw await refusedByStore(response, "give the object");
|
|
179
|
+
return read(response);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
73
182
|
const response = await service.call("core", `${BASE}/${key}`, { tenantId });
|
|
74
183
|
if (response.status === 404) {
|
|
75
184
|
await response.body?.cancel();
|
|
@@ -77,24 +186,20 @@ export function createBlobs(options) {
|
|
|
77
186
|
}
|
|
78
187
|
if (response.status !== 200)
|
|
79
188
|
throw await failure(response, "give the object");
|
|
80
|
-
return
|
|
81
|
-
data: new Uint8Array(await response.arrayBuffer()),
|
|
82
|
-
etag: response.headers.get("etag") ?? "",
|
|
83
|
-
contentType: response.headers.get("content-type") ?? "application/octet-stream",
|
|
84
|
-
};
|
|
189
|
+
return read(response);
|
|
85
190
|
},
|
|
86
191
|
async put(key, data, putOptions) {
|
|
87
192
|
// With no condition there is nothing not to hold.
|
|
88
193
|
return (await write(key, data, {}, putOptions));
|
|
89
194
|
},
|
|
90
195
|
putIfAbsent(key, data, putOptions) {
|
|
91
|
-
return write(key, data, {
|
|
196
|
+
return write(key, data, { ifAbsent: true }, putOptions);
|
|
92
197
|
},
|
|
93
198
|
async putIfMatch(key, data, etag, putOptions) {
|
|
94
199
|
if (typeof etag !== "string" || etag === "" || etag.length > 200) {
|
|
95
200
|
throw unavailable("putIfMatch needs the etag the object was read with");
|
|
96
201
|
}
|
|
97
|
-
return write(key, data, {
|
|
202
|
+
return write(key, data, { ifMatch: etag }, putOptions);
|
|
98
203
|
},
|
|
99
204
|
async delete(key) {
|
|
100
205
|
checkKey(key);
|
|
@@ -128,6 +233,20 @@ export function createBlobs(options) {
|
|
|
128
233
|
next: page.next ?? undefined,
|
|
129
234
|
};
|
|
130
235
|
},
|
|
236
|
+
async presignGet(key, addressOptions) {
|
|
237
|
+
checkKey(key);
|
|
238
|
+
return (await handOn({ key, method: "GET", ...seconds(addressOptions) })).url;
|
|
239
|
+
},
|
|
240
|
+
async presignPut(key, addressOptions) {
|
|
241
|
+
checkKey(key);
|
|
242
|
+
const { size, contentType = "application/octet-stream" } = addressOptions ?? {};
|
|
243
|
+
if (!Number.isInteger(size) || size < 0)
|
|
244
|
+
throw unavailable("presignPut needs the size of what will be sent, in bytes");
|
|
245
|
+
if (typeof contentType !== "string" || !/^[\x20-\x7e]{1,200}$/.test(contentType))
|
|
246
|
+
throw unavailable("The content type is not one");
|
|
247
|
+
const to = await handOn({ key, method: "PUT", contentType, size, ...seconds(addressOptions) });
|
|
248
|
+
return { url: to.url, method: "PUT", headers: to.headers };
|
|
249
|
+
},
|
|
131
250
|
};
|
|
132
251
|
},
|
|
133
252
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { createBlobs } from "./blobs.js";
|
|
2
|
-
export type { BlobInfo, BlobPage, Blobs, BlobsOptions, ListOptions, PutOptions, StoredBlob, TenantBlobs } from "./blobs.js";
|
|
2
|
+
export type { AddressOptions, BlobInfo, BlobPage, Blobs, BlobsOptions, ListOptions, PutAddress, PutAddressOptions, PutOptions, StoredBlob, TenantBlobs, } from "./blobs.js";
|
|
3
3
|
export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
|
|
4
4
|
export type { PlatformConfig } from "./config.js";
|
|
5
5
|
export { connectDatabase, fetchSettings } from "./db.js";
|