@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.
- package/dist/cjs/HttpService.js +34 -15
- package/dist/cjs/api/accounts.js +2 -33
- package/dist/cjs/api/billing.js +0 -9
- package/dist/cjs/i18n/locales/en-US.js +0 -7
- package/dist/cjs/i18n/locales/es-ES.js +0 -7
- package/dist/cjs/server/OxyServer.js +43 -16
- package/dist/cjs/server/index.js +3 -2
- package/dist/cjs/server/workloadIdentity.js +6 -1
- package/dist/cjs/utils/corsPreflight.js +48 -0
- package/dist/esm/HttpService.js +34 -15
- package/dist/esm/api/accounts.js +1 -31
- package/dist/esm/api/billing.js +1 -10
- package/dist/esm/i18n/locales/en-US.js +0 -7
- package/dist/esm/i18n/locales/es-ES.js +0 -7
- package/dist/esm/server/OxyServer.js +42 -15
- package/dist/esm/server/index.js +1 -1
- package/dist/esm/server/workloadIdentity.js +6 -1
- package/dist/esm/utils/corsPreflight.js +45 -0
- package/dist/types/api/accounts.d.ts +1 -38
- package/dist/types/api/billing.d.ts +1 -4
- package/dist/types/i18n/locales/en-US.d.ts +0 -7
- package/dist/types/i18n/locales/es-ES.d.ts +0 -7
- package/dist/types/index.d.ts +0 -1
- package/dist/types/models/interfaces.d.ts +0 -2
- package/dist/types/server/OxyServer.d.ts +17 -1
- package/dist/types/server/index.d.ts +2 -2
- package/dist/types/server/workloadIdentity.d.ts +5 -0
- package/dist/types/utils/corsPreflight.d.ts +19 -0
- package/package.json +2 -2
- package/src/HttpService.ts +31 -12
- package/src/api/accounts.ts +1 -82
- package/src/api/billing.ts +0 -43
- package/src/i18n/locales/en-US.ts +0 -8
- package/src/i18n/locales/es-ES.ts +0 -8
- package/src/index.ts +0 -1
- package/src/models/interfaces.ts +0 -2
- package/src/server/OxyServer.ts +56 -15
- package/src/server/index.ts +2 -1
- package/src/server/workloadIdentity.ts +11 -1
- 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
package/src/models/interfaces.ts
CHANGED
|
@@ -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. */
|
package/src/server/OxyServer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{
|
|
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
|
|
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(
|
|
665
|
+
this.serviceTokens.set(cacheKey, seeded);
|
|
627
666
|
|
|
628
667
|
const pending = (async () => {
|
|
629
|
-
const granted = await requestWorkloadServiceToken({
|
|
630
|
-
|
|
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(
|
|
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(
|
|
688
|
+
const settled = this.serviceTokens.get(cacheKey);
|
|
648
689
|
if (settled?.pending === pending) settled.pending = null;
|
|
649
690
|
}
|
|
650
691
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -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({
|
|
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
|
+
}
|