@oxy.so/core 1.18.0 → 1.18.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.
@@ -17,6 +17,11 @@ import type { DeviceSecretMintOutcome } from './session/refresh';
17
17
  export type AuthRefreshReason = 'preflight' | 'response-401';
18
18
  export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise<string | null>;
19
19
  export type AccessTokenProvider = () => string | null;
20
+ /**
21
+ * Supplies a bearer for a request made while this client holds NO user session
22
+ * — `null` to send it anonymous. See {@link HttpService.setAnonymousAuthProvider}.
23
+ */
24
+ export type AnonymousAuthProvider = () => Promise<string | null>;
20
25
  /**
21
26
  * A low-level authenticated request whose response body remains unread.
22
27
  *
@@ -162,6 +167,7 @@ export declare class HttpService {
162
167
  private noFresherTokenThan;
163
168
  private authRefreshHandler;
164
169
  private accessTokenProvider;
170
+ private anonymousAuthProvider;
165
171
  private deviceSecretMintInFlight;
166
172
  /**
167
173
  * Bumped by every {@link endSession}. A re-mint captures it before its first
@@ -324,6 +330,13 @@ export declare class HttpService {
324
330
  * Get auth header with automatic token refresh
325
331
  */
326
332
  private getAuthHeader;
333
+ /**
334
+ * The bearer for a request that has no user session behind it, from the
335
+ * {@link AnonymousAuthProvider} if one is installed. Never throws: a provider
336
+ * that fails sends the request anonymous, exactly as it went before any
337
+ * provider existed.
338
+ */
339
+ private getAnonymousAuthHeader;
327
340
  refreshAccessToken(reason: AuthRefreshReason): Promise<string | null>;
328
341
  /**
329
342
  * Report that the refresh/mint endpoint answered 429 Too Many Requests, so the
@@ -442,6 +455,14 @@ export declare class HttpService {
442
455
  hasSessionEnded(): boolean;
443
456
  setAuthRefreshHandler(handler: AuthRefreshHandler | null): void;
444
457
  setAccessTokenProvider(provider: AccessTokenProvider | null): void;
458
+ /**
459
+ * Authenticate requests that carry no user session with the bearer this
460
+ * provider returns — in practice a backend's own service token (see
461
+ * `OxyConfig.serviceIdentity`). A user session always wins: the provider is
462
+ * consulted only when there is no access token, and never for `skipAuth`
463
+ * requests. `null` removes it.
464
+ */
465
+ setAnonymousAuthProvider(provider: AnonymousAuthProvider | null): void;
445
466
  clearTokens(): void;
446
467
  /**
447
468
  * Subscribe to access-token changes on this instance.
@@ -322,6 +322,11 @@ interface ServiceTokenCacheEntry {
322
322
  export declare class ServiceCredentialMismatchError extends Error {
323
323
  constructor();
324
324
  }
325
+ /**
326
+ * How long a failed service-token mint sends session-less requests anonymous
327
+ * before the next attempt, under `serviceIdentity: 'when-anonymous'`.
328
+ */
329
+ export declare const ANONYMOUS_SERVICE_TOKEN_RETRY_MS = 30000;
325
330
  export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T): {
326
331
  new (...args: any[]): {
327
332
  /**
@@ -346,6 +351,34 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
346
351
  _serviceApiKey: string | null;
347
352
  /** @internal Raw apiSecret stored by configureServiceAuth() for use by getServiceToken() */
348
353
  _serviceApiSecret: string | null;
354
+ /**
355
+ * Before this instant, a request without a user session does not try to
356
+ * mint a service token and goes out anonymous. Set after a failed mint; see
357
+ * {@link _serviceTokenForAnonymousRequest}.
358
+ *
359
+ * @internal
360
+ */
361
+ _anonymousServiceTokenRetryAt: number;
362
+ /**
363
+ * The bearer for a request with no user session, under `serviceIdentity:
364
+ * 'when-anonymous'`: this process's service token, or `null` to send the
365
+ * request anonymous as before.
366
+ *
367
+ * `null`, never a throw, whenever there is no token to offer:
368
+ * - no key pair and no attestable workload (a local checkout) — asking
369
+ * costs nothing and there is nothing to ask for;
370
+ * - the mint failed. The request still goes out, anonymous, and so does
371
+ * every request for the next {@link ANONYMOUS_SERVICE_TOKEN_RETRY_MS}, so
372
+ * an Oxy that is refusing attestation (seen for ~2.5 minutes after a
373
+ * deploy on 2026-09-25) is asked once per window rather than once per
374
+ * read, and no read waits on a mint that just failed.
375
+ *
376
+ * The token itself comes from {@link getServiceToken}: cached for its
377
+ * lifetime and single-flight, so concurrent reads share one mint.
378
+ *
379
+ * @internal
380
+ */
381
+ _serviceTokenForAnonymousRequest(): Promise<string | null>;
349
382
  /**
350
383
  * Hash an apiKey into a stable Map cache key. Uses Node's SHA-256 — service
351
384
  * tokens are only ever issued by a Node host (the SDK on web/RN never has
@@ -13,6 +13,27 @@ export interface OxyConfig {
13
13
  * bounce (which uses the RP origin, not this registered client id).
14
14
  */
15
15
  clientId?: string;
16
+ /**
17
+ * How a BACKEND identifies itself on requests that carry no user session.
18
+ *
19
+ * - `'never'` (default): such requests go out anonymous. Right for browsers
20
+ * and apps, which have no service identity to offer.
21
+ * - `'when-anonymous'`: they carry this process's service token instead —
22
+ * from `configureServiceAuth()`'s key pair, or, with none, from workload
23
+ * attestation (ADR 0026). A user session, when there is one, still wins,
24
+ * and a process that can produce no service token (a local checkout) keeps
25
+ * sending the request anonymous.
26
+ *
27
+ * Why a server wants it: oxy-api charges anonymous traffic to the SOURCE
28
+ * ADDRESS (`rl:general`, and a +500 ms `slowDown` per request past 100 in 15
29
+ * minutes), and a fleet leaves through one NAT address, so every anonymous
30
+ * read a backend makes shares — and past the threshold pays for — one budget.
31
+ * A first-party service token is exempt from both and charged to its own
32
+ * application's budget instead. Measured from Mention's task (2026-09-25):
33
+ * `GET /users/:id` 543–575 ms anonymous past the threshold, `POST
34
+ * /users/by-ids` 19–24 ms with the service token.
35
+ */
36
+ serviceIdentity?: 'never' | 'when-anonymous';
16
37
  /**
17
38
  * Enable the per-instance GET response cache. Defaults to `true` (5-minute
18
39
  * TTL). Set to `false` to disable caching entirely for this instance — GET
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.18.0",
3
+ "version": "1.18.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",
@@ -38,6 +38,11 @@ interface JwtPayload {
38
38
  export type AuthRefreshReason = 'preflight' | 'response-401';
39
39
  export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise<string | null>;
40
40
  export type AccessTokenProvider = () => string | null;
41
+ /**
42
+ * Supplies a bearer for a request made while this client holds NO user session
43
+ * — `null` to send it anonymous. See {@link HttpService.setAnonymousAuthProvider}.
44
+ */
45
+ export type AnonymousAuthProvider = () => Promise<string | null>;
41
46
 
42
47
  /**
43
48
  * A low-level authenticated request whose response body remains unread.
@@ -378,6 +383,7 @@ export class HttpService {
378
383
  private noFresherTokenThan: string | null = null;
379
384
  private authRefreshHandler: AuthRefreshHandler | null = null;
380
385
  private accessTokenProvider: AccessTokenProvider | null = null;
386
+ private anonymousAuthProvider: AnonymousAuthProvider | null = null;
381
387
  private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
382
388
  /**
383
389
  * Bumped by every {@link endSession}. A re-mint captures it before its first
@@ -1191,7 +1197,7 @@ export class HttpService {
1191
1197
  private async getAuthHeader(): Promise<string | null> {
1192
1198
  const accessToken = this.syncAccessTokenFromProvider();
1193
1199
  if (!accessToken) {
1194
- return null;
1200
+ return this.getAnonymousAuthHeader();
1195
1201
  }
1196
1202
 
1197
1203
  try {
@@ -1222,6 +1228,23 @@ export class HttpService {
1222
1228
  }
1223
1229
  }
1224
1230
 
1231
+ /**
1232
+ * The bearer for a request that has no user session behind it, from the
1233
+ * {@link AnonymousAuthProvider} if one is installed. Never throws: a provider
1234
+ * that fails sends the request anonymous, exactly as it went before any
1235
+ * provider existed.
1236
+ */
1237
+ private async getAnonymousAuthHeader(): Promise<string | null> {
1238
+ if (!this.anonymousAuthProvider) return null;
1239
+ try {
1240
+ const token = await this.anonymousAuthProvider();
1241
+ return token ? `Bearer ${token}` : null;
1242
+ } catch (error) {
1243
+ this.logger.debug('Anonymous auth provider failed; sending the request anonymous:', error);
1244
+ return null;
1245
+ }
1246
+ }
1247
+
1225
1248
  async refreshAccessToken(reason: AuthRefreshReason): Promise<string | null> {
1226
1249
  if (!this.authRefreshHandler) {
1227
1250
  return null;
@@ -1565,6 +1588,17 @@ export class HttpService {
1565
1588
  this.accessTokenProvider = provider;
1566
1589
  }
1567
1590
 
1591
+ /**
1592
+ * Authenticate requests that carry no user session with the bearer this
1593
+ * provider returns — in practice a backend's own service token (see
1594
+ * `OxyConfig.serviceIdentity`). A user session always wins: the provider is
1595
+ * consulted only when there is no access token, and never for `skipAuth`
1596
+ * requests. `null` removes it.
1597
+ */
1598
+ setAnonymousAuthProvider(provider: AnonymousAuthProvider | null): void {
1599
+ this.anonymousAuthProvider = provider;
1600
+ }
1601
+
1568
1602
  clearTokens(): void {
1569
1603
  this.tokenStore.clearTokens();
1570
1604
  // Drop the response cache on logout. The cache is identity-scoped, so a
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `serviceIdentity: 'when-anonymous'` — a backend's session-less reads carry
3
+ * its service token.
4
+ *
5
+ * oxy-api charges anonymous requests to their source address, and a backend
6
+ * fleet leaves through one NAT address, so past 100 anonymous requests in 15
7
+ * minutes EVERY anonymous read that backend makes pays a +500 ms `slowDown`.
8
+ * Mention measured 543–575 ms for `GET /users/:id` anonymous against ~20 ms
9
+ * with its service token (OxyHQ/Mention#1173). A first-party service token is
10
+ * exempt, so a backend should present one on every read that has no user.
11
+ *
12
+ * What is asserted is the `Authorization` header that reaches `fetch`, because
13
+ * that is the only thing oxy-api's limiter sees.
14
+ */
15
+
16
+ import { OxyServices } from '../OxyServices';
17
+ import { ANONYMOUS_SERVICE_TOKEN_RETRY_MS } from '../mixins/OxyServices.auth';
18
+
19
+ interface FetchCall {
20
+ url: string;
21
+ authorization: string | undefined;
22
+ }
23
+
24
+ function jsonResponse(data: unknown): Response {
25
+ return new Response(JSON.stringify({ data }), {
26
+ status: 200,
27
+ headers: { 'content-type': 'application/json' },
28
+ });
29
+ }
30
+
31
+ function authorizationOf(init: RequestInit | undefined): string | undefined {
32
+ const headers = init?.headers;
33
+ if (!headers) return undefined;
34
+ if (headers instanceof Headers) return headers.get('authorization') ?? undefined;
35
+ const record = headers as Record<string, string>;
36
+ return record.Authorization ?? record.authorization;
37
+ }
38
+
39
+ function createJwt(payload: Record<string, unknown>): string {
40
+ const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
41
+ return `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode(payload)}.signature`;
42
+ }
43
+
44
+ describe("OxyServices serviceIdentity: 'when-anonymous'", () => {
45
+ const originalFetch = globalThis.fetch;
46
+ let calls: FetchCall[];
47
+
48
+ beforeEach(() => {
49
+ calls = [];
50
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
51
+ calls.push({ url: String(input), authorization: authorizationOf(init) });
52
+ return jsonResponse({ id: 'u1', username: 'alice', name: { displayName: 'Alice' } });
53
+ }) as typeof fetch;
54
+ });
55
+
56
+ afterEach(() => {
57
+ globalThis.fetch = originalFetch;
58
+ jest.restoreAllMocks();
59
+ });
60
+
61
+ function backend(): OxyServices {
62
+ return new OxyServices({ baseURL: 'https://api.oxy.test', serviceIdentity: 'when-anonymous' });
63
+ }
64
+
65
+ it('sends a session-less read with the service token', async () => {
66
+ const oxy = backend();
67
+ jest.spyOn(oxy, '_canUseWorkloadIdentity').mockResolvedValue(true);
68
+ const getServiceToken = jest.spyOn(oxy, 'getServiceToken').mockResolvedValue('svc-token');
69
+
70
+ await oxy.getUserById('u1');
71
+ await oxy.getProfileByUsername('alice');
72
+
73
+ expect(calls.map((call) => call.authorization)).toEqual(['Bearer svc-token', 'Bearer svc-token']);
74
+ expect(getServiceToken).toHaveBeenCalledTimes(2);
75
+ });
76
+
77
+ it('uses a configured key pair without asking about attestation', async () => {
78
+ const oxy = backend();
79
+ oxy.configureServiceAuth('oxy_dk_test', 'secret');
80
+ const canAttest = jest.spyOn(oxy, '_canUseWorkloadIdentity');
81
+ jest.spyOn(oxy, 'getServiceToken').mockResolvedValue('keypair-token');
82
+
83
+ await oxy.getUserById('u1');
84
+
85
+ expect(calls[0].authorization).toBe('Bearer keypair-token');
86
+ expect(canAttest).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it('lets a user session win over the service token', async () => {
90
+ const oxy = backend();
91
+ const getServiceToken = jest.spyOn(oxy, 'getServiceToken').mockResolvedValue('svc-token');
92
+ const userToken = createJwt({ userId: 'viewer', exp: Math.floor(Date.now() / 1000) + 3600 });
93
+ oxy.setTokens(userToken);
94
+
95
+ await oxy.getUserById('u1');
96
+
97
+ expect(calls[0].authorization).toBe(`Bearer ${userToken}`);
98
+ expect(getServiceToken).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it('stays anonymous where no service token can exist (a local checkout)', async () => {
102
+ const oxy = backend();
103
+ jest.spyOn(oxy, '_canUseWorkloadIdentity').mockResolvedValue(false);
104
+ const getServiceToken = jest.spyOn(oxy, 'getServiceToken');
105
+
106
+ await oxy.getUserById('u1');
107
+
108
+ expect(calls[0].authorization).toBeUndefined();
109
+ expect(getServiceToken).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it('sends the read anonymous when the mint fails, and does not retry the mint for a window', async () => {
113
+ const oxy = backend();
114
+ jest.spyOn(oxy, '_canUseWorkloadIdentity').mockResolvedValue(true);
115
+ const getServiceToken = jest
116
+ .spyOn(oxy, 'getServiceToken')
117
+ .mockRejectedValue(new Error('Oxy refused the workload attestation (401)'));
118
+ const now = jest.spyOn(Date, 'now');
119
+ const start = 1_800_000_000_000;
120
+ now.mockReturnValue(start);
121
+
122
+ // Different ids: each is a real request, not a cache hit or a deduplicated one.
123
+ await expect(oxy.getUserById('u1')).resolves.toBeDefined();
124
+ await expect(oxy.getUserById('u2')).resolves.toBeDefined();
125
+
126
+ expect(calls.map((call) => call.authorization)).toEqual([undefined, undefined]);
127
+ expect(getServiceToken).toHaveBeenCalledTimes(1);
128
+
129
+ // After the window the mint is tried again.
130
+ getServiceToken.mockResolvedValue('svc-token');
131
+ now.mockReturnValue(start + ANONYMOUS_SERVICE_TOKEN_RETRY_MS + 1);
132
+ await oxy.getUserById('u3');
133
+
134
+ expect(calls[2].authorization).toBe('Bearer svc-token');
135
+ expect(getServiceToken).toHaveBeenCalledTimes(2);
136
+ });
137
+
138
+ it('never attaches a bearer to a skipAuth request', async () => {
139
+ const oxy = backend();
140
+ jest.spyOn(oxy, '_canUseWorkloadIdentity').mockResolvedValue(true);
141
+ const getServiceToken = jest.spyOn(oxy, 'getServiceToken').mockResolvedValue('svc-token');
142
+
143
+ await oxy.makeRequest('GET', '/auth/lookup/alice', undefined, { skipAuth: true, cache: false });
144
+
145
+ expect(calls[0].authorization).toBeUndefined();
146
+ expect(getServiceToken).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('is off by default: a client without the option stays anonymous', async () => {
150
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.test' });
151
+ jest.spyOn(oxy, '_canUseWorkloadIdentity').mockResolvedValue(true);
152
+ const getServiceToken = jest.spyOn(oxy, 'getServiceToken').mockResolvedValue('svc-token');
153
+
154
+ await oxy.getUserById('u1');
155
+
156
+ expect(calls[0].authorization).toBeUndefined();
157
+ expect(getServiceToken).not.toHaveBeenCalled();
158
+ });
159
+ });
@@ -471,6 +471,12 @@ export class ServiceCredentialMismatchError extends Error {
471
471
  }
472
472
  }
473
473
 
474
+ /**
475
+ * How long a failed service-token mint sends session-less requests anonymous
476
+ * before the next attempt, under `serviceIdentity: 'when-anonymous'`.
477
+ */
478
+ export const ANONYMOUS_SERVICE_TOKEN_RETRY_MS = 30_000;
479
+
474
480
  export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T) {
475
481
  return class extends Base {
476
482
  /**
@@ -497,8 +503,57 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
497
503
  /** @internal Raw apiSecret stored by configureServiceAuth() for use by getServiceToken() */
498
504
  _serviceApiSecret: string | null = null;
499
505
 
506
+ /**
507
+ * Before this instant, a request without a user session does not try to
508
+ * mint a service token and goes out anonymous. Set after a failed mint; see
509
+ * {@link _serviceTokenForAnonymousRequest}.
510
+ *
511
+ * @internal
512
+ */
513
+ _anonymousServiceTokenRetryAt = 0;
514
+
500
515
  constructor(...args: any[]) {
501
516
  super(...(args as [any]));
517
+ if (this.config.serviceIdentity === 'when-anonymous') {
518
+ this.httpService.setAnonymousAuthProvider(() => this._serviceTokenForAnonymousRequest());
519
+ }
520
+ }
521
+
522
+ /**
523
+ * The bearer for a request with no user session, under `serviceIdentity:
524
+ * 'when-anonymous'`: this process's service token, or `null` to send the
525
+ * request anonymous as before.
526
+ *
527
+ * `null`, never a throw, whenever there is no token to offer:
528
+ * - no key pair and no attestable workload (a local checkout) — asking
529
+ * costs nothing and there is nothing to ask for;
530
+ * - the mint failed. The request still goes out, anonymous, and so does
531
+ * every request for the next {@link ANONYMOUS_SERVICE_TOKEN_RETRY_MS}, so
532
+ * an Oxy that is refusing attestation (seen for ~2.5 minutes after a
533
+ * deploy on 2026-09-25) is asked once per window rather than once per
534
+ * read, and no read waits on a mint that just failed.
535
+ *
536
+ * The token itself comes from {@link getServiceToken}: cached for its
537
+ * lifetime and single-flight, so concurrent reads share one mint.
538
+ *
539
+ * @internal
540
+ */
541
+ async _serviceTokenForAnonymousRequest(): Promise<string | null> {
542
+ if (Date.now() < this._anonymousServiceTokenRetryAt) return null;
543
+ const hasKeyPair = Boolean(this._serviceApiKey && this._serviceApiSecret);
544
+ if (!hasKeyPair && !(await this._canUseWorkloadIdentity())) return null;
545
+ try {
546
+ return await this.getServiceToken();
547
+ } catch (error) {
548
+ this._anonymousServiceTokenRetryAt = Date.now() + ANONYMOUS_SERVICE_TOKEN_RETRY_MS;
549
+ logger.warn('[oxy.auth] No service token for a request without a session; sending it anonymous', {
550
+ component: 'auth',
551
+ method: '_serviceTokenForAnonymousRequest',
552
+ retryInMs: ANONYMOUS_SERVICE_TOKEN_RETRY_MS,
553
+ error: error instanceof Error ? error.message : String(error),
554
+ });
555
+ return null;
556
+ }
502
557
  }
503
558
 
504
559
  /**
@@ -22,6 +22,27 @@ export interface OxyConfig {
22
22
  * bounce (which uses the RP origin, not this registered client id).
23
23
  */
24
24
  clientId?: string;
25
+ /**
26
+ * How a BACKEND identifies itself on requests that carry no user session.
27
+ *
28
+ * - `'never'` (default): such requests go out anonymous. Right for browsers
29
+ * and apps, which have no service identity to offer.
30
+ * - `'when-anonymous'`: they carry this process's service token instead —
31
+ * from `configureServiceAuth()`'s key pair, or, with none, from workload
32
+ * attestation (ADR 0026). A user session, when there is one, still wins,
33
+ * and a process that can produce no service token (a local checkout) keeps
34
+ * sending the request anonymous.
35
+ *
36
+ * Why a server wants it: oxy-api charges anonymous traffic to the SOURCE
37
+ * ADDRESS (`rl:general`, and a +500 ms `slowDown` per request past 100 in 15
38
+ * minutes), and a fleet leaves through one NAT address, so every anonymous
39
+ * read a backend makes shares — and past the threshold pays for — one budget.
40
+ * A first-party service token is exempt from both and charged to its own
41
+ * application's budget instead. Measured from Mention's task (2026-09-25):
42
+ * `GET /users/:id` 543–575 ms anonymous past the threshold, `POST
43
+ * /users/by-ids` 19–24 ms with the service token.
44
+ */
45
+ serviceIdentity?: 'never' | 'when-anonymous';
25
46
  // Performance & caching options
26
47
  /**
27
48
  * Enable the per-instance GET response cache. Defaults to `true` (5-minute