@teleporthq/teleport-plugin-next-workflows 0.44.7 → 0.44.9

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 (141) hide show
  1. package/__tests__/_helpers/load-server-runtime.ts +6 -2
  2. package/__tests__/_helpers/run-account-delete-route.ts +140 -0
  3. package/__tests__/account-delete-current-password.test.ts +73 -0
  4. package/__tests__/account-delete-password-check.test.ts +113 -0
  5. package/__tests__/account-delete-visitor-details.test.ts +206 -0
  6. package/__tests__/auth-password-change-ends-sessions.test.ts +178 -0
  7. package/__tests__/credential-result-reply.test.ts +509 -0
  8. package/__tests__/data-api-invalid-body.test.ts +110 -0
  9. package/__tests__/data-node-not-awaited.test.ts +7 -6
  10. package/__tests__/invoice-payment-line.test.ts +68 -0
  11. package/__tests__/invoice-tender.test.ts +116 -0
  12. package/__tests__/pending-work-after-reply.test.ts +294 -0
  13. package/__tests__/runtime-condition-operands.test.ts +86 -0
  14. package/__tests__/webhook-delivery-lease.test.ts +68 -6
  15. package/dist/cjs/account-delete-route-generator.d.ts +7 -2
  16. package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
  17. package/dist/cjs/account-delete-route-generator.js +90 -43
  18. package/dist/cjs/account-delete-route-generator.js.map +1 -1
  19. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  20. package/dist/cjs/api-route-generator.js +17 -15
  21. package/dist/cjs/api-route-generator.js.map +1 -1
  22. package/dist/cjs/auth-generator.d.ts.map +1 -1
  23. package/dist/cjs/auth-generator.js +34 -1
  24. package/dist/cjs/auth-generator.js.map +1 -1
  25. package/dist/cjs/await-result.d.ts +13 -5
  26. package/dist/cjs/await-result.d.ts.map +1 -1
  27. package/dist/cjs/await-result.js +19 -8
  28. package/dist/cjs/await-result.js.map +1 -1
  29. package/dist/cjs/credential-results.d.ts +18 -0
  30. package/dist/cjs/credential-results.d.ts.map +1 -0
  31. package/dist/cjs/credential-results.js +72 -0
  32. package/dist/cjs/credential-results.js.map +1 -0
  33. package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
  34. package/dist/cjs/data-api-route-generator.js +9 -1
  35. package/dist/cjs/data-api-route-generator.js.map +1 -1
  36. package/dist/cjs/executor-generator.d.ts.map +1 -1
  37. package/dist/cjs/executor-generator.js +26 -7
  38. package/dist/cjs/executor-generator.js.map +1 -1
  39. package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
  40. package/dist/cjs/invoice/invoice-html-code.js +96 -5
  41. package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
  42. package/dist/cjs/nodes/account/account-delete-current.d.ts.map +1 -1
  43. package/dist/cjs/nodes/account/account-delete-current.js +4 -1
  44. package/dist/cjs/nodes/account/account-delete-current.js.map +1 -1
  45. package/dist/cjs/nodes/account/account-hash-password.d.ts.map +1 -1
  46. package/dist/cjs/nodes/account/account-hash-password.js +1 -0
  47. package/dist/cjs/nodes/account/account-hash-password.js.map +1 -1
  48. package/dist/cjs/nodes/types.d.ts +6 -0
  49. package/dist/cjs/nodes/types.d.ts.map +1 -1
  50. package/dist/cjs/server-runtime-code.d.ts +8 -0
  51. package/dist/cjs/server-runtime-code.d.ts.map +1 -1
  52. package/dist/cjs/server-runtime-code.js +127 -0
  53. package/dist/cjs/server-runtime-code.js.map +1 -1
  54. package/dist/cjs/transactional-email-code.d.ts +7 -0
  55. package/dist/cjs/transactional-email-code.d.ts.map +1 -1
  56. package/dist/cjs/transactional-email-code.js +5 -5
  57. package/dist/cjs/transactional-email-code.js.map +1 -1
  58. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  59. package/dist/cjs/vercel-functions.d.ts +34 -0
  60. package/dist/cjs/vercel-functions.d.ts.map +1 -0
  61. package/dist/cjs/vercel-functions.js +62 -0
  62. package/dist/cjs/vercel-functions.js.map +1 -0
  63. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.d.ts +6 -0
  64. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.d.ts.map +1 -1
  65. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.js +26 -11
  66. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.js.map +1 -1
  67. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  68. package/dist/cjs/workflow-project-plugin.js +3 -0
  69. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  70. package/dist/esm/account-delete-route-generator.d.ts +7 -2
  71. package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
  72. package/dist/esm/account-delete-route-generator.js +90 -43
  73. package/dist/esm/account-delete-route-generator.js.map +1 -1
  74. package/dist/esm/api-route-generator.d.ts.map +1 -1
  75. package/dist/esm/api-route-generator.js +17 -15
  76. package/dist/esm/api-route-generator.js.map +1 -1
  77. package/dist/esm/auth-generator.d.ts.map +1 -1
  78. package/dist/esm/auth-generator.js +34 -1
  79. package/dist/esm/auth-generator.js.map +1 -1
  80. package/dist/esm/await-result.d.ts +13 -5
  81. package/dist/esm/await-result.d.ts.map +1 -1
  82. package/dist/esm/await-result.js +18 -7
  83. package/dist/esm/await-result.js.map +1 -1
  84. package/dist/esm/credential-results.d.ts +18 -0
  85. package/dist/esm/credential-results.d.ts.map +1 -0
  86. package/dist/esm/credential-results.js +68 -0
  87. package/dist/esm/credential-results.js.map +1 -0
  88. package/dist/esm/data-api-route-generator.d.ts.map +1 -1
  89. package/dist/esm/data-api-route-generator.js +9 -1
  90. package/dist/esm/data-api-route-generator.js.map +1 -1
  91. package/dist/esm/executor-generator.d.ts.map +1 -1
  92. package/dist/esm/executor-generator.js +26 -7
  93. package/dist/esm/executor-generator.js.map +1 -1
  94. package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
  95. package/dist/esm/invoice/invoice-html-code.js +96 -5
  96. package/dist/esm/invoice/invoice-html-code.js.map +1 -1
  97. package/dist/esm/nodes/account/account-delete-current.d.ts.map +1 -1
  98. package/dist/esm/nodes/account/account-delete-current.js +4 -1
  99. package/dist/esm/nodes/account/account-delete-current.js.map +1 -1
  100. package/dist/esm/nodes/account/account-hash-password.d.ts.map +1 -1
  101. package/dist/esm/nodes/account/account-hash-password.js +1 -0
  102. package/dist/esm/nodes/account/account-hash-password.js.map +1 -1
  103. package/dist/esm/nodes/types.d.ts +6 -0
  104. package/dist/esm/nodes/types.d.ts.map +1 -1
  105. package/dist/esm/server-runtime-code.d.ts +8 -0
  106. package/dist/esm/server-runtime-code.d.ts.map +1 -1
  107. package/dist/esm/server-runtime-code.js +127 -0
  108. package/dist/esm/server-runtime-code.js.map +1 -1
  109. package/dist/esm/transactional-email-code.d.ts +7 -0
  110. package/dist/esm/transactional-email-code.d.ts.map +1 -1
  111. package/dist/esm/transactional-email-code.js +5 -5
  112. package/dist/esm/transactional-email-code.js.map +1 -1
  113. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  114. package/dist/esm/vercel-functions.d.ts +34 -0
  115. package/dist/esm/vercel-functions.d.ts.map +1 -0
  116. package/dist/esm/vercel-functions.js +57 -0
  117. package/dist/esm/vercel-functions.js.map +1 -0
  118. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.d.ts +6 -0
  119. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.d.ts.map +1 -1
  120. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.js +26 -11
  121. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.js.map +1 -1
  122. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  123. package/dist/esm/workflow-project-plugin.js +3 -0
  124. package/dist/esm/workflow-project-plugin.js.map +1 -1
  125. package/package.json +2 -2
  126. package/src/account-delete-route-generator.ts +97 -45
  127. package/src/api-route-generator.ts +17 -15
  128. package/src/auth-generator.ts +34 -1
  129. package/src/await-result.ts +19 -7
  130. package/src/credential-results.ts +71 -0
  131. package/src/data-api-route-generator.ts +9 -1
  132. package/src/executor-generator.ts +26 -7
  133. package/src/invoice/invoice-html-code.ts +96 -5
  134. package/src/nodes/account/account-delete-current.ts +4 -1
  135. package/src/nodes/account/account-hash-password.ts +1 -0
  136. package/src/nodes/types.ts +6 -0
  137. package/src/server-runtime-code.ts +128 -0
  138. package/src/transactional-email-code.ts +12 -5
  139. package/src/vercel-functions.ts +67 -0
  140. package/src/webhook-delivery-lease/webhook-delivery-lease-code.ts +26 -11
  141. package/src/workflow-project-plugin.ts +3 -0
@@ -4,9 +4,14 @@
4
4
  *
5
5
  * The client node calls this route, which:
6
6
  * 1. resolves the current user from the NextAuth session (never trusting a
7
- * client-supplied id),
7
+ * client-supplied id) and, when the request carries a password, goes on
8
+ * only if it is the account's own,
8
9
  * 2. runs a single DB transaction that detaches + anonymises business/legal
9
- * records (orders, withdrawal requests, bookings) so tracking survives,
10
+ * records (orders, withdrawal requests, bookings, downloads, gift cards
11
+ * the user bought or received) so tracking survives, takes the user's
12
+ * email out of the activity log and the sent-email ledger, leaves the
13
+ * invoices already issued as they are (an invoice must keep naming its
14
+ * buyer),
10
15
  * deletes the user's personal data (reviews, cart, favourites, chat
11
16
  * history), deletes the rows the user owns in the project's own tables —
12
17
  * the ones the AI made for the site, each through the one column the
@@ -100,10 +105,29 @@ const ANONYMIZE_TABLES = [
100
105
  'teleport_subscriptions',
101
106
  'teleport_abandoned_carts',
102
107
  'teleport_voucher_redemptions',
108
+ 'teleport_downloads',
103
109
  ];
104
110
  const EMAIL_COLUMNS = ['billing_email', 'shipping_email', 'customer_email', 'email'];
105
111
  // A booking also names the customer and carries their phone; both are cleared.
106
- const CLEARED_COLUMNS = { teleport_bookings: ['customer_name', 'customer_phone'] };
112
+ // A download keeps its count against the order's limit, without who fetched it
113
+ // from where.
114
+ const CLEARED_COLUMNS = {
115
+ teleport_bookings: ['customer_name', 'customer_phone'],
116
+ teleport_downloads: ['ip_address'],
117
+ };
118
+ // Tables that name the user only by their email: each column holding it takes
119
+ // the anonymised address (a gift card stays redeemable by its code) or a blank
120
+ // (an activity-log line keeps what happened, not who did it).
121
+ const EMAIL_REFERENCES = [
122
+ { table: 'teleport_gift_cards', column: 'purchaser_email', replacement: 'anonymised' },
123
+ { table: 'teleport_gift_cards', column: 'recipient_email', replacement: 'anonymised' },
124
+ { table: 'teleport_events', column: 'actor', replacement: 'blank' },
125
+ ];
126
+ const SENT_EMAILS_TABLE = 'teleport_sent_emails';
127
+
128
+ function anonymisedEmailOf(userId) {
129
+ return String(DELETED_EMAIL_PATTERN).split('{{userId}}').join(String(userId));
130
+ }
107
131
 
108
132
  async function tableColumns(client, table) {
109
133
  var r = await client.query(
@@ -146,6 +170,36 @@ async function deleteRowsByEmail(client, table, column, email) {
146
170
  await client.query('DELETE FROM ' + table + ' WHERE ' + column + ' = $1', [email]);
147
171
  }
148
172
 
173
+ async function replaceEmailReferences(client, email, anonymisedEmail) {
174
+ if (!email) { return; }
175
+ for (var i = 0; i < EMAIL_REFERENCES.length; i++) {
176
+ var ref = EMAIL_REFERENCES[i];
177
+ var cols = await tableColumns(client, ref.table);
178
+ if (cols.indexOf(ref.column) === -1) { continue; }
179
+ await client.query(
180
+ 'UPDATE ' + ref.table + ' SET ' + ref.column + ' = $1 WHERE lower(' + ref.column + ') = lower($2)',
181
+ [ref.replacement === 'anonymised' ? anonymisedEmail : '', email]
182
+ );
183
+ }
184
+ }
185
+
186
+ // The emails the site sent the user stay on record (which email, when, through
187
+ // which provider), without the address, the body or the values it was filled
188
+ // with.
189
+ async function blankSentEmailsTo(client, email) {
190
+ if (!email) { return; }
191
+ var cols = await tableColumns(client, SENT_EMAILS_TABLE);
192
+ if (cols.indexOf('recipient_email') === -1) { return; }
193
+ var sets = ["recipient_email = ''"];
194
+ if (cols.indexOf('recipients') !== -1) { sets.push('recipients = NULL'); }
195
+ if (cols.indexOf('body_html') !== -1) { sets.push("body_html = ''"); }
196
+ if (cols.indexOf('payload') !== -1) { sets.push('payload = NULL'); }
197
+ await client.query(
198
+ 'UPDATE ' + SENT_EMAILS_TABLE + ' SET ' + sets.join(', ') + ' WHERE lower(recipient_email) = lower($1)',
199
+ [email]
200
+ );
201
+ }
202
+
149
203
  // Rows the user owns in the project's OWN tables — the ones the AI made for
150
204
  // this site (saved items, requests, reviews …). Their link to the user is
151
205
  // ON DELETE SET NULL, so they outlived the account with a blank owner, and the
@@ -259,44 +313,34 @@ async function deleteOwnedRows(client, userId) {
259
313
  }
260
314
  }
261
315
 
262
- // Invoices are a financial/tax record that must survive, but they store the
263
- // customer's PII denormalised and link to the user only through order_id (no
264
- // user_id). Scrub the PII in place, matching invoices whose order belongs to the
265
- // user. MUST run BEFORE the orders row is anonymised (its user_id is still set).
266
- var INVOICE_PII_NULL_COLUMNS = [
267
- 'customer_name',
268
- 'customer_address',
269
- 'customer_city',
270
- 'customer_state',
271
- 'customer_zip',
272
- 'customer_country',
273
- 'customer_vat',
274
- 'customer_phone',
275
- ];
276
-
277
- async function anonymizeInvoices(client, ordersTable, invoicesTable, userId, syntheticEmail) {
278
- var invCols = await tableColumns(client, invoicesTable);
279
- if (invCols.indexOf('order_id') === -1) { return; }
280
- var orderCols = await tableColumns(client, ordersTable);
281
- if (orderCols.indexOf('user_id') === -1) { return; }
282
- var sets = [];
283
- var params = [];
284
- if (invCols.indexOf('customer_email') !== -1) {
285
- params.push(syntheticEmail);
286
- sets.push('customer_email = $' + params.length);
316
+ // The password a request confirms the deletion with, or null when it names
317
+ // none. A request without one deletes as before; one with a password deletes
318
+ // only when it is the account's own (the editor's delete-account form asks for
319
+ // it).
320
+ function readRequestPassword(req) {
321
+ var body = req.body;
322
+ if (typeof body === 'string') {
323
+ try { body = JSON.parse(body); } catch (e) { body = null; }
287
324
  }
288
- for (var i = 0; i < INVOICE_PII_NULL_COLUMNS.length; i++) {
289
- if (invCols.indexOf(INVOICE_PII_NULL_COLUMNS[i]) !== -1) {
290
- sets.push(INVOICE_PII_NULL_COLUMNS[i] + ' = NULL');
291
- }
325
+ if (!body || typeof body !== 'object' || body.password === undefined || body.password === null) {
326
+ return null;
327
+ }
328
+ return String(body.password);
329
+ }
330
+
331
+ // The sign-in check (utils/auth/hash-password.js verifyPassword): bcrypt over
332
+ // the SHA-256 of the password and AUTH_PEPPER. An account without a stored
333
+ // password (a social sign-in) matches no password.
334
+ function passwordMatches(password, storedHash) {
335
+ if (!storedHash || password.length > 1000) { return false; }
336
+ var bcrypt = require('bcryptjs');
337
+ var crypto = require('crypto');
338
+ var prepared = crypto.createHash('sha256').update(password + (process.env.AUTH_PEPPER || '')).digest('base64');
339
+ try {
340
+ return bcrypt.compareSync(prepared, String(storedHash));
341
+ } catch (e) {
342
+ return false;
292
343
  }
293
- if (sets.length === 0) { return; }
294
- params.push(userId);
295
- await client.query(
296
- 'UPDATE ' + invoicesTable + ' SET ' + sets.join(', ') +
297
- ' WHERE order_id IN (SELECT id FROM ' + ordersTable + ' WHERE user_id = $' + params.length + ')',
298
- params
299
- );
300
344
  }
301
345
 
302
346
  ${generateFillTemplateFn()}
@@ -313,6 +357,7 @@ ${generateProviderSendFunction(provider, {
313
357
  source: 'account-delete',
314
358
  sourceRef: 'api/account/delete-current',
315
359
  relativePrefix: '../../..',
360
+ withoutRecipientDetails: true,
316
361
  })}
317
362
 
318
363
  // \`locale\` is the language of the page the deletion was requested from
@@ -328,7 +373,7 @@ async function sendFarewellEmail(toEmail, tokenValues, userId, locale) {
328
373
  var copy = resolveFarewellEmailCopy(locale);
329
374
  var subject = fillTemplate(copy.subject, tokenValues);
330
375
  var html = fillTemplate(copy.body, tokenValues);
331
- await __sendProviderEmail({ from: from, fromName: EMAIL_FROM_NAME, to: toEmail, subject: subject, html: html, apiKey: apiKey, tokenValues: tokenValues, userId: userId });
376
+ await __sendProviderEmail({ from: from, fromName: EMAIL_FROM_NAME, to: toEmail, recordedTo: anonymisedEmailOf(userId), subject: subject, html: html, apiKey: apiKey, tokenValues: tokenValues, userId: userId });
332
377
  }
333
378
 
334
379
  module.exports = async function handler(req, res) {
@@ -348,10 +393,12 @@ module.exports = async function handler(req, res) {
348
393
  return res.status(401).json({ error: 'Unauthenticated' });
349
394
  }
350
395
 
396
+ var requestPassword = readRequestPassword(req);
351
397
  var client = getClient();
352
398
  var userEmail = '';
353
399
  var userName = '';
354
400
  var userFound = false;
401
+ var passwordRefused = false;
355
402
  var failed = false;
356
403
  // try/finally so the pg connection is ALWAYS closed — every early exit
357
404
  // (not-found, error) leaves via the finally, never leaking a connection.
@@ -364,14 +411,14 @@ module.exports = async function handler(req, res) {
364
411
  userFound = true;
365
412
  userEmail = userRes.rows[0].email || '';
366
413
  userName = userRes.rows[0].name || '';
367
-
368
- var syntheticEmail = String(DELETED_EMAIL_PATTERN).split('{{userId}}').join(String(userId));
414
+ passwordRefused =
415
+ requestPassword !== null && !passwordMatches(requestPassword, userRes.rows[0].password);
416
+ }
417
+ if (userFound && !passwordRefused) {
418
+ var syntheticEmail = anonymisedEmailOf(userId);
369
419
 
370
420
  await client.query('BEGIN');
371
421
  try {
372
- // Scrub invoice PII FIRST — invoices link to the user only through
373
- // order_id, so this must run while the orders row still carries user_id.
374
- await anonymizeInvoices(client, 'teleport_orders', 'teleport_invoices', userId, syntheticEmail);
375
422
  for (var a = 0; a < ANONYMIZE_TABLES.length; a++) {
376
423
  await anonymizeUserRows(client, ANONYMIZE_TABLES[a], userId, syntheticEmail);
377
424
  }
@@ -379,6 +426,8 @@ module.exports = async function handler(req, res) {
379
426
  await deleteUserRows(client, DELETE_TABLES[d], userId);
380
427
  }
381
428
  await deleteOwnedRows(client, userId);
429
+ await replaceEmailReferences(client, userEmail, syntheticEmail);
430
+ await blankSentEmailsTo(client, userEmail);
382
431
  // password_reset_tokens has no user_id — it is keyed by the email string.
383
432
  await deleteRowsByEmail(client, 'password_reset_tokens', 'email', userEmail);
384
433
  await client.query('DELETE FROM ' + AUTH_USERS_TABLE + ' WHERE id = $1', [userId]);
@@ -401,6 +450,9 @@ module.exports = async function handler(req, res) {
401
450
  if (!userFound) {
402
451
  return res.status(404).json({ error: 'Account not found' });
403
452
  }
453
+ if (passwordRefused) {
454
+ return res.status(403).json({ error: 'Incorrect password' });
455
+ }
404
456
 
405
457
  // Best-effort farewell email AFTER the successful commit (connection closed).
406
458
  try {
@@ -442,7 +442,7 @@ module.exports = async function handler(req, res) {
442
442
  for (var h = 0; h < hKeys.length; h++) {
443
443
  res.setHeader(hKeys[h], earlyRes.headers[hKeys[h]]);
444
444
  }
445
- await utils.settlePendingNodePromises(context);
445
+ await utils.handOffPendingNodePromises(context);
446
446
  res.status(earlyRes.status || 500).json(earlyRes.body || {});
447
447
  return;
448
448
  }
@@ -453,11 +453,11 @@ module.exports = async function handler(req, res) {
453
453
  if (result && result.__terminal) break;
454
454
  }
455
455
 
456
- // Land every fire-and-forget query BEFORE replying: the platform may freeze
457
- // this function the moment the response is sent, which would drop the write.
458
- // The visitor does not pay for this — the client dispatched this segment
459
- // without awaiting it (see seg.fireAndForget in the client runtime).
460
- await utils.settlePendingNodePromises(context);
456
+ // Every not-awaited step must still land: the platform may freeze this
457
+ // function the moment the response is sent, which would drop the work. On
458
+ // Vercel it finishes after the reply (waitUntil); elsewhere the reply waits
459
+ // for it (see handOffPendingNodePromises in the server runtime).
460
+ await utils.handOffPendingNodePromises(context);
461
461
  delete context.__request;
462
462
  delete context.__pendingNodePromises;
463
463
  // MUST be stripped before replying: the results payload below IS this
@@ -469,7 +469,7 @@ module.exports = async function handler(req, res) {
469
469
  res.status(200).json({ success: true, results: utils.segmentReply(SEGMENT_CONFIG, context, __incomingSnapshot) });
470
470
  } catch (error) {
471
471
  console.error('Workflow segment error:', error);
472
- if (__wfContext) { await utils.settlePendingNodePromises(__wfContext); }
472
+ if (__wfContext) { await utils.handOffPendingNodePromises(__wfContext); }
473
473
  res.status(500).json({ success: false, error: error.message || 'Internal server error' });
474
474
  }
475
475
  };
@@ -527,9 +527,12 @@ function __isStreamingAI(node, config) {
527
527
  return config && config.streaming && __streamingAITypes.indexOf(node.type) >= 0;
528
528
  }
529
529
 
530
- // One node's result to the browser — never one kept on the server, and a data
531
- // node's rows without the users table's credentials (see segmentReply).
530
+ // One node's result to the browser — never one kept on the server nor a
531
+ // credential (the closing reply carries that one sealed), and a data node's
532
+ // rows without the users table's credentials (see segmentReply).
533
+ const __credentialResultIds = utils.credentialResultNodeIds(SEGMENT_CONFIG);
532
534
  function __streamNodeResult(res, nodeId, result) {
535
+ if (__credentialResultIds[nodeId]) return;
533
536
  const kept = SEGMENT_CONFIG.nodes.find(function(nd) { return nd.id === nodeId; });
534
537
  if (kept && kept.config && kept.config.keepResultOnServer === true && !utils.isBranchingNodeType(kept.type)) return;
535
538
  res.write('data: ' + JSON.stringify({ type: 'node-result', nodeId: nodeId, result: utils.withoutCredentialColumns(kept, result) }) + '\\n\\n');
@@ -873,7 +876,7 @@ module.exports = async function handler(req, res) {
873
876
  }
874
877
  const result = await nodeHandler(resolved, context);
875
878
  if (result && result.__earlyResponse) {
876
- await utils.settlePendingNodePromises(context);
879
+ await utils.handOffPendingNodePromises(context);
877
880
  if (streamStarted) {
878
881
  res.write('data: ' + JSON.stringify({ type: 'error', error: (result.__earlyResponse.body && result.__earlyResponse.body.message) || 'Request rejected' }) + '\\n\\n');
879
882
  res.end();
@@ -898,10 +901,9 @@ module.exports = async function handler(req, res) {
898
901
  }
899
902
  }
900
903
 
901
- // See the non-streaming segment route: land every fire-and-forget query
902
- // before the response completes, or the platform may freeze this function
903
- // with the write still in flight.
904
- await utils.settlePendingNodePromises(context);
904
+ // See the non-streaming segment route: every not-awaited step must still
905
+ // land, after the reply on Vercel, before it anywhere else.
906
+ await utils.handOffPendingNodePromises(context);
905
907
  delete context.__request;
906
908
  delete context.__pendingNodePromises;
907
909
  // MUST be stripped before replying: the results payload below IS this
@@ -919,7 +921,7 @@ module.exports = async function handler(req, res) {
919
921
  }
920
922
  } catch (error) {
921
923
  console.error('Streaming workflow segment error:', error);
922
- if (__wfContext) { await utils.settlePendingNodePromises(__wfContext); }
924
+ if (__wfContext) { await utils.handOffPendingNodePromises(__wfContext); }
923
925
  if (streamStarted) {
924
926
  try {
925
927
  res.write('data: ' + JSON.stringify({ type: 'error', error: error.message || 'Internal server error' }) + '\\n\\n');
@@ -673,7 +673,9 @@ const generateProvidersSetup = (auth: UIDLAuthentication): string => {
673
673
  if (!verifyPassword(String(credentials.password), user.password)) {
674
674
  throw new Error('InvalidCredentials');
675
675
  }
676
- return sanitizeUser(user);
676
+ const signedIn = sanitizeUser(user);
677
+ signedIn[TOKEN_PASSWORD_FINGERPRINT] = passwordFingerprint(user.password);
678
+ return signedIn;
677
679
  } catch (err) {
678
680
  if (err && err.message === 'InvalidCredentials') throw err;
679
681
  // A hashing/serialisation fault is an infrastructure problem, not a
@@ -758,6 +760,22 @@ const USER_REFRESH_INTERVAL_MS = ${USER_REFRESH_INTERVAL_MS};
758
760
  // internal timestamp on every binding that enumerates the user object.
759
761
  const TOKEN_REFRESH_STAMP = '__userRefreshedAt';
760
762
 
763
+ // A short fingerprint of the account's stored password hash, carried in the
764
+ // token from sign-in (or from the first refresh of a token that has none). A
765
+ // password change, a reset included, changes it, and the refresh then ends the
766
+ // session: every session opened before the change is signed out at its next
767
+ // refresh. Keyed with NEXTAUTH_SECRET; never the hash itself, and like the
768
+ // stamp above never copied onto \`session.user\`.
769
+ const TOKEN_PASSWORD_FINGERPRINT = '__passwordFingerprint';
770
+
771
+ function passwordFingerprint(storedHash) {
772
+ return require('crypto')
773
+ .createHmac('sha256', String(process.env.NEXTAUTH_SECRET || ''))
774
+ .update('teleport-password-fingerprint:v1|' + (storedHash ? String(storedHash) : ''))
775
+ .digest('base64')
776
+ .slice(0, 16);
777
+ }
778
+
761
779
  const authOptions = {
762
780
  providers: providers,
763
781
  pages: {
@@ -803,6 +821,7 @@ const authOptions = {
803
821
  if (params.trigger !== 'update' && isFresh) {
804
822
  return token;
805
823
  }
824
+ let passwordChanged = false;
806
825
  try {
807
826
  if (token && token.email && typeof findUserByEmail === 'function') {
808
827
  const fresh = await findUserByEmail(String(token.email));
@@ -813,10 +832,23 @@ const authOptions = {
813
832
  token[fk[i]] = safe[fk[i]];
814
833
  }
815
834
  }
835
+ if (fresh) {
836
+ const currentFingerprint = passwordFingerprint(fresh.password);
837
+ if (typeof token[TOKEN_PASSWORD_FINGERPRINT] !== 'string') {
838
+ token[TOKEN_PASSWORD_FINGERPRINT] = currentFingerprint;
839
+ } else if (token[TOKEN_PASSWORD_FINGERPRINT] !== currentFingerprint) {
840
+ passwordChanged = true;
841
+ }
842
+ }
816
843
  }
817
844
  } catch (e) {
818
845
  // Keep the existing token on any DB hiccup — never sign the user out.
819
846
  }
847
+ if (passwordChanged) {
848
+ // NextAuth answers a jwt callback that throws by clearing the session
849
+ // cookie and returning no session: the visitor is signed out.
850
+ throw new Error('The account password changed since this session signed in');
851
+ }
820
852
  // Stamped even when the read threw, so an unreachable database costs one
821
853
  // attempt per interval instead of one per request.
822
854
  if (token) {
@@ -830,6 +862,7 @@ const authOptions = {
830
862
  if (token && session.user) {
831
863
  const skip = { iat: 1, exp: 1, jti: 1, sub: 1 };
832
864
  skip[TOKEN_REFRESH_STAMP] = 1;
865
+ skip[TOKEN_PASSWORD_FINGERPRINT] = 1;
833
866
  const keys = Object.keys(token);
834
867
  for (let i = 0; i < keys.length; i++) {
835
868
  if (!skip[keys[i]]) {
@@ -15,6 +15,15 @@ export const DATA_NODE_TYPES: ReadonlyArray<string> = [
15
15
  'data-delete-item',
16
16
  ]
17
17
 
18
+ /**
19
+ * The other steps the author may choose not to wait for: server work whose
20
+ * result nothing downstream needs (a cash order's invoice, rendered, stored
21
+ * and mailed while the buyer is already on the confirmation page). The
22
+ * editor offers the choice on the same steps (`supportsAwaitResultOption` in
23
+ * teleport-gui's `@teleport/workflow-schema`) — a PAIRED EDIT.
24
+ */
25
+ export const NOT_AWAITABLE_SERVER_NODE_TYPES: ReadonlyArray<string> = ['ecommerce-generate-invoice']
26
+
18
27
  /**
19
28
  * Config key carrying the author's await choice. Only `false` opts out —
20
29
  * `undefined` (never configured) and `true` both mean "await", so every
@@ -22,14 +31,17 @@ export const DATA_NODE_TYPES: ReadonlyArray<string> = [
22
31
  */
23
32
  export const AWAIT_RESULT_CONFIG_KEY = 'awaitResult'
24
33
 
25
- const DATA_NODE_TYPE_SET = new Set<string>(DATA_NODE_TYPES)
34
+ const AWAIT_OPTIONAL_NODE_TYPE_SET = new Set<string>([
35
+ ...DATA_NODE_TYPES,
36
+ ...NOT_AWAITABLE_SERVER_NODE_TYPES,
37
+ ])
26
38
 
27
39
  /**
28
- * True when this node runs fire-and-forget: the runtime starts the query,
40
+ * True when this node runs fire-and-forget: the runtime starts the step,
29
41
  * publishes `null` under the node's id and moves straight on to the next node.
30
42
  */
31
43
  export const isFireAndForgetNode = (node: UIDLWorkflowNode | undefined | null): boolean => {
32
- if (!node || !DATA_NODE_TYPE_SET.has(node.type)) {
44
+ if (!node || !AWAIT_OPTIONAL_NODE_TYPE_SET.has(node.type)) {
33
45
  return false
34
46
  }
35
47
  const config = node.config as Record<string, unknown> | undefined
@@ -41,10 +53,10 @@ export const isFireAndForgetNode = (node: UIDLWorkflowNode | undefined | null):
41
53
  * response — i.e. every node it holds is fire-and-forget, so nothing
42
54
  * downstream can read anything the segment produces.
43
55
  *
44
- * The API route itself still awaits each query before replying (a serverless
45
- * function may be frozen the moment it responds, which would drop an in-flight
46
- * write); what this flag removes is the CLIENT's wait on that round trip, which
47
- * is where the latency the visitor feels actually comes from.
56
+ * The API route itself still lands each step (a serverless function may be
57
+ * frozen the moment it responds, which would drop an in-flight write: on
58
+ * Vercel the step finishes after the reply, elsewhere the reply waits); what
59
+ * this flag removes is the CLIENT's wait on that round trip.
48
60
  */
49
61
  export const isFireAndForgetSegment = (segment: WorkflowSegment): boolean =>
50
62
  segment.env === 'server' &&
@@ -0,0 +1,71 @@
1
+ import { nodeRegistry } from './nodes'
2
+
3
+ /**
4
+ * The node types whose result is a credential, read off the node registry
5
+ * (`resultIsCredential`): today the password-hashing node. Its result stays on
6
+ * the server. A server segment replies it to the browser only sealed —
7
+ * encrypted with a key derived from the app secret, signed like every other
8
+ * result — and a later server segment opens it again, so a workflow that
9
+ * hashes in one segment and writes in another still works (see the server
10
+ * runtime: `sealResult`, `openSealedResults`).
11
+ */
12
+ export const CREDENTIAL_RESULT_NODE_TYPES: string[] = Object.keys(nodeRegistry).filter(
13
+ (nodeType) => nodeRegistry[nodeType].resultIsCredential === true
14
+ )
15
+
16
+ /**
17
+ * `credentialResultNodeIds(segmentConfig)`: the nodes of a segment whose
18
+ * result is a credential — every node of a credential type, and every map
19
+ * loop whose results are such a node's results (the loop's last body step, as
20
+ * the route runs it; a loop of loops follows its inner loop).
21
+ */
22
+ export const generateCredentialResultsCode = (): string => `
23
+ var __CREDENTIAL_RESULT_NODE_TYPES = ${JSON.stringify(CREDENTIAL_RESULT_NODE_TYPES)};
24
+
25
+ function lastLoopBodyNodeId(loopId, sortedNodes, edges) {
26
+ var start = edges.find(function(e) { return e.source === loopId && e.sourceHandle === 'loop'; });
27
+ if (!start) return null;
28
+ var body = {};
29
+ var queue = [start.target];
30
+ while (queue.length > 0) {
31
+ var current = queue.shift();
32
+ if (body[current] || current === loopId) continue;
33
+ body[current] = true;
34
+ edges.forEach(function(e) {
35
+ if (e.source === current && (e.sourceHandle === 'loop-body-out' || !e.sourceHandle) && e.target !== loopId) {
36
+ queue.push(e.target);
37
+ }
38
+ });
39
+ }
40
+ var last = null;
41
+ sortedNodes.forEach(function(n) { if (body[n.id]) last = n.id; });
42
+ return last;
43
+ }
44
+
45
+ function credentialResultNodeIds(segmentConfig) {
46
+ var nodes = (segmentConfig && segmentConfig.nodes) || [];
47
+ var edges = (segmentConfig && segmentConfig.edges) || [];
48
+ var ids = {};
49
+ nodes.forEach(function(n) {
50
+ if (__CREDENTIAL_RESULT_NODE_TYPES.indexOf(n.type) !== -1) ids[n.id] = true;
51
+ });
52
+ var sortedNodes = nodes.slice().sort(function(a, b) { return (a.stepNumber || 0) - (b.stepNumber || 0); });
53
+ var mapLoops = nodes.filter(function(n) {
54
+ var loopType = n.config && n.config.loopType;
55
+ return n.type === 'general-loop' && !!loopType && loopType !== 'forEach';
56
+ });
57
+ var grew = true;
58
+ while (grew) {
59
+ grew = false;
60
+ mapLoops.forEach(function(loop) {
61
+ if (ids[loop.id]) return;
62
+ var last = lastLoopBodyNodeId(loop.id, sortedNodes, edges);
63
+ if (last && ids[last]) {
64
+ ids[loop.id] = true;
65
+ grew = true;
66
+ }
67
+ });
68
+ }
69
+ return ids;
70
+ }
71
+ `
@@ -1219,7 +1219,15 @@ module.exports = async function handler(req, res) {
1219
1219
  }
1220
1220
 
1221
1221
  var operation = params[params.length - 1];
1222
- var body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
1222
+ var body;
1223
+ try {
1224
+ body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
1225
+ } catch (e) {
1226
+ body = null;
1227
+ }
1228
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
1229
+ return res.status(400).json({ error: 'Invalid request body. Expected a JSON object.' });
1230
+ }
1223
1231
  var client = getClient();
1224
1232
 
1225
1233
  try {
@@ -5,6 +5,7 @@ import {
5
5
  } from './email-locale'
6
6
  import { generateSignedInDestinationCode } from './auth-return/signed-in-destination-code'
7
7
  import { generateCredentialColumnsCode } from './credential-columns-code'
8
+ import { generateCredentialResultsCode } from './credential-results'
8
9
 
9
10
  export interface SharedRuntimeUtilsOptions {
10
11
  /**
@@ -654,9 +655,15 @@ function normalizeComparisonOperator(op) {
654
655
  .toLowerCase();
655
656
  }
656
657
 
658
+ // The editor's templates write a comparison's sides as leftOperand /
659
+ // rightOperand; everything else writes leftValue / rightValue.
660
+ function comparisonSide(config, valueKey, operandKey) {
661
+ return config[valueKey] !== undefined ? config[valueKey] : config[operandKey];
662
+ }
663
+
657
664
  function evaluateSingleComparison(config, context) {
658
- var rawLeft = resolveValue(config.leftValue, context);
659
- var rawRight = resolveValue(config.rightValue, context);
665
+ var rawLeft = resolveValue(comparisonSide(config, 'leftValue', 'leftOperand'), context);
666
+ var rawRight = resolveValue(comparisonSide(config, 'rightValue', 'rightOperand'), context);
660
667
  const op = normalizeComparisonOperator(config.operator || 'equals');
661
668
  var isUnary = op === 'is-empty' || op === 'is-not-empty' || op === 'is-truthy' || op === 'is-falsy' ||
662
669
  op === 'is-null' || op === 'is-not-null' || op === 'is-true' || op === 'is-false';
@@ -666,7 +673,7 @@ function evaluateSingleComparison(config, context) {
666
673
  switch (op) {
667
674
  case '===': return left === right;
668
675
  case '!==': return left !== right;
669
- case '==': case 'equals': return left == right;
676
+ case '=': case '==': case 'equals': return left == right;
670
677
  case '!=': case 'not-equals': return left != right;
671
678
  case '>': case 'greater-than': return left > right;
672
679
  case '<': case 'less-than': return left < right;
@@ -808,10 +815,16 @@ var __DATA_NODE_TYPES = {
808
815
  'data-delete-item': true
809
816
  };
810
817
 
811
- // A data node the author opted out of awaiting. Only an explicit \`false\` opts
812
- // out, so a config written before the option existed keeps awaiting.
818
+ // Mirrors NOT_AWAITABLE_SERVER_NODE_TYPES in await-result.ts — PAIRED EDIT.
819
+ var __NOT_AWAITABLE_SERVER_NODE_TYPES = {
820
+ 'ecommerce-generate-invoice': true
821
+ };
822
+
823
+ // A data node (or a step listed above) the author opted out of awaiting. Only
824
+ // an explicit \`false\` opts out, so a config written before the option existed
825
+ // keeps awaiting.
813
826
  function isFireAndForgetNode(node) {
814
- if (!node || !__DATA_NODE_TYPES[node.type]) return false;
827
+ if (!node || !(__DATA_NODE_TYPES[node.type] || __NOT_AWAITABLE_SERVER_NODE_TYPES[node.type])) return false;
815
828
  return !!node.config && node.config.awaitResult === false;
816
829
  }
817
830
 
@@ -1418,7 +1431,12 @@ var SEGMENT_REPLY_PRIVATE_KEYS = {
1418
1431
  //
1419
1432
  // A data node's rows are replied without the auth users table's credentials
1420
1433
  // (see credential-columns-code.ts).
1434
+ //
1435
+ // A node whose result is a credential (a password hash) is left out here too,
1436
+ // whatever its config says: the server runtime replies it sealed, readable by
1437
+ // a later server segment only (see credential-results.ts).
1421
1438
  ${generateCredentialColumnsCode()}
1439
+ ${generateCredentialResultsCode()}
1422
1440
  // The nodes whose result the client branches on: never kept on the server.
1423
1441
  function isBranchingNodeType(type) {
1424
1442
  return type === 'general-if-statement' || type === 'general-switch';
@@ -1427,7 +1445,7 @@ function isBranchingNodeType(type) {
1427
1445
  function segmentReply(segmentConfig, context, snapshot) {
1428
1446
  var reply = {};
1429
1447
  var own = {};
1430
- var kept = {};
1448
+ var kept = credentialResultNodeIds(segmentConfig);
1431
1449
  var nodes = (segmentConfig && segmentConfig.nodes) || [];
1432
1450
  for (var ni = 0; ni < nodes.length; ni++) {
1433
1451
  own[nodes[ni].id] = nodes[ni];
@@ -1514,6 +1532,7 @@ module.exports = {
1514
1532
  snapshotIncomingContext,
1515
1533
  segmentReply,
1516
1534
  withoutCredentialColumns,
1535
+ credentialResultNodeIds,
1517
1536
  evaluateCondition,
1518
1537
  evaluateSingleComparison,
1519
1538
  normalizeComparisonOperator,