@teleporthq/teleport-plugin-next-workflows 0.45.2 → 0.45.5

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 (78) hide show
  1. package/__tests__/analytics-outcome-visit-ids.test.ts +583 -0
  2. package/__tests__/analytics-track-outcome.test.ts +71 -3
  3. package/__tests__/auth-password-security.test.ts +101 -0
  4. package/__tests__/automations-bus.test.ts +439 -2
  5. package/__tests__/security-lists-parity.test.ts +2 -2
  6. package/__tests__/security-runtime.test.ts +37 -20
  7. package/dist/cjs/api-route-generator.d.ts +2 -0
  8. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  9. package/dist/cjs/api-route-generator.js +43 -1
  10. package/dist/cjs/api-route-generator.js.map +1 -1
  11. package/dist/cjs/auth-generator.d.ts.map +1 -1
  12. package/dist/cjs/auth-generator.js +48 -19
  13. package/dist/cjs/auth-generator.js.map +1 -1
  14. package/dist/cjs/automations/dispatcher-source.d.ts.map +1 -1
  15. package/dist/cjs/automations/dispatcher-source.js +13 -5
  16. package/dist/cjs/automations/dispatcher-source.js.map +1 -1
  17. package/dist/cjs/automations/event-bus-source.d.ts.map +1 -1
  18. package/dist/cjs/automations/event-bus-source.js +37 -23
  19. package/dist/cjs/automations/event-bus-source.js.map +1 -1
  20. package/dist/cjs/executor-generator.d.ts.map +1 -1
  21. package/dist/cjs/executor-generator.js +4 -0
  22. package/dist/cjs/executor-generator.js.map +1 -1
  23. package/dist/cjs/nodes/account/account-request-password-reset.d.ts.map +1 -1
  24. package/dist/cjs/nodes/account/account-request-password-reset.js +3 -2
  25. package/dist/cjs/nodes/account/account-request-password-reset.js.map +1 -1
  26. package/dist/cjs/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
  27. package/dist/cjs/nodes/analytics/analytics-track-outcome.js +17 -4
  28. package/dist/cjs/nodes/analytics/analytics-track-outcome.js.map +1 -1
  29. package/dist/cjs/password-reset-route-generator.d.ts +2 -0
  30. package/dist/cjs/password-reset-route-generator.d.ts.map +1 -1
  31. package/dist/cjs/password-reset-route-generator.js +17 -10
  32. package/dist/cjs/password-reset-route-generator.js.map +1 -1
  33. package/dist/cjs/security-runtime/security-runtime-code.d.ts +3 -2
  34. package/dist/cjs/security-runtime/security-runtime-code.d.ts.map +1 -1
  35. package/dist/cjs/security-runtime/security-runtime-code.js +17 -13
  36. package/dist/cjs/security-runtime/security-runtime-code.js.map +1 -1
  37. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  38. package/dist/esm/api-route-generator.d.ts +2 -0
  39. package/dist/esm/api-route-generator.d.ts.map +1 -1
  40. package/dist/esm/api-route-generator.js +44 -2
  41. package/dist/esm/api-route-generator.js.map +1 -1
  42. package/dist/esm/auth-generator.d.ts.map +1 -1
  43. package/dist/esm/auth-generator.js +48 -19
  44. package/dist/esm/auth-generator.js.map +1 -1
  45. package/dist/esm/automations/dispatcher-source.d.ts.map +1 -1
  46. package/dist/esm/automations/dispatcher-source.js +13 -5
  47. package/dist/esm/automations/dispatcher-source.js.map +1 -1
  48. package/dist/esm/automations/event-bus-source.d.ts.map +1 -1
  49. package/dist/esm/automations/event-bus-source.js +37 -23
  50. package/dist/esm/automations/event-bus-source.js.map +1 -1
  51. package/dist/esm/executor-generator.d.ts.map +1 -1
  52. package/dist/esm/executor-generator.js +4 -0
  53. package/dist/esm/executor-generator.js.map +1 -1
  54. package/dist/esm/nodes/account/account-request-password-reset.d.ts.map +1 -1
  55. package/dist/esm/nodes/account/account-request-password-reset.js +3 -2
  56. package/dist/esm/nodes/account/account-request-password-reset.js.map +1 -1
  57. package/dist/esm/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
  58. package/dist/esm/nodes/analytics/analytics-track-outcome.js +17 -4
  59. package/dist/esm/nodes/analytics/analytics-track-outcome.js.map +1 -1
  60. package/dist/esm/password-reset-route-generator.d.ts +2 -0
  61. package/dist/esm/password-reset-route-generator.d.ts.map +1 -1
  62. package/dist/esm/password-reset-route-generator.js +16 -9
  63. package/dist/esm/password-reset-route-generator.js.map +1 -1
  64. package/dist/esm/security-runtime/security-runtime-code.d.ts +3 -2
  65. package/dist/esm/security-runtime/security-runtime-code.d.ts.map +1 -1
  66. package/dist/esm/security-runtime/security-runtime-code.js +17 -13
  67. package/dist/esm/security-runtime/security-runtime-code.js.map +1 -1
  68. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +2 -2
  70. package/src/api-route-generator.ts +52 -2
  71. package/src/auth-generator.ts +48 -19
  72. package/src/automations/dispatcher-source.ts +13 -5
  73. package/src/automations/event-bus-source.ts +37 -23
  74. package/src/executor-generator.ts +4 -0
  75. package/src/nodes/account/account-request-password-reset.ts +3 -2
  76. package/src/nodes/analytics/analytics-track-outcome.ts +17 -4
  77. package/src/password-reset-route-generator.ts +18 -9
  78. package/src/security-runtime/security-runtime-code.ts +17 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teleporthq/teleport-plugin-next-workflows",
3
- "version": "0.45.2",
3
+ "version": "0.45.5",
4
4
  "description": "A plugin for workflow code generation in Next.js projects.",
5
5
  "author": "teleportHQ",
6
6
  "license": "MIT",
@@ -69,5 +69,5 @@
69
69
  "@teleporthq/teleport-shared": "^0.45.0",
70
70
  "@teleporthq/teleport-types": "^0.45.0"
71
71
  },
72
- "gitHead": "3be925a09c56ec0afe44940a623cbfcc54e5170f"
72
+ "gitHead": "3af9d41f936652db5b37d0a40ebcc78bdfd685a5"
73
73
  }
@@ -14,7 +14,12 @@ import { isCostBearingNodeType } from './nodes/cost-bearing-node-types'
14
14
  import { nodeRegistry } from './nodes'
15
15
  import { NodeHandlerGenerator, ServerHandlerOptions, resolveHandlerEntryName } from './nodes/types'
16
16
  import { generalWait } from './nodes/general/general-wait'
17
- import { needsVisitorRequest, VISITOR_REQUEST_INJECTION } from './nodes/general/visitor-request'
17
+ import {
18
+ isOutboundMessageNodeType,
19
+ needsVisitorRequest,
20
+ VISITOR_REQUEST_INJECTION,
21
+ } from './nodes/general/visitor-request'
22
+ import { stepNameOf } from './nodes/email/message-trust'
18
23
  import {
19
24
  markVisitorBoundMessageNodes,
20
25
  markWebhookMessageNodes,
@@ -2180,6 +2185,33 @@ const generateErrorHandlerRunBlock = (
2180
2185
  }
2181
2186
  }`
2182
2187
 
2188
+ /**
2189
+ * The email and SMS steps a run of this automation can report as not having
2190
+ * sent. None when it could not tell: a custom node may send by itself, and a
2191
+ * parallel branch the run does not wait for has no result when the run ends.
2192
+ * Steps on the error branch do not count: a run that ends well never reaches them.
2193
+ */
2194
+ const reportableSendSteps = (
2195
+ workflow: UIDLWorkflow,
2196
+ errorHandlerNodes: Array<{ id: string }> | null
2197
+ ): Array<{ id: string; label: string }> => {
2198
+ const nodes = workflow.nodes || []
2199
+ const couldNotTell = nodes.some(
2200
+ (node) =>
2201
+ node.type === 'general-custom-node' ||
2202
+ (node.type === 'general-parallel' &&
2203
+ node.config?.waitForAll !== undefined &&
2204
+ node.config.waitForAll !== true)
2205
+ )
2206
+ if (couldNotTell) {
2207
+ return []
2208
+ }
2209
+ const errorBranch = new Set((errorHandlerNodes || []).map((node) => node.id))
2210
+ return nodes
2211
+ .filter((node) => isOutboundMessageNodeType(node.type) && !errorBranch.has(node.id))
2212
+ .map((node) => ({ id: node.id, label: stepNameOf(node) }))
2213
+ }
2214
+
2183
2215
  /**
2184
2216
  * An automation's handler module (`lib/automations/handlers/<file>.js`, see
2185
2217
  * automations/handler-module-generator.ts): the webhook route's node loop as
@@ -2199,6 +2231,8 @@ const generateErrorHandlerRunBlock = (
2199
2231
  * nothing and resolves `{ waiting: true, wouldWait }`; a Wait with no step
2200
2232
  * after it stores nothing and the run ends there). A run that fails rejects,
2201
2233
  * after the error-handler branch ran, so the dispatcher records the failure.
2234
+ * A run that ends with its email and SMS steps all unsent resolves with their
2235
+ * names under `sentNothing`, so the History can say so instead of Done.
2202
2236
  */
2203
2237
  export const generateHandlerFunction = (
2204
2238
  workflow: UIDLWorkflow,
@@ -2277,6 +2311,8 @@ const WORKFLOW_CONFIG = ${workflowConfig};
2277
2311
 
2278
2312
  var ERROR_HANDLER_NODES = ${errorHandlerNodesJson};
2279
2313
 
2314
+ var SEND_STEPS = ${JSON.stringify(reportableSendSteps(workflow, errorHandlerNodes))};
2315
+
2280
2316
  const nodeHandlers = {
2281
2317
  ${nodeHandlersEntries}
2282
2318
  };
@@ -2318,6 +2354,17 @@ ${executionLoop}
2318
2354
  return { waiting: false };
2319
2355
  }
2320
2356
 
2357
+ // The names of the send steps when the run has some and none of them sent: a
2358
+ // step a condition skipped has no result, one that failed and went on says so.
2359
+ function unsentSendSteps(context) {
2360
+ if (SEND_STEPS.length === 0) return null;
2361
+ var sent = SEND_STEPS.some(function(step) {
2362
+ var result = context[step.id];
2363
+ return !!result && !utils.isFatalNodeResult(result);
2364
+ });
2365
+ return sent ? null : SEND_STEPS.map(function(step) { return step.label; });
2366
+ }
2367
+
2321
2368
  async function run(event, context, resumeFrom) {
2322
2369
  context = context && typeof context === 'object' ? context : {};
2323
2370
  // Shared fire-and-forget queue — drained before the run settles.
@@ -2350,13 +2397,16 @@ async function run(event, context, resumeFrom) {
2350
2397
  sourceRef: WORKFLOW_ID,
2351
2398
  orderId: trigger.order && trigger.order.id ? String(trigger.order.id) : null,
2352
2399
  };
2400
+ var outcome;
2353
2401
  try {
2354
- return await runNodes(context, resumeFrom || null);
2402
+ outcome = await runNodes(context, resumeFrom || null);
2355
2403
  } catch (error) {
2356
2404
  ${generateErrorHandlerRunBlock('Automation error handler failed:')}
2357
2405
  await utils.settlePendingNodePromises(context);
2358
2406
  throw error;
2359
2407
  }
2408
+ var unsent = outcome.waiting ? null : unsentSendSteps(context);
2409
+ return unsent ? Object.assign({}, outcome, { sentNothing: unsent }) : outcome;
2360
2410
  }
2361
2411
 
2362
2412
  module.exports.run = run;
@@ -730,13 +730,15 @@ export const PASSWORD_RESETS_TABLE = 'teleport_password_resets'
730
730
  const PG_PASSWORD_RESET_FUNCTIONS = `
731
731
  // Password-reset links (pages/api/auth/forgot-password + reset-password). Only
732
732
  // the SHA-256 of a link's token is stored, bound to the account id, with its
733
- // expiry; the table is created on first use. The earlier password_reset_tokens
734
- // table and its rows are never read.
733
+ // expiry. The editor makes the table with the other account tables, and a site's
734
+ // database role may read and write it without being allowed to create one
735
+ // (CREATE ... IF NOT EXISTS asks for that permission even when the table is
736
+ // there), so a create is attempted only for a table that really is missing. The
737
+ // earlier password_reset_tokens table and its rows are never read.
735
738
  var PASSWORD_RESETS_TABLE = '${PASSWORD_RESETS_TABLE}';
736
- var __passwordResetsTableReady = false;
739
+ var UNDEFINED_TABLE_CODE = '42P01';
737
740
 
738
- async function ensurePasswordResetsTable(client) {
739
- if (__passwordResetsTableReady) return;
741
+ async function createPasswordResetsTable(client) {
740
742
  await client.query('BEGIN');
741
743
  try {
742
744
  await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [PASSWORD_RESETS_TABLE]);
@@ -748,28 +750,53 @@ async function ensurePasswordResetsTable(client) {
748
750
  await client.query('COMMIT');
749
751
  } catch (err) {
750
752
  try { await client.query('ROLLBACK'); } catch (_e) {}
751
- throw err;
753
+ throw new Error('[password-reset] the table ' + PASSWORD_RESETS_TABLE + ' is missing and could not be created: ' + (err && err.message ? err.message : String(err)));
754
+ }
755
+ }
756
+
757
+ // Throws when a link could not be kept. Asked for every address, with an account
758
+ // or without, so a table that cannot be used refuses them all alike.
759
+ async function ensurePasswordResetsUsable() {
760
+ const client = getClient();
761
+ try {
762
+ await client.connect();
763
+ try {
764
+ await client.query('SELECT 1 FROM ' + PASSWORD_RESETS_TABLE + ' LIMIT 1');
765
+ } catch (err) {
766
+ if (!err || err.code !== UNDEFINED_TABLE_CODE) throw err;
767
+ await createPasswordResetsTable(client);
768
+ }
769
+ } finally {
770
+ try { await client.end(); } catch (_e) {}
752
771
  }
753
- __passwordResetsTableReady = true;
754
772
  }
755
773
 
756
774
  // One live link per account: a new one ends the ones sent before it.
775
+ async function writePasswordReset(client, userId, tokenHash, ttlSeconds) {
776
+ await client.query(
777
+ 'UPDATE ' + PASSWORD_RESETS_TABLE + ' SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL',
778
+ [String(userId)]
779
+ );
780
+ await client.query(
781
+ 'INSERT INTO ' + PASSWORD_RESETS_TABLE + " (token_hash, user_id, expires_at) VALUES ($1, $2, NOW() + ($3::int * INTERVAL '1 second'))",
782
+ [tokenHash, String(userId), ttlSeconds]
783
+ );
784
+ await client.query(
785
+ 'DELETE FROM ' + PASSWORD_RESETS_TABLE + " WHERE expires_at < NOW() - INTERVAL '1 day'"
786
+ );
787
+ }
788
+
757
789
  async function storePasswordReset(userId, tokenHash, ttlSeconds) {
758
790
  const client = getClient();
759
791
  try {
760
792
  await client.connect();
761
- await ensurePasswordResetsTable(client);
762
- await client.query(
763
- 'UPDATE ' + PASSWORD_RESETS_TABLE + ' SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL',
764
- [String(userId)]
765
- );
766
- await client.query(
767
- 'INSERT INTO ' + PASSWORD_RESETS_TABLE + " (token_hash, user_id, expires_at) VALUES ($1, $2, NOW() + ($3::int * INTERVAL '1 second'))",
768
- [tokenHash, String(userId), ttlSeconds]
769
- );
770
- await client.query(
771
- 'DELETE FROM ' + PASSWORD_RESETS_TABLE + " WHERE expires_at < NOW() - INTERVAL '1 day'"
772
- );
793
+ try {
794
+ await writePasswordReset(client, userId, tokenHash, ttlSeconds);
795
+ } catch (err) {
796
+ if (!err || err.code !== UNDEFINED_TABLE_CODE) throw err;
797
+ await createPasswordResetsTable(client);
798
+ await writePasswordReset(client, userId, tokenHash, ttlSeconds);
799
+ }
773
800
  } finally {
774
801
  try { await client.end(); } catch (_e) {}
775
802
  }
@@ -825,6 +852,7 @@ const PASSWORD_RESET_SUPPORTED = true;`
825
852
  const UNSUPPORTED_PASSWORD_RESET_FUNCTIONS = `
826
853
  // Password-reset links need a Postgres database; this project's user store is
827
854
  // not one, so the reset routes answer without ever sending a link.
855
+ async function ensurePasswordResetsUsable() { throw new Error('Password reset needs a Postgres database'); }
828
856
  async function storePasswordReset() { throw new Error('Password reset needs a Postgres database'); }
829
857
  async function consumePasswordReset() { return null; }
830
858
  async function passwordResetIsLive() { return false; }
@@ -1349,6 +1377,7 @@ ${
1349
1377
  module.exports.checkSignInAllowed = checkSignInAllowed;
1350
1378
  module.exports.countSignInFailure = countSignInFailure;
1351
1379
  module.exports.SIGN_IN_VISITOR_HEADER = SIGN_IN_VISITOR_HEADER;
1380
+ module.exports.ensurePasswordResetsUsable = ensurePasswordResetsUsable;
1352
1381
  module.exports.storePasswordReset = storePasswordReset;
1353
1382
  module.exports.consumePasswordReset = consumePasswordReset;
1354
1383
  module.exports.passwordResetIsLive = passwordResetIsLive;
@@ -361,6 +361,14 @@ function runStatus(error, value) {
361
361
  return value && value.waiting === true ? 'waiting' : 'delivered';
362
362
  }
363
363
 
364
+ // What a run leaves in its delivery's error column: its error, or, for a run
365
+ // that finished, the send steps that did not send. Its status says nothing of
366
+ // them: such a run is delivered, and is neither retried nor counted as failed.
367
+ function runNote(error, value) {
368
+ if (error) return store.errorMessage(error);
369
+ return value && value.sentNothing ? JSON.stringify({ sentNothing: value.sentNothing }) : null;
370
+ }
371
+
364
372
  // Whether a guarded write found its row (the claim it names still held it).
365
373
  function wrote(result) {
366
374
  return !!result && result.rowCount > 0;
@@ -368,7 +376,7 @@ function wrote(result) {
368
376
 
369
377
  function lateDeliveryWriter(delivery, testRun, resumePoint) {
370
378
  return function (error, value) {
371
- var message = error ? store.errorMessage(error) : null;
379
+ var message = runNote(error, value);
372
380
  var status = testRun ? 'test' : runStatus(error, value);
373
381
  store.withClient(async function (client) {
374
382
  var finished = await client.query(FINISH_DELIVERY_SQL, [delivery.id, status, message, delivery.attempts]);
@@ -386,7 +394,7 @@ function lateWaitWriter(wait, resumedAt) {
386
394
  // Nothing is written once another dispatcher took the stale resume over:
387
395
  // that run records the outcome.
388
396
  store.withClient(function (client) {
389
- return finishResume(client, wait, runStatus(error, value), error ? store.errorMessage(error) : null, resumedAt);
397
+ return finishResume(client, wait, runStatus(error, value), runNote(error, value), resumedAt);
390
398
  }).catch(function (writeError) {
391
399
  console.error('[automations] could not record a late resume: ' + store.errorMessage(writeError));
392
400
  });
@@ -624,7 +632,7 @@ async function deliverEvent(client, row, context) {
624
632
  var error = ran.error ? store.errorMessage(ran.error) : null;
625
633
  if (error) console.error('[automations] ' + entry.name + ' failed for event ' + row.id + ': ' + error);
626
634
  // A run that stopped at a Wait is not finished: its delivery waits too.
627
- var finished = await client.query(FINISH_DELIVERY_SQL, [delivery.id, runStatus(ran.error, ran.value), error, delivery.attempts]);
635
+ var finished = await client.query(FINISH_DELIVERY_SQL, [delivery.id, runStatus(ran.error, ran.value), runNote(ran.error, ran.value), delivery.attempts]);
628
636
  if (resumePoint && !error && wrote(finished)) await client.query(SETTLE_FAILED_WAIT_SQL, [resumePoint.id]);
629
637
  if (error) outcome.failed += 1;
630
638
  else outcome.delivered += 1;
@@ -690,7 +698,7 @@ async function resumeWaits(client, context) {
690
698
  wait,
691
699
  eventRow,
692
700
  runStatus(ran.error, ran.value),
693
- ran.error ? store.errorMessage(ran.error) : null,
701
+ runNote(ran.error, ran.value),
694
702
  resumedAt
695
703
  );
696
704
  }
@@ -834,7 +842,7 @@ async function runTest(input, options) {
834
842
  }, timeoutMs, lateDeliveryWriter(delivery, true));
835
843
  if (ran.timedOut) return totals;
836
844
  var wouldWait = ran.value && ran.value.wouldWait ? ran.value.wouldWait : null;
837
- var note = ran.error ? store.errorMessage(ran.error) : wouldWait ? JSON.stringify({ wouldWait: wouldWait }) : null;
845
+ var note = wouldWait ? JSON.stringify({ wouldWait: wouldWait }) : runNote(ran.error, ran.value);
838
846
  await client.query(FINISH_DELIVERY_SQL, [delivery.id, 'test', note, delivery.attempts]);
839
847
  if (ran.error) totals.failed = 1;
840
848
  else totals.delivered = 1;
@@ -89,10 +89,15 @@ async function saveWait(wait) {
89
89
 
90
90
  // The signed requests the dispatch route has taken, each kept until its
91
91
  // signature can no longer verify: the same request again is refused. The
92
- // table (and its expiry index) is made on first use, two instances taking
93
- // turns under the lock. The rows that are over are deleted at most once per
94
- // instance every SIGNED_REQUESTS_CLEANUP_MS, a bounded batch at a time.
92
+ // editor makes the table with the other automation tables, and a site's
93
+ // database role may read and write it without being allowed to create one
94
+ // (CREATE ... IF NOT EXISTS asks for that permission even when the table is
95
+ // there), so a create is attempted only for a table that really is missing,
96
+ // two instances taking turns under the lock. The rows that are over are
97
+ // deleted at most once per instance every SIGNED_REQUESTS_CLEANUP_MS, a
98
+ // bounded batch at a time.
95
99
  var SIGNED_REQUESTS_TABLE = '${SIGNED_REQUESTS_TABLE}';
100
+ var UNDEFINED_TABLE_CODE = '42P01';
96
101
  var LOCK_SIGNED_REQUESTS_SQL = 'SELECT pg_advisory_xact_lock(hashtext($1))';
97
102
  var CREATE_SIGNED_REQUESTS_SQL = 'CREATE TABLE IF NOT EXISTS ' + SIGNED_REQUESTS_TABLE +
98
103
  ' (request_key TEXT PRIMARY KEY, expires_at BIGINT NOT NULL)';
@@ -103,34 +108,43 @@ var FORGET_SIGNED_REQUESTS_SQL = 'DELETE FROM ' + SIGNED_REQUESTS_TABLE + ' WHER
103
108
  var TAKE_SIGNED_REQUEST_SQL = 'INSERT INTO ' + SIGNED_REQUESTS_TABLE + ' (request_key, expires_at) VALUES ($1, $2) ' +
104
109
  'ON CONFLICT (request_key) DO NOTHING RETURNING request_key';
105
110
  var SIGNED_REQUESTS_CLEANUP_MS = 600000;
106
- var __signedRequestsReady = false;
107
111
  var __signedRequestsCleanedAt = 0;
108
112
 
113
+ async function createSignedRequestsTable(client) {
114
+ await client.query('BEGIN');
115
+ try {
116
+ await client.query(LOCK_SIGNED_REQUESTS_SQL, [SIGNED_REQUESTS_TABLE]);
117
+ await client.query(CREATE_SIGNED_REQUESTS_SQL);
118
+ await client.query(INDEX_SIGNED_REQUESTS_SQL);
119
+ await client.query('COMMIT');
120
+ } catch (error) {
121
+ try { await client.query('ROLLBACK'); } catch (_e) {}
122
+ throw new Error('[automations] the table ' + SIGNED_REQUESTS_TABLE + ' is missing and could not be created: ' + errorMessage(error));
123
+ }
124
+ }
125
+
126
+ async function recordSignedRequest(client, key, expiresAt) {
127
+ var nowMs = Date.now();
128
+ if (nowMs - __signedRequestsCleanedAt >= SIGNED_REQUESTS_CLEANUP_MS) {
129
+ __signedRequestsCleanedAt = nowMs;
130
+ await client.query(FORGET_SIGNED_REQUESTS_SQL, [Math.floor(nowMs / 1000)]);
131
+ }
132
+ var taken = await client.query(TAKE_SIGNED_REQUEST_SQL, [key, expiresAt]);
133
+ return !!(taken && taken.rows && taken.rows[0]);
134
+ }
135
+
109
136
  // true the first time a signed request (its key) is taken, false every time
110
137
  // after while it is kept (until expiresAt, unix seconds). Throws when the
111
138
  // database cannot say: the route then refuses.
112
139
  async function takeSignedRequest(key, expiresAt) {
113
140
  return withClient(async function (client) {
114
- if (!__signedRequestsReady) {
115
- await client.query('BEGIN');
116
- try {
117
- await client.query(LOCK_SIGNED_REQUESTS_SQL, [SIGNED_REQUESTS_TABLE]);
118
- await client.query(CREATE_SIGNED_REQUESTS_SQL);
119
- await client.query(INDEX_SIGNED_REQUESTS_SQL);
120
- await client.query('COMMIT');
121
- } catch (error) {
122
- try { await client.query('ROLLBACK'); } catch (_e) {}
123
- throw error;
124
- }
125
- __signedRequestsReady = true;
126
- }
127
- var nowMs = Date.now();
128
- if (nowMs - __signedRequestsCleanedAt >= SIGNED_REQUESTS_CLEANUP_MS) {
129
- __signedRequestsCleanedAt = nowMs;
130
- await client.query(FORGET_SIGNED_REQUESTS_SQL, [Math.floor(nowMs / 1000)]);
141
+ try {
142
+ return await recordSignedRequest(client, key, expiresAt);
143
+ } catch (error) {
144
+ if (!error || error.code !== UNDEFINED_TABLE_CODE) throw error;
145
+ await createSignedRequestsTable(client);
146
+ return recordSignedRequest(client, key, expiresAt);
131
147
  }
132
- var taken = await client.query(TAKE_SIGNED_REQUEST_SQL, [key, expiresAt]);
133
- return !!(taken && taken.rows && taken.rows[0]);
134
148
  });
135
149
  }
136
150
 
@@ -1008,6 +1008,9 @@ async function executeWorkflow(workflowConfig, triggerContext, nodeHandlers, opt
1008
1008
  if (triggerContext && triggerContext.__dynamicRouteParam) {
1009
1009
  context.__dynamicRouteParam = triggerContext.__dynamicRouteParam;
1010
1010
  }
1011
+ if (triggerContext && triggerContext.__tpa) {
1012
+ context.__tpa = triggerContext.__tpa;
1013
+ }
1011
1014
  if (triggerContext && triggerContext.triggerElement) {
1012
1015
  context.triggerElement = triggerContext.triggerElement;
1013
1016
  }
@@ -2151,6 +2154,7 @@ function buildContext(workflowConfig, triggerContext) {
2151
2154
  if (triggerContext.__stateValues) context.__stateValues = triggerContext.__stateValues;
2152
2155
  if (triggerContext.__routeParams) context.__routeParams = triggerContext.__routeParams;
2153
2156
  if (triggerContext.__dynamicRouteParam) context.__dynamicRouteParam = triggerContext.__dynamicRouteParam;
2157
+ if (triggerContext.__tpa) context.__tpa = triggerContext.__tpa;
2154
2158
  if (triggerContext.triggerElement) context.triggerElement = triggerContext.triggerElement;
2155
2159
  }
2156
2160
  // See executeWorkflow: the page's language travels with the run.
@@ -5,8 +5,9 @@ import { NodeHandlerGenerator, handlerToString } from '../types'
5
5
  // server: whether the address has an account, the link's token (minted there,
6
6
  // stored hashed), the page it opens and the email itself (provider, sender and
7
7
  // template baked from this node's config at generation). The route answers the
8
- // same whether the address has an account or not, so this node always reports
9
- // success for a well-formed request.
8
+ // same whether the address has an account or not, so this node reports success
9
+ // for a well-formed request unless the site could not keep a link, for any
10
+ // address.
10
11
  async function account_request_password_reset(config: any, context: Record<string, unknown>) {
11
12
  const email = config && typeof config.email === 'string' ? config.email.trim() : ''
12
13
  if (!email) {
@@ -14,6 +14,11 @@ import { NodeHandlerGenerator } from '../types'
14
14
  * carries the ids the trigger stamped on the context (`__tpa`, set by every
15
15
  * generated trigger) plus whatever first-touch snapshot the caller binds in
16
16
  * `attribution` (the checkout stamps `utm_*`, `referrer_host`, `landing_path`).
17
+ * A snapshot naming a `session_id` is the visit that placed an order, read back
18
+ * by a run outside that visit (a payment webhook, a cron, an admin page): its
19
+ * `session_id`, `visitor_id` and `exp` stand in for the trigger's ids. Otherwise
20
+ * the visitor id is the tracker's alone: a browser may still hold a persistent
21
+ * id from before its consent was withdrawn.
17
22
  *
18
23
  * Never throws: an outcome that cannot be reported answers `{ sent: false }`
19
24
  * and the workflow continues — analytics must never cost a sale.
@@ -60,22 +65,30 @@ const SERVER_BODY = `async function analytics_track_outcome(config, context) {
60
65
  if (baseUrl.indexOf('http') !== 0 || !publicKey || !serverKey) {
61
66
  return { sent: false, reason: 'not-configured' };
62
67
  }
68
+ // The service dedupes on this id and drops an outcome without one.
69
+ if (config.outcomeId == null || config.outcomeId === '') {
70
+ return { sent: false, reason: 'no-outcome-id' };
71
+ }
63
72
  var ids = context && context.__tpa && typeof context.__tpa === 'object' ? context.__tpa : {};
64
73
  var attribution = config.attribution && typeof config.attribution === 'object' ? config.attribution : {};
74
+ var visit = attribution.session_id
75
+ ? { sessionId: attribution.session_id, visitorId: attribution.visitor_id, exp: attribution.exp }
76
+ : ids;
65
77
  var outcome = {
66
78
  name: name,
67
79
  value: config.value == null || config.value === '' ? null : Number(config.value),
68
80
  currency: config.currency == null ? null : String(config.currency),
69
81
  outcomeId: config.outcomeId == null ? null : String(config.outcomeId),
70
82
  recordId: config.recordId == null ? null : String(config.recordId),
71
- sessionId: ids.sessionId || null,
72
- visitorId: ids.visitorId || attribution.visitor_id || null,
73
- path: ids.path || null,
74
- exp: ids.exp || null,
83
+ sessionId: visit.sessionId || null,
84
+ visitorId: visit.visitorId || null,
85
+ path: visit.path || null,
86
+ exp: visit.exp || null,
75
87
  attribution: {
76
88
  utmSource: attribution.utm_source || attribution.utmSource || null,
77
89
  utmMedium: attribution.utm_medium || attribution.utmMedium || null,
78
90
  utmCampaign: attribution.utm_campaign || attribution.utmCampaign || null,
91
+ utmContent: attribution.utm_content || attribution.utmContent || null,
79
92
  referrerHost: attribution.referrer_host || attribution.referrerHost || null,
80
93
  landingPath: attribution.landing_path || attribution.landingPath || null
81
94
  }
@@ -55,6 +55,10 @@ export const DEFAULT_RESET_PAGE_PATH = '/reset-password'
55
55
  export const FORGOT_PASSWORD_ANSWER =
56
56
  'If an account exists for that email, we sent it a link to reset the password.'
57
57
 
58
+ /** What the route answers, to every address, when a link could not be kept. */
59
+ export const FORGOT_PASSWORD_NOT_SENT =
60
+ 'We could not send the reset link. Please try again in a moment.'
61
+
58
62
  /**
59
63
  * The sessionStorage key the reset page reads the link's token from (mirrored
60
64
  * in teleport-gui `RESET_TOKEN_STORAGE_KEY`). The published site's first head
@@ -333,7 +337,9 @@ module.exports = async function handler(req, res) {
333
337
  return __security.sendClientError(res, 503, 'We could not reach the account service. Please try again in a moment.', err);
334
338
  }
335
339
 
340
+ var link = null;
336
341
  try {
342
+ await authUtils.ensurePasswordResetsUsable();
337
343
  // Counted for every address, with an account or without, so neither the
338
344
  // answer nor its timing differs.
339
345
  var recipient = await __security.rateLimit({
@@ -345,17 +351,20 @@ module.exports = async function handler(req, res) {
345
351
  if (user && userId != null && user.email && !recipient.allowed) {
346
352
  console.warn('[forgot-password] reset link not sent: this address was sent ${FORGOT_PASSWORD_RECIPIENT_LIMIT} in the last hour');
347
353
  } else if (user && userId != null && user.email) {
348
- if (!authUtils.PASSWORD_RESET_SUPPORTED) {
349
- console.warn('[forgot-password] reset link not sent: password reset needs a Postgres user store');
350
- } else {
351
- var token = crypto.randomBytes(32).toString('hex');
352
- await authUtils.storePasswordReset(userId, sha256(token), TOKEN_TTL_SECONDS);
353
- var resetUrl = trustedOrigin(req) + RESET_PAGE_PATH + TOKEN_LINK_PREFIX + token;
354
- await sendResetEmail(user, resetUrl, token, __emailLocale.resolveRequestLocale(req));
355
- }
354
+ var token = crypto.randomBytes(32).toString('hex');
355
+ await authUtils.storePasswordReset(userId, sha256(token), TOKEN_TTL_SECONDS);
356
+ link = { token: token, url: trustedOrigin(req) + RESET_PAGE_PATH + TOKEN_LINK_PREFIX + token };
356
357
  }
357
358
  } catch (err) {
358
- console.error('[forgot-password] could not send a reset link:', err && err.message ? err.message : err);
359
+ await waitUntilElapsed(startedAt, MIN_RESPONSE_MS);
360
+ return __security.sendClientError(res, 503, ${JSON.stringify(FORGOT_PASSWORD_NOT_SENT)}, err);
361
+ }
362
+ if (link) {
363
+ try {
364
+ await sendResetEmail(user, link.url, link.token, __emailLocale.resolveRequestLocale(req));
365
+ } catch (err) {
366
+ console.error('[forgot-password] could not send a reset link:', err && err.message ? err.message : err);
367
+ }
359
368
  }
360
369
 
361
370
  await waitUntilElapsed(startedAt, MIN_RESPONSE_MS);
@@ -38,8 +38,9 @@ export const securityRuntimeSpecifier = (relativePrefix: string): string =>
38
38
  * itself, an IPv6 one its /64 (an IPv4 tail counted as the two groups it
39
39
  * fills). Every rate limit keyed by visitor uses it.
40
40
  * - `rateLimit` counts fixed windows in the app's Postgres database (table
41
- * `teleport_rate_limits`, created on first use under an advisory lock), so
42
- * every instance of a serverless app counts together. Only an app with no
41
+ * `teleport_rate_limits`, which the editor makes, created here under an
42
+ * advisory lock only when it is really missing), so every instance of a
43
+ * serverless app counts together. Only an app with no
43
44
  * database (or no `pg` driver) counts in a bounded per-instance map; a
44
45
  * database error counts in that map for a short while and is logged once.
45
46
  * A request waits on the database at most 1.5 s; after a failure one
@@ -102,6 +103,9 @@ var CLEANUP_MAX_BATCHES = 3;
102
103
  var CLEANUP_QUERY_TIMEOUT_MS = 5000;
103
104
  var MEMORY_MAX_WINDOWS = 10000;
104
105
 
106
+ // A site's database role may read and write its tables without being allowed
107
+ // to create one, and CREATE ... IF NOT EXISTS asks for that permission even when
108
+ // the table is there: these run only for a table that really is missing.
105
109
  var CREATE_SQL = 'CREATE TABLE IF NOT EXISTS "' + RATE_LIMITS_TABLE + '" (' +
106
110
  '"key" TEXT NOT NULL, "window_start" BIGINT NOT NULL, "count" INTEGER NOT NULL DEFAULT 0, ' +
107
111
  '"expires_at" BIGINT NOT NULL, PRIMARY KEY ("key", "window_start"))';
@@ -298,7 +302,6 @@ function __memoryHit(storageKey, windowStart, windowSeconds, nowSec) {
298
302
  return entry.count;
299
303
  }
300
304
 
301
- var __tableReady = false;
302
305
  // Healthy, every request asks the database. After a failure, requests count in
303
306
  // memory until __databaseRetryAt; then ONE request probes (it pushes the retry
304
307
  // time back before it asks), so the requests beside it never wait on a
@@ -377,12 +380,15 @@ async function __databaseHit(storageKey, windowStart, expiresAt, nowSec) {
377
380
  return within(client.query(sql, values || []), QUERY_TIMEOUT_MS);
378
381
  };
379
382
  var createTable = async function () {
380
- await run('BEGIN');
381
- await run(LOCK_SQL, [RATE_LIMITS_TABLE]);
382
- await run(CREATE_SQL);
383
- await run(INDEX_SQL);
384
- await run('COMMIT');
385
- __tableReady = true;
383
+ try {
384
+ await run('BEGIN');
385
+ await run(LOCK_SQL, [RATE_LIMITS_TABLE]);
386
+ await run(CREATE_SQL);
387
+ await run(INDEX_SQL);
388
+ await run('COMMIT');
389
+ } catch (err) {
390
+ throw new Error('the table ' + RATE_LIMITS_TABLE + ' is missing and could not be created: ' + __errorText(err));
391
+ }
386
392
  };
387
393
  var hit = function () {
388
394
  return run(HIT_SQL, [storageKey, windowStart, expiresAt]);
@@ -390,15 +396,13 @@ async function __databaseHit(storageKey, windowStart, expiresAt, nowSec) {
390
396
  var handedToCleanup = false;
391
397
  try {
392
398
  await within(client.connect(), CONNECT_TIMEOUT_MS);
393
- if (!__tableReady) await createTable();
394
399
  var result;
395
400
  try {
396
401
  result = await hit();
397
402
  } catch (err) {
398
- // The table went away (dropped, or the database restored from an
399
- // older backup): create it again and count this request in it.
403
+ // The table is not there (dropped, or the database restored from an
404
+ // older backup): create it and count this request in it.
400
405
  if (!err || err.code !== '42P01') throw err;
401
- __tableReady = false;
402
406
  await createTable();
403
407
  result = await hit();
404
408
  }