@byok-sdk/testkit 0.3.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ancienttwo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # @byok-sdk/testkit
2
+
3
+ A headless device simulator for the BYOK device wire (docs/protocol.md §6, §12.3).
4
+
5
+ If you are integrating the SDK and writing a smoke test, this package is the part
6
+ of that test you should not be writing yourself: generating an Ed25519 identity,
7
+ exporting it the way `PairRequest.devicePublicKey` expects, knowing that a
8
+ challenge nonce is signed under a domain-separation prefix, knowing what that
9
+ prefix is, and knowing the exact shapes of `pair` / `challenge` / `token` /
10
+ `presence`. All of that is upstream knowledge. Hand-written downstream, it goes
11
+ quietly green against nothing the moment upstream changes any of it.
12
+
13
+ Runtime dependencies are `@byok-sdk/core` and `@byok-sdk/protocol`, and nothing
14
+ else — **no test framework**. The negative assertions are async functions
15
+ returning structured results, so the same checks run under vitest, under a plain
16
+ CI script, and inside a Worker. Requires Node ≥ 22.19 (or any runtime exposing
17
+ WebCrypto Ed25519 on `globalThis.crypto.subtle`).
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ npm install --save-dev @byok-sdk/testkit
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```ts
28
+ import { createDeviceSimulator, runNegativeAssertions, failedAssertions } from '@byok-sdk/testkit';
29
+
30
+ const simulator = await createDeviceSimulator({
31
+ baseUrl: 'https://cloud.example.com',
32
+ // Optional. Defaults to globalThis.fetch; pass `cloud.fetch` to drive an
33
+ // in-process composition with no socket.
34
+ // fetch: async (input, init) => cloud.fetch(new Request(input, init)),
35
+ host: {
36
+ listPresence: () => cloud.listPresence(tenant),
37
+ revokeDevice: (deviceId) => cloud.revokeDevice(tenant, deviceId),
38
+ },
39
+ });
40
+
41
+ // 1. pair — redeem a one-time pairing code your control plane minted
42
+ const session = await simulator.pair(pairingCode, 'ci-device');
43
+
44
+ // 2. challenge + 3. token — renew without re-pairing
45
+ const nonce = await simulator.challenge(session.deviceId);
46
+ await simulator.token({
47
+ deviceId: session.deviceId,
48
+ nonce,
49
+ signature: await simulator.identity.signNonce(nonce),
50
+ });
51
+ // …or `await simulator.renewAccessToken()` for all three in one call.
52
+
53
+ // 4. presence — published under the device bearer, read back host-side
54
+ await simulator.publishPresence('working', 'running the suite');
55
+ const hints = await simulator.readHostPresence();
56
+
57
+ // 5. revoke — through your host control plane
58
+ await simulator.revoke();
59
+ ```
60
+
61
+ ### The host adapter
62
+
63
+ `pair`, `challenge`, `token`, and `publishPresence` go over HTTP, because those
64
+ are real mounted routes. Minting a pairing code, reading presence back, and
65
+ revoking a device are **not**: the SDK mounts no admin route, and those are
66
+ in-process calls on `ByokCloud` / `ByokServer`. A deployment that exposes them
67
+ over HTTP chooses the paths and the credential itself, so this package will not
68
+ guess either — you pass a `SimulatorHost` with two methods. In-process (above)
69
+ and HTTP are both a few lines:
70
+
71
+ ```ts
72
+ const host = {
73
+ async listPresence() {
74
+ const response = await fetch(`${adminUrl}/presence`, { headers: adminAuth });
75
+ return response.json();
76
+ },
77
+ async revokeDevice(deviceId) {
78
+ await fetch(`${adminUrl}/devices/${deviceId}/revoke`, { method: 'POST', headers: adminAuth });
79
+ },
80
+ };
81
+ ```
82
+
83
+ ### Negative assertions
84
+
85
+ Four checks, each returning `{ name, ok, expected, actual, response }` rather
86
+ than throwing, so your runner decides what a failure means:
87
+
88
+ ```ts
89
+ const results = await runNegativeAssertions(simulator, { redeemedPairingCode: pairingCode });
90
+ expect(failedAssertions(results)).toEqual([]);
91
+ ```
92
+
93
+ `runNegativeAssertions` revokes the device as its last step (post-revocation is
94
+ terminal for that identity), so give it a simulator you are done with.
95
+
96
+ They can also be called individually — `assertUnauthenticatedRejected`,
97
+ `assertPairingCodeSingleUse`, `assertUndomainedSignatureRejected`,
98
+ `assertRevokedDeviceChallengeRejected`. Each takes an argument that makes it go
99
+ red, and the repo's `pairing-simulator` conformance suite exercises exactly that
100
+ before trusting the green form: an assertion that cannot fail is decoration.
101
+
102
+ `assertUnauthenticatedRejected` defaults to `PUT /byok/presence` — the SDK's own
103
+ credentialed HTTP surface is device-class. If your deployment publishes admin
104
+ routes, pass one as the second argument to assert the same property about it.
105
+
106
+ ## Replacement condition
107
+
108
+ This table is the contract with a downstream host: when every row is covered,
109
+ the hand-written protocol section of your smoke test can be deleted. The
110
+ left-hand column is the inventory a real integration (`salesko`,
111
+ `scripts/byok-pairing-smoke.ts`, 11 HTTP requests / 15 assertions) was forced to
112
+ write by hand before this package existed.
113
+
114
+ | Hand-written detail | Replaced by | Verified by |
115
+ |---|---|---|
116
+ | `crypto.subtle.generateKey({name:'Ed25519'})` + JWK `x` export, base64url, 43 chars | `createDeviceIdentity()`, `simulator.identity.publicKeyBase64Url`, `DEVICE_PUBLIC_KEY_LENGTH` | `packages/testkit/src/__tests__/identity.test.ts` |
117
+ | `byok-nonce-v1\n` + nonce domain-separated signing bytes | `simulator.identity.signNonce(nonce)` — bytes come from `@byok-sdk/core`'s `nonceSigningBytes`, the single authority; this package defines no domain literal | `packages/core/src/__tests__/pairing.test.ts` |
118
+ | `pair(pairingCode, deviceName, devicePublicKey) → {deviceId, accessToken}` | `simulator.pair(pairingCode, deviceName)` | conformance `pairing simulator › five primitives` |
119
+ | `challenge → {nonce}` | `simulator.challenge(deviceId?)` | same |
120
+ | `token(deviceId, nonce, signature)` | `simulator.token({deviceId, nonce, signature})`, or `renewAccessToken()` | same |
121
+ | `PUT /byok/presence {level, detail}` under device bearer | `simulator.publishPresence(level, detail?)` | same |
122
+ | Host-side presence read-back | `simulator.readHostPresence()` via `SimulatorHost` | same |
123
+ | Revoke | `simulator.revoke()` via `SimulatorHost` | same |
124
+ | Negative: unauthenticated request → 401 | `assertUnauthenticatedRejected` | conformance `negative assertions hold` + `can fail` |
125
+ | Negative: pairing code single-use, second redemption → 401 | `assertPairingCodeSingleUse` | same |
126
+ | Negative: non-domain-separated signature rejected | `assertUndomainedSignatureRejected` | same |
127
+ | Negative: post-revocation challenge → 401 | `assertRevokedDeviceChallengeRejected` | same |
128
+
129
+ Not covered, and still yours to write: anything about your own product surface —
130
+ your admin routes' authorization, your pairing-code issuance UI, and any
131
+ assertion about what your application does with a paired device.
132
+
133
+ ## License
134
+
135
+ MIT
@@ -0,0 +1,20 @@
1
+ /** Length of a base64url-encoded raw 32-byte Ed25519 public key, unpadded. */
2
+ export declare const DEVICE_PUBLIC_KEY_LENGTH = 43;
3
+ export interface DeviceIdentity {
4
+ /** JWK `x` — raw 32-byte Ed25519 public key, base64url, 43 characters. */
5
+ readonly publicKeyBase64Url: string;
6
+ /** Raw 64-byte Ed25519 signature over `message`, base64url. */
7
+ sign(message: Uint8Array): Promise<string>;
8
+ /**
9
+ * Signature over the domain-separated challenge bytes — core's
10
+ * {@link nonceSigningBytes}, never a local re-spelling of the domain. This is
11
+ * the only signing shape any real device produces.
12
+ */
13
+ signNonce(nonce: string): Promise<string>;
14
+ }
15
+ /**
16
+ * Generate a fresh device keypair. The private key stays a non-extractable
17
+ * `CryptoKey` inside this object — a simulator has no reason to hand one out,
18
+ * and a test that cannot leak a key cannot accidentally assert on one.
19
+ */
20
+ export declare function createDeviceIdentity(): Promise<DeviceIdentity>;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `@byok-sdk/testkit` — the device end of docs/protocol.md §6, headless.
3
+ *
4
+ * A host integrating the SDK needs a smoke test that proves a real device can
5
+ * pair, renew a token, publish presence, and be revoked. Written by hand, that
6
+ * test carries upstream knowledge downstream — the JWK export shape, the nonce
7
+ * signing domain, the three auth route DTOs — and goes quietly green against
8
+ * nothing when upstream changes any of them. This package is that knowledge,
9
+ * shipped from the same repo it belongs to.
10
+ *
11
+ * Runtime dependencies are `@byok-sdk/core` (the signing bytes) and
12
+ * `@byok-sdk/protocol` (the wire DTOs), and nothing else. In particular there
13
+ * is no test framework here: the negative assertions are async functions
14
+ * returning structured results, so the same four checks run under vitest, under
15
+ * a plain CI script, and inside a Worker.
16
+ */
17
+ export { DEVICE_PUBLIC_KEY_LENGTH, createDeviceIdentity } from './identity';
18
+ export type { DeviceIdentity } from './identity';
19
+ export { DEFAULT_DEVICE_NAME, DEVICE_ROUTES, DeviceSimulatorError, createDeviceSimulator } from './simulator';
20
+ export type { Credential, DeviceSession, DeviceSimulator, DeviceSimulatorOptions, SimulatorHost, SimulatorRequest, SimulatorResponse, } from './simulator';
21
+ export { assertPairingCodeSingleUse, assertRevokedDeviceChallengeRejected, assertUnauthenticatedRejected, assertUndomainedSignatureRejected, failedAssertions, runNegativeAssertions, } from './negatives';
22
+ export type { NegativeAssertionResult, NegativeSuiteInput } from './negatives';
package/dist/index.js ADDED
@@ -0,0 +1,288 @@
1
+ import { nonceSigningBytes } from '@byok-sdk/core';
2
+ import { BYOK_PRESENCE_PATH, BYOK_TOKEN_PATH, BYOK_CHALLENGE_PATH, BYOK_PAIR_PATH, PairResponseSchema, ChallengeResponseSchema, TokenResponseSchema } from '@byok-sdk/protocol';
3
+
4
+ // src/identity.ts
5
+ var DEVICE_PUBLIC_KEY_LENGTH = 43;
6
+ function base64url(bytes) {
7
+ let binary = "";
8
+ for (const byte of bytes) binary += String.fromCharCode(byte);
9
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
10
+ }
11
+ async function createDeviceIdentity() {
12
+ const subtle = globalThis.crypto?.subtle;
13
+ if (subtle === void 0) {
14
+ throw new Error(
15
+ "@byok-sdk/testkit needs WebCrypto: globalThis.crypto.subtle is unavailable in this runtime (Node >=22.19 or a Worker provides it)."
16
+ );
17
+ }
18
+ const generated = await subtle.generateKey({ name: "Ed25519" }, false, ["sign", "verify"]);
19
+ if (!("privateKey" in generated)) {
20
+ throw new Error("WebCrypto returned a single key for Ed25519, expected a keypair");
21
+ }
22
+ const jwk = await subtle.exportKey("jwk", generated.publicKey);
23
+ const x = jwk.x;
24
+ if (typeof x !== "string" || x.length !== DEVICE_PUBLIC_KEY_LENGTH) {
25
+ throw new Error(
26
+ `Ed25519 public key JWK 'x' must be ${DEVICE_PUBLIC_KEY_LENGTH} base64url characters, got ${String(x)}`
27
+ );
28
+ }
29
+ const sign = async (message) => {
30
+ const signature = await subtle.sign(
31
+ { name: "Ed25519" },
32
+ generated.privateKey,
33
+ message.slice().buffer
34
+ );
35
+ return base64url(new Uint8Array(signature));
36
+ };
37
+ return {
38
+ publicKeyBase64Url: x,
39
+ sign,
40
+ signNonce: (nonce) => sign(nonceSigningBytes(nonce))
41
+ };
42
+ }
43
+ var DEVICE_ROUTES = {
44
+ pair: BYOK_PAIR_PATH,
45
+ challenge: BYOK_CHALLENGE_PATH,
46
+ token: BYOK_TOKEN_PATH,
47
+ presence: BYOK_PRESENCE_PATH
48
+ };
49
+ var DEFAULT_DEVICE_NAME = "byok-testkit-device";
50
+ var DeviceSimulatorError = class extends Error {
51
+ status;
52
+ body;
53
+ text;
54
+ constructor(message, response) {
55
+ super(`${message} (HTTP ${response.status}): ${response.text || "<empty body>"}`);
56
+ this.name = "DeviceSimulatorError";
57
+ this.status = response.status;
58
+ this.body = response.body;
59
+ this.text = response.text;
60
+ }
61
+ };
62
+ function parseJson(text) {
63
+ if (text.length === 0) return void 0;
64
+ try {
65
+ return JSON.parse(text);
66
+ } catch {
67
+ return void 0;
68
+ }
69
+ }
70
+ async function createDeviceSimulator(options) {
71
+ const identity = await createDeviceIdentity();
72
+ const doFetch = options.fetch ?? globalThis.fetch;
73
+ const origin = options.baseUrl.replace(/\/+$/, "");
74
+ const defaultDeviceName = options.deviceName ?? DEFAULT_DEVICE_NAME;
75
+ let session;
76
+ function requireSession(action) {
77
+ if (session === void 0) {
78
+ throw new Error(`${action} needs a paired device: call pair(pairingCode) first.`);
79
+ }
80
+ return session;
81
+ }
82
+ function requireHost(action) {
83
+ if (options.host === void 0) {
84
+ throw new Error(
85
+ `${action} is a host control-plane operation and the SDK mounts no admin route for it: pass a SimulatorHost to createDeviceSimulator.`
86
+ );
87
+ }
88
+ return options.host;
89
+ }
90
+ async function request(input) {
91
+ const headers = new Headers();
92
+ if (input.body !== void 0) headers.set("content-type", "application/json");
93
+ if ((input.credential ?? "device") === "device" && session !== void 0) {
94
+ headers.set("authorization", `Bearer ${session.accessToken}`);
95
+ }
96
+ const response = await doFetch(
97
+ new Request(`${origin}${input.path}`, {
98
+ method: input.method,
99
+ headers,
100
+ ...input.body === void 0 ? {} : { body: JSON.stringify(input.body) }
101
+ })
102
+ );
103
+ const text = await response.text();
104
+ return { status: response.status, text, body: parseJson(text) };
105
+ }
106
+ function expectOk(what, response) {
107
+ if (response.status !== 200) throw new DeviceSimulatorError(what, response);
108
+ return response;
109
+ }
110
+ async function pair(pairingCode, deviceName = defaultDeviceName) {
111
+ const response = expectOk(
112
+ "pairing rejected",
113
+ await request({
114
+ method: "POST",
115
+ path: DEVICE_ROUTES.pair,
116
+ credential: "none",
117
+ body: {
118
+ pairingCode,
119
+ deviceName,
120
+ devicePublicKey: identity.publicKeyBase64Url
121
+ }
122
+ })
123
+ );
124
+ const parsed = PairResponseSchema.parse(response.body);
125
+ session = { deviceId: parsed.deviceId, accessToken: parsed.accessToken };
126
+ return session;
127
+ }
128
+ async function challenge(deviceId) {
129
+ const response = expectOk(
130
+ "challenge rejected",
131
+ await request({
132
+ method: "POST",
133
+ path: DEVICE_ROUTES.challenge,
134
+ credential: "none",
135
+ body: { deviceId: deviceId ?? requireSession("challenge()").deviceId }
136
+ })
137
+ );
138
+ return ChallengeResponseSchema.parse(response.body).nonce;
139
+ }
140
+ async function token(input) {
141
+ const response = expectOk(
142
+ "token exchange rejected",
143
+ await request({
144
+ method: "POST",
145
+ path: DEVICE_ROUTES.token,
146
+ credential: "none",
147
+ body: input
148
+ })
149
+ );
150
+ return TokenResponseSchema.parse(response.body).accessToken;
151
+ }
152
+ async function renewAccessToken() {
153
+ const current = requireSession("renewAccessToken()");
154
+ const nonce = await challenge(current.deviceId);
155
+ const accessToken = await token({
156
+ deviceId: current.deviceId,
157
+ nonce,
158
+ signature: await identity.signNonce(nonce)
159
+ });
160
+ session = { deviceId: current.deviceId, accessToken };
161
+ return accessToken;
162
+ }
163
+ return {
164
+ identity,
165
+ get session() {
166
+ return session;
167
+ },
168
+ pair,
169
+ challenge,
170
+ token,
171
+ renewAccessToken,
172
+ async publishPresence(level, detail) {
173
+ requireSession("publishPresence()");
174
+ expectOk(
175
+ "presence publication rejected",
176
+ await request({
177
+ method: "PUT",
178
+ path: DEVICE_ROUTES.presence,
179
+ body: { level, ...detail === void 0 ? {} : { detail } }
180
+ })
181
+ );
182
+ },
183
+ readHostPresence() {
184
+ return requireHost("readHostPresence()").listPresence();
185
+ },
186
+ revoke() {
187
+ return requireHost("revoke()").revokeDevice(requireSession("revoke()").deviceId);
188
+ },
189
+ request
190
+ };
191
+ }
192
+
193
+ // src/negatives.ts
194
+ function result(name, expected, response, expectedStatus) {
195
+ return {
196
+ name,
197
+ ok: response.status === expectedStatus,
198
+ expected,
199
+ actual: `HTTP ${response.status}`,
200
+ response
201
+ };
202
+ }
203
+ async function assertUnauthenticatedRejected(simulator, target = {
204
+ method: "PUT",
205
+ path: DEVICE_ROUTES.presence,
206
+ body: { level: "online" }
207
+ }) {
208
+ const response = await simulator.request({ ...target, credential: "none" });
209
+ return result(
210
+ "unauthenticated-request-rejected",
211
+ `${target.method} ${target.path} without a credential is 401`,
212
+ response,
213
+ 401
214
+ );
215
+ }
216
+ async function assertPairingCodeSingleUse(simulator, redeemedPairingCode, deviceName = "byok-testkit-replay") {
217
+ const response = await simulator.request({
218
+ method: "POST",
219
+ path: DEVICE_ROUTES.pair,
220
+ credential: "none",
221
+ body: {
222
+ pairingCode: redeemedPairingCode,
223
+ deviceName,
224
+ devicePublicKey: simulator.identity.publicKeyBase64Url
225
+ }
226
+ });
227
+ return result(
228
+ "pairing-code-single-use",
229
+ "a second redemption of the same pairing code is 401",
230
+ response,
231
+ 401
232
+ );
233
+ }
234
+ async function assertUndomainedSignatureRejected(simulator, options = {}) {
235
+ const session = simulator.session;
236
+ if (session === void 0) {
237
+ throw new Error("assertUndomainedSignatureRejected needs a paired device.");
238
+ }
239
+ const nonce = await simulator.challenge(session.deviceId);
240
+ const signature = options.domainSeparated === true ? await simulator.identity.signNonce(nonce) : await simulator.identity.sign(new TextEncoder().encode(nonce));
241
+ const response = await simulator.request({
242
+ method: "POST",
243
+ path: DEVICE_ROUTES.token,
244
+ credential: "none",
245
+ body: { deviceId: session.deviceId, nonce, signature }
246
+ });
247
+ return result(
248
+ "undomained-signature-rejected",
249
+ "a signature over the bare nonce, without the signing domain, is 401",
250
+ response,
251
+ 401
252
+ );
253
+ }
254
+ async function assertRevokedDeviceChallengeRejected(simulator) {
255
+ const session = simulator.session;
256
+ if (session === void 0) {
257
+ throw new Error("assertRevokedDeviceChallengeRejected needs a paired device.");
258
+ }
259
+ const response = await simulator.request({
260
+ method: "POST",
261
+ path: DEVICE_ROUTES.challenge,
262
+ credential: "none",
263
+ body: { deviceId: session.deviceId }
264
+ });
265
+ return result(
266
+ "revoked-device-challenge-rejected",
267
+ "a revoked device asking for a challenge is 401",
268
+ response,
269
+ 401
270
+ );
271
+ }
272
+ async function runNegativeAssertions(simulator, input) {
273
+ const results = [
274
+ input.unauthenticatedTarget === void 0 ? await assertUnauthenticatedRejected(simulator) : await assertUnauthenticatedRejected(simulator, input.unauthenticatedTarget),
275
+ await assertPairingCodeSingleUse(simulator, input.redeemedPairingCode),
276
+ await assertUndomainedSignatureRejected(simulator)
277
+ ];
278
+ await simulator.revoke();
279
+ results.push(await assertRevokedDeviceChallengeRejected(simulator));
280
+ return results;
281
+ }
282
+ function failedAssertions(results) {
283
+ return results.filter((entry) => !entry.ok);
284
+ }
285
+
286
+ export { DEFAULT_DEVICE_NAME, DEVICE_PUBLIC_KEY_LENGTH, DEVICE_ROUTES, DeviceSimulatorError, assertPairingCodeSingleUse, assertRevokedDeviceChallengeRejected, assertUnauthenticatedRejected, assertUndomainedSignatureRejected, createDeviceIdentity, createDeviceSimulator, failedAssertions, runNegativeAssertions };
287
+ //# sourceMappingURL=index.js.map
288
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/identity.ts","../src/simulator.ts","../src/negatives.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * A simulated device's identity key (docs/protocol.md §6.1).\n *\n * Ed25519 through WebCrypto rather than `node:crypto`, for the same reason\n * `@byok-sdk/core` holds no `node:` import: a simulator that only runs on Node\n * cannot smoke-test a Workers deployment from inside a Worker. Node ≥22.19\n * (this workspace's floor) exposes Ed25519 on `globalThis.crypto.subtle`, so\n * there is no polyfill seam and no second code path.\n *\n * The public key crosses the wire as the JWK `x` member: RFC 8037 defines it as\n * the raw 32-byte key, base64url with no padding (43 characters), which is\n * exactly the encoding `PairRequest.devicePublicKey` is specified in and the\n * form both the reference server and the hosted surface hand to their verifier\n * without an ASN.1/SPKI unwrap. Signatures are the raw 64-byte Ed25519\n * signature, base64url.\n */\nimport { nonceSigningBytes } from '@byok-sdk/core';\n\n/** Length of a base64url-encoded raw 32-byte Ed25519 public key, unpadded. */\nexport const DEVICE_PUBLIC_KEY_LENGTH = 43;\n\nexport interface DeviceIdentity {\n /** JWK `x` — raw 32-byte Ed25519 public key, base64url, 43 characters. */\n readonly publicKeyBase64Url: string;\n /** Raw 64-byte Ed25519 signature over `message`, base64url. */\n sign(message: Uint8Array): Promise<string>;\n /**\n * Signature over the domain-separated challenge bytes — core's\n * {@link nonceSigningBytes}, never a local re-spelling of the domain. This is\n * the only signing shape any real device produces.\n */\n signNonce(nonce: string): Promise<string>;\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let binary = '';\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');\n}\n\n/**\n * Generate a fresh device keypair. The private key stays a non-extractable\n * `CryptoKey` inside this object — a simulator has no reason to hand one out,\n * and a test that cannot leak a key cannot accidentally assert on one.\n */\nexport async function createDeviceIdentity(): Promise<DeviceIdentity> {\n const subtle = globalThis.crypto?.subtle;\n if (subtle === undefined) {\n throw new Error(\n '@byok-sdk/testkit needs WebCrypto: globalThis.crypto.subtle is unavailable in this runtime (Node >=22.19 or a Worker provides it).',\n );\n }\n\n const generated = await subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify']);\n // `generateKey` is typed as \"a key or a key pair\" because the algorithm decides\n // which; Ed25519 is asymmetric, so narrow rather than assert.\n if (!('privateKey' in generated)) {\n throw new Error('WebCrypto returned a single key for Ed25519, expected a keypair');\n }\n\n const jwk = await subtle.exportKey('jwk', generated.publicKey);\n const x = jwk.x;\n if (typeof x !== 'string' || x.length !== DEVICE_PUBLIC_KEY_LENGTH) {\n throw new Error(\n `Ed25519 public key JWK 'x' must be ${DEVICE_PUBLIC_KEY_LENGTH} base64url characters, got ${String(x)}`,\n );\n }\n\n const sign = async (message: Uint8Array): Promise<string> => {\n // `BufferSource` in the DOM lib is `ArrayBufferView<ArrayBuffer>`; a\n // Uint8Array over a SharedArrayBuffer is not one, so hand `subtle` the\n // backing bytes explicitly rather than widening the parameter type.\n const signature = await subtle.sign(\n { name: 'Ed25519' },\n generated.privateKey,\n message.slice().buffer as ArrayBuffer,\n );\n return base64url(new Uint8Array(signature));\n };\n\n return {\n publicKeyBase64Url: x,\n sign,\n signNonce: (nonce) => sign(nonceSigningBytes(nonce)),\n };\n}\n","/**\n * `createDeviceSimulator` — one device, driven over the real device wire.\n *\n * The problem this exists to remove: a host integrating the SDK writes a smoke\n * test, and to write it has to re-implement the device end of docs/protocol.md\n * §6 by hand — generate an Ed25519 key, export the JWK `x`, know that a nonce\n * is signed under a domain prefix, know the exact literal, know the three auth\n * route shapes. Every one of those is upstream knowledge living downstream, so\n * when upstream changes the domain or the pairing schema, every host's smoke\n * test keeps passing against nothing.\n *\n * So this package owns the device end, and owns *nothing else*:\n *\n * - The bytes signed for a challenge come from `@byok-sdk/core`'s\n * `nonceSigningBytes`. There is no domain literal in this package — a\n * simulator carrying its own copy would be the fourth copy of the drift this\n * slice removed.\n * - The request/response DTOs are parsed with `@byok-sdk/protocol`'s schemas,\n * fail-closed. A response this package cannot parse is an error, never a\n * partially-trusted object.\n * - The paths are the hosted surface's real ones (`POST /byok/pair`,\n * `POST /byok/challenge`, `POST /byok/token`, `PUT /byok/presence`).\n *\n * What it deliberately does NOT own is the host control plane. The SDK mounts\n * no admin route: minting a pairing code, listing presence, and revoking a\n * device are in-process calls on `ByokCloud`/`ByokServer`, and a deployment\n * that exposes them over HTTP defines those paths and their credential itself.\n * Inventing `/byok/admin/...` here would be publishing a wire contract that\n * does not exist, so the host surface arrives as a {@link SimulatorHost}\n * adapter the caller supplies — three methods, in-process or HTTP, its choice.\n */\nimport type { PresenceHint, PresenceLevel } from '@byok-sdk/core';\nimport {\n BYOK_CHALLENGE_PATH,\n BYOK_PAIR_PATH,\n BYOK_PRESENCE_PATH,\n BYOK_TOKEN_PATH,\n ChallengeResponseSchema,\n PairResponseSchema,\n TokenResponseSchema,\n} from '@byok-sdk/protocol';\nimport { createDeviceIdentity, type DeviceIdentity } from './identity';\n\n/** The device-surface routes this simulator drives (docs/protocol.md §6, §12.3). */\nexport const DEVICE_ROUTES = {\n pair: BYOK_PAIR_PATH,\n challenge: BYOK_CHALLENGE_PATH,\n token: BYOK_TOKEN_PATH,\n presence: BYOK_PRESENCE_PATH,\n} as const;\n\nexport const DEFAULT_DEVICE_NAME = 'byok-testkit-device';\n\n/**\n * The host control plane, supplied by the caller.\n *\n * In-process (`cloud.listPresence(tenant)`) and over HTTP (the host's own admin\n * route plus whatever credential guards it) are both one small adapter. What\n * this package will not do is guess either the path or the credential.\n */\nexport interface SimulatorHost {\n /** Every live presence hint the host can see for the tenant under test. */\n listPresence(): Promise<readonly PresenceHint[]>;\n /** Revoke a device. After this, its next challenge/token/authed call is a 401 (§6.3). */\n revokeDevice(deviceId: string): Promise<void>;\n}\n\nexport interface DeviceSimulatorOptions {\n /** Origin the device surface is mounted at, e.g. `https://cloud.example.com`. */\n readonly baseUrl: string;\n /** Defaults to `globalThis.fetch`. A composition with no socket passes its own `cloud.fetch`. */\n readonly fetch?: typeof globalThis.fetch;\n /** Host control plane. Absent is legal; `readHostPresence`/`revoke` then throw rather than guess. */\n readonly host?: SimulatorHost;\n /** Device name sent at pairing time. */\n readonly deviceName?: string;\n}\n\n/** What `POST /byok/pair` handed back — the device's identity on this deployment. */\nexport interface DeviceSession {\n readonly deviceId: string;\n readonly accessToken: string;\n}\n\nexport type Credential = 'device' | 'none';\n\nexport interface SimulatorRequest {\n readonly method: string;\n readonly path: string;\n readonly body?: unknown;\n /** `'device'` sends the current access token; `'none'` sends no Authorization header. */\n readonly credential?: Credential;\n}\n\nexport interface SimulatorResponse {\n readonly status: number;\n /** The response body verbatim. */\n readonly text: string;\n /**\n * `text` parsed as JSON, or `undefined` when it is empty or is not JSON at\n * all — a 404 from a router that answers in plain text is a real answer, and\n * the status is what an assertion is asking about. Absent means \"no JSON\n * here\", never \"here is what it probably meant\": every caller that needs a\n * DTO runs it through a `@byok-sdk/protocol` schema, which refuses\n * `undefined` outright.\n */\n readonly body: unknown;\n}\n\n/** A request that did not get the status this call requires. Carries the response, not a summary of it. */\nexport class DeviceSimulatorError extends Error {\n readonly status: number;\n readonly body: unknown;\n readonly text: string;\n\n constructor(message: string, response: SimulatorResponse) {\n super(`${message} (HTTP ${response.status}): ${response.text || '<empty body>'}`);\n this.name = 'DeviceSimulatorError';\n this.status = response.status;\n this.body = response.body;\n this.text = response.text;\n }\n}\n\nexport interface DeviceSimulator {\n readonly identity: DeviceIdentity;\n /** The paired session, or `undefined` before `pair()`. */\n readonly session: DeviceSession | undefined;\n\n /** §6.1 — redeem a one-time pairing code and register this device's public key. */\n pair(pairingCode: string, deviceName?: string): Promise<DeviceSession>;\n /** §6.2 — ask for a one-time nonce to sign. */\n challenge(deviceId?: string): Promise<string>;\n /** §6.2 — trade a signed nonce for a fresh access token. The signature is supplied, never assumed. */\n token(request: { deviceId: string; nonce: string; signature: string }): Promise<string>;\n /** `challenge()` → sign with the domain-separated bytes → `token()`, adopting the new access token. */\n renewAccessToken(): Promise<string>;\n /** §12.3 — publish a presence hint under the device bearer. */\n publishPresence(level: PresenceLevel, detail?: string): Promise<void>;\n /** Read presence back the way a host does, through the supplied {@link SimulatorHost}. */\n readHostPresence(): Promise<readonly PresenceHint[]>;\n /** §6.3 — revoke this device through the supplied {@link SimulatorHost}. */\n revoke(): Promise<void>;\n\n /**\n * The raw request primitive every call above is built from. Exposed because a\n * host's own extra assertions should not have to re-derive base URL joining,\n * bearer injection, and JSON parsing to make one off-path request.\n */\n request(input: SimulatorRequest): Promise<SimulatorResponse>;\n}\n\nfunction parseJson(text: string): unknown {\n if (text.length === 0) return undefined;\n try {\n return JSON.parse(text);\n } catch {\n return undefined;\n }\n}\n\nexport async function createDeviceSimulator(\n options: DeviceSimulatorOptions,\n): Promise<DeviceSimulator> {\n const identity = await createDeviceIdentity();\n const doFetch = options.fetch ?? globalThis.fetch;\n const origin = options.baseUrl.replace(/\\/+$/, '');\n const defaultDeviceName = options.deviceName ?? DEFAULT_DEVICE_NAME;\n let session: DeviceSession | undefined;\n\n function requireSession(action: string): DeviceSession {\n if (session === undefined) {\n throw new Error(`${action} needs a paired device: call pair(pairingCode) first.`);\n }\n return session;\n }\n\n function requireHost(action: string): SimulatorHost {\n if (options.host === undefined) {\n throw new Error(\n `${action} is a host control-plane operation and the SDK mounts no admin route for it: pass a SimulatorHost to createDeviceSimulator.`,\n );\n }\n return options.host;\n }\n\n async function request(input: SimulatorRequest): Promise<SimulatorResponse> {\n const headers = new Headers();\n if (input.body !== undefined) headers.set('content-type', 'application/json');\n if ((input.credential ?? 'device') === 'device' && session !== undefined) {\n headers.set('authorization', `Bearer ${session.accessToken}`);\n }\n\n const response = await doFetch(\n new Request(`${origin}${input.path}`, {\n method: input.method,\n headers,\n ...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }),\n }),\n );\n const text = await response.text();\n return { status: response.status, text, body: parseJson(text) };\n }\n\n function expectOk(what: string, response: SimulatorResponse): SimulatorResponse {\n if (response.status !== 200) throw new DeviceSimulatorError(what, response);\n return response;\n }\n\n async function pair(pairingCode: string, deviceName = defaultDeviceName): Promise<DeviceSession> {\n const response = expectOk(\n 'pairing rejected',\n await request({\n method: 'POST',\n path: DEVICE_ROUTES.pair,\n credential: 'none',\n body: {\n pairingCode,\n deviceName,\n devicePublicKey: identity.publicKeyBase64Url,\n },\n }),\n );\n const parsed = PairResponseSchema.parse(response.body);\n session = { deviceId: parsed.deviceId, accessToken: parsed.accessToken };\n return session;\n }\n\n async function challenge(deviceId?: string): Promise<string> {\n const response = expectOk(\n 'challenge rejected',\n await request({\n method: 'POST',\n path: DEVICE_ROUTES.challenge,\n credential: 'none',\n body: { deviceId: deviceId ?? requireSession('challenge()').deviceId },\n }),\n );\n return ChallengeResponseSchema.parse(response.body).nonce;\n }\n\n async function token(input: {\n deviceId: string;\n nonce: string;\n signature: string;\n }): Promise<string> {\n const response = expectOk(\n 'token exchange rejected',\n await request({\n method: 'POST',\n path: DEVICE_ROUTES.token,\n credential: 'none',\n body: input,\n }),\n );\n return TokenResponseSchema.parse(response.body).accessToken;\n }\n\n async function renewAccessToken(): Promise<string> {\n const current = requireSession('renewAccessToken()');\n const nonce = await challenge(current.deviceId);\n const accessToken = await token({\n deviceId: current.deviceId,\n nonce,\n signature: await identity.signNonce(nonce),\n });\n session = { deviceId: current.deviceId, accessToken };\n return accessToken;\n }\n\n return {\n identity,\n get session() {\n return session;\n },\n\n pair,\n challenge,\n token,\n renewAccessToken,\n\n async publishPresence(level, detail) {\n requireSession('publishPresence()');\n expectOk(\n 'presence publication rejected',\n await request({\n method: 'PUT',\n path: DEVICE_ROUTES.presence,\n body: { level, ...(detail === undefined ? {} : { detail }) },\n }),\n );\n },\n\n readHostPresence() {\n return requireHost('readHostPresence()').listPresence();\n },\n\n revoke() {\n return requireHost('revoke()').revokeDevice(requireSession('revoke()').deviceId);\n },\n\n request,\n };\n}\n","/**\n * The four negative assertions a host's pairing smoke test hand-writes.\n *\n * These are plain async functions on purpose. A negative assertion belongs to\n * the protocol, not to a test runner: the same four have to be runnable from\n * vitest, from a CI script, from a deployment gate, and from a Worker. So each\n * returns a structured {@link NegativeAssertionResult} instead of throwing —\n * the caller decides what a failure means, and a vitest consumer writes\n * `expect(result.ok).toBe(true)` and gets the observed status in the diff.\n *\n * Each one names a defense that must hold, and each is written so that pointing\n * it at the wrong input makes it FAIL. An assertion that cannot go red proves\n * nothing, and the ones here are checked that way in\n * `@byok-sdk/conformance`'s `pairing-simulator` suite before they are trusted\n * green: unauthenticated against a public route, single-use against an unused\n * code, undomained against a properly domained signature, revoked against a\n * device that was never revoked.\n */\nimport type { DeviceSimulator, SimulatorRequest, SimulatorResponse } from './simulator';\nimport { DEVICE_ROUTES } from './simulator';\n\nexport interface NegativeAssertionResult {\n /** Stable identifier — safe to use as a test name or a CI check id. */\n readonly name: string;\n readonly ok: boolean;\n /** What the protocol requires. */\n readonly expected: string;\n /** What the deployment actually did. */\n readonly actual: string;\n /** The response that decided it, when one status decided it. */\n readonly response?: SimulatorResponse;\n}\n\nfunction result(\n name: string,\n expected: string,\n response: SimulatorResponse,\n expectedStatus: number,\n): NegativeAssertionResult {\n return {\n name,\n ok: response.status === expectedStatus,\n expected,\n actual: `HTTP ${response.status}`,\n response,\n };\n}\n\n/**\n * A credentialed route answers 401 when the credential is absent.\n *\n * Defaults to `PUT /byok/presence` — the SDK's own credentialed HTTP surface is\n * device-class, because the host control plane (pairing-code mint, presence\n * read, revoke) is in-process and mounts no route. A deployment that publishes\n * its own admin routes passes one here and gets the same assertion against it.\n */\nexport async function assertUnauthenticatedRejected(\n simulator: DeviceSimulator,\n target: Omit<SimulatorRequest, 'credential'> = {\n method: 'PUT',\n path: DEVICE_ROUTES.presence,\n body: { level: 'online' },\n },\n): Promise<NegativeAssertionResult> {\n const response = await simulator.request({ ...target, credential: 'none' });\n return result(\n 'unauthenticated-request-rejected',\n `${target.method} ${target.path} without a credential is 401`,\n response,\n 401,\n );\n}\n\n/**\n * A pairing code is single-use (§6.1): redeeming an already-redeemed code is a\n * 401, indistinguishable from an unknown or expired one.\n *\n * Pass the code THIS simulator (or another one) already paired with. Pointing\n * it at a fresh code pairs a second device and returns `ok: false` — that is\n * the red form.\n */\nexport async function assertPairingCodeSingleUse(\n simulator: DeviceSimulator,\n redeemedPairingCode: string,\n deviceName = 'byok-testkit-replay',\n): Promise<NegativeAssertionResult> {\n const response = await simulator.request({\n method: 'POST',\n path: DEVICE_ROUTES.pair,\n credential: 'none',\n body: {\n pairingCode: redeemedPairingCode,\n deviceName,\n devicePublicKey: simulator.identity.publicKeyBase64Url,\n },\n });\n return result(\n 'pairing-code-single-use',\n 'a second redemption of the same pairing code is 401',\n response,\n 401,\n );\n}\n\n/**\n * A signature over the BARE nonce does not renew a token (§6.2, GAP-004).\n *\n * This is the one place in the package that signs something other than\n * `nonceSigningBytes` — and it signs *less*, never a second domain of its own.\n * The whole point is that the undomained encoding has no accepted form\n * anywhere, so there is no dual mode to accidentally validate.\n *\n * Set `domainSeparated: true` to get the red form: the correctly domained\n * signature renews the token, 200, and the assertion reports failure.\n */\nexport async function assertUndomainedSignatureRejected(\n simulator: DeviceSimulator,\n options: { readonly domainSeparated?: boolean } = {},\n): Promise<NegativeAssertionResult> {\n const session = simulator.session;\n if (session === undefined) {\n throw new Error('assertUndomainedSignatureRejected needs a paired device.');\n }\n\n const nonce = await simulator.challenge(session.deviceId);\n const signature =\n options.domainSeparated === true\n ? await simulator.identity.signNonce(nonce)\n : await simulator.identity.sign(new TextEncoder().encode(nonce));\n\n const response = await simulator.request({\n method: 'POST',\n path: DEVICE_ROUTES.token,\n credential: 'none',\n body: { deviceId: session.deviceId, nonce, signature },\n });\n return result(\n 'undomained-signature-rejected',\n 'a signature over the bare nonce, without the signing domain, is 401',\n response,\n 401,\n );\n}\n\n/**\n * A revoked device cannot start a renewal (§6.3): its next `/byok/challenge` is\n * a 401, indistinguishable from an unknown device.\n *\n * Does NOT revoke anything itself — revocation is the host's act, so the caller\n * revokes and then asserts. Calling it before revoking is the red form.\n */\nexport async function assertRevokedDeviceChallengeRejected(\n simulator: DeviceSimulator,\n): Promise<NegativeAssertionResult> {\n const session = simulator.session;\n if (session === undefined) {\n throw new Error('assertRevokedDeviceChallengeRejected needs a paired device.');\n }\n\n const response = await simulator.request({\n method: 'POST',\n path: DEVICE_ROUTES.challenge,\n credential: 'none',\n body: { deviceId: session.deviceId },\n });\n return result(\n 'revoked-device-challenge-rejected',\n 'a revoked device asking for a challenge is 401',\n response,\n 401,\n );\n}\n\nexport interface NegativeSuiteInput {\n /** A pairing code this simulator has already redeemed. */\n readonly redeemedPairingCode: string;\n /** Optional non-default target for the unauthenticated check (e.g. a host admin route). */\n readonly unauthenticatedTarget?: Omit<SimulatorRequest, 'credential'>;\n}\n\n/**\n * All four, in the only order they can run in: revocation is terminal for this\n * device, so the checks that need a live session go first.\n */\nexport async function runNegativeAssertions(\n simulator: DeviceSimulator,\n input: NegativeSuiteInput,\n): Promise<readonly NegativeAssertionResult[]> {\n const results: NegativeAssertionResult[] = [\n input.unauthenticatedTarget === undefined\n ? await assertUnauthenticatedRejected(simulator)\n : await assertUnauthenticatedRejected(simulator, input.unauthenticatedTarget),\n await assertPairingCodeSingleUse(simulator, input.redeemedPairingCode),\n await assertUndomainedSignatureRejected(simulator),\n ];\n await simulator.revoke();\n results.push(await assertRevokedDeviceChallengeRejected(simulator));\n return results;\n}\n\n/** Every failed assertion in `results`, for a caller that wants one error instead of four booleans. */\nexport function failedAssertions(\n results: readonly NegativeAssertionResult[],\n): readonly NegativeAssertionResult[] {\n return results.filter((entry) => !entry.ok);\n}\n"]}
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The four negative assertions a host's pairing smoke test hand-writes.
3
+ *
4
+ * These are plain async functions on purpose. A negative assertion belongs to
5
+ * the protocol, not to a test runner: the same four have to be runnable from
6
+ * vitest, from a CI script, from a deployment gate, and from a Worker. So each
7
+ * returns a structured {@link NegativeAssertionResult} instead of throwing —
8
+ * the caller decides what a failure means, and a vitest consumer writes
9
+ * `expect(result.ok).toBe(true)` and gets the observed status in the diff.
10
+ *
11
+ * Each one names a defense that must hold, and each is written so that pointing
12
+ * it at the wrong input makes it FAIL. An assertion that cannot go red proves
13
+ * nothing, and the ones here are checked that way in
14
+ * `@byok-sdk/conformance`'s `pairing-simulator` suite before they are trusted
15
+ * green: unauthenticated against a public route, single-use against an unused
16
+ * code, undomained against a properly domained signature, revoked against a
17
+ * device that was never revoked.
18
+ */
19
+ import type { DeviceSimulator, SimulatorRequest, SimulatorResponse } from './simulator';
20
+ export interface NegativeAssertionResult {
21
+ /** Stable identifier — safe to use as a test name or a CI check id. */
22
+ readonly name: string;
23
+ readonly ok: boolean;
24
+ /** What the protocol requires. */
25
+ readonly expected: string;
26
+ /** What the deployment actually did. */
27
+ readonly actual: string;
28
+ /** The response that decided it, when one status decided it. */
29
+ readonly response?: SimulatorResponse;
30
+ }
31
+ /**
32
+ * A credentialed route answers 401 when the credential is absent.
33
+ *
34
+ * Defaults to `PUT /byok/presence` — the SDK's own credentialed HTTP surface is
35
+ * device-class, because the host control plane (pairing-code mint, presence
36
+ * read, revoke) is in-process and mounts no route. A deployment that publishes
37
+ * its own admin routes passes one here and gets the same assertion against it.
38
+ */
39
+ export declare function assertUnauthenticatedRejected(simulator: DeviceSimulator, target?: Omit<SimulatorRequest, 'credential'>): Promise<NegativeAssertionResult>;
40
+ /**
41
+ * A pairing code is single-use (§6.1): redeeming an already-redeemed code is a
42
+ * 401, indistinguishable from an unknown or expired one.
43
+ *
44
+ * Pass the code THIS simulator (or another one) already paired with. Pointing
45
+ * it at a fresh code pairs a second device and returns `ok: false` — that is
46
+ * the red form.
47
+ */
48
+ export declare function assertPairingCodeSingleUse(simulator: DeviceSimulator, redeemedPairingCode: string, deviceName?: string): Promise<NegativeAssertionResult>;
49
+ /**
50
+ * A signature over the BARE nonce does not renew a token (§6.2, GAP-004).
51
+ *
52
+ * This is the one place in the package that signs something other than
53
+ * `nonceSigningBytes` — and it signs *less*, never a second domain of its own.
54
+ * The whole point is that the undomained encoding has no accepted form
55
+ * anywhere, so there is no dual mode to accidentally validate.
56
+ *
57
+ * Set `domainSeparated: true` to get the red form: the correctly domained
58
+ * signature renews the token, 200, and the assertion reports failure.
59
+ */
60
+ export declare function assertUndomainedSignatureRejected(simulator: DeviceSimulator, options?: {
61
+ readonly domainSeparated?: boolean;
62
+ }): Promise<NegativeAssertionResult>;
63
+ /**
64
+ * A revoked device cannot start a renewal (§6.3): its next `/byok/challenge` is
65
+ * a 401, indistinguishable from an unknown device.
66
+ *
67
+ * Does NOT revoke anything itself — revocation is the host's act, so the caller
68
+ * revokes and then asserts. Calling it before revoking is the red form.
69
+ */
70
+ export declare function assertRevokedDeviceChallengeRejected(simulator: DeviceSimulator): Promise<NegativeAssertionResult>;
71
+ export interface NegativeSuiteInput {
72
+ /** A pairing code this simulator has already redeemed. */
73
+ readonly redeemedPairingCode: string;
74
+ /** Optional non-default target for the unauthenticated check (e.g. a host admin route). */
75
+ readonly unauthenticatedTarget?: Omit<SimulatorRequest, 'credential'>;
76
+ }
77
+ /**
78
+ * All four, in the only order they can run in: revocation is terminal for this
79
+ * device, so the checks that need a live session go first.
80
+ */
81
+ export declare function runNegativeAssertions(simulator: DeviceSimulator, input: NegativeSuiteInput): Promise<readonly NegativeAssertionResult[]>;
82
+ /** Every failed assertion in `results`, for a caller that wants one error instead of four booleans. */
83
+ export declare function failedAssertions(results: readonly NegativeAssertionResult[]): readonly NegativeAssertionResult[];
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `createDeviceSimulator` — one device, driven over the real device wire.
3
+ *
4
+ * The problem this exists to remove: a host integrating the SDK writes a smoke
5
+ * test, and to write it has to re-implement the device end of docs/protocol.md
6
+ * §6 by hand — generate an Ed25519 key, export the JWK `x`, know that a nonce
7
+ * is signed under a domain prefix, know the exact literal, know the three auth
8
+ * route shapes. Every one of those is upstream knowledge living downstream, so
9
+ * when upstream changes the domain or the pairing schema, every host's smoke
10
+ * test keeps passing against nothing.
11
+ *
12
+ * So this package owns the device end, and owns *nothing else*:
13
+ *
14
+ * - The bytes signed for a challenge come from `@byok-sdk/core`'s
15
+ * `nonceSigningBytes`. There is no domain literal in this package — a
16
+ * simulator carrying its own copy would be the fourth copy of the drift this
17
+ * slice removed.
18
+ * - The request/response DTOs are parsed with `@byok-sdk/protocol`'s schemas,
19
+ * fail-closed. A response this package cannot parse is an error, never a
20
+ * partially-trusted object.
21
+ * - The paths are the hosted surface's real ones (`POST /byok/pair`,
22
+ * `POST /byok/challenge`, `POST /byok/token`, `PUT /byok/presence`).
23
+ *
24
+ * What it deliberately does NOT own is the host control plane. The SDK mounts
25
+ * no admin route: minting a pairing code, listing presence, and revoking a
26
+ * device are in-process calls on `ByokCloud`/`ByokServer`, and a deployment
27
+ * that exposes them over HTTP defines those paths and their credential itself.
28
+ * Inventing `/byok/admin/...` here would be publishing a wire contract that
29
+ * does not exist, so the host surface arrives as a {@link SimulatorHost}
30
+ * adapter the caller supplies — three methods, in-process or HTTP, its choice.
31
+ */
32
+ import type { PresenceHint, PresenceLevel } from '@byok-sdk/core';
33
+ import { type DeviceIdentity } from './identity';
34
+ /** The device-surface routes this simulator drives (docs/protocol.md §6, §12.3). */
35
+ export declare const DEVICE_ROUTES: {
36
+ readonly pair: "/byok/pair";
37
+ readonly challenge: "/byok/challenge";
38
+ readonly token: "/byok/token";
39
+ readonly presence: "/byok/presence";
40
+ };
41
+ export declare const DEFAULT_DEVICE_NAME = "byok-testkit-device";
42
+ /**
43
+ * The host control plane, supplied by the caller.
44
+ *
45
+ * In-process (`cloud.listPresence(tenant)`) and over HTTP (the host's own admin
46
+ * route plus whatever credential guards it) are both one small adapter. What
47
+ * this package will not do is guess either the path or the credential.
48
+ */
49
+ export interface SimulatorHost {
50
+ /** Every live presence hint the host can see for the tenant under test. */
51
+ listPresence(): Promise<readonly PresenceHint[]>;
52
+ /** Revoke a device. After this, its next challenge/token/authed call is a 401 (§6.3). */
53
+ revokeDevice(deviceId: string): Promise<void>;
54
+ }
55
+ export interface DeviceSimulatorOptions {
56
+ /** Origin the device surface is mounted at, e.g. `https://cloud.example.com`. */
57
+ readonly baseUrl: string;
58
+ /** Defaults to `globalThis.fetch`. A composition with no socket passes its own `cloud.fetch`. */
59
+ readonly fetch?: typeof globalThis.fetch;
60
+ /** Host control plane. Absent is legal; `readHostPresence`/`revoke` then throw rather than guess. */
61
+ readonly host?: SimulatorHost;
62
+ /** Device name sent at pairing time. */
63
+ readonly deviceName?: string;
64
+ }
65
+ /** What `POST /byok/pair` handed back — the device's identity on this deployment. */
66
+ export interface DeviceSession {
67
+ readonly deviceId: string;
68
+ readonly accessToken: string;
69
+ }
70
+ export type Credential = 'device' | 'none';
71
+ export interface SimulatorRequest {
72
+ readonly method: string;
73
+ readonly path: string;
74
+ readonly body?: unknown;
75
+ /** `'device'` sends the current access token; `'none'` sends no Authorization header. */
76
+ readonly credential?: Credential;
77
+ }
78
+ export interface SimulatorResponse {
79
+ readonly status: number;
80
+ /** The response body verbatim. */
81
+ readonly text: string;
82
+ /**
83
+ * `text` parsed as JSON, or `undefined` when it is empty or is not JSON at
84
+ * all — a 404 from a router that answers in plain text is a real answer, and
85
+ * the status is what an assertion is asking about. Absent means "no JSON
86
+ * here", never "here is what it probably meant": every caller that needs a
87
+ * DTO runs it through a `@byok-sdk/protocol` schema, which refuses
88
+ * `undefined` outright.
89
+ */
90
+ readonly body: unknown;
91
+ }
92
+ /** A request that did not get the status this call requires. Carries the response, not a summary of it. */
93
+ export declare class DeviceSimulatorError extends Error {
94
+ readonly status: number;
95
+ readonly body: unknown;
96
+ readonly text: string;
97
+ constructor(message: string, response: SimulatorResponse);
98
+ }
99
+ export interface DeviceSimulator {
100
+ readonly identity: DeviceIdentity;
101
+ /** The paired session, or `undefined` before `pair()`. */
102
+ readonly session: DeviceSession | undefined;
103
+ /** §6.1 — redeem a one-time pairing code and register this device's public key. */
104
+ pair(pairingCode: string, deviceName?: string): Promise<DeviceSession>;
105
+ /** §6.2 — ask for a one-time nonce to sign. */
106
+ challenge(deviceId?: string): Promise<string>;
107
+ /** §6.2 — trade a signed nonce for a fresh access token. The signature is supplied, never assumed. */
108
+ token(request: {
109
+ deviceId: string;
110
+ nonce: string;
111
+ signature: string;
112
+ }): Promise<string>;
113
+ /** `challenge()` → sign with the domain-separated bytes → `token()`, adopting the new access token. */
114
+ renewAccessToken(): Promise<string>;
115
+ /** §12.3 — publish a presence hint under the device bearer. */
116
+ publishPresence(level: PresenceLevel, detail?: string): Promise<void>;
117
+ /** Read presence back the way a host does, through the supplied {@link SimulatorHost}. */
118
+ readHostPresence(): Promise<readonly PresenceHint[]>;
119
+ /** §6.3 — revoke this device through the supplied {@link SimulatorHost}. */
120
+ revoke(): Promise<void>;
121
+ /**
122
+ * The raw request primitive every call above is built from. Exposed because a
123
+ * host's own extra assertions should not have to re-derive base URL joining,
124
+ * bearer injection, and JSON parsing to make one off-path request.
125
+ */
126
+ request(input: SimulatorRequest): Promise<SimulatorResponse>;
127
+ }
128
+ export declare function createDeviceSimulator(options: DeviceSimulatorOptions): Promise<DeviceSimulator>;
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@byok-sdk/testkit",
3
+ "version": "0.3.0",
4
+ "description": "BYOK SDK headless device simulator: protocol-level pairing, token renewal, presence, and the built-in negative assertions a host's smoke test would otherwise hand-write",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Ancienttwo/byok-sdk.git",
10
+ "directory": "packages/testkit"
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
+ "@byok-sdk/core": "0.3.0",
40
+ "@byok-sdk/protocol": "0.3.0"
41
+ },
42
+ "scripts": {
43
+ "build": "tsup && tsc -p tsconfig.build.json",
44
+ "dev": "tsup --watch",
45
+ "test": "vitest run",
46
+ "test:watch": "vitest",
47
+ "typecheck": "tsc --noEmit",
48
+ "clean": "rm -rf dist"
49
+ }
50
+ }