@vxil/sdk 0.6.0 → 0.7.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 +148 -0
- package/dist/index.js +126 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -802,6 +802,11 @@ export interface JobRunEventPayload {
|
|
|
802
802
|
attempt: number;
|
|
803
803
|
level?: 'error';
|
|
804
804
|
state?: 'broken';
|
|
805
|
+
/** `job.dead_lettered` only, and only when something other than the
|
|
806
|
+
* executor killed the run: `reaped` (the stuck-run reaper) or
|
|
807
|
+
* `queue_backstop` (the queue's own retries ran out). Absent when the run
|
|
808
|
+
* exhausted its attempts normally. */
|
|
809
|
+
reason?: 'reaped' | 'queue_backstop';
|
|
805
810
|
}
|
|
806
811
|
/** Event name → typed `data`, for the events that settle work you started.
|
|
807
812
|
* `VxilEventPayload<'job.generation.failed'>` names one; everything else on
|
|
@@ -814,6 +819,121 @@ export interface VxilEventPayloads {
|
|
|
814
819
|
'job.dead_lettered': JobRunEventPayload;
|
|
815
820
|
}
|
|
816
821
|
export type VxilEventPayload<E extends string> = E extends keyof VxilEventPayloads ? VxilEventPayloads[E] : Record<string, unknown>;
|
|
822
|
+
/** The `trigger` label on the envelope. The config spells two of them in
|
|
823
|
+
* camelCase (`cmsHook`, `authHook`); on the wire they are kebab-case. */
|
|
824
|
+
export type FunctionEnvelopeTrigger = 'http' | 'cms-hook' | 'auth-hook' | 'queue' | 'cron' | 'webhook';
|
|
825
|
+
/** The audiences a function's scoped callback tokens are minted for — one per
|
|
826
|
+
* feature its declared scopes reach. `control-plane` carries `users:*` /
|
|
827
|
+
* `usage:read` only. There is deliberately no `functions` audience: a function
|
|
828
|
+
* cannot call another function. */
|
|
829
|
+
export type FunctionCallbackAudience = 'cms' | 'payments' | 'notifications' | 'comments' | 'files' | 'ai' | 'rag' | 'vector-search' | 'activity-feed' | 'orgs' | 'auth' | 'jobs' | 'realtime' | 'rate-limits' | 'control-plane';
|
|
830
|
+
/** One short-lived scoped token per audience your scopes imply
|
|
831
|
+
* (`env.scoped_jwts.cms`, `env.scoped_jwts['control-plane']`); an audience your
|
|
832
|
+
* scopes do not reach is absent. Send it as `Authorization: Bearer …` to
|
|
833
|
+
* `vxil_base`. */
|
|
834
|
+
export type FunctionScopedJwts = {
|
|
835
|
+
[A in FunctionCallbackAudience]?: string;
|
|
836
|
+
};
|
|
837
|
+
/** The verified end-user an `http` invocation carries when the caller presented
|
|
838
|
+
* a vxil-auth session. */
|
|
839
|
+
export interface FunctionEndUser {
|
|
840
|
+
id: string;
|
|
841
|
+
sid: string;
|
|
842
|
+
/** the step-up epoch, when the session has one */
|
|
843
|
+
elv?: number;
|
|
844
|
+
}
|
|
845
|
+
/** The fields every invocation carries, whatever fired it. */
|
|
846
|
+
export interface FunctionEnvelopeBase {
|
|
847
|
+
tenant_id: string;
|
|
848
|
+
request_id: string;
|
|
849
|
+
/** Stable across redeliveries of the same event: delivery is at-least-once,
|
|
850
|
+
* so dedupe your writes on it. */
|
|
851
|
+
idempotency_key: string;
|
|
852
|
+
/** The edge to call vxil back through. */
|
|
853
|
+
vxil_base: string;
|
|
854
|
+
scoped_jwts: FunctionScopedJwts;
|
|
855
|
+
/** Your declared per-function secrets (`secrets: ['secret:<name>']`), keyed by
|
|
856
|
+
* bare name and resolved at invoke time. */
|
|
857
|
+
secrets: Record<string, string>;
|
|
858
|
+
}
|
|
859
|
+
/** `payload` of a `cms-hook` invocation. It carries no field values: re-read
|
|
860
|
+
* the item by `item_id`. */
|
|
861
|
+
export interface CmsHookTriggerPayload {
|
|
862
|
+
/** `cms.item.created` or `cms.item.updated` for a binding that names a
|
|
863
|
+
* collection; a binding without one also sees the other `cms.item.*` events */
|
|
864
|
+
event: string;
|
|
865
|
+
collection: string;
|
|
866
|
+
item_id: string;
|
|
867
|
+
}
|
|
868
|
+
/** `payload` of an `auth-hook` invocation: the event's own fields plus
|
|
869
|
+
* `event`. `auth.user.created` → `{ user_id, method, is_anonymous }`;
|
|
870
|
+
* `auth.session.created` → `{ user_id, session_id }`; `auth.session.revoked`
|
|
871
|
+
* adds `reason`; `auth.signin.failure` → `{ email_hash | user_id, reason }`. */
|
|
872
|
+
export interface AuthHookTriggerPayload {
|
|
873
|
+
event: string;
|
|
874
|
+
user_id?: string;
|
|
875
|
+
[field: string]: unknown;
|
|
876
|
+
}
|
|
877
|
+
/** `payload` of a `webhook` invocation: one platform event under the binding's
|
|
878
|
+
* `source` prefix. `D` is the event's own payload (see `VxilEventPayload`). */
|
|
879
|
+
export interface WebhookTriggerPayload<D = Record<string, unknown>> {
|
|
880
|
+
event: string;
|
|
881
|
+
audit_id: number | null;
|
|
882
|
+
occurred_at: string | null;
|
|
883
|
+
actor: string | null;
|
|
884
|
+
surface: string | null;
|
|
885
|
+
/** the event's payload; `{ truncated: true }` when it was larger than the
|
|
886
|
+
* function's webhook payload bound, null when the event carried none */
|
|
887
|
+
data: D | {
|
|
888
|
+
truncated: true;
|
|
889
|
+
} | null;
|
|
890
|
+
}
|
|
891
|
+
/** `POST /v1/fn/:name`: `payload` is the JSON request body (the query
|
|
892
|
+
* parameters on a GET). */
|
|
893
|
+
export interface HttpFunctionEnvelope<P = unknown> extends FunctionEnvelopeBase {
|
|
894
|
+
trigger: 'http';
|
|
895
|
+
/** present only when the caller presented a verified end-user session */
|
|
896
|
+
end_user?: FunctionEndUser;
|
|
897
|
+
payload: P;
|
|
898
|
+
}
|
|
899
|
+
export interface CmsHookFunctionEnvelope extends FunctionEnvelopeBase {
|
|
900
|
+
trigger: 'cms-hook';
|
|
901
|
+
payload: CmsHookTriggerPayload;
|
|
902
|
+
}
|
|
903
|
+
export interface AuthHookFunctionEnvelope extends FunctionEnvelopeBase {
|
|
904
|
+
trigger: 'auth-hook';
|
|
905
|
+
payload: AuthHookTriggerPayload;
|
|
906
|
+
}
|
|
907
|
+
/** `payload` is exactly what the job was enqueued with. */
|
|
908
|
+
export interface QueueFunctionEnvelope<P = unknown> extends FunctionEnvelopeBase {
|
|
909
|
+
trigger: 'queue';
|
|
910
|
+
payload: P;
|
|
911
|
+
}
|
|
912
|
+
/** A schedule tick carries no data: `payload` is `{}`. */
|
|
913
|
+
export interface CronFunctionEnvelope extends FunctionEnvelopeBase {
|
|
914
|
+
trigger: 'cron';
|
|
915
|
+
payload: Record<string, unknown>;
|
|
916
|
+
}
|
|
917
|
+
export interface WebhookFunctionEnvelope<D = Record<string, unknown>> extends FunctionEnvelopeBase {
|
|
918
|
+
trigger: 'webhook';
|
|
919
|
+
payload: WebhookTriggerPayload<D>;
|
|
920
|
+
}
|
|
921
|
+
/** The invocation envelope, discriminated on `trigger`. `P` types the `http`
|
|
922
|
+
* body and the `queue` payload. A function bound to one trigger can name its
|
|
923
|
+
* member directly (`CmsHookFunctionEnvelope`, `WebhookFunctionEnvelope<D>`). */
|
|
924
|
+
export type FunctionEnvelope<P = unknown> = HttpFunctionEnvelope<P> | CmsHookFunctionEnvelope | AuthHookFunctionEnvelope | QueueFunctionEnvelope<P> | CronFunctionEnvelope | WebhookFunctionEnvelope;
|
|
925
|
+
/** The body the jobs engine POSTs to a run's `target_url` (signed with
|
|
926
|
+
* `X-Vxil-Jobs-Signature`; verify it with the secret from
|
|
927
|
+
* `jobs.signingSecret()`). Redelivered attempts carry the same `run_id` and a
|
|
928
|
+
* higher `attempt`. */
|
|
929
|
+
export interface JobDelivery<P = unknown> {
|
|
930
|
+
run_id: string;
|
|
931
|
+
job_name: string;
|
|
932
|
+
/** 1-based */
|
|
933
|
+
attempt: number;
|
|
934
|
+
/** exactly what the run was enqueued with */
|
|
935
|
+
payload: P;
|
|
936
|
+
}
|
|
817
937
|
/** A re-minted mid-stream connect token (GET /v1/ai/generations/{id}/token). */
|
|
818
938
|
export interface AiStreamToken {
|
|
819
939
|
generation_id: string;
|
|
@@ -2616,6 +2736,34 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
2616
2736
|
user_id: string;
|
|
2617
2737
|
session: AuthSession;
|
|
2618
2738
|
}>;
|
|
2739
|
+
/** Keep a session pair usable: when it expires within `skewSeconds`
|
|
2740
|
+
* (default 300) — or `expires_at` is missing/unparseable, or already past
|
|
2741
|
+
* — rotate it through `refresh` and return the NEW pair with
|
|
2742
|
+
* `refreshed: true`; otherwise return it unchanged, with no request.
|
|
2743
|
+
* Store the pair again whenever `refreshed` is true (the old refresh
|
|
2744
|
+
* token is revoked by the rotation).
|
|
2745
|
+
*
|
|
2746
|
+
* The window is capped at half the token's own lifetime (iat→exp, read
|
|
2747
|
+
* from the session JWT; 60 s for an opaque token), so a short session
|
|
2748
|
+
* TTL never makes every call rotate. Concurrent calls for the same
|
|
2749
|
+
* refresh token in one process share ONE rotation, and for 30 s after it
|
|
2750
|
+
* settles a call still carrying the OLD pair gets the new pair (no second
|
|
2751
|
+
* request) while a call with the just-minted pair gets it back unchanged.
|
|
2752
|
+
* A refresh that fails (revoked, expired, or already rotated by another
|
|
2753
|
+
* process) throws the `VxilError` — treat it as "sign in again".
|
|
2754
|
+
*
|
|
2755
|
+
* Call it on a client whose key carries `auth:signin` — normally the
|
|
2756
|
+
* sign-in key, in server mode. An end-user-mode client sends its
|
|
2757
|
+
* X-Vxil-End-User token with the rotation only while that token is the
|
|
2758
|
+
* pair's own and still has 10 s left; otherwise the rotation goes out
|
|
2759
|
+
* without it, which a thin-client (`end_user_required`) key cannot do.
|
|
2760
|
+
* See https://vxil.com/docs/guide/09-security-and-multitenancy. */
|
|
2761
|
+
ensureFresh: (pair: AuthSession, opts?: {
|
|
2762
|
+
skewSeconds?: number;
|
|
2763
|
+
}) => Promise<{
|
|
2764
|
+
session: AuthSession;
|
|
2765
|
+
refreshed: boolean;
|
|
2766
|
+
}>;
|
|
2619
2767
|
revoke: (token: string) => Promise<void>;
|
|
2620
2768
|
/** "Sign out everywhere": revoke EVERY live session of a user in one call
|
|
2621
2769
|
* (one statement, one batched edge-cache write; each session's
|
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
|
|
@@ -788,6 +851,69 @@ export class Vxil {
|
|
|
788
851
|
verify: async (token) => (await this.call('POST', '/v1/auth/sessions/verify', { token })).data,
|
|
789
852
|
/** Rotates the refresh token: store the returned pair, discard the old one. */
|
|
790
853
|
refresh: async (refreshToken) => (await this.call('POST', '/v1/auth/sessions/refresh', { refresh_token: refreshToken })).data,
|
|
854
|
+
/** Keep a session pair usable: when it expires within `skewSeconds`
|
|
855
|
+
* (default 300) — or `expires_at` is missing/unparseable, or already past
|
|
856
|
+
* — rotate it through `refresh` and return the NEW pair with
|
|
857
|
+
* `refreshed: true`; otherwise return it unchanged, with no request.
|
|
858
|
+
* Store the pair again whenever `refreshed` is true (the old refresh
|
|
859
|
+
* token is revoked by the rotation).
|
|
860
|
+
*
|
|
861
|
+
* The window is capped at half the token's own lifetime (iat→exp, read
|
|
862
|
+
* from the session JWT; 60 s for an opaque token), so a short session
|
|
863
|
+
* TTL never makes every call rotate. Concurrent calls for the same
|
|
864
|
+
* refresh token in one process share ONE rotation, and for 30 s after it
|
|
865
|
+
* settles a call still carrying the OLD pair gets the new pair (no second
|
|
866
|
+
* request) while a call with the just-minted pair gets it back unchanged.
|
|
867
|
+
* A refresh that fails (revoked, expired, or already rotated by another
|
|
868
|
+
* process) throws the `VxilError` — treat it as "sign in again".
|
|
869
|
+
*
|
|
870
|
+
* Call it on a client whose key carries `auth:signin` — normally the
|
|
871
|
+
* sign-in key, in server mode. An end-user-mode client sends its
|
|
872
|
+
* X-Vxil-End-User token with the rotation only while that token is the
|
|
873
|
+
* pair's own and still has 10 s left; otherwise the rotation goes out
|
|
874
|
+
* without it, which a thin-client (`end_user_required`) key cannot do.
|
|
875
|
+
* See https://vxil.com/docs/guide/09-security-and-multitenancy. */
|
|
876
|
+
ensureFresh: async (pair, opts = {}) => {
|
|
877
|
+
const skew = opts.skewSeconds ?? ENSURE_FRESH_SKEW_SECONDS;
|
|
878
|
+
if (typeof skew !== 'number' || !Number.isFinite(skew) || skew < 0) {
|
|
879
|
+
throw new VxilError(0, 'invalid_argument', 'ensureFresh: skewSeconds must be a finite number ≥ 0.');
|
|
880
|
+
}
|
|
881
|
+
const now = Date.now();
|
|
882
|
+
const key = `${this.base}\n${pair.refresh_token}`;
|
|
883
|
+
// A pair this process already rotated (or is rotating): its tokens are
|
|
884
|
+
// revoked, so the answer is the winner's pair whatever expires_at says.
|
|
885
|
+
const prior = pair.refresh_token ? refreshRotations.get(key) : undefined;
|
|
886
|
+
if (prior && prior.until > now)
|
|
887
|
+
return { session: await prior.session, refreshed: true };
|
|
888
|
+
const lifetime = sessionLifetimeSeconds(pair.token);
|
|
889
|
+
const window = lifetime !== undefined
|
|
890
|
+
? Math.min(skew, lifetime * ENSURE_FRESH_MAX_WINDOW_FRACTION)
|
|
891
|
+
: Math.min(skew, ENSURE_FRESH_OPAQUE_MAX_SKEW_SECONDS);
|
|
892
|
+
const expMs = Date.parse(pair.expires_at);
|
|
893
|
+
if (Number.isFinite(expMs) && expMs - now > window * 1000)
|
|
894
|
+
return { session: pair, refreshed: false };
|
|
895
|
+
if (pair.refresh_token && (refreshJustMinted.get(key) ?? 0) > now)
|
|
896
|
+
return { session: pair, refreshed: false };
|
|
897
|
+
if (!pair.refresh_token) {
|
|
898
|
+
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.');
|
|
899
|
+
}
|
|
900
|
+
const keepEndUser = this.endUserToken !== undefined && this.endUserToken === pair.token
|
|
901
|
+
&& Number.isFinite(expMs) && expMs - now >= ENSURE_FRESH_HEADER_MARGIN_MS;
|
|
902
|
+
const client = this.endUserToken && !keepEndUser ? this.asEndUser(undefined) : this;
|
|
903
|
+
sweepRefreshCaches(now);
|
|
904
|
+
const entry = {
|
|
905
|
+
session: client.call('POST', '/v1/auth/sessions/refresh', { refresh_token: pair.refresh_token }).then((r) => r.data.session),
|
|
906
|
+
until: Number.POSITIVE_INFINITY,
|
|
907
|
+
};
|
|
908
|
+
entry.session.then((fresh) => {
|
|
909
|
+
entry.until = Date.now() + ENSURE_FRESH_GRACE_MS;
|
|
910
|
+
if (fresh.refresh_token)
|
|
911
|
+
refreshJustMinted.set(`${this.base}\n${fresh.refresh_token}`, entry.until);
|
|
912
|
+
}, () => { if (refreshRotations.get(key) === entry)
|
|
913
|
+
refreshRotations.delete(key); });
|
|
914
|
+
refreshRotations.set(key, entry);
|
|
915
|
+
return { session: await entry.session, refreshed: true };
|
|
916
|
+
},
|
|
791
917
|
revoke: async (token) => {
|
|
792
918
|
await this.call('POST', '/v1/auth/sessions/revoke', { token });
|
|
793
919
|
},
|
package/package.json
CHANGED