@cosmoops/connect-sdk 0.33.0 → 0.34.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 (35) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/README.md +17 -5
  3. package/dist/cjs/client/components/access-gate.js +98 -2
  4. package/dist/cjs/client/components/subscription.js +6 -0
  5. package/dist/cjs/client/provider.js +5 -2
  6. package/dist/cjs/client/use-checkout.js +3 -1
  7. package/dist/cjs/helpers.js +46 -0
  8. package/dist/cjs/index.js +5 -1
  9. package/dist/cjs/next/index.js +10 -3
  10. package/dist/cjs/server/client.js +1 -1
  11. package/dist/cjs/server/handler.js +162 -28
  12. package/dist/cjs/server/resources/billing.js +5 -0
  13. package/dist/cjs/server/resources/entitlements.js +47 -0
  14. package/dist/esm/client/components/access-gate.js +99 -3
  15. package/dist/esm/client/components/subscription.js +6 -0
  16. package/dist/esm/client/provider.js +5 -2
  17. package/dist/esm/client/use-checkout.js +3 -1
  18. package/dist/esm/helpers.js +43 -0
  19. package/dist/esm/index.js +1 -0
  20. package/dist/esm/next/index.js +10 -3
  21. package/dist/esm/server/client.js +1 -1
  22. package/dist/esm/server/handler.js +162 -28
  23. package/dist/esm/server/resources/billing.js +5 -0
  24. package/dist/esm/server/resources/entitlements.js +47 -0
  25. package/dist/types/client/components/access-gate.d.ts +9 -0
  26. package/dist/types/client/components/subscription.d.ts +6 -0
  27. package/dist/types/client/provider.d.ts +155 -51
  28. package/dist/types/client/use-checkout.d.ts +18 -1
  29. package/dist/types/helpers.d.ts +36 -1
  30. package/dist/types/index.d.ts +1 -0
  31. package/dist/types/server/client.d.ts +1 -1
  32. package/dist/types/server/resources/billing.d.ts +4 -0
  33. package/dist/types/server/resources/entitlements.d.ts +40 -1
  34. package/dist/types/types.d.ts +164 -0
  35. package/package.json +1 -1
@@ -77,6 +77,23 @@ export interface CheckoutUrlOptions {
77
77
  subscriptionId?: string | null;
78
78
  /** Carried into Connect's checkout, where it is validated against the real cart. */
79
79
  coupon?: string | null;
80
+ /**
81
+ * Where Connect sends the customer once the subscription is live - an
82
+ * absolute URL on your own origin.
83
+ *
84
+ * **Without it they do not come back.** Connect's default destination is
85
+ * the new subscription's own billing page, which is the right answer for
86
+ * someone who started in Connect and the wrong one for someone who started
87
+ * on your pricing page: they pay, land on a billing screen for a product
88
+ * they were in the middle of adopting, and nothing on it points home.
89
+ *
90
+ * Connect validates the origin against the redirect URIs registered for
91
+ * your Connect client and silently ignores anything else, so a value it
92
+ * does not recognise costs the return trip and never redirects your
93
+ * customer somewhere you did not register. Register the origin you intend
94
+ * to use; the path and query are yours.
95
+ */
96
+ next?: string | null;
80
97
  }
81
98
  /**
82
99
  * The Connect URL that cancels a subscription.
@@ -103,4 +120,4 @@ export declare function buildCancelUrl({ appUrl, subscriptionId }: {
103
120
  * prefix that has never served this page. Building the other one is what
104
121
  * sent every first-time subscriber from a partner's pricing page to a 404.
105
122
  */
106
- export declare function buildCheckoutUrl({ appUrl, applicationId, planId, subscriptionId, coupon, }: CheckoutUrlOptions): string;
123
+ export declare function buildCheckoutUrl({ appUrl, applicationId, planId, subscriptionId, coupon, next, }: CheckoutUrlOptions): string;
@@ -1,4 +1,4 @@
1
- import type { PlanFeature, ReadinessBlocker, ReadinessResponse } from "./types.js";
1
+ import type { PlanFeature, PlanLimit, ReadinessBlocker, ReadinessResponse } from "./types.js";
2
2
  /**
3
3
  * The blockers that actually deny access, in the order to show them.
4
4
  *
@@ -31,3 +31,38 @@ export type WirePlanFeature = string | PlanFeature;
31
31
  * hand-rolled a bridge instead of using `createConnectHandler`.
32
32
  */
33
33
  export declare function normalizePlanFeatures(features: readonly WirePlanFeature[] | undefined): PlanFeature[];
34
+ /**
35
+ * What a key the plan carries no answer for reads as - see
36
+ * `SubscribedPlan.limits`, which is sparse.
37
+ *
38
+ * `"none"` rather than `"unlimited"`: a plan that was never asked about a
39
+ * limit has not sold it, and defaulting the other way would hand an unbounded
40
+ * allowance to every tier that predates the question.
41
+ */
42
+ export declare const NO_PLAN_LIMIT: PlanLimit;
43
+ /**
44
+ * One plan's answer for one limit key, with the sparse-map default applied.
45
+ *
46
+ * Indexing `plan.limits` directly is what makes a missing key `undefined`,
47
+ * which then reads as unlimited through the first `?.` somebody writes. This
48
+ * returns a `PlanLimit` either way.
49
+ */
50
+ export declare function planLimitFor(limits: Readonly<Record<string, PlanLimit>> | undefined, key: string): PlanLimit;
51
+ /**
52
+ * Whether `additional` more of the resource fit under this limit, given how
53
+ * many the subscriber already has. The comparison, written once.
54
+ *
55
+ * Every way of writing it at a call site is wrong in a different direction:
56
+ * `count < limit.value` is off by one as soon as you add more than one row,
57
+ * `if (!limit.value)` refuses the unlimited tier, and
58
+ * `limit.value ?? Infinity` grants unlimited to a plan that allows none. This
59
+ * is the same implementation Connect's own server uses to validate a plan.
60
+ *
61
+ * ```ts
62
+ * const limit = await connect.entitlements.getLimit(workspaceId, "sites");
63
+ * if (!planLimitAllows(limit, await countSites(workspaceId))) {
64
+ * throw new Error("Your plan's site limit is reached.");
65
+ * }
66
+ * ```
67
+ */
68
+ export declare function planLimitAllows(limit: PlanLimit, currentCount: number, additional?: number): boolean;
@@ -15,6 +15,7 @@
15
15
  */
16
16
  export { ConnectError, isConnectError } from "./errors.js";
17
17
  export { blockingBlockers, normalizePlanFeatures } from "./helpers.js";
18
+ export { NO_PLAN_LIMIT, planLimitAllows, planLimitFor } from "./helpers.js";
18
19
  export { arrayRemove, arrayUnion, deleteField, documentExists, increment, serverTimestamp, storeIndexFor, storeIndexSignature, unchangedSince, } from "./store.js";
19
20
  export type { StoreIndexDirection, StoreIndexField } from "./store.js";
20
21
  export { describeQueryRejection, storeIndexCovers } from "./store-schema.js";
@@ -138,4 +138,4 @@ export declare class ConnectClient {
138
138
  constructor(options: ConnectClientOptions);
139
139
  }
140
140
  /** Kept in step with `package.json` by `sdk.test.ts`. */
141
- export declare const SDK_VERSION = "0.32.0";
141
+ export declare const SDK_VERSION = "0.34.0";
@@ -12,6 +12,10 @@ export declare class BillingResource {
12
12
  * independently of the instance it talks to. Without the coercion a partner
13
13
  * pointed at a Connect that has not upgraded yet renders a feature list of
14
14
  * blanks - which is exactly what happened. See `normalizePlanFeatures`.
15
+ *
16
+ * `limits` is defaulted for the same reason, one version later: an instance
17
+ * that predates limits sends no such field, and a pricing grid mapping over
18
+ * `plan.limits` would throw rather than simply show no limit rows.
15
19
  */
16
20
  listPlans(): Promise<PlansResponse>;
17
21
  /**
@@ -1,5 +1,5 @@
1
1
  import { type TransportOptions } from "../http.js";
2
- import type { ApplicationSubscriber, ReadinessResponse, SubscribersResponse, SubscriptionResponse } from "../../types.js";
2
+ import type { ApplicationSubscriber, PlanLimit, ReadinessResponse, SubscribersResponse, SubscriptionResponse } from "../../types.js";
3
3
  /**
4
4
  * The two checks worth making on every request: may this workspace be here at
5
5
  * all, and what is it paying for.
@@ -28,6 +28,45 @@ export declare class EntitlementsResource {
28
28
  * should not need a try/catch around it.
29
29
  */
30
30
  hasFeature(workspaceId: string, feature: string): Promise<boolean>;
31
+ /**
32
+ * How much of a metered resource this workspace may have - the numeric
33
+ * counterpart of `hasFeature`, for the resources your application counts
34
+ * itself (sites, projects, pipelines).
35
+ *
36
+ * **This is the effective ceiling, not the plan's own number.** Connect has
37
+ * already folded in whatever the add-ons on the subscription raise it by, so
38
+ * "Starter includes 1 site, they bought two 5-site packs" arrives here as
39
+ * `{ model: "limited", value: 11 }`. You never join the plan against the
40
+ * add-on list yourself, and a customer who buys another pack needs no change
41
+ * on your side.
42
+ *
43
+ * Returns `{ model: "none" }` rather than throwing when there is no active
44
+ * subscription, when the plan carries no answer for this key, and when the
45
+ * Connect instance predates limits entirely - the same reasoning
46
+ * `hasFeature` gives for answering `false`: "not entitled" is the same answer
47
+ * either way, and a limit check should not need a try/catch around it.
48
+ *
49
+ * Connect does not enforce this. It cannot see what you are counting, so it
50
+ * hands you the number and your code refuses the next row - `canAdd` is that
51
+ * comparison if you already have the count.
52
+ */
53
+ getLimit(workspaceId: string, key: string): Promise<PlanLimit>;
54
+ /**
55
+ * Whether this workspace may add `additional` more of a limited resource,
56
+ * given how many it already has.
57
+ *
58
+ * You supply the count, because only your application can produce it. This
59
+ * is `getLimit` plus `planLimitAllows`, which exists so the comparison is
60
+ * not rewritten per call site - see that helper for the three ways of
61
+ * getting it wrong.
62
+ *
63
+ * ```ts
64
+ * if (!(await connect.entitlements.canAdd(workspaceId, "sites", sites.length))) {
65
+ * return refuse("Your plan's site limit is reached.");
66
+ * }
67
+ * ```
68
+ */
69
+ canAdd(workspaceId: string, key: string, currentCount: number, additional?: number): Promise<boolean>;
31
70
  /**
32
71
  * One page of the workspaces subscribed to your application, newest first.
33
72
  *
@@ -292,6 +292,57 @@ export interface PlanFeature {
292
292
  /** Empty string when the catalog carries no description. */
293
293
  description: string;
294
294
  }
295
+ /**
296
+ * How much of one metered resource a plan allows, as the **catalogue** read
297
+ * reports it: the application's own declaration (`key`, `name`, `unit`) joined
298
+ * to this plan's answer.
299
+ *
300
+ * The limits counterpart of `PlanFeature`, and it carries the same asymmetry
301
+ * against `SubscribedPlan.limits` for the same reason - that one is a contract
302
+ * keyed for lookup, this one is a row to render.
303
+ *
304
+ * There is one entry per limit the application declares, including the ones a
305
+ * tier allows none of (`model: "none"`). "Starter: 1 site" only means anything
306
+ * beside "Growth: 5 sites", so a comparison table needs the row even where the
307
+ * answer is nothing; a card showing only what a tier includes filters on
308
+ * `model !== "none"`.
309
+ */
310
+ export interface ResolvedPlanLimit {
311
+ /** What `getLimit` and `SubscribedPlan.limits` are keyed by. */
312
+ key: string;
313
+ /** Human label, e.g. "Sites". */
314
+ name: string;
315
+ /** The noun the number needs, e.g. "sites" - render as `${value} ${unit}`. */
316
+ unit: string;
317
+ /** Empty string when the application's catalog carries no description. */
318
+ description: string;
319
+ model: PlanLimit["model"];
320
+ /** Non-null iff `model` is `"limited"`. */
321
+ value: number | null;
322
+ }
323
+ /**
324
+ * What a plan allows of one metered resource - the **gating** shape, which is
325
+ * what `getLimit` returns and `planLimitAllows` takes.
326
+ *
327
+ * **Three states rather than a nullable number, deliberately.** The obvious
328
+ * encoding - a `number | null` where `null` means unlimited - makes
329
+ * `if (!limit) refuse()` read as correct while refusing every subscriber on
330
+ * the most generous tier, which is the one case nobody tests. Use
331
+ * `planLimitAllows` rather than comparing by hand: `count < value` is off by
332
+ * one when adding more than one row, and `value ?? Infinity` grants unlimited
333
+ * to a plan that allows none.
334
+ */
335
+ export interface PlanLimit {
336
+ /**
337
+ * - `"none"`: this plan does not allow the resource at all. Also what a key
338
+ * the plan carries no answer for reads as - see `SubscribedPlan.limits`.
339
+ * - `"limited"`: a ceiling of `value`.
340
+ * - `"unlimited"`: no ceiling.
341
+ */
342
+ model: "none" | "limited" | "unlimited";
343
+ /** The ceiling. Non-null iff `model` is `"limited"`, and always >= 1 there - zero is spelled `"none"`. */
344
+ value: number | null;
345
+ }
295
346
  export interface Plan {
296
347
  id: string;
297
348
  name: string;
@@ -319,6 +370,8 @@ export interface Plan {
319
370
  trialDays: number | null;
320
371
  /** What this plan unlocks, labelled for display - see `PlanFeature`. */
321
372
  features: PlanFeature[];
373
+ /** How much of each metered resource this plan allows, labelled for display - see `ResolvedPlanLimit`. */
374
+ limits: ResolvedPlanLimit[];
322
375
  }
323
376
  /**
324
377
  * How often a plan bills.
@@ -351,6 +404,27 @@ export interface SubscribedPlan {
351
404
  * comparable, a display name is neither.
352
405
  */
353
406
  features: string[];
407
+ /**
408
+ * How much of each metered resource this workspace may have, keyed by the
409
+ * limit key your application declared - see `PlanLimit`.
410
+ *
411
+ * **Effective, not the tier's raw number**: whatever the add-ons on this
412
+ * subscription raise a limit by is already folded in, so this is the figure
413
+ * to compare your own count against. The tier's base number and the add-ons
414
+ * that raised it are deliberately not broken out here - that is the join
415
+ * Connect does so nobody has to redo it per resource, per partner.
416
+ *
417
+ * **Sparse, and that is the thing to get right**: a key absent here is not
418
+ * "unlimited" and not an error, it is `{ model: "none" }`. Read it through
419
+ * `getLimit`/`planLimitFor` rather than indexing the map, which is what
420
+ * turns a missing key into `undefined` and then, one `?.` later, into an
421
+ * unbounded allowance.
422
+ *
423
+ * Same posture as `features`: Connect cannot intercept calls to your
424
+ * backend, so your backend compares its own count against this number.
425
+ * Connect stores the answer and hands it over.
426
+ */
427
+ limits: Record<string, PlanLimit>;
354
428
  }
355
429
  export interface AppliedDiscount {
356
430
  source: "coupon" | "admin";
@@ -368,6 +442,14 @@ export interface SubscriptionDetail {
368
442
  id: string;
369
443
  status: SubscriptionStatus;
370
444
  cancelAtPeriodEnd: boolean;
445
+ /**
446
+ * How many workspace members currently hold a role for your application,
447
+ * counted when you read it. Compare it against `plan.seats.included`.
448
+ *
449
+ * `0` on an `"unlimited"` plan - there is no ceiling to count against, so
450
+ * Connect does not pay for the count. `ConnectSeatUsage` renders nothing in
451
+ * that case for the same reason.
452
+ */
371
453
  seatCount: number;
372
454
  addons: string[];
373
455
  currentBillingCycle: {
@@ -400,6 +482,13 @@ export interface ApplicationSubscriber {
400
482
  /** `null` if the plan was hard-deleted rather than archived. */
401
483
  planName: string | null;
402
484
  status: "created" | "authenticated" | "active" | "paused" | "cancelled";
485
+ /**
486
+ * The last-billed seat snapshot, not the live count `SubscriptionDetail`
487
+ * carries: a page of subscribers cannot afford one member scan per row. It
488
+ * is `0` unless the plan is `"per-seat"`, which is the only model that
489
+ * bills by seat. Read the single workspace's subscription for "who is
490
+ * assigned right now".
491
+ */
403
492
  seatCount: number;
404
493
  /** ISO 8601. */
405
494
  subscribedAt: string;
@@ -1026,6 +1115,67 @@ export interface EmitEventsResponse {
1026
1115
  * registered a handler for.
1027
1116
  */
1028
1117
  export type ConnectWebhookEventType = "workspace.deletion_scheduled" | "workspace.deletion_cancelled" | "workspace.deleted" | "subscription.cancelled" | "call.completed" | "call.missed" | "booking.confirmed" | "booking.cancelled" | "booking.rescheduled" | "contact.created" | "contact.updated";
1118
+ /** What becomes of one class of data when the workspace is purged. */
1119
+ export type ConnectDataDisposition = "erase" | "anonymize" | "retain";
1120
+ /** Who answers for your copy of the customer's data. See the deletion-policy guide. */
1121
+ export type ConnectFiduciaryRole = "connect_owned" | "processor" | "independent";
1122
+ /** One kind of data your application holds, and what your policy said happens to it. */
1123
+ export interface ConnectDataClassNotice {
1124
+ /** Stable key, as declared in Console. Safe to switch on. */
1125
+ key: string;
1126
+ /** What the customer was shown - e.g. "Call records". */
1127
+ label: string;
1128
+ disposition: ConnectDataDisposition;
1129
+ /**
1130
+ * How many days this class outlives the workspace **in your own archive**,
1131
+ * or `null` when it goes with the purge.
1132
+ *
1133
+ * The one number in either deletion event you have to act on, and the
1134
+ * reason `policy` is on the wire at all: `purgeAfter` is Connect's clock,
1135
+ * this is yours. Read it rather than hardcoding the figure you typed into
1136
+ * Console - that copy drifts the first time somebody edits the policy, and
1137
+ * this one is the version the customer was actually promised.
1138
+ *
1139
+ * It is Connect's clamped value, so it is never longer than the platform
1140
+ * ceiling even if your stored policy says otherwise.
1141
+ */
1142
+ retainDays: number | null;
1143
+ /** The clause or rule you named as requiring it, shown to the customer verbatim. `null` for a class that is erased. */
1144
+ basis: string | null;
1145
+ }
1146
+ /** An identifier allocated to the workspace that must be held before reissue - a third clock, neither the grace window nor a retention floor. */
1147
+ export interface ConnectResourceReleaseNotice {
1148
+ key: string;
1149
+ label: string;
1150
+ /** Days to hold it before handing it to somebody else. */
1151
+ quarantineDays: number;
1152
+ }
1153
+ /**
1154
+ * Your application's own data policy, as it stood when this deletion was
1155
+ * confirmed - the declaration the customer read before pressing the button.
1156
+ *
1157
+ * Carried on both deletion events so your handler can be driven by the
1158
+ * declaration rather than by a second copy of it in your code. Two things
1159
+ * about it are worth relying on:
1160
+ *
1161
+ * **It is frozen.** An operator editing the policy while a workspace is
1162
+ * already pending deletion does not change what that deletion runs under;
1163
+ * `version` is which one it is, so a deletion certificate can name it.
1164
+ *
1165
+ * **It is yours alone.** A deletion fans out to every application the
1166
+ * workspace subscribed to, and each one receives only its own policy.
1167
+ */
1168
+ export interface ConnectDeletionPolicyNotice {
1169
+ /** The policy version in force when the deletion was confirmed. */
1170
+ version: number;
1171
+ fiduciaryRole: ConnectFiduciaryRole;
1172
+ /** Whether your policy promised the customer an export before the window closed. */
1173
+ exportBeforePurge: boolean;
1174
+ policyUrl: string | null;
1175
+ /** Empty when your application has declared no policy - which the customer was shown as "this application hasn't told us", not as "nothing is kept". */
1176
+ dataClasses: ConnectDataClassNotice[];
1177
+ resourceReleases: ConnectResourceReleaseNotice[];
1178
+ }
1029
1179
  /**
1030
1180
  * A workspace's deletion has been confirmed and the grace window is running.
1031
1181
  *
@@ -1041,6 +1191,13 @@ export interface WorkspaceDeletionScheduledEvent {
1041
1191
  purgeAfter: string;
1042
1192
  requestedBy: "owner" | "platform_admin";
1043
1193
  graceDays: number;
1194
+ /**
1195
+ * Your declared policy for this deletion - including how long each data
1196
+ * class may outlive the purge. `null` for a deletion confirmed before
1197
+ * policies existed, which is the only case where you fall back to your own
1198
+ * records.
1199
+ */
1200
+ policy: ConnectDeletionPolicyNotice | null;
1044
1201
  }
1045
1202
  /**
1046
1203
  * The deletion was withdrawn and the workspace is live again.
@@ -1058,6 +1215,13 @@ export interface WorkspaceDeletedEvent {
1058
1215
  workspaceId: string;
1059
1216
  /** ISO-8601. */
1060
1217
  purgedAt: string;
1218
+ /**
1219
+ * The same frozen policy `workspace.deletion_scheduled` carried, repeated
1220
+ * here because this is the event you act on: whether a class is destroyed
1221
+ * now or moved to a retained store for another `retainDays` is this
1222
+ * object's answer, not your code's.
1223
+ */
1224
+ policy: ConnectDeletionPolicyNotice | null;
1061
1225
  }
1062
1226
  /** A subscription ended. Fires on its own, not only as part of a deletion. */
1063
1227
  export interface SubscriptionCancelledEvent {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmoops/connect-sdk",
3
- "version": "0.33.0",
3
+ "version": "0.34.1",
4
4
  "description": "Official server-side SDK for the CosmoOps Connect platform API - Login with Connect, entitlements, billing and metering.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",