@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.
- package/__tests__/analytics-outcome-visit-ids.test.ts +583 -0
- package/__tests__/analytics-track-outcome.test.ts +71 -3
- package/__tests__/auth-password-security.test.ts +101 -0
- package/__tests__/automations-bus.test.ts +439 -2
- package/__tests__/security-lists-parity.test.ts +2 -2
- package/__tests__/security-runtime.test.ts +37 -20
- package/dist/cjs/api-route-generator.d.ts +2 -0
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +43 -1
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/auth-generator.d.ts.map +1 -1
- package/dist/cjs/auth-generator.js +48 -19
- package/dist/cjs/auth-generator.js.map +1 -1
- package/dist/cjs/automations/dispatcher-source.d.ts.map +1 -1
- package/dist/cjs/automations/dispatcher-source.js +13 -5
- package/dist/cjs/automations/dispatcher-source.js.map +1 -1
- package/dist/cjs/automations/event-bus-source.d.ts.map +1 -1
- package/dist/cjs/automations/event-bus-source.js +37 -23
- package/dist/cjs/automations/event-bus-source.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +4 -0
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/nodes/account/account-request-password-reset.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-request-password-reset.js +3 -2
- package/dist/cjs/nodes/account/account-request-password-reset.js.map +1 -1
- package/dist/cjs/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
- package/dist/cjs/nodes/analytics/analytics-track-outcome.js +17 -4
- package/dist/cjs/nodes/analytics/analytics-track-outcome.js.map +1 -1
- package/dist/cjs/password-reset-route-generator.d.ts +2 -0
- package/dist/cjs/password-reset-route-generator.d.ts.map +1 -1
- package/dist/cjs/password-reset-route-generator.js +17 -10
- package/dist/cjs/password-reset-route-generator.js.map +1 -1
- package/dist/cjs/security-runtime/security-runtime-code.d.ts +3 -2
- package/dist/cjs/security-runtime/security-runtime-code.d.ts.map +1 -1
- package/dist/cjs/security-runtime/security-runtime-code.js +17 -13
- package/dist/cjs/security-runtime/security-runtime-code.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/api-route-generator.d.ts +2 -0
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +44 -2
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/auth-generator.d.ts.map +1 -1
- package/dist/esm/auth-generator.js +48 -19
- package/dist/esm/auth-generator.js.map +1 -1
- package/dist/esm/automations/dispatcher-source.d.ts.map +1 -1
- package/dist/esm/automations/dispatcher-source.js +13 -5
- package/dist/esm/automations/dispatcher-source.js.map +1 -1
- package/dist/esm/automations/event-bus-source.d.ts.map +1 -1
- package/dist/esm/automations/event-bus-source.js +37 -23
- package/dist/esm/automations/event-bus-source.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +4 -0
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/nodes/account/account-request-password-reset.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-request-password-reset.js +3 -2
- package/dist/esm/nodes/account/account-request-password-reset.js.map +1 -1
- package/dist/esm/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
- package/dist/esm/nodes/analytics/analytics-track-outcome.js +17 -4
- package/dist/esm/nodes/analytics/analytics-track-outcome.js.map +1 -1
- package/dist/esm/password-reset-route-generator.d.ts +2 -0
- package/dist/esm/password-reset-route-generator.d.ts.map +1 -1
- package/dist/esm/password-reset-route-generator.js +16 -9
- package/dist/esm/password-reset-route-generator.js.map +1 -1
- package/dist/esm/security-runtime/security-runtime-code.d.ts +3 -2
- package/dist/esm/security-runtime/security-runtime-code.d.ts.map +1 -1
- package/dist/esm/security-runtime/security-runtime-code.js +17 -13
- package/dist/esm/security-runtime/security-runtime-code.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/api-route-generator.ts +52 -2
- package/src/auth-generator.ts +48 -19
- package/src/automations/dispatcher-source.ts +13 -5
- package/src/automations/event-bus-source.ts +37 -23
- package/src/executor-generator.ts +4 -0
- package/src/nodes/account/account-request-password-reset.ts +3 -2
- package/src/nodes/analytics/analytics-track-outcome.ts +17 -4
- package/src/password-reset-route-generator.ts +18 -9
- 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.
|
|
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": "
|
|
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 {
|
|
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
|
-
|
|
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;
|
package/src/auth-generator.ts
CHANGED
|
@@ -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
|
|
734
|
-
//
|
|
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
|
|
739
|
+
var UNDEFINED_TABLE_CODE = '42P01';
|
|
737
740
|
|
|
738
|
-
async function
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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
|
-
|
|
115
|
-
await client
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
9
|
-
//
|
|
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:
|
|
72
|
-
visitorId:
|
|
73
|
-
path:
|
|
74
|
-
exp:
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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`,
|
|
42
|
-
*
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
399
|
-
// older backup): create 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
|
}
|