@noresponse/node 6.0.2 → 7.0.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.
@@ -1,28 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ProviderActionError = exports.DEFAULT_LATE_RESULT_POLICY = void 0;
4
- exports.lateResultAction = lateResultAction;
5
- exports.handleLateResult = handleLateResult;
6
- exports.clerkActions = clerkActions;
7
- exports.workosActions = workosActions;
8
- exports.auth0Actions = auth0Actions;
9
- exports.logOnlyActions = logOnlyActions;
3
+ exports.ProviderActionError = void 0;
4
+ exports.handleRiskChange = handleRiskChange;
5
+ exports.enforceAccount = enforceAccount;
6
+ exports.identityProvider = identityProvider;
7
+ exports.customProvider = customProvider;
8
+ const not_found_error_js_1 = require("./models/errors/not-found-error.js");
10
9
  const webhooks_js_1 = require("./webhooks.js");
11
- /** Revoke sessions on review and block; do nothing on allow or step_up. Banning is opt-in. */
12
- exports.DEFAULT_LATE_RESULT_POLICY = Object.freeze({
13
- allow: "none",
14
- step_up: "none",
15
- review: "revoke_sessions",
16
- block: "revoke_sessions",
17
- });
18
- /**
19
- * Thrown when the provider's admin API refuses a call. Answer the webhook with a 5xx so NoResponse retries it.
20
- * A 404 is never thrown: the user or session is already gone, so the action is done.
21
- */
10
+ const TABLE = { "lifecycle": { "onboarding": { "flow": "account_creation", "on": { "review": ["fail_onboarding", "revoke_sessions"], "block": ["fail_onboarding", "revoke_sessions"] } }, "provisional": { "flow": "account_creation", "on": { "review": ["reject_activation", "revoke_sessions"], "block": ["reject_activation", "revoke_sessions"] } }, "active": { "flow": "login", "on": { "review": ["revoke_sessions"], "block": ["restrict", "revoke_sessions"] } }, "restricted": { "flow": "login", "on": {}, "reinstate": { "allow": ["unrestrict"] } }, "rejected": { "flow": "account_creation", "on": {} } }, "providers": { "clerk": { "base_url": "https://api.clerk.com/v1", "actions": { "fail_onboarding": [{ "call": { "method": "POST", "path": "/users/{user}/ban" } }], "reject_activation": [{ "call": { "method": "POST", "path": "/users/{user}/ban" } }], "restrict": [{ "call": { "method": "POST", "path": "/users/{user}/ban" } }], "unrestrict": [{ "call": { "method": "POST", "path": "/users/{user}/unban" } }], "revoke_sessions": [{ "each": { "list": { "method": "GET", "path": "/sessions", "query": { "user_id": "{user}", "status": "active", "limit": "100" } }, "page": { "kind": "offset", "param": "offset", "size": 100 }, "where": { "status": "active" } }, "call": { "method": "POST", "path": "/sessions/{item}/revoke" } }] } }, "workos": { "base_url": "https://api.workos.com", "actions": { "fail_onboarding": [{ "call": { "method": "PUT", "path": "/user_management/users/{user}", "body": { "metadata": { "noresponse_state": "onboarding_failed" } } } }], "reject_activation": [{ "call": { "method": "PUT", "path": "/user_management/users/{user}", "body": { "metadata": { "noresponse_state": "activation_rejected" } } } }], "restrict": [{ "call": { "method": "PUT", "path": "/user_management/users/{user}", "body": { "metadata": { "noresponse_state": "restricted" } } } }], "unrestrict": [{ "call": { "method": "PUT", "path": "/user_management/users/{user}", "body": { "metadata": { "noresponse_state": null } } } }], "revoke_sessions": [{ "each": { "list": { "method": "GET", "path": "/user_management/users/{user}/sessions", "query": { "limit": "100" } }, "page": { "kind": "cursor", "param": "after", "next": ["list_metadata", "after"] }, "where": { "status": "active" } }, "call": { "method": "POST", "path": "/user_management/sessions/revoke", "body": { "session_id": "{item}" } } }] } }, "auth0": { "base_url": "https://{domain}/api/v2", "actions": { "fail_onboarding": [{ "call": { "method": "PATCH", "path": "/users/{user}", "body": { "blocked": true } } }], "reject_activation": [{ "call": { "method": "PATCH", "path": "/users/{user}", "body": { "blocked": true } } }], "restrict": [{ "call": { "method": "PATCH", "path": "/users/{user}", "body": { "blocked": true } } }], "unrestrict": [{ "call": { "method": "PATCH", "path": "/users/{user}", "body": { "blocked": false } } }], "revoke_sessions": [{ "call": { "method": "DELETE", "path": "/users/{user}/sessions" }, "fallback": { "status": 403, "code": "feature_not_enabled", "reports": "restrict", "steps": [{ "call": { "method": "PATCH", "path": "/users/{user}", "body": { "blocked": true } } }] } }, { "call": { "method": "DELETE", "path": "/users/{user}/refresh-tokens" }, "fallback": { "status": 403, "code": "feature_not_enabled", "steps": [{ "each": { "list": { "method": "GET", "path": "/device-credentials", "query": { "user_id": "{user}", "type": "refresh_token", "per_page": "100" } }, "page": { "kind": "index", "param": "page", "size": 100 } }, "call": { "method": "DELETE", "path": "/device-credentials/{item}" } }, { "each": { "list": { "method": "GET", "path": "/device-credentials", "query": { "user_id": "{user}", "type": "rotating_refresh_token", "per_page": "100" } }, "page": { "kind": "index", "param": "page", "size": 100 } }, "call": { "method": "DELETE", "path": "/device-credentials/{item}" } }] } }] } } }, "limits": { "retries": 4, "budget_ms": 3000, "backoff_ms": 250, "max_pages": 100 } };
11
+ /** Every action a provider runs, as the table names them. */
12
+ const ACTIONS = new Set(Object.values(TABLE.providers).flatMap((provider) => Object.keys(provider.actions)));
13
+ /** Each admin API call gives up after this long; the provider then counts as unreachable. */
14
+ const REQUEST_TIMEOUT_MS = 10000;
15
+ /** The provider refused an action or could not be reached (status 0). Answer the webhook with a 5xx so it is delivered again. */
22
16
  class ProviderActionError extends Error {
23
17
  provider;
24
18
  status;
25
- /** The provider's error code from the response body, such as Auth0's errorCode "insufficient_scope", when it sent one. */
19
+ /** The provider's error code, such as Auth0's errorCode "insufficient_scope", when it sent one. */
26
20
  code;
27
21
  constructor(provider, status, message, code) {
28
22
  super(message);
@@ -33,86 +27,200 @@ class ProviderActionError extends Error {
33
27
  }
34
28
  }
35
29
  exports.ProviderActionError = ProviderActionError;
36
- const ACTIONS = ["none", "revoke_sessions", "ban"];
37
30
  /**
38
- * The action your policy names for a verified event's account.risk.decision. Only a complete or partial
39
- * assessment whose account has a decision can act; a pending one and a test event from
40
- * webhookEndpoints.sendTestEvent never do.
31
+ * Verify an account.risk_changed delivery and enforce the account's current
32
+ * risk. A delivery that fails verification throws WebhookVerificationError
33
+ * before anything is read; any other event, and a test event, does nothing.
41
34
  */
42
- function lateResultAction(event, policy = exports.DEFAULT_LATE_RESULT_POLICY) {
43
- if (event.type !== "assessment.updated" && event.type !== "account.risk_changed")
44
- return "none";
45
- if (event.type === "assessment.updated" && event.data.status === "expired")
46
- return "none";
47
- if (event.type === "assessment.updated" && event.test === true)
48
- return "none";
49
- const data = event.data;
50
- // A pending answer carries an earlier assessment's decision; acting on it would repeat that action.
51
- if (event.type === "assessment.updated" && data.status === "pending")
52
- return "none";
53
- if (event.type === "account.risk_changed") {
54
- // The event is sent when any flow's decision changes; sessions are about the login
55
- // decision, so an unchanged login decision acts on nothing.
56
- const changed = event.data;
57
- if (changed.previousDecisionsByFlow.login === changed.account.decisionsByFlow.login)
58
- return "none";
35
+ async function handleRiskChange(client, payload, headers, secrets, options) {
36
+ const { provider: _provider, getAccountState: _getAccountState, flows: _flows, ...verify } = options;
37
+ const event = await (0, webhooks_js_1.constructEvent)(payload, headers, secrets, verify);
38
+ if (event.type !== "account.risk_changed" || event.test === true) {
39
+ return { event, skipped: "not_actionable", enforcements: [] };
59
40
  }
60
- const decision = event.type === "account.risk_changed"
61
- ? event.data.account.decisionsByFlow.login
62
- : data.account?.risk.decision;
63
- if (decision !== "allow" && decision !== "step_up" && decision !== "review" && decision !== "block")
64
- return "none";
65
- const action = Object.prototype.hasOwnProperty.call(policy, decision) ? policy[decision] : undefined;
66
- if (action === undefined)
67
- return "none";
68
- if (!ACTIONS.includes(action))
69
- throw new TypeError(`unknown late result action ${String(action)}`);
70
- return action;
41
+ let account;
42
+ try {
43
+ account = await client.accounts.get(event.data.account.id);
44
+ }
45
+ catch (error) {
46
+ if (error instanceof not_found_error_js_1.NotFoundError)
47
+ return { event, skipped: "account_gone", enforcements: [] };
48
+ throw error;
49
+ }
50
+ return { event, ...(await enforceAccount(client, account, options)) };
71
51
  }
52
+ const FLOW_KEYS = { account_creation: "accountCreation", login: "login", purchase: "purchase" };
72
53
  /**
73
- * Verify a webhook delivery and apply your policy to it.
74
- *
75
- * `payload`, `headers`, `secrets` and the tolerance options are exactly what
76
- * constructEvent takes; a delivery that fails verification throws
77
- * WebhookVerificationError before any provider call. A test event acts on
78
- * nothing. Revoke and ban are safe to repeat, but deduplicate on
79
- * `result.event.id` and ignore versions older than one you have acted on.
54
+ * Enforce one account as read: the account a handler fetched, or one from
55
+ * accounts.listChanged({ unenforced: true }) when catching up.
80
56
  */
81
- async function handleLateResult(payload, headers, secrets, provider, options = {}) {
82
- const { policy, userId: resolve, ...verify } = options;
83
- const event = await (0, webhooks_js_1.constructEvent)(payload, headers, secrets, verify);
84
- const action = lateResultAction(event, policy);
85
- if (action === "none")
86
- return { event, action: "none" };
87
- const accountId = event.type === "account.risk_changed"
88
- ? event.data.account.id
89
- : event.data.detail.accountId;
90
- const userId = resolve ? await resolve(accountId, event) : accountId;
91
- if (userId === null || userId === undefined || userId === "")
92
- return { event, action: "none" };
93
- let ran = action;
94
- if (provider.act)
95
- ran = await provider.act(action, userId);
96
- else if (action === "ban")
97
- await provider.ban(userId);
98
- else
99
- await provider.revokeSessions(userId);
100
- return { event, action: ran, userId };
57
+ async function enforceAccount(client, account, options) {
58
+ const answer = await options.getAccountState(account);
59
+ if (!answer)
60
+ return { account, skipped: "unknown_user", enforcements: [] };
61
+ const { state } = answer;
62
+ const rule = TABLE.lifecycle[state];
63
+ if (!rule)
64
+ throw new TypeError(`unknown account state ${String(state)}`);
65
+ const riskVersion = account.riskVersion;
66
+ if (answer.enforcedRiskVersion !== undefined && riskVersion <= answer.enforcedRiskVersion) {
67
+ return { account, state, riskVersion, skipped: "already_enforced", enforcements: [] };
68
+ }
69
+ const { flow } = rule;
70
+ const decision = account.decisionsByFlow[FLOW_KEYS[flow]];
71
+ const actions = rule.on[decision] ?? (options.flows?.[flow]?.reinstate ? rule.reinstate?.[decision] : undefined) ?? [];
72
+ const provider = options.provider;
73
+ const enforcements = [];
74
+ const record = async (action, status, errorCode) => {
75
+ const body = { riskVersion, flow, action, stateBefore: state, status, provider: provider.name, ...(errorCode ? { errorCode } : {}) };
76
+ await client.accounts.recordEnforcement(account.id, body);
77
+ enforcements.push({ flow, action, status, ...(errorCode ? { errorCode } : {}) });
78
+ };
79
+ if (actions.length === 0) {
80
+ // An adverse or step_up decision that needs nothing still counts as handled.
81
+ if (decision !== "allow")
82
+ await record("none", "applied");
83
+ return { account, state, riskVersion, enforcements };
84
+ }
85
+ const recorded = new Set();
86
+ for (const action of actions) {
87
+ let alsoRan;
88
+ try {
89
+ alsoRan = await provider.run(action, answer.userId || account.id);
90
+ }
91
+ catch (error) {
92
+ await record(action, "failed", failureCode(provider.name, error));
93
+ throw error;
94
+ }
95
+ for (const ran of [...alsoRan, action]) {
96
+ if (recorded.has(ran))
97
+ continue;
98
+ recorded.add(ran);
99
+ await record(ran, "applied");
100
+ }
101
+ }
102
+ return { account, state, riskVersion, enforcements };
101
103
  }
102
- /**
103
- * Rate limits. Auth0 answers 429 when the Management API rate limit is hit,
104
- * and a 429 means it did not act, so the call is safe to send again, a DELETE
105
- * included. One revoke or ban waits at most RATE_LIMIT_BUDGET_MS in all, and
106
- * each call retries at most RATE_LIMIT_RETRIES times: it waits Retry-After
107
- * seconds, else until X-RateLimit-Reset (Unix seconds), else 250 ms, 500 ms,
108
- * 1 s, 2 s. A wait that would pass the budget is not started: the 429 throws
109
- * at once as ProviderActionError, the webhook answers 5xx and NoResponse
110
- * delivers it again later. The budget keeps the handler inside the 5 s a
111
- * delivery has to be answered.
112
- */
113
- const RATE_LIMIT_RETRIES = 4;
114
- const RATE_LIMIT_BUDGET_MS = 3000;
115
- const RATE_LIMIT_BACKOFF_MS = 250;
104
+ const SLUG = /^[a-z0-9_.-]+$/;
105
+ /** The error_code of a failed action: the provider's code, else its HTTP status, as <provider>.<code>. */
106
+ function failureCode(provider, error) {
107
+ const code = error?.code;
108
+ const status = error instanceof ProviderActionError ? error.status : undefined;
109
+ const suffix = typeof code === "string" && SLUG.test(code.toLowerCase())
110
+ ? code.toLowerCase()
111
+ : status === 0 ? "unreachable" : status !== undefined ? `http_${status}` : "error";
112
+ return `${provider}.${suffix}`.slice(0, 128);
113
+ }
114
+ /** Clerk, WorkOS or Auth0, run from the shared action table. */
115
+ function identityProvider(name, options) {
116
+ const spec = TABLE.providers[name];
117
+ if (!spec)
118
+ throw new TypeError(`unknown identity provider ${String(name)}`);
119
+ if (!options?.token)
120
+ throw new TypeError(`${name} needs token`);
121
+ let base = options.baseUrl ?? spec.base_url;
122
+ if (base.includes("{domain}")) {
123
+ if (!options.domain)
124
+ throw new TypeError(`${name} needs domain or baseUrl`);
125
+ base = base.replace("{domain}", options.domain);
126
+ }
127
+ const http = { name, base: base.replace(/\/+$/, ""), authorization: `Bearer ${options.token}`, fetch: options.fetch ?? globalThis.fetch };
128
+ return {
129
+ name,
130
+ async run(action, userId) {
131
+ if (typeof userId !== "string" || userId === "")
132
+ throw new TypeError("user id must be a non-empty string");
133
+ const steps = spec.actions[action];
134
+ if (!steps)
135
+ throw new TypeError(`unknown action ${String(action)}`);
136
+ return runSteps(steps, { ...http, user: userId, waitedMs: 0 });
137
+ },
138
+ };
139
+ }
140
+ /** Your own user store: one function per action you support. An action without one fails. */
141
+ function customProvider(handlers) {
142
+ return {
143
+ name: "custom",
144
+ async run(action, userId) {
145
+ const handler = handlers[action];
146
+ if (!handler)
147
+ throw new TypeError(`customProvider has no ${action} handler`);
148
+ const alsoRan = await handler(userId);
149
+ // Only a list of action names counts; any other return value is no extra action.
150
+ return Array.isArray(alsoRan) ? alsoRan.filter((name) => typeof name === "string" && ACTIONS.has(name)) : [];
151
+ },
152
+ };
153
+ }
154
+ async function runSteps(steps, ctx) {
155
+ const alsoRan = [];
156
+ for (const step of steps) {
157
+ if (step.each) {
158
+ for (const item of await collect(step.each, ctx))
159
+ await send(step.call, ctx, item);
160
+ continue;
161
+ }
162
+ try {
163
+ await send(step.call, ctx);
164
+ }
165
+ catch (error) {
166
+ const fallback = step.fallback;
167
+ if (!fallback || !(error instanceof ProviderActionError) || error.status !== fallback.status || error.code !== fallback.code)
168
+ throw error;
169
+ alsoRan.push(...(await runSteps(fallback.steps, ctx)));
170
+ if (fallback.reports)
171
+ alsoRan.push(fallback.reports);
172
+ }
173
+ }
174
+ return alsoRan;
175
+ }
176
+ /** Every matching item id across the list's pages, collected before any is acted on. */
177
+ async function collect(each, ctx) {
178
+ const { list, page, where } = each;
179
+ const ids = [];
180
+ let cursor;
181
+ for (let index = 0; index < TABLE.limits.max_pages; index++) {
182
+ const query = { ...list.query };
183
+ if (page.kind === "offset")
184
+ query[page.param] = String(index * (page.size ?? 0));
185
+ else if (page.kind === "index")
186
+ query[page.param] = String(index);
187
+ else if (cursor !== undefined)
188
+ query[page.param] = cursor;
189
+ const body = await send({ ...list, query }, ctx);
190
+ const data = body?.data;
191
+ const items = Array.isArray(body) ? body : Array.isArray(data) ? data : [];
192
+ for (const item of items) {
193
+ const record = item;
194
+ const id = record?.["id"];
195
+ if (typeof id !== "string" || id === "")
196
+ continue;
197
+ if (Object.entries(where ?? {}).every(([key, value]) => record?.[key] === value))
198
+ ids.push(id);
199
+ }
200
+ if (page.kind === "cursor") {
201
+ let next = body;
202
+ for (const key of page.next ?? [])
203
+ next = next?.[key];
204
+ if (typeof next !== "string" || next === "")
205
+ break;
206
+ cursor = next;
207
+ }
208
+ else if (items.length < (page.size ?? 0))
209
+ break;
210
+ }
211
+ return ids;
212
+ }
213
+ function fill(value, user, item) {
214
+ if (typeof value === "string")
215
+ return value.replaceAll("{user}", user).replaceAll("{item}", item ?? "");
216
+ if (Array.isArray(value))
217
+ return value.map((entry) => fill(entry, user, item));
218
+ if (value !== null && typeof value === "object") {
219
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, fill(entry, user, item)]));
220
+ }
221
+ return value;
222
+ }
223
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
116
224
  /** How long a 429 asks to wait, in milliseconds. */
117
225
  function rateLimitWaitMs(headers, retry) {
118
226
  const after = headers.get("retry-after")?.trim();
@@ -127,261 +235,64 @@ function rateLimitWaitMs(headers, retry) {
127
235
  if (Number.isFinite(at))
128
236
  return Math.max(0, at * 1000 - Date.now());
129
237
  }
130
- return RATE_LIMIT_BACKOFF_MS * 2 ** retry;
131
- }
132
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
133
- function caller(name, baseUrl, authorization, fetcher, rateLimit) {
134
- const base = baseUrl.replace(/\/+$/, "");
135
- const run = fetcher ?? globalThis.fetch;
136
- // Every call names a user or a session. A 404 means it is already gone, so
137
- // revoke and ban count it as done and a repeated delivery does not fail.
138
- return async ({ method, path, query, body }) => {
139
- const url = new URL(base + path);
140
- for (const [key, value] of Object.entries(query ?? {}))
141
- url.searchParams.set(key, value);
142
- const headers = { authorization, accept: "application/json" };
143
- if (body !== undefined)
144
- headers["content-type"] = "application/json";
145
- for (let retry = 0;; retry++) {
146
- const response = await run(url, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) });
147
- const text = await response.text();
148
- if (response.status === 429 && rateLimit && retry < RATE_LIMIT_RETRIES) {
149
- const wait = rateLimitWaitMs(response.headers, retry);
150
- if (rateLimit.waitedMs + wait <= RATE_LIMIT_BUDGET_MS) {
151
- rateLimit.waitedMs += wait;
152
- await sleep(wait);
153
- continue;
154
- }
155
- }
156
- if (response.status === 404)
157
- return undefined;
158
- if (!response.ok)
159
- throw new ProviderActionError(name, response.status, `${name} ${method} ${path} answered ${response.status}`, errorCode(text));
160
- if (!text)
161
- return undefined;
162
- try {
163
- return JSON.parse(text);
164
- }
165
- catch {
166
- return undefined;
167
- }
168
- }
169
- };
238
+ return TABLE.limits.backoff_ms * 2 ** retry;
170
239
  }
171
- /** The error code in a provider's error body: Auth0's errorCode, or a top-level code. */
240
+ /** The provider's error code: Auth0's errorCode, WorkOS's code or Clerk's errors[0].code. */
172
241
  function errorCode(text) {
173
242
  try {
174
243
  const body = JSON.parse(text);
175
- const code = body?.errorCode ?? body?.code;
244
+ const code = body?.errorCode ?? body?.code ?? body?.errors?.[0]?.code;
176
245
  return typeof code === "string" && code !== "" ? code : undefined;
177
246
  }
178
247
  catch {
179
248
  return undefined;
180
249
  }
181
250
  }
182
- const segment = (id) => {
183
- if (typeof id !== "string" || id === "")
184
- throw new TypeError("user id must be a non-empty string");
185
- return encodeURIComponent(id);
186
- };
187
- function sessions(body) {
188
- if (Array.isArray(body))
189
- return body;
190
- const data = body?.data;
191
- return Array.isArray(data) ? data : [];
192
- }
193
- /**
194
- * Clerk Backend API. revokeSessions lists the user's active sessions
195
- * (GET /sessions?user_id&status=active) and revokes each
196
- * (POST /sessions/{id}/revoke). ban is POST /users/{id}/ban, which revokes
197
- * every session and blocks sign-in.
198
- */
199
- function clerkActions(options) {
200
- if (!options?.secretKey)
201
- throw new TypeError("clerkActions needs secretKey");
202
- const call = caller("clerk", options.baseUrl ?? "https://api.clerk.com/v1", `Bearer ${options.secretKey}`, options.fetch);
203
- const PAGE = 100;
204
- return {
205
- name: "clerk",
206
- async revokeSessions(userId) {
207
- segment(userId);
208
- const active = [];
209
- for (let offset = 0; offset < PAGE * 1000; offset += PAGE) {
210
- const page = sessions(await call({ method: "GET", path: "/sessions", query: { user_id: userId, status: "active", limit: String(PAGE), offset: String(offset) } }));
211
- for (const s of page)
212
- if (s.status === "active" && typeof s.id === "string" && s.id)
213
- active.push(s.id);
214
- if (page.length < PAGE)
215
- break;
216
- }
217
- for (const id of active)
218
- await call({ method: "POST", path: `/sessions/${encodeURIComponent(id)}/revoke` });
219
- },
220
- async ban(userId) {
221
- await call({ method: "POST", path: `/users/${segment(userId)}/ban` });
222
- },
223
- };
224
- }
225
- /**
226
- * WorkOS User Management. revokeSessions pages through
227
- * GET /user_management/users/{id}/sessions and revokes each active one with
228
- * POST /user_management/sessions/revoke. WorkOS has no block or ban, so ban
229
- * revokes the sessions and then deletes the user
230
- * (DELETE /user_management/users/{id}); deletion is permanent.
231
- */
232
- function workosActions(options) {
233
- if (!options?.apiKey)
234
- throw new TypeError("workosActions needs apiKey");
235
- const call = caller("workos", options.baseUrl ?? "https://api.workos.com", `Bearer ${options.apiKey}`, options.fetch);
236
- const revokeSessions = async (userId) => {
237
- const user = segment(userId);
238
- const active = [];
239
- let after;
240
- for (let round = 0; round < 1000; round++) {
241
- const query = { limit: "100" };
242
- if (after)
243
- query["after"] = after;
244
- const body = (await call({ method: "GET", path: `/user_management/users/${user}/sessions`, query }));
245
- for (const s of sessions(body))
246
- if (s.status === "active" && typeof s.id === "string" && s.id)
247
- active.push(s.id);
248
- const next = body?.list_metadata?.after;
249
- if (typeof next !== "string" || next === "")
250
- break;
251
- after = next;
252
- }
253
- for (const id of active)
254
- await call({ method: "POST", path: "/user_management/sessions/revoke", body: { session_id: id } });
255
- };
256
- return {
257
- name: "workos",
258
- revokeSessions,
259
- async ban(userId) {
260
- await revokeSessions(userId);
261
- await call({ method: "DELETE", path: `/user_management/users/${segment(userId)}` });
262
- },
263
- };
264
- }
265
- /** Auth0 answers 403 with errorCode feature_not_enabled when the tenant's plan does not include an endpoint. */
266
- const notOnPlan = (error) => error instanceof ProviderActionError && error.status === 403 && error.code === "feature_not_enabled";
267
- /**
268
- * Auth0 Management API.
269
- *
270
- * revokeSessions deletes every session (DELETE /users/{id}/sessions) and
271
- * every refresh token (DELETE /users/{id}/refresh-tokens). ban first sets
272
- * blocked: true (PATCH /users/{id}), then does the same.
273
- *
274
- * Auth0 offers the sessions and refresh-token endpoints on Enterprise plans
275
- * only; on other plans they answer 403 feature_not_enabled. Then:
276
- * - refresh tokens are deleted one by one through
277
- * /device-credentials (types refresh_token and rotating_refresh_token),
278
- * which every plan has;
279
- * - a login session cannot be ended, so revokeSessions blocks the user
280
- * (PATCH blocked: true) before deleting the refresh tokens, and act
281
- * reports "ban". Unblock the user in the dashboard to undo it.
282
- * Any other refusal, such as 403 insufficient_scope, throws.
283
- *
284
- * A 429 from the Management API is waited out and retried: Retry-After, else
285
- * X-RateLimit-Reset, else 250 ms doubling, at most 4 retries a call and 3 s of
286
- * waiting a revoke or ban. Past that the 429 throws as ProviderActionError.
287
- */
288
- function auth0Actions(options) {
289
- if (!options?.token)
290
- throw new TypeError("auth0Actions needs token");
291
- const base = options.baseUrl ?? (options.domain ? `https://${options.domain}/api/v2` : undefined);
292
- if (!base)
293
- throw new TypeError("auth0Actions needs domain or baseUrl");
294
- // A fresh caller per action, so each revoke or ban has its own rate-limit budget.
295
- const session = () => caller("auth0", base, `Bearer ${options.token}`, options.fetch, { waitedMs: 0 });
296
- const block = (call, user) => call({ method: "PATCH", path: `/users/${user}`, body: { blocked: true } });
297
- /** End every session; false when the plan has no sessions API. */
298
- const deleteSessions = async (call, user) => {
251
+ /** One admin API call. A 404 is done (undefined); a 429 is waited out inside the action's budget; any other failure throws. */
252
+ async function send(request, ctx, item) {
253
+ const path = request.path.replaceAll("{user}", encodeURIComponent(ctx.user)).replaceAll("{item}", encodeURIComponent(item ?? ""));
254
+ const url = new URL(ctx.base + path);
255
+ for (const [key, value] of Object.entries(request.query ?? {}))
256
+ url.searchParams.set(key, fill(value, ctx.user, item));
257
+ const headers = { authorization: ctx.authorization, accept: "application/json" };
258
+ const body = request.body === undefined ? undefined : JSON.stringify(fill(request.body, ctx.user, item));
259
+ if (body !== undefined)
260
+ headers["content-type"] = "application/json";
261
+ const label = `${ctx.name} ${request.method} ${request.path}`;
262
+ for (let retry = 0;; retry++) {
263
+ let response;
299
264
  try {
300
- await call({ method: "DELETE", path: `/users/${user}/sessions` });
301
- return true;
265
+ response = await ctx.fetch(url, {
266
+ method: request.method,
267
+ headers,
268
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
269
+ ...(body !== undefined ? { body } : {}),
270
+ });
302
271
  }
303
272
  catch (error) {
304
- if (notOnPlan(error))
305
- return false;
306
- throw error;
273
+ throw new ProviderActionError(ctx.name, 0, `${label} failed: ${error?.message ?? String(error)}`);
307
274
  }
308
- };
309
- const deleteRefreshTokens = async (call, userId, user) => {
310
- try {
311
- await call({ method: "DELETE", path: `/users/${user}/refresh-tokens` });
312
- return;
275
+ const text = await response.text();
276
+ if (response.status === 429 && retry < TABLE.limits.retries) {
277
+ const wait = rateLimitWaitMs(response.headers, retry);
278
+ if (ctx.waitedMs + wait <= TABLE.limits.budget_ms) {
279
+ ctx.waitedMs += wait;
280
+ await sleep(wait);
281
+ continue;
282
+ }
313
283
  }
314
- catch (error) {
315
- if (!notOnPlan(error))
316
- throw error;
284
+ if (response.status === 404)
285
+ return undefined;
286
+ if (!response.ok)
287
+ throw new ProviderActionError(ctx.name, response.status, `${label} answered ${response.status}`, errorCode(text));
288
+ if (!text)
289
+ return undefined;
290
+ try {
291
+ return JSON.parse(text);
317
292
  }
318
- // Collect every id before deleting any, so the pages do not shift underneath.
319
- const ids = [];
320
- for (const type of ["refresh_token", "rotating_refresh_token"]) {
321
- for (let page = 0; page < 10; page++) {
322
- const body = await call({ method: "GET", path: "/device-credentials", query: { user_id: userId, type, page: String(page), per_page: "100" } });
323
- const list = Array.isArray(body) ? body : [];
324
- for (const credential of list)
325
- if (typeof credential?.id === "string" && credential.id)
326
- ids.push(credential.id);
327
- if (list.length < 100)
328
- break;
329
- }
293
+ catch {
294
+ return undefined;
330
295
  }
331
- for (const id of ids)
332
- await call({ method: "DELETE", path: `/device-credentials/${encodeURIComponent(id)}` });
333
- };
334
- const revoke = async (userId) => {
335
- const user = segment(userId);
336
- const call = session();
337
- const ended = await deleteSessions(call, user);
338
- // Without the sessions API a login session survives; blocking is the only way to end it.
339
- if (!ended)
340
- await block(call, user);
341
- await deleteRefreshTokens(call, userId, user);
342
- return ended ? "revoke_sessions" : "ban";
343
- };
344
- const ban = async (userId) => {
345
- const user = segment(userId);
346
- const call = session();
347
- await block(call, user);
348
- // The block already ends a session the plan cannot delete.
349
- await deleteSessions(call, user);
350
- await deleteRefreshTokens(call, userId, user);
351
- };
352
- return {
353
- name: "auth0",
354
- async revokeSessions(userId) {
355
- await revoke(userId);
356
- },
357
- ban,
358
- async act(action, userId) {
359
- if (action === "ban") {
360
- await ban(userId);
361
- return "ban";
362
- }
363
- return revoke(userId);
364
- },
365
- };
366
- }
367
- /**
368
- * A provider for an app with its own login, or for trying the handler before
369
- * you wire up a real one. revokeSessions and ban each log what they would do
370
- * and call nothing: no request leaves your process. Replace it with your own
371
- * LateResultProvider once you know what revoking and banning mean in your app.
372
- */
373
- function logOnlyActions(options = {}) {
374
- const log = options.log ?? ((message) => console.info(message));
375
- return {
376
- name: "log_only",
377
- async revokeSessions(userId) {
378
- segment(userId);
379
- log(`NoResponse late result (log only): would revoke every session of user ${userId}`, { action: "revoke_sessions", userId });
380
- },
381
- async ban(userId) {
382
- segment(userId);
383
- log(`NoResponse late result (log only): would ban user ${userId} and end their sessions`, { action: "ban", userId });
384
- },
385
- };
296
+ }
386
297
  }
387
298
  //# sourceMappingURL=late-results.js.map