@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 +241 -14
- package/dist/index.js +168 -3
- package/package.json +1 -1
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
|
|
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:
|
|
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
|
|
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