@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.
@@ -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
+ }