@pikku/core 0.12.93 → 0.12.95
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/CHANGELOG.md +157 -0
- package/dist/services/email-template.d.ts +43 -0
- package/dist/services/email-template.js +139 -0
- package/dist/services/http-personas.d.ts +6 -1
- package/dist/services/http-personas.js +4 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/wirings/agent/agent-prepare.d.ts +14 -0
- package/dist/wirings/agent/agent-prepare.js +24 -0
- package/dist/wirings/agent/index.d.ts +1 -1
- package/dist/wirings/agent/index.js +1 -1
- package/dist/wirings/persona/index.d.ts +1 -0
- package/dist/wirings/persona/index.js +1 -0
- package/dist/wirings/persona/persona-app-scopes.d.ts +41 -0
- package/dist/wirings/persona/persona-app-scopes.js +61 -0
- package/dist/wirings/scheduler/scheduler-runner.js +0 -1
- package/dist/wirings/virtual-user/index.d.ts +1 -0
- package/dist/wirings/virtual-user/index.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-derive.js +9 -0
- package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +267 -0
- package/dist/wirings/virtual-user/virtual-user-scaffold.js +400 -0
- package/dist/wirings/workflow/index.d.ts +1 -0
- package/dist/wirings/workflow/index.js +1 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +3 -9
- package/dist/wirings/workflow/workflow-queue-routing.d.ts +18 -0
- package/dist/wirings/workflow/workflow-queue-routing.js +35 -0
- package/dist/wirings/workflow/workflow-status-stream.d.ts +28 -0
- package/dist/wirings/workflow/workflow-status-stream.js +105 -0
- package/package.json +1 -1
- package/src/public-surface.json +20 -1
- package/src/services/email-template.test.ts +311 -0
- package/src/services/email-template.ts +254 -0
- package/src/services/http-personas.ts +10 -2
- package/src/services/index.ts +8 -0
- package/src/services/persona-sign-in.test.ts +22 -0
- package/src/wirings/agent/agent-helpers.test.ts +63 -0
- package/src/wirings/agent/agent-prepare.ts +25 -0
- package/src/wirings/agent/index.ts +1 -0
- package/src/wirings/persona/index.ts +5 -0
- package/src/wirings/persona/persona-app-scopes.test.ts +47 -0
- package/src/wirings/persona/persona-app-scopes.ts +74 -0
- package/src/wirings/scheduler/scheduler-runner.test.ts +178 -0
- package/src/wirings/scheduler/scheduler-runner.ts +0 -1
- package/src/wirings/virtual-user/index.ts +20 -0
- package/src/wirings/virtual-user/virtual-user-derive.test.ts +28 -0
- package/src/wirings/virtual-user/virtual-user-derive.ts +9 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.test.ts +795 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.ts +634 -0
- package/src/wirings/workflow/index.ts +4 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +71 -2
- package/src/wirings/workflow/pikku-workflow-service.ts +5 -11
- package/src/wirings/workflow/workflow-child-run-session.test.ts +79 -0
- package/src/wirings/workflow/workflow-queue-routing.ts +44 -0
- package/src/wirings/workflow/workflow-status-stream.test.ts +354 -0
- package/src/wirings/workflow/workflow-status-stream.ts +144 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -11,7 +11,7 @@ import { buildRunTimeline, reconstructStateAt, } from './run-timeline.js';
|
|
|
11
11
|
import { DEFAULT_STEP_RETRIES, WORKFLOW_CHILD_POLL_MAX_MS, WORKFLOW_END_STATES, WORKFLOW_POLL_FACTOR, WORKFLOW_POLL_MIN_MS, WORKFLOW_TERMINAL_STATES, isRunSettled, } from './workflow-constants.js';
|
|
12
12
|
import { WorkflowAsyncException, WorkflowCancelledException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotFoundError, WorkflowStepNameNotString, WorkflowSuspendedException, } from './workflow-errors.js';
|
|
13
13
|
import { resolveWorkflowMeta } from './workflow-meta-resolver.js';
|
|
14
|
-
import { jobGroupFor, orchestratorQueueName, resolveWorkflowConfig, stepJobOptions, stepWorkerQueueName, } from './workflow-queue-routing.js';
|
|
14
|
+
import { jobGroupFor, orchestratorQueueName, resolveWorkflowConfig, stepDispatchTarget, stepJobOptions, stepWorkerQueueName, } from './workflow-queue-routing.js';
|
|
15
15
|
import { wireWorkflowQueueWorkers } from './workflow-queue-wiring.js';
|
|
16
16
|
import { approvalStepNameFor, evaluateApprovalStep, recordApprovalDecision, } from './workflow-approval.js';
|
|
17
17
|
import { auditApprovalDecision } from './workflow-approval-audit.js';
|
|
@@ -323,16 +323,10 @@ export class PikkuWorkflowService {
|
|
|
323
323
|
});
|
|
324
324
|
}
|
|
325
325
|
async dispatchStep(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
const rpcMeta = typeof rpcFuncId === 'string' ? functionsMeta[rpcFuncId] : undefined;
|
|
329
|
-
const forceQueue = rpcMeta?.workflowQueued === true;
|
|
330
|
-
if (!forceQueue) {
|
|
326
|
+
const target = await stepDispatchTarget(rpcName, stepName, () => this.isInline(runId));
|
|
327
|
+
if (target === 'inline') {
|
|
331
328
|
return false;
|
|
332
329
|
}
|
|
333
|
-
if (!getSingletonServices()?.queueService) {
|
|
334
|
-
throw new Error(`Workflow step '${stepName}' (function '${rpcName}') is marked 'workflowQueued: true' but no queue service is configured.`);
|
|
335
|
-
}
|
|
336
330
|
try {
|
|
337
331
|
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), { runId, stepName, rpcName, data, fromStepName }, {
|
|
338
332
|
...this.resolveStepJobOptions(stepOptions),
|
|
@@ -4,5 +4,23 @@ export type WorkflowQueueStrategy = 'per-workflow' | 'shared-groups';
|
|
|
4
4
|
export declare const resolveWorkflowConfig: () => WorkflowServiceConfig;
|
|
5
5
|
export declare const orchestratorQueueName: (strategy: WorkflowQueueStrategy, workflowName?: string) => string;
|
|
6
6
|
export declare const stepWorkerQueueName: (strategy: WorkflowQueueStrategy, rpcName?: string) => string;
|
|
7
|
+
/**
|
|
8
|
+
* How a step reaches its worker: on the queue, or here in the orchestrator.
|
|
9
|
+
*
|
|
10
|
+
* A step naming a workflow queues whenever a queue exists, even unmarked. Run
|
|
11
|
+
* here, it holds the parent's run lock — and its lock connection — until the
|
|
12
|
+
* child ends, and marks the child inline so the child's own `sleep` degrades
|
|
13
|
+
* from a suspension into a real in-process wait. Workflows cannot opt in
|
|
14
|
+
* through `workflowQueued`: that flag is read off `rpc` meta, and `addWorkflow`
|
|
15
|
+
* never registers there.
|
|
16
|
+
*
|
|
17
|
+
* Throws only for a step that asked for the queue by name and has none, which
|
|
18
|
+
* is a deployment missing a service rather than a routing choice.
|
|
19
|
+
*
|
|
20
|
+
* `parentIsInline` is a thunk because resolving it can read the run store, and
|
|
21
|
+
* every step dispatch would pay for that — only a step that names a workflow
|
|
22
|
+
* and has a queue to reach ever asks.
|
|
23
|
+
*/
|
|
24
|
+
export declare const stepDispatchTarget: (rpcName: string, stepName: string, parentIsInline: () => Promise<boolean>) => Promise<"queue" | "inline">;
|
|
7
25
|
export declare const jobGroupFor: (strategy: WorkflowQueueStrategy, id?: string) => JobGroup | undefined;
|
|
8
26
|
export declare const stepJobOptions: (stepOptions?: WorkflowStepOptions) => JobOptions;
|
|
@@ -25,6 +25,41 @@ const dedicatedQueueName = (prefix, name, strategy, fallback) => {
|
|
|
25
25
|
};
|
|
26
26
|
export const orchestratorQueueName = (strategy, workflowName) => dedicatedQueueName('wf-orchestrator-', workflowName, strategy, resolveWorkflowConfig().orchestratorQueueName);
|
|
27
27
|
export const stepWorkerQueueName = (strategy, rpcName) => dedicatedQueueName('wf-step-', rpcName, strategy, resolveWorkflowConfig().stepWorkerQueueName);
|
|
28
|
+
/**
|
|
29
|
+
* How a step reaches its worker: on the queue, or here in the orchestrator.
|
|
30
|
+
*
|
|
31
|
+
* A step naming a workflow queues whenever a queue exists, even unmarked. Run
|
|
32
|
+
* here, it holds the parent's run lock — and its lock connection — until the
|
|
33
|
+
* child ends, and marks the child inline so the child's own `sleep` degrades
|
|
34
|
+
* from a suspension into a real in-process wait. Workflows cannot opt in
|
|
35
|
+
* through `workflowQueued`: that flag is read off `rpc` meta, and `addWorkflow`
|
|
36
|
+
* never registers there.
|
|
37
|
+
*
|
|
38
|
+
* Throws only for a step that asked for the queue by name and has none, which
|
|
39
|
+
* is a deployment missing a service rather than a routing choice.
|
|
40
|
+
*
|
|
41
|
+
* `parentIsInline` is a thunk because resolving it can read the run store, and
|
|
42
|
+
* every step dispatch would pay for that — only a step that names a workflow
|
|
43
|
+
* and has a queue to reach ever asks.
|
|
44
|
+
*/
|
|
45
|
+
export const stepDispatchTarget = async (rpcName, stepName, parentIsInline) => {
|
|
46
|
+
const rpcFuncId = pikkuState(null, 'rpc', 'meta')[rpcName];
|
|
47
|
+
const rpcMeta = typeof rpcFuncId === 'string'
|
|
48
|
+
? pikkuState(null, 'function', 'meta')[rpcFuncId]
|
|
49
|
+
: undefined;
|
|
50
|
+
const hasQueue = getSingletonServices()?.queueService !== undefined;
|
|
51
|
+
if (rpcMeta?.workflowQueued === true) {
|
|
52
|
+
if (!hasQueue) {
|
|
53
|
+
throw new Error(`Workflow step '${stepName}' (function '${rpcName}') is marked 'workflowQueued: true' but no queue service is configured.`);
|
|
54
|
+
}
|
|
55
|
+
return 'queue';
|
|
56
|
+
}
|
|
57
|
+
const isWorkflow = pikkuState(null, 'workflows', 'meta')[rpcName] !== undefined;
|
|
58
|
+
if (!isWorkflow || !hasQueue) {
|
|
59
|
+
return 'inline';
|
|
60
|
+
}
|
|
61
|
+
return (await parentIsInline()) ? 'inline' : 'queue';
|
|
62
|
+
};
|
|
28
63
|
export const jobGroupFor = (strategy, id) => id && strategy === 'shared-groups' ? { id, tier: id } : undefined;
|
|
29
64
|
export const stepJobOptions = (stepOptions) => {
|
|
30
65
|
const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CoreUserSession } from '../../types/core.types.js';
|
|
2
|
+
import type { PikkuChannel } from '../channel/channel.types.js';
|
|
3
|
+
import type { WorkflowRunService } from './workflow.types.js';
|
|
4
|
+
export interface WorkflowStatusStreamParams {
|
|
5
|
+
workflowRunService: WorkflowRunService;
|
|
6
|
+
runId: string;
|
|
7
|
+
channel: Pick<PikkuChannel<unknown, any>, 'send' | 'close'>;
|
|
8
|
+
session: CoreUserSession | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Whether to include what the run produced. Off for the user-facing route:
|
|
11
|
+
* a workflow's output and its error messages are internal detail, and a step
|
|
12
|
+
* that spawned a child run says so only to tooling that can follow it.
|
|
13
|
+
*/
|
|
14
|
+
detailed?: boolean;
|
|
15
|
+
pollIntervalMs?: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Streams one run's progress until it reaches a terminal state.
|
|
19
|
+
*
|
|
20
|
+
* Polled rather than subscribed because a run's steps are written by whichever
|
|
21
|
+
* worker picked them up, in whichever process — there is no in-memory event to
|
|
22
|
+
* listen for that every deployment shape would deliver.
|
|
23
|
+
*
|
|
24
|
+
* Each poll sends only when something changed, compared by a hash of exactly
|
|
25
|
+
* what this stream reports. A run that sits on a slow step for a minute costs
|
|
26
|
+
* one message, not a hundred and twenty.
|
|
27
|
+
*/
|
|
28
|
+
export declare const streamWorkflowRunStatus: ({ workflowRunService, runId, channel, session, detailed, pollIntervalMs, }: WorkflowStatusStreamParams) => Promise<void>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { assertWorkflowRunOwner } from './workflow-run-ownership.js';
|
|
2
|
+
/**
|
|
3
|
+
* The status stream behind the scaffolded workflow SSE routes.
|
|
4
|
+
*
|
|
5
|
+
* Two routes share it, and they differ by one thing: whether the caller is
|
|
6
|
+
* trusted with what the run produced. A user-facing frontend gets step names
|
|
7
|
+
* and statuses; an admin console also gets the output, the error and the child
|
|
8
|
+
* run ids. That is a parameter, not a second copy of the loop.
|
|
9
|
+
*/
|
|
10
|
+
const TERMINAL = new Set([
|
|
11
|
+
'completed',
|
|
12
|
+
'failed',
|
|
13
|
+
'cancelled',
|
|
14
|
+
]);
|
|
15
|
+
const DEFAULT_POLL_INTERVAL_MS = 500;
|
|
16
|
+
/**
|
|
17
|
+
* Streams one run's progress until it reaches a terminal state.
|
|
18
|
+
*
|
|
19
|
+
* Polled rather than subscribed because a run's steps are written by whichever
|
|
20
|
+
* worker picked them up, in whichever process — there is no in-memory event to
|
|
21
|
+
* listen for that every deployment shape would deliver.
|
|
22
|
+
*
|
|
23
|
+
* Each poll sends only when something changed, compared by a hash of exactly
|
|
24
|
+
* what this stream reports. A run that sits on a slow step for a minute costs
|
|
25
|
+
* one message, not a hundred and twenty.
|
|
26
|
+
*/
|
|
27
|
+
export const streamWorkflowRunStatus = async ({ workflowRunService, runId, channel, session, detailed = false, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, }) => {
|
|
28
|
+
let lastHash = '';
|
|
29
|
+
let initSent = false;
|
|
30
|
+
const poll = async () => {
|
|
31
|
+
const run = await workflowRunService.getRun(runId);
|
|
32
|
+
if (!run) {
|
|
33
|
+
await channel.close();
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
// Checked on every poll, not just the first: ownership is read from the run
|
|
37
|
+
// itself, and a stream that outlives a session should stop rather than keep
|
|
38
|
+
// reporting.
|
|
39
|
+
assertWorkflowRunOwner(run.wire, session);
|
|
40
|
+
const steps = await workflowRunService.getRunSteps(runId);
|
|
41
|
+
// A deterministic run knows its whole shape up front, so the client can
|
|
42
|
+
// draw every step — including the ones not started — before anything runs.
|
|
43
|
+
// A dynamic run has nothing to send here, and gets no init frame.
|
|
44
|
+
if (!initSent && run.deterministic) {
|
|
45
|
+
const statusByStep = new Map(steps.map((step) => [step.stepName, step.status]));
|
|
46
|
+
await channel.send({
|
|
47
|
+
type: 'init',
|
|
48
|
+
deterministic: true,
|
|
49
|
+
steps: (run.plannedSteps ?? []).map((step) => ({
|
|
50
|
+
stepName: step.stepName,
|
|
51
|
+
status: statusByStep.get(step.stepName) ?? 'pending',
|
|
52
|
+
})),
|
|
53
|
+
});
|
|
54
|
+
initSent = true;
|
|
55
|
+
}
|
|
56
|
+
const hash = JSON.stringify({
|
|
57
|
+
s: run.status,
|
|
58
|
+
...(detailed ? { o: run.output } : {}),
|
|
59
|
+
steps: steps.map((step) => [step.stepName, step.status]),
|
|
60
|
+
});
|
|
61
|
+
if (hash !== lastHash) {
|
|
62
|
+
lastHash = hash;
|
|
63
|
+
await channel.send({
|
|
64
|
+
type: 'update',
|
|
65
|
+
status: run.status,
|
|
66
|
+
...(detailed ? { output: run.output, error: run.error } : {}),
|
|
67
|
+
steps: steps.map((step) => ({
|
|
68
|
+
stepName: step.stepName,
|
|
69
|
+
status: step.status,
|
|
70
|
+
...(detailed && step.childRunId
|
|
71
|
+
? { childRunId: step.childRunId }
|
|
72
|
+
: {}),
|
|
73
|
+
})),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (TERMINAL.has(run.status)) {
|
|
77
|
+
await channel.send({ type: 'done' });
|
|
78
|
+
await channel.close();
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
83
|
+
// Every exit from here closes the channel, including the ones a throw takes:
|
|
84
|
+
// `assertWorkflowRunOwner` rejecting a session that lost access is exactly
|
|
85
|
+
// the case where the stream should end rather than be left hanging open.
|
|
86
|
+
try {
|
|
87
|
+
// A run that is already finished is answered without ever starting a timer.
|
|
88
|
+
if (!(await poll())) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// The next poll is scheduled when the previous one resolves rather than on
|
|
92
|
+
// a fixed interval. A timer that fires regardless would let two polls
|
|
93
|
+
// overlap on a slow store — both seeing `initSent` unset and sending the
|
|
94
|
+
// init frame twice, and racing `lastHash` into out-of-order updates.
|
|
95
|
+
while (await new Promise((resolve, reject) => {
|
|
96
|
+
setTimeout(() => void poll().then(resolve, reject), pollIntervalMs);
|
|
97
|
+
})) {
|
|
98
|
+
// The condition is the whole loop: poll until it says to stop.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
await channel.close();
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
};
|
package/package.json
CHANGED
package/src/public-surface.json
CHANGED
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"pikkuWorkflowGraph",
|
|
68
68
|
"reconstructFinalState",
|
|
69
69
|
"reconstructStateAt",
|
|
70
|
+
"streamWorkflowRunStatus",
|
|
70
71
|
"template",
|
|
71
72
|
"uuidv5"
|
|
72
73
|
],
|
|
@@ -103,22 +104,35 @@
|
|
|
103
104
|
"IntentStack",
|
|
104
105
|
"PRODUCTION_DISPOSITION",
|
|
105
106
|
"STALE_RUN_AFTER_MS",
|
|
107
|
+
"VIRTUAL_USER_VARIABLES",
|
|
106
108
|
"catalogueClassification",
|
|
107
109
|
"catalogueLookup",
|
|
108
110
|
"deriveCatalogue",
|
|
109
111
|
"deriveIntents",
|
|
110
112
|
"dispositionProfile",
|
|
113
|
+
"executeVirtualUserRun",
|
|
111
114
|
"intentsForPersona",
|
|
112
115
|
"isDue",
|
|
113
116
|
"isReadOnly",
|
|
117
|
+
"logVirtualUserTick",
|
|
114
118
|
"nextRunAt",
|
|
115
119
|
"personaScopes",
|
|
116
120
|
"personaVirtualUserTarget",
|
|
117
121
|
"prepareVirtualUserRun",
|
|
118
122
|
"reachableCatalogue",
|
|
123
|
+
"requireVirtualUserRunStore",
|
|
124
|
+
"requireVirtualUserScheduleStore",
|
|
119
125
|
"runVirtualUser",
|
|
126
|
+
"runnablePersona",
|
|
127
|
+
"serializeVirtualUserRun",
|
|
128
|
+
"serializeVirtualUserSchedule",
|
|
129
|
+
"serializeVirtualUserSteps",
|
|
130
|
+
"signInPathFor",
|
|
131
|
+
"startVirtualUserRun",
|
|
120
132
|
"tickVirtualUserSchedules",
|
|
121
|
-
"unreachableCatalogue"
|
|
133
|
+
"unreachableCatalogue",
|
|
134
|
+
"virtualUserScheduleRunInput",
|
|
135
|
+
"writeVirtualUserSchedule"
|
|
122
136
|
],
|
|
123
137
|
"./channel/local": [
|
|
124
138
|
"LocalEventHubService",
|
|
@@ -203,6 +217,7 @@
|
|
|
203
217
|
"addAgent",
|
|
204
218
|
"agent",
|
|
205
219
|
"agentApprove",
|
|
220
|
+
"agentCallOptions",
|
|
206
221
|
"agentInterrupt",
|
|
207
222
|
"agentResume",
|
|
208
223
|
"agentStream",
|
|
@@ -272,10 +287,13 @@
|
|
|
272
287
|
"validateAndBuildSystemRoleDefinitionsMeta"
|
|
273
288
|
],
|
|
274
289
|
"./persona": [
|
|
290
|
+
"APP_SCOPE_ROOT",
|
|
275
291
|
"ActorSignIn",
|
|
276
292
|
"HttpPersona",
|
|
277
293
|
"IMPERSONATE_USER_ID_HEADER",
|
|
278
294
|
"OperatorSignIn",
|
|
295
|
+
"appScopeId",
|
|
296
|
+
"buildAppScopeDefinition",
|
|
279
297
|
"createHttpPersonas",
|
|
280
298
|
"definePersonas",
|
|
281
299
|
"establishOperatorSession",
|
|
@@ -381,6 +399,7 @@
|
|
|
381
399
|
"getStubTracker",
|
|
382
400
|
"isTestRun",
|
|
383
401
|
"pikkuWebhookWorkerFunc",
|
|
402
|
+
"renderEmail",
|
|
384
403
|
"scenarioArtifactContentType",
|
|
385
404
|
"scenarioRunSummary",
|
|
386
405
|
"spy",
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { renderEmail, type EmailAssets } from './email-template.js'
|
|
4
|
+
|
|
5
|
+
const THEME = {
|
|
6
|
+
appName: 'Pikku App',
|
|
7
|
+
colors: {
|
|
8
|
+
background: '#f5f7fb',
|
|
9
|
+
surface: '#ffffff',
|
|
10
|
+
text: '#101828',
|
|
11
|
+
border: '#d0d5dd',
|
|
12
|
+
primary: '#7c3aed',
|
|
13
|
+
},
|
|
14
|
+
fonts: {
|
|
15
|
+
body: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const LOCALES = {
|
|
20
|
+
en: {
|
|
21
|
+
passwordReset: {
|
|
22
|
+
subject: 'Reset your {{appName}} password',
|
|
23
|
+
note: 'If you did not ask for this, you can ignore it.',
|
|
24
|
+
cta: 'Choose a new password',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PARTIALS = {
|
|
30
|
+
layout: `<!doctype html>
|
|
31
|
+
<html lang="{{locale}}">
|
|
32
|
+
<head><title>{{subject}}</title></head>
|
|
33
|
+
<body style="font-family:{{theme.fonts.body}};color:{{theme.colors.text}};">
|
|
34
|
+
<div>{{content}}</div>
|
|
35
|
+
</body>
|
|
36
|
+
</html>`,
|
|
37
|
+
footer: `<div class="footer" style="color:{{theme.colors.border}};">{{t.passwordReset.note}}</div>`,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const HTML = `<div>
|
|
41
|
+
<p>Hello {{userName}}.</p>
|
|
42
|
+
<a href="{{resetUrl}}" style="background:{{theme.colors.primary}};">{{t.passwordReset.cta}}</a>
|
|
43
|
+
<p>{{{rawNotice}}}</p>
|
|
44
|
+
</div>
|
|
45
|
+
{{> footer}}`
|
|
46
|
+
|
|
47
|
+
const SUBJECT = `{{t.passwordReset.subject}}\n`
|
|
48
|
+
|
|
49
|
+
const TEXT = `{{t.passwordReset.subject}}
|
|
50
|
+
|
|
51
|
+
Hello {{userName}}.
|
|
52
|
+
|
|
53
|
+
{{t.passwordReset.cta}}: {{resetUrl}}
|
|
54
|
+
`
|
|
55
|
+
|
|
56
|
+
const ASSETS: EmailAssets = {
|
|
57
|
+
theme: THEME,
|
|
58
|
+
locales: LOCALES,
|
|
59
|
+
partials: PARTIALS,
|
|
60
|
+
templates: {
|
|
61
|
+
'password-reset': {
|
|
62
|
+
html: HTML,
|
|
63
|
+
subject: SUBJECT,
|
|
64
|
+
text: TEXT,
|
|
65
|
+
variables: ['appName', 'rawNotice', 'resetUrl', 'userName'],
|
|
66
|
+
hashes: {
|
|
67
|
+
en: {
|
|
68
|
+
contentHash: 'content-hash',
|
|
69
|
+
htmlHash: 'html-hash',
|
|
70
|
+
subjectHash: 'subject-hash',
|
|
71
|
+
textHash: 'text-hash',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const render = (data: Record<string, unknown>) =>
|
|
79
|
+
renderEmail(ASSETS, { name: 'password-reset', locale: 'en', data })
|
|
80
|
+
|
|
81
|
+
describe('renderEmail escaping', () => {
|
|
82
|
+
test('a value containing a double quote cannot break out of an attribute', () => {
|
|
83
|
+
const { html } = render({
|
|
84
|
+
resetUrl: 'https://x.test/" onmouseover="alert(1)',
|
|
85
|
+
})
|
|
86
|
+
assert.ok(
|
|
87
|
+
!html.includes('onmouseover="alert(1)"'),
|
|
88
|
+
`attribute broke out:\n${html}`
|
|
89
|
+
)
|
|
90
|
+
assert.ok(
|
|
91
|
+
html.includes('href="https://x.test/" onmouseover="alert(1)"'),
|
|
92
|
+
`quote was not escaped:\n${html}`
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('a value containing markup renders escaped, with no raw script tag', () => {
|
|
97
|
+
const { html } = render({
|
|
98
|
+
resetUrl: '"><script>alert(1)</script><a href="',
|
|
99
|
+
})
|
|
100
|
+
assert.ok(!html.includes('<script>'), `raw <script> present:\n${html}`)
|
|
101
|
+
assert.ok(html.includes('<script>alert(1)</script>'))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('a value containing handlebars is not re-expanded', () => {
|
|
105
|
+
const { html } = render({ resetUrl: '{{t.passwordReset.note}}' })
|
|
106
|
+
assert.ok(
|
|
107
|
+
!html.includes('If you did not ask for this, you can ignore it.</a>'),
|
|
108
|
+
`caller value was re-expanded as a template:\n${html}`
|
|
109
|
+
)
|
|
110
|
+
assert.ok(html.includes('href="{{t.passwordReset.note}}"'))
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('a value cannot forge a partial reference', () => {
|
|
114
|
+
const { html } = render({ resetUrl: '{{> footer}}' })
|
|
115
|
+
assert.ok(html.includes('href="{{> footer}}"'), html)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('a font stack with embedded quotes renders a valid style attribute', () => {
|
|
119
|
+
const { html } = render({ resetUrl: 'https://x.test/reset' })
|
|
120
|
+
assert.ok(
|
|
121
|
+
html.includes(
|
|
122
|
+
'<body style="font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;color:#101828;">'
|
|
123
|
+
),
|
|
124
|
+
`theme font stack corrupted the style attribute:\n${html}`
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('an app name with an apostrophe and an ampersand stays inside the tag', () => {
|
|
129
|
+
const { html, subject } = render({
|
|
130
|
+
appName: "Peet's & Co",
|
|
131
|
+
resetUrl: 'https://x.test/reset',
|
|
132
|
+
})
|
|
133
|
+
assert.ok(
|
|
134
|
+
html.includes('<title>Reset your Peet's & Co password</title>'),
|
|
135
|
+
html
|
|
136
|
+
)
|
|
137
|
+
assert.strictEqual(subject, "Reset your Peet's & Co password")
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('{{content}} and partials still render raw', () => {
|
|
141
|
+
const { html } = render({ resetUrl: 'https://x.test/reset' })
|
|
142
|
+
assert.ok(html.includes('<a href="https://x.test/reset"'), html)
|
|
143
|
+
assert.ok(html.includes('<div class="footer"'), html)
|
|
144
|
+
assert.ok(
|
|
145
|
+
html.includes('>If you did not ask for this, you can ignore it.</div>'),
|
|
146
|
+
html
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('the triple-brace form is an opt-in raw escape hatch', () => {
|
|
151
|
+
const { html } = render({
|
|
152
|
+
resetUrl: 'https://x.test/reset',
|
|
153
|
+
rawNotice: '<strong>read me</strong>',
|
|
154
|
+
})
|
|
155
|
+
assert.ok(html.includes('<p><strong>read me</strong></p>'), html)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('locale strings that reference caller variables still expand', () => {
|
|
159
|
+
const { subject, text } = render({
|
|
160
|
+
userName: 'Ada',
|
|
161
|
+
resetUrl: 'https://x.test/reset',
|
|
162
|
+
})
|
|
163
|
+
assert.strictEqual(subject, 'Reset your Pikku App password')
|
|
164
|
+
assert.ok(text?.includes('Hello Ada.'), text)
|
|
165
|
+
assert.ok(
|
|
166
|
+
text?.includes('Choose a new password: https://x.test/reset'),
|
|
167
|
+
text
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('plain-text outputs are not html escaped', () => {
|
|
172
|
+
const { text } = render({
|
|
173
|
+
userName: 'Ada & Bob',
|
|
174
|
+
resetUrl: 'https://x.test/reset?a=1&b=2',
|
|
175
|
+
})
|
|
176
|
+
assert.ok(text?.includes('Hello Ada & Bob.'), text)
|
|
177
|
+
assert.ok(text?.includes('https://x.test/reset?a=1&b=2'), text)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// The recursion bounds are why a malicious partial or locale string cannot
|
|
181
|
+
// hang the render, and neither is reachable from the generated wrapper's
|
|
182
|
+
// types — only a template author can trip them.
|
|
183
|
+
test('a partial that includes itself terminates instead of hanging', () => {
|
|
184
|
+
const { html } = renderEmail(
|
|
185
|
+
{
|
|
186
|
+
...ASSETS,
|
|
187
|
+
partials: { loop: '<i>{{> loop}}</i>' },
|
|
188
|
+
templates: {
|
|
189
|
+
...ASSETS.templates,
|
|
190
|
+
'password-reset': {
|
|
191
|
+
...ASSETS.templates['password-reset']!,
|
|
192
|
+
html: '{{> loop}}',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{ name: 'password-reset', locale: 'en', data: {} }
|
|
197
|
+
)
|
|
198
|
+
assert.equal(html.match(/<i>/g)?.length, 5)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('a locale string that references itself terminates instead of hanging', () => {
|
|
202
|
+
const { subject } = renderEmail(
|
|
203
|
+
{
|
|
204
|
+
...ASSETS,
|
|
205
|
+
locales: { en: { loop: 'a{{t.loop}}' } },
|
|
206
|
+
templates: {
|
|
207
|
+
...ASSETS.templates,
|
|
208
|
+
'password-reset': {
|
|
209
|
+
...ASSETS.templates['password-reset']!,
|
|
210
|
+
subject: '{{t.loop}}',
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{ name: 'password-reset', locale: 'en', data: {} }
|
|
215
|
+
)
|
|
216
|
+
// Five trusted passes plus the final substitute pass, and the token the
|
|
217
|
+
// last one produced is left as text rather than expanded again.
|
|
218
|
+
assert.equal(subject, 'aaaaaa{{t.loop}}')
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('renderEmail lookups', () => {
|
|
223
|
+
test('an unknown template names itself rather than rendering an empty mail', () => {
|
|
224
|
+
assert.throws(
|
|
225
|
+
() => renderEmail(ASSETS, { name: 'nope' }),
|
|
226
|
+
/Unknown email template: nope/
|
|
227
|
+
)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('an unknown locale names itself', () => {
|
|
231
|
+
assert.throws(
|
|
232
|
+
() => renderEmail(ASSETS, { name: 'password-reset', locale: 'de' }),
|
|
233
|
+
/Unknown email locale: de/
|
|
234
|
+
)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('the locale defaults to en', () => {
|
|
238
|
+
assert.equal(renderEmail(ASSETS, { name: 'password-reset' }).locale, 'en')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('the content hash comes from the rendered locale', () => {
|
|
242
|
+
assert.equal(render({}).hash, 'content-hash')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('a template with no text produces no text field at all', () => {
|
|
246
|
+
const result = renderEmail(
|
|
247
|
+
{
|
|
248
|
+
...ASSETS,
|
|
249
|
+
templates: {
|
|
250
|
+
'password-reset': {
|
|
251
|
+
...ASSETS.templates['password-reset']!,
|
|
252
|
+
text: '',
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{ name: 'password-reset' }
|
|
257
|
+
)
|
|
258
|
+
assert.equal('text' in result, false)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* `content` is the layout's slot, not a name a caller may fill. It is the one
|
|
264
|
+
* value written without escaping, because by then it holds a body that has
|
|
265
|
+
* already been rendered and escaped.
|
|
266
|
+
*/
|
|
267
|
+
describe('renderEmail treats content as the layout slot only', () => {
|
|
268
|
+
const assets = (html: string, layout?: string): EmailAssets => ({
|
|
269
|
+
theme: { appName: 'Shop' },
|
|
270
|
+
locales: { en: {} },
|
|
271
|
+
partials: layout ? { layout } : {},
|
|
272
|
+
templates: {
|
|
273
|
+
welcome: {
|
|
274
|
+
html,
|
|
275
|
+
subject: 'Hi',
|
|
276
|
+
text: '',
|
|
277
|
+
variables: [],
|
|
278
|
+
hashes: {
|
|
279
|
+
en: {
|
|
280
|
+
contentHash: 'h',
|
|
281
|
+
htmlHash: 'h',
|
|
282
|
+
subjectHash: 'h',
|
|
283
|
+
textHash: 'h',
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('caller data named content is escaped like any other value', () => {
|
|
291
|
+
const { html } = renderEmail(assets('<p>{{ content }}</p>'), {
|
|
292
|
+
name: 'welcome',
|
|
293
|
+
data: { content: '<img src=x onerror=alert(1)>' },
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
assert.ok(
|
|
297
|
+
!html.includes('<img src=x'),
|
|
298
|
+
`caller content must not be written raw, got: ${html}`
|
|
299
|
+
)
|
|
300
|
+
assert.match(html, /<img src=x/)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('the layout still receives the rendered body unescaped', () => {
|
|
304
|
+
const { html } = renderEmail(
|
|
305
|
+
assets('<p>hello</p>', '<main>{{ content }}</main>'),
|
|
306
|
+
{ name: 'welcome' }
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
assert.equal(html, '<main><p>hello</p></main>')
|
|
310
|
+
})
|
|
311
|
+
})
|