@oxy.so/core 4.4.0 → 4.5.0-oxy-one.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.
@@ -38,6 +38,12 @@ const ASSET_URL_CACHE_LIFETIME_FRACTION = 0.5;
38
38
  /** Assumed URL lifetime (seconds) when no `expiresIn` is asked for; the API default. */
39
39
  const DEFAULT_ASSET_URL_EXPIRES_IN_SECONDS = 3600;
40
40
  const ASSET_TTL = 5 * 60 * 1000;
41
+ /**
42
+ * Default bound on one `POST /assets/upload`, in ms. The client's per-request
43
+ * default (5s) is sized for JSON calls; a video sent over a phone connection
44
+ * needs minutes, and timing it out at 5s failed every upload past a few MB.
45
+ */
46
+ const ASSET_UPLOAD_TIMEOUT_MS = 10 * 60 * 1000;
41
47
  /**
42
48
  * How long a resolved asset URL may stay in the SDK's GET cache, in ms: never
43
49
  * longer than the media token's lifetime, and discounted below it.
@@ -51,6 +57,24 @@ class AssetsApi {
51
57
  constructor(ctx) {
52
58
  this.ctx = ctx;
53
59
  }
60
+ /** Upload already-admitted bytes. Sends required bucket headers, never an Oxy bearer. */
61
+ async putPresignedUpload(init, body) {
62
+ if (!init.uploadUrl)
63
+ return; // bytes already exist for this account
64
+ const target = new URL(init.uploadUrl);
65
+ if (target.protocol !== 'https:')
66
+ throw new Error('Presigned uploads require HTTPS');
67
+ const headers = new Headers(init.requiredHeaders);
68
+ headers.forEach((_value, name) => {
69
+ if (!['if-none-match', 'content-type', 'x-amz-checksum-sha256', 'cache-control'].includes(name.toLowerCase()))
70
+ throw new Error('Unsupported required upload header');
71
+ });
72
+ if (body.type && !headers.has('Content-Type'))
73
+ headers.set('Content-Type', body.type);
74
+ const result = await fetch(target.toString(), { method: 'PUT', body, headers, credentials: 'omit', redirect: 'error' });
75
+ if (!result.ok)
76
+ throw new Error(`Presigned upload failed (${result.status})`);
77
+ }
54
78
  // ── Upload ───────────────────────────────────────────────────────────────
55
79
  /**
56
80
  * Upload a file. Accepts a web `File`/`Blob` or a React Native
@@ -96,7 +120,10 @@ class AssetsApi {
96
120
  if (options.metadata)
97
121
  formData.append('metadata', JSON.stringify(options.metadata));
98
122
  try {
99
- const response = await this.ctx.request('POST', '/assets/upload', formData, { cache: false });
123
+ const response = await this.ctx.request('POST', '/assets/upload', formData, {
124
+ cache: false,
125
+ timeout: options.timeout ?? ASSET_UPLOAD_TIMEOUT_MS,
126
+ });
100
127
  options.onProgress?.(100);
101
128
  return response;
102
129
  }
@@ -714,14 +714,19 @@ class AuthApi {
714
714
  this.agent = new AuthAgentApi(ctx);
715
715
  }
716
716
  /**
717
- * Register a new identity with public key authentication
718
- * Identity is purely cryptographic - username and profile data are optional
717
+ * Register a new account for a public key, together with its username.
718
+ *
719
+ * The username is required: the server writes it in the same insert as the
720
+ * key, so an account never exists without one. A taken username is a 409
721
+ * (`Username already taken`), as is a key that is already registered
722
+ * (`Identity already registered`).
719
723
  *
720
724
  * @param publicKey - The user's ECDSA public key (hex)
721
725
  * @param signature - Signature of the registration request
722
726
  * @param timestamp - Timestamp when the signature was created
727
+ * @param username - The account's username
723
728
  */
724
- async registerKey(publicKey, signature, timestamp) {
729
+ async registerKey(publicKey, signature, timestamp, username) {
725
730
  // Advisory for now (server soft-enforces — see
726
731
  // `SessionController.register`), but solved unconditionally so every
727
732
  // client is already sending it once the server starts requiring it.
@@ -731,6 +736,7 @@ class AuthApi {
731
736
  publicKey,
732
737
  signature,
733
738
  timestamp,
739
+ username,
734
740
  powNonce,
735
741
  }, { cache: false, skipAuth: true });
736
742
  if (!res || (typeof res === 'object' && Object.keys(res).length === 0)) {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BillingApi = void 0;
4
+ const contracts_1 = require("@oxy.so/contracts");
4
5
  /**
5
6
  * `oxy.billing` — the signed-in user's subscription, wallet and payment history.
6
7
  *
@@ -12,35 +13,43 @@ exports.BillingApi = void 0;
12
13
  * `payment.controller`, `wallet.routes` in `packages/api`); `Date` columns
13
14
  * arrive as ISO-8601 strings.
14
15
  */
15
- const contracts_1 = require("@oxy.so/contracts");
16
16
  const contracts_2 = require("@oxy.so/contracts");
17
+ const contracts_3 = require("@oxy.so/contracts");
18
+ const contracts_4 = require("@oxy.so/contracts");
17
19
  const SUBSCRIPTION_TTL = 2 * 60 * 1000;
18
20
  const WALLET_TTL = 60 * 1000;
19
21
  class BillingApi {
20
22
  constructor(ctx) {
21
23
  this.ctx = ctx;
22
24
  }
25
+ async startPersonalPlanCheckout(request) {
26
+ return contracts_1.personalPlanCheckoutResultSchema.parse(await this.ctx.request('POST', '/billing/checkout/personal-plan', contracts_1.personalPlanCheckoutRequestSchema.parse(request), { cache: false }));
27
+ }
28
+ /** Public versioned personal plans. Discovery never proves customer access. */
29
+ async personalPlans() {
30
+ return contracts_2.personalPlanCatalogueSchema.parse(await this.ctx.request('GET', '/billing/personal-plans', undefined, { cache: false }));
31
+ }
23
32
  /** Product rights only; requires a production application-bound user session. Never cached. */
24
33
  async productAccess(query) {
25
- const parsed = contracts_1.subjectProductAccessQuerySchema.parse(query);
34
+ const parsed = contracts_3.subjectProductAccessQuerySchema.parse(query);
26
35
  const response = await this.ctx.request('GET', `/v1/products/${encodeURIComponent(parsed.productId)}/access/${encodeURIComponent(parsed.subjectAccountId)}`, undefined, { cache: false });
27
- return contracts_1.subjectProductAccessSchema.parse(response);
36
+ return contracts_3.subjectProductAccessSchema.parse(response);
28
37
  }
29
38
  /** Named commercial sources for the current subject; no inferred plan collapse. */
30
- async productSubscriptions() {
31
- return contracts_2.productSubscriptionsResponseSchema.parse(await this.ctx.request("GET", "/billing/product-subscriptions", undefined, { cache: false })).subscriptions;
39
+ async productSubscriptions(expectedSubjectAccountId) {
40
+ return contracts_4.productSubscriptionsResponseSchema.parse(await this.ctx.request("GET", expectedSubjectAccountId ? `/billing/product-subscriptions?expectedSubjectAccountId=${encodeURIComponent(expectedSubjectAccountId)}` : "/billing/product-subscriptions", undefined, { cache: false })).subscriptions;
32
41
  }
33
42
  /** Per-grant credit provenance; no interpretation of the historical mixed balance. */
34
43
  async creditGrants() {
35
- return contracts_2.subscriptionCreditGrantsResponseSchema.parse(await this.ctx.request("GET", "/billing/credit-grants", undefined, { cache: false })).grants;
44
+ return contracts_4.subscriptionCreditGrantsResponseSchema.parse(await this.ctx.request("GET", "/billing/credit-grants", undefined, { cache: false })).grants;
36
45
  }
37
46
  /** Payer-only named-source cancellation. Resolves on provider acceptance, including pending local reconciliation. Use cancelProductSubscriptionWithStatus for detailed completion state. */
38
- async cancelProductSubscription(sourceId, expectedSubjectAccountId) {
39
- await this.ctx.request("POST", "/billing/product-subscriptions/cancel", contracts_2.cancelProductSubscriptionSchema.parse({ sourceId, ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }), { cache: false });
47
+ async cancelProductSubscription(sourceId, expectedSubjectAccountId, actionId) {
48
+ await this.ctx.request("POST", "/billing/product-subscriptions/cancel", contracts_4.cancelProductSubscriptionSchema.parse({ sourceId, ...(actionId ? { actionId } : {}), ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }), { cache: false });
40
49
  }
41
50
  /** Provider acceptance versus completed local cancellation, for user-facing flows. */
42
- async cancelProductSubscriptionWithStatus(sourceId, expectedSubjectAccountId) {
43
- return contracts_2.productSubscriptionCancellationResultSchema.parse(await this.ctx.request('POST', '/billing/product-subscriptions/cancel', contracts_2.cancelProductSubscriptionSchema.parse({ sourceId, ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }), { cache: false }));
51
+ async cancelProductSubscriptionWithStatus(sourceId, expectedSubjectAccountId, actionId) {
52
+ return contracts_4.productSubscriptionCancellationResultSchema.parse(await this.ctx.request('POST', '/billing/product-subscriptions/cancel', contracts_4.cancelProductSubscriptionSchema.parse({ sourceId, ...(actionId ? { actionId } : {}), ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }), { cache: false }));
44
53
  }
45
54
  /** The signed-in user's payment history. Never cached. */
46
55
  async payments() {
@@ -109,8 +109,8 @@ class UsersApi {
109
109
  return settled.flat();
110
110
  }
111
111
  /** The signed-in user. Cached 1 minute. */
112
- async me() {
113
- const user = await this.ctx.request('GET', '/users/me', undefined, { cache: true, cacheTTL: ME_TTL });
112
+ async me(options) {
113
+ const user = await this.ctx.request('GET', '/users/me', undefined, { cache: options?.cache ?? true, cacheTTL: ME_TTL });
114
114
  return (0, userIdentity_1.normalizeUserIdentity)(user);
115
115
  }
116
116
  /** A profile by username. Cached 5 minutes; `{ cache: false }` as in `get`. */
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OxyServer = exports.ServiceCredentialMismatchError = exports.ANONYMOUS_SERVICE_TOKEN_RETRY_MS = void 0;
4
+ const contracts_1 = require("@oxy.so/contracts");
4
5
  /**
5
6
  * `OxyServer` — the Oxy client for a backend.
6
7
  *
@@ -279,6 +280,26 @@ class OxyServer extends OxyServices_1.OxyServices {
279
280
  * request is on behalf of that user (the API checks the delegation grant).
280
281
  * Never cached unless `cache` says so. Rejects with `OxyApiError`.
281
282
  */
283
+ /** Validated request's user session only; never changes the shared client's token. */
284
+ async productGrantSnapshotForUser(query, accessToken) {
285
+ const parsed = contracts_1.subjectProductAccessQuerySchema.parse(query);
286
+ if (!accessToken || /[\r\n]/.test(accessToken))
287
+ throw new Error('A user session is required');
288
+ const answer = await this.request('GET', `/v1/products/${encodeURIComponent(parsed.productId)}/access/${encodeURIComponent(parsed.subjectAccountId)}/grants`, undefined, { cache: false, retry: false, headers: { Authorization: `Bearer ${accessToken}` } });
289
+ const snapshot = contracts_1.subjectProductGrantSnapshotSchema.parse(answer);
290
+ if (snapshot.access.subjectAccountId !== parsed.subjectAccountId || snapshot.access.productId !== parsed.productId)
291
+ throw new Error('Product grant response attribution differs');
292
+ return snapshot;
293
+ }
294
+ /** Offline metering read: the API requires this app's live consent for this exact subject. */
295
+ async productGrantSnapshotForService(query) {
296
+ const parsed = contracts_1.subjectProductAccessQuerySchema.parse(query);
297
+ const answer = await this.serviceRequest('GET', `/v1/products/${encodeURIComponent(parsed.productId)}/access/${encodeURIComponent(parsed.subjectAccountId)}/service-grants`, undefined, { cache: false, retry: false });
298
+ const snapshot = contracts_1.subjectProductGrantSnapshotSchema.parse(answer);
299
+ if (snapshot.access.subjectAccountId !== parsed.subjectAccountId || snapshot.access.productId !== parsed.productId)
300
+ throw new Error('Product grant response attribution differs');
301
+ return snapshot;
302
+ }
282
303
  async serviceRequest(method, url, data, options = {}) {
283
304
  const { actAs, headers, ...rest } = options;
284
305
  const token = await this.serviceToken();
@@ -34,6 +34,12 @@ const ASSET_URL_CACHE_LIFETIME_FRACTION = 0.5;
34
34
  /** Assumed URL lifetime (seconds) when no `expiresIn` is asked for; the API default. */
35
35
  const DEFAULT_ASSET_URL_EXPIRES_IN_SECONDS = 3600;
36
36
  const ASSET_TTL = 5 * 60 * 1000;
37
+ /**
38
+ * Default bound on one `POST /assets/upload`, in ms. The client's per-request
39
+ * default (5s) is sized for JSON calls; a video sent over a phone connection
40
+ * needs minutes, and timing it out at 5s failed every upload past a few MB.
41
+ */
42
+ const ASSET_UPLOAD_TIMEOUT_MS = 10 * 60 * 1000;
37
43
  /**
38
44
  * How long a resolved asset URL may stay in the SDK's GET cache, in ms: never
39
45
  * longer than the media token's lifetime, and discounted below it.
@@ -47,6 +53,24 @@ export class AssetsApi {
47
53
  constructor(ctx) {
48
54
  this.ctx = ctx;
49
55
  }
56
+ /** Upload already-admitted bytes. Sends required bucket headers, never an Oxy bearer. */
57
+ async putPresignedUpload(init, body) {
58
+ if (!init.uploadUrl)
59
+ return; // bytes already exist for this account
60
+ const target = new URL(init.uploadUrl);
61
+ if (target.protocol !== 'https:')
62
+ throw new Error('Presigned uploads require HTTPS');
63
+ const headers = new Headers(init.requiredHeaders);
64
+ headers.forEach((_value, name) => {
65
+ if (!['if-none-match', 'content-type', 'x-amz-checksum-sha256', 'cache-control'].includes(name.toLowerCase()))
66
+ throw new Error('Unsupported required upload header');
67
+ });
68
+ if (body.type && !headers.has('Content-Type'))
69
+ headers.set('Content-Type', body.type);
70
+ const result = await fetch(target.toString(), { method: 'PUT', body, headers, credentials: 'omit', redirect: 'error' });
71
+ if (!result.ok)
72
+ throw new Error(`Presigned upload failed (${result.status})`);
73
+ }
50
74
  // ── Upload ───────────────────────────────────────────────────────────────
51
75
  /**
52
76
  * Upload a file. Accepts a web `File`/`Blob` or a React Native
@@ -92,7 +116,10 @@ export class AssetsApi {
92
116
  if (options.metadata)
93
117
  formData.append('metadata', JSON.stringify(options.metadata));
94
118
  try {
95
- const response = await this.ctx.request('POST', '/assets/upload', formData, { cache: false });
119
+ const response = await this.ctx.request('POST', '/assets/upload', formData, {
120
+ cache: false,
121
+ timeout: options.timeout ?? ASSET_UPLOAD_TIMEOUT_MS,
122
+ });
96
123
  options.onProgress?.(100);
97
124
  return response;
98
125
  }
@@ -672,14 +672,19 @@ export class AuthApi {
672
672
  this.agent = new AuthAgentApi(ctx);
673
673
  }
674
674
  /**
675
- * Register a new identity with public key authentication
676
- * Identity is purely cryptographic - username and profile data are optional
675
+ * Register a new account for a public key, together with its username.
676
+ *
677
+ * The username is required: the server writes it in the same insert as the
678
+ * key, so an account never exists without one. A taken username is a 409
679
+ * (`Username already taken`), as is a key that is already registered
680
+ * (`Identity already registered`).
677
681
  *
678
682
  * @param publicKey - The user's ECDSA public key (hex)
679
683
  * @param signature - Signature of the registration request
680
684
  * @param timestamp - Timestamp when the signature was created
685
+ * @param username - The account's username
681
686
  */
682
- async registerKey(publicKey, signature, timestamp) {
687
+ async registerKey(publicKey, signature, timestamp, username) {
683
688
  // Advisory for now (server soft-enforces — see
684
689
  // `SessionController.register`), but solved unconditionally so every
685
690
  // client is already sending it once the server starts requiring it.
@@ -689,6 +694,7 @@ export class AuthApi {
689
694
  publicKey,
690
695
  signature,
691
696
  timestamp,
697
+ username,
692
698
  powNonce,
693
699
  }, { cache: false, skipAuth: true });
694
700
  if (!res || (typeof res === 'object' && Object.keys(res).length === 0)) {
@@ -1,3 +1,4 @@
1
+ import { personalPlanCheckoutRequestSchema, personalPlanCheckoutResultSchema } from '@oxy.so/contracts';
1
2
  /**
2
3
  * `oxy.billing` — the signed-in user's subscription, wallet and payment history.
3
4
  *
@@ -9,6 +10,7 @@
9
10
  * `payment.controller`, `wallet.routes` in `packages/api`); `Date` columns
10
11
  * arrive as ISO-8601 strings.
11
12
  */
13
+ import { personalPlanCatalogueSchema } from '@oxy.so/contracts';
12
14
  import { subjectProductAccessQuerySchema, subjectProductAccessSchema, } from '@oxy.so/contracts';
13
15
  import { productSubscriptionCancellationResultSchema, cancelProductSubscriptionSchema, productSubscriptionsResponseSchema, subscriptionCreditGrantsResponseSchema, } from '@oxy.so/contracts';
14
16
  const SUBSCRIPTION_TTL = 2 * 60 * 1000;
@@ -17,6 +19,13 @@ export class BillingApi {
17
19
  constructor(ctx) {
18
20
  this.ctx = ctx;
19
21
  }
22
+ async startPersonalPlanCheckout(request) {
23
+ return personalPlanCheckoutResultSchema.parse(await this.ctx.request('POST', '/billing/checkout/personal-plan', personalPlanCheckoutRequestSchema.parse(request), { cache: false }));
24
+ }
25
+ /** Public versioned personal plans. Discovery never proves customer access. */
26
+ async personalPlans() {
27
+ return personalPlanCatalogueSchema.parse(await this.ctx.request('GET', '/billing/personal-plans', undefined, { cache: false }));
28
+ }
20
29
  /** Product rights only; requires a production application-bound user session. Never cached. */
21
30
  async productAccess(query) {
22
31
  const parsed = subjectProductAccessQuerySchema.parse(query);
@@ -24,20 +33,20 @@ export class BillingApi {
24
33
  return subjectProductAccessSchema.parse(response);
25
34
  }
26
35
  /** Named commercial sources for the current subject; no inferred plan collapse. */
27
- async productSubscriptions() {
28
- return productSubscriptionsResponseSchema.parse(await this.ctx.request("GET", "/billing/product-subscriptions", undefined, { cache: false })).subscriptions;
36
+ async productSubscriptions(expectedSubjectAccountId) {
37
+ return productSubscriptionsResponseSchema.parse(await this.ctx.request("GET", expectedSubjectAccountId ? `/billing/product-subscriptions?expectedSubjectAccountId=${encodeURIComponent(expectedSubjectAccountId)}` : "/billing/product-subscriptions", undefined, { cache: false })).subscriptions;
29
38
  }
30
39
  /** Per-grant credit provenance; no interpretation of the historical mixed balance. */
31
40
  async creditGrants() {
32
41
  return subscriptionCreditGrantsResponseSchema.parse(await this.ctx.request("GET", "/billing/credit-grants", undefined, { cache: false })).grants;
33
42
  }
34
43
  /** Payer-only named-source cancellation. Resolves on provider acceptance, including pending local reconciliation. Use cancelProductSubscriptionWithStatus for detailed completion state. */
35
- async cancelProductSubscription(sourceId, expectedSubjectAccountId) {
36
- await this.ctx.request("POST", "/billing/product-subscriptions/cancel", cancelProductSubscriptionSchema.parse({ sourceId, ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }), { cache: false });
44
+ async cancelProductSubscription(sourceId, expectedSubjectAccountId, actionId) {
45
+ await this.ctx.request("POST", "/billing/product-subscriptions/cancel", cancelProductSubscriptionSchema.parse({ sourceId, ...(actionId ? { actionId } : {}), ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }), { cache: false });
37
46
  }
38
47
  /** Provider acceptance versus completed local cancellation, for user-facing flows. */
39
- async cancelProductSubscriptionWithStatus(sourceId, expectedSubjectAccountId) {
40
- return productSubscriptionCancellationResultSchema.parse(await this.ctx.request('POST', '/billing/product-subscriptions/cancel', cancelProductSubscriptionSchema.parse({ sourceId, ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }), { cache: false }));
48
+ async cancelProductSubscriptionWithStatus(sourceId, expectedSubjectAccountId, actionId) {
49
+ return productSubscriptionCancellationResultSchema.parse(await this.ctx.request('POST', '/billing/product-subscriptions/cancel', cancelProductSubscriptionSchema.parse({ sourceId, ...(actionId ? { actionId } : {}), ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }), { cache: false }));
41
50
  }
42
51
  /** The signed-in user's payment history. Never cached. */
43
52
  async payments() {
@@ -73,8 +73,8 @@ export class UsersApi {
73
73
  return settled.flat();
74
74
  }
75
75
  /** The signed-in user. Cached 1 minute. */
76
- async me() {
77
- const user = await this.ctx.request('GET', '/users/me', undefined, { cache: true, cacheTTL: ME_TTL });
76
+ async me(options) {
77
+ const user = await this.ctx.request('GET', '/users/me', undefined, { cache: options?.cache ?? true, cacheTTL: ME_TTL });
78
78
  return normalizeUserIdentity(user);
79
79
  }
80
80
  /** A profile by username. Cached 5 minutes; `{ cache: false }` as in `get`. */
@@ -1,3 +1,4 @@
1
+ import { subjectProductAccessQuerySchema, subjectProductGrantSnapshotSchema } from '@oxy.so/contracts';
1
2
  /**
2
3
  * `OxyServer` — the Oxy client for a backend.
3
4
  *
@@ -275,6 +276,26 @@ export class OxyServer extends OxyServices {
275
276
  * request is on behalf of that user (the API checks the delegation grant).
276
277
  * Never cached unless `cache` says so. Rejects with `OxyApiError`.
277
278
  */
279
+ /** Validated request's user session only; never changes the shared client's token. */
280
+ async productGrantSnapshotForUser(query, accessToken) {
281
+ const parsed = subjectProductAccessQuerySchema.parse(query);
282
+ if (!accessToken || /[\r\n]/.test(accessToken))
283
+ throw new Error('A user session is required');
284
+ const answer = await this.request('GET', `/v1/products/${encodeURIComponent(parsed.productId)}/access/${encodeURIComponent(parsed.subjectAccountId)}/grants`, undefined, { cache: false, retry: false, headers: { Authorization: `Bearer ${accessToken}` } });
285
+ const snapshot = subjectProductGrantSnapshotSchema.parse(answer);
286
+ if (snapshot.access.subjectAccountId !== parsed.subjectAccountId || snapshot.access.productId !== parsed.productId)
287
+ throw new Error('Product grant response attribution differs');
288
+ return snapshot;
289
+ }
290
+ /** Offline metering read: the API requires this app's live consent for this exact subject. */
291
+ async productGrantSnapshotForService(query) {
292
+ const parsed = subjectProductAccessQuerySchema.parse(query);
293
+ const answer = await this.serviceRequest('GET', `/v1/products/${encodeURIComponent(parsed.productId)}/access/${encodeURIComponent(parsed.subjectAccountId)}/service-grants`, undefined, { cache: false, retry: false });
294
+ const snapshot = subjectProductGrantSnapshotSchema.parse(answer);
295
+ if (snapshot.access.subjectAccountId !== parsed.subjectAccountId || snapshot.access.productId !== parsed.productId)
296
+ throw new Error('Product grant response attribution differs');
297
+ return snapshot;
298
+ }
278
299
  async serviceRequest(method, url, data, options = {}) {
279
300
  const { actAs, headers, ...rest } = options;
280
301
  const token = await this.serviceToken();
@@ -1,5 +1,5 @@
1
1
  import type { OxyContext } from '../client/context';
2
- import type { AccountStorageUsageResponse, Asset, AssetDeleteSummary, AssetLink, AssetMetadata, AssetUploadInput, BatchFileAccessResponse, FileVisibility } from '../models/interfaces';
2
+ import type { AccountStorageUsageResponse, AssetInitResponse, Asset, AssetDeleteSummary, AssetLink, AssetMetadata, AssetUploadInput, BatchFileAccessResponse, FileVisibility } from '../models/interfaces';
3
3
  /**
4
4
  * How long a resolved asset URL may stay in the SDK's GET cache, in ms: never
5
5
  * longer than the media token's lifetime, and discounted below it.
@@ -27,6 +27,8 @@ export interface AssetUploadOptions {
27
27
  metadata?: AssetMetadata;
28
28
  /** Called with `100` once the upload completes. */
29
29
  onProgress?: (progress: number) => void;
30
+ /** Bound on the whole upload, in ms. Default {@link ASSET_UPLOAD_TIMEOUT_MS}. */
31
+ timeout?: number;
30
32
  }
31
33
  export interface AssetLinkTarget {
32
34
  app: string;
@@ -41,6 +43,8 @@ export interface AssetDeleteResult {
41
43
  export declare class AssetsApi {
42
44
  protected readonly ctx: OxyContext;
43
45
  constructor(ctx: OxyContext);
46
+ /** Upload already-admitted bytes. Sends required bucket headers, never an Oxy bearer. */
47
+ putPresignedUpload(init: AssetInitResponse, body: Blob): Promise<void>;
44
48
  /**
45
49
  * Upload a file. Accepts a web `File`/`Blob` or a React Native
46
50
  * `{ uri, type?, name?, size? }` descriptor.
@@ -633,14 +633,19 @@ export declare class AuthApi {
633
633
  readonly agent: AuthAgentApi;
634
634
  constructor(ctx: OxyContext);
635
635
  /**
636
- * Register a new identity with public key authentication
637
- * Identity is purely cryptographic - username and profile data are optional
636
+ * Register a new account for a public key, together with its username.
637
+ *
638
+ * The username is required: the server writes it in the same insert as the
639
+ * key, so an account never exists without one. A taken username is a 409
640
+ * (`Username already taken`), as is a key that is already registered
641
+ * (`Identity already registered`).
638
642
  *
639
643
  * @param publicKey - The user's ECDSA public key (hex)
640
644
  * @param signature - Signature of the registration request
641
645
  * @param timestamp - Timestamp when the signature was created
646
+ * @param username - The account's username
642
647
  */
643
- registerKey(publicKey: string, signature: string, timestamp: number): Promise<{
648
+ registerKey(publicKey: string, signature: string, timestamp: number, username: string): Promise<{
644
649
  message: string;
645
650
  user: User;
646
651
  }>;
@@ -1,3 +1,4 @@
1
+ import { type PersonalPlanCheckoutRequest, type PersonalPlanCheckoutResult } from '@oxy.so/contracts';
1
2
  /**
2
3
  * `oxy.billing` — the signed-in user's subscription, wallet and payment history.
3
4
  *
@@ -9,6 +10,7 @@
9
10
  * `payment.controller`, `wallet.routes` in `packages/api`); `Date` columns
10
11
  * arrive as ISO-8601 strings.
11
12
  */
13
+ import { type PersonalPlanCatalogue } from '@oxy.so/contracts';
12
14
  import { type SubjectProductAccess, type SubjectProductAccessQuery } from '@oxy.so/contracts';
13
15
  import { type ProductSubscriptionSummary, type SubscriptionCreditGrant, type ProductSubscriptionCancellationResult } from '@oxy.so/contracts';
14
16
  import type { OxyContext } from '../client/context';
@@ -99,16 +101,19 @@ export interface WalletTransactionsPage {
99
101
  export declare class BillingApi {
100
102
  protected readonly ctx: OxyContext;
101
103
  constructor(ctx: OxyContext);
104
+ startPersonalPlanCheckout(request: PersonalPlanCheckoutRequest): Promise<PersonalPlanCheckoutResult>;
105
+ /** Public versioned personal plans. Discovery never proves customer access. */
106
+ personalPlans(): Promise<PersonalPlanCatalogue>;
102
107
  /** Product rights only; requires a production application-bound user session. Never cached. */
103
108
  productAccess(query: SubjectProductAccessQuery): Promise<SubjectProductAccess>;
104
109
  /** Named commercial sources for the current subject; no inferred plan collapse. */
105
- productSubscriptions(): Promise<ProductSubscriptionSummary[]>;
110
+ productSubscriptions(expectedSubjectAccountId?: string): Promise<ProductSubscriptionSummary[]>;
106
111
  /** Per-grant credit provenance; no interpretation of the historical mixed balance. */
107
112
  creditGrants(): Promise<SubscriptionCreditGrant[]>;
108
113
  /** Payer-only named-source cancellation. Resolves on provider acceptance, including pending local reconciliation. Use cancelProductSubscriptionWithStatus for detailed completion state. */
109
- cancelProductSubscription(sourceId: string, expectedSubjectAccountId?: string): Promise<void>;
114
+ cancelProductSubscription(sourceId: string, expectedSubjectAccountId?: string, actionId?: string): Promise<void>;
110
115
  /** Provider acceptance versus completed local cancellation, for user-facing flows. */
111
- cancelProductSubscriptionWithStatus(sourceId: string, expectedSubjectAccountId?: string): Promise<ProductSubscriptionCancellationResult>;
116
+ cancelProductSubscriptionWithStatus(sourceId: string, expectedSubjectAccountId?: string, actionId?: string): Promise<ProductSubscriptionCancellationResult>;
112
117
  /** The signed-in user's payment history. Never cached. */
113
118
  payments(): Promise<Payment[]>;
114
119
  /** A user's subscription (default: the signed-in user's). */
@@ -61,7 +61,9 @@ export declare class UsersApi {
61
61
  */
62
62
  getMany(ids: string[]): Promise<User[]>;
63
63
  /** The signed-in user. Cached 1 minute. */
64
- me(): Promise<User>;
64
+ me(options?: {
65
+ cache?: boolean;
66
+ }): Promise<User>;
65
67
  /** A profile by username. Cached 5 minutes; `{ cache: false }` as in `get`. */
66
68
  byUsername(username: string, options?: {
67
69
  cache?: boolean;
@@ -200,6 +200,13 @@ export interface User {
200
200
  * user sets it. Updated via `updateThemePreference` / `updateProfile`.
201
201
  */
202
202
  themePreference?: ThemePreference;
203
+ /** Product-specific personalization; never inferred from generic premium. */
204
+ personalization?: {
205
+ mentionMono: {
206
+ allowed: boolean;
207
+ expiresAt: string | null;
208
+ };
209
+ };
203
210
  /**
204
211
  * The authenticated viewer's relationship to THIS profile. Populated ONLY on
205
212
  * single-profile fetches (`getProfileByUsername` / `getUserById`) when the
@@ -520,6 +527,8 @@ export interface AssetInitRequest {
520
527
  mime: string;
521
528
  }
522
529
  export interface AssetInitResponse {
530
+ /** Replay these headers on PUT; Content-Length is supplied by the transport. */
531
+ requiredHeaders?: Record<string, string>;
523
532
  uploadUrl: string;
524
533
  fileId: string;
525
534
  sha256: string;
@@ -694,6 +703,8 @@ export interface AccountStorageCategoryUsage {
694
703
  count: number;
695
704
  }
696
705
  export interface AccountStorageUsageResponse {
706
+ quotaEnforcement?: 'metadata_admission' | 'unconfigured';
707
+ reservedBytes?: string | null;
697
708
  plan: 'basic' | 'pro' | 'business';
698
709
  totalUsedBytes: number;
699
710
  totalLimitBytes: number;
@@ -1,3 +1,4 @@
1
+ import { type SubjectProductAccessQuery, type SubjectProductGrantSnapshot } from '@oxy.so/contracts';
1
2
  import { OxyServices, type OxyConfig } from '../OxyServices';
2
3
  import type { HttpMethod } from '../client/context';
3
4
  import type { RequestOptions } from '../HttpService';
@@ -132,6 +133,10 @@ export declare class OxyServer extends OxyServices {
132
133
  * request is on behalf of that user (the API checks the delegation grant).
133
134
  * Never cached unless `cache` says so. Rejects with `OxyApiError`.
134
135
  */
136
+ /** Validated request's user session only; never changes the shared client's token. */
137
+ productGrantSnapshotForUser(query: SubjectProductAccessQuery, accessToken: string): Promise<SubjectProductGrantSnapshot>;
138
+ /** Offline metering read: the API requires this app's live consent for this exact subject. */
139
+ productGrantSnapshotForService(query: SubjectProductAccessQuery): Promise<SubjectProductGrantSnapshot>;
135
140
  serviceRequest<T>(method: HttpMethod, url: string, data?: unknown, options?: RequestOptions & {
136
141
  actAs?: string;
137
142
  }): Promise<T>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "4.4.0",
3
+ "version": "4.5.0-oxy-one.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",
@@ -179,7 +179,7 @@
179
179
  "@oxy.so/telemetry": "^1.2.0",
180
180
  "@noble/ciphers": "^1.3.0",
181
181
  "@noble/hashes": "^1.8.0",
182
- "@oxy.so/contracts": "^4.9.1",
182
+ "@oxy.so/contracts": "4.11.0-oxy-one.0",
183
183
  "@oxy.so/protocol": "^1.2.2",
184
184
  "@scure/bip39": "^1.6.0",
185
185
  "buffer": "^6.0.3",
package/src/api/assets.ts CHANGED
@@ -18,6 +18,7 @@ import type { OxyContext } from '../client/context';
18
18
  import { logger } from '../logger';
19
19
  import type {
20
20
  AccountStorageUsageResponse,
21
+ AssetInitResponse,
21
22
  Asset,
22
23
  AssetDeleteSummary,
23
24
  AssetLink,
@@ -51,6 +52,13 @@ const DEFAULT_ASSET_URL_EXPIRES_IN_SECONDS = 3600;
51
52
 
52
53
  const ASSET_TTL = 5 * 60 * 1000;
53
54
 
55
+ /**
56
+ * Default bound on one `POST /assets/upload`, in ms. The client's per-request
57
+ * default (5s) is sized for JSON calls; a video sent over a phone connection
58
+ * needs minutes, and timing it out at 5s failed every upload past a few MB.
59
+ */
60
+ const ASSET_UPLOAD_TIMEOUT_MS = 10 * 60 * 1000;
61
+
54
62
  /**
55
63
  * How long a resolved asset URL may stay in the SDK's GET cache, in ms: never
56
64
  * longer than the media token's lifetime, and discounted below it.
@@ -84,6 +92,8 @@ export interface AssetUploadOptions {
84
92
  metadata?: AssetMetadata;
85
93
  /** Called with `100` once the upload completes. */
86
94
  onProgress?: (progress: number) => void;
95
+ /** Bound on the whole upload, in ms. Default {@link ASSET_UPLOAD_TIMEOUT_MS}. */
96
+ timeout?: number;
87
97
  }
88
98
 
89
99
  export interface AssetLinkTarget {
@@ -101,6 +111,21 @@ export interface AssetDeleteResult {
101
111
  export class AssetsApi {
102
112
  constructor(protected readonly ctx: OxyContext) {}
103
113
 
114
+ /** Upload already-admitted bytes. Sends required bucket headers, never an Oxy bearer. */
115
+ async putPresignedUpload(init: AssetInitResponse, body: Blob): Promise<void> {
116
+ if (!init.uploadUrl) return; // bytes already exist for this account
117
+ const target = new URL(init.uploadUrl);
118
+ if (target.protocol !== 'https:') throw new Error('Presigned uploads require HTTPS');
119
+ const headers = new Headers(init.requiredHeaders);
120
+ headers.forEach((_value,name) => {
121
+ if (!['if-none-match','content-type','x-amz-checksum-sha256','cache-control'].includes(name.toLowerCase()))
122
+ throw new Error('Unsupported required upload header');
123
+ });
124
+ if (body.type && !headers.has('Content-Type')) headers.set('Content-Type',body.type);
125
+ const result = await fetch(target.toString(),{method:'PUT',body,headers,credentials:'omit',redirect:'error'});
126
+ if(!result.ok)throw new Error(`Presigned upload failed (${result.status})`);
127
+ }
128
+
104
129
  // ── Upload ───────────────────────────────────────────────────────────────
105
130
 
106
131
  /**
@@ -144,7 +169,10 @@ export class AssetsApi {
144
169
  if (options.metadata) formData.append('metadata', JSON.stringify(options.metadata));
145
170
 
146
171
  try {
147
- const response = await this.ctx.request<{ file: UploadedAsset }>('POST', '/assets/upload', formData, { cache: false });
172
+ const response = await this.ctx.request<{ file: UploadedAsset }>('POST', '/assets/upload', formData, {
173
+ cache: false,
174
+ timeout: options.timeout ?? ASSET_UPLOAD_TIMEOUT_MS,
175
+ });
148
176
  options.onProgress?.(100);
149
177
  return response;
150
178
  } catch (error) {
package/src/api/auth.ts CHANGED
@@ -1208,17 +1208,23 @@ export class AuthApi {
1208
1208
  }
1209
1209
 
1210
1210
  /**
1211
- * Register a new identity with public key authentication
1212
- * Identity is purely cryptographic - username and profile data are optional
1213
- *
1211
+ * Register a new account for a public key, together with its username.
1212
+ *
1213
+ * The username is required: the server writes it in the same insert as the
1214
+ * key, so an account never exists without one. A taken username is a 409
1215
+ * (`Username already taken`), as is a key that is already registered
1216
+ * (`Identity already registered`).
1217
+ *
1214
1218
  * @param publicKey - The user's ECDSA public key (hex)
1215
1219
  * @param signature - Signature of the registration request
1216
1220
  * @param timestamp - Timestamp when the signature was created
1221
+ * @param username - The account's username
1217
1222
  */
1218
1223
  async registerKey(
1219
1224
  publicKey: string,
1220
1225
  signature: string,
1221
- timestamp: number
1226
+ timestamp: number,
1227
+ username: string
1222
1228
  ): Promise<{ message: string; user: User }> {
1223
1229
  // Advisory for now (server soft-enforces — see
1224
1230
  // `SessionController.register`), but solved unconditionally so every
@@ -1230,6 +1236,7 @@ export class AuthApi {
1230
1236
  publicKey,
1231
1237
  signature,
1232
1238
  timestamp,
1239
+ username,
1233
1240
  powNonce,
1234
1241
  }, { cache: false, skipAuth: true });
1235
1242
 
@@ -1,3 +1,4 @@
1
+ import { personalPlanCheckoutRequestSchema, personalPlanCheckoutResultSchema, type PersonalPlanCheckoutRequest, type PersonalPlanCheckoutResult } from '@oxy.so/contracts';
1
2
  /**
2
3
  * `oxy.billing` — the signed-in user's subscription, wallet and payment history.
3
4
  *
@@ -9,6 +10,7 @@
9
10
  * `payment.controller`, `wallet.routes` in `packages/api`); `Date` columns
10
11
  * arrive as ISO-8601 strings.
11
12
  */
13
+ import { personalPlanCatalogueSchema, type PersonalPlanCatalogue } from '@oxy.so/contracts';
12
14
  import { type SubjectProductAccess,
13
15
  type SubjectProductAccessQuery,
14
16
  subjectProductAccessQuerySchema, subjectProductAccessSchema, } from '@oxy.so/contracts';
@@ -115,6 +117,17 @@ export interface WalletTransactionsPage {
115
117
  export class BillingApi {
116
118
  constructor(protected readonly ctx: OxyContext) {}
117
119
 
120
+ async startPersonalPlanCheckout(request: PersonalPlanCheckoutRequest): Promise<PersonalPlanCheckoutResult> {
121
+ return personalPlanCheckoutResultSchema.parse(await this.ctx.request<unknown>('POST', '/billing/checkout/personal-plan',
122
+ personalPlanCheckoutRequestSchema.parse(request), { cache: false }));
123
+ }
124
+
125
+ /** Public versioned personal plans. Discovery never proves customer access. */
126
+ async personalPlans(): Promise<PersonalPlanCatalogue> {
127
+ return personalPlanCatalogueSchema.parse(await this.ctx.request<unknown>(
128
+ 'GET', '/billing/personal-plans', undefined, { cache: false }));
129
+ }
130
+
118
131
  /** Product rights only; requires a production application-bound user session. Never cached. */
119
132
  async productAccess(query: SubjectProductAccessQuery): Promise<SubjectProductAccess> {
120
133
  const parsed = subjectProductAccessQuerySchema.parse(query);
@@ -125,11 +138,11 @@ export class BillingApi {
125
138
  }
126
139
 
127
140
  /** Named commercial sources for the current subject; no inferred plan collapse. */
128
- async productSubscriptions(): Promise<ProductSubscriptionSummary[]> {
141
+ async productSubscriptions(expectedSubjectAccountId?: string): Promise<ProductSubscriptionSummary[]> {
129
142
  return productSubscriptionsResponseSchema.parse(
130
143
  await this.ctx.request<unknown>(
131
144
  "GET",
132
- "/billing/product-subscriptions",
145
+ expectedSubjectAccountId ? `/billing/product-subscriptions?expectedSubjectAccountId=${encodeURIComponent(expectedSubjectAccountId)}` : "/billing/product-subscriptions",
133
146
  undefined,
134
147
  { cache: false },
135
148
  ),
@@ -149,20 +162,20 @@ export class BillingApi {
149
162
  }
150
163
 
151
164
  /** Payer-only named-source cancellation. Resolves on provider acceptance, including pending local reconciliation. Use cancelProductSubscriptionWithStatus for detailed completion state. */
152
- async cancelProductSubscription(sourceId: string, expectedSubjectAccountId?: string): Promise<void> {
165
+ async cancelProductSubscription(sourceId: string, expectedSubjectAccountId?: string, actionId?:string): Promise<void> {
153
166
  await this.ctx.request(
154
167
  "POST",
155
168
  "/billing/product-subscriptions/cancel",
156
- cancelProductSubscriptionSchema.parse({ sourceId, ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }),
169
+ cancelProductSubscriptionSchema.parse({ sourceId, ...(actionId?{actionId}:{}), ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }),
157
170
  { cache: false },
158
171
  );
159
172
  }
160
173
 
161
174
  /** Provider acceptance versus completed local cancellation, for user-facing flows. */
162
- async cancelProductSubscriptionWithStatus(sourceId: string, expectedSubjectAccountId?: string): Promise<ProductSubscriptionCancellationResult> {
175
+ async cancelProductSubscriptionWithStatus(sourceId: string, expectedSubjectAccountId?: string, actionId?:string): Promise<ProductSubscriptionCancellationResult> {
163
176
  return productSubscriptionCancellationResultSchema.parse(await this.ctx.request<unknown>(
164
177
  'POST', '/billing/product-subscriptions/cancel',
165
- cancelProductSubscriptionSchema.parse({ sourceId, ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }),
178
+ cancelProductSubscriptionSchema.parse({ sourceId, ...(actionId?{actionId}:{}), ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }),
166
179
  { cache: false },
167
180
  ));
168
181
  }
package/src/api/users.ts CHANGED
@@ -121,8 +121,8 @@ export class UsersApi {
121
121
  }
122
122
 
123
123
  /** The signed-in user. Cached 1 minute. */
124
- async me(): Promise<User> {
125
- const user = await this.ctx.request<User>('GET', '/users/me', undefined, { cache: true, cacheTTL: ME_TTL });
124
+ async me(options?: {cache?: boolean}): Promise<User> {
125
+ const user = await this.ctx.request<User>('GET', '/users/me', undefined, { cache: options?.cache ?? true, cacheTTL: ME_TTL });
126
126
  return normalizeUserIdentity(user);
127
127
  }
128
128
 
@@ -224,6 +224,8 @@ export interface User {
224
224
  * user sets it. Updated via `updateThemePreference` / `updateProfile`.
225
225
  */
226
226
  themePreference?: ThemePreference;
227
+ /** Product-specific personalization; never inferred from generic premium. */
228
+ personalization?: {mentionMono:{allowed:boolean;expiresAt:string|null}};
227
229
  /**
228
230
  * The authenticated viewer's relationship to THIS profile. Populated ONLY on
229
231
  * single-profile fetches (`getProfileByUsername` / `getUserById`) when the
@@ -581,6 +583,8 @@ export interface AssetInitRequest {
581
583
  }
582
584
 
583
585
  export interface AssetInitResponse {
586
+ /** Replay these headers on PUT; Content-Length is supplied by the transport. */
587
+ requiredHeaders?: Record<string, string>;
584
588
  uploadUrl: string;
585
589
  fileId: string;
586
590
  sha256: string;
@@ -769,6 +773,8 @@ export interface AccountStorageCategoryUsage {
769
773
  }
770
774
 
771
775
  export interface AccountStorageUsageResponse {
776
+ quotaEnforcement?: 'metadata_admission' | 'unconfigured';
777
+ reservedBytes?: string | null;
772
778
  plan: 'basic' | 'pro' | 'business';
773
779
  totalUsedBytes: number;
774
780
  totalLimitBytes: number;
@@ -1,3 +1,4 @@
1
+ import { subjectProductAccessQuerySchema, subjectProductGrantSnapshotSchema, type SubjectProductAccessQuery, type SubjectProductGrantSnapshot } from '@oxy.so/contracts';
1
2
  /**
2
3
  * `OxyServer` — the Oxy client for a backend.
3
4
  *
@@ -362,6 +363,31 @@ export class OxyServer extends OxyServices {
362
363
  * request is on behalf of that user (the API checks the delegation grant).
363
364
  * Never cached unless `cache` says so. Rejects with `OxyApiError`.
364
365
  */
366
+ /** Validated request's user session only; never changes the shared client's token. */
367
+ async productGrantSnapshotForUser(query: SubjectProductAccessQuery, accessToken: string): Promise<SubjectProductGrantSnapshot> {
368
+ const parsed = subjectProductAccessQuerySchema.parse(query);
369
+ if (!accessToken || /[\r\n]/.test(accessToken)) throw new Error('A user session is required');
370
+ const answer = await this.request<unknown>('GET',
371
+ `/v1/products/${encodeURIComponent(parsed.productId)}/access/${encodeURIComponent(parsed.subjectAccountId)}/grants`,
372
+ undefined, { cache: false, retry: false, headers: { Authorization: `Bearer ${accessToken}` } });
373
+ const snapshot = subjectProductGrantSnapshotSchema.parse(answer);
374
+ if(snapshot.access.subjectAccountId !== parsed.subjectAccountId || snapshot.access.productId !== parsed.productId)
375
+ throw new Error('Product grant response attribution differs');
376
+ return snapshot;
377
+ }
378
+
379
+ /** Offline metering read: the API requires this app's live consent for this exact subject. */
380
+ async productGrantSnapshotForService(query: SubjectProductAccessQuery): Promise<SubjectProductGrantSnapshot> {
381
+ const parsed = subjectProductAccessQuerySchema.parse(query);
382
+ const answer = await this.serviceRequest<unknown>('GET',
383
+ `/v1/products/${encodeURIComponent(parsed.productId)}/access/${encodeURIComponent(parsed.subjectAccountId)}/service-grants`,
384
+ undefined, { cache: false, retry: false });
385
+ const snapshot = subjectProductGrantSnapshotSchema.parse(answer);
386
+ if (snapshot.access.subjectAccountId !== parsed.subjectAccountId || snapshot.access.productId !== parsed.productId)
387
+ throw new Error('Product grant response attribution differs');
388
+ return snapshot;
389
+ }
390
+
365
391
  async serviceRequest<T>(
366
392
  method: HttpMethod,
367
393
  url: string,