@oxyhq/core 15.0.0 → 15.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -104,6 +104,24 @@ export declare function OxyServicesDeviceBootMixin<T extends typeof OxyServicesB
104
104
  * method would be dead code plus a second implementation of the failure
105
105
  * rules. The asymmetry is the design.
106
106
  *
107
+ * **Call this from NATIVE only.** There is no background worker on web to
108
+ * consume the credential, and handing a browser origin a long-lived
109
+ * non-rotating secret to persist is strictly weaker than the rotating device
110
+ * secret it already holds. The 404 degrade below is also native-shaped: a
111
+ * browser attaches `Origin`, which a server predating this route answers
112
+ * `403 BAD_ORIGIN` from its router-wide same-site guard rather than 404, so
113
+ * the quiet degrade would not fire there. A native client sends no `Origin`
114
+ * and gets the 404. Gate the caller by platform; do not widen the degrade to
115
+ * 403, which would also swallow a genuine origin misconfiguration.
116
+ *
117
+ * That paragraph is LOAD-BEARING, not belt-and-braces: the route sits above
118
+ * oxy-api's router-wide origin guard (deliberately, so a native client with
119
+ * no `Origin` is not rejected), so as of this writing NOTHING server-side
120
+ * refuses a browser caller that presents a valid bearer. Until a server-side
121
+ * check lands, caller discipline is the only control — which is also why a
122
+ * doc note cannot be the whole answer to browser XSS minting a long-lived
123
+ * credential with the victim's bearer.
124
+ *
107
125
  * @returns the provisioned credential, or `null` when the endpoint is absent
108
126
  * (404). The API deploy leads the SDK release, so a client on a newer SDK
109
127
  * than the server degrades to "no background session" quietly instead of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "15.0.0",
3
+ "version": "15.0.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -157,6 +157,24 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
157
157
  * method would be dead code plus a second implementation of the failure
158
158
  * rules. The asymmetry is the design.
159
159
  *
160
+ * **Call this from NATIVE only.** There is no background worker on web to
161
+ * consume the credential, and handing a browser origin a long-lived
162
+ * non-rotating secret to persist is strictly weaker than the rotating device
163
+ * secret it already holds. The 404 degrade below is also native-shaped: a
164
+ * browser attaches `Origin`, which a server predating this route answers
165
+ * `403 BAD_ORIGIN` from its router-wide same-site guard rather than 404, so
166
+ * the quiet degrade would not fire there. A native client sends no `Origin`
167
+ * and gets the 404. Gate the caller by platform; do not widen the degrade to
168
+ * 403, which would also swallow a genuine origin misconfiguration.
169
+ *
170
+ * That paragraph is LOAD-BEARING, not belt-and-braces: the route sits above
171
+ * oxy-api's router-wide origin guard (deliberately, so a native client with
172
+ * no `Origin` is not rejected), so as of this writing NOTHING server-side
173
+ * refuses a browser caller that presents a valid bearer. Until a server-side
174
+ * check lands, caller discipline is the only control — which is also why a
175
+ * doc note cannot be the whole answer to browser XSS minting a long-lived
176
+ * credential with the victim's bearer.
177
+ *
160
178
  * @returns the provisioned credential, or `null` when the endpoint is absent
161
179
  * (404). The API deploy leads the SDK release, so a client on a newer SDK
162
180
  * than the server degrades to "no background session" quietly instead of
@@ -0,0 +1,135 @@
1
+ import type { DeviceBackgroundCredentialResponse } from '@oxyhq/contracts';
2
+ import { OxyServices } from '../../OxyServices';
3
+
4
+ /**
5
+ * Real-stack integration test for `provisionBackgroundCredential`: a genuine
6
+ * `HttpService` (via `OxyServices`) with `global.fetch` stubbed to return the
7
+ * EXACT wire bodies oxy-api sends, rather than a `makeRequest` spy.
8
+ *
9
+ * The unit suite (`OxyServices.deviceBoot.test.ts`) stubs `makeRequest`, so by
10
+ * construction it cannot see either of the two things this file pins:
11
+ *
12
+ * 1. **The `{ data }` envelope.** The route answers
13
+ * `{ data: { deviceId, secret, accountId, expiresAt } }`, and
14
+ * `HttpService.unwrapResponse` strips that outer envelope — so the mixin
15
+ * must validate the FLAT credential and must not read `.data` a second
16
+ * time. A spy returning the flat shape asserts that assumption instead of
17
+ * testing it; the same blind spot produced a P0 in `SessionClient` (see
18
+ * `SessionClient.httpIntegration.test.ts`).
19
+ * 2. **The 404 degrade against the REAL error object.** The `404 → null` path
20
+ * is what keeps a client on a newer SDK than the server from breaking, and
21
+ * it keys on the status surviving whatever `HttpService` throws. A
22
+ * hand-built `Object.assign(new Error(), { status: 404 })` proves only that
23
+ * the branch reads the shape the test itself invented.
24
+ *
25
+ * The stub is URL-aware because a state-changing request through the real stack
26
+ * fetches `GET /csrf-token` FIRST. A blanket stub answers that call too, which
27
+ * both shifts the request under test out of `calls[0]` and (on a non-200 stub)
28
+ * makes the CSRF fetch burn its own retries — so a naive call-count assertion
29
+ * measures CSRF attempts rather than the route.
30
+ */
31
+ const ROUTE = '/session/device/background-credential';
32
+
33
+ const CREDENTIAL: DeviceBackgroundCredentialResponse = {
34
+ deviceId: 'device-real',
35
+ secret: 'bg-secret-from-the-wire',
36
+ accountId: 'acct-1',
37
+ expiresAt: '2030-01-01T00:00:00.000Z',
38
+ };
39
+
40
+ /** The route's success body: the credential under this API's `data` envelope. */
41
+ const ROUTE_BODY = { data: CREDENTIAL };
42
+
43
+ /** oxy-api's 404 body for an unmatched path (server.ts's terminal handler). */
44
+ const NOT_FOUND_BODY = { error: 'NOT_FOUND', message: 'Resource not found' };
45
+
46
+ const jsonResponse = (body: unknown, status: number) =>
47
+ new Response(JSON.stringify(body), {
48
+ status,
49
+ headers: { 'content-type': 'application/json' },
50
+ });
51
+
52
+ /**
53
+ * A syntactically real, far-future access token. It must be a decodable JWT:
54
+ * `HttpService.getAuthHeader` runs `jwtDecode` and sends NO bearer at all when
55
+ * that throws, so an opaque placeholder would silently turn this into an
56
+ * anonymous request and make the bearer assertion below untestable.
57
+ */
58
+ const ACCESS_TOKEN = (() => {
59
+ const segment = (payload: object) => Buffer.from(JSON.stringify(payload)).toString('base64url');
60
+ return [
61
+ segment({ alg: 'none', typ: 'JWT' }),
62
+ segment({ sub: 'acct-1', exp: 4_102_444_800 }), // 2100-01-01
63
+ 'signature-not-verified-client-side',
64
+ ].join('.');
65
+ })();
66
+
67
+ describe('provisionBackgroundCredential over a real HttpService', () => {
68
+ const originalFetch = global.fetch;
69
+
70
+ /** Answers the CSRF preflight properly; answers the route under test with `body`/`status`. */
71
+ const stubFetch = (body: unknown, status: number) => {
72
+ const fetchMock = jest.fn(async (input: unknown) => {
73
+ if (String(input).includes('/csrf-token')) {
74
+ return jsonResponse({ csrfToken: 'csrf-test-token' }, 200);
75
+ }
76
+ return jsonResponse(body, status);
77
+ });
78
+ global.fetch = fetchMock as unknown as typeof fetch;
79
+ return fetchMock;
80
+ };
81
+
82
+ /** Every stubbed call whose URL is the route under test (i.e. not the CSRF preflight). */
83
+ const routeCalls = (fetchMock: jest.Mock) =>
84
+ fetchMock.mock.calls.filter(([input]) => String(input).includes(ROUTE));
85
+
86
+ const client = () => {
87
+ const oxy = new OxyServices({ baseURL: 'http://api.test.invalid' });
88
+ // Bearer required: the server derives both the deviceId and the account
89
+ // from it, and this call (unlike the device-secret mint) does not skipAuth.
90
+ oxy.setTokens(ACCESS_TOKEN);
91
+ return oxy;
92
+ };
93
+
94
+ afterEach(() => {
95
+ global.fetch = originalFetch;
96
+ });
97
+
98
+ it('unwraps the { data } envelope and returns the flat credential', async () => {
99
+ stubFetch(ROUTE_BODY, 200);
100
+
101
+ const result = await client().provisionBackgroundCredential();
102
+
103
+ // Would be `{ data: {...} }` if the envelope were not unwrapped, and would
104
+ // throw (contract validation failure) if `.data` were read twice.
105
+ expect(result).toEqual(CREDENTIAL);
106
+ expect(result?.secret).toBe('bg-secret-from-the-wire');
107
+ });
108
+
109
+ it('sends a POST to the route with NO body and a bearer', async () => {
110
+ const fetchMock = stubFetch(ROUTE_BODY, 200);
111
+
112
+ await client().provisionBackgroundCredential();
113
+
114
+ const calls = routeCalls(fetchMock);
115
+ expect(calls).toHaveLength(1);
116
+ const [, init] = calls[0] as unknown as [string, RequestInit];
117
+ expect(init.method).toBe('POST');
118
+ // No body at all — not `'undefined'`, not `'{}'`. The server derives the
119
+ // deviceId and the account from the bearer; anything sent here would be
120
+ // ignored at best and mass-assignment surface at worst.
121
+ expect(init.body ?? null).toBeNull();
122
+ expect(new Headers(init.headers).get('authorization')).toBe(`Bearer ${ACCESS_TOKEN}`);
123
+ });
124
+
125
+ it('returns null on the real 404 response, without retrying the route', async () => {
126
+ const fetchMock = stubFetch(NOT_FOUND_BODY, 404);
127
+
128
+ await expect(client().provisionBackgroundCredential()).resolves.toBeNull();
129
+
130
+ // 4xx is not retried (`retryAsync`'s default shouldRetry), so an absent
131
+ // endpoint costs exactly one request to the route — the degrade must not
132
+ // burn a backoff loop on every provision attempt.
133
+ expect(routeCalls(fetchMock)).toHaveLength(1);
134
+ });
135
+ });