@firetail/finchly 0.0.9 → 0.0.10

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,15 @@ All notable changes to `@firetail/finchly`. Versions follow semver.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.0.10 - 2026-11-12
8
+
9
+ - `createBlobs({ service })`: objects kept with the platform for each
10
+ tenant. `blobs.forTenant(caller.tenantId)` gives `get`, `put`,
11
+ `putIfAbsent`, `putIfMatch`, `delete` and `list`. The manifest must set
12
+ `permissions.blobs`.
13
+ - New error codes: `blobs_not_granted`, `blobs_unavailable`.
14
+ - **Needs a platform that serves objects.**
15
+
7
16
  ## 0.0.9 - 2026-11-12
8
17
 
9
18
  - `createSecrets({ service })`: secrets kept with the platform for each
package/README.md CHANGED
@@ -274,6 +274,58 @@ been changed, rejects with `secrets_cannot_decrypt`.
274
274
  A manifest that does not ask for secrets gets `secrets_not_granted`. Any
275
275
  other refusal is `secrets_unavailable`.
276
276
 
277
+ ### Objects
278
+
279
+ A service whose manifest sets `permissions.blobs` keeps objects with the
280
+ platform, separately for each tenant: receipts, exports, files. The service
281
+ holds no credentials for the object store; the platform does.
282
+
283
+ ```js
284
+ import { createBlobs } from "@firetail/finchly";
285
+
286
+ const blobs = createBlobs({ service });
287
+
288
+ // Per request: the objects of the caller's tenant.
289
+ const mine = blobs.forTenant(caller.tenantId);
290
+
291
+ await mine.put(`receipts/${id}.pdf`, bytes, { contentType: "application/pdf" });
292
+ const receipt = await mine.get(`receipts/${id}.pdf`); // undefined if there is none
293
+ receipt.data; // a Uint8Array
294
+ receipt.contentType; // "application/pdf"
295
+
296
+ const { objects, next } = await mine.list({ prefix: "receipts/", limit: 100 });
297
+ await mine.delete(`receipts/${id}.pdf`);
298
+ ```
299
+
300
+ - A key is 1 to 512 letters, digits, dots, dashes and underscores, in
301
+ segments divided by `/`. Do not use a key that is also the start of
302
+ another followed by `/`, such as `a` and `a/b`.
303
+ - An object is text or bytes, of at most 16 MiB.
304
+ - A tenant's objects are reached with that tenant's id and no other. Take
305
+ the id from a verified identity, never from the request itself.
306
+ - A listing is in key order. When `next` is not undefined there are more:
307
+ ask again with `{ after: next }`.
308
+
309
+ Two writers do not lose each other's work if each writes on a condition:
310
+
311
+ ```js
312
+ // Only if there is none by that key. Undefined if there was.
313
+ const made = await mine.putIfAbsent("state.json", "{}", { contentType: "application/json" });
314
+
315
+ // Only if it has not changed since it was read. Undefined if it has:
316
+ // read again and decide again.
317
+ const state = await mine.get("state.json");
318
+ const next = change(JSON.parse(Buffer.from(state.data).toString()));
319
+ const written = await mine.putIfMatch("state.json", JSON.stringify(next), state.etag, { contentType: "application/json" });
320
+ ```
321
+
322
+ State that needs transactions or queries belongs in the service's
323
+ database, not in an object.
324
+
325
+ A manifest that does not ask for objects gets `blobs_not_granted`. Any
326
+ other refusal, a deployment with no object store among them, is
327
+ `blobs_unavailable`.
328
+
277
329
  ### The queue
278
330
 
279
331
  A message belongs to a tenant. The manifest lists the topics the service
@@ -358,7 +410,7 @@ not leak them.
358
410
  | `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 |
359
411
  | `service` | Call other services through the gateway, as the service or for a user | Done, for built-in services and for apps |
360
412
  | `db` | PostgreSQL: queries, transactions, advisory locks, migrations | Done |
361
- | `blobs` | Object storage with conditional writes | Planned |
413
+ | `blobs` | Objects, with conditional writes | Done. Objects over 16 MiB are not |
362
414
  | `queue` | Publish, lease, ack, retry, dead-letter | Done |
363
415
  | `secrets` | Secrets, and encryption under the tenant's key | Done |
364
416
  | `llm` | Model calls through the platform | Planned |
@@ -0,0 +1,71 @@
1
+ import type { ServiceClient } from "./service.js";
2
+ /** An object, as it was read. */
3
+ export interface StoredBlob {
4
+ readonly data: Uint8Array;
5
+ /** Changes whenever the content does. Hand it to `putIfMatch` as it came. */
6
+ readonly etag: string;
7
+ readonly contentType: string;
8
+ }
9
+ /** What a listing says of an object. */
10
+ export interface BlobInfo {
11
+ readonly key: string;
12
+ /** In bytes. */
13
+ readonly size: number;
14
+ readonly etag: string;
15
+ readonly modifiedAt: Date;
16
+ }
17
+ export interface PutOptions {
18
+ /** Kept with the object. Default: text/plain for text, application/octet-stream for bytes. */
19
+ readonly contentType?: string;
20
+ }
21
+ export interface ListOptions {
22
+ /** The start of the keys to list. May end anywhere. Default: every key. */
23
+ readonly prefix?: string;
24
+ /** List the keys after this one: the `next` of the page before. */
25
+ readonly after?: string;
26
+ /** The most objects to list. 1 to 1000, default 100. */
27
+ readonly limit?: number;
28
+ }
29
+ export interface BlobPage {
30
+ /** In key order. */
31
+ readonly objects: BlobInfo[];
32
+ /** Pass as `after` for the next page. Undefined when there are no more. */
33
+ readonly next: string | undefined;
34
+ }
35
+ /** A service's objects in one tenant. */
36
+ export interface TenantBlobs {
37
+ readonly tenantId: string;
38
+ /** The object, or undefined if there is none by that key. */
39
+ get(key: string): Promise<StoredBlob | undefined>;
40
+ /** Write an object, replacing any there was. At most 16 MiB. Resolves with its etag. */
41
+ put(key: string, data: string | Uint8Array, options?: PutOptions): Promise<string>;
42
+ /**
43
+ * Write an object only if there is none by that key. Resolves with its
44
+ * etag, or undefined if there was one, which is left as it is.
45
+ */
46
+ putIfAbsent(key: string, data: string | Uint8Array, options?: PutOptions): Promise<string | undefined>;
47
+ /**
48
+ * Write an object only if it still has the etag it was read with.
49
+ * Resolves with the new etag, or undefined if it has changed or gone:
50
+ * read it again and decide again. This is how two writers do not lose
51
+ * each other's work.
52
+ */
53
+ putIfMatch(key: string, data: string | Uint8Array, etag: string, options?: PutOptions): Promise<string | undefined>;
54
+ /** Delete an object. Not an error if there is none. */
55
+ delete(key: string): Promise<void>;
56
+ /** The service's objects in this tenant, a page at a time. */
57
+ list(options?: ListOptions): Promise<BlobPage>;
58
+ }
59
+ export interface Blobs {
60
+ /**
61
+ * The objects of one tenant: `caller.tenantId` of the request being
62
+ * handled. Take the id from a verified identity, never from the request
63
+ * itself.
64
+ */
65
+ forTenant(tenantId: string): TenantBlobs;
66
+ }
67
+ export interface BlobsOptions {
68
+ /** The service's client. Every call goes through it. */
69
+ readonly service: Pick<ServiceClient, "call">;
70
+ }
71
+ export declare function createBlobs(options: BlobsOptions): Blobs;
package/dist/blobs.js ADDED
@@ -0,0 +1,134 @@
1
+ // Objects a service keeps with the platform, for each tenant. See
2
+ // spec/blobs.md in the platform repository.
3
+ //
4
+ // The manifest must set permissions.blobs. Everything here is for one
5
+ // tenant: the handle that reads and writes is made with `forTenant`. The
6
+ // service holds no credentials for the object store; the platform does.
7
+ import { FinchlyError } from "./errors.js";
8
+ const TENANT_ID = /^t_[a-z2-7]{16}$/;
9
+ const KEY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
10
+ const PREFIX = /^[A-Za-z0-9._/-]*$/;
11
+ const MAX_KEY_LENGTH = 512;
12
+ /** The largest object the platform takes. */
13
+ const MAX_BYTES = 16 * 1024 * 1024;
14
+ const BASE = "/api/apps/self/blobs";
15
+ const unavailable = (message) => new FinchlyError("blobs_unavailable", message);
16
+ function isKey(key) {
17
+ return typeof key === "string" && key.length <= MAX_KEY_LENGTH && KEY.test(key) && !key.split("/").some((segment) => segment === "." || segment === "..");
18
+ }
19
+ function checkKey(key) {
20
+ if (!isKey(key)) {
21
+ throw unavailable('A key is 1 to 512 letters, digits, dots, dashes and underscores, in segments divided by "/"');
22
+ }
23
+ }
24
+ async function failure(response, what) {
25
+ const answer = (await response.json().catch(() => null));
26
+ const error = typeof answer?.error === "string" ? answer.error : undefined;
27
+ if (error === "not_granted") {
28
+ return new FinchlyError("blobs_not_granted", "The platform keeps no objects for this service", [
29
+ "Set permissions.blobs to true in the manifest",
30
+ ]);
31
+ }
32
+ return new FinchlyError("blobs_unavailable", `The platform did not ${what}`, [
33
+ `it answered ${response.status}${error ? ` ${error}` : ""}${typeof answer?.detail === "string" ? `: ${answer.detail}` : ""}`,
34
+ ]);
35
+ }
36
+ export function createBlobs(options) {
37
+ const { service } = options;
38
+ return {
39
+ forTenant(tenantId) {
40
+ if (typeof tenantId !== "string" || !TENANT_ID.test(tenantId)) {
41
+ throw unavailable("forTenant needs a tenant id, as in caller.tenantId");
42
+ }
43
+ /** Write, with a condition or none. Undefined if the condition did not hold. */
44
+ async function write(key, data, condition, putOptions = {}) {
45
+ checkKey(key);
46
+ const text = typeof data === "string";
47
+ if (!text && !(data instanceof Uint8Array))
48
+ throw unavailable("An object is text or bytes");
49
+ 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
+ const contentType = putOptions.contentType ?? (text ? "text/plain; charset=utf-8" : "application/octet-stream");
53
+ if (typeof contentType !== "string" || !/^[\x20-\x7e]{1,200}$/.test(contentType))
54
+ throw unavailable("The content type is not one");
55
+ const response = await service.call("core", `${BASE}/${key}`, {
56
+ tenantId,
57
+ method: "PUT",
58
+ headers: { "content-type": contentType, ...condition },
59
+ body: bytes,
60
+ });
61
+ if (response.status === 412) {
62
+ await response.body?.cancel();
63
+ return undefined;
64
+ }
65
+ if (response.status !== 200)
66
+ throw await failure(response, "keep the object");
67
+ return (await response.json()).etag;
68
+ }
69
+ return {
70
+ tenantId,
71
+ async get(key) {
72
+ checkKey(key);
73
+ const response = await service.call("core", `${BASE}/${key}`, { tenantId });
74
+ if (response.status === 404) {
75
+ await response.body?.cancel();
76
+ return undefined;
77
+ }
78
+ if (response.status !== 200)
79
+ 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
+ };
85
+ },
86
+ async put(key, data, putOptions) {
87
+ // With no condition there is nothing not to hold.
88
+ return (await write(key, data, {}, putOptions));
89
+ },
90
+ putIfAbsent(key, data, putOptions) {
91
+ return write(key, data, { "if-none-match": "*" }, putOptions);
92
+ },
93
+ async putIfMatch(key, data, etag, putOptions) {
94
+ if (typeof etag !== "string" || etag === "" || etag.length > 200) {
95
+ throw unavailable("putIfMatch needs the etag the object was read with");
96
+ }
97
+ return write(key, data, { "if-match": etag }, putOptions);
98
+ },
99
+ async delete(key) {
100
+ checkKey(key);
101
+ const response = await service.call("core", `${BASE}/${key}`, { tenantId, method: "DELETE" });
102
+ if (response.status !== 204)
103
+ throw await failure(response, "delete the object");
104
+ },
105
+ async list(listOptions = {}) {
106
+ const { prefix, after, limit } = listOptions;
107
+ if (prefix !== undefined && (typeof prefix !== "string" || prefix.length > MAX_KEY_LENGTH || !PREFIX.test(prefix))) {
108
+ throw unavailable("A prefix is the start of a key");
109
+ }
110
+ if (after !== undefined)
111
+ checkKey(after);
112
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 1000)) {
113
+ throw unavailable("limit is 1 to 1000");
114
+ }
115
+ const query = new URLSearchParams();
116
+ if (prefix)
117
+ query.set("prefix", prefix);
118
+ if (after !== undefined)
119
+ query.set("after", after);
120
+ if (limit !== undefined)
121
+ query.set("limit", String(limit));
122
+ const response = await service.call("core", query.size > 0 ? `${BASE}?${query}` : BASE, { tenantId });
123
+ if (response.status !== 200)
124
+ throw await failure(response, "list the objects");
125
+ const page = (await response.json());
126
+ return {
127
+ objects: page.objects.map(({ key, size, etag, modifiedAt }) => ({ key, size, etag, modifiedAt: new Date(modifiedAt) })),
128
+ next: page.next ?? undefined,
129
+ };
130
+ },
131
+ };
132
+ },
133
+ };
134
+ }
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type FinchlyErrorCode = "config_invalid" | "manifest_invalid" | "manifest_unreadable" | "identity_invalid" | "gateway_unreachable" | "platform_api_unsupported" | "service_call_invalid" | "service_key_rejected" | "app_registration_refused" | "app_pending_approval" | "database_unavailable" | "database_not_granted" | "migration_invalid" | "secrets_not_granted" | "secrets_unavailable" | "secrets_cannot_decrypt" | "queue_not_granted" | "queue_unavailable";
1
+ export type FinchlyErrorCode = "config_invalid" | "manifest_invalid" | "manifest_unreadable" | "identity_invalid" | "gateway_unreachable" | "platform_api_unsupported" | "service_call_invalid" | "service_key_rejected" | "app_registration_refused" | "app_pending_approval" | "database_unavailable" | "database_not_granted" | "migration_invalid" | "secrets_not_granted" | "secrets_unavailable" | "secrets_cannot_decrypt" | "queue_not_granted" | "queue_unavailable" | "blobs_not_granted" | "blobs_unavailable";
2
2
  /**
3
3
  * Every error this SDK throws on purpose. `code` is stable and safe to
4
4
  * branch on; `problems` lists each thing that was wrong, so a caller can
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export { createBlobs } from "./blobs.js";
2
+ export type { BlobInfo, BlobPage, Blobs, BlobsOptions, ListOptions, PutOptions, StoredBlob, TenantBlobs } from "./blobs.js";
1
3
  export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
2
4
  export type { PlatformConfig } from "./config.js";
3
5
  export { connectDatabase, fetchSettings } from "./db.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { createBlobs } from "./blobs.js";
1
2
  export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
2
3
  export { connectDatabase, fetchSettings } from "./db.js";
3
4
  export { FinchlyError, IdentityError } from "./errors.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firetail/finchly",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "Node SDK for the Finchly platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",