@vxil/sdk 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -535,6 +535,33 @@ export interface PaymentsEntitlementView {
535
535
  period_end: string | null;
536
536
  };
537
537
  }
538
+ /** The provider's own state of a charge, as a closed union (2026-09-25). Paddle
539
+ * delivers `paid` (money taken) and then `completed` (fulfilled) for ONE
540
+ * charge; every other provider has a single terminal state and lands
541
+ * `completed` at once. `payments.charge.succeeded` carries the state of the
542
+ * delivery that recorded the charge; `payments.charge.completed` fires at
543
+ * most once per charge, only when a recorded charge later TRANSITIONS to
544
+ * `completed` (Paddle paid → completed). Handle `succeeded` gated on
545
+ * `provider_status === 'completed'` plus `completed` to act once per charge. */
546
+ export type PaymentsProviderChargeStatus = 'paid' | 'completed';
547
+ /** A charge row as listed by GET /v1/payments/charges (payments.md §3). */
548
+ export interface PaymentsCharge {
549
+ /** the vxil charge id (`chg_…`) — what refunds and a charge-linked grant key on */
550
+ charge_id: string;
551
+ end_user_id: string;
552
+ provider: string;
553
+ /** the provider's own transaction / payment id */
554
+ provider_charge_id: string | null;
555
+ /** null on rows recorded before the provider-state column existed */
556
+ provider_status: PaymentsProviderChargeStatus | null;
557
+ /** the one-off product the charge bought (the `ledger.productMap` key), if any */
558
+ product_id: string | null;
559
+ amount_cents: number;
560
+ amount_refunded: number;
561
+ currency: string;
562
+ status: string;
563
+ created_at: string;
564
+ }
538
565
  export interface FileObject {
539
566
  object_id: string;
540
567
  filename: string;
@@ -802,6 +829,11 @@ export interface JobRunEventPayload {
802
829
  attempt: number;
803
830
  level?: 'error';
804
831
  state?: 'broken';
832
+ /** `job.dead_lettered` only, and only when something other than the
833
+ * executor killed the run: `reaped` (the stuck-run reaper) or
834
+ * `queue_backstop` (the queue's own retries ran out). Absent when the run
835
+ * exhausted its attempts normally. */
836
+ reason?: 'reaped' | 'queue_backstop';
805
837
  }
806
838
  /** Event name → typed `data`, for the events that settle work you started.
807
839
  * `VxilEventPayload<'job.generation.failed'>` names one; everything else on
@@ -814,6 +846,121 @@ export interface VxilEventPayloads {
814
846
  'job.dead_lettered': JobRunEventPayload;
815
847
  }
816
848
  export type VxilEventPayload<E extends string> = E extends keyof VxilEventPayloads ? VxilEventPayloads[E] : Record<string, unknown>;
849
+ /** The `trigger` label on the envelope. The config spells two of them in
850
+ * camelCase (`cmsHook`, `authHook`); on the wire they are kebab-case. */
851
+ export type FunctionEnvelopeTrigger = 'http' | 'cms-hook' | 'auth-hook' | 'queue' | 'cron' | 'webhook';
852
+ /** The audiences a function's scoped callback tokens are minted for — one per
853
+ * feature its declared scopes reach. `control-plane` carries `users:*` /
854
+ * `usage:read` only. There is deliberately no `functions` audience: a function
855
+ * cannot call another function. */
856
+ export type FunctionCallbackAudience = 'cms' | 'payments' | 'notifications' | 'comments' | 'files' | 'ai' | 'rag' | 'vector-search' | 'activity-feed' | 'orgs' | 'auth' | 'jobs' | 'realtime' | 'rate-limits' | 'control-plane';
857
+ /** One short-lived scoped token per audience your scopes imply
858
+ * (`env.scoped_jwts.cms`, `env.scoped_jwts['control-plane']`); an audience your
859
+ * scopes do not reach is absent. Send it as `Authorization: Bearer …` to
860
+ * `vxil_base`. */
861
+ export type FunctionScopedJwts = {
862
+ [A in FunctionCallbackAudience]?: string;
863
+ };
864
+ /** The verified end-user an `http` invocation carries when the caller presented
865
+ * a vxil-auth session. */
866
+ export interface FunctionEndUser {
867
+ id: string;
868
+ sid: string;
869
+ /** the step-up epoch, when the session has one */
870
+ elv?: number;
871
+ }
872
+ /** The fields every invocation carries, whatever fired it. */
873
+ export interface FunctionEnvelopeBase {
874
+ tenant_id: string;
875
+ request_id: string;
876
+ /** Stable across redeliveries of the same event: delivery is at-least-once,
877
+ * so dedupe your writes on it. */
878
+ idempotency_key: string;
879
+ /** The edge to call vxil back through. */
880
+ vxil_base: string;
881
+ scoped_jwts: FunctionScopedJwts;
882
+ /** Your declared per-function secrets (`secrets: ['secret:<name>']`), keyed by
883
+ * bare name and resolved at invoke time. */
884
+ secrets: Record<string, string>;
885
+ }
886
+ /** `payload` of a `cms-hook` invocation. It carries no field values: re-read
887
+ * the item by `item_id`. */
888
+ export interface CmsHookTriggerPayload {
889
+ /** `cms.item.created` or `cms.item.updated` for a binding that names a
890
+ * collection; a binding without one also sees the other `cms.item.*` events */
891
+ event: string;
892
+ collection: string;
893
+ item_id: string;
894
+ }
895
+ /** `payload` of an `auth-hook` invocation: the event's own fields plus
896
+ * `event`. `auth.user.created` → `{ user_id, method, is_anonymous }`;
897
+ * `auth.session.created` → `{ user_id, session_id }`; `auth.session.revoked`
898
+ * adds `reason`; `auth.signin.failure` → `{ email_hash | user_id, reason }`. */
899
+ export interface AuthHookTriggerPayload {
900
+ event: string;
901
+ user_id?: string;
902
+ [field: string]: unknown;
903
+ }
904
+ /** `payload` of a `webhook` invocation: one platform event under the binding's
905
+ * `source` prefix. `D` is the event's own payload (see `VxilEventPayload`). */
906
+ export interface WebhookTriggerPayload<D = Record<string, unknown>> {
907
+ event: string;
908
+ audit_id: number | null;
909
+ occurred_at: string | null;
910
+ actor: string | null;
911
+ surface: string | null;
912
+ /** the event's payload; `{ truncated: true }` when it was larger than the
913
+ * function's webhook payload bound, null when the event carried none */
914
+ data: D | {
915
+ truncated: true;
916
+ } | null;
917
+ }
918
+ /** `POST /v1/fn/:name`: `payload` is the JSON request body (the query
919
+ * parameters on a GET). */
920
+ export interface HttpFunctionEnvelope<P = unknown> extends FunctionEnvelopeBase {
921
+ trigger: 'http';
922
+ /** present only when the caller presented a verified end-user session */
923
+ end_user?: FunctionEndUser;
924
+ payload: P;
925
+ }
926
+ export interface CmsHookFunctionEnvelope extends FunctionEnvelopeBase {
927
+ trigger: 'cms-hook';
928
+ payload: CmsHookTriggerPayload;
929
+ }
930
+ export interface AuthHookFunctionEnvelope extends FunctionEnvelopeBase {
931
+ trigger: 'auth-hook';
932
+ payload: AuthHookTriggerPayload;
933
+ }
934
+ /** `payload` is exactly what the job was enqueued with. */
935
+ export interface QueueFunctionEnvelope<P = unknown> extends FunctionEnvelopeBase {
936
+ trigger: 'queue';
937
+ payload: P;
938
+ }
939
+ /** A schedule tick carries no data: `payload` is `{}`. */
940
+ export interface CronFunctionEnvelope extends FunctionEnvelopeBase {
941
+ trigger: 'cron';
942
+ payload: Record<string, unknown>;
943
+ }
944
+ export interface WebhookFunctionEnvelope<D = Record<string, unknown>> extends FunctionEnvelopeBase {
945
+ trigger: 'webhook';
946
+ payload: WebhookTriggerPayload<D>;
947
+ }
948
+ /** The invocation envelope, discriminated on `trigger`. `P` types the `http`
949
+ * body and the `queue` payload. A function bound to one trigger can name its
950
+ * member directly (`CmsHookFunctionEnvelope`, `WebhookFunctionEnvelope<D>`). */
951
+ export type FunctionEnvelope<P = unknown> = HttpFunctionEnvelope<P> | CmsHookFunctionEnvelope | AuthHookFunctionEnvelope | QueueFunctionEnvelope<P> | CronFunctionEnvelope | WebhookFunctionEnvelope;
952
+ /** The body the jobs engine POSTs to a run's `target_url` (signed with
953
+ * `X-Vxil-Jobs-Signature`; verify it with the secret from
954
+ * `jobs.signingSecret()`). Redelivered attempts carry the same `run_id` and a
955
+ * higher `attempt`. */
956
+ export interface JobDelivery<P = unknown> {
957
+ run_id: string;
958
+ job_name: string;
959
+ /** 1-based */
960
+ attempt: number;
961
+ /** exactly what the run was enqueued with */
962
+ payload: P;
963
+ }
817
964
  /** A re-minted mid-stream connect token (GET /v1/ai/generations/{id}/token). */
818
965
  export interface AiStreamToken {
819
966
  generation_id: string;
@@ -1393,7 +1540,12 @@ export interface CmsReindexPage {
1393
1540
  /** The result of a bounded filtered delete (cms.md §20). `matched` counts the
1394
1541
  * rows this page selected (≤ `limit`); `deleted` counts the ones actually
1395
1542
  * removed (a row that vanished between the match and the delete is skipped).
1396
- * Loop while `deleted > 0` — the filter re-evaluates against live rows. */
1543
+ * Loop while `deleted > 0` — the filter re-evaluates against live rows.
1544
+ * `complete` is false when the call stopped starting rows at its wall-clock
1545
+ * bound (8 s; the row in flight finishes) before every matched row was
1546
+ * processed — such a call always deleted at least one row, so the loop above
1547
+ * stays exact; `next_cursor` resumes that page if you prefer. Rows already
1548
+ * deleted stay deleted. */
1397
1549
  export interface CmsBulkDeleteResult {
1398
1550
  collection: string;
1399
1551
  matched: number;
@@ -1401,6 +1553,8 @@ export interface CmsBulkDeleteResult {
1401
1553
  cascaded: number;
1402
1554
  set_null: number;
1403
1555
  dry_run: boolean;
1556
+ /** every matched row was processed (always true for a dry run). */
1557
+ complete: boolean;
1404
1558
  next_cursor: string | null;
1405
1559
  }
1406
1560
  /** A write guard (cms.md §10): "after this write, at most `max` live items
@@ -1839,6 +1993,24 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
1839
1993
  delete: (id: string, opts?: {
1840
1994
  erase?: boolean;
1841
1995
  }) => Promise<void>;
1996
+ /**
1997
+ * Merge a REGISTRY-ONLY user (a row you created with upsert that never
1998
+ * signed in) INTO another user of the tenant: its attributes fill the
1999
+ * survivor's gaps (the survivor wins on conflicts), the merged id is
2000
+ * soft-deleted, its owner-scoped cms / files / payments rows move to the
2001
+ * survivor, and `auth.user.merged { from, into, method: 'registry_merge' }`
2002
+ * is audited. `rekeyed: false` means a re-key could not finish inside the
2003
+ * request — the event is the backstop (call the feature's re-key route
2004
+ * again; it is idempotent). 409 `has_auth_identity` when the id already
2005
+ * has a sign-in identity: merge that through the auth flows instead.
2006
+ * Server-only (403 in end-user mode). Scope users:write.
2007
+ */
2008
+ merge: (id: string, into: string) => Promise<{
2009
+ from: string;
2010
+ into: string;
2011
+ merged: boolean;
2012
+ rekeyed: boolean;
2013
+ }>;
1842
2014
  list: (q?: {
1843
2015
  email?: string;
1844
2016
  /** substring match on email + display name (server-side, trgm-indexed), 1-100 chars */
@@ -1966,6 +2138,8 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
1966
2138
  * `counts.total / sent` to 4 dp, or null when nothing was sent. `sent`
1967
2139
  * counts deliveries the provider ACCEPTED, not inbox-delivered mail —
1968
2140
  * this is a suppressions-per-accepted-send ratio, not an RFC bounce rate.
2141
+ * `sent` is counted up to 100 000: past that it is the cap and
2142
+ * `sent_capped` is true (the rate is then a lower bound).
1969
2143
  * Operator surface: a verified end-user token gets 403. */
1970
2144
  stats: (q?: {
1971
2145
  days?: number;
@@ -1980,6 +2154,7 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
1980
2154
  total: number;
1981
2155
  };
1982
2156
  sent: number;
2157
+ sent_capped?: boolean;
1983
2158
  rate: number | null;
1984
2159
  }>;
1985
2160
  };
@@ -2616,6 +2791,34 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2616
2791
  user_id: string;
2617
2792
  session: AuthSession;
2618
2793
  }>;
2794
+ /** Keep a session pair usable: when it expires within `skewSeconds`
2795
+ * (default 300) — or `expires_at` is missing/unparseable, or already past
2796
+ * — rotate it through `refresh` and return the NEW pair with
2797
+ * `refreshed: true`; otherwise return it unchanged, with no request.
2798
+ * Store the pair again whenever `refreshed` is true (the old refresh
2799
+ * token is revoked by the rotation).
2800
+ *
2801
+ * The window is capped at half the token's own lifetime (iat→exp, read
2802
+ * from the session JWT; 60 s for an opaque token), so a short session
2803
+ * TTL never makes every call rotate. Concurrent calls for the same
2804
+ * refresh token in one process share ONE rotation, and for 30 s after it
2805
+ * settles a call still carrying the OLD pair gets the new pair (no second
2806
+ * request) while a call with the just-minted pair gets it back unchanged.
2807
+ * A refresh that fails (revoked, expired, or already rotated by another
2808
+ * process) throws the `VxilError` — treat it as "sign in again".
2809
+ *
2810
+ * Call it on a client whose key carries `auth:signin` — normally the
2811
+ * sign-in key, in server mode. An end-user-mode client sends its
2812
+ * X-Vxil-End-User token with the rotation only while that token is the
2813
+ * pair's own and still has 10 s left; otherwise the rotation goes out
2814
+ * without it, which a thin-client (`end_user_required`) key cannot do.
2815
+ * See https://vxil.com/docs/guide/09-security-and-multitenancy. */
2816
+ ensureFresh: (pair: AuthSession, opts?: {
2817
+ skewSeconds?: number;
2818
+ }) => Promise<{
2819
+ session: AuthSession;
2820
+ refreshed: boolean;
2821
+ }>;
2619
2822
  revoke: (token: string) => Promise<void>;
2620
2823
  /** "Sign out everywhere": revoke EVERY live session of a user in one call
2621
2824
  * (one statement, one batched edge-cache write; each session's
@@ -2984,7 +3187,10 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2984
3187
  * the SAME single-item delete path (restrict refusal, cascade budget,
2985
3188
  * unique-claim freeing, CDC), so this is N deletes in one round trip, not
2986
3189
  * a set-delete. `dryRun` reports `matched` and mutates nothing. Loop
2987
- * while `deleted > 0`. A refusal mid-sweep (409 referenced / 422
3190
+ * while `deleted > 0`. A call stops STARTING rows after 8 s of wall
3191
+ * clock (the row in flight finishes; never before its first delete) and
3192
+ * answers `complete: false` + `next_cursor` (pass it as `cursor` to
3193
+ * resume the page). A refusal mid-sweep (409 referenced / 422
2988
3194
  * cascade_too_large) stops the call — rows already deleted STAY deleted,
2989
3195
  * and the error message names how many. Soft-deleted rows are hard-purged
2990
3196
  * by the platform 30 days later (there is no per-tenant retention knob). */
@@ -3529,6 +3735,21 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3529
3735
  state: string;
3530
3736
  }>>;
3531
3737
  unsubscribe: (subId: string) => Promise<void>;
3738
+ /**
3739
+ * Change a subscription's event filter in place (2026-09-25). The sub_id
3740
+ * and the delivery cursor are kept — nothing is replayed, nothing is
3741
+ * skipped; the new prefixes apply from the next delivery pass. An empty
3742
+ * array subscribes to every event. `target_url` is the subscription's
3743
+ * identity and is not editable: a new target is a new subscription.
3744
+ */
3745
+ update: (subId: string, input: {
3746
+ event_prefixes: string[];
3747
+ }) => Promise<{
3748
+ sub_id: string;
3749
+ target_url: string;
3750
+ event_prefixes: string[];
3751
+ state: string;
3752
+ }>;
3532
3753
  /**
3533
3754
  * Send a synthetic 'webhooks.test' event to the subscription's endpoint
3534
3755
  * through the REAL signing+delivery pipeline and wait inline (≤ ~8 s).
@@ -4646,19 +4867,29 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
4646
4867
  * entitlements + the tier's recurring credits apply; `until` null =
4647
4868
  * open-ended, else access ends at `until`. Idempotent on
4648
4869
  * `idempotency_key` (a replay returns the first grant, `replayed: true`).
4649
- * 422 unknown_tier when the tier is not in ledger.tierMap. */
4870
+ * 422 unknown_tier when the tier is not in ledger.tierMap.
4871
+ * `charge_id` (optional) links the grant to the vxil charge (`chg_…`, from
4872
+ * `listCharges` or the `payments.charge.succeeded` / `.completed` event)
4873
+ * it was bought with: a FULL refund or chargeback of that charge ends the
4874
+ * grant (`payments.subscription.revoked` with `via: 'refund' | 'chargeback'`).
4875
+ * 404 charge_not_found; 422 charge_user_mismatch (another user's charge) /
4876
+ * charge_refunded (the refund beat the grant — nothing to grant against).
4877
+ * 422 reserved_idempotency_key when `idempotency_key` starts with
4878
+ * `purchase:` (the namespace of purchase passes). */
4650
4879
  grantSubscription: (input: {
4651
4880
  user_id: string;
4652
4881
  tier: string;
4653
4882
  until?: string | null;
4654
4883
  reason: string;
4655
4884
  idempotency_key: string;
4885
+ charge_id?: string | null;
4656
4886
  }) => Promise<{
4657
4887
  subscription_id: string | null;
4658
4888
  user_id: string;
4659
4889
  tier: string;
4660
4890
  status: string;
4661
4891
  until: string | null;
4892
+ charge_id: string | null;
4662
4893
  replayed: boolean;
4663
4894
  }>;
4664
4895
  /** SERVER-ONLY. Revoke a MANUAL grant now (409 not_manual for a provider
@@ -4736,22 +4967,18 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
4736
4967
  } | null;
4737
4968
  }>;
4738
4969
  /** List charges newest-first (the refund enabler — discover the charge_id).
4739
- * Filters: user_id, status, limit (clamped 1..100, default 50). */
4970
+ * Filters: user_id, status, limit (clamped 1..100, default 50).
4971
+ * Each row carries the provider's own charge id (`provider_charge_id`),
4972
+ * the provider's charge state (`provider_status`: `'paid'` — Paddle's
4973
+ * money-taken state — or `'completed'`; null on rows recorded before
4974
+ * 2026-09-25) and the one-off `product_id` it bought, so a function can
4975
+ * match a charge to a provider event without reading the delivery log. */
4740
4976
  listCharges: (q?: {
4741
4977
  user_id?: string;
4742
4978
  status?: "succeeded" | "failed" | "refunded" | "partially_refunded" | "pending";
4743
4979
  limit?: number;
4744
4980
  }) => Promise<{
4745
- charges: Array<{
4746
- charge_id: string;
4747
- end_user_id: string;
4748
- provider: string;
4749
- amount_cents: number;
4750
- amount_refunded: number;
4751
- currency: string;
4752
- status: string;
4753
- created_at: string;
4754
- }>;
4981
+ charges: PaymentsCharge[];
4755
4982
  }>;
4756
4983
  /** Provider webhook event log (payments.md §7 "Event log & replay"):
4757
4984
  * operator visibility over every delivery — incl. persisted signature
package/dist/index.js CHANGED
@@ -50,6 +50,69 @@ export class VxilError extends Error {
50
50
  /** The run states no later write can move — what `waitForRun` and an
51
51
  * async+wait invoke resolve `done: true` on. */
52
52
  export const JOB_TERMINAL_STATES = new Set(['succeeded', 'failed', 'dead', 'cancelled']);
53
+ /** `vx.auth.sessions.ensureFresh` rotates a pair this many seconds before it
54
+ * expires unless the call names its own `skewSeconds`. */
55
+ const ENSURE_FRESH_SKEW_SECONDS = 300;
56
+ /** The window never exceeds this fraction of the token's own lifetime (iat→exp
57
+ * from the session JWT), so a short session TTL cannot make every call rotate. */
58
+ const ENSURE_FRESH_MAX_WINDOW_FRACTION = 0.5;
59
+ /** Cap for a token whose lifetime cannot be read (an opaque, non-JWT token). */
60
+ const ENSURE_FRESH_OPAQUE_MAX_SKEW_SECONDS = 60;
61
+ /** An end-user-mode client keeps its X-Vxil-End-User token on the rotation
62
+ * while that token has at least this long left (a thin-client key has no
63
+ * server-mode fallback at the edge); closer to expiry it rotates in server mode. */
64
+ const ENSURE_FRESH_HEADER_MARGIN_MS = 10_000;
65
+ /** A settled rotation stays readable this long: a request that arrives just
66
+ * after the winner still carrying the OLD pair adopts the winner's pair
67
+ * instead of re-sending a revoked refresh token, and a pair this process just
68
+ * minted is never rotated again inside the window (bounds rotations to one
69
+ * per window per session per process whatever the clocks say). */
70
+ const ENSURE_FRESH_GRACE_MS = 30_000;
71
+ /** Rotations keyed by (edge base, OLD refresh token), shared by every client in
72
+ * the process. `until` is Infinity while in flight and settle-time + grace
73
+ * after a success; a failure is dropped at once. */
74
+ const refreshRotations = new Map();
75
+ /** (edge base, NEW refresh token) → the time until which that just-minted pair
76
+ * is returned unchanged even if it already looks due. */
77
+ const refreshJustMinted = new Map();
78
+ function sweepRefreshCaches(now) {
79
+ for (const [k, r] of refreshRotations)
80
+ if (r.until <= now)
81
+ refreshRotations.delete(k);
82
+ for (const [k, until] of refreshJustMinted)
83
+ if (until <= now)
84
+ refreshJustMinted.delete(k);
85
+ }
86
+ const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
87
+ /** iat→exp of a session JWT in seconds, read WITHOUT verification (it only
88
+ * schedules the refresh); undefined for an opaque or malformed token. No
89
+ * atob: the SDK also runs on React Native builds that lack it. */
90
+ function sessionLifetimeSeconds(token) {
91
+ const parts = token.split('.');
92
+ if (parts.length !== 3 || !parts[1] || parts[1].length > 8192)
93
+ return undefined;
94
+ let acc = 0;
95
+ let bits = 0;
96
+ let json = '';
97
+ for (const ch of parts[1].replace(/=+$/, '')) {
98
+ const v = B64URL.indexOf(ch);
99
+ if (v < 0)
100
+ return undefined;
101
+ acc = ((acc << 6) | v) & 0xffff;
102
+ bits += 6;
103
+ if (bits >= 8) {
104
+ bits -= 8;
105
+ json += String.fromCharCode((acc >> bits) & 0xff);
106
+ }
107
+ }
108
+ try {
109
+ const p = JSON.parse(json);
110
+ if (typeof p.iat === 'number' && typeof p.exp === 'number' && p.exp > p.iat)
111
+ return p.exp - p.iat;
112
+ }
113
+ catch { /* not a JWT payload */ }
114
+ return undefined;
115
+ }
53
116
  const DEFAULT_BASE = 'https://api.vxil.com';
54
117
  /** Normalize the `expand` option to the `$expand` query value: a comma-joined
55
118
  * list (the server's spelling), or `undefined` when nothing is expanded so the
@@ -337,6 +400,19 @@ export class Vxil {
337
400
  delete: async (id, opts) => {
338
401
  await this.call('DELETE', `/v1/users/${encodeURIComponent(id)}${opts?.erase ? '?erase=true' : ''}`);
339
402
  },
403
+ /**
404
+ * Merge a REGISTRY-ONLY user (a row you created with upsert that never
405
+ * signed in) INTO another user of the tenant: its attributes fill the
406
+ * survivor's gaps (the survivor wins on conflicts), the merged id is
407
+ * soft-deleted, its owner-scoped cms / files / payments rows move to the
408
+ * survivor, and `auth.user.merged { from, into, method: 'registry_merge' }`
409
+ * is audited. `rekeyed: false` means a re-key could not finish inside the
410
+ * request — the event is the backstop (call the feature's re-key route
411
+ * again; it is idempotent). 409 `has_auth_identity` when the id already
412
+ * has a sign-in identity: merge that through the auth flows instead.
413
+ * Server-only (403 in end-user mode). Scope users:write.
414
+ */
415
+ merge: async (id, into) => (await this.call('POST', `/v1/users/${encodeURIComponent(id)}/merge`, { into })).data,
340
416
  list: async (q) => {
341
417
  const s = qs({
342
418
  email: q?.email || undefined,
@@ -397,6 +473,8 @@ export class Vxil {
397
473
  * `counts.total / sent` to 4 dp, or null when nothing was sent. `sent`
398
474
  * counts deliveries the provider ACCEPTED, not inbox-delivered mail —
399
475
  * this is a suppressions-per-accepted-send ratio, not an RFC bounce rate.
476
+ * `sent` is counted up to 100 000: past that it is the cap and
477
+ * `sent_capped` is true (the rate is then a lower bound).
400
478
  * Operator surface: a verified end-user token gets 403. */
401
479
  stats: async (q) => {
402
480
  const s = qs({ days: q?.days || undefined });
@@ -788,6 +866,69 @@ export class Vxil {
788
866
  verify: async (token) => (await this.call('POST', '/v1/auth/sessions/verify', { token })).data,
789
867
  /** Rotates the refresh token: store the returned pair, discard the old one. */
790
868
  refresh: async (refreshToken) => (await this.call('POST', '/v1/auth/sessions/refresh', { refresh_token: refreshToken })).data,
869
+ /** Keep a session pair usable: when it expires within `skewSeconds`
870
+ * (default 300) — or `expires_at` is missing/unparseable, or already past
871
+ * — rotate it through `refresh` and return the NEW pair with
872
+ * `refreshed: true`; otherwise return it unchanged, with no request.
873
+ * Store the pair again whenever `refreshed` is true (the old refresh
874
+ * token is revoked by the rotation).
875
+ *
876
+ * The window is capped at half the token's own lifetime (iat→exp, read
877
+ * from the session JWT; 60 s for an opaque token), so a short session
878
+ * TTL never makes every call rotate. Concurrent calls for the same
879
+ * refresh token in one process share ONE rotation, and for 30 s after it
880
+ * settles a call still carrying the OLD pair gets the new pair (no second
881
+ * request) while a call with the just-minted pair gets it back unchanged.
882
+ * A refresh that fails (revoked, expired, or already rotated by another
883
+ * process) throws the `VxilError` — treat it as "sign in again".
884
+ *
885
+ * Call it on a client whose key carries `auth:signin` — normally the
886
+ * sign-in key, in server mode. An end-user-mode client sends its
887
+ * X-Vxil-End-User token with the rotation only while that token is the
888
+ * pair's own and still has 10 s left; otherwise the rotation goes out
889
+ * without it, which a thin-client (`end_user_required`) key cannot do.
890
+ * See https://vxil.com/docs/guide/09-security-and-multitenancy. */
891
+ ensureFresh: async (pair, opts = {}) => {
892
+ const skew = opts.skewSeconds ?? ENSURE_FRESH_SKEW_SECONDS;
893
+ if (typeof skew !== 'number' || !Number.isFinite(skew) || skew < 0) {
894
+ throw new VxilError(0, 'invalid_argument', 'ensureFresh: skewSeconds must be a finite number ≥ 0.');
895
+ }
896
+ const now = Date.now();
897
+ const key = `${this.base}\n${pair.refresh_token}`;
898
+ // A pair this process already rotated (or is rotating): its tokens are
899
+ // revoked, so the answer is the winner's pair whatever expires_at says.
900
+ const prior = pair.refresh_token ? refreshRotations.get(key) : undefined;
901
+ if (prior && prior.until > now)
902
+ return { session: await prior.session, refreshed: true };
903
+ const lifetime = sessionLifetimeSeconds(pair.token);
904
+ const window = lifetime !== undefined
905
+ ? Math.min(skew, lifetime * ENSURE_FRESH_MAX_WINDOW_FRACTION)
906
+ : Math.min(skew, ENSURE_FRESH_OPAQUE_MAX_SKEW_SECONDS);
907
+ const expMs = Date.parse(pair.expires_at);
908
+ if (Number.isFinite(expMs) && expMs - now > window * 1000)
909
+ return { session: pair, refreshed: false };
910
+ if (pair.refresh_token && (refreshJustMinted.get(key) ?? 0) > now)
911
+ return { session: pair, refreshed: false };
912
+ if (!pair.refresh_token) {
913
+ throw new VxilError(0, 'refresh_token_missing', 'ensureFresh: the session is due for refresh but the pair carries no refresh_token.', 'Store both halves of the pair a sign-in returns (token + refresh_token), or sign in again.');
914
+ }
915
+ const keepEndUser = this.endUserToken !== undefined && this.endUserToken === pair.token
916
+ && Number.isFinite(expMs) && expMs - now >= ENSURE_FRESH_HEADER_MARGIN_MS;
917
+ const client = this.endUserToken && !keepEndUser ? this.asEndUser(undefined) : this;
918
+ sweepRefreshCaches(now);
919
+ const entry = {
920
+ session: client.call('POST', '/v1/auth/sessions/refresh', { refresh_token: pair.refresh_token }).then((r) => r.data.session),
921
+ until: Number.POSITIVE_INFINITY,
922
+ };
923
+ entry.session.then((fresh) => {
924
+ entry.until = Date.now() + ENSURE_FRESH_GRACE_MS;
925
+ if (fresh.refresh_token)
926
+ refreshJustMinted.set(`${this.base}\n${fresh.refresh_token}`, entry.until);
927
+ }, () => { if (refreshRotations.get(key) === entry)
928
+ refreshRotations.delete(key); });
929
+ refreshRotations.set(key, entry);
930
+ return { session: await entry.session, refreshed: true };
931
+ },
791
932
  revoke: async (token) => {
792
933
  await this.call('POST', '/v1/auth/sessions/revoke', { token });
793
934
  },
@@ -1044,7 +1185,10 @@ export class Vxil {
1044
1185
  * the SAME single-item delete path (restrict refusal, cascade budget,
1045
1186
  * unique-claim freeing, CDC), so this is N deletes in one round trip, not
1046
1187
  * a set-delete. `dryRun` reports `matched` and mutates nothing. Loop
1047
- * while `deleted > 0`. A refusal mid-sweep (409 referenced / 422
1188
+ * while `deleted > 0`. A call stops STARTING rows after 8 s of wall
1189
+ * clock (the row in flight finishes; never before its first delete) and
1190
+ * answers `complete: false` + `next_cursor` (pass it as `cursor` to
1191
+ * resume the page). A refusal mid-sweep (409 referenced / 422
1048
1192
  * cascade_too_large) stops the call — rows already deleted STAY deleted,
1049
1193
  * and the error message names how many. Soft-deleted rows are hard-purged
1050
1194
  * by the platform 30 days later (there is no per-tenant retention knob). */
@@ -1313,6 +1457,14 @@ export class Vxil {
1313
1457
  unsubscribe: async (subId) => {
1314
1458
  await this.call('DELETE', `/v1/webhooks/subscriptions/${encodeURIComponent(subId)}`);
1315
1459
  },
1460
+ /**
1461
+ * Change a subscription's event filter in place (2026-09-25). The sub_id
1462
+ * and the delivery cursor are kept — nothing is replayed, nothing is
1463
+ * skipped; the new prefixes apply from the next delivery pass. An empty
1464
+ * array subscribes to every event. `target_url` is the subscription's
1465
+ * identity and is not editable: a new target is a new subscription.
1466
+ */
1467
+ update: async (subId, input) => (await this.call('PATCH', `/v1/webhooks/subscriptions/${encodeURIComponent(subId)}`, input)).data,
1316
1468
  /**
1317
1469
  * Send a synthetic 'webhooks.test' event to the subscription's endpoint
1318
1470
  * through the REAL signing+delivery pipeline and wait inline (≤ ~8 s).
@@ -1862,7 +2014,15 @@ export class Vxil {
1862
2014
  * entitlements + the tier's recurring credits apply; `until` null =
1863
2015
  * open-ended, else access ends at `until`. Idempotent on
1864
2016
  * `idempotency_key` (a replay returns the first grant, `replayed: true`).
1865
- * 422 unknown_tier when the tier is not in ledger.tierMap. */
2017
+ * 422 unknown_tier when the tier is not in ledger.tierMap.
2018
+ * `charge_id` (optional) links the grant to the vxil charge (`chg_…`, from
2019
+ * `listCharges` or the `payments.charge.succeeded` / `.completed` event)
2020
+ * it was bought with: a FULL refund or chargeback of that charge ends the
2021
+ * grant (`payments.subscription.revoked` with `via: 'refund' | 'chargeback'`).
2022
+ * 404 charge_not_found; 422 charge_user_mismatch (another user's charge) /
2023
+ * charge_refunded (the refund beat the grant — nothing to grant against).
2024
+ * 422 reserved_idempotency_key when `idempotency_key` starts with
2025
+ * `purchase:` (the namespace of purchase passes). */
1866
2026
  grantSubscription: async (input) => (await this.call('POST', '/v1/payments/subscriptions/grant', input)).data,
1867
2027
  /** SERVER-ONLY. Revoke a MANUAL grant now (409 not_manual for a provider
1868
2028
  * subscription — use `cancel`). Never touches a provider. */
@@ -1896,7 +2056,12 @@ export class Vxil {
1896
2056
  * 403 `server_only`; the sweep is a tenant-wide operator surface. */
1897
2057
  reconcile: async () => (await this.call('GET', '/v1/payments/reconcile')).data,
1898
2058
  /** List charges newest-first (the refund enabler — discover the charge_id).
1899
- * Filters: user_id, status, limit (clamped 1..100, default 50). */
2059
+ * Filters: user_id, status, limit (clamped 1..100, default 50).
2060
+ * Each row carries the provider's own charge id (`provider_charge_id`),
2061
+ * the provider's charge state (`provider_status`: `'paid'` — Paddle's
2062
+ * money-taken state — or `'completed'`; null on rows recorded before
2063
+ * 2026-09-25) and the one-off `product_id` it bought, so a function can
2064
+ * match a charge to a provider event without reading the delivery log. */
1900
2065
  listCharges: async (q) => {
1901
2066
  const suffix = qs({
1902
2067
  user_id: q?.user_id || undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/sdk",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Typed client for the Vxil REST API (notifications, auth, jobs, files, cms, comments, webhooks, realtime, orgs, rate-limits).",