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