@shiprev/server-sdk-node 0.1.0-beta.1 → 0.1.0-beta.2

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/README.md CHANGED
@@ -181,9 +181,9 @@ A `Ruling` is `{ allowed, source, reason?, remaining?, limit?, adjudicated_at }`
181
181
  | `degraded` | Offline, ruled from the last snapshot |
182
182
  | `client_default` | No basis for a ruling: invalid request, cold start offline, or online with nothing in the mirror |
183
183
 
184
- Online rulings from the mirror compare `quantity` exactly against the remaining allowance, which the snapshot carries as fixed-point decimal strings. Beyond the remaining allowance nothing is allowed locally, whatever the overage policy: whether credits (and, for `ALLOW_AND_BILL`, the wallet's overdraft limit) cover the write is state of the instance, so the local answer is a refusal with `reason: 'quota_exhausted'`, which `adjudicateAsync()` turns into a question to the instance and returns its ruling. When no ruling can be had the write stays refused. A capability the mirror does not hold is refused with `reason: 'capability_not_granted'` (which `adjudicateAsync()` turns into a question to the instance).
184
+ Online rulings from the mirror compare `quantity` exactly against the remaining allowance, which the snapshot carries as fixed-point decimal strings. Beyond the remaining allowance nothing is allowed locally, whatever the overage policy: whether credits cover the write is state of the instance, so the local answer is a refusal with `reason: 'quota_exhausted'` (`'plan_upgrade_required'` when the capability's overage policy is `PROMPT_UPGRADE`; the instance's ruling uses the same two reasons), which `adjudicateAsync()` turns into a question to the instance and returns its ruling. When no ruling can be had the write stays refused. A capability the mirror does not hold is refused with `reason: 'capability_not_granted'` (which `adjudicateAsync()` turns into a question to the instance).
185
185
 
186
- `guardWrite(options, action)` runs `action` only when a `write` ruling allows it, and otherwise throws `DisconnectedWriteError`.
186
+ `guardWrite(options, action)` runs `action` only when a `write` ruling allows it. A refusal with `reason: 'quota_exhausted'` throws `QuotaExhaustedError`, one with `reason: 'plan_upgrade_required'` throws `PlanUpgradeRequiredError`, and any other refusal (disconnected, a capability the mirror does not hold) throws `DisconnectedWriteError`. Both funds errors carry `code` (`'QUOTA_EXHAUSTED'`, `'PLAN_UPGRADE_REQUIRED'`).
187
187
 
188
188
  ## Usage reporting
189
189
 
@@ -203,7 +203,7 @@ Before sending the buffer, each flush first drains what is waiting in the backlo
203
203
 
204
204
  `await flush()` (and `await stop()`) resolves only when every event reported before the call has been accepted, refused (`on_rejected`), or moved to the backlog because it could not be delivered (offline, no service token, or a failed request; `on_flush_failure` is called for a failed buffer batch). If another flush is in flight, for example the one that follows a reconnection, the call waits for it and then makes its own pass. A flush started by `flush()` sends the whole backlog, batch by batch, stopping at the first failed batch; a timer tick skips while a flush is in flight and sends at most one backlog batch.
205
205
 
206
- For work whose cost is only known afterwards, reserve first and settle later: `reserve({ organization_id, capability_key, amount, ttl_seconds? })`, then `confirm(reservationId, actualAmount?)` (without an amount, the reserved amount is confirmed) or `release(reservationId)`. Reservations need a service token, which `start()` obtains; without one they throw.
206
+ For work whose cost is only known afterwards, reserve first and settle later: `reserve({ organization_id, capability_key, amount, ttl_seconds? })`, then `confirm(reservationId, actualAmount?)` (without an amount, the reserved amount is confirmed) or `release(reservationId)`. Reservations need a service token, which `start()` obtains; without one they throw. A reservation refused for funds is HTTP `400` with `error.code` `QUOTA_EXHAUSTED` (credits short: offer a top-up) or `PLAN_UPGRADE_REQUIRED` (`PROMPT_UPGRADE` policy: offer a larger plan); `reserve()` throws `QuotaExhaustedError` or `PlanUpgradeRequiredError` accordingly, chosen by the code and never by the message, with `code`, `status` and `request_id`. It is not retried. Any other refusal throws as before.
207
207
 
208
208
  ## Connection
209
209
 
@@ -83,8 +83,10 @@ export declare class ShipRevClient {
83
83
  */
84
84
  sendUsage(events: UsageSendEvent[]): Promise<UsageSendResult>;
85
85
  /**
86
- * Guard for write operations (fail-closed).
87
- * Intercepts action if write operation is denied (e.g. disconnected).
86
+ * Guard for write operations (fail-closed): runs `action` only when the local ruling allows the write.
87
+ * A refusal for funds throws `QuotaExhaustedError` (`quota_exhausted`) or `PlanUpgradeRequiredError`
88
+ * (`plan_upgrade_required`, PROMPT_UPGRADE); any other refusal, a disconnected write included, throws
89
+ * `DisconnectedWriteError` as before.
88
90
  */
89
91
  guardWrite<T>(options: Omit<AdjudicateOptions, 'operation'>, action: () => Promise<T> | T): Promise<T>;
90
92
  /**
@@ -127,7 +127,8 @@ class ShipRevClient {
127
127
  // Two cases are not decided by the mirror, and the service is asked for an authoritative ruling:
128
128
  // the mirror misses the organization or capability, or the write goes beyond the allowance the mirror
129
129
  // counts (credits and overdraft are the service's state). The mirror's refusal stands when no ruling comes.
130
- const beyondAllowance = ruling.reason === 'quota_exhausted' && ruling.source === 'realtime';
130
+ const beyondAllowance = (ruling.reason === 'quota_exhausted' || ruling.reason === 'plan_upgrade_required') &&
131
+ ruling.source === 'realtime';
131
132
  if (this.supervisor.currentToken() && (ruling.reason === 'capability_not_granted' || beyondAllowance)) {
132
133
  try {
133
134
  const remote = await this.supervisor.authorized((token) => this.transport.lookupAdjudication({
@@ -215,12 +216,18 @@ class ShipRevClient {
215
216
  }
216
217
  }
217
218
  /**
218
- * Guard for write operations (fail-closed).
219
- * Intercepts action if write operation is denied (e.g. disconnected).
219
+ * Guard for write operations (fail-closed): runs `action` only when the local ruling allows the write.
220
+ * A refusal for funds throws `QuotaExhaustedError` (`quota_exhausted`) or `PlanUpgradeRequiredError`
221
+ * (`plan_upgrade_required`, PROMPT_UPGRADE); any other refusal, a disconnected write included, throws
222
+ * `DisconnectedWriteError` as before.
220
223
  */
221
224
  async guardWrite(options, action) {
222
225
  const ruling = this.adjudicate({ ...options, operation: 'write' });
223
226
  if (!ruling.allowed) {
227
+ if (ruling.reason === 'quota_exhausted')
228
+ throw new types_1.QuotaExhaustedError();
229
+ if (ruling.reason === 'plan_upgrade_required')
230
+ throw new types_1.PlanUpgradeRequiredError();
224
231
  throw new types_1.DisconnectedWriteError(ruling.reason === 'disconnected_write'
225
232
  ? 'Write operation denied while client is disconnected'
226
233
  : `Write operation denied: ${ruling.reason ?? 'not allowed'}`);
@@ -94,7 +94,8 @@ class Adjudicator {
94
94
  remaining: (0, decimal_1.decimalToNumber)(item.remaining),
95
95
  limit,
96
96
  source: 'realtime',
97
- reason: 'quota_exhausted',
97
+ // Under PROMPT_UPGRADE the way on is a larger plan, not more credits; the service rules the same way.
98
+ reason: item.overage_policy === 'PROMPT_UPGRADE' ? 'plan_upgrade_required' : 'quota_exhausted',
98
99
  adjudicated_at: now,
99
100
  };
100
101
  }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HttpTransport = exports.HttpError = void 0;
4
4
  exports.isHttpStatus = isHttpStatus;
5
+ const types_1 = require("../types");
5
6
  const decimal_1 = require("../util/decimal");
6
7
  /** A non-success HTTP answer from the instance; `status` lets callers tell a refused token from an outage. */
7
8
  class HttpError extends Error {
@@ -17,6 +18,25 @@ exports.HttpError = HttpError;
17
18
  function isHttpStatus(error, ...statuses) {
18
19
  return error instanceof HttpError && statuses.includes(error.status);
19
20
  }
21
+ /**
22
+ * The typed error of a refusal for funds, read from `error.code` of the instance's error envelope; undefined for any
23
+ * other answer. Only the code decides: the message text is never parsed for meaning.
24
+ */
25
+ function fundsRefusal(errorText, status, message) {
26
+ let error;
27
+ try {
28
+ error = JSON.parse(errorText)?.error;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ const details = { status, request_id: typeof error?.request_id === 'string' ? error.request_id : undefined };
34
+ if (error?.code === 'QUOTA_EXHAUSTED')
35
+ return new types_1.QuotaExhaustedError(message, details);
36
+ if (error?.code === 'PLAN_UPGRADE_REQUIRED')
37
+ return new types_1.PlanUpgradeRequiredError(message, details);
38
+ return undefined;
39
+ }
20
40
  class HttpTransport {
21
41
  baseUrl;
22
42
  timeoutMs;
@@ -205,7 +225,8 @@ class HttpTransport {
205
225
  });
206
226
  if (!response.ok) {
207
227
  const errorText = await response.text();
208
- throw new HttpError(`${failure}: HTTP ${response.status} - ${errorText}`, response.status);
228
+ const message = `${failure}: HTTP ${response.status} - ${errorText}`;
229
+ throw fundsRefusal(errorText, response.status, message) ?? new HttpError(message, response.status);
209
230
  }
210
231
  return (await response.json());
211
232
  }
package/dist/types.d.ts CHANGED
@@ -4,7 +4,13 @@ export interface Ruling {
4
4
  remaining?: number;
5
5
  limit?: number;
6
6
  source: RulingSource;
7
- reason?: 'disconnected_write' | 'invalid_request' | string;
7
+ /**
8
+ * Why the call was refused. `quota_exhausted`: the allowance is used up and the credits do not cover the overage
9
+ * (BLOCK, ALLOW_AND_BILL). `plan_upgrade_required`: the same under the PROMPT_UPGRADE policy, where the way on
10
+ * is a larger plan. Other values: `capability_not_found`, `capability_not_granted`, `disconnected_write`,
11
+ * `invalid_request`.
12
+ */
13
+ reason?: 'quota_exhausted' | 'plan_upgrade_required' | 'disconnected_write' | 'invalid_request' | string;
8
14
  adjudicated_at: number;
9
15
  }
10
16
  export interface AdjudicateOptions {
@@ -55,6 +61,11 @@ export interface OrganizationSnapshot {
55
61
  * a local ruling with it; beyond the allowance the service decides. Absent on instances that predate it.
56
62
  */
57
63
  overdraft_limit?: string;
64
+ /**
65
+ * Credits that could still be spent when the entry was read: `wallet_balance` plus the overdraft limit, minus
66
+ * the credits held by open reservations. Informational, like `overdraft_limit`. Absent on instances that predate it.
67
+ */
68
+ available_credits?: string;
58
69
  items: Record<string, SnapshotItem>;
59
70
  /**
60
71
  * Degradation level the instance computed for the organization. Instances that predate it leave it out; a
@@ -208,6 +219,38 @@ export declare class DisconnectedWriteError extends Error {
208
219
  readonly reason = "disconnected_write";
209
220
  constructor(message?: string);
210
221
  }
222
+ /** The `error.code` values of a refusal for funds, as the instance sends them and the two error types carry them. */
223
+ export type FundsRefusalCode = 'QUOTA_EXHAUSTED' | 'PLAN_UPGRADE_REQUIRED';
224
+ /** Where a funds refusal came from: the instance's HTTP answer (`status`, `request_id`) or a local ruling. */
225
+ export interface FundsRefusalDetails {
226
+ /** HTTP status of the instance's answer (400); absent when the refusal is a local ruling. */
227
+ status?: number;
228
+ /** The instance's request id, for support; absent when the refusal is a local ruling. */
229
+ request_id?: string;
230
+ }
231
+ /**
232
+ * The allowance is used up and the credits do not cover the overage: the buyer can top up (BLOCK, ALLOW_AND_BILL
233
+ * past the overdraft limit, credits held by other reservations, or an overage that is not for sale). Thrown by
234
+ * `reserve()` when the instance answers `code: 'QUOTA_EXHAUSTED'`, and by `guardWrite()` when the local ruling is
235
+ * `quota_exhausted`. The type follows the code, never the message.
236
+ */
237
+ export declare class QuotaExhaustedError extends Error {
238
+ readonly code: "QUOTA_EXHAUSTED";
239
+ readonly status?: number;
240
+ readonly request_id?: string;
241
+ constructor(message?: string, details?: FundsRefusalDetails);
242
+ }
243
+ /**
244
+ * The capability's policy is PROMPT_UPGRADE and the allowance is used up: the buyer has to move to a larger plan.
245
+ * Thrown by `reserve()` when the instance answers `code: 'PLAN_UPGRADE_REQUIRED'`, and by `guardWrite()` when the
246
+ * local ruling is `plan_upgrade_required`. The type follows the code, never the message.
247
+ */
248
+ export declare class PlanUpgradeRequiredError extends Error {
249
+ readonly code: "PLAN_UPGRADE_REQUIRED";
250
+ readonly status?: number;
251
+ readonly request_id?: string;
252
+ constructor(message?: string, details?: FundsRefusalDetails);
253
+ }
211
254
  /**
212
255
  * Thrown by `sendUsage()` when the batch did not reach the instance or the instance failed to process it (a
213
256
  * network error, a timeout or a 5xx answer). Nothing in the batch can be assumed delivered; sending it again is
package/dist/types.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.TokenRejectedError = exports.UsageDeliveryError = exports.DisconnectedWriteError = void 0;
3
+ exports.TokenRejectedError = exports.UsageDeliveryError = exports.PlanUpgradeRequiredError = exports.QuotaExhaustedError = exports.DisconnectedWriteError = void 0;
4
4
  class DisconnectedWriteError extends Error {
5
5
  reason = 'disconnected_write';
6
6
  constructor(message = 'Write operation denied while client is disconnected') {
@@ -9,6 +9,41 @@ class DisconnectedWriteError extends Error {
9
9
  }
10
10
  }
11
11
  exports.DisconnectedWriteError = DisconnectedWriteError;
12
+ /**
13
+ * The allowance is used up and the credits do not cover the overage: the buyer can top up (BLOCK, ALLOW_AND_BILL
14
+ * past the overdraft limit, credits held by other reservations, or an overage that is not for sale). Thrown by
15
+ * `reserve()` when the instance answers `code: 'QUOTA_EXHAUSTED'`, and by `guardWrite()` when the local ruling is
16
+ * `quota_exhausted`. The type follows the code, never the message.
17
+ */
18
+ class QuotaExhaustedError extends Error {
19
+ code = 'QUOTA_EXHAUSTED';
20
+ status;
21
+ request_id;
22
+ constructor(message = 'The allowance is used up and the credits do not cover the overage', details = {}) {
23
+ super(message);
24
+ this.name = 'QuotaExhaustedError';
25
+ this.status = details.status;
26
+ this.request_id = details.request_id;
27
+ }
28
+ }
29
+ exports.QuotaExhaustedError = QuotaExhaustedError;
30
+ /**
31
+ * The capability's policy is PROMPT_UPGRADE and the allowance is used up: the buyer has to move to a larger plan.
32
+ * Thrown by `reserve()` when the instance answers `code: 'PLAN_UPGRADE_REQUIRED'`, and by `guardWrite()` when the
33
+ * local ruling is `plan_upgrade_required`. The type follows the code, never the message.
34
+ */
35
+ class PlanUpgradeRequiredError extends Error {
36
+ code = 'PLAN_UPGRADE_REQUIRED';
37
+ status;
38
+ request_id;
39
+ constructor(message = 'The allowance is used up; the plan has to be upgraded', details = {}) {
40
+ super(message);
41
+ this.name = 'PlanUpgradeRequiredError';
42
+ this.status = details.status;
43
+ this.request_id = details.request_id;
44
+ }
45
+ }
46
+ exports.PlanUpgradeRequiredError = PlanUpgradeRequiredError;
12
47
  /**
13
48
  * Thrown by `sendUsage()` when the batch did not reach the instance or the instance failed to process it (a
14
49
  * network error, a timeout or a 5xx answer). Nothing in the batch can be assumed delivered; sending it again is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shiprev/server-sdk-node",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.1.0-beta.2",
4
4
  "description": "Server SDK for Node.js and NestJS services: token verification, local entitlement rulings, usage reporting, and two-phase reservations against a ShipRev instance.",
5
5
  "keywords": [
6
6
  "shiprev",