@oxy.so/core 4.9.0 → 4.9.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.
Files changed (40) hide show
  1. package/dist/cjs/HttpService.js +34 -15
  2. package/dist/cjs/api/accounts.js +2 -33
  3. package/dist/cjs/api/billing.js +0 -9
  4. package/dist/cjs/i18n/locales/en-US.js +0 -7
  5. package/dist/cjs/i18n/locales/es-ES.js +0 -7
  6. package/dist/cjs/server/OxyServer.js +43 -16
  7. package/dist/cjs/server/index.js +3 -2
  8. package/dist/cjs/server/workloadIdentity.js +6 -1
  9. package/dist/cjs/utils/corsPreflight.js +48 -0
  10. package/dist/esm/HttpService.js +34 -15
  11. package/dist/esm/api/accounts.js +1 -31
  12. package/dist/esm/api/billing.js +1 -10
  13. package/dist/esm/i18n/locales/en-US.js +0 -7
  14. package/dist/esm/i18n/locales/es-ES.js +0 -7
  15. package/dist/esm/server/OxyServer.js +42 -15
  16. package/dist/esm/server/index.js +1 -1
  17. package/dist/esm/server/workloadIdentity.js +6 -1
  18. package/dist/esm/utils/corsPreflight.js +45 -0
  19. package/dist/types/api/accounts.d.ts +1 -38
  20. package/dist/types/api/billing.d.ts +1 -4
  21. package/dist/types/i18n/locales/en-US.d.ts +0 -7
  22. package/dist/types/i18n/locales/es-ES.d.ts +0 -7
  23. package/dist/types/index.d.ts +0 -1
  24. package/dist/types/models/interfaces.d.ts +0 -2
  25. package/dist/types/server/OxyServer.d.ts +17 -1
  26. package/dist/types/server/index.d.ts +2 -2
  27. package/dist/types/server/workloadIdentity.d.ts +5 -0
  28. package/dist/types/utils/corsPreflight.d.ts +19 -0
  29. package/package.json +2 -2
  30. package/src/HttpService.ts +31 -12
  31. package/src/api/accounts.ts +1 -82
  32. package/src/api/billing.ts +0 -43
  33. package/src/i18n/locales/en-US.ts +0 -8
  34. package/src/i18n/locales/es-ES.ts +0 -8
  35. package/src/index.ts +0 -1
  36. package/src/models/interfaces.ts +0 -2
  37. package/src/server/OxyServer.ts +56 -15
  38. package/src/server/index.ts +2 -1
  39. package/src/server/workloadIdentity.ts +11 -1
  40. package/src/utils/corsPreflight.ts +49 -0
@@ -1465,14 +1465,6 @@ const dict = {
1465
1465
  },
1466
1466
  },
1467
1467
  preferences: {
1468
- displayCurrency: {
1469
- label: 'Display currency',
1470
- saveFailed: 'Could not save display currency. Please try again.',
1471
- unavailable: 'Equivalent in {{currency}} unavailable',
1472
- approximate: 'Approx. {{amount}}',
1473
- estimate: 'Display estimate. The final payable amount is quoted in FAIR at checkout.',
1474
- },
1475
-
1476
1468
  title: 'Preferences',
1477
1469
  subtitle: 'Theme, motion, and regional settings',
1478
1470
  sections: {
@@ -1470,14 +1470,6 @@ const dict = {
1470
1470
  },
1471
1471
  },
1472
1472
  preferences: {
1473
- displayCurrency: {
1474
- label: 'Moneda mostrada',
1475
- saveFailed: 'No se ha podido guardar la moneda. Inténtalo de nuevo.',
1476
- unavailable: 'Equivalencia en {{currency}} no disponible',
1477
- approximate: 'Aprox. {{amount}}',
1478
- estimate: 'Importe orientativo. El importe final se cotiza en FAIR al pagar.',
1479
- },
1480
-
1481
1473
  title: 'Preferencias',
1482
1474
  subtitle: 'Tema, movimiento y configuración regional',
1483
1475
  sections: {
package/src/index.ts CHANGED
@@ -616,4 +616,3 @@ export type {
616
616
  } from '@oxy.so/contracts';
617
617
 
618
618
  export type { ProfileAccessInput, ProfileAccessDecision } from '@oxy.so/contracts';
619
- export type { AccountInvitation, AccountInvitationPreview } from './api/accounts';
@@ -264,8 +264,6 @@ export interface NotificationPreferences {
264
264
  * Persisted on the User document.
265
265
  */
266
266
  export interface UserPreferences {
267
- /** Preferred display units only; never changes a payment's denomination. */
268
- displayCurrency?: 'USD' | 'EUR' | 'FAIR';
269
267
  /** BCP-47 language tag, e.g. "en-US", "es-ES". Empty string = follow device. */
270
268
  language?: string;
271
269
  /** Theme mode preference. */
@@ -162,6 +162,22 @@ interface ServiceTokenCacheEntry {
162
162
  }
163
163
 
164
164
  const WORKLOAD_CACHE_KEY = 'workload-identity';
165
+
166
+ /**
167
+ * The resource server a service token is minted for (Oxy's closed set,
168
+ * `packages/api/src/config/serviceTokenAudiences.ts`). A token is only ever
169
+ * accepted by its own audience: Oxy's API refuses a `kaana.ai` token and
170
+ * https://kaana.ai/v1 refuses an `oxy-api` one (Kaana ADR 0002 decision 6).
171
+ */
172
+ export type ServiceTokenAudience = 'oxy-api' | 'kaana.ai' | 'oxy-api:kaana-control';
173
+
174
+ /** What `serviceToken()` mints, and what Oxy assumes when no audience is sent. */
175
+ export const DEFAULT_SERVICE_TOKEN_AUDIENCE: ServiceTokenAudience = 'oxy-api';
176
+
177
+ /** Tokens of different audiences never share a cache entry; the default keeps its old key. */
178
+ function audienceCacheKey(audience: ServiceTokenAudience, key: string): string {
179
+ return audience === DEFAULT_SERVICE_TOKEN_AUDIENCE ? key : `${audience}\u0000${key}`;
180
+ }
165
181
  /** A token is reused until 60s before expiry (clock drift). */
166
182
  const TOKEN_REUSE_MARGIN_MS = 60_000;
167
183
 
@@ -298,6 +314,20 @@ export class OxyServer extends OxyServices {
298
314
  * {@link ServiceCredentialMismatchError} rather than return the cached token.
299
315
  */
300
316
  async serviceToken(apiKey?: string, apiSecret?: string): Promise<string> {
317
+ return this.serviceTokenFor(DEFAULT_SERVICE_TOKEN_AUDIENCE, apiKey, apiSecret);
318
+ }
319
+
320
+ /**
321
+ * A service token for `audience` — e.g. `kaana.ai` for https://kaana.ai/v1.
322
+ * Minted, cached and refreshed exactly as {@link serviceToken} (which is this
323
+ * with `oxy-api`), from the same credential or attested workload identity;
324
+ * each audience has its own cache entry, so one never answers for another.
325
+ */
326
+ async serviceTokenFor(
327
+ audience: ServiceTokenAudience,
328
+ apiKey?: string,
329
+ apiSecret?: string,
330
+ ): Promise<string> {
301
331
  const key = apiKey || this.serviceApiKey;
302
332
  const secret = apiSecret || this.serviceApiSecret;
303
333
 
@@ -305,7 +335,7 @@ export class OxyServer extends OxyServices {
305
335
  // No credential — prove what this process IS instead. The fallback, not the
306
336
  // preference: a deployment that still has a credential keeps using it.
307
337
  if (canAttestWorkloadIdentity()) {
308
- return this.workloadServiceToken();
338
+ return this.workloadServiceToken(audience);
309
339
  }
310
340
  throw new Error(
311
341
  'Service credentials not provided. Pass serviceAuth, call configureServiceAuth(), or pass apiKey and apiSecret.',
@@ -314,7 +344,7 @@ export class OxyServer extends OxyServices {
314
344
 
315
345
  // The apiKey is the credential's PUBLIC id (`oxy_dk_…`, the OAuth
316
346
  // client_id), so it keys the in-memory cache as is; the secret never does.
317
- const cacheKey = key;
347
+ const cacheKey = audienceCacheKey(audience, key);
318
348
  const now = Date.now();
319
349
  const providedSecretBuf = Buffer.from(secret, 'utf8');
320
350
 
@@ -351,7 +381,7 @@ export class OxyServer extends OxyServices {
351
381
  this.serviceTokens.set(cacheKey, entry);
352
382
  }
353
383
 
354
- const pending = this.mintServiceToken(key, secret, cacheKey, providedSecretBuf);
384
+ const pending = this.mintServiceToken(key, secret, cacheKey, providedSecretBuf, audience);
355
385
  entry.pending = pending;
356
386
  try {
357
387
  return await pending;
@@ -382,7 +412,10 @@ export class OxyServer extends OxyServices {
382
412
  this.serviceTokens.clear();
383
413
  return;
384
414
  }
385
- this.serviceTokens.delete(targetKey);
415
+ // Every audience minted from that credential goes with it.
416
+ for (const [cacheKey, entry] of this.serviceTokens) {
417
+ if (entry.apiKey === targetKey) this.serviceTokens.delete(cacheKey);
418
+ }
386
419
  }
387
420
 
388
421
  /**
@@ -580,11 +613,16 @@ export class OxyServer extends OxyServices {
580
613
  secret: string,
581
614
  cacheKey: string,
582
615
  secretBuf: Buffer,
616
+ audience: ServiceTokenAudience,
583
617
  ): Promise<string> {
584
618
  const response = await this.request<ServiceTokenResponse>(
585
619
  'POST',
586
620
  '/auth/service-token',
587
- { apiKey: key, apiSecret: secret },
621
+ {
622
+ apiKey: key,
623
+ apiSecret: secret,
624
+ ...(audience === DEFAULT_SERVICE_TOKEN_AUDIENCE ? {} : { audience }),
625
+ },
588
626
  { cache: false, retry: false, skipAuth: true },
589
627
  );
590
628
  const expiresAt = Date.now() + response.expiresIn * 1000;
@@ -605,9 +643,10 @@ export class OxyServer extends OxyServices {
605
643
  return response.token;
606
644
  }
607
645
 
608
- /** A token obtained by attestation, cached like a credential's (one identity per process). */
609
- private async workloadServiceToken(): Promise<string> {
610
- const entry = this.serviceTokens.get(WORKLOAD_CACHE_KEY);
646
+ /** A token obtained by attestation, cached like a credential's (one identity per process, one entry per audience). */
647
+ private async workloadServiceToken(audience: ServiceTokenAudience): Promise<string> {
648
+ const cacheKey = audienceCacheKey(audience, WORKLOAD_CACHE_KEY);
649
+ const entry = this.serviceTokens.get(cacheKey);
611
650
  if (
612
651
  entry?.token &&
613
652
  reusableServiceToken(entry.token) &&
@@ -623,11 +662,14 @@ export class OxyServer extends OxyServices {
623
662
  pending: null,
624
663
  apiKey: WORKLOAD_CACHE_KEY,
625
664
  };
626
- this.serviceTokens.set(WORKLOAD_CACHE_KEY, seeded);
665
+ this.serviceTokens.set(cacheKey, seeded);
627
666
 
628
667
  const pending = (async () => {
629
- const granted = await requestWorkloadServiceToken({ baseUrl: this.baseURL });
630
- const current = this.serviceTokens.get(WORKLOAD_CACHE_KEY);
668
+ const granted = await requestWorkloadServiceToken({
669
+ baseUrl: this.baseURL,
670
+ ...(audience === DEFAULT_SERVICE_TOKEN_AUDIENCE ? {} : { audience }),
671
+ });
672
+ const current = this.serviceTokens.get(cacheKey);
631
673
  if (current) {
632
674
  current.token = granted.token;
633
675
  current.expiresAt = Date.now() + granted.expiresIn * 1000;
@@ -639,12 +681,11 @@ export class OxyServer extends OxyServices {
639
681
  try {
640
682
  return await pending;
641
683
  } catch (error) {
642
- const failed = this.serviceTokens.get(WORKLOAD_CACHE_KEY);
643
- if (failed?.pending === pending && !failed.token)
644
- this.serviceTokens.delete(WORKLOAD_CACHE_KEY);
684
+ const failed = this.serviceTokens.get(cacheKey);
685
+ if (failed?.pending === pending && !failed.token) this.serviceTokens.delete(cacheKey);
645
686
  throw error;
646
687
  } finally {
647
- const settled = this.serviceTokens.get(WORKLOAD_CACHE_KEY);
688
+ const settled = this.serviceTokens.get(cacheKey);
648
689
  if (settled?.pending === pending) settled.pending = null;
649
690
  }
650
691
  }
@@ -21,8 +21,9 @@ export {
21
21
  OxyServer,
22
22
  ServiceCredentialMismatchError,
23
23
  ANONYMOUS_SERVICE_TOKEN_RETRY_MS,
24
+ DEFAULT_SERVICE_TOKEN_AUDIENCE,
24
25
  } from './OxyServer';
25
- export type { OxyServerConfig, ServiceTokenResponse } from './OxyServer';
26
+ export type { OxyServerConfig, ServiceTokenAudience, ServiceTokenResponse } from './OxyServer';
26
27
  export {
27
28
  ServerAppsApi,
28
29
  ServerAgencyApi,
@@ -41,6 +41,11 @@ const STS_BODY = 'Action=GetCallerIdentity&Version=2011-06-15';
41
41
  export interface WorkloadServiceTokenOptions {
42
42
  /** Oxy's base URL, e.g. `https://api.oxy.so`. */
43
43
  baseUrl: string;
44
+ /**
45
+ * The resource server the token is for (e.g. `kaana.ai`). Omitted, Oxy mints
46
+ * for `oxy-api`, as it always has.
47
+ */
48
+ audience?: string;
44
49
  /** Injected in tests; defaults to the global. */
45
50
  fetch?: typeof fetch;
46
51
  }
@@ -233,7 +238,12 @@ export async function requestWorkloadServiceToken(
233
238
  const tokenResponse = await fetchImpl(`${base}/auth/service-token/workload`, {
234
239
  method: 'POST',
235
240
  headers: { 'content-type': 'application/json' },
236
- body: JSON.stringify({ provider: 'aws-iam', nonce: challenge.nonce, attestation }),
241
+ body: JSON.stringify({
242
+ provider: 'aws-iam',
243
+ nonce: challenge.nonce,
244
+ attestation,
245
+ ...(options.audience === undefined ? {} : { audience: options.audience }),
246
+ }),
237
247
  });
238
248
  if (!tokenResponse.ok) {
239
249
  // The body carries a bounded `reason`; the token never travels on a failure,
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Whether a browser has to send a CORS preflight (`OPTIONS`) before this
3
+ * cross-origin request goes out, per the Fetch standard's CORS-safelisted
4
+ * methods and request headers.
5
+ *
6
+ * The SDK uses it for one invariant: its anonymous telemetry headers
7
+ * (`X-Oxy-Activity-Id`, `X-Oxy-Edge-Region`) ride only on requests that are
8
+ * preflighted anyway, so they never turn a request that would go straight out
9
+ * into one that first waits a round trip for an `OPTIONS`. A browser caches a
10
+ * preflight per URL, so without this every distinct anonymous read (a post's
11
+ * replies, a profile, a public app record) paid a round trip of its own.
12
+ *
13
+ * Deliberately conservative: a header it does not recognise as safelisted
14
+ * counts as needing a preflight, which only means the telemetry headers ride
15
+ * along exactly as they always did. Value-level rules (for example `Accept`'s
16
+ * 128-byte limit) are not modelled; a misjudgement in that direction drops two
17
+ * advisory headers from one request and nothing else.
18
+ */
19
+
20
+ const SAFELISTED_METHODS: ReadonlySet<string> = new Set(['GET', 'HEAD', 'POST']);
21
+
22
+ const SAFELISTED_HEADERS: ReadonlySet<string> = new Set([
23
+ 'accept',
24
+ 'accept-language',
25
+ 'content-language',
26
+ 'content-type',
27
+ ]);
28
+
29
+ const SAFELISTED_CONTENT_TYPES: ReadonlySet<string> = new Set([
30
+ 'application/x-www-form-urlencoded',
31
+ 'multipart/form-data',
32
+ 'text/plain',
33
+ ]);
34
+
35
+ export function requiresCorsPreflight(
36
+ method: string,
37
+ headers: Iterable<readonly [string, string]>,
38
+ ): boolean {
39
+ if (!SAFELISTED_METHODS.has(method.toUpperCase())) return true;
40
+ for (const [rawName, value] of headers) {
41
+ const name = rawName.toLowerCase();
42
+ if (!SAFELISTED_HEADERS.has(name)) return true;
43
+ if (name === 'content-type') {
44
+ const essence = value.split(';')[0]?.trim().toLowerCase() ?? '';
45
+ if (!SAFELISTED_CONTENT_TYPES.has(essence)) return true;
46
+ }
47
+ }
48
+ return false;
49
+ }