@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
@@ -63,6 +63,12 @@ export class ServiceCredentialMismatchError extends Error {
63
63
  }
64
64
  }
65
65
  const WORKLOAD_CACHE_KEY = 'workload-identity';
66
+ /** What `serviceToken()` mints, and what Oxy assumes when no audience is sent. */
67
+ export const DEFAULT_SERVICE_TOKEN_AUDIENCE = 'oxy-api';
68
+ /** Tokens of different audiences never share a cache entry; the default keeps its old key. */
69
+ function audienceCacheKey(audience, key) {
70
+ return audience === DEFAULT_SERVICE_TOKEN_AUDIENCE ? key : `${audience}\u0000${key}`;
71
+ }
66
72
  /** A token is reused until 60s before expiry (clock drift). */
67
73
  const TOKEN_REUSE_MARGIN_MS = 60000;
68
74
  /** Bound on remembered `appId:userId` delegation answers. */
@@ -198,19 +204,28 @@ export class OxyServer extends OxyServices {
198
204
  * {@link ServiceCredentialMismatchError} rather than return the cached token.
199
205
  */
200
206
  async serviceToken(apiKey, apiSecret) {
207
+ return this.serviceTokenFor(DEFAULT_SERVICE_TOKEN_AUDIENCE, apiKey, apiSecret);
208
+ }
209
+ /**
210
+ * A service token for `audience` — e.g. `kaana.ai` for https://kaana.ai/v1.
211
+ * Minted, cached and refreshed exactly as {@link serviceToken} (which is this
212
+ * with `oxy-api`), from the same credential or attested workload identity;
213
+ * each audience has its own cache entry, so one never answers for another.
214
+ */
215
+ async serviceTokenFor(audience, apiKey, apiSecret) {
201
216
  const key = apiKey || this.serviceApiKey;
202
217
  const secret = apiSecret || this.serviceApiSecret;
203
218
  if (!key || !secret) {
204
219
  // No credential — prove what this process IS instead. The fallback, not the
205
220
  // preference: a deployment that still has a credential keeps using it.
206
221
  if (canAttestWorkloadIdentity()) {
207
- return this.workloadServiceToken();
222
+ return this.workloadServiceToken(audience);
208
223
  }
209
224
  throw new Error('Service credentials not provided. Pass serviceAuth, call configureServiceAuth(), or pass apiKey and apiSecret.');
210
225
  }
211
226
  // The apiKey is the credential's PUBLIC id (`oxy_dk_…`, the OAuth
212
227
  // client_id), so it keys the in-memory cache as is; the secret never does.
213
- const cacheKey = key;
228
+ const cacheKey = audienceCacheKey(audience, key);
214
229
  const now = Date.now();
215
230
  const providedSecretBuf = Buffer.from(secret, 'utf8');
216
231
  let entry = this.serviceTokens.get(cacheKey);
@@ -243,7 +258,7 @@ export class OxyServer extends OxyServices {
243
258
  entry = { token: '', expiresAt: 0, secretBuf: providedSecretBuf, pending: null, apiKey: key };
244
259
  this.serviceTokens.set(cacheKey, entry);
245
260
  }
246
- const pending = this.mintServiceToken(key, secret, cacheKey, providedSecretBuf);
261
+ const pending = this.mintServiceToken(key, secret, cacheKey, providedSecretBuf, audience);
247
262
  entry.pending = pending;
248
263
  try {
249
264
  return await pending;
@@ -276,7 +291,11 @@ export class OxyServer extends OxyServices {
276
291
  this.serviceTokens.clear();
277
292
  return;
278
293
  }
279
- this.serviceTokens.delete(targetKey);
294
+ // Every audience minted from that credential goes with it.
295
+ for (const [cacheKey, entry] of this.serviceTokens) {
296
+ if (entry.apiKey === targetKey)
297
+ this.serviceTokens.delete(cacheKey);
298
+ }
280
299
  }
281
300
  /**
282
301
  * One request with this service's token. `actAs` sends `X-Oxy-User-Id`: the
@@ -402,8 +421,12 @@ export class OxyServer extends OxyServices {
402
421
  canMintServiceToken() {
403
422
  return Boolean(this.serviceApiKey && this.serviceApiSecret) || canAttestWorkloadIdentity();
404
423
  }
405
- async mintServiceToken(key, secret, cacheKey, secretBuf) {
406
- const response = await this.request('POST', '/auth/service-token', { apiKey: key, apiSecret: secret }, { cache: false, retry: false, skipAuth: true });
424
+ async mintServiceToken(key, secret, cacheKey, secretBuf, audience) {
425
+ const response = await this.request('POST', '/auth/service-token', {
426
+ apiKey: key,
427
+ apiSecret: secret,
428
+ ...(audience === DEFAULT_SERVICE_TOKEN_AUDIENCE ? {} : { audience }),
429
+ }, { cache: false, retry: false, skipAuth: true });
407
430
  const expiresAt = Date.now() + response.expiresIn * 1000;
408
431
  const entry = this.serviceTokens.get(cacheKey);
409
432
  if (entry) {
@@ -422,9 +445,10 @@ export class OxyServer extends OxyServices {
422
445
  }
423
446
  return response.token;
424
447
  }
425
- /** A token obtained by attestation, cached like a credential's (one identity per process). */
426
- async workloadServiceToken() {
427
- const entry = this.serviceTokens.get(WORKLOAD_CACHE_KEY);
448
+ /** A token obtained by attestation, cached like a credential's (one identity per process, one entry per audience). */
449
+ async workloadServiceToken(audience) {
450
+ const cacheKey = audienceCacheKey(audience, WORKLOAD_CACHE_KEY);
451
+ const entry = this.serviceTokens.get(cacheKey);
428
452
  if (entry?.token &&
429
453
  reusableServiceToken(entry.token) &&
430
454
  entry.expiresAt > Date.now() + TOKEN_REUSE_MARGIN_MS)
@@ -438,10 +462,13 @@ export class OxyServer extends OxyServices {
438
462
  pending: null,
439
463
  apiKey: WORKLOAD_CACHE_KEY,
440
464
  };
441
- this.serviceTokens.set(WORKLOAD_CACHE_KEY, seeded);
465
+ this.serviceTokens.set(cacheKey, seeded);
442
466
  const pending = (async () => {
443
- const granted = await requestWorkloadServiceToken({ baseUrl: this.baseURL });
444
- const current = this.serviceTokens.get(WORKLOAD_CACHE_KEY);
467
+ const granted = await requestWorkloadServiceToken({
468
+ baseUrl: this.baseURL,
469
+ ...(audience === DEFAULT_SERVICE_TOKEN_AUDIENCE ? {} : { audience }),
470
+ });
471
+ const current = this.serviceTokens.get(cacheKey);
445
472
  if (current) {
446
473
  current.token = granted.token;
447
474
  current.expiresAt = Date.now() + granted.expiresIn * 1000;
@@ -453,13 +480,13 @@ export class OxyServer extends OxyServices {
453
480
  return await pending;
454
481
  }
455
482
  catch (error) {
456
- const failed = this.serviceTokens.get(WORKLOAD_CACHE_KEY);
483
+ const failed = this.serviceTokens.get(cacheKey);
457
484
  if (failed?.pending === pending && !failed.token)
458
- this.serviceTokens.delete(WORKLOAD_CACHE_KEY);
485
+ this.serviceTokens.delete(cacheKey);
459
486
  throw error;
460
487
  }
461
488
  finally {
462
- const settled = this.serviceTokens.get(WORKLOAD_CACHE_KEY);
489
+ const settled = this.serviceTokens.get(cacheKey);
463
490
  if (settled?.pending === pending)
464
491
  settled.pending = null;
465
492
  }
@@ -16,7 +16,7 @@
16
16
  */
17
17
  // The backend client: OxyServices plus the service-token lane, middleware,
18
18
  // account events and the service-only namespace methods.
19
- export { OxyServer, ServiceCredentialMismatchError, ANONYMOUS_SERVICE_TOKEN_RETRY_MS, } from './OxyServer.js';
19
+ export { OxyServer, ServiceCredentialMismatchError, ANONYMOUS_SERVICE_TOKEN_RETRY_MS, DEFAULT_SERVICE_TOKEN_AUDIENCE, } from './OxyServer.js';
20
20
  export { ServerAppsApi, ServerAgencyApi, ServerAssetsApi, ServerLinkedAccountsApi, ServerNotificationsApi, ServerReputationApi, } from './namespaces.js';
21
21
  export { createOxyAliaMachineCredentialAuth, getOxyAliaMachineCredentialBearer, isOxyAliaMachinePrincipal, OXY_ALIA_RESOURCE_APPLICATION_ID, OXY_ALIA_MACHINE_SCOPES, } from './aliaMachineCredential.js';
22
22
  export { OxyAccountEventError, OXY_ACCOUNT_DELETED_EVENT_URI } from './middleware.js';
@@ -171,7 +171,12 @@ export async function requestWorkloadServiceToken(options) {
171
171
  const tokenResponse = await fetchImpl(`${base}/auth/service-token/workload`, {
172
172
  method: 'POST',
173
173
  headers: { 'content-type': 'application/json' },
174
- body: JSON.stringify({ provider: 'aws-iam', nonce: challenge.nonce, attestation }),
174
+ body: JSON.stringify({
175
+ provider: 'aws-iam',
176
+ nonce: challenge.nonce,
177
+ attestation,
178
+ ...(options.audience === undefined ? {} : { audience: options.audience }),
179
+ }),
175
180
  });
176
181
  if (!tokenResponse.ok) {
177
182
  // The body carries a bounded `reason`; the token never travels on a failure,
@@ -0,0 +1,45 @@
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
+ const SAFELISTED_METHODS = new Set(['GET', 'HEAD', 'POST']);
20
+ const SAFELISTED_HEADERS = new Set([
21
+ 'accept',
22
+ 'accept-language',
23
+ 'content-language',
24
+ 'content-type',
25
+ ]);
26
+ const SAFELISTED_CONTENT_TYPES = new Set([
27
+ 'application/x-www-form-urlencoded',
28
+ 'multipart/form-data',
29
+ 'text/plain',
30
+ ]);
31
+ export function requiresCorsPreflight(method, headers) {
32
+ if (!SAFELISTED_METHODS.has(method.toUpperCase()))
33
+ return true;
34
+ for (const [rawName, value] of headers) {
35
+ const name = rawName.toLowerCase();
36
+ if (!SAFELISTED_HEADERS.has(name))
37
+ return true;
38
+ if (name === 'content-type') {
39
+ const essence = value.split(';')[0]?.trim().toLowerCase() ?? '';
40
+ if (!SAFELISTED_CONTENT_TYPES.has(essence))
41
+ return true;
42
+ }
43
+ }
44
+ return false;
45
+ }
@@ -336,8 +336,7 @@ export declare class AccountMembersApi {
336
336
  */
337
337
  list(accountId: string): Promise<AccountMember[]>;
338
338
  /**
339
- * @deprecated The API returns 410. Use accounts.invitations.create with an email.
340
- * Access is granted only after recipient confirmation.
339
+ * Add a member to an account.
341
340
  * @param accountId - The account's `_id`.
342
341
  * @param data - Target user's username or email and role (never `owner`).
343
342
  * The server resolves `usernameOrEmail` to a personal account; an unknown
@@ -394,46 +393,10 @@ export declare class AccountAgentKeysApi {
394
393
  revokedMethodIds: string[];
395
394
  }>;
396
395
  }
397
- export interface AccountInvitation {
398
- id: string;
399
- accountId: string;
400
- email: string;
401
- role: Exclude<AccountRole, 'owner'>;
402
- status: 'pending' | 'expired' | 'accepted' | 'cancelled';
403
- expiresAt: string;
404
- createdAt: string;
405
- }
406
- export interface AccountInvitationPreview {
407
- invitation: AccountInvitation;
408
- account: {
409
- id: string;
410
- username: string | null;
411
- displayName: string | null;
412
- };
413
- }
414
- /** Consent-based invitations; never cached or queued as offline mutations. */
415
- export declare class AccountInvitationsApi {
416
- private readonly ctx;
417
- constructor(ctx: OxyContext);
418
- list(accountId: string): Promise<AccountInvitation[]>;
419
- create(accountId: string, data: {
420
- email: string;
421
- role: Exclude<AccountRole, 'owner'>;
422
- inherit?: boolean;
423
- }): Promise<AccountInvitation>;
424
- resend(accountId: string, id: string): Promise<AccountInvitation>;
425
- cancel(accountId: string, id: string): Promise<AccountSuccessResult>;
426
- preview(token: string): Promise<AccountInvitationPreview>;
427
- /** selectedUserId must equal the currently authenticated personal account. */
428
- accept(token: string, selectedUserId: string): Promise<AccountSuccessResult & {
429
- accountId: string;
430
- }>;
431
- }
432
396
  export declare class AccountsApi {
433
397
  private readonly ctx;
434
398
  /** Who can act on an account, and with what role. */
435
399
  readonly members: AccountMembersApi;
436
- readonly invitations: AccountInvitationsApi;
437
400
  readonly agentKeys: AccountAgentKeysApi;
438
401
  constructor(ctx: OxyContext);
439
402
  /**
@@ -1,4 +1,4 @@
1
- import { type ProductSubscriptionRenewalRequest, type ProductPlanCheckoutRequest, type ProductPlanCatalogue, type PersonalPlanCheckoutRequest, type PersonalPlanCheckoutResult } from '@oxy.so/contracts';
1
+ import { type PersonalPlanCheckoutRequest, type PersonalPlanCheckoutResult } from '@oxy.so/contracts';
2
2
  /**
3
3
  * `oxy.billing` — the signed-in user's subscription, wallet and payment history.
4
4
  *
@@ -101,9 +101,6 @@ export interface WalletTransactionsPage {
101
101
  export declare class BillingApi {
102
102
  protected readonly ctx: OxyContext;
103
103
  constructor(ctx: OxyContext);
104
- renewProductSubscription(request: ProductSubscriptionRenewalRequest): Promise<PersonalPlanCheckoutResult>;
105
- startProductPlanCheckout(request: ProductPlanCheckoutRequest): Promise<PersonalPlanCheckoutResult>;
106
- productPlans(productId: string): Promise<ProductPlanCatalogue>;
107
104
  startPersonalPlanCheckout(request: PersonalPlanCheckoutRequest): Promise<PersonalPlanCheckoutResult>;
108
105
  /** Public versioned personal plans. Discovery never proves customer access. */
109
106
  personalPlans(): Promise<PersonalPlanCatalogue>;
@@ -1425,13 +1425,6 @@ declare const dict: {
1425
1425
  };
1426
1426
  };
1427
1427
  preferences: {
1428
- displayCurrency: {
1429
- label: string;
1430
- saveFailed: string;
1431
- unavailable: string;
1432
- approximate: string;
1433
- estimate: string;
1434
- };
1435
1428
  title: string;
1436
1429
  subtitle: string;
1437
1430
  sections: {
@@ -1425,13 +1425,6 @@ declare const dict: {
1425
1425
  };
1426
1426
  };
1427
1427
  preferences: {
1428
- displayCurrency: {
1429
- label: string;
1430
- saveFailed: string;
1431
- unavailable: string;
1432
- approximate: string;
1433
- estimate: string;
1434
- };
1435
1428
  title: string;
1436
1429
  subtitle: string;
1437
1430
  sections: {
@@ -101,4 +101,3 @@ export type { PkcePair, BuildOAuthAuthorizeUrlParams, OxyAuthScreen } from './ut
101
101
  export { packageInfo } from './constants/version';
102
102
  export type { Organization, OrganizationType, CreateOrganization, UpdateOrganization, OrganizationSearch, OrganizationClaim, } from '@oxy.so/contracts';
103
103
  export type { ProfileAccessInput, ProfileAccessDecision } from '@oxy.so/contracts';
104
- export type { AccountInvitation, AccountInvitationPreview } from './api/accounts';
@@ -242,8 +242,6 @@ export interface NotificationPreferences {
242
242
  * Persisted on the User document.
243
243
  */
244
244
  export interface UserPreferences {
245
- /** Preferred display units only; never changes a payment's denomination. */
246
- displayCurrency?: 'USD' | 'EUR' | 'FAIR';
247
245
  /** BCP-47 language tag, e.g. "en-US", "es-ES". Empty string = follow device. */
248
246
  language?: string;
249
247
  /** Theme mode preference. */
@@ -58,6 +58,15 @@ export interface ServiceTokenResponse {
58
58
  export declare class ServiceCredentialMismatchError extends Error {
59
59
  constructor();
60
60
  }
61
+ /**
62
+ * The resource server a service token is minted for (Oxy's closed set,
63
+ * `packages/api/src/config/serviceTokenAudiences.ts`). A token is only ever
64
+ * accepted by its own audience: Oxy's API refuses a `kaana.ai` token and
65
+ * https://kaana.ai/v1 refuses an `oxy-api` one (Kaana ADR 0002 decision 6).
66
+ */
67
+ export type ServiceTokenAudience = 'oxy-api' | 'kaana.ai' | 'oxy-api:kaana-control';
68
+ /** What `serviceToken()` mints, and what Oxy assumes when no audience is sent. */
69
+ export declare const DEFAULT_SERVICE_TOKEN_AUDIENCE: ServiceTokenAudience;
61
70
  export declare class OxyServer extends OxyServices {
62
71
  private readonly serviceTokens;
63
72
  private serviceApiKey;
@@ -120,6 +129,13 @@ export declare class OxyServer extends OxyServices {
120
129
  * {@link ServiceCredentialMismatchError} rather than return the cached token.
121
130
  */
122
131
  serviceToken(apiKey?: string, apiSecret?: string): Promise<string>;
132
+ /**
133
+ * A service token for `audience` — e.g. `kaana.ai` for https://kaana.ai/v1.
134
+ * Minted, cached and refreshed exactly as {@link serviceToken} (which is this
135
+ * with `oxy-api`), from the same credential or attested workload identity;
136
+ * each audience has its own cache entry, so one never answers for another.
137
+ */
138
+ serviceTokenFor(audience: ServiceTokenAudience, apiKey?: string, apiSecret?: string): Promise<string>;
123
139
  /**
124
140
  * Forget cached service token(s) so the next `serviceToken()` mints anew —
125
141
  * the way back from a 401 after a credential was revoked or rotated.
@@ -187,7 +203,7 @@ export declare class OxyServer extends OxyServices {
187
203
  private serviceTokenForAnonymousRequest;
188
204
  private canMintServiceToken;
189
205
  private mintServiceToken;
190
- /** A token obtained by attestation, cached like a credential's (one identity per process). */
206
+ /** A token obtained by attestation, cached like a credential's (one identity per process, one entry per audience). */
191
207
  private workloadServiceToken;
192
208
  private lookupActingAs;
193
209
  private clearActingAsPair;
@@ -14,8 +14,8 @@
14
14
  * app.use('/api', createOxyAuthMiddleware(oxy));
15
15
  * ```
16
16
  */
17
- export { OxyServer, ServiceCredentialMismatchError, ANONYMOUS_SERVICE_TOKEN_RETRY_MS, } from './OxyServer';
18
- export type { OxyServerConfig, ServiceTokenResponse } from './OxyServer';
17
+ export { OxyServer, ServiceCredentialMismatchError, ANONYMOUS_SERVICE_TOKEN_RETRY_MS, DEFAULT_SERVICE_TOKEN_AUDIENCE, } from './OxyServer';
18
+ export type { OxyServerConfig, ServiceTokenAudience, ServiceTokenResponse } from './OxyServer';
19
19
  export { ServerAppsApi, ServerAgencyApi, ServerAssetsApi, ServerLinkedAccountsApi, ServerNotificationsApi, ServerReputationApi, } from './namespaces';
20
20
  export { createOxyAliaMachineCredentialAuth, getOxyAliaMachineCredentialBearer, isOxyAliaMachinePrincipal, OXY_ALIA_RESOURCE_APPLICATION_ID, OXY_ALIA_MACHINE_SCOPES, } from './aliaMachineCredential';
21
21
  export type { AliaMachineCredentialIntrospection, OxyAliaMachinePrincipal, OxyAliaMachineRequest, OxyAliaMachineCredentialHost, } from './aliaMachineCredential';
@@ -1,6 +1,11 @@
1
1
  export interface WorkloadServiceTokenOptions {
2
2
  /** Oxy's base URL, e.g. `https://api.oxy.so`. */
3
3
  baseUrl: string;
4
+ /**
5
+ * The resource server the token is for (e.g. `kaana.ai`). Omitted, Oxy mints
6
+ * for `oxy-api`, as it always has.
7
+ */
8
+ audience?: string;
4
9
  /** Injected in tests; defaults to the global. */
5
10
  fetch?: typeof fetch;
6
11
  }
@@ -0,0 +1,19 @@
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
+ export declare function requiresCorsPreflight(method: string, headers: Iterable<readonly [string, string]>): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "4.9.0",
3
+ "version": "4.9.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",
@@ -178,7 +178,7 @@
178
178
  "dependencies": {
179
179
  "@noble/ciphers": "^1.3.0",
180
180
  "@noble/hashes": "^1.8.0",
181
- "@oxy.so/contracts": "4.15.0",
181
+ "@oxy.so/contracts": "4.14.0",
182
182
  "@oxy.so/protocol": "^1.2.2",
183
183
  "@oxy.so/telemetry": "^1.2.0",
184
184
  "@scure/bip39": "^1.6.0",
@@ -32,6 +32,7 @@ import type { DeviceSecretMintOutcome } from './session/refresh';
32
32
  import { OxyAuthenticationError } from './OxyServices.errors';
33
33
  import { peekBrowserEdgeRegionHeader } from './utils/edgeRegion';
34
34
  import { getBrowserActivityIdHeader } from './utils/activityId';
35
+ import { requiresCorsPreflight } from './utils/corsPreflight';
35
36
  import { trimLeadingSlashes, trimTrailingSlashes } from './utils/slashes';
36
37
 
37
38
  export type AuthRefreshReason = 'preflight' | 'response-401';
@@ -712,11 +713,16 @@ export class HttpService {
712
713
  Accept: 'application/json',
713
714
  };
714
715
 
715
- // Only set Content-Type for non-FormData requests (FormData sets it automatically with boundary)
716
- if (isUrlEncoded) {
717
- headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
718
- } else if (!isFormData) {
719
- headers['Content-Type'] = 'application/json';
716
+ // Content-Type describes a body, so a GET (which never sends one) gets
717
+ // none: `application/json` is not a CORS-safelisted type, and on a GET it
718
+ // only made the browser preflight a request with nothing to describe.
719
+ // FormData sets its own multipart type with the boundary.
720
+ if (method !== 'GET') {
721
+ if (isUrlEncoded) {
722
+ headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
723
+ } else if (!isFormData) {
724
+ headers['Content-Type'] = 'application/json';
725
+ }
720
726
  }
721
727
 
722
728
  // Add authorization header if available
@@ -741,9 +747,14 @@ export class HttpService {
741
747
 
742
748
  // This is Cloudflare's coarse serving PoP (for example `mad`), never an
743
749
  // IP or coordinate. Set it after caller headers so request code cannot
744
- // accidentally or deliberately substitute a different origin.
745
- Object.assign(headers, edgeRegionHeader);
746
- Object.assign(headers, activityIdHeader);
750
+ // accidentally or deliberately substitute a different origin. Both are
751
+ // advisory telemetry, so they ride only on a request the browser
752
+ // preflights anyway: on an anonymous GET they were the only thing that
753
+ // made it non-simple, and every distinct URL paid an OPTIONS round trip.
754
+ if (requiresCorsPreflight(method, Object.entries(headers))) {
755
+ Object.assign(headers, edgeRegionHeader);
756
+ Object.assign(headers, activityIdHeader);
757
+ }
747
758
 
748
759
  // `URLSearchParams` is serialised explicitly rather than handed to
749
760
  // `fetch` as-is: RN's fetch does not consistently encode it, and doing
@@ -1637,10 +1648,18 @@ export class HttpService {
1637
1648
  for (const name of ['Authorization', 'Cookie', 'Cookie2', 'Host', 'Proxy-Authorization'])
1638
1649
  headers.delete(name);
1639
1650
  if (authHeader) headers.set('Authorization', authHeader);
1640
- const edgeRegionHeader = peekBrowserEdgeRegionHeader();
1641
- for (const [name, value] of Object.entries(edgeRegionHeader)) headers.set(name, value);
1642
- const activityIdHeader = getBrowserActivityIdHeader();
1643
- for (const [name, value] of Object.entries(activityIdHeader)) headers.set(name, value);
1651
+ // Telemetry rides only on a request the browser preflights anyway — see
1652
+ // `request()`.
1653
+ const sentHeaders: Array<[string, string]> = [];
1654
+ headers.forEach((value, name) => {
1655
+ sentHeaders.push([name, value]);
1656
+ });
1657
+ if (requiresCorsPreflight(config.method, sentHeaders)) {
1658
+ const edgeRegionHeader = peekBrowserEdgeRegionHeader();
1659
+ for (const [name, value] of Object.entries(edgeRegionHeader)) headers.set(name, value);
1660
+ const activityIdHeader = getBrowserActivityIdHeader();
1661
+ for (const [name, value] of Object.entries(activityIdHeader)) headers.set(name, value);
1662
+ }
1644
1663
 
1645
1664
  try {
1646
1665
  const body = config.method === 'GET' ? undefined : config.body;
@@ -391,8 +391,7 @@ export class AccountMembersApi {
391
391
  }
392
392
 
393
393
  /**
394
- * @deprecated The API returns 410. Use accounts.invitations.create with an email.
395
- * Access is granted only after recipient confirmation.
394
+ * Add a member to an account.
396
395
  * @param accountId - The account's `_id`.
397
396
  * @param data - Target user's username or email and role (never `owner`).
398
397
  * The server resolves `usernameOrEmail` to a personal account; an unknown
@@ -477,93 +476,13 @@ export class AccountAgentKeysApi {
477
476
  }
478
477
  }
479
478
 
480
- export interface AccountInvitation {
481
- id: string;
482
- accountId: string;
483
- email: string;
484
- role: Exclude<AccountRole, 'owner'>;
485
- status: 'pending' | 'expired' | 'accepted' | 'cancelled';
486
- expiresAt: string;
487
- createdAt: string;
488
- }
489
- export interface AccountInvitationPreview {
490
- invitation: AccountInvitation;
491
- account: { id: string; username: string | null; displayName: string | null };
492
- }
493
-
494
- /** Consent-based invitations; never cached or queued as offline mutations. */
495
- export class AccountInvitationsApi {
496
- constructor(private readonly ctx: OxyContext) {}
497
- async list(accountId: string): Promise<AccountInvitation[]> {
498
- return (
499
- await this.ctx.request<{ invitations: AccountInvitation[] }>(
500
- 'GET',
501
- `/accounts/${enc(accountId)}/invitations`,
502
- undefined,
503
- { cache: false },
504
- )
505
- ).invitations;
506
- }
507
- async create(
508
- accountId: string,
509
- data: { email: string; role: Exclude<AccountRole, 'owner'>; inherit?: boolean },
510
- ): Promise<AccountInvitation> {
511
- return (
512
- await this.ctx.request<{ invitation: AccountInvitation }>(
513
- 'POST',
514
- `/accounts/${enc(accountId)}/invitations`,
515
- data,
516
- { cache: false },
517
- )
518
- ).invitation;
519
- }
520
- async resend(accountId: string, id: string): Promise<AccountInvitation> {
521
- return (
522
- await this.ctx.request<{ invitation: AccountInvitation }>(
523
- 'POST',
524
- `/accounts/${enc(accountId)}/invitations/${enc(id)}/resend`,
525
- undefined,
526
- { cache: false },
527
- )
528
- ).invitation;
529
- }
530
- async cancel(accountId: string, id: string): Promise<AccountSuccessResult> {
531
- return this.ctx.request(
532
- 'DELETE',
533
- `/accounts/${enc(accountId)}/invitations/${enc(id)}`,
534
- undefined,
535
- { cache: false },
536
- );
537
- }
538
- async preview(token: string): Promise<AccountInvitationPreview> {
539
- return this.ctx.request('POST', '/accounts/invitations/preview', { token }, { cache: false });
540
- }
541
- /** selectedUserId must equal the currently authenticated personal account. */
542
- async accept(
543
- token: string,
544
- selectedUserId: string,
545
- ): Promise<AccountSuccessResult & { accountId: string }> {
546
- const result = await this.ctx.request<AccountSuccessResult & { accountId: string }>(
547
- 'POST',
548
- '/accounts/invitations/accept',
549
- { token, selectedUserId, confirmed: true },
550
- { cache: false },
551
- );
552
- invalidateAccountMembership(this.ctx, result.accountId);
553
- evictOxyAccountForestCache(this.ctx.http);
554
- return result;
555
- }
556
- }
557
-
558
479
  export class AccountsApi {
559
480
  /** Who can act on an account, and with what role. */
560
481
  readonly members: AccountMembersApi;
561
- readonly invitations: AccountInvitationsApi;
562
482
  readonly agentKeys: AccountAgentKeysApi;
563
483
 
564
484
  constructor(private readonly ctx: OxyContext) {
565
485
  this.members = new AccountMembersApi(ctx);
566
- this.invitations = new AccountInvitationsApi(ctx);
567
486
  this.agentKeys = new AccountAgentKeysApi(ctx);
568
487
  }
569
488
 
@@ -1,11 +1,5 @@
1
1
  import {
2
2
  personalPlanCheckoutRequestSchema,
3
- productPlanCheckoutRequestSchema,
4
- productSubscriptionRenewalRequestSchema,
5
- type ProductSubscriptionRenewalRequest,
6
- productPlanCatalogueSchema,
7
- type ProductPlanCheckoutRequest,
8
- type ProductPlanCatalogue,
9
3
  personalPlanCheckoutResultSchema,
10
4
  type PersonalPlanCheckoutRequest,
11
5
  type PersonalPlanCheckoutResult,
@@ -133,43 +127,6 @@ export interface WalletTransactionsPage {
133
127
  export class BillingApi {
134
128
  constructor(protected readonly ctx: OxyContext) {}
135
129
 
136
- async renewProductSubscription(
137
- request: ProductSubscriptionRenewalRequest,
138
- ): Promise<PersonalPlanCheckoutResult> {
139
- return personalPlanCheckoutResultSchema.parse(
140
- await this.ctx.request<unknown>(
141
- 'POST',
142
- '/billing/product-subscriptions/renew',
143
- productSubscriptionRenewalRequestSchema.parse(request),
144
- { cache: false },
145
- ),
146
- );
147
- }
148
-
149
- async startProductPlanCheckout(
150
- request: ProductPlanCheckoutRequest,
151
- ): Promise<PersonalPlanCheckoutResult> {
152
- return personalPlanCheckoutResultSchema.parse(
153
- await this.ctx.request<unknown>(
154
- 'POST',
155
- '/billing/checkout/product-plan',
156
- productPlanCheckoutRequestSchema.parse(request),
157
- { cache: false },
158
- ),
159
- );
160
- }
161
-
162
- async productPlans(productId: string): Promise<ProductPlanCatalogue> {
163
- return productPlanCatalogueSchema.parse(
164
- await this.ctx.request<unknown>(
165
- 'GET',
166
- `/billing/product-plans/${encodeURIComponent(productId)}`,
167
- undefined,
168
- { cache: false },
169
- ),
170
- );
171
- }
172
-
173
130
  async startPersonalPlanCheckout(
174
131
  request: PersonalPlanCheckoutRequest,
175
132
  ): Promise<PersonalPlanCheckoutResult> {