@byok-sdk/server 0.1.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.
- package/LICENSE +21 -0
- package/README.md +10 -0
- package/dist/auth.d.ts +171 -0
- package/dist/blob-store.d.ts +77 -0
- package/dist/event-queue.d.ts +20 -0
- package/dist/heartbeat.d.ts +30 -0
- package/dist/http.d.ts +24 -0
- package/dist/hub.d.ts +861 -0
- package/dist/ids.d.ts +3 -0
- package/dist/index.d.ts +116 -0
- package/dist/index.js +2675 -0
- package/dist/index.js.map +1 -0
- package/dist/pairing.d.ts +58 -0
- package/dist/rate-limiter.d.ts +114 -0
- package/dist/sqlite-blob-store.d.ts +89 -0
- package/dist/sqlite-support.d.ts +84 -0
- package/dist/sqlite-task-store.d.ts +123 -0
- package/dist/task-store.d.ts +125 -0
- package/dist/types.d.ts +378 -0
- package/dist/ws-server.d.ts +20 -0
- package/package.json +56 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { TenantId } from './auth';
|
|
2
|
+
/**
|
|
3
|
+
* S1: the tenant identity a pairing code carries. Minted out-of-band by the
|
|
4
|
+
* SaaS's own auth/device-flow UI — the only party that knows which tenant a
|
|
5
|
+
* human is acting for — and returned by {@link PairingManager.redeemPairingCode}
|
|
6
|
+
* so `POST /byok/pair` can write it onto the device row in the same
|
|
7
|
+
* synchronous step that consumes the code.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately NOT a wire field: `PairRequest` has no tenant of its own
|
|
10
|
+
* (docs/protocol.md §6.1), so a device can never name the tenant it lands in.
|
|
11
|
+
* These claims are the single source of truth for the row, and the row — not
|
|
12
|
+
* a later token, and never client input — is what every authed surface
|
|
13
|
+
* checks against.
|
|
14
|
+
*/
|
|
15
|
+
export interface PairingCodeClaims {
|
|
16
|
+
tenantId: TenantId;
|
|
17
|
+
productId: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PairingCodeInfo {
|
|
20
|
+
code: string;
|
|
21
|
+
expiresAt: string;
|
|
22
|
+
}
|
|
23
|
+
/** Thrown when a pairing code is missing, expired, or already used. */
|
|
24
|
+
export declare class PairingCodeInvalidError extends Error {
|
|
25
|
+
constructor(reason: string);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* In-memory pairing-code lifecycle: single-use, ~10min TTL codes minted
|
|
29
|
+
* out-of-band (by the SaaS's own auth/device-flow UI) and redeemed exactly
|
|
30
|
+
* once by `POST /byok/pair`.
|
|
31
|
+
*
|
|
32
|
+
* Device identity (deviceId/deviceName/devicePublicKey/revocation) and
|
|
33
|
+
* token issuance moved to `auth.ts`'s `DeviceRegistry`/`TokenSigner` as of
|
|
34
|
+
* Auth v2 (docs/protocol.md §6) — this class knows about devices only to the
|
|
35
|
+
* extent of carrying the {@link PairingCodeClaims} that decide which tenant
|
|
36
|
+
* and product the device being paired will belong to (S1).
|
|
37
|
+
*/
|
|
38
|
+
export declare class PairingManager {
|
|
39
|
+
private readonly codes;
|
|
40
|
+
/**
|
|
41
|
+
* Mint a single-use code bound to `claims`. Claims are REQUIRED — a
|
|
42
|
+
* claimless mint is a compile error, and (for a JS caller, or a claims
|
|
43
|
+
* object assembled from untyped config) a runtime {@link TypeError}. There
|
|
44
|
+
* is no default tenant and no default product: a device with no tenant
|
|
45
|
+
* must be inexpressible, so the failure happens here, at the mint, rather
|
|
46
|
+
* than being filled in downstream.
|
|
47
|
+
*/
|
|
48
|
+
createPairingCode(claims: PairingCodeClaims): PairingCodeInfo;
|
|
49
|
+
/**
|
|
50
|
+
* Validate and consume a pairing code, returning the {@link PairingCodeClaims}
|
|
51
|
+
* it was minted with. Throws {@link PairingCodeInvalidError} if the code is
|
|
52
|
+
* unknown, expired, or already used — callers (the HTTP handler) map that to
|
|
53
|
+
* a 401. Single-use is what makes the caller's "redeem, then register the
|
|
54
|
+
* device row with these claims" sequence safe: a second redeem of the same
|
|
55
|
+
* code can never reach the registration step at all.
|
|
56
|
+
*/
|
|
57
|
+
redeemPairingCode(code: string): PairingCodeClaims;
|
|
58
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M4 Phase 4 (part A): per-key token bucket, used by `ConnectionHub`
|
|
3
|
+
* (`hub.ts`) to rate-limit inbound daemon->server envelopes per device.
|
|
4
|
+
* Framework-agnostic on purpose (no hub/transport types here) so it stays
|
|
5
|
+
* unit-testable in isolation, mirroring `event-queue.ts`'s own
|
|
6
|
+
* transport-agnostic split.
|
|
7
|
+
*
|
|
8
|
+
* Token bucket, not a fixed window: `burst` tokens are available immediately
|
|
9
|
+
* (accommodating a legitimate short spike — e.g. a reconnect's redelivery
|
|
10
|
+
* catch-up), refilling continuously at `messagesPerSecond` tokens/sec up to
|
|
11
|
+
* that same `burst` ceiling. A bucket is created lazily per key on first use
|
|
12
|
+
* and persists for as long as it stays active — deliberately NOT reset when
|
|
13
|
+
* a device disconnects/reconnects (see `ConnectionHub`'s own use of this
|
|
14
|
+
* class): resetting on reconnect would let a device that just got
|
|
15
|
+
* disconnected for exceeding its budget immediately burst again on
|
|
16
|
+
* reconnect, defeating the limit entirely. It IS dropped once idle long
|
|
17
|
+
* enough that keeping it around would be pointless — see
|
|
18
|
+
* `evictIdleBucketsIfDue`.
|
|
19
|
+
*
|
|
20
|
+
* Construction validates `messagesPerSecond`/`burst` fail-fast (throws
|
|
21
|
+
* `TypeError` on anything non-finite or <= 0) rather than silently building
|
|
22
|
+
* a limiter that either divides into `NaN` token math or (an `Infinity`
|
|
23
|
+
* burst/rate) never actually limits anything.
|
|
24
|
+
*
|
|
25
|
+
* Finding R5 (cross-model re-review — F10 residual): `burst` specifically
|
|
26
|
+
* must be `>= 1`, not merely `> 0`. A `0 < burst < 1` value used to pass
|
|
27
|
+
* the old `<= 0` check cleanly, but `consume()`'s own debit logic
|
|
28
|
+
* (`if (bucket.tokens < 1) return false;`) can NEVER succeed once the
|
|
29
|
+
* bucket's own CEILING (`burst`) is itself below 1 — `Math.min(this.burst,
|
|
30
|
+
* ...)` caps refill there, so `tokens` can never reach 1 no matter how
|
|
31
|
+
* long the bucket sits idle. The old validation let this construct
|
|
32
|
+
* silently — a limiter that rejects every single message, forever, for
|
|
33
|
+
* every key, is not a rate LIMIT, it's a permanent, total block; that
|
|
34
|
+
* should be a construction-time error, not a runtime surprise discovered
|
|
35
|
+
* once real traffic starts getting rejected.
|
|
36
|
+
*/
|
|
37
|
+
export interface RateLimiterOptions {
|
|
38
|
+
/** Sustained refill rate, tokens (i.e. messages) per second. Must be a finite number > 0. Default 50. */
|
|
39
|
+
messagesPerSecond?: number;
|
|
40
|
+
/** Bucket capacity — how many messages may arrive back-to-back before the limit engages. Must be a finite number >= 1 (finding R5 — see the module doc comment for why `< 1` can never let a single message through, ever). Default 100. */
|
|
41
|
+
burst?: number;
|
|
42
|
+
/** Hard cap on how many distinct keys (finding R5) this limiter tracks at once — see {@link DEFAULT_MAX_TRACKED_DEVICES}'s own doc comment. Must be a finite number >= 1. Default 10,000. */
|
|
43
|
+
maxTrackedDevices?: number;
|
|
44
|
+
}
|
|
45
|
+
export declare class RateLimiter {
|
|
46
|
+
private readonly messagesPerSecond;
|
|
47
|
+
private readonly burst;
|
|
48
|
+
private readonly buckets;
|
|
49
|
+
/**
|
|
50
|
+
* Wall-clock idle duration (ms) after which a bucket is GUARANTEED to
|
|
51
|
+
* already be refilled to `burst`, regardless of its actual token count at
|
|
52
|
+
* last touch — i.e. the time to go from 0 tokens to `burst` at this
|
|
53
|
+
* instance's configured rate. `evictIdleBucketsIfDue` uses this as the
|
|
54
|
+
* eviction threshold: dropping an entry idle at least this long and
|
|
55
|
+
* recreating it fresh (tokens = burst) on the next `consume()` is
|
|
56
|
+
* therefore behaviorally IDENTICAL to refilling it in place would have
|
|
57
|
+
* been — both cap at `burst` — so eviction is semantically invisible to
|
|
58
|
+
* the caller.
|
|
59
|
+
*/
|
|
60
|
+
private readonly idleEvictionThresholdMs;
|
|
61
|
+
/** Finding R5: hard cap on `buckets.size` — see {@link DEFAULT_MAX_TRACKED_DEVICES}'s own doc comment. */
|
|
62
|
+
private readonly maxTrackedDevices;
|
|
63
|
+
/** Calls to `consume()` since the last sweep — see `EVICTION_SWEEP_EVERY_N_CALLS`. */
|
|
64
|
+
private callsSinceSweep;
|
|
65
|
+
constructor(opts?: RateLimiterOptions);
|
|
66
|
+
/**
|
|
67
|
+
* Debit one token from `key`'s bucket, refilling first for however much
|
|
68
|
+
* wall-clock time has elapsed since its last refill. Returns `false`
|
|
69
|
+
* (and debits nothing) when the bucket is currently empty — the caller is
|
|
70
|
+
* over budget right now.
|
|
71
|
+
*/
|
|
72
|
+
consume(key: string): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Every `EVICTION_SWEEP_EVERY_N_CALLS` calls to `consume()`, drops every
|
|
75
|
+
* bucket idle for at least `idleEvictionThresholdMs` (see that field's doc
|
|
76
|
+
* comment for why this is safe). Without this, `buckets` would hold one
|
|
77
|
+
* permanent entry per historical key forever — every device that ever
|
|
78
|
+
* connected, even long after it disconnected for good — growing without
|
|
79
|
+
* bound over a long-lived server's lifetime.
|
|
80
|
+
*/
|
|
81
|
+
private evictIdleBucketsIfDue;
|
|
82
|
+
/**
|
|
83
|
+
* Finding R5 (cross-model re-review — F10 residual): called right before
|
|
84
|
+
* inserting a bucket for a genuinely NEW key, evicting the single
|
|
85
|
+
* LEAST-RECENTLY-refilled entry if `buckets` is already at
|
|
86
|
+
* `maxTrackedDevices` — an O(n) scan, but one that only ever runs once
|
|
87
|
+
* the map is already at its hard ceiling (a rare/bounded event under
|
|
88
|
+
* ordinary operation, not a per-call cost), mirroring this codebase's own
|
|
89
|
+
* established "acceptable O(n) for a rare/bounded case" precedent (e.g.
|
|
90
|
+
* `audit-log.ts`'s `compactPreservingLiveTasks` during rotation).
|
|
91
|
+
*
|
|
92
|
+
* Equivalence split (stated explicitly, not left implied):
|
|
93
|
+
* - For any evicted bucket that was ALREADY idle for at least
|
|
94
|
+
* `idleEvictionThresholdMs` (i.e. `evictIdleBucketsIfDue` would have
|
|
95
|
+
* reclaimed it anyway, just not yet — sweeps only run every
|
|
96
|
+
* `EVICTION_SWEEP_EVERY_N_CALLS` calls, not continuously), eviction is
|
|
97
|
+
* PROVABLY equivalent to an in-place refill: both cap at `burst`, so a
|
|
98
|
+
* caller can never observe the difference (see `idleEvictionThresholdMs`'s
|
|
99
|
+
* own doc comment for the identical reasoning `evictIdleBucketsIfDue`
|
|
100
|
+
* already relies on).
|
|
101
|
+
* - For a bucket evicted EARLY — still within its idle threshold, forced
|
|
102
|
+
* out only because `buckets` is at capacity (many thousands of
|
|
103
|
+
* genuinely-distinct, actively-used keys, not a quiet one) — this is
|
|
104
|
+
* BEST-EFFORT, not equivalence-preserving: whatever partial token debt
|
|
105
|
+
* that key had is discarded, and its very next `consume()` call starts
|
|
106
|
+
* completely fresh (`tokens: this.burst`), a strictly MORE permissive
|
|
107
|
+
* outcome than if it had kept its place. This is an accepted,
|
|
108
|
+
* deliberately bounded trade-off — it only ever engages under
|
|
109
|
+
* cardinality far beyond any plausible real deployment — favoring
|
|
110
|
+
* bounded memory over perfect per-key continuity in that one extreme
|
|
111
|
+
* case.
|
|
112
|
+
*/
|
|
113
|
+
private evictOldestIfAtCapacity;
|
|
114
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { type BlobStore, type CreateUploadInput, type ReadContentResult, type WriteContentResult } from './blob-store';
|
|
3
|
+
export interface SqliteBlobStoreOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Database file path. Use `:memory:` to exercise the SQLite code path
|
|
6
|
+
* without persistence — defeats this store's whole purpose (same caveat
|
|
7
|
+
* as `SqliteTaskStore`'s `:memory:` option); real restart-safety requires
|
|
8
|
+
* a real file path.
|
|
9
|
+
*/
|
|
10
|
+
path: string;
|
|
11
|
+
/** How long a presigned upload/download URL stays valid, ms. Default 15 minutes — same default as `LocalDiskBlobStore`. */
|
|
12
|
+
urlTtlMs?: number;
|
|
13
|
+
/**
|
|
14
|
+
* HMAC signing key for presigned URLs. Defaults to a key generated once
|
|
15
|
+
* and persisted in this same database (a `meta` table) — so, unlike
|
|
16
|
+
* `LocalDiskBlobStore`'s fresh-per-instance `randomBytes(32)` (fine there
|
|
17
|
+
* only because its metadata doesn't survive a restart either), the
|
|
18
|
+
* default here is *already* stable across restarts: a URL signed by one
|
|
19
|
+
* process instance still verifies against a later instance pointed at
|
|
20
|
+
* the same database file. Pass this explicitly only if the key needs to
|
|
21
|
+
* live outside the database (e.g. shared across multiple database files,
|
|
22
|
+
* or rotated independently of the data).
|
|
23
|
+
*/
|
|
24
|
+
signingKey?: Buffer;
|
|
25
|
+
}
|
|
26
|
+
/** Exported (only) so `sqlite-blob-store.test.ts` can apply the same schema to a raw `DatabaseSync` connection when testing {@link loadOrCreateSigningSecret}'s concurrency behavior directly. */
|
|
27
|
+
export declare const SCHEMA = "\nCREATE TABLE IF NOT EXISTS meta (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS blobs (\n blob_id TEXT PRIMARY KEY,\n size INTEGER NOT NULL,\n content_type TEXT NOT NULL,\n content_hash TEXT NOT NULL,\n uploaded INTEGER NOT NULL DEFAULT 0,\n data BLOB\n);\n";
|
|
28
|
+
/**
|
|
29
|
+
* Atomically load the persisted HMAC signing secret from `db`'s `meta`
|
|
30
|
+
* table, generating and persisting one if none exists yet.
|
|
31
|
+
*
|
|
32
|
+
* Safe under two `DatabaseSync` connections racing on the same file — e.g.
|
|
33
|
+
* two fresh `SqliteBlobStore` instances constructed against a brand-new
|
|
34
|
+
* database at nearly the same moment. Both may see no existing row (via the
|
|
35
|
+
* initial `SELECT`) and both generate a candidate secret, but `INSERT OR
|
|
36
|
+
* IGNORE` guarantees at most one candidate is ever persisted, and —
|
|
37
|
+
* critically — EVERY caller unconditionally re-reads the row afterward and
|
|
38
|
+
* returns THAT value, never its own locally-generated candidate. Without
|
|
39
|
+
* that re-read (the bug this fixes: the previous implementation used
|
|
40
|
+
* `INSERT OR REPLACE` and returned its own candidate unconditionally), a
|
|
41
|
+
* caller whose candidate lost the race would keep using its own discarded
|
|
42
|
+
* value in memory — so a presigned URL it signs would fail to verify
|
|
43
|
+
* against any other instance, which persisted (and uses) the winning value.
|
|
44
|
+
*
|
|
45
|
+
* `generateCandidate` defaults to `randomBytes(32)`; overridable so
|
|
46
|
+
* `sqlite-blob-store.test.ts` can deterministically force the race window —
|
|
47
|
+
* real callers never need to pass it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function loadOrCreateSigningSecret(db: DatabaseSync, generateCandidate?: () => Buffer): Buffer;
|
|
50
|
+
/**
|
|
51
|
+
* Persistent {@link BlobStore} backed by `node:sqlite` — no native
|
|
52
|
+
* dependency, same rationale as `SqliteTaskStore` (`sqlite-support.ts`).
|
|
53
|
+
* Metadata AND content bytes both live in the same database file (a `data
|
|
54
|
+
* BLOB` column), so a fresh instance pointed at the same file recovers
|
|
55
|
+
* everything: declared blobs, upload state, and the bytes themselves,
|
|
56
|
+
* byte-for-byte.
|
|
57
|
+
*
|
|
58
|
+
* Presigned URLs use the same HMAC-signed-query-param scheme as
|
|
59
|
+
* `LocalDiskBlobStore` (`/byok/blobs/:id/content?sig=...&exp=...`, verified
|
|
60
|
+
* generically by `http.ts` via {@link verifySignedUrl} regardless of which
|
|
61
|
+
* `BlobStore` is plugged in) — the only difference is where the signing
|
|
62
|
+
* secret comes from; see {@link SqliteBlobStoreOptions.signingKey}.
|
|
63
|
+
*
|
|
64
|
+
* Requires Node.js 22.5+ (`node:sqlite`'s minimum); constructing this on an
|
|
65
|
+
* unsupported runtime throws `SqliteUnavailableError` (`sqlite-support.ts`).
|
|
66
|
+
*/
|
|
67
|
+
export declare class SqliteBlobStore implements BlobStore {
|
|
68
|
+
private readonly db;
|
|
69
|
+
private readonly urlTtlMs;
|
|
70
|
+
private readonly secret;
|
|
71
|
+
private readonly insertBlobStmt;
|
|
72
|
+
private readonly selectBlobStmt;
|
|
73
|
+
private readonly selectUploadedStmt;
|
|
74
|
+
private readonly writeContentStmt;
|
|
75
|
+
constructor(opts: SqliteBlobStoreOptions);
|
|
76
|
+
createUpload(input: CreateUploadInput, requestedBlobId?: string): Promise<{
|
|
77
|
+
blobId: string;
|
|
78
|
+
uploadUrl: string;
|
|
79
|
+
}>;
|
|
80
|
+
getDownloadUrl(blobId: string): Promise<string | undefined>;
|
|
81
|
+
exists(blobId: string): Promise<boolean>;
|
|
82
|
+
verifySignedUrl(blobId: string, action: 'put' | 'get', sig: string, exp: number): boolean;
|
|
83
|
+
writeContent(blobId: string, data: Buffer): Promise<WriteContentResult>;
|
|
84
|
+
readContent(blobId: string): Promise<ReadContentResult | undefined>;
|
|
85
|
+
/** Close the underlying database connection — see `SqliteTaskStore.close`'s doc comment; same rationale. */
|
|
86
|
+
close(): void;
|
|
87
|
+
private computeSig;
|
|
88
|
+
private signUrl;
|
|
89
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { DatabaseSync, DatabaseSyncOptions } from 'node:sqlite';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown when `node:sqlite` isn't available in the running Node.js binary.
|
|
4
|
+
* `node:sqlite` shipped in Node.js 22.5.0 (https://nodejs.org/api/sqlite.html)
|
|
5
|
+
* and remains marked experimental there (an `ExperimentalWarning` on stderr
|
|
6
|
+
* is expected and harmless — not an error). The SQLite-backed reference
|
|
7
|
+
* stores in this package (`SqliteTaskStore`, `SqliteBlobStore`) deliberately
|
|
8
|
+
* depend on nothing else — no `better-sqlite3` or other native module —
|
|
9
|
+
* because staying at zero native dependencies is required to keep
|
|
10
|
+
* `@byok-sdk/server` trivially packageable across platforms. The tradeoff is
|
|
11
|
+
* that these stores simply don't work below Node 22.5; this error says so
|
|
12
|
+
* clearly and up front, instead of letting a cryptic `Cannot find module
|
|
13
|
+
* 'node:sqlite'` surface from deep inside a query.
|
|
14
|
+
*/
|
|
15
|
+
export declare class SqliteUnavailableError extends Error {
|
|
16
|
+
constructor(cause: unknown);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether `nodeVersion` (a `major.minor.patch` string shaped like
|
|
20
|
+
* `process.versions.node`) is new enough to have `node:sqlite` at all.
|
|
21
|
+
* Exported only for this package's own tests to exercise the guard
|
|
22
|
+
* deterministically (this dev/CI machine is already on a qualifying Node,
|
|
23
|
+
* so the real "unavailable" path can't be triggered end-to-end here) — not
|
|
24
|
+
* re-exported from `index.ts`, so not part of the public package API.
|
|
25
|
+
* Unparsable input returns `true` (don't false-negative on a version string
|
|
26
|
+
* shape this hasn't seen before): `loadSqliteModule`'s own `require` call is
|
|
27
|
+
* the real, authoritative gate; this check only exists to turn the common
|
|
28
|
+
* case (a too-old Node) into a clear message instead of a cryptic one.
|
|
29
|
+
*
|
|
30
|
+
* Not a sufficient capability check on its own: `node:sqlite` shipped in
|
|
31
|
+
* Node 22.5.0 behind the `--experimental-sqlite` flag and only became usable
|
|
32
|
+
* unflagged in a later 22.x release (https://nodejs.org/api/sqlite.html), so
|
|
33
|
+
* a runtime that satisfies this version check can still fail to actually
|
|
34
|
+
* load the module. Use {@link isSqliteAvailable} when the question is "can I
|
|
35
|
+
* use `node:sqlite` right now", not "is the Node version new enough for it
|
|
36
|
+
* to exist at all".
|
|
37
|
+
*/
|
|
38
|
+
export declare function isSqliteCapableNodeVersion(nodeVersion: string): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Whether `node:sqlite` can ACTUALLY be loaded right now — the authoritative
|
|
41
|
+
* capability check, in contrast to {@link isSqliteCapableNodeVersion}'s
|
|
42
|
+
* version-string heuristic. This is what this package's own test suite uses
|
|
43
|
+
* to decide whether to skip the SQLite-backed reference-store tests (rather
|
|
44
|
+
* than fail them), and what anything else should call before assuming a
|
|
45
|
+
* `SqliteTaskStore`/`SqliteBlobStore` can be constructed: it attempts the
|
|
46
|
+
* real `require('node:sqlite')` (via {@link loadSqliteModule}, memoized) and
|
|
47
|
+
* reports whether that succeeded, so it agrees with reality regardless of
|
|
48
|
+
* whether the current runtime is old, too-new-but-flagged, or fully capable.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isSqliteAvailable(): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Open (or create) a `node:sqlite` `DatabaseSync` at `path`, applying the
|
|
53
|
+
* pragmas both reference SQLite stores share: WAL journaling for a
|
|
54
|
+
* file-backed database (allows a reader and a writer to proceed without
|
|
55
|
+
* blocking each other, and is what makes "close instance A, open instance B
|
|
56
|
+
* on the same file" — the restart-safety story — reliable) and a busy
|
|
57
|
+
* timeout (see {@link DEFAULT_BUSY_TIMEOUT_MS}). WAL is skipped for
|
|
58
|
+
* `:memory:`, where it's meaningless. For a file-backed path whose parent
|
|
59
|
+
* directory doesn't exist yet, creates it (recursively) at
|
|
60
|
+
* {@link SECURE_DIR_MODE} — mirroring the client-side device store's
|
|
61
|
+
* convention of never leaving a credential directory at a permissive
|
|
62
|
+
* default mode. Throws {@link SqliteUnavailableError} if `node:sqlite`
|
|
63
|
+
* itself can't be loaded (Node <22.5, or a flagged intermediate 22.x — see
|
|
64
|
+
* {@link isSqliteAvailable}) — callers don't need their own guard for that;
|
|
65
|
+
* this is the single choke point.
|
|
66
|
+
*/
|
|
67
|
+
export declare function openSqliteDatabase(path: string, options?: DatabaseSyncOptions): DatabaseSync;
|
|
68
|
+
/**
|
|
69
|
+
* Restrict `dbPath` and its WAL/SHM sibling files (`<path>-wal`,
|
|
70
|
+
* `<path>-shm` — created by SQLite itself once WAL mode is active and a
|
|
71
|
+
* write has happened) to owner-only read/write ({@link SECURE_FILE_MODE}).
|
|
72
|
+
* Both SQLite reference stores hold sensitive bytes on disk with no other
|
|
73
|
+
* access-control layer of their own — `SqliteBlobStore` an HMAC signing
|
|
74
|
+
* secret plus arbitrary uploaded blob content, `SqliteTaskStore` task
|
|
75
|
+
* instructions and device/session refs — so a real (non-`:memory:`)
|
|
76
|
+
* database file must not be left at whatever permissive mode the process'
|
|
77
|
+
* umask would otherwise give it.
|
|
78
|
+
*
|
|
79
|
+
* Call this AFTER the schema has been created against `dbPath` (so the
|
|
80
|
+
* WAL/SHM files, which SQLite creates lazily on first write, already exist)
|
|
81
|
+
* — a sibling file that doesn't exist yet is silently skipped rather than
|
|
82
|
+
* treated as an error. No-op for `:memory:`.
|
|
83
|
+
*/
|
|
84
|
+
export declare function secureSqliteFilePermissions(dbPath: string): void;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { type TaskState } from '@byok-sdk/protocol';
|
|
2
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { type CreateTaskInput, type TaskRecord, type TaskStore } from './task-store';
|
|
4
|
+
export interface SqliteTaskStoreOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Database file path. Use `:memory:` to exercise the SQLite code path
|
|
7
|
+
* without a temp file (e.g. schema/query correctness tests) — but note
|
|
8
|
+
* that defeats the entire point of this store (restart-safety), since an
|
|
9
|
+
* in-memory SQLite database vanishes with the process exactly like
|
|
10
|
+
* `InMemoryTaskStore` does. Real persistence requires a real file path.
|
|
11
|
+
*/
|
|
12
|
+
path: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* S5 hardening: two processes (or two `SqliteTaskStore` instances in this
|
|
16
|
+
* one) can both construct against the same pre-existing file at close to the
|
|
17
|
+
* same instant, both see a given column missing via the `PRAGMA table_info`
|
|
18
|
+
* read below, and both attempt the same `ALTER TABLE ... ADD COLUMN` —
|
|
19
|
+
* SQLite allows only one to actually add it; the loser's `db.exec` throws
|
|
20
|
+
* `duplicate column name`. That failure means the OTHER writer already won —
|
|
21
|
+
* the column now genuinely exists, which is exactly the end state this
|
|
22
|
+
* function is trying to reach — so it's caught here, the column list is
|
|
23
|
+
* re-inspected fresh, and this function proceeds normally (no throw) once
|
|
24
|
+
* confirmed. Anything else (a real schema problem, a disk error, a
|
|
25
|
+
* `duplicate column name` for a DIFFERENT column than expected) is rethrown
|
|
26
|
+
* unchanged — this only swallows the exact race this is written for.
|
|
27
|
+
*
|
|
28
|
+
* Exported only for this package's own tests to exercise the race
|
|
29
|
+
* deterministically (mirrors `sqlite-support.ts`'s
|
|
30
|
+
* `isSqliteCapableNodeVersion` convention) — not re-exported from
|
|
31
|
+
* `index.ts`, so not part of the public package API.
|
|
32
|
+
*/
|
|
33
|
+
export declare function ensureAdditiveColumns(db: DatabaseSync): void;
|
|
34
|
+
/**
|
|
35
|
+
* Persistent {@link TaskStore} backed by the Node.js built-in `node:sqlite`
|
|
36
|
+
* module — no native dependency (`sqlite-support.ts`'s doc comment explains
|
|
37
|
+
* why that's a hard requirement here). A fresh instance pointed at the same
|
|
38
|
+
* database file recovers every task's full RECORD (instruction, policy,
|
|
39
|
+
* device/session refs, result) exactly as `InMemoryTaskStore` would have
|
|
40
|
+
* held it in memory — this is the M3 "task records survive a process
|
|
41
|
+
* restart" story.
|
|
42
|
+
*
|
|
43
|
+
* Scope of that claim, precisely: this is RECORD persistence, not live
|
|
44
|
+
* active-task recovery. A fresh `ConnectionHub` (`hub.ts`) wired to a
|
|
45
|
+
* reopened store starts with empty runtimes/result-promises/event-queues/
|
|
46
|
+
* device-registry/outboxes — so a task that was `Running` at restart comes
|
|
47
|
+
* back as a `Running` *record* you can read and further `transition()`, not
|
|
48
|
+
* as a task with its device/runtime connection reattached that will go on
|
|
49
|
+
* to actually produce more events. Recovering/resuming an in-flight task's
|
|
50
|
+
* live connection is a larger feature and out of scope here.
|
|
51
|
+
*
|
|
52
|
+
* Every write goes through a compare-and-set `UPDATE ... WHERE task_id = ?
|
|
53
|
+
* AND state = ?` (the state {@link transition} just validated against), not
|
|
54
|
+
* an unconditional update: two connections racing on the same task (both
|
|
55
|
+
* reading e.g. `Running`, both independently validating a different target
|
|
56
|
+
* state) can't both commit, which would otherwise let the later write
|
|
57
|
+
* silently perform an illegal transition — including terminal -> terminal —
|
|
58
|
+
* that neither validation call would have allowed had it seen the other's
|
|
59
|
+
* write first. A lost compare-and-set re-reads the row and either
|
|
60
|
+
* re-validates the requested move against the state that actually won, or
|
|
61
|
+
* throws {@link IllegalTaskTransitionError} against it.
|
|
62
|
+
*
|
|
63
|
+
* Requires Node.js 22.5+ (`node:sqlite`'s minimum); constructing this on an
|
|
64
|
+
* older/unsupported runtime throws `SqliteUnavailableError`
|
|
65
|
+
* (`sqlite-support.ts`) with a clear message rather than a cryptic "Cannot
|
|
66
|
+
* find module" trace.
|
|
67
|
+
*
|
|
68
|
+
* Enforces the exact same `TASK_TRANSITIONS`/`canTransition` state machine
|
|
69
|
+
* as `InMemoryTaskStore`, via the same {@link IllegalTaskTransitionError}.
|
|
70
|
+
*/
|
|
71
|
+
export declare class SqliteTaskStore implements TaskStore {
|
|
72
|
+
private readonly db;
|
|
73
|
+
private readonly insertStmt;
|
|
74
|
+
private readonly updateStmt;
|
|
75
|
+
private readonly selectStmt;
|
|
76
|
+
private readonly selectAllStmt;
|
|
77
|
+
private readonly updatePendingApprovalIdStmt;
|
|
78
|
+
constructor(opts: SqliteTaskStoreOptions);
|
|
79
|
+
create(input: CreateTaskInput): TaskRecord;
|
|
80
|
+
get(taskId: string): TaskRecord | undefined;
|
|
81
|
+
list(): TaskRecord[];
|
|
82
|
+
/**
|
|
83
|
+
* Apply `taskId`'s state -> `to`, merging `patch` into the record. Throws
|
|
84
|
+
* {@link IllegalTaskTransitionError} if the move isn't legal per
|
|
85
|
+
* `TASK_TRANSITIONS`, and if the task doesn't exist at all — identical
|
|
86
|
+
* contract and error shapes to `InMemoryTaskStore.transition`.
|
|
87
|
+
*
|
|
88
|
+
* Implemented as a compare-and-set retry loop rather than a single
|
|
89
|
+
* read-validate-write, because two separate connections (two processes,
|
|
90
|
+
* or two `SqliteTaskStore` instances in this one) can both read the same
|
|
91
|
+
* current state and both validate a move against it before either writes.
|
|
92
|
+
* An unconditional `UPDATE` would let whichever commits last silently win
|
|
93
|
+
* — including an illegal terminal -> terminal transition neither
|
|
94
|
+
* validation call would have allowed with up-to-date information. Each
|
|
95
|
+
* iteration here reads the CURRENT state fresh, validates `to` against
|
|
96
|
+
* it, then writes with `WHERE state = <the state just validated>`
|
|
97
|
+
* (`updateStmt`). If zero rows changed, some other writer committed
|
|
98
|
+
* between this read and this write, so the loop re-reads and either
|
|
99
|
+
* re-validates `to` against whatever the state actually is now, or throws
|
|
100
|
+
* {@link IllegalTaskTransitionError} against it — the same outcome a
|
|
101
|
+
* caller would get if it happened to run a moment later.
|
|
102
|
+
*/
|
|
103
|
+
transition(taskId: string, to: TaskState, patch?: Partial<Omit<TaskRecord, 'taskId' | 'state'>>): TaskRecord;
|
|
104
|
+
/**
|
|
105
|
+
* See {@link TaskStore.setPendingApprovalId}'s own doc comment for the
|
|
106
|
+
* full rationale, and `updatePendingApprovalIdStmt`'s own doc comment
|
|
107
|
+
* (constructor, above) for the S3 CAS-guard rationale. The guarded
|
|
108
|
+
* statement affecting 0 rows means `taskId` is no longer `AwaitApproval`
|
|
109
|
+
* (or vanished) as of the write — a legitimate no-op, not an error: this
|
|
110
|
+
* method never throws for a state mismatch (best-effort bookkeeping, same
|
|
111
|
+
* as its unconditional pre-S3 form). Returns a FRESH read in that case
|
|
112
|
+
* rather than the caller's now-stale pre-write snapshot, so a caller sees
|
|
113
|
+
* what's actually stored.
|
|
114
|
+
*/
|
|
115
|
+
setPendingApprovalId(taskId: string, pendingApprovalId: string | undefined): TaskRecord | undefined;
|
|
116
|
+
/**
|
|
117
|
+
* Close the underlying database connection. Not part of the `TaskStore`
|
|
118
|
+
* interface (an in-memory store has nothing to close) — call this
|
|
119
|
+
* explicitly when a store instance is done, e.g. before opening a second
|
|
120
|
+
* instance against the same file, or on process shutdown.
|
|
121
|
+
*/
|
|
122
|
+
close(): void;
|
|
123
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { type PermissionPolicy, type RuntimeId, type TaskState } from '@byok-sdk/protocol';
|
|
2
|
+
import type { TaskSnapshot } from './types';
|
|
3
|
+
/** Thrown by a {@link TaskStore}'s `transition` when `from -> to` is not in TASK_TRANSITIONS. Every implementation (in-memory, SQLite, or otherwise) must throw this rather than silently applying an invalid move. */
|
|
4
|
+
export declare class IllegalTaskTransitionError extends Error {
|
|
5
|
+
readonly taskId: string;
|
|
6
|
+
readonly from: TaskState;
|
|
7
|
+
readonly to: TaskState;
|
|
8
|
+
constructor(taskId: string, from: TaskState, to: TaskState);
|
|
9
|
+
}
|
|
10
|
+
export interface CreateTaskInput {
|
|
11
|
+
taskId: string;
|
|
12
|
+
instruction: string;
|
|
13
|
+
runtime?: RuntimeId;
|
|
14
|
+
policy: PermissionPolicy;
|
|
15
|
+
deviceId?: string;
|
|
16
|
+
sessionRef?: string;
|
|
17
|
+
}
|
|
18
|
+
/** A task's full persisted state, as tracked by any {@link TaskStore} implementation. Same shape as {@link TaskSnapshot} — kept as its own (structurally identical) type so this storage-layer contract can evolve independently of the SDK-facing `TaskSnapshot` if a future need arises. */
|
|
19
|
+
export interface TaskRecord extends TaskSnapshot {
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Storage contract for task records — the M3 injection point, mirroring how
|
|
23
|
+
* {@link BlobStore} (`blob-store.ts`) is injectable: `createByokServer`
|
|
24
|
+
* (`index.ts`) accepts `opts.taskStore`, defaulting to {@link InMemoryTaskStore}
|
|
25
|
+
* so nothing breaks for an embedder that doesn't override it. `ConnectionHub`
|
|
26
|
+
* (`hub.ts`) is written against this interface only — it never references
|
|
27
|
+
* {@link InMemoryTaskStore} (or any other concrete implementation) directly —
|
|
28
|
+
* so a persistent implementation such as `sqlite-task-store.ts`'s
|
|
29
|
+
* `SqliteTaskStore` (M3) drops in with zero changes anywhere else.
|
|
30
|
+
*
|
|
31
|
+
* Every implementation MUST enforce the protocol's `TASK_TRANSITIONS` state
|
|
32
|
+
* machine (via `canTransition`) inside `transition`, throwing
|
|
33
|
+
* {@link IllegalTaskTransitionError} rather than silently applying an
|
|
34
|
+
* invalid move — this is part of the interface's contract, not just an
|
|
35
|
+
* `InMemoryTaskStore` implementation detail. `ConnectionHub`'s `applyOrFail`
|
|
36
|
+
* (`hub.ts`) relies on that exception type to decide "illegal transition ->
|
|
37
|
+
* force `Failed` if possible, else drop".
|
|
38
|
+
*/
|
|
39
|
+
export interface TaskStore {
|
|
40
|
+
/** Create a new task record in the `Offered` state. */
|
|
41
|
+
create(input: CreateTaskInput): TaskRecord;
|
|
42
|
+
/** Look up a task by id, or `undefined` if unknown. */
|
|
43
|
+
get(taskId: string): TaskRecord | undefined;
|
|
44
|
+
/** All known tasks. */
|
|
45
|
+
list(): TaskRecord[];
|
|
46
|
+
/**
|
|
47
|
+
* Apply `taskId`'s state -> `to`, merging `patch` into the record. Must
|
|
48
|
+
* throw {@link IllegalTaskTransitionError} if the move isn't legal per
|
|
49
|
+
* `TASK_TRANSITIONS`, and a plain `Error` (message: `` `unknown taskId:
|
|
50
|
+
* ${taskId}` ``) if the task doesn't exist at all —
|
|
51
|
+
* {@link InMemoryTaskStore.transition}'s existing message format, which
|
|
52
|
+
* some tests match on.
|
|
53
|
+
*/
|
|
54
|
+
transition(taskId: string, to: TaskState, patch?: Partial<Omit<TaskRecord, 'taskId' | 'state'>>): TaskRecord;
|
|
55
|
+
/**
|
|
56
|
+
* M5 (approval targeting): update `taskId`'s `pendingApprovalId` WITHOUT a
|
|
57
|
+
* state transition. Needed because `AwaitApproval -> AwaitApproval` is
|
|
58
|
+
* deliberately not a legal `TASK_TRANSITIONS` edge (`@byok-sdk/protocol`'s
|
|
59
|
+
* `task-state.ts`) — self-transitions aren't part of the frozen wire state
|
|
60
|
+
* machine, so `transition` above cannot be used to update this field while
|
|
61
|
+
* the record STAYS in `AwaitApproval` — yet a re-sent/updated
|
|
62
|
+
* `task.await_approval` carrying a NEWER id while the record is ALREADY
|
|
63
|
+
* `AwaitApproval` must still be reflected (see `ConnectionHub.
|
|
64
|
+
* onAwaitApproval`, `hub.ts`). Every state-CHANGING write still goes
|
|
65
|
+
* through `transition`; this is the one narrow exception for a same-state
|
|
66
|
+
* field update. Returns `undefined` for an unknown `taskId` rather than
|
|
67
|
+
* throwing — this is a best-effort bookkeeping update, not a
|
|
68
|
+
* state-machine-enforced operation like `transition`.
|
|
69
|
+
*
|
|
70
|
+
* OPTIONAL: a `TaskStore` implementation predating M5 (a custom embedder
|
|
71
|
+
* store written against the pre-M5 interface) doesn't have this method at
|
|
72
|
+
* all — every call site in `hub.ts` guards its absence with `?.()`. A
|
|
73
|
+
* store that omits it simply never records a superseding
|
|
74
|
+
* `pendingApprovalId` on the same-state redelivery path (the first entry
|
|
75
|
+
* into `AwaitApproval` still records one via `transition`'s own patch,
|
|
76
|
+
* same as ever); an operator-supplied `opts.approvalId` on
|
|
77
|
+
* `approveTask`/`rejectTask` then has nothing recorded to compare against
|
|
78
|
+
* and proceeds untargeted, exactly like a legacy daemon that never
|
|
79
|
+
* reported an id at all — a graceful degrade, not a broken embedder.
|
|
80
|
+
* Every CURRENT implementation in this package ({@link InMemoryTaskStore},
|
|
81
|
+
* `sqlite-task-store.ts`'s `SqliteTaskStore`) still implements it as a
|
|
82
|
+
* required, always-present method — optionality is a concession to
|
|
83
|
+
* EXISTING third-party implementations of this interface, not a hint that
|
|
84
|
+
* a new one should skip it.
|
|
85
|
+
*/
|
|
86
|
+
setPendingApprovalId?(taskId: string, pendingApprovalId: string | undefined): TaskRecord | undefined;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Plain, framework-agnostic in-memory {@link TaskStore}. Enforces the
|
|
90
|
+
* protocol's `TASK_TRANSITIONS` state machine via `canTransition` — every
|
|
91
|
+
* state change must go through {@link transition}, which throws
|
|
92
|
+
* {@link IllegalTaskTransitionError} rather than silently applying an invalid
|
|
93
|
+
* move. Callers (the connection hub) decide what to do with that error; see
|
|
94
|
+
* `hub.ts`'s `applyOrFail` for the "illegal transition -> force Failed if
|
|
95
|
+
* possible, else drop" policy.
|
|
96
|
+
*
|
|
97
|
+
* M0/M1/M2 reference default — loses all state on process restart. See
|
|
98
|
+
* `sqlite-task-store.ts`'s `SqliteTaskStore` (M3) for a persistent
|
|
99
|
+
* alternative implementing the same {@link TaskStore} contract.
|
|
100
|
+
*/
|
|
101
|
+
export declare class InMemoryTaskStore implements TaskStore {
|
|
102
|
+
private readonly tasks;
|
|
103
|
+
create(input: CreateTaskInput): TaskRecord;
|
|
104
|
+
get(taskId: string): TaskRecord | undefined;
|
|
105
|
+
list(): TaskRecord[];
|
|
106
|
+
/**
|
|
107
|
+
* Apply `taskId`'s state -> `to`, merging `patch` into the record. Throws
|
|
108
|
+
* {@link IllegalTaskTransitionError} if the move isn't legal per
|
|
109
|
+
* `TASK_TRANSITIONS`, and if the task doesn't exist at all.
|
|
110
|
+
*/
|
|
111
|
+
transition(taskId: string, to: TaskState, patch?: Partial<Omit<TaskRecord, 'taskId' | 'state'>>): TaskRecord;
|
|
112
|
+
/**
|
|
113
|
+
* See {@link TaskStore.setPendingApprovalId}'s own doc comment for the
|
|
114
|
+
* full rationale. State-guarded (S3 hardening): a write only applies while
|
|
115
|
+
* `taskId` is still `AwaitApproval` — mirrors `SqliteTaskStore`'s own `AND
|
|
116
|
+
* state = 'AwaitApproval'` CAS predicate (`sqlite-task-store.ts`) for
|
|
117
|
+
* symmetry between the two reference implementations, guarding against a
|
|
118
|
+
* laggard caller resurrecting a pending id after the task already left
|
|
119
|
+
* `AwaitApproval` (e.g. a queued/delayed `task.await_approval` processed
|
|
120
|
+
* after a real `approveTask`/`rejectTask` already transitioned it
|
|
121
|
+
* elsewhere). A non-matching call is a no-op: returns the record exactly
|
|
122
|
+
* as it currently stands, not the caller's requested (rejected) value.
|
|
123
|
+
*/
|
|
124
|
+
setPendingApprovalId(taskId: string, pendingApprovalId: string | undefined): TaskRecord | undefined;
|
|
125
|
+
}
|