@byok-sdk/cloud-dataplane 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,249 @@
1
+ /**
2
+ * The R2/S3 {@link CloudBlobStore} — grants, and only grants (§12.7.4, §12.7.7).
3
+ *
4
+ * Zero bytes cross this file either, but for the opposite reason to
5
+ * `stores/core/objects.ts`: the manifest store holds metadata because bytes are
6
+ * not its job, and this store holds no bytes because the DEVICE talks to the
7
+ * object store directly. What it mints is a presigned URL; what the device does
8
+ * with it is between the device and R2. That is why this composition supplies
9
+ * no `BlobContentProxy` — there is no byte path here to proxy, and the absence
10
+ * is the honest declaration (`@byok-sdk/cloud`'s `ports.ts`, design §6).
11
+ *
12
+ * Manifest and bytes are one transaction authority split across two systems
13
+ * with no shared transaction, so the protocol is reserve/verify rather than
14
+ * write/trust:
15
+ *
16
+ * 1. `createUpload` writes the `pending` manifest row FIRST, then signs a PUT.
17
+ * Row-before-bytes is what makes an abandoned upload a reclaimable
18
+ * tombstone instead of an object nobody has a record of.
19
+ * 2. The device PUTs straight to the object store. `Content-Length` and
20
+ * `Content-Type` are in the signed headers, so a body of the wrong size or
21
+ * the wrong type is refused by the object store itself — before a byte is
22
+ * stored, and without this process being in the path.
23
+ * 3. The explicit finalize route calls `observeUpload`, which performs an
24
+ * unconditional `HEAD`. Unconditional because §12.7.7 step 4 is about what
25
+ * the store OBSERVES versus what the client DECLARED, and signing the length
26
+ * proves what one client sent, not what is at the key now. The quota
27
+ * authority then commits manifest/reservation/usage in one Postgres
28
+ * statement; download never performs that transition.
29
+ * 4. `committed` is terminal for writes. Step 1 is re-runnable while a row is
30
+ * `pending` — that is what makes a retried upload idempotent — and refused
31
+ * once it is `committed`, because §12.7.4 lets truth reference a committed
32
+ * manifest and the reference means nothing if the bytes can still be
33
+ * replaced by a same-shaped body under a freshly signed PUT.
34
+ *
35
+ * **No checksum header.** `x-amz-checksum-sha256` was probed rather than
36
+ * assumed (design §3 marked it `[unverified]`): MinIO honors it in a presigned
37
+ * PUT and rejects mismatched bytes with `XAmzContentChecksumMismatch`, but R2's
38
+ * S3 compatibility table lists SHA-256 as `COMPOSITE` only — `FULL_OBJECT`, the
39
+ * type a single-shot PutObject uses, is ❌, and R2's PutObject feature row names
40
+ * no `x-amz-checksum-*` header at all. Signing one would mint URLs that work
41
+ * against the test substrate and fail against production, which is worse than
42
+ * not signing it. The `HEAD` above was never conditional on it.
43
+ *
44
+ * The `blobId` this store mints IS the content hash. That is what makes "no
45
+ * naked object index" constructive rather than disciplinary: there is no
46
+ * surrogate id to look up, every read is `(tenant, hash)` against the manifest
47
+ * primary key, and the object key is derived — once, in {@link #objectUrl} —
48
+ * from a `ContentHash` that core already validated. A non-hex id cannot reach
49
+ * key construction because it cannot become a `ContentHash`.
50
+ */
51
+ import { type Clock, type ContentHash, type ObjectStore, type StorageReservation, type TenantId } from '@byok-sdk/core';
52
+ import type { BlobObservation, CloudBlobStore } from '@byok-sdk/cloud';
53
+ /** 15 minutes, matching the in-memory reference's `BLOB_URL_TTL_MS`. */
54
+ export declare const DEFAULT_PRESIGN_TTL_SECONDS: number;
55
+ /** `X-Amz-Expires` floor. Below a second there is no grant, only a signature. */
56
+ export declare const MIN_PRESIGN_TTL_SECONDS = 1;
57
+ /** `X-Amz-Expires` ceiling: seven days, the longest lifetime R2 will honor. */
58
+ export declare const MAX_PRESIGN_TTL_SECONDS = 604800;
59
+ /** Three total attempts: the first plus two retries. */
60
+ export declare const DEFAULT_MAX_ATTEMPTS = 3;
61
+ /** Doubles per retry. Deterministic — no jitter, so a test can assert the sequence. */
62
+ export declare const DEFAULT_RETRY_DELAY_MS = 100;
63
+ /** The subset of `fetch` this store uses. The seam a fault injector replaces. */
64
+ export type ObjectStoreFetch = (request: Request) => Promise<Response>;
65
+ /**
66
+ * Raised when the object store answered something this adapter cannot act on —
67
+ * a 4xx that is not "absent", or a transient failure that outlived its retries.
68
+ *
69
+ * A local class rather than a core code: core's taxonomy describes the manifest
70
+ * contract, and "R2 returned 503 three times" is an adapter fault, not a
71
+ * statement about the object.
72
+ */
73
+ export declare class ObjectStoreRequestError extends Error {
74
+ readonly status: number | undefined;
75
+ readonly attempts: number;
76
+ constructor(message: string, attempts: number, status?: number, options?: ErrorOptions);
77
+ }
78
+ /**
79
+ * What this adapter refuses on its own account, before anything is signed.
80
+ *
81
+ * A second local class rather than more core codes, for the same reason
82
+ * {@link ObjectStoreRequestError} is one: core's taxonomy describes the
83
+ * manifest contract, and "this deployment's tenant ids cannot be key segments"
84
+ * or "this deployment configured a lifetime R2 will not honor" are facts about
85
+ * an S3 adapter's configuration and inputs, not about an object. Core's code
86
+ * union is closed and deliberately so; widening it from an adapter would put
87
+ * an adapter's vocabulary on a wire contract every composition shares.
88
+ *
89
+ * Code-based branching, matching the idiom of every other error type here.
90
+ */
91
+ export declare const R2_BLOB_ERROR_CODES: {
92
+ /**
93
+ * A tenant id that cannot be one safe path segment. Wire-relevant: it is the
94
+ * only signal a control plane gets that the id it issued cannot address
95
+ * object storage, and it is raised BEFORE any key is built.
96
+ */
97
+ readonly storage_tenant_key_unsafe: 'storage_tenant_key_unsafe';
98
+ /** A presign lifetime outside `[MIN_PRESIGN_TTL_SECONDS, MAX_PRESIGN_TTL_SECONDS]`. Construction-time only. */
99
+ readonly storage_presign_ttl_invalid: 'storage_presign_ttl_invalid';
100
+ /** ListObjectsV2 accepts 1..1000 keys per page. Maintenance input only. */
101
+ readonly storage_list_limit_invalid: 'storage_list_limit_invalid';
102
+ /** Continuation tokens are opaque but non-empty. Maintenance input only. */
103
+ readonly storage_list_cursor_invalid: 'storage_list_cursor_invalid';
104
+ };
105
+ export type R2BlobErrorCode = (typeof R2_BLOB_ERROR_CODES)[keyof typeof R2_BLOB_ERROR_CODES];
106
+ export declare class R2BlobStoreError extends Error {
107
+ readonly code: R2BlobErrorCode;
108
+ constructor(code: R2BlobErrorCode, message: string, options?: ErrorOptions);
109
+ }
110
+ export interface R2BlobStoreOptions {
111
+ /**
112
+ * The manifest authority. Same `ObjectStore` the core composition supplies —
113
+ * one row per (tenant, hash), and this store never opens a second one.
114
+ */
115
+ readonly objects: ObjectStore;
116
+ /**
117
+ * The clock SigV4's `X-Amz-Date` is read from — WALL time, and deliberately
118
+ * NOT the composition's logical clock.
119
+ *
120
+ * Every other instant in this program comes from an injected clock so TTLs
121
+ * are assertable without sleeping, and a store reaching for wall time is a
122
+ * bug. A request signature is the exception, and not a soft one: its validity
123
+ * window is adjudicated by the object store against the object store's own
124
+ * clock. Signing with a logical instant produces a credential the remote side
125
+ * rejects as skewed the moment the two disagree — a 403 at upload time, from
126
+ * a decision made at composition time.
127
+ *
128
+ * Separate and required rather than defaulted, so the distinction is a choice
129
+ * someone makes rather than one they inherit. A test that needs an EXPIRED
130
+ * grant backdates this clock, which is also the only way to assert expiry
131
+ * without sleeping through it.
132
+ */
133
+ readonly signingClock: Clock;
134
+ /** Origin only, e.g. `https://<account>.r2.cloudflarestorage.com`. */
135
+ readonly endpoint: string;
136
+ readonly bucket: string;
137
+ readonly accessKeyId: string;
138
+ readonly secretAccessKey: string;
139
+ /**
140
+ * `auto` for R2. Required rather than defaulted: a signature scoped to the
141
+ * wrong region fails as a 403 at upload time, which is a terrible place to
142
+ * discover a config default nobody chose.
143
+ */
144
+ readonly region: string;
145
+ /**
146
+ * A key namespace for this deployment, e.g. `acme/prod` →
147
+ * `acme/prod/tenants/<tenant>/objects/sha256/<hex>`. Omit it and the key is
148
+ * the unprefixed `tenants/...` layout, byte for byte.
149
+ *
150
+ * What it is for: one bucket, several deployments. Without it every
151
+ * deployment owns the bucket root, so a host running two products against
152
+ * one R2 account has to open a bucket per product.
153
+ *
154
+ * **Immutable per deployment, and only for a NEW one.** This value is
155
+ * spliced into the key at write time and at read time from the same field;
156
+ * there is no second layout anything falls back to, by design. Change it on
157
+ * a deployment that has already stored objects and those objects are
158
+ * stranded — still in the bucket, no longer addressable, and invisible to
159
+ * this SDK's own maintenance surface. A dual-read across the old and new
160
+ * prefix is NOT a supported way to switch and will not be added: it would
161
+ * make two key layouts simultaneously authoritative for the same object.
162
+ * Moving an existing deployment onto a prefix is a separate, one-shot,
163
+ * operator-invoked copy of the objects themselves, out of this SDK's scope.
164
+ *
165
+ * Validated at construction ({@link ObjectKeyPrefix}): slash-joined segments
166
+ * of lowercase alphanumerics, `.`, `_`, `-`, each starting with an
167
+ * alphanumeric, no leading/trailing slash, no empty segment. `''` is a
168
+ * refusal, not a synonym for "no prefix" — it is what an unset environment
169
+ * variable looks like, and silently treating it as the default would put a
170
+ * deployment's objects somewhere nobody chose.
171
+ */
172
+ readonly keyPrefix?: string;
173
+ readonly presignTtlSeconds?: number;
174
+ /** Injected so a fault injector can sit in front of the real one. */
175
+ readonly fetch?: ObjectStoreFetch;
176
+ readonly maxAttempts?: number;
177
+ readonly retryDelayMs?: number;
178
+ }
179
+ /** One tenant-prefixed R2 key returned by ListObjectsV2. */
180
+ export interface R2ListedObject {
181
+ readonly key: string;
182
+ /** Present only when the key is exactly `<tenant>/sha256/<64 lowercase hex>`. */
183
+ readonly hash?: ContentHash;
184
+ readonly byteSize: bigint;
185
+ }
186
+ export interface R2ObjectPage {
187
+ readonly objects: readonly R2ListedObject[];
188
+ readonly nextContinuationToken?: string;
189
+ }
190
+ export type R2DeleteResult = 'deleted' | 'absent';
191
+ /** Operations used only by the host-owned S4B-c maintenance worker. */
192
+ export interface R2ObjectMaintenance {
193
+ inspectObject(tenant: TenantId, hash: ContentHash): Promise<BlobObservation | undefined>;
194
+ deleteObject(tenant: TenantId, hash: ContentHash): Promise<R2DeleteResult>;
195
+ listTenantObjects(tenant: TenantId, continuationToken?: string, limit?: number): Promise<R2ObjectPage>;
196
+ }
197
+ export declare class R2CloudBlobStore implements CloudBlobStore {
198
+ #private;
199
+ constructor(options: R2BlobStoreOptions);
200
+ /**
201
+ * Reserve the manifest row, then hand back a PUT bound to this tenant, this
202
+ * key, this length, this type, and this expiry.
203
+ *
204
+ * `putManifest` is idempotent per (tenant, hash), so a device that declares
205
+ * the same content twice while it is still `pending` gets the same row and
206
+ * the same key — an interrupted upload is retried, not duplicated. It is
207
+ * idempotent per TENANT, which is the same reason the key embeds the tenant:
208
+ * two tenants holding identical bytes hold two independent objects, and
209
+ * neither can learn of the other's.
210
+ *
211
+ * Idempotence stops at `committed`, and that boundary is the point: a
212
+ * committed object is what a truth record is allowed to reference, so it has
213
+ * to be immutable, and re-issuing a write grant for one is the only way this
214
+ * adapter could make it otherwise.
215
+ */
216
+ createUpload(tenant: TenantId, reservation: StorageReservation): Promise<{
217
+ readonly blobId: string;
218
+ readonly uploadUrl: string;
219
+ }>;
220
+ observeUpload(tenant: TenantId, blobId: string, reservation: StorageReservation): Promise<BlobObservation | undefined>;
221
+ /**
222
+ * A GET for a committed object this tenant owns; `undefined` otherwise.
223
+ *
224
+ * Every miss answers identically — unknown hash, another tenant's object, a
225
+ * malformed id, bytes that never landed, a tombstoned row. A caller cannot
226
+ * tell them apart, which is what keeps `getDownloadUrl` from being an
227
+ * existence oracle across tenants.
228
+ *
229
+ * This is a pure committed-manifest gate. Observation and commit belong to
230
+ * the explicit finalize route; a download must never decide accounting.
231
+ */
232
+ getDownloadUrl(tenant: TenantId, blobId: string): Promise<string | undefined>;
233
+ }
234
+ export type R2ObjectMaintenanceOptions = Omit<R2BlobStoreOptions, 'objects' | 'presignTtlSeconds'>;
235
+ /**
236
+ * R2 maintenance adapter kept deliberately outside `CloudBlobStore`.
237
+ *
238
+ * Cloud conformance certifies the device-facing blob port with an exact method
239
+ * inventory. LIST/HEAD/DELETE are host operations, so putting them on
240
+ * `R2CloudBlobStore` would over-declare a capability no other cloud composition
241
+ * implements.
242
+ */
243
+ export declare class R2ObjectMaintenanceStore implements R2ObjectMaintenance {
244
+ #private;
245
+ constructor(options: R2ObjectMaintenanceOptions);
246
+ inspectObject(tenant: TenantId, hash: ContentHash): Promise<BlobObservation | undefined>;
247
+ deleteObject(tenant: TenantId, hash: ContentHash): Promise<R2DeleteResult>;
248
+ listTenantObjects(tenant: TenantId, continuationToken?: string, limit?: number): Promise<R2ObjectPage>;
249
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Postgres {@link RequestReceiptStore}: the first write is the fact.
3
+ *
4
+ * `INSERT ... ON CONFLICT DO NOTHING` and nothing else. The terminal a device
5
+ * reports is a fact, and the retry the at-least-once wire guarantees must not
6
+ * overwrite it (§12.6.4: 不覆写第一份事实). `created: false` is how the caller
7
+ * learns it was a replay, which is why an upsert that UPDATED would be wrong in
8
+ * a way no naive "record it twice" test would catch — it would pass, while
9
+ * silently rewriting history and restamping `recorded_at`.
10
+ */
11
+ import type { RequestReceipt, RequestReceiptStore } from '@byok-sdk/cloud';
12
+ import type { Clock, TenantId } from '@byok-sdk/core';
13
+ import type { Pool } from 'pg';
14
+ export declare class PostgresRequestReceiptStore implements RequestReceiptStore {
15
+ #private;
16
+ constructor(pool: Pool, clock: Clock);
17
+ record(tenant: TenantId, input: {
18
+ readonly key: string;
19
+ readonly body: string;
20
+ }): Promise<{
21
+ readonly receipt: RequestReceipt;
22
+ readonly created: boolean;
23
+ }>;
24
+ get(tenant: TenantId, key: string): Promise<RequestReceipt | undefined>;
25
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Postgres {@link TaskAttemptStore} — the ownership authority the inbound gate
3
+ * reads (N2).
4
+ *
5
+ * `claim` is a single guarded statement: `UPDATE ... WHERE owner_device_id IS
6
+ * NULL RETURNING ...`. Two devices racing the same offer therefore produce one
7
+ * owner, not a last writer. When the guard rejects, the row is re-read and
8
+ * returned as-is, which makes a losing claim (and the winner's own re-claim)
9
+ * idempotent rather than an error — the caller learns who owns the task either
10
+ * way. Ownership never transfers: reassigning an owner is the one operation
11
+ * that would make the gate's cross-device assertion unfalsifiable.
12
+ *
13
+ * Two deliberate no-ops, both about not letting a guessed id leave a trace:
14
+ * `claim` and `recordStatus` on a task this tenant never offered write nothing
15
+ * and return `undefined`.
16
+ */
17
+ import type { TaskAttempt, TaskAttemptStatus, TaskAttemptStore } from '@byok-sdk/cloud';
18
+ import type { Clock, TenantId } from '@byok-sdk/core';
19
+ import type { Pool } from 'pg';
20
+ export declare class PostgresTaskAttemptStore implements TaskAttemptStore {
21
+ #private;
22
+ constructor(pool: Pool, clock: Clock);
23
+ open(tenant: TenantId, input: {
24
+ readonly taskId: string;
25
+ readonly deviceId: string;
26
+ }): Promise<TaskAttempt>;
27
+ get(tenant: TenantId, taskId: string): Promise<TaskAttempt | undefined>;
28
+ claim(tenant: TenantId, input: {
29
+ readonly taskId: string;
30
+ readonly deviceId: string;
31
+ }): Promise<TaskAttempt | undefined>;
32
+ recordStatus(tenant: TenantId, input: {
33
+ readonly taskId: string;
34
+ readonly status: TaskAttemptStatus;
35
+ }): Promise<TaskAttempt | undefined>;
36
+ }
@@ -0,0 +1,15 @@
1
+ import { type CloudCrypto, type TruthCommitInput, type TruthCommitResult, type TruthCommitter } from '@byok-sdk/cloud';
2
+ import { type Clock, type TenantId, type TruthRecord } from '@byok-sdk/core';
3
+ import type { Pool } from 'pg';
4
+ export interface PostgresTruthCommitterOptions {
5
+ readonly pool: Pool;
6
+ readonly clock: Clock;
7
+ readonly crypto: Pick<CloudCrypto, 'sha256'>;
8
+ }
9
+ export declare class PostgresTruthCommitter implements TruthCommitter {
10
+ #private;
11
+ constructor(options: PostgresTruthCommitterOptions);
12
+ getRecord(tenant: TenantId, selector: Parameters<TruthCommitter['getRecord']>[1]): Promise<TruthRecord | undefined>;
13
+ listManifest(tenant: TenantId, query: Parameters<TruthCommitter['listManifest']>[1]): Promise<readonly import("@byok-sdk/core").TruthManifestEntry[]>;
14
+ commit(tenant: TenantId, input: TruthCommitInput): Promise<TruthCommitResult>;
15
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@byok-sdk/cloud-dataplane",
3
+ "version": "0.4.0",
4
+ "description": "BYOK SDK hosted data plane: canonical Postgres + R2 stores, migrations, truth transactions, and maintenance",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Ancienttwo/byok-sdk.git",
10
+ "directory": "packages/cloud-dataplane"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/Ancienttwo/byok-sdk/issues"
14
+ },
15
+ "homepage": "https://github.com/Ancienttwo/byok-sdk#readme",
16
+ "engines": {
17
+ "node": ">=22.19.0"
18
+ },
19
+ "sideEffects": false,
20
+ "main": "./dist/index.js",
21
+ "module": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "aws4fetch": "1.0.20",
40
+ "fast-xml-parser": "^5.10.1",
41
+ "pg": "^8.22.0",
42
+ "@byok-sdk/cloud": "0.4.0",
43
+ "@byok-sdk/protocol": "0.4.0",
44
+ "@byok-sdk/core": "0.4.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/pg": "^8.20.4",
48
+ "undici": "8.9.0",
49
+ "@byok-sdk/conformance": "0.0.0"
50
+ },
51
+ "scripts": {
52
+ "build": "tsup && tsc -p tsconfig.build.json && node scripts/copy-migrations.mjs",
53
+ "dev": "tsup --watch",
54
+ "test": "vitest run",
55
+ "test:watch": "vitest",
56
+ "typecheck": "tsc --noEmit",
57
+ "clean": "rm -rf dist"
58
+ }
59
+ }