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

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 (54) hide show
  1. package/__tests__/analytics-track-outcome.test.ts +71 -3
  2. package/__tests__/auth-password-security.test.ts +101 -0
  3. package/__tests__/automations-bus.test.ts +85 -2
  4. package/__tests__/security-lists-parity.test.ts +2 -2
  5. package/__tests__/security-runtime.test.ts +37 -20
  6. package/dist/cjs/auth-generator.d.ts.map +1 -1
  7. package/dist/cjs/auth-generator.js +48 -19
  8. package/dist/cjs/auth-generator.js.map +1 -1
  9. package/dist/cjs/automations/event-bus-source.d.ts.map +1 -1
  10. package/dist/cjs/automations/event-bus-source.js +37 -23
  11. package/dist/cjs/automations/event-bus-source.js.map +1 -1
  12. package/dist/cjs/nodes/account/account-request-password-reset.d.ts.map +1 -1
  13. package/dist/cjs/nodes/account/account-request-password-reset.js +3 -2
  14. package/dist/cjs/nodes/account/account-request-password-reset.js.map +1 -1
  15. package/dist/cjs/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
  16. package/dist/cjs/nodes/analytics/analytics-track-outcome.js +17 -4
  17. package/dist/cjs/nodes/analytics/analytics-track-outcome.js.map +1 -1
  18. package/dist/cjs/password-reset-route-generator.d.ts +2 -0
  19. package/dist/cjs/password-reset-route-generator.d.ts.map +1 -1
  20. package/dist/cjs/password-reset-route-generator.js +17 -10
  21. package/dist/cjs/password-reset-route-generator.js.map +1 -1
  22. package/dist/cjs/security-runtime/security-runtime-code.d.ts +3 -2
  23. package/dist/cjs/security-runtime/security-runtime-code.d.ts.map +1 -1
  24. package/dist/cjs/security-runtime/security-runtime-code.js +17 -13
  25. package/dist/cjs/security-runtime/security-runtime-code.js.map +1 -1
  26. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  27. package/dist/esm/auth-generator.d.ts.map +1 -1
  28. package/dist/esm/auth-generator.js +48 -19
  29. package/dist/esm/auth-generator.js.map +1 -1
  30. package/dist/esm/automations/event-bus-source.d.ts.map +1 -1
  31. package/dist/esm/automations/event-bus-source.js +37 -23
  32. package/dist/esm/automations/event-bus-source.js.map +1 -1
  33. package/dist/esm/nodes/account/account-request-password-reset.d.ts.map +1 -1
  34. package/dist/esm/nodes/account/account-request-password-reset.js +3 -2
  35. package/dist/esm/nodes/account/account-request-password-reset.js.map +1 -1
  36. package/dist/esm/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
  37. package/dist/esm/nodes/analytics/analytics-track-outcome.js +17 -4
  38. package/dist/esm/nodes/analytics/analytics-track-outcome.js.map +1 -1
  39. package/dist/esm/password-reset-route-generator.d.ts +2 -0
  40. package/dist/esm/password-reset-route-generator.d.ts.map +1 -1
  41. package/dist/esm/password-reset-route-generator.js +16 -9
  42. package/dist/esm/password-reset-route-generator.js.map +1 -1
  43. package/dist/esm/security-runtime/security-runtime-code.d.ts +3 -2
  44. package/dist/esm/security-runtime/security-runtime-code.d.ts.map +1 -1
  45. package/dist/esm/security-runtime/security-runtime-code.js +17 -13
  46. package/dist/esm/security-runtime/security-runtime-code.js.map +1 -1
  47. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +2 -2
  49. package/src/auth-generator.ts +48 -19
  50. package/src/automations/event-bus-source.ts +37 -23
  51. package/src/nodes/account/account-request-password-reset.ts +3 -2
  52. package/src/nodes/analytics/analytics-track-outcome.ts +17 -4
  53. package/src/password-reset-route-generator.ts +18 -9
  54. package/src/security-runtime/security-runtime-code.ts +17 -13
@@ -88,7 +88,7 @@ describe('analytics-track-outcome node', () => {
88
88
  value: '49.90',
89
89
  currency: 'EUR',
90
90
  outcomeId: 'ord-1',
91
- attribution: { utm_source: 'facebook', visitor_id: 'aid-1' },
91
+ attribution: { utm_source: 'facebook', utm_content: 'hero', visitor_id: 'stale-aid' },
92
92
  },
93
93
  { __tpa: { sessionId: 'sid-1', visitorId: null, path: '/checkout' } }
94
94
  )
@@ -102,9 +102,77 @@ describe('analytics-track-outcome node', () => {
102
102
  currency: 'EUR',
103
103
  outcomeId: 'ord-1',
104
104
  sessionId: 'sid-1',
105
- visitorId: 'aid-1',
105
+ visitorId: null,
106
106
  path: '/checkout',
107
- attribution: { utmSource: 'facebook' },
107
+ attribution: { utmSource: 'facebook', utmContent: 'hero' },
108
+ })
109
+ })
110
+
111
+ describe('server: an order read back outside the visit that placed it', () => {
112
+ const configured = (calls: Array<{ body: string }>) =>
113
+ evalHandler(generator.generateServerHandler?.() ?? '', {
114
+ globalThis: {
115
+ process: {
116
+ env: {
117
+ NEXT_PUBLIC_TELEPORT_ANALYTICS_URL: 'https://analytics.example',
118
+ NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY: 'tpa_key',
119
+ TELEPORT_ANALYTICS_SERVER_KEY: 'tpas_secret',
120
+ },
121
+ },
122
+ },
123
+ fetch: (_url: string, init: { body: string }) => {
124
+ calls.push(init)
125
+ return Promise.resolve({ status: 202 })
126
+ },
127
+ AbortController: undefined,
128
+ })
129
+
130
+ it('credits the visit the order names, with no ids on the context', async () => {
131
+ const calls: Array<{ body: string }> = []
132
+ await configured(calls)(
133
+ {
134
+ name: 'purchase',
135
+ value: '20.00',
136
+ currency: 'EUR',
137
+ outcomeId: 'ord-2',
138
+ attribution: {
139
+ session_id: 'sid-shopper',
140
+ visitor_id: 'aid-shopper',
141
+ exp: 'hero:b',
142
+ landing_path: '/sale',
143
+ },
144
+ },
145
+ {}
146
+ )
147
+ expect(JSON.parse(calls[0].body).outcomes[0]).toMatchObject({
148
+ outcomeId: 'ord-2',
149
+ sessionId: 'sid-shopper',
150
+ visitorId: 'aid-shopper',
151
+ exp: 'hero:b',
152
+ attribution: { landingPath: '/sale' },
153
+ })
154
+ })
155
+
156
+ it('never credits the visit of whoever runs the step', async () => {
157
+ const calls: Array<{ body: string }> = []
158
+ await configured(calls)(
159
+ { name: 'purchase', outcomeId: 'ord-3', attribution: { session_id: 'sid-shopper' } },
160
+ { __tpa: { sessionId: 'sid-merchant', visitorId: 'aid-merchant', exp: 'hero:a' } }
161
+ )
162
+ expect(JSON.parse(calls[0].body).outcomes[0]).toMatchObject({
163
+ sessionId: 'sid-shopper',
164
+ visitorId: null,
165
+ exp: null,
166
+ })
167
+ })
168
+
169
+ it('sends nothing for an order that resolved to no id', async () => {
170
+ const calls: Array<{ body: string }> = []
171
+ expect(await configured(calls)({ name: 'purchase', value: '20.00' }, {})).toEqual({
172
+ sent: false,
173
+ reason: 'no-outcome-id',
174
+ })
175
+ expect(calls).toEqual([])
108
176
  })
109
177
  })
110
178
 
@@ -74,6 +74,10 @@ class FakePostgres {
74
74
  users: Row[] = []
75
75
  resets: Reset[] = []
76
76
  resetsTableExists = false
77
+ /** Off, as the site's role connects: it may read and write tables, and create none, whether or not one exists. */
78
+ mayCreateTables = true
79
+ /** Fails the statement that stores a link: the database went away between two calls. */
80
+ failResetWrites = false
77
81
  now = Date.UTC(2026, 9, 25, 12)
78
82
  statements: string[] = []
79
83
  // The account store cannot be read (a connection refused) for lookups by id.
@@ -85,6 +89,9 @@ class FakePostgres {
85
89
  return { rows: [] }
86
90
  }
87
91
  if (sql.startsWith('CREATE TABLE IF NOT EXISTS teleport_password_resets')) {
92
+ if (!this.mayCreateTables) {
93
+ throw Object.assign(new Error('permission denied for schema public'), { code: '42501' })
94
+ }
88
95
  this.resetsTableExists = true
89
96
  return { rows: [] }
90
97
  }
@@ -134,6 +141,12 @@ class FakePostgres {
134
141
  code: '42P01',
135
142
  })
136
143
  }
144
+ if (sql === 'SELECT 1 FROM teleport_password_resets LIMIT 1') {
145
+ return { rows: [] }
146
+ }
147
+ if (this.failResetWrites && sql.startsWith('INSERT INTO teleport_password_resets')) {
148
+ throw Object.assign(new Error('Connection terminated unexpectedly'), { code: '57P01' })
149
+ }
137
150
  if (sql.startsWith('UPDATE teleport_password_resets SET used_at = NOW() WHERE user_id = $1')) {
138
151
  for (const reset of this.resets) {
139
152
  if (reset.user_id === values[0] && reset.used_at === null) {
@@ -428,6 +441,7 @@ const loadApp = (
428
441
  }
429
442
 
430
443
  const VICTIM = { id: 'u-victim', email: 'victim@site.test', name: 'Vic', role: 'admin' }
444
+ const FORGOT_PASSWORD_NOT_SENT = 'We could not send the reset link. Please try again in a moment.'
431
445
 
432
446
  const tokenFromEmail = (sent: Row[]): string => {
433
447
  const mail = sent.find((entry) => entry.to)
@@ -576,6 +590,93 @@ describe('A3-C1: forgot / reset password are decided on the server', () => {
576
590
  expect(app.delays.length).toBeGreaterThan(0)
577
591
  })
578
592
 
593
+ const asksToCreate = (db: FakePostgres) =>
594
+ db.statements.filter((sql) => /\bCREATE\b|pg_advisory_xact_lock/.test(sql))
595
+
596
+ it('stores a link in the table the editor made, never asking to create it', async () => {
597
+ const db = victimDb()
598
+ db.resetsTableExists = true
599
+ db.mayCreateTables = false
600
+ const app = loadApp({ db })
601
+ const res = fakeResponse()
602
+
603
+ await app.load('forgot')(request({ email: VICTIM.email }), res)
604
+
605
+ expect(res.statusCode).toBe(200)
606
+ expect(res.body).toEqual({ success: true, message: FORGOT_PASSWORD_ANSWER })
607
+ expect(db.resets).toHaveLength(1)
608
+ expect(app.sent.filter((entry) => entry.to)).toHaveLength(1)
609
+ expect(asksToCreate(db)).toEqual([])
610
+ })
611
+
612
+ it('creates the table only when it is really missing, then stores the link in it', async () => {
613
+ const db = victimDb()
614
+ const app = loadApp({ db })
615
+ await app.load('forgot')(request({ email: VICTIM.email }), fakeResponse())
616
+
617
+ expect(asksToCreate(db).map((sql) => sql.split(' (')[0])).toEqual([
618
+ 'SELECT pg_advisory_xact_lock(hashtext($1))',
619
+ 'CREATE TABLE IF NOT EXISTS teleport_password_resets',
620
+ 'CREATE INDEX IF NOT EXISTS teleport_password_resets_user_id_idx ON teleport_password_resets',
621
+ ])
622
+ expect(db.resets).toHaveLength(1)
623
+ expect(app.sent.filter((entry) => entry.to)).toHaveLength(1)
624
+ })
625
+
626
+ it('stores a sign-up set-password link the same way when the table went missing', async () => {
627
+ const db = victimDb()
628
+ const app = loadApp({ db })
629
+ await app.load('options').storePasswordReset(VICTIM.id, 'hash', 1800)
630
+ expect(db.resets).toHaveLength(1)
631
+
632
+ const refused = victimDb()
633
+ refused.mayCreateTables = false
634
+ const refusedApp = loadApp({ db: refused })
635
+ await expect(
636
+ refusedApp.load('options').storePasswordReset(VICTIM.id, 'hash', 1800)
637
+ ).rejects.toThrow(
638
+ 'the table teleport_password_resets is missing and could not be created: permission denied for schema public'
639
+ )
640
+ expect(refused.statements).toContain('ROLLBACK')
641
+ })
642
+
643
+ it('tells no one a link was sent when the table is missing and cannot be made, naming it in the log', async () => {
644
+ const db = victimDb()
645
+ db.mayCreateTables = false
646
+ const app = loadApp({ db })
647
+ const known = fakeResponse()
648
+ await app.load('forgot')(request({ email: VICTIM.email }), known)
649
+ const unknown = fakeResponse()
650
+ await app.load('forgot')(request({ email: 'nobody@site.test' }), unknown)
651
+
652
+ for (const res of [known, unknown]) {
653
+ expect(res.statusCode).toBe(503)
654
+ expect(res.body.error).toBe(FORGOT_PASSWORD_NOT_SENT)
655
+ expect(res.body).not.toHaveProperty('success')
656
+ }
657
+ expect(db.resets).toEqual([])
658
+ expect(app.sent).toEqual([])
659
+ const logged = app.logs.map((args) => args.map(String).join(' ')).join('\n')
660
+ expect(logged).toContain('teleport_password_resets')
661
+ expect(logged).toContain('permission denied for schema public')
662
+ })
663
+
664
+ it('tells no one a link was sent when storing it fails', async () => {
665
+ const db = victimDb()
666
+ db.resetsTableExists = true
667
+ db.failResetWrites = true
668
+ const app = loadApp({ db })
669
+ const res = fakeResponse()
670
+
671
+ await app.load('forgot')(request({ email: VICTIM.email }), res)
672
+
673
+ expect(res.statusCode).toBe(503)
674
+ expect(res.body.error).toBe(FORGOT_PASSWORD_NOT_SENT)
675
+ expect(app.sent).toEqual([])
676
+ // Waits out the same minimum, so the time tells nothing either.
677
+ expect(app.delays.length).toBeGreaterThan(0)
678
+ })
679
+
579
680
  it('sends one address at most 3 links an hour, answering the same', async () => {
580
681
  const app = loadApp({ db: victimDb() })
581
682
  for (let i = 0; i < 5; i++) {
@@ -359,6 +359,10 @@ class FakeDatabase {
359
359
  signedRequests = new Map<string, number>()
360
360
  /** Fails every statement on the signed-request table. */
361
361
  signedRequestsDown = false
362
+ /** The editor did not make the signed-request table. */
363
+ signedRequestsMissing = false
364
+ /** As the site's role connects: it may read and write tables, and create none, whether or not one exists. */
365
+ mayCreateTables = false
362
366
  private serial = 0
363
367
 
364
368
  query(sql: string, values: unknown[] = []): { rows: Row[]; rowCount?: number } {
@@ -381,8 +385,17 @@ class FakeDatabase {
381
385
  text ===
382
386
  'CREATE INDEX IF NOT EXISTS teleport_automation_requests_expires_at ON teleport_automation_requests (expires_at)'
383
387
  ) {
388
+ if (!this.mayCreateTables) {
389
+ throw Object.assign(new Error('permission denied for schema public'), { code: '42501' })
390
+ }
391
+ this.signedRequestsMissing = false
384
392
  return { rows: [] }
385
393
  }
394
+ if (this.signedRequestsMissing) {
395
+ throw Object.assign(new Error('relation "teleport_automation_requests" does not exist'), {
396
+ code: '42P01',
397
+ })
398
+ }
386
399
  if (
387
400
  text ===
388
401
  'DELETE FROM teleport_automation_requests WHERE request_key IN (SELECT request_key FROM teleport_automation_requests WHERE expires_at < $1 LIMIT 1000)'
@@ -3055,7 +3068,12 @@ describe('in-app event bus — emitting and the dispatch route', () => {
3055
3068
  })
3056
3069
 
3057
3070
  // A request sent exactly as it was captured: the same headers, the same body.
3058
- const resend = async (db: FakeDatabase, headers: Record<string, string>, rawBody: string) => {
3071
+ const resend = async (
3072
+ db: FakeDatabase,
3073
+ headers: Record<string, string>,
3074
+ rawBody: string,
3075
+ logging: typeof silenced = silenced
3076
+ ) => {
3059
3077
  const reply = { status: 0, body: null as unknown }
3060
3078
  const res = {
3061
3079
  setHeader: () => undefined,
@@ -3082,7 +3100,7 @@ describe('in-app event bus — emitting and the dispatch route', () => {
3082
3100
  const calls = await withEcho(
3083
3101
  async (recorded) => {
3084
3102
  const handler = createApp(files, db)('pages/api/automations/dispatch.js') as RouteHandler
3085
- await silenced(() => handler(req, res))
3103
+ await logging(() => handler(req, res))
3086
3104
  return recorded
3087
3105
  },
3088
3106
  { TELEPORT_CRON_SECRET: CRON_KEY }
@@ -3145,6 +3163,71 @@ describe('in-app event bus — emitting and the dispatch route', () => {
3145
3163
  expect(down.deliveries).toEqual([])
3146
3164
  })
3147
3165
 
3166
+ const asksToCreate = (db: FakeDatabase) =>
3167
+ db.statements.filter((statement) => /\bCREATE\b|pg_advisory_xact_lock/.test(statement))
3168
+
3169
+ it('takes a signed request in the table the editor made, never asking to create it', async () => {
3170
+ const db = new FakeDatabase()
3171
+ seedOrderPaid(db)
3172
+
3173
+ const { reply } = await resend(db, signedHeaders('{}', false), '{}')
3174
+
3175
+ expect(reply.status).toBe(200)
3176
+ expect(db.signedRequests.size).toBe(1)
3177
+ expect(asksToCreate(db)).toEqual([])
3178
+ })
3179
+
3180
+ it('creates the table only when it is really missing, then takes the request in it', async () => {
3181
+ const db = new FakeDatabase()
3182
+ seedOrderPaid(db)
3183
+ db.signedRequestsMissing = true
3184
+ db.mayCreateTables = true
3185
+ const headers = signedHeaders('{}', false)
3186
+
3187
+ expect((await resend(db, headers, '{}')).reply.status).toBe(200)
3188
+ expect(asksToCreate(db)).toEqual([
3189
+ 'SELECT pg_advisory_xact_lock(hashtext($1))',
3190
+ 'CREATE TABLE IF NOT EXISTS teleport_automation_requests (request_key TEXT PRIMARY KEY, expires_at BIGINT NOT NULL)',
3191
+ 'CREATE INDEX IF NOT EXISTS teleport_automation_requests_expires_at ON teleport_automation_requests (expires_at)',
3192
+ ])
3193
+ expect(db.signedRequests.size).toBe(1)
3194
+
3195
+ // Replays are still refused, and the table is not asked for again.
3196
+ db.statements.length = 0
3197
+ expect((await resend(db, headers, '{}')).reply.status).toBe(409)
3198
+ expect(asksToCreate(db)).toEqual([])
3199
+ })
3200
+
3201
+ it('refuses a request when its table is missing and cannot be made, naming the table in the log', async () => {
3202
+ const db = new FakeDatabase()
3203
+ seedOrderPaid(db)
3204
+ db.signedRequestsMissing = true
3205
+ const logged: unknown[][] = []
3206
+ const capturingErrors = async <T>(run: () => Promise<T>): Promise<T> => {
3207
+ const sink = jest.spyOn(console, 'error').mockImplementation((...args) => {
3208
+ logged.push(args)
3209
+ })
3210
+ try {
3211
+ return await run()
3212
+ } finally {
3213
+ sink.mockRestore()
3214
+ }
3215
+ }
3216
+
3217
+ const { reply } = await resend(db, signedHeaders('{}', false), '{}', capturingErrors)
3218
+
3219
+ expect(reply).toEqual({
3220
+ status: 503,
3221
+ body: { error: 'The request could not be checked. Try again.' },
3222
+ })
3223
+ expect(db.deliveries).toEqual([])
3224
+ expect(db.signedRequests.size).toBe(0)
3225
+ expect(db.statements).toContain('ROLLBACK')
3226
+ const said = logged.map((args) => args.map(String).join(' ')).join('\n')
3227
+ expect(said).toContain('teleport_automation_requests')
3228
+ expect(said).toContain('permission denied for schema public')
3229
+ })
3230
+
3148
3231
  it('refuses a caller without the app secret or a cron signature', async () => {
3149
3232
  const db = new FakeDatabase()
3150
3233
  seedOrderPaid(db)
@@ -176,7 +176,7 @@ describe('the runtime process shadow in general-custom-js', () => {
176
176
  describeCopiesOf('teleport-gui', GUI)('the editor copies (teleport-gui)', () => {
177
177
  it('protect the same env names and credential pattern', () => {
178
178
  const copy = loadTs(GUI_FILES.protectedNames)
179
- expect([...copy.PROTECTED_ENV_NAMES]).toEqual(CODEGEN.names)
179
+ expect([...copy.PROTECTED_ENV_NAMES].sort()).toEqual([...CODEGEN.names].sort())
180
180
  expect(patternOf(copy.PROTECTED_ENV_NAME_PATTERN)).toEqual(CODEGEN.pattern)
181
181
  expect(patternOf(copy.PLATFORM_ENV_NAME_PATTERN)).toEqual(CODEGEN.platformPattern)
182
182
  expect(patternOf(copy.INTEGRATION_ENV_NAME_PATTERN)).toEqual(CODEGEN.integrationPattern)
@@ -212,7 +212,7 @@ describeCopiesOf('teleport-services-worker', WORKER)(
212
212
  () => {
213
213
  it('protect the same env names and credential pattern', () => {
214
214
  const copy = loadTs(WORKER_FILES.protectedNames)
215
- expect([...copy.PROTECTED_ENV_NAMES]).toEqual(CODEGEN.names)
215
+ expect([...copy.PROTECTED_ENV_NAMES].sort()).toEqual([...CODEGEN.names].sort())
216
216
  expect(patternOf(copy.PROTECTED_ENV_NAME_PATTERN)).toEqual(CODEGEN.pattern)
217
217
  expect(patternOf(copy.PLATFORM_ENV_NAME_PATTERN)).toEqual(CODEGEN.platformPattern)
218
218
  expect(patternOf(copy.INTEGRATION_ENV_NAME_PATTERN)).toEqual(CODEGEN.integrationPattern)
@@ -61,7 +61,9 @@ class FakeDatabase {
61
61
  /** `connect`: connect() never settles; `query`: no query settles; `cleanup`: the DELETE never settles. */
62
62
  hang: 'connect' | 'query' | 'cleanup' | null = null
63
63
  connectDelayMs = 0
64
- tableExists = false
64
+ tableExists = true
65
+ /** Off, as the site's role connects: it may read and write tables, and create none, whether or not one exists. */
66
+ mayCreateTables = true
65
67
 
66
68
  rowKey(key: unknown, windowStart: unknown): string {
67
69
  return `${key} @ ${windowStart}`
@@ -93,6 +95,9 @@ class FakeDatabase {
93
95
  return none
94
96
  }
95
97
  if (sql === CREATE_SQL) {
98
+ if (!this.mayCreateTables) {
99
+ throw Object.assign(new Error('permission denied for schema public'), { code: '42501' })
100
+ }
96
101
  this.tableExists = true
97
102
  return none
98
103
  }
@@ -287,44 +292,56 @@ describe('rateLimit — Postgres-backed', () => {
287
292
  expect(wire).not.toContain('203.0.113.7')
288
293
  })
289
294
 
290
- it('creates its table and index once per instance, under an advisory lock', async () => {
295
+ it('counts in the table the editor made, never asking to create it', async () => {
291
296
  const db = new FakeDatabase()
292
- const clock = { now: MIDNIGHT + 5000 }
293
- const { runtime } = loadRuntime({ db, clock })
297
+ db.mayCreateTables = false
298
+ const { runtime, log } = loadRuntime({ db, clock: { now: MIDNIGHT + 5000 } })
294
299
  await runtime.rateLimit({ key: 'k', limit: 5, windowSeconds: 60 })
295
300
  await settle()
296
301
  await runtime.rateLimit({ key: 'k', limit: 5, windowSeconds: 60 })
297
- expect(db.queries.map((query) => query.sql)).toEqual([
302
+ expect(db.queries.map((query) => query.sql)).toEqual([HIT_SQL, CLEANUP_SQL, HIT_SQL])
303
+ expect(Array.from(db.rows.values()).map((row) => row.count)).toEqual([2])
304
+ expect(log.error).not.toHaveBeenCalled()
305
+ })
306
+
307
+ it('creates the table when it is really missing, under an advisory lock, within the same request', async () => {
308
+ const db = new FakeDatabase()
309
+ const { runtime, log } = loadRuntime({ db, clock: { now: MIDNIGHT + 5000 } })
310
+ await runtime.rateLimit({ key: 'k', limit: 5, windowSeconds: 60 })
311
+ // Dropped, or the database restored from a backup taken before it existed.
312
+ db.dropTable()
313
+ db.queries.length = 0
314
+ expect(await runtime.rateLimit({ key: 'k', limit: 5, windowSeconds: 60 })).toEqual({
315
+ allowed: true,
316
+ retryAfter: 0,
317
+ })
318
+ expect(db.queries.map((query) => query.sql).slice(0, 7)).toEqual([
319
+ HIT_SQL,
298
320
  'BEGIN',
299
321
  LOCK_SQL,
300
322
  CREATE_SQL,
301
323
  INDEX_SQL,
302
324
  'COMMIT',
303
325
  HIT_SQL,
304
- CLEANUP_SQL,
305
- HIT_SQL,
306
326
  ])
307
- // A second instance creates them again (IF NOT EXISTS), under the same lock.
308
- await loadRuntime({ db, clock }).runtime.rateLimit({ key: 'k', limit: 5, windowSeconds: 60 })
309
- expect(db.statements(CREATE_SQL)).toHaveLength(2)
310
- expect(db.statements(INDEX_SQL)).toHaveLength(2)
311
- expect(db.statements(LOCK_SQL)).toHaveLength(2)
327
+ // Counted in the database, not in memory.
328
+ expect(Array.from(db.rows.values()).map((row) => row.count)).toEqual([1])
329
+ expect(log.error).not.toHaveBeenCalled()
312
330
  })
313
331
 
314
- it('creates the table again, within the same request, when it went missing', async () => {
332
+ it('counts in memory, naming the table in the log, when it is missing and cannot be made', async () => {
315
333
  const db = new FakeDatabase()
316
- const { runtime, log } = loadRuntime({ db, clock: { now: MIDNIGHT + 5000 } })
317
- await runtime.rateLimit({ key: 'k', limit: 5, windowSeconds: 60 })
318
- // Dropped, or the database restored from a backup taken before it existed.
319
334
  db.dropTable()
335
+ db.mayCreateTables = false
336
+ const { runtime, log } = loadRuntime({ db, clock: { now: MIDNIGHT + 5000 } })
320
337
  expect(await runtime.rateLimit({ key: 'k', limit: 5, windowSeconds: 60 })).toEqual({
321
338
  allowed: true,
322
339
  retryAfter: 0,
323
340
  })
324
- expect(db.statements(CREATE_SQL)).toHaveLength(2)
325
- // Counted in the database, not in memory.
326
- expect(Array.from(db.rows.values()).map((row) => row.count)).toEqual([1])
327
- expect(log.error).not.toHaveBeenCalled()
341
+ expect(log.error).toHaveBeenCalledTimes(1)
342
+ expect(String(log.error.mock.calls[0][0])).toContain(
343
+ 'the table teleport_rate_limits is missing and could not be created: permission denied for schema public'
344
+ )
328
345
  })
329
346
 
330
347
  it('keeps one counter per window length, so a minute and a day limit never share a row', async () => {
@@ -1 +1 @@
1
- {"version":3,"file":"auth-generator.d.ts","sourceRoot":"","sources":["../../src/auth-generator.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"auth-generator.d.ts","sourceRoot":"","sources":["../../src/auth-generator.ts"],"names":[],"mappings":"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"}
@@ -665,13 +665,15 @@ exports.PASSWORD_RESETS_TABLE = 'teleport_password_resets';
665
665
  const PG_PASSWORD_RESET_FUNCTIONS = `
666
666
  // Password-reset links (pages/api/auth/forgot-password + reset-password). Only
667
667
  // the SHA-256 of a link's token is stored, bound to the account id, with its
668
- // expiry; the table is created on first use. The earlier password_reset_tokens
669
- // table and its rows are never read.
668
+ // expiry. The editor makes the table with the other account tables, and a site's
669
+ // database role may read and write it without being allowed to create one
670
+ // (CREATE ... IF NOT EXISTS asks for that permission even when the table is
671
+ // there), so a create is attempted only for a table that really is missing. The
672
+ // earlier password_reset_tokens table and its rows are never read.
670
673
  var PASSWORD_RESETS_TABLE = '${exports.PASSWORD_RESETS_TABLE}';
671
- var __passwordResetsTableReady = false;
674
+ var UNDEFINED_TABLE_CODE = '42P01';
672
675
 
673
- async function ensurePasswordResetsTable(client) {
674
- if (__passwordResetsTableReady) return;
676
+ async function createPasswordResetsTable(client) {
675
677
  await client.query('BEGIN');
676
678
  try {
677
679
  await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [PASSWORD_RESETS_TABLE]);
@@ -683,28 +685,53 @@ async function ensurePasswordResetsTable(client) {
683
685
  await client.query('COMMIT');
684
686
  } catch (err) {
685
687
  try { await client.query('ROLLBACK'); } catch (_e) {}
686
- throw err;
688
+ throw new Error('[password-reset] the table ' + PASSWORD_RESETS_TABLE + ' is missing and could not be created: ' + (err && err.message ? err.message : String(err)));
689
+ }
690
+ }
691
+
692
+ // Throws when a link could not be kept. Asked for every address, with an account
693
+ // or without, so a table that cannot be used refuses them all alike.
694
+ async function ensurePasswordResetsUsable() {
695
+ const client = getClient();
696
+ try {
697
+ await client.connect();
698
+ try {
699
+ await client.query('SELECT 1 FROM ' + PASSWORD_RESETS_TABLE + ' LIMIT 1');
700
+ } catch (err) {
701
+ if (!err || err.code !== UNDEFINED_TABLE_CODE) throw err;
702
+ await createPasswordResetsTable(client);
703
+ }
704
+ } finally {
705
+ try { await client.end(); } catch (_e) {}
687
706
  }
688
- __passwordResetsTableReady = true;
689
707
  }
690
708
 
691
709
  // One live link per account: a new one ends the ones sent before it.
710
+ async function writePasswordReset(client, userId, tokenHash, ttlSeconds) {
711
+ await client.query(
712
+ 'UPDATE ' + PASSWORD_RESETS_TABLE + ' SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL',
713
+ [String(userId)]
714
+ );
715
+ await client.query(
716
+ 'INSERT INTO ' + PASSWORD_RESETS_TABLE + " (token_hash, user_id, expires_at) VALUES ($1, $2, NOW() + ($3::int * INTERVAL '1 second'))",
717
+ [tokenHash, String(userId), ttlSeconds]
718
+ );
719
+ await client.query(
720
+ 'DELETE FROM ' + PASSWORD_RESETS_TABLE + " WHERE expires_at < NOW() - INTERVAL '1 day'"
721
+ );
722
+ }
723
+
692
724
  async function storePasswordReset(userId, tokenHash, ttlSeconds) {
693
725
  const client = getClient();
694
726
  try {
695
727
  await client.connect();
696
- await ensurePasswordResetsTable(client);
697
- await client.query(
698
- 'UPDATE ' + PASSWORD_RESETS_TABLE + ' SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL',
699
- [String(userId)]
700
- );
701
- await client.query(
702
- 'INSERT INTO ' + PASSWORD_RESETS_TABLE + " (token_hash, user_id, expires_at) VALUES ($1, $2, NOW() + ($3::int * INTERVAL '1 second'))",
703
- [tokenHash, String(userId), ttlSeconds]
704
- );
705
- await client.query(
706
- 'DELETE FROM ' + PASSWORD_RESETS_TABLE + " WHERE expires_at < NOW() - INTERVAL '1 day'"
707
- );
728
+ try {
729
+ await writePasswordReset(client, userId, tokenHash, ttlSeconds);
730
+ } catch (err) {
731
+ if (!err || err.code !== UNDEFINED_TABLE_CODE) throw err;
732
+ await createPasswordResetsTable(client);
733
+ await writePasswordReset(client, userId, tokenHash, ttlSeconds);
734
+ }
708
735
  } finally {
709
736
  try { await client.end(); } catch (_e) {}
710
737
  }
@@ -759,6 +786,7 @@ const PASSWORD_RESET_SUPPORTED = true;`;
759
786
  const UNSUPPORTED_PASSWORD_RESET_FUNCTIONS = `
760
787
  // Password-reset links need a Postgres database; this project's user store is
761
788
  // not one, so the reset routes answer without ever sending a link.
789
+ async function ensurePasswordResetsUsable() { throw new Error('Password reset needs a Postgres database'); }
762
790
  async function storePasswordReset() { throw new Error('Password reset needs a Postgres database'); }
763
791
  async function consumePasswordReset() { return null; }
764
792
  async function passwordResetIsLive() { return false; }
@@ -1248,6 +1276,7 @@ ${auth.passwordAuthEnabled
1248
1276
  module.exports.checkSignInAllowed = checkSignInAllowed;
1249
1277
  module.exports.countSignInFailure = countSignInFailure;
1250
1278
  module.exports.SIGN_IN_VISITOR_HEADER = SIGN_IN_VISITOR_HEADER;
1279
+ module.exports.ensurePasswordResetsUsable = ensurePasswordResetsUsable;
1251
1280
  module.exports.storePasswordReset = storePasswordReset;
1252
1281
  module.exports.consumePasswordReset = consumePasswordReset;
1253
1282
  module.exports.passwordResetIsLive = passwordResetIsLive;