@kindgi/blob-binding 0.0.0-bootstrap.0 → 0.1.1

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,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for describing the origin of the Work and
141
+ reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Kindgi Inc.
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,81 @@
1
- # @kindgi/blob-binding
1
+ # `@kindgi/blob-binding`
2
2
 
3
- A placeholder. Releases come from https://github.com/kindgi/kindgi-sdk.
3
+ Storage contract for Kindgi artifacts (blobs). `BlobStorageBinding` is the interface a storage adapter implements and [`@kindgi/api`](../api/) consumes through its `blobStorage` input; it backs both the `/v1/artifacts/*` surface and the S3-compatible `/s3/*` surface. This package contains types only and has no runtime code.
4
+
5
+ ## Purpose
6
+
7
+ Keep storage out of the API package while giving both wire surfaces one store. Every blob has two addresses kept in a single metadata record: a `blobId` (used by `/v1/artifacts/*`) and a `(bucket, key)` pair (used by `/s3/*`). Uploads through `put` get a synthetic `(bucket, key)` so they are also reachable over S3, and `head(blobId)` and `headByKey(bucket, key)` return the same `BlobMeta` for the same blob. Every method takes `tenantId` explicitly so adapters can partition storage per tenant. Cursors and continuation tokens are opaque and adapter-defined.
8
+
9
+ Contract rules adapters follow:
10
+
11
+ - `BlobMeta.hash` is the lowercase hex SHA-256 of the exact bytes persisted. When `BlobPutInput.expectedHash` is set, a mismatch fails the write with `blob-hash-mismatch`. The one exception is a multipart-assembled object, whose `hash` is the S3 multipart ETag.
12
+ - `delete` and `deleteByKey` are idempotent: `{ deleted: true }` on the first call, `{ deleted: false }` afterwards. Adapters may tombstone or remove data.
13
+ - `listByPrefix` sorts lexicographically by `key`, as S3 does; `putByKey` overwrites an existing key.
14
+
15
+ ## Exports
16
+
17
+ - **`BlobStorageBinding`** — the adapter interface, in three groups:
18
+ - **By `blobId`** — `put(tenantId, input)`, `get(tenantId, blobId)` (body as a `ReadableStream`), `head(tenantId, blobId)` (`null` when unknown), `list(tenantId, filter, cursor?, limit?)`, `delete(tenantId, blobId)`.
19
+ - **By `(bucket, key)`** — `putByKey`, `getByKey`, `headByKey`, `deleteByKey`, and `listByPrefix(tenantId, bucket, prefix, continuationToken?, maxKeys?)` (`maxKeys` 1 to 1000, default 1000).
20
+ - **Multipart upload** — `initiateMultipartUpload` (returns `uploadId`), `uploadPart` (returns the part `etag`), `completeMultipartUpload` (parts in ascending `partNumber`), `abortMultipartUpload`, `listParts`.
21
+ - **Inputs**
22
+ - **`BlobPutInput`** — `name`, `contentType`, `bytes` (`ReadableStream<Uint8Array>` or `Uint8Array`), `size?`, `tags?`, `ownerRunId?`, `expectedHash?`.
23
+ - **`MultipartInitiateInput`** — `contentType`, `tags?`, `ownerRunId?`.
24
+ - **`BlobFilter`** — `ownerRunId`, `contentType`, `tags` (all must match), `scope` (a `Scope` from [`@kindgi/platform`](../platform/)), and `inherit` (no effect here: blobs always live at project level).
25
+ - **Results** — **`BlobMeta`** (`blobId`, `tenantId`, `name`, `contentType`, `size`, `hash`, `tags`, `ownerRunId?`, `createdAt`, `bucket?`, `key?`), **`BlobRead`** (`meta` + `stream`), **`BlobListPage`** (`data`, `nextCursor?`), **`BlobDeleteOutcome`** (`deleted`), **`S3ListPage`** (`contents`, `isTruncated`, `nextContinuationToken?`), **`MultipartListPartsPage`** (`parts` with `partNumber`, `etag`, `size`, `lastModified`).
26
+ - **`BlobError`** — union discriminated by `code`: `blob-not-found` (`blobId`), `blob-hash-mismatch` (`expected`, `actual`), `blob-size-mismatch` (`declared`, `actual`), `blob-storage-error` (`cause?`).
27
+
28
+ ## Example
29
+
30
+ ```ts
31
+ import { createHash } from 'node:crypto';
32
+
33
+ import type { BlobMeta, BlobStorageBinding } from '@kindgi/blob-binding';
34
+ import type { RunId, TenantId } from '@kindgi/types';
35
+
36
+ async function storeReport(
37
+ blobs: BlobStorageBinding,
38
+ tenantId: TenantId,
39
+ runId: RunId,
40
+ report: string,
41
+ ): Promise<BlobMeta> {
42
+ const bytes = new TextEncoder().encode(report);
43
+ const stored = await blobs.put(tenantId, {
44
+ name: 'q3-report.txt',
45
+ contentType: 'text/plain',
46
+ bytes,
47
+ size: bytes.byteLength,
48
+ tags: { kind: 'report' },
49
+ ownerRunId: runId,
50
+ // Optional integrity check: the binding rejects the write if its own sha256 differs.
51
+ expectedHash: createHash('sha256').update(bytes).digest('hex'),
52
+ });
53
+ if (stored.kind === 'err') throw new Error(`${stored.error.code}: ${stored.error.message}`);
54
+ const meta = stored.value;
55
+
56
+ // The same object is addressable by (bucket, key) on the S3-compatible surface.
57
+ if (meta.bucket !== undefined && meta.key !== undefined) {
58
+ const viaKey = await blobs.headByKey(tenantId, meta.bucket, meta.key);
59
+ console.log(viaKey?.blobId === meta.blobId); // true
60
+ }
61
+
62
+ // Everything this run has produced with the same tag, first page.
63
+ const page = await blobs.list(tenantId, { ownerRunId: runId, tags: { kind: 'report' } }, undefined, 20);
64
+ if (page.kind === 'ok') console.log(page.value.data.map((b) => `${b.name} ${b.size}B`));
65
+
66
+ return meta;
67
+ }
68
+ ```
69
+
70
+ ## Non-goals
71
+
72
+ - **No storage implementation.** Filesystem, object-store, and other adapters implement `BlobStorageBinding` in their own packages.
73
+ - **No `Content-MD5` verification.** The S3-compatible route verifies it before calling the binding.
74
+ - **No object versioning.** Writing an existing `(bucket, key)` replaces the object (last write wins).
75
+ - **No scope inheritance for blobs.** Blobs are project-level content; `BlobFilter.inherit` exists only to keep one filter shape across scope-aware bindings.
76
+
77
+ ## Related
78
+
79
+ - [`@kindgi/api`](../api/) — mounts `/v1/artifacts/*` over a `BlobStorageBinding`, and `/s3/*` when an S3 credential binding is also supplied.
80
+ - [`@kindgi/platform`](../platform/) — `Scope`.
81
+ - [`@kindgi/types`](../types/) — `ArtifactId`, `Cursor`, `RunId`, `TenantId`, `Timestamp`, `Result`.
@@ -0,0 +1,292 @@
1
+ import type { Scope } from '@kindgi/platform';
2
+ import type { ArtifactId, Cursor, Result, RunId, TenantId, Timestamp } from '@kindgi/types';
3
+ /**
4
+ * Caller-plugged surface for artifact (blob) storage. Same pattern as
5
+ * `AgentRegistryBinding` / `MemoryBinding` / `ReviewerRegistryBinding`
6
+ * — the API package does NOT own storage. Deployments plug in a
7
+ * binding implementation: typically a filesystem-backed one for
8
+ * development and tests, and an object-store-backed one in production.
9
+ *
10
+ * Every method is tenant-scoped: callers pass `tenantId` explicitly so
11
+ * multi-tenant deployments can partition storage without exposing the
12
+ * scoping inside the API package. Cursors are opaque — the binding
13
+ * chooses its encoding.
14
+ *
15
+ * ## Two lookup surfaces, one storage
16
+ *
17
+ * Kindgi artifacts have two wire faces: the bespoke
18
+ * `/v1/artifacts/*` surface identifies blobs by `blobId` (UUID minted
19
+ * server-side), while the S3-compat `/s3/*` surface identifies them by
20
+ * `(bucket, key)` tuple. Adapters MUST unify these — every blob has
21
+ * both a `blobId` AND a `(bucket, key)` address, stored in a single
22
+ * meta record. This binding exposes both lookup shapes; adapters
23
+ * choose their own on-disk / on-storage layout so long as
24
+ * `head(blobId)` and `headByKey(bucket, key)` return the same
25
+ * `BlobMeta` for a given blob.
26
+ *
27
+ * Bespoke uploads via `put(...)` MUST allocate a synthetic
28
+ * `(bucket, key)` — conventionally
29
+ * `bucket: 'artifacts', key: <blobId>`, so bespoke uploads are
30
+ * visible to any S3 credential granted access to the `artifacts`
31
+ * bucket (cross-surface interop).
32
+ *
33
+ * ## Hash contract
34
+ *
35
+ * `BlobMeta.hash` is `sha256`, hex-encoded, lowercase. Bindings MUST
36
+ * compute it at boundary from the exact byte stream persisted. Callers
37
+ * MAY assert an expected hash on `BlobPutInput.expectedHash`; when
38
+ * present, the binding compares against the computed hash and returns
39
+ * `blob-hash-mismatch` on divergence (dedup + integrity check in one).
40
+ *
41
+ * ## Delete semantics
42
+ *
43
+ * `delete` is soft-delete at the binding level — the contract only
44
+ * requires idempotence: a second delete returns
45
+ * `{ deleted: false }` (already gone), a first delete returns
46
+ * `{ deleted: true }`. Implementations MAY keep tombstones with
47
+ * retention; a development implementation may remove the data
48
+ * outright.
49
+ */
50
+ export interface BlobStorageBinding {
51
+ /**
52
+ * Store bytes for the bespoke `/v1/artifacts` surface. Adapter
53
+ * allocates the `blobId` and a synthetic `(bucket, key)`
54
+ * (conventionally `bucket: 'artifacts', key: <blobId>`) so the object
55
+ * is also reachable via the S3-compat surface. Route validates the
56
+ * multipart body before calling.
57
+ */
58
+ put(tenantId: TenantId, input: BlobPutInput): Promise<Result<BlobMeta, BlobError>>;
59
+ /**
60
+ * Fetch bytes + metadata for download by `blobId`. Body is a
61
+ * `ReadableStream` so large blobs stream without buffering. Returns
62
+ * `blob-not-found` when the id is unknown to this tenant.
63
+ */
64
+ get(tenantId: TenantId, blobId: ArtifactId): Promise<Result<BlobRead, BlobError>>;
65
+ /**
66
+ * Metadata-only lookup by `blobId` — no body. Returns `null` when
67
+ * unknown. The route surfaces `null` as `404 blob-not-found`.
68
+ */
69
+ head(tenantId: TenantId, blobId: ArtifactId): Promise<BlobMeta | null>;
70
+ /**
71
+ * Cursor-paginated metadata list. Filters compose as AND. Sort is
72
+ * binding-defined (for example `createdAt desc, blobId desc`).
73
+ */
74
+ list(tenantId: TenantId, filter: BlobFilter, cursor?: Cursor, limit?: number): Promise<Result<BlobListPage, BlobError>>;
75
+ /**
76
+ * Idempotent soft-delete by `blobId`. Returns `{ deleted: true }` on
77
+ * the first call, `{ deleted: false }` on subsequent calls (already
78
+ * gone). The route surfaces both as `200` per API convention; only
79
+ * structural failures (permission, backing store error) become
80
+ * non-`200`.
81
+ */
82
+ delete(tenantId: TenantId, blobId: ArtifactId): Promise<Result<BlobDeleteOutcome, BlobError>>;
83
+ /**
84
+ * Store bytes under an explicit `(bucket, key)` — the S3-compat
85
+ * `PUT /s3/:bucket/*` route. Adapter still allocates a `blobId`
86
+ * internally so the bespoke surface can address the same object.
87
+ * The route verifies the caller's `Content-MD5` (when supplied)
88
+ * BEFORE calling — the binding is not responsible for MD5
89
+ * verification. If the key already exists, the write overwrites
90
+ * silently (S3 semantics without versioning — last write wins).
91
+ */
92
+ putByKey(tenantId: TenantId, bucket: string, key: string, input: BlobPutInput): Promise<Result<BlobMeta, BlobError>>;
93
+ /**
94
+ * Fetch bytes + metadata by `(bucket, key)`. Same shape as `get`
95
+ * but with the S3-native address. Returns `blob-not-found` when the
96
+ * key is unknown under the bucket.
97
+ */
98
+ getByKey(tenantId: TenantId, bucket: string, key: string): Promise<Result<BlobRead, BlobError>>;
99
+ /**
100
+ * Metadata-only lookup by `(bucket, key)`. `null` on unknown. The
101
+ * route surfaces `null` as S3 `NoSuchKey` (HTTP 404).
102
+ */
103
+ headByKey(tenantId: TenantId, bucket: string, key: string): Promise<BlobMeta | null>;
104
+ /**
105
+ * Idempotent soft-delete by `(bucket, key)`. S3 semantics: 204 on
106
+ * success either way — S3 does not distinguish "was there" vs. "was
107
+ * not." `{ deleted: false }` when the key was already gone; `true`
108
+ * otherwise. Route always responds 204 regardless.
109
+ */
110
+ deleteByKey(tenantId: TenantId, bucket: string, key: string): Promise<Result<BlobDeleteOutcome, BlobError>>;
111
+ /**
112
+ * S3-style prefix + delimiter list under a bucket. `continuationToken`
113
+ * is the opaque S3 pagination marker (adapter-defined encoding);
114
+ * `maxKeys` bounds the page size (1..1000, default 1000). Returns
115
+ * matching blob metadata + `isTruncated` + optional
116
+ * `nextContinuationToken`. Sort order MUST be lexicographic on `key`
117
+ * ascending (S3 spec).
118
+ */
119
+ listByPrefix(tenantId: TenantId, bucket: string, prefix: string, continuationToken?: string, maxKeys?: number): Promise<Result<S3ListPage, BlobError>>;
120
+ /**
121
+ * Initiate a multipart upload. Returns an opaque `uploadId` that
122
+ * subsequent `uploadPart` / `completeMultipartUpload` /
123
+ * `abortMultipartUpload` calls use. `contentType` + `tags` are
124
+ * pinned at initiate time (S3 semantics — the client sends them on
125
+ * `POST ?uploads` and they apply to the finalized object).
126
+ */
127
+ initiateMultipartUpload(tenantId: TenantId, bucket: string, key: string, input: MultipartInitiateInput): Promise<Result<{
128
+ uploadId: string;
129
+ }, BlobError>>;
130
+ /**
131
+ * Upload a single part (bytes). Returns the part's ETag
132
+ * (`md5(part_bytes)`, hex, un-quoted — the route wraps in quotes
133
+ * per S3 wire spec). `partNumber` is 1..10,000. When a part number is
134
+ * uploaded again, adapters MAY either replace the earlier part (last
135
+ * write wins, as in S3) or reject the upload.
136
+ */
137
+ uploadPart(tenantId: TenantId, bucket: string, key: string, uploadId: string, partNumber: number, bytes: Uint8Array): Promise<Result<{
138
+ etag: string;
139
+ }, BlobError>>;
140
+ /**
141
+ * Atomically assemble the declared parts into the final object.
142
+ * `parts` MUST be in ascending `partNumber` order (S3 spec). The
143
+ * adapter verifies each part's declared ETag matches its stored
144
+ * ETag, concatenates the parts in order, writes the final blob
145
+ * (`putByKey`-equivalent), and cleans the staging area. Returns
146
+ * the finalized `BlobMeta`; the S3 wire spec's `ETag` for a
147
+ * multipart object is `md5(concat(md5(part_i)))-<N>` — the adapter
148
+ * writes this into `BlobMeta.hash` too (deviates from the
149
+ * single-shot `sha256` semantic — the S3 spec's ETag is the only
150
+ * stable value clients rely on for multipart).
151
+ */
152
+ completeMultipartUpload(tenantId: TenantId, bucket: string, key: string, uploadId: string, parts: readonly {
153
+ readonly partNumber: number;
154
+ readonly etag: string;
155
+ }[]): Promise<Result<BlobMeta, BlobError>>;
156
+ /**
157
+ * Discard all staged parts + metadata for an in-progress upload.
158
+ * Returns `blob-not-found` when the `uploadId` is unknown — including
159
+ * one that was already aborted — which the S3-compat route answers
160
+ * with `404 NoSuchUpload`.
161
+ */
162
+ abortMultipartUpload(tenantId: TenantId, bucket: string, key: string, uploadId: string): Promise<Result<void, BlobError>>;
163
+ /**
164
+ * Read the parts staged so far for a given uploadId. Sorted by
165
+ * `partNumber` ascending. Returns `blob-not-found` when the
166
+ * uploadId is unknown.
167
+ */
168
+ listParts(tenantId: TenantId, bucket: string, key: string, uploadId: string): Promise<Result<MultipartListPartsPage, BlobError>>;
169
+ }
170
+ export interface MultipartInitiateInput {
171
+ readonly contentType: string;
172
+ readonly tags?: Readonly<Record<string, string>>;
173
+ readonly ownerRunId?: RunId;
174
+ }
175
+ export interface MultipartListPartsPage {
176
+ readonly parts: readonly {
177
+ readonly partNumber: number;
178
+ readonly etag: string;
179
+ readonly size: number;
180
+ readonly lastModified: Timestamp;
181
+ }[];
182
+ }
183
+ export interface BlobPutInput {
184
+ readonly name: string;
185
+ readonly contentType: string;
186
+ readonly bytes: ReadableStream<Uint8Array> | Uint8Array;
187
+ /** If known ahead of streaming. Bindings MAY reject when the actual byte count diverges. */
188
+ readonly size?: number;
189
+ readonly tags?: Readonly<Record<string, string>>;
190
+ /** Optional back-ref to the run that produced this blob. Filter target on `list`. */
191
+ readonly ownerRunId?: RunId;
192
+ /**
193
+ * Caller-computed hash for dedup + integrity. `sha256`, hex-encoded,
194
+ * lowercase. When set, bindings MUST reject on mismatch with
195
+ * `blob-hash-mismatch`; when omitted, the binding computes and returns
196
+ * the hash in `BlobMeta.hash`.
197
+ */
198
+ readonly expectedHash?: string;
199
+ }
200
+ export interface BlobMeta {
201
+ readonly blobId: ArtifactId;
202
+ readonly tenantId: TenantId;
203
+ readonly name: string;
204
+ readonly contentType: string;
205
+ readonly size: number;
206
+ /** `sha256`, hex-encoded, lowercase. */
207
+ readonly hash: string;
208
+ readonly tags: Readonly<Record<string, string>>;
209
+ readonly ownerRunId?: RunId;
210
+ readonly createdAt: Timestamp;
211
+ /**
212
+ * S3-compat address. Every persisted blob carries a `(bucket, key)`
213
+ * pair — bespoke uploads default to `bucket: 'artifacts', key:
214
+ * <blobId>` so they're visible via the S3-compat surface too. When
215
+ * absent, the S3-compat surface treats the blob as unaddressable via
216
+ * S3 (still reachable via bespoke `blobId`).
217
+ */
218
+ readonly bucket?: string;
219
+ readonly key?: string;
220
+ }
221
+ export interface BlobRead {
222
+ readonly meta: BlobMeta;
223
+ readonly stream: ReadableStream<Uint8Array>;
224
+ }
225
+ export interface BlobFilter {
226
+ readonly ownerRunId?: RunId;
227
+ readonly contentType?: string;
228
+ /** Every entry matches as `tags[key] === value`. All must match. */
229
+ readonly tags?: Readonly<Record<string, string>>;
230
+ /**
231
+ * Narrow the list to a specific scope. Absent = no scope narrow
232
+ * (return every row in the tenant the caller can see — admin/audit
233
+ * default).
234
+ *
235
+ * Content-scoped semantics (this binding):
236
+ * - `{ kind: 'project', projectId }` — rows in that project.
237
+ * - `{ kind: 'org', orgId }` — rows in every project belonging to
238
+ * that org.
239
+ * - `{ kind: 'tenant', tenantId }` — every row in the tenant.
240
+ *
241
+ * Blobs are content: every blob belongs to a project, so `inherit`
242
+ * has no effect here.
243
+ */
244
+ readonly scope?: Scope;
245
+ /**
246
+ * `false` = literal-at-this-scope only (admin/audit view).
247
+ * `true` (default) = inheritance walk (user-facing view).
248
+ * No-op for content-scoped bindings (rows only exist at
249
+ * project-level — there is no upward hierarchy to walk). Kept for
250
+ * uniformity: scope-aware bindings share one filter shape across the
251
+ * SDK and OpenAPI schemas.
252
+ */
253
+ readonly inherit?: boolean;
254
+ }
255
+ export interface BlobListPage {
256
+ readonly data: readonly BlobMeta[];
257
+ readonly nextCursor?: Cursor;
258
+ }
259
+ export interface BlobDeleteOutcome {
260
+ readonly deleted: boolean;
261
+ }
262
+ /** S3 `ListObjectsV2` result shape (normalized). */
263
+ export interface S3ListPage {
264
+ readonly contents: readonly BlobMeta[];
265
+ readonly isTruncated: boolean;
266
+ /** Present only when `isTruncated: true`. */
267
+ readonly nextContinuationToken?: string;
268
+ }
269
+ /**
270
+ * Discriminated error union. `@kindgi/api` maps each `code` to an HTTP
271
+ * status.
272
+ */
273
+ export type BlobError = {
274
+ readonly code: 'blob-not-found';
275
+ readonly message: string;
276
+ readonly blobId: string;
277
+ } | {
278
+ readonly code: 'blob-hash-mismatch';
279
+ readonly message: string;
280
+ readonly expected: string;
281
+ readonly actual: string;
282
+ } | {
283
+ readonly code: 'blob-size-mismatch';
284
+ readonly message: string;
285
+ readonly declared: number;
286
+ readonly actual: number;
287
+ } | {
288
+ readonly code: 'blob-storage-error';
289
+ readonly message: string;
290
+ readonly cause?: unknown;
291
+ };
292
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+ export {};
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,iCAAiC"}
package/package.json CHANGED
@@ -1,7 +1,49 @@
1
1
  {
2
2
  "name": "@kindgi/blob-binding",
3
- "version": "0.0.0-bootstrap.0",
4
- "description": "Placeholder so a trusted publisher can be attached. Releases are published from https://github.com/kindgi/kindgi-sdk with provenance; use 0.1.0 or later.",
3
+ "version": "0.1.1",
4
+ "description": "Kindgi™ blob storage binding contract. Types only: the BlobStorageBinding interface that storage implementations provide and @kindgi/api consumes — blobId-addressed put / get / head / list / delete, S3-compatible putByKey / getByKey / headByKey / deleteByKey / listByPrefix, and multipart initiate / upload-part / complete / abort / list-parts — its input and result shapes (BlobPutInput, MultipartInitiateInput, BlobFilter, BlobMeta, BlobRead, BlobListPage, BlobDeleteOutcome, S3ListPage, MultipartListPartsPage), and the BlobError union. No runtime code.",
5
5
  "license": "Apache-2.0",
6
- "repository": { "type": "git", "url": "git+https://github.com/kindgi/kindgi-sdk.git" }
7
- }
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/kindgi/kindgi-sdk.git",
9
+ "directory": "packages/blob-binding"
10
+ },
11
+ "homepage": "https://github.com/kindgi/kindgi-sdk/tree/main/packages/blob-binding#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/kindgi/kindgi-sdk/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "README.md"
28
+ ],
29
+ "dependencies": {
30
+ "@kindgi/platform": "0.1.1",
31
+ "@kindgi/types": "0.1.1"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.10.5",
35
+ "typescript": "^5.7.3"
36
+ },
37
+ "engines": {
38
+ "node": ">=22.0.0"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "provenance": true
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.build.json",
46
+ "typecheck": "tsc --noEmit",
47
+ "clean": "rm -rf dist *.tsbuildinfo"
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,350 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { Scope } from '@kindgi/platform';
5
+ import type { ArtifactId, Cursor, Result, RunId, TenantId, Timestamp } from '@kindgi/types';
6
+
7
+ /**
8
+ * Caller-plugged surface for artifact (blob) storage. Same pattern as
9
+ * `AgentRegistryBinding` / `MemoryBinding` / `ReviewerRegistryBinding`
10
+ * — the API package does NOT own storage. Deployments plug in a
11
+ * binding implementation: typically a filesystem-backed one for
12
+ * development and tests, and an object-store-backed one in production.
13
+ *
14
+ * Every method is tenant-scoped: callers pass `tenantId` explicitly so
15
+ * multi-tenant deployments can partition storage without exposing the
16
+ * scoping inside the API package. Cursors are opaque — the binding
17
+ * chooses its encoding.
18
+ *
19
+ * ## Two lookup surfaces, one storage
20
+ *
21
+ * Kindgi artifacts have two wire faces: the bespoke
22
+ * `/v1/artifacts/*` surface identifies blobs by `blobId` (UUID minted
23
+ * server-side), while the S3-compat `/s3/*` surface identifies them by
24
+ * `(bucket, key)` tuple. Adapters MUST unify these — every blob has
25
+ * both a `blobId` AND a `(bucket, key)` address, stored in a single
26
+ * meta record. This binding exposes both lookup shapes; adapters
27
+ * choose their own on-disk / on-storage layout so long as
28
+ * `head(blobId)` and `headByKey(bucket, key)` return the same
29
+ * `BlobMeta` for a given blob.
30
+ *
31
+ * Bespoke uploads via `put(...)` MUST allocate a synthetic
32
+ * `(bucket, key)` — conventionally
33
+ * `bucket: 'artifacts', key: <blobId>`, so bespoke uploads are
34
+ * visible to any S3 credential granted access to the `artifacts`
35
+ * bucket (cross-surface interop).
36
+ *
37
+ * ## Hash contract
38
+ *
39
+ * `BlobMeta.hash` is `sha256`, hex-encoded, lowercase. Bindings MUST
40
+ * compute it at boundary from the exact byte stream persisted. Callers
41
+ * MAY assert an expected hash on `BlobPutInput.expectedHash`; when
42
+ * present, the binding compares against the computed hash and returns
43
+ * `blob-hash-mismatch` on divergence (dedup + integrity check in one).
44
+ *
45
+ * ## Delete semantics
46
+ *
47
+ * `delete` is soft-delete at the binding level — the contract only
48
+ * requires idempotence: a second delete returns
49
+ * `{ deleted: false }` (already gone), a first delete returns
50
+ * `{ deleted: true }`. Implementations MAY keep tombstones with
51
+ * retention; a development implementation may remove the data
52
+ * outright.
53
+ */
54
+ export interface BlobStorageBinding {
55
+ // -------------- bespoke (blobId-addressed) surface --------------
56
+
57
+ /**
58
+ * Store bytes for the bespoke `/v1/artifacts` surface. Adapter
59
+ * allocates the `blobId` and a synthetic `(bucket, key)`
60
+ * (conventionally `bucket: 'artifacts', key: <blobId>`) so the object
61
+ * is also reachable via the S3-compat surface. Route validates the
62
+ * multipart body before calling.
63
+ */
64
+ put(tenantId: TenantId, input: BlobPutInput): Promise<Result<BlobMeta, BlobError>>;
65
+ /**
66
+ * Fetch bytes + metadata for download by `blobId`. Body is a
67
+ * `ReadableStream` so large blobs stream without buffering. Returns
68
+ * `blob-not-found` when the id is unknown to this tenant.
69
+ */
70
+ get(tenantId: TenantId, blobId: ArtifactId): Promise<Result<BlobRead, BlobError>>;
71
+ /**
72
+ * Metadata-only lookup by `blobId` — no body. Returns `null` when
73
+ * unknown. The route surfaces `null` as `404 blob-not-found`.
74
+ */
75
+ head(tenantId: TenantId, blobId: ArtifactId): Promise<BlobMeta | null>;
76
+ /**
77
+ * Cursor-paginated metadata list. Filters compose as AND. Sort is
78
+ * binding-defined (for example `createdAt desc, blobId desc`).
79
+ */
80
+ list(
81
+ tenantId: TenantId,
82
+ filter: BlobFilter,
83
+ cursor?: Cursor,
84
+ limit?: number,
85
+ ): Promise<Result<BlobListPage, BlobError>>;
86
+ /**
87
+ * Idempotent soft-delete by `blobId`. Returns `{ deleted: true }` on
88
+ * the first call, `{ deleted: false }` on subsequent calls (already
89
+ * gone). The route surfaces both as `200` per API convention; only
90
+ * structural failures (permission, backing store error) become
91
+ * non-`200`.
92
+ */
93
+ delete(tenantId: TenantId, blobId: ArtifactId): Promise<Result<BlobDeleteOutcome, BlobError>>;
94
+
95
+ // -------------- S3-compat (bucket/key-addressed) surface --------------
96
+
97
+ /**
98
+ * Store bytes under an explicit `(bucket, key)` — the S3-compat
99
+ * `PUT /s3/:bucket/*` route. Adapter still allocates a `blobId`
100
+ * internally so the bespoke surface can address the same object.
101
+ * The route verifies the caller's `Content-MD5` (when supplied)
102
+ * BEFORE calling — the binding is not responsible for MD5
103
+ * verification. If the key already exists, the write overwrites
104
+ * silently (S3 semantics without versioning — last write wins).
105
+ */
106
+ putByKey(
107
+ tenantId: TenantId,
108
+ bucket: string,
109
+ key: string,
110
+ input: BlobPutInput,
111
+ ): Promise<Result<BlobMeta, BlobError>>;
112
+ /**
113
+ * Fetch bytes + metadata by `(bucket, key)`. Same shape as `get`
114
+ * but with the S3-native address. Returns `blob-not-found` when the
115
+ * key is unknown under the bucket.
116
+ */
117
+ getByKey(tenantId: TenantId, bucket: string, key: string): Promise<Result<BlobRead, BlobError>>;
118
+ /**
119
+ * Metadata-only lookup by `(bucket, key)`. `null` on unknown. The
120
+ * route surfaces `null` as S3 `NoSuchKey` (HTTP 404).
121
+ */
122
+ headByKey(tenantId: TenantId, bucket: string, key: string): Promise<BlobMeta | null>;
123
+ /**
124
+ * Idempotent soft-delete by `(bucket, key)`. S3 semantics: 204 on
125
+ * success either way — S3 does not distinguish "was there" vs. "was
126
+ * not." `{ deleted: false }` when the key was already gone; `true`
127
+ * otherwise. Route always responds 204 regardless.
128
+ */
129
+ deleteByKey(
130
+ tenantId: TenantId,
131
+ bucket: string,
132
+ key: string,
133
+ ): Promise<Result<BlobDeleteOutcome, BlobError>>;
134
+ /**
135
+ * S3-style prefix + delimiter list under a bucket. `continuationToken`
136
+ * is the opaque S3 pagination marker (adapter-defined encoding);
137
+ * `maxKeys` bounds the page size (1..1000, default 1000). Returns
138
+ * matching blob metadata + `isTruncated` + optional
139
+ * `nextContinuationToken`. Sort order MUST be lexicographic on `key`
140
+ * ascending (S3 spec).
141
+ */
142
+ listByPrefix(
143
+ tenantId: TenantId,
144
+ bucket: string,
145
+ prefix: string,
146
+ continuationToken?: string,
147
+ maxKeys?: number,
148
+ ): Promise<Result<S3ListPage, BlobError>>;
149
+
150
+ // -------------- S3-compat multipart upload surface --------------
151
+
152
+ /**
153
+ * Initiate a multipart upload. Returns an opaque `uploadId` that
154
+ * subsequent `uploadPart` / `completeMultipartUpload` /
155
+ * `abortMultipartUpload` calls use. `contentType` + `tags` are
156
+ * pinned at initiate time (S3 semantics — the client sends them on
157
+ * `POST ?uploads` and they apply to the finalized object).
158
+ */
159
+ initiateMultipartUpload(
160
+ tenantId: TenantId,
161
+ bucket: string,
162
+ key: string,
163
+ input: MultipartInitiateInput,
164
+ ): Promise<Result<{ uploadId: string }, BlobError>>;
165
+ /**
166
+ * Upload a single part (bytes). Returns the part's ETag
167
+ * (`md5(part_bytes)`, hex, un-quoted — the route wraps in quotes
168
+ * per S3 wire spec). `partNumber` is 1..10,000. When a part number is
169
+ * uploaded again, adapters MAY either replace the earlier part (last
170
+ * write wins, as in S3) or reject the upload.
171
+ */
172
+ uploadPart(
173
+ tenantId: TenantId,
174
+ bucket: string,
175
+ key: string,
176
+ uploadId: string,
177
+ partNumber: number,
178
+ bytes: Uint8Array,
179
+ ): Promise<Result<{ etag: string }, BlobError>>;
180
+ /**
181
+ * Atomically assemble the declared parts into the final object.
182
+ * `parts` MUST be in ascending `partNumber` order (S3 spec). The
183
+ * adapter verifies each part's declared ETag matches its stored
184
+ * ETag, concatenates the parts in order, writes the final blob
185
+ * (`putByKey`-equivalent), and cleans the staging area. Returns
186
+ * the finalized `BlobMeta`; the S3 wire spec's `ETag` for a
187
+ * multipart object is `md5(concat(md5(part_i)))-<N>` — the adapter
188
+ * writes this into `BlobMeta.hash` too (deviates from the
189
+ * single-shot `sha256` semantic — the S3 spec's ETag is the only
190
+ * stable value clients rely on for multipart).
191
+ */
192
+ completeMultipartUpload(
193
+ tenantId: TenantId,
194
+ bucket: string,
195
+ key: string,
196
+ uploadId: string,
197
+ parts: readonly { readonly partNumber: number; readonly etag: string }[],
198
+ ): Promise<Result<BlobMeta, BlobError>>;
199
+ /**
200
+ * Discard all staged parts + metadata for an in-progress upload.
201
+ * Returns `blob-not-found` when the `uploadId` is unknown — including
202
+ * one that was already aborted — which the S3-compat route answers
203
+ * with `404 NoSuchUpload`.
204
+ */
205
+ abortMultipartUpload(
206
+ tenantId: TenantId,
207
+ bucket: string,
208
+ key: string,
209
+ uploadId: string,
210
+ ): Promise<Result<void, BlobError>>;
211
+ /**
212
+ * Read the parts staged so far for a given uploadId. Sorted by
213
+ * `partNumber` ascending. Returns `blob-not-found` when the
214
+ * uploadId is unknown.
215
+ */
216
+ listParts(
217
+ tenantId: TenantId,
218
+ bucket: string,
219
+ key: string,
220
+ uploadId: string,
221
+ ): Promise<Result<MultipartListPartsPage, BlobError>>;
222
+ }
223
+
224
+ export interface MultipartInitiateInput {
225
+ readonly contentType: string;
226
+ readonly tags?: Readonly<Record<string, string>>;
227
+ readonly ownerRunId?: RunId;
228
+ }
229
+
230
+ export interface MultipartListPartsPage {
231
+ readonly parts: readonly {
232
+ readonly partNumber: number;
233
+ readonly etag: string;
234
+ readonly size: number;
235
+ readonly lastModified: Timestamp;
236
+ }[];
237
+ }
238
+
239
+ export interface BlobPutInput {
240
+ readonly name: string;
241
+ readonly contentType: string;
242
+ readonly bytes: ReadableStream<Uint8Array> | Uint8Array;
243
+ /** If known ahead of streaming. Bindings MAY reject when the actual byte count diverges. */
244
+ readonly size?: number;
245
+ readonly tags?: Readonly<Record<string, string>>;
246
+ /** Optional back-ref to the run that produced this blob. Filter target on `list`. */
247
+ readonly ownerRunId?: RunId;
248
+ /**
249
+ * Caller-computed hash for dedup + integrity. `sha256`, hex-encoded,
250
+ * lowercase. When set, bindings MUST reject on mismatch with
251
+ * `blob-hash-mismatch`; when omitted, the binding computes and returns
252
+ * the hash in `BlobMeta.hash`.
253
+ */
254
+ readonly expectedHash?: string;
255
+ }
256
+
257
+ export interface BlobMeta {
258
+ readonly blobId: ArtifactId;
259
+ readonly tenantId: TenantId;
260
+ readonly name: string;
261
+ readonly contentType: string;
262
+ readonly size: number;
263
+ /** `sha256`, hex-encoded, lowercase. */
264
+ readonly hash: string;
265
+ readonly tags: Readonly<Record<string, string>>;
266
+ readonly ownerRunId?: RunId;
267
+ readonly createdAt: Timestamp;
268
+ /**
269
+ * S3-compat address. Every persisted blob carries a `(bucket, key)`
270
+ * pair — bespoke uploads default to `bucket: 'artifacts', key:
271
+ * <blobId>` so they're visible via the S3-compat surface too. When
272
+ * absent, the S3-compat surface treats the blob as unaddressable via
273
+ * S3 (still reachable via bespoke `blobId`).
274
+ */
275
+ readonly bucket?: string;
276
+ readonly key?: string;
277
+ }
278
+
279
+ export interface BlobRead {
280
+ readonly meta: BlobMeta;
281
+ readonly stream: ReadableStream<Uint8Array>;
282
+ }
283
+
284
+ export interface BlobFilter {
285
+ readonly ownerRunId?: RunId;
286
+ readonly contentType?: string;
287
+ /** Every entry matches as `tags[key] === value`. All must match. */
288
+ readonly tags?: Readonly<Record<string, string>>;
289
+ /**
290
+ * Narrow the list to a specific scope. Absent = no scope narrow
291
+ * (return every row in the tenant the caller can see — admin/audit
292
+ * default).
293
+ *
294
+ * Content-scoped semantics (this binding):
295
+ * - `{ kind: 'project', projectId }` — rows in that project.
296
+ * - `{ kind: 'org', orgId }` — rows in every project belonging to
297
+ * that org.
298
+ * - `{ kind: 'tenant', tenantId }` — every row in the tenant.
299
+ *
300
+ * Blobs are content: every blob belongs to a project, so `inherit`
301
+ * has no effect here.
302
+ */
303
+ readonly scope?: Scope;
304
+ /**
305
+ * `false` = literal-at-this-scope only (admin/audit view).
306
+ * `true` (default) = inheritance walk (user-facing view).
307
+ * No-op for content-scoped bindings (rows only exist at
308
+ * project-level — there is no upward hierarchy to walk). Kept for
309
+ * uniformity: scope-aware bindings share one filter shape across the
310
+ * SDK and OpenAPI schemas.
311
+ */
312
+ readonly inherit?: boolean;
313
+ }
314
+
315
+ export interface BlobListPage {
316
+ readonly data: readonly BlobMeta[];
317
+ readonly nextCursor?: Cursor;
318
+ }
319
+
320
+ export interface BlobDeleteOutcome {
321
+ readonly deleted: boolean;
322
+ }
323
+
324
+ /** S3 `ListObjectsV2` result shape (normalized). */
325
+ export interface S3ListPage {
326
+ readonly contents: readonly BlobMeta[];
327
+ readonly isTruncated: boolean;
328
+ /** Present only when `isTruncated: true`. */
329
+ readonly nextContinuationToken?: string;
330
+ }
331
+
332
+ /**
333
+ * Discriminated error union. `@kindgi/api` maps each `code` to an HTTP
334
+ * status.
335
+ */
336
+ export type BlobError =
337
+ | { readonly code: 'blob-not-found'; readonly message: string; readonly blobId: string }
338
+ | {
339
+ readonly code: 'blob-hash-mismatch';
340
+ readonly message: string;
341
+ readonly expected: string;
342
+ readonly actual: string;
343
+ }
344
+ | {
345
+ readonly code: 'blob-size-mismatch';
346
+ readonly message: string;
347
+ readonly declared: number;
348
+ readonly actual: number;
349
+ }
350
+ | { readonly code: 'blob-storage-error'; readonly message: string; readonly cause?: unknown };