@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 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; the platform does.
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 at most 16 MiB.
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 | Done. Objects over 16 MiB are not |
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. At most 16 MiB. Resolves with its etag. */
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 takes. */
13
- const MAX_BYTES = 16 * 1024 * 1024;
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: { "content-type": contentType, ...condition },
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, { "if-none-match": "*" }, putOptions);
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, { "if-match": etag }, putOptions);
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firetail/finchly",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "Node SDK for the Finchly platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",