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

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 (35) hide show
  1. package/__tests__/account-delete-password-check.test.ts +39 -0
  2. package/__tests__/analytics-track-outcome.test.ts +56 -0
  3. package/__tests__/public-step-guards.test.ts +26 -0
  4. package/__tests__/server-job-self-calls-absolute.test.ts +430 -0
  5. package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
  6. package/dist/cjs/account-delete-route-generator.js +17 -8
  7. package/dist/cjs/account-delete-route-generator.js.map +1 -1
  8. package/dist/cjs/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
  9. package/dist/cjs/nodes/analytics/analytics-track-outcome.js +12 -0
  10. package/dist/cjs/nodes/analytics/analytics-track-outcome.js.map +1 -1
  11. package/dist/cjs/public-step-guards.d.ts +2 -2
  12. package/dist/cjs/public-step-guards.d.ts.map +1 -1
  13. package/dist/cjs/public-step-guards.js +11 -8
  14. package/dist/cjs/public-step-guards.js.map +1 -1
  15. package/dist/cjs/publish-refusals.js +1 -1
  16. package/dist/cjs/publish-refusals.js.map +1 -1
  17. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  18. package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
  19. package/dist/esm/account-delete-route-generator.js +17 -8
  20. package/dist/esm/account-delete-route-generator.js.map +1 -1
  21. package/dist/esm/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
  22. package/dist/esm/nodes/analytics/analytics-track-outcome.js +12 -0
  23. package/dist/esm/nodes/analytics/analytics-track-outcome.js.map +1 -1
  24. package/dist/esm/public-step-guards.d.ts +2 -2
  25. package/dist/esm/public-step-guards.d.ts.map +1 -1
  26. package/dist/esm/public-step-guards.js +11 -8
  27. package/dist/esm/public-step-guards.js.map +1 -1
  28. package/dist/esm/publish-refusals.js +1 -1
  29. package/dist/esm/publish-refusals.js.map +1 -1
  30. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +2 -2
  32. package/src/account-delete-route-generator.ts +17 -8
  33. package/src/nodes/analytics/analytics-track-outcome.ts +12 -0
  34. package/src/public-step-guards.ts +14 -8
  35. package/src/publish-refusals.ts +1 -1
@@ -86,6 +86,45 @@ describe('deleting an account with a password', () => {
86
86
  expect(withoutPepper.status).toBe(403)
87
87
  })
88
88
 
89
+ it('checks with the project’s own pepper first, and still with the old one, as sign-in does', async () => {
90
+ const env = { AUTH_PEPPER: 'pepper-1', AUTH_PEPPER_SECRET: 'secret-1' }
91
+ // Signed up, or signed in (rehashed), after the deploy provisioned the secret.
92
+ const current = await runAccountDeleteRoute({
93
+ userRow: userRow(storedHashOf(PASSWORD, 'secret-1')),
94
+ tables: TABLES,
95
+ requestBody: { password: PASSWORD },
96
+ env,
97
+ })
98
+ expect(current.status).toBe(200)
99
+
100
+ // Not signed in since: the hash is still on the old pepper.
101
+ const earlier = await runAccountDeleteRoute({
102
+ userRow: userRow(storedHashOf(PASSWORD, 'pepper-1')),
103
+ tables: TABLES,
104
+ requestBody: { password: PASSWORD },
105
+ env,
106
+ })
107
+ expect(earlier.status).toBe(200)
108
+
109
+ const wrong = await runAccountDeleteRoute({
110
+ userRow: userRow(storedHashOf(PASSWORD, 'secret-1')),
111
+ tables: TABLES,
112
+ requestBody: { password: 'not it' },
113
+ env,
114
+ })
115
+ expect(wrong.status).toBe(403)
116
+ })
117
+
118
+ it('takes an unresolved secret placeholder for no secret', async () => {
119
+ const run = await runAccountDeleteRoute({
120
+ userRow: userRow(storedHashOf(PASSWORD, 'pepper-1')),
121
+ tables: TABLES,
122
+ requestBody: { password: PASSWORD },
123
+ env: { AUTH_PEPPER: 'pepper-1', AUTH_PEPPER_SECRET: 'teleporthq.secrets.AUTH_PEPPER_SECRET' },
124
+ })
125
+ expect(run.status).toBe(200)
126
+ })
127
+
89
128
  it('refuses any password for an account that has none stored (a social sign-in)', async () => {
90
129
  const run = await runAccountDeleteRoute({
91
130
  userRow: userRow(null),
@@ -176,6 +176,62 @@ describe('analytics-track-outcome node', () => {
176
176
  })
177
177
  })
178
178
 
179
+ describe('server: an outcome the analytics service takes only from a browser', () => {
180
+ // The service's server endpoint counts `purchase` and `booking_made` alone;
181
+ // a lead, sign-up, newsletter, search or custom outcome comes from the page.
182
+ const configured = (calls: Array<{ body: string }>) =>
183
+ evalHandler(generator.generateServerHandler?.() ?? '', {
184
+ globalThis: {
185
+ process: {
186
+ env: {
187
+ NEXT_PUBLIC_TELEPORT_ANALYTICS_URL: 'https://analytics.example',
188
+ NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY: 'tpa_key',
189
+ TELEPORT_ANALYTICS_SERVER_KEY: 'tpas_secret',
190
+ },
191
+ },
192
+ },
193
+ fetch: (_url: string, init: { body: string }) => {
194
+ calls.push(init)
195
+ return Promise.resolve({ status: 202 })
196
+ },
197
+ AbortController: undefined,
198
+ })
199
+
200
+ let warn: jest.SpyInstance
201
+ beforeEach(() => {
202
+ warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
203
+ })
204
+ afterEach(() => warn.mockRestore())
205
+
206
+ it('is refused as browser-only, with the reason in the logs, and never sent', async () => {
207
+ const calls: Array<{ body: string }> = []
208
+ const report = configured(calls)
209
+ for (const name of ['lead', 'signup', 'newsletter', 'search', 'custom:demo_booked']) {
210
+ expect(await report({ name }, {})).toEqual({ sent: false, reason: 'browser-only' })
211
+ expect(await report({ name, outcomeId: 'row-1' }, {})).toEqual({
212
+ sent: false,
213
+ reason: 'browser-only',
214
+ })
215
+ }
216
+ expect(calls).toEqual([])
217
+ expect(warn).toHaveBeenCalledWith(
218
+ '[analytics] the "lead" outcome was not reported: only a visitor\'s browser can report it, never a server step'
219
+ )
220
+ })
221
+
222
+ it('says so in the logs when a money outcome has no id to report', async () => {
223
+ const calls: Array<{ body: string }> = []
224
+ expect(await configured(calls)({ name: 'booking_made', value: '40' }, {})).toEqual({
225
+ sent: false,
226
+ reason: 'no-outcome-id',
227
+ })
228
+ expect(calls).toEqual([])
229
+ expect(warn).toHaveBeenCalledWith(
230
+ '[analytics] the "booking_made" outcome was not reported: it has no order or booking id'
231
+ )
232
+ })
233
+ })
234
+
179
235
  it('server: answers not-configured without the key and never throws on a network failure', async () => {
180
236
  const unconfigured = evalHandler(generator.generateServerHandler?.() ?? '', {
181
237
  globalThis: { process: { env: {} } },
@@ -3,6 +3,7 @@ import adminFileWorkflows from './fixtures/admin-file-workflows.json'
3
3
  import {
4
4
  PUBLIC_STEP_REFUSAL_CODE,
5
5
  PublicStepRefusalError,
6
+ assertNoPublicRestrictedSteps,
6
7
  findPublicRestrictedSteps,
7
8
  } from '../src/public-step-guards'
8
9
 
@@ -234,6 +235,31 @@ describe('restricted steps on a public route', () => {
234
235
  expect(error).toBeInstanceOf(PublicStepRefusalError)
235
236
  })
236
237
 
238
+ it('on a site without sign-in, says to enable authentication before any role can be asked', () => {
239
+ const workflows = {
240
+ workflows: { w1: pageWorkflow([node('n1', 'file-storage-delete', 'Delete Upload')]) },
241
+ }
242
+ const refusedWith = (authentication: unknown): string => {
243
+ try {
244
+ assertNoPublicRestrictedSteps(workflows, authentication as never)
245
+ } catch (error) {
246
+ return shownSentence(error as Error)
247
+ }
248
+ throw new Error('not refused')
249
+ }
250
+
251
+ for (const withoutSignIn of [refusedWith(null), refusedWith({ enabled: false, roles: [] })]) {
252
+ expect(withoutSignIn).toContain('"Delete Upload"')
253
+ expect(withoutSignIn).toContain('has no sign-in')
254
+ expect(withoutSignIn).toContain('enable authentication')
255
+ expect(withoutSignIn).not.toContain('let only the role')
256
+ expect(withoutSignIn.length).toBeLessThanOrEqual(400)
257
+ }
258
+ const withSignIn = refusedWith({ enabled: true, roles: ['admin', 'user'] })
259
+ expect(withSignIn).toContain('let only the role "admin" start it')
260
+ expect(withSignIn).not.toContain('has no sign-in')
261
+ })
262
+
237
263
  it('stays one short sentence for many steps', () => {
238
264
  const nodes = Array.from({ length: 12 }, (_, i) =>
239
265
  node(
@@ -0,0 +1,430 @@
1
+ /* tslint:disable:function-constructor no-any */
2
+ import * as http from 'http'
3
+ import { AddressInfo } from 'net'
4
+ import * as nodePath from 'path'
5
+ import * as vm from 'vm'
6
+ import { NextWorkflowProjectPlugin } from '../src/workflow-project-plugin'
7
+ import { generateHandlerFunction } from '../src/api-route-generator'
8
+ import { withEnv } from './_helpers/fake-fetch'
9
+
10
+ /**
11
+ * A scheduled workflow on a published site failed every run with HTTP 500
12
+ * "Failed to parse URL from /api/data/<id>/raw-query": its data step called the
13
+ * site's own data API by a relative address, which a browser resolves against
14
+ * the page and Node's fetch refuses. A server job has no page, so each route
15
+ * that runs one hands its steps the absolute origin (`context.__baseUrl`).
16
+ *
17
+ * Every kind of server job is run here as the deployment runs it — the
18
+ * generated files, Node's own fetch, and a real HTTP server standing in for the
19
+ * site — so a relative self-call fails exactly as it did in production.
20
+ */
21
+
22
+ const DS = 'ds-store'
23
+ const ref = (nodeId: string, path: string[]) => ({ type: 'workflowContext', nodeId, path })
24
+
25
+ const CART_ROWS = [{ cart_id: 'cart-1', email: 'a@shop.test' }]
26
+
27
+ /** The predefined "Abandoned Cart Reminder", reduced to its database steps. */
28
+ const CART_REMINDER = {
29
+ id: 'wf-cart-reminder',
30
+ name: 'Abandoned Cart Reminder',
31
+ trigger: {
32
+ type: 'event-cron-triggered',
33
+ nodeId: 't',
34
+ scope: 'global',
35
+ config: { schedule: '0 * * * *', urlPath: 'cron_cartreminder' },
36
+ },
37
+ nodes: [
38
+ {
39
+ id: 'scan',
40
+ type: 'data-raw-query',
41
+ label: 'Scan Abandoned Carts',
42
+ config: {
43
+ dataSourceId: DS,
44
+ query:
45
+ 'SELECT c.id AS cart_id FROM teleport_cart c WHERE c.updated_at < NOW() - make_interval(hours => $1::int)',
46
+ params: ['2'],
47
+ },
48
+ stepNumber: 1,
49
+ },
50
+ {
51
+ id: 'loop',
52
+ type: 'general-loop',
53
+ label: 'Loop Over Each Abandoned Cart',
54
+ config: { loopType: 'forEach', collection: ref('scan', ['result']), itemVariable: 'cart' },
55
+ stepNumber: 2,
56
+ },
57
+ {
58
+ id: 'record',
59
+ type: 'data-create-item',
60
+ label: 'Record Abandoned Cart',
61
+ config: {
62
+ dataSourceId: DS,
63
+ tableName: 'teleport_abandoned_carts',
64
+ columnMappings: [{ column: 'cart_id', value: ref('loop', ['currentItem', 'cart_id']) }],
65
+ },
66
+ stepNumber: 3,
67
+ },
68
+ ],
69
+ edges: [
70
+ { id: 'e1', source: 't', target: 'scan' },
71
+ { id: 'e2', source: 'scan', target: 'loop' },
72
+ {
73
+ id: 'e3',
74
+ source: 'loop',
75
+ target: 'record',
76
+ sourceHandle: 'loop',
77
+ targetHandle: 'loop-body-in',
78
+ },
79
+ {
80
+ id: 'e4',
81
+ source: 'record',
82
+ target: 'loop',
83
+ sourceHandle: 'loop-body-out',
84
+ targetHandle: 'loop-back',
85
+ },
86
+ ],
87
+ }
88
+
89
+ /** A shared step the store's jobs call: its database read runs in its own server segment route. */
90
+ const RELEASE_NODE = {
91
+ id: 'cn-release',
92
+ name: 'Release Expired Holds',
93
+ parameters: [] as unknown[],
94
+ nodes: [
95
+ {
96
+ id: 'release-query',
97
+ type: 'data-raw-query',
98
+ label: 'Release Expired Holds',
99
+ config: { dataSourceId: DS, query: "UPDATE teleport_orders SET status = 'expired'" },
100
+ stepNumber: 0,
101
+ },
102
+ ],
103
+ edges: [] as unknown[],
104
+ }
105
+
106
+ const callRelease = (id: string, stepNumber: number) => ({
107
+ id,
108
+ type: 'general-custom-node',
109
+ label: 'Release Expired Holds',
110
+ config: { customNodeId: 'cn-release', parameters: [] as unknown[] },
111
+ stepNumber,
112
+ })
113
+
114
+ const RELEASE_CRON = {
115
+ id: 'wf-release',
116
+ name: 'Release Abandoned Checkouts',
117
+ trigger: {
118
+ type: 'event-cron-triggered',
119
+ nodeId: 't',
120
+ scope: 'global',
121
+ config: { schedule: '*/15 * * * *', urlPath: 'cron_release' },
122
+ },
123
+ nodes: [callRelease('release-call', 1)],
124
+ edges: [{ id: 'r1', source: 't', target: 'release-call' }],
125
+ }
126
+
127
+ const SYNC_WEBHOOK = {
128
+ id: 'wf-sync',
129
+ name: 'Inventory Sync',
130
+ trigger: { type: 'event-webhook-received', nodeId: 't', scope: 'global', config: {} },
131
+ webhookConfig: { urlPath: 'webhooks/inventory-sync', httpMethod: 'POST' },
132
+ nodes: [
133
+ {
134
+ id: 'sync-query',
135
+ type: 'data-raw-query',
136
+ label: 'Read Stock',
137
+ config: { dataSourceId: DS, query: 'SELECT id, stock FROM teleport_products' },
138
+ stepNumber: 1,
139
+ },
140
+ ],
141
+ edges: [{ id: 's1', source: 't', target: 'sync-query' }],
142
+ }
143
+
144
+ const ORDER_AUTOMATION = {
145
+ id: 'wf-order-placed',
146
+ name: 'When an order is placed',
147
+ trigger: { type: 'event-business-event', nodeId: 't', scope: 'global', config: {} },
148
+ nodes: [
149
+ {
150
+ id: 'auto-query',
151
+ type: 'data-raw-query',
152
+ label: 'Read The Order',
153
+ config: { dataSourceId: DS, query: 'SELECT id FROM teleport_orders LIMIT 1' },
154
+ stepNumber: 1,
155
+ },
156
+ ],
157
+ edges: [{ id: 'a1', source: 't', target: 'auto-query' }],
158
+ }
159
+
160
+ const ENV: Record<string, string | undefined> = {
161
+ NEXTAUTH_SECRET: 'app-secret',
162
+ NEXTAUTH_URL: undefined,
163
+ TELEPORT_CRON_ALLOW_UNSIGNED: 'true',
164
+ TELEPORT_CRON_SECRET: undefined,
165
+ VERCEL: undefined,
166
+ VERCEL_AUTOMATION_BYPASS_SECRET: undefined,
167
+ }
168
+
169
+ const generateFiles = async (): Promise<Map<string, string>> => {
170
+ const structure: any = {
171
+ uidl: {
172
+ name: 'Store',
173
+ globals: { settings: { title: 'Store', language: 'en' }, env: { NEXTAUTH_SECRET: 'k' } },
174
+ root: {
175
+ name: 'Root',
176
+ stateDefinitions: {
177
+ route: { type: 'string', defaultValue: 'Home', values: [{ value: 'Home' }] },
178
+ },
179
+ node: { type: 'element', content: { elementType: 'container', children: [] } },
180
+ },
181
+ components: {},
182
+ dataSources: { [DS]: { id: DS, type: 'teleport', name: 'Store', config: {} } },
183
+ workflows: {
184
+ workflows: JSON.parse(
185
+ JSON.stringify({
186
+ [CART_REMINDER.id]: CART_REMINDER,
187
+ [RELEASE_CRON.id]: RELEASE_CRON,
188
+ [SYNC_WEBHOOK.id]: SYNC_WEBHOOK,
189
+ })
190
+ ),
191
+ customNodes: JSON.parse(JSON.stringify({ [RELEASE_NODE.id]: RELEASE_NODE })),
192
+ },
193
+ },
194
+ strategy: { pages: { options: {} } },
195
+ files: new Map(),
196
+ dependencies: {},
197
+ devDependencies: {},
198
+ }
199
+ await new NextWorkflowProjectPlugin().runAfter(structure)
200
+ const files = new Map<string, string>()
201
+ for (const entry of structure.files.values()) {
202
+ for (const file of entry.files) {
203
+ files.set(
204
+ [...entry.path, `${file.name}.${file.fileType || 'js'}`].join('/'),
205
+ String(file.content)
206
+ )
207
+ }
208
+ }
209
+ files.set(
210
+ 'lib/automations/handlers/wf-order-placed.js',
211
+ generateHandlerFunction(JSON.parse(JSON.stringify(ORDER_AUTOMATION)), {
212
+ [RELEASE_NODE.id]: RELEASE_NODE,
213
+ } as any)
214
+ )
215
+ return files
216
+ }
217
+
218
+ interface Reply {
219
+ status: number
220
+ body: any
221
+ }
222
+
223
+ interface Site {
224
+ origin: string
225
+ /** Every request the site received: its path and the Host it was sent to. */
226
+ received: Array<{ path: string; host: string }>
227
+ call: (routeFile: string, body?: unknown) => Promise<Reply>
228
+ load: (path: string) => any
229
+ }
230
+
231
+ /**
232
+ * The generated files served by a real HTTP server: the data API answers with
233
+ * rows, and a call to one of the site's own workflow routes runs that route.
234
+ */
235
+ const startSite = async (files: Map<string, string>): Promise<Site & { close: () => void }> => {
236
+ let origin = ''
237
+ const received: Site['received'] = []
238
+ const cache = new Map<string, { exports: any }>()
239
+ const overrides: Record<string, unknown> = {
240
+ 'lib/automations/store.js': {
241
+ appBaseUrl: () => origin,
242
+ saveWait: async (): Promise<void> => undefined,
243
+ },
244
+ }
245
+
246
+ const load = (path: string): any => {
247
+ if (Object.prototype.hasOwnProperty.call(overrides, path)) {
248
+ return overrides[path]
249
+ }
250
+ const cached = cache.get(path)
251
+ if (cached) {
252
+ return cached.exports
253
+ }
254
+ const source = files.get(path)
255
+ if (source === undefined) {
256
+ throw new Error(`Cannot find module '${path}'`)
257
+ }
258
+ const module = { exports: {} as any }
259
+ cache.set(path, module)
260
+ const dir = nodePath.posix.dirname(path)
261
+ const localRequire = (id: string): unknown => {
262
+ if (id.startsWith('.')) {
263
+ const joined = nodePath.posix.normalize(nodePath.posix.join(dir, id))
264
+ const found = [joined, `${joined}.js`, `${joined}/index.js`].find(
265
+ (candidate) =>
266
+ files.has(candidate) || Object.prototype.hasOwnProperty.call(overrides, candidate)
267
+ )
268
+ return load(found || `${joined}.js`)
269
+ }
270
+ if (id === 'next-auth/jwt') {
271
+ return { getToken: async (): Promise<null> => null }
272
+ }
273
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
274
+ return require(id)
275
+ }
276
+ new Function('module', 'exports', 'require', source)(module, module.exports, localRequire)
277
+ return module.exports
278
+ }
279
+
280
+ const run = async (
281
+ routeFile: string,
282
+ headers: Record<string, string | string[] | undefined>,
283
+ body: unknown
284
+ ): Promise<Reply> => {
285
+ const reply: Reply = { status: 200, body: null }
286
+ const res: any = {
287
+ setHeader: (): any => res,
288
+ status(code: number) {
289
+ reply.status = code
290
+ return res
291
+ },
292
+ json(payload: unknown) {
293
+ reply.body = payload
294
+ return res
295
+ },
296
+ end() {
297
+ return res
298
+ },
299
+ }
300
+ const handler = load(routeFile)
301
+ await (handler.default || handler)(
302
+ {
303
+ method: 'POST',
304
+ url: '/' + routeFile.replace(/^pages\//, '').replace(/\.js$/, ''),
305
+ query: {},
306
+ headers,
307
+ body,
308
+ },
309
+ res
310
+ )
311
+ return reply
312
+ }
313
+
314
+ const server = http.createServer((req, res) => {
315
+ const chunks: Buffer[] = []
316
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
317
+ req.on('end', async () => {
318
+ const path = new URL(req.url || '/', 'http://site').pathname
319
+ received.push({ path, host: String(req.headers.host) })
320
+ const text = Buffer.concat(chunks).toString('utf8')
321
+ const body = text ? JSON.parse(text) : undefined
322
+ let reply: Reply = { status: 404, body: { error: 'not found' } }
323
+ if (/^\/api\/data\/[^/]+\/[^/]+$/.test(path)) {
324
+ reply = { status: 200, body: { success: true, rows: CART_ROWS, data: CART_ROWS[0] } }
325
+ } else if (files.has(`pages${path}.js`)) {
326
+ reply = await run(`pages${path}.js`, req.headers, body)
327
+ }
328
+ res.statusCode = reply.status
329
+ res.setHeader('content-type', 'application/json')
330
+ res.end(JSON.stringify(reply.body))
331
+ })
332
+ })
333
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
334
+ const { port } = server.address() as AddressInfo
335
+ origin = `http://127.0.0.1:${port}`
336
+
337
+ return {
338
+ origin,
339
+ received,
340
+ load,
341
+ call: (routeFile, body = {}) =>
342
+ run(routeFile, { host: `127.0.0.1:${port}`, 'content-type': 'application/json' }, body),
343
+ close: () => server.close(),
344
+ }
345
+ }
346
+
347
+ // Node's own fetch (undici), as the deployment has it: the one that refuses a
348
+ // relative address. Jest's sandbox has no global fetch of its own.
349
+ const nodeFetch = vm.runInThisContext('fetch')
350
+
351
+ const silenced = async <T>(run: () => Promise<T>): Promise<T> => {
352
+ const sink = console as unknown as Record<string, unknown>
353
+ const levels = ['warn', 'error', 'log', 'info']
354
+ const saved = levels.map((level) => sink[level])
355
+ levels.forEach((level) => {
356
+ sink[level] = (): undefined => undefined
357
+ })
358
+ try {
359
+ return await run()
360
+ } finally {
361
+ levels.forEach((level, index) => {
362
+ sink[level] = saved[index]
363
+ })
364
+ }
365
+ }
366
+
367
+ const onSite = async <T>(run: (site: Site) => Promise<T>): Promise<T> => {
368
+ const site = await startSite(await generateFiles())
369
+ const scope = globalThis as any
370
+ const previous = scope.fetch
371
+ scope.fetch = nodeFetch
372
+ try {
373
+ return await withEnv(ENV, () => silenced(() => run(site)))
374
+ } finally {
375
+ scope.fetch = previous
376
+ site.close()
377
+ }
378
+ }
379
+
380
+ /** The paths the site received, each asserted to have come to its own address. */
381
+ const selfCalls = (site: Site): string[] => {
382
+ const host = new URL(site.origin).host
383
+ site.received.forEach((call) => expect(call.host).toBe(host))
384
+ return site.received.map((call) => call.path)
385
+ }
386
+
387
+ const webhookDelivery = (payload: unknown) => Buffer.from(JSON.stringify(payload))
388
+
389
+ describe("a server job calls the site's own routes by their absolute address", () => {
390
+ it('a scheduled workflow reads with a raw query, then records each row', async () => {
391
+ await onSite(async (site) => {
392
+ const reply = await site.call('pages/api/workflows/cron_cartreminder.js')
393
+
394
+ expect(reply).toEqual({ status: 200, body: expect.objectContaining({ success: true }) })
395
+ expect(selfCalls(site)).toEqual([`/api/data/${DS}/raw-query`, `/api/data/${DS}/create`])
396
+ })
397
+ })
398
+
399
+ it("a scheduled workflow runs a shared step whose query is in that step's own route", async () => {
400
+ await onSite(async (site) => {
401
+ const reply = await site.call('pages/api/workflows/cron_release.js')
402
+
403
+ expect(reply).toEqual({ status: 200, body: expect.objectContaining({ success: true }) })
404
+ const [segmentRoute, ...rest] = selfCalls(site)
405
+ expect(segmentRoute).toMatch(/^\/api\/workflows\/release-expired-holds-/)
406
+ expect(rest).toEqual([`/api/data/${DS}/raw-query`])
407
+ })
408
+ })
409
+
410
+ it('a webhook reads with a raw query', async () => {
411
+ await onSite(async (site) => {
412
+ const reply = await site.call(
413
+ 'pages/api/webhooks/inventory-sync.js',
414
+ webhookDelivery({ event: 'stock' })
415
+ )
416
+
417
+ expect(reply).toEqual({ status: 200, body: { received: true } })
418
+ expect(selfCalls(site)).toEqual([`/api/data/${DS}/raw-query`])
419
+ })
420
+ })
421
+
422
+ it('an automation reads with a raw query', async () => {
423
+ await onSite(async (site) => {
424
+ const handler = site.load('lib/automations/handlers/wf-order-placed.js')
425
+ await handler.run({ event: { id: 'e1', type: 'order-placed' }, order: { id: 'o1' } }, {})
426
+
427
+ expect(selfCalls(site)).toEqual([`/api/data/${DS}/raw-query`])
428
+ })
429
+ })
430
+ })
@@ -1 +1 @@
1
- {"version":3,"file":"account-delete-route-generator.d.ts","sourceRoot":"","sources":["../../src/account-delete-route-generator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EAAE,2BAA2B,EAAE,MAAM,4BAA4B,CAAA;AAcxE,MAAM,WAAW,yBAAyB;IAExC,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAI3B,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAClC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IAGtB,kBAAkB,CAAC,EAAE,2BAA2B,CAAA;IAEhD,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAG5B,eAAe,CAAC,EAAE,OAAO,CAAA;CAC1B;AAID,eAAO,MAAM,8BAA8B,+CAAiC,CAAA;AAE5E,eAAO,MAAM,0BAA0B,aAAa,yBAAyB,KAAQ,MAwbpF,CAAA"}
1
+ {"version":3,"file":"account-delete-route-generator.d.ts","sourceRoot":"","sources":["../../src/account-delete-route-generator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EAAE,2BAA2B,EAAE,MAAM,4BAA4B,CAAA;AAcxE,MAAM,WAAW,yBAAyB;IAExC,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAI3B,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAClC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IAGtB,kBAAkB,CAAC,EAAE,2BAA2B,CAAA;IAEhD,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAG5B,eAAe,CAAC,EAAE,OAAO,CAAA;CAC1B;AAID,eAAO,MAAM,8BAA8B,+CAAiC,CAAA;AAE5E,eAAO,MAAM,0BAA0B,aAAa,yBAAyB,KAAQ,MAicpF,CAAA"}
@@ -275,19 +275,28 @@ function readRequestPassword(req) {
275
275
  return String(body.password);
276
276
  }
277
277
 
278
- // The sign-in check (utils/auth/hash-password.js verifyPassword): bcrypt over
279
- // the SHA-256 of the password and AUTH_PEPPER. An account without a stored
280
- // password (a social sign-in) matches no password.
278
+ // The sign-in check (utils/auth/hash-password.js checkPassword): bcrypt over
279
+ // the SHA-256 of the password and a pepper, the project's own
280
+ // (AUTH_PEPPER_SECRET) first, then the one every earlier hash used
281
+ // (AUTH_PEPPER). An account without a stored password (a social sign-in)
282
+ // matches no password.
281
283
  function passwordMatches(password, storedHash) {
282
284
  if (!storedHash || password.length > 1000) { return false; }
283
285
  var bcrypt = require('bcryptjs');
284
286
  var crypto = require('crypto');
285
- var prepared = crypto.createHash('sha256').update(password + (process.env.AUTH_PEPPER || '')).digest('base64');
286
- try {
287
- return bcrypt.compareSync(prepared, String(storedHash));
288
- } catch (e) {
289
- return false;
287
+ var secret = process.env.AUTH_PEPPER_SECRET;
288
+ var peppers = typeof secret === 'string' && secret && secret.indexOf('teleporthq.secrets.') !== 0
289
+ ? [secret, process.env.AUTH_PEPPER || '']
290
+ : [process.env.AUTH_PEPPER || ''];
291
+ for (var i = 0; i < peppers.length; i++) {
292
+ var prepared = crypto.createHash('sha256').update(password + peppers[i]).digest('base64');
293
+ try {
294
+ if (bcrypt.compareSync(prepared, String(storedHash))) { return true; }
295
+ } catch (e) {
296
+ return false;
297
+ }
290
298
  }
299
+ return false;
291
300
  }
292
301
 
293
302
  ${(0, transactional_email_code_1.generateFillTemplateFn)()}
@@ -1 +1 @@
1
- {"version":3,"file":"account-delete-route-generator.js","sourceRoot":"","sources":["../../src/account-delete-route-generator.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"account-delete-route-generator.js","sourceRoot":"","sources":["../../src/account-delete-route-generator.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"analytics-track-outcome.d.ts","sourceRoot":"","sources":["../../../../src/nodes/analytics/analytics-track-outcome.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA;AAkH/C,eAAO,MAAM,qBAAqB,EAAE,oBASnC,CAAA"}
1
+ {"version":3,"file":"analytics-track-outcome.d.ts","sourceRoot":"","sources":["../../../../src/nodes/analytics/analytics-track-outcome.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA;AA8H/C,eAAO,MAAM,qBAAqB,EAAE,oBASnC,CAAA"}
@@ -21,6 +21,13 @@ exports.analyticsTrackOutcome = void 0;
21
21
  * the visitor id is the tracker's alone: a browser may still hold a persistent
22
22
  * id from before its consent was withdrawn.
23
23
  *
24
+ * The service takes exactly the money outcomes from a server, each with the
25
+ * order or booking id it dedupes a retried report on, and every other outcome
26
+ * from a browser only. So on the server any other name is refused as
27
+ * `browser-only` (an automation, a webhook or a cron has no visitor's page to
28
+ * report it from), and a money outcome without an id as `no-outcome-id`: an id
29
+ * made up here would count a retried report twice. Both say why in the logs.
30
+ *
24
31
  * Never throws: an outcome that cannot be reported answers `{ sent: false }`
25
32
  * and the workflow continues — analytics must never cost a sale.
26
33
  */
@@ -56,6 +63,10 @@ const SERVER_BODY = `async function analytics_track_outcome(config, context) {
56
63
  if (!name) {
57
64
  return { sent: false, reason: 'no-name' };
58
65
  }
66
+ if (${JSON.stringify(SERVER_ONLY_OUTCOMES)}.indexOf(name) === -1) {
67
+ console.warn('[analytics] the "' + name + '" outcome was not reported: only a visitor\\'s browser can report it, never a server step');
68
+ return { sent: false, reason: 'browser-only' };
69
+ }
59
70
  // \`globalThis.process\` (member access): webpack rewrites a bare \`process\`.
60
71
  var env = globalThis.process && globalThis.process.env;
61
72
  var baseUrl = (env && env.NEXT_PUBLIC_TELEPORT_ANALYTICS_URL) || '';
@@ -66,6 +77,7 @@ const SERVER_BODY = `async function analytics_track_outcome(config, context) {
66
77
  }
67
78
  // The service dedupes on this id and drops an outcome without one.
68
79
  if (config.outcomeId == null || config.outcomeId === '') {
80
+ console.warn('[analytics] the "' + name + '" outcome was not reported: it has no order or booking id');
69
81
  return { sent: false, reason: 'no-outcome-id' };
70
82
  }
71
83
  var ids = context && context.__tpa && typeof context.__tpa === 'object' ? context.__tpa : {};
@@ -1 +1 @@
1
- {"version":3,"file":"analytics-track-outcome.js","sourceRoot":"","sources":["../../../../src/nodes/analytics/analytics-track-outcome.ts"],"names":[],"mappings":";;;AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,oBAAoB,GAAG,CAAC,UAAU,EAAE,cAAc,CAAC,CAAA;AAEzD,MAAM,WAAW,GAAG;;;;;QAKZ,IAAI,CAAC,SAAS,CAAC,oBAAoB,CAAC;;;;;;;;;;;;;;;;;;;;EAoB1C,CAAA;AAEF,MAAM,WAAW,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0DlB,CAAA;AAEW,QAAA,qBAAqB,GAAyB;IACzD,QAAQ,EAAE,yBAAyB;IACnC,YAAY,EAAE,WAAW;IACzB,eAAe;QACb,OAAO,WAAW,CAAA;IACpB,CAAC;IACD,qBAAqB;QACnB,OAAO,WAAW,CAAA;IACpB,CAAC;CACF,CAAA"}
1
+ {"version":3,"file":"analytics-track-outcome.js","sourceRoot":"","sources":["../../../../src/nodes/analytics/analytics-track-outcome.ts"],"names":[],"mappings":";;;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,oBAAoB,GAAG,CAAC,UAAU,EAAE,cAAc,CAAC,CAAA;AAEzD,MAAM,WAAW,GAAG;;;;;QAKZ,IAAI,CAAC,SAAS,CAAC,oBAAoB,CAAC;;;;;;;;;;;;;;;;;;;;EAoB1C,CAAA;AAEF,MAAM,WAAW,GAAG;;;;;QAKZ,IAAI,CAAC,SAAS,CAAC,oBAAoB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0D1C,CAAA;AAEW,QAAA,qBAAqB,GAAyB;IACzD,QAAQ,EAAE,yBAAyB;IACnC,YAAY,EAAE,WAAW;IACzB,eAAe;QACb,OAAO,WAAW,CAAA;IACpB,CAAC;IACD,qBAAqB;QACnB,OAAO,WAAW,CAAA;IACpB,CAAC;CACF,CAAA"}