pipework 0.261.2 → 0.263.0

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 (57) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/REFERENCE.md +2 -2
  3. package/dist/audit/schema/audit-record-fields.d.ts.map +1 -1
  4. package/dist/audit/schema/audit-record-fields.js +2 -1
  5. package/dist/audit/schema/audit-record-fields.js.map +1 -1
  6. package/dist/auth/auth/schema.d.ts.map +1 -1
  7. package/dist/auth/auth/schema.js +4 -0
  8. package/dist/auth/auth/schema.js.map +1 -1
  9. package/dist/data/db/sql.d.ts +2 -0
  10. package/dist/data/db/sql.d.ts.map +1 -1
  11. package/dist/data/db/sql.js +4 -0
  12. package/dist/data/db/sql.js.map +1 -1
  13. package/dist/data/domain/types.d.ts +7 -0
  14. package/dist/data/domain/types.d.ts.map +1 -1
  15. package/dist/data/write/audit/audit-select.d.ts +2 -1
  16. package/dist/data/write/audit/audit-select.d.ts.map +1 -1
  17. package/dist/data/write/audit/audit-select.js +6 -4
  18. package/dist/data/write/audit/audit-select.js.map +1 -1
  19. package/dist/data/write/audit/primary-key.d.ts +2 -1
  20. package/dist/data/write/audit/primary-key.d.ts.map +1 -1
  21. package/dist/data/write/audit/primary-key.js +5 -2
  22. package/dist/data/write/audit/primary-key.js.map +1 -1
  23. package/dist/infra/access-links/schema.d.ts.map +1 -1
  24. package/dist/infra/access-links/schema.js +6 -0
  25. package/dist/infra/access-links/schema.js.map +1 -1
  26. package/dist/infra/alerts/handle.d.ts.map +1 -1
  27. package/dist/infra/alerts/handle.js +8 -11
  28. package/dist/infra/alerts/handle.js.map +1 -1
  29. package/dist/infra/audit/namespace.d.ts +1 -1
  30. package/dist/infra/audit/namespace.js +1 -1
  31. package/dist/infra/email/inbound.d.ts.map +1 -1
  32. package/dist/infra/email/inbound.js +3 -7
  33. package/dist/infra/email/inbound.js.map +1 -1
  34. package/dist/infra/email/mail.d.ts +2 -0
  35. package/dist/infra/email/mail.d.ts.map +1 -1
  36. package/dist/infra/email/mail.js +22 -1
  37. package/dist/infra/email/mail.js.map +1 -1
  38. package/dist/infra/email/schema.d.ts.map +1 -1
  39. package/dist/infra/email/schema.js +3 -0
  40. package/dist/infra/email/schema.js.map +1 -1
  41. package/dist/request/http/rate-limit-classes.d.ts.map +1 -1
  42. package/dist/request/http/rate-limit-classes.js +2 -0
  43. package/dist/request/http/rate-limit-classes.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/audit/schema/audit-record-fields.ts +2 -1
  46. package/src/auth/auth/schema.ts +4 -0
  47. package/src/data/db/sql.ts +5 -0
  48. package/src/data/domain/types.ts +7 -0
  49. package/src/data/write/audit/audit-select.ts +6 -4
  50. package/src/data/write/audit/primary-key.ts +6 -2
  51. package/src/infra/access-links/schema.ts +6 -0
  52. package/src/infra/alerts/handle.ts +8 -12
  53. package/src/infra/audit/namespace.ts +1 -1
  54. package/src/infra/email/inbound.ts +3 -8
  55. package/src/infra/email/mail.ts +19 -1
  56. package/src/infra/email/schema.ts +3 -0
  57. package/src/request/http/rate-limit-classes.ts +2 -0
@@ -1,4 +1,4 @@
1
- import { sql } from '../../data/db/sql.js'
1
+ import { sql, textArrayParam } from '../../data/db/sql.js'
2
2
  import { pipeSystem } from '../../data/db/db.js'
3
3
  import { fitting } from '../../request/di/index.js'
4
4
  import { getContext } from '../../request/context/store.js'
@@ -323,10 +323,6 @@ function pickList<T extends string>(fn: string, name: string, value: T | readonl
323
323
  /** Key prefix of the alert raised when deliveries to a sink are given up; never queued to the sink named after it. */
324
324
  export const UNDELIVERABLE_PREFIX = 'pipework.alerts.undeliverable:'
325
325
 
326
- function pgTextArray(values: readonly string[]): string {
327
- return `{${values.map(v => `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`).join(',')}}`
328
- }
329
-
330
326
  export function createAlerts(config: AlertsConfig): Alerts {
331
327
  if (config.sinks.length === 0) throw new Error('[pipework] alerts.create needs at least one sink.')
332
328
  const sinks = new Map<string, AlertSink>()
@@ -334,11 +330,11 @@ export function createAlerts(config: AlertsConfig): Alerts {
334
330
  if (sinks.has(s.name)) throw new Error(`[pipework] alerts.create: two sinks are named "${s.name}"; give each its own name.`)
335
331
  sinks.set(s.name, s)
336
332
  }
337
- const sinkNames = pgTextArray([...sinks.keys()])
333
+ const sinkNames = textArrayParam([...sinks.keys()])
338
334
  function sinksFor(key: string): string {
339
335
  if (!key.startsWith(UNDELIVERABLE_PREFIX)) return sinkNames
340
336
  const excluded = key.slice(UNDELIVERABLE_PREFIX.length)
341
- return pgTextArray([...sinks.keys()].filter(n => n !== excluded))
337
+ return textArrayParam([...sinks.keys()].filter(n => n !== excluded))
342
338
  }
343
339
  const digestEveryMs = config.digest?.everyMs ?? null
344
340
  if (config.digest !== undefined) {
@@ -351,7 +347,7 @@ export function createAlerts(config: AlertsConfig): Alerts {
351
347
  }
352
348
  if (config.digest.severities?.length === 0) throw new Error('[pipework] alerts.create: digest.severities needs at least one severity, or leave it out for warning.')
353
349
  }
354
- const digestSeverities = pgTextArray(config.digest === undefined ? [] : [...new Set(config.digest.severities ?? ['warning'])])
350
+ const digestSeverities = textArrayParam(config.digest === undefined ? [] : [...new Set(config.digest.severities ?? ['warning'])])
355
351
  const retention = checkRetention('alerts.create', config)
356
352
  const jobType = config.jobType ?? 'pipework.alerts'
357
353
  const maxAttempts = config.maxAttempts ?? 10
@@ -396,7 +392,7 @@ export function createAlerts(config: AlertsConfig): Alerts {
396
392
  const resendStored = storedMs('resendAfterMs', options.resendAfterMs)
397
393
  const quietStored = storedMs('resolveAfterQuietMs', options.resolveAfterQuietMs)
398
394
  const traceId = getContext()?.trace?.id ?? null
399
- const traceArray = traceId === null ? '{}' : pgTextArray([traceId])
395
+ const traceArray = traceId === null ? '{}' : textArrayParam([traceId])
400
396
  const subject = options.subject === undefined ? null : JSON.stringify(options.subject)
401
397
  const context = options.context === undefined ? null : JSON.stringify(redactJson(options.context, redactExtra))
402
398
  const message = redactText(options.message, redactExtra)
@@ -543,9 +539,9 @@ export function createAlerts(config: AlertsConfig): Alerts {
543
539
  const cursor = options.cursor === undefined ? null : readCursor('list', options.cursor)
544
540
  const rows = await db().execute(sql`
545
541
  SELECT *, ${exact('last_seen_at')} AS exact_at FROM pipework_alerts
546
- WHERE (${statuses === null}::boolean OR status = ANY(${pgTextArray(statuses ?? [])}::text[]))
547
- AND (${severities === null}::boolean OR severity = ANY(${pgTextArray(severities ?? [])}::text[]))
548
- AND (${owners === null}::boolean OR owner = ANY(${pgTextArray([...(owners ?? [])])}::text[]))
542
+ WHERE (${statuses === null}::boolean OR status = ANY(${textArrayParam(statuses ?? [])}::text[]))
543
+ AND (${severities === null}::boolean OR severity = ANY(${textArrayParam(severities ?? [])}::text[]))
544
+ AND (${owners === null}::boolean OR owner = ANY(${textArrayParam([...(owners ?? [])])}::text[]))
549
545
  AND (${cursor === null}::boolean OR last_seen_at < ${cursor?.[0] ?? null}::timestamptz
550
546
  OR (last_seen_at = ${cursor?.[0] ?? null}::timestamptz AND key > ${cursor?.[1] ?? null}::text))
551
547
  ORDER BY last_seen_at DESC, key ASC
@@ -10,7 +10,7 @@ export const audit = {
10
10
  /** Queries the framework-managed `audit_record` table. Tenant-scoped via the active Flow. `{ entity, entityId }` returns one row's history from an index; build a composite key's `entityId` with `audit.entityId`. */
11
11
  query,
12
12
  /**
13
- * The `entityId` filter text for a written table's row: the primary key's value as text, or for a composite key a compact JSON array of the key values as strings in key order (`["a","7"]`). Pass the table and a row (or just its key fields). Dates become ISO strings.
13
+ * The `entityId` filter text for a written table's row: the primary key's value as text, or for a composite key a compact JSON array of the key values as strings in key order (`["a","7"]`). Pass the table and a row (or just its key fields). Dates become ISO strings. A table declared `auditEntityId: 'hashed'` gets the lowercase hex SHA-256 of that text, which is what its audit rows store.
14
14
  */
15
15
  entityId: auditEntityId,
16
16
  /** The newest record's `{ seq, hash }` in the tenant's audit hash chain (blueprint `audit.chain`), to store outside the database; `null` when empty. */
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'
2
2
  import { copyFile, mkdir, readdir, readFile, stat, unlink } from 'node:fs/promises'
3
3
  import { basename, join } from 'node:path'
4
4
  import { Readable } from 'node:stream'
5
- import { sql } from '../../data/db/sql.js'
5
+ import { sql, textArrayParam } from '../../data/db/sql.js'
6
6
  import { pipeSystem } from '../../data/db/db.js'
7
7
  import { schema } from '../../data/schema/namespace.js'
8
8
  import { fitting } from '../../request/di/index.js'
@@ -293,11 +293,6 @@ function toDate(value: unknown): Date {
293
293
  return new Date(/(Z|[+-]\d\d(:?\d\d)?)$/.test(text) ? text : `${text.replace(' ', 'T')}Z`)
294
294
  }
295
295
 
296
- /** A Postgres text-array literal, sent as one parameter and cast with `::text[]`. */
297
- function textArray(values: readonly string[]): string {
298
- return `{${values.map(v => `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`).join(',')}}`
299
- }
300
-
301
296
  function verdictsOf(receipt: Record<string, unknown>): EmailVerdicts {
302
297
  const status = (key: string): string | null => {
303
298
  const v = receipt[key] as { status?: unknown } | undefined
@@ -419,11 +414,11 @@ export function createInbound(config: InboundConfig): Inbound {
419
414
  })
420
415
  const received = await db.execute(sql`
421
416
  SELECT message_id_header AS id, conversation_id FROM pipework_email_inbound
422
- WHERE message_id_header = ANY(${textArray(ids)}::text[]) AND conversation_id IS NOT NULL
417
+ WHERE message_id_header = ANY(${textArrayParam(ids)}::text[]) AND conversation_id IS NOT NULL
423
418
  AND (${tenantId}::uuid IS NULL OR tenant_id IS NULL OR tenant_id = ${tenantId}::uuid)`)
424
419
  const sent = sesLocals.length === 0 ? [] : await db.execute(sql`
425
420
  SELECT provider_message_id AS id, conversation_id FROM pipework_email_messages
426
- WHERE provider_message_id = ANY(${textArray(sesLocals)}::text[]) AND conversation_id IS NOT NULL`)
421
+ WHERE provider_message_id = ANY(${textArrayParam(sesLocals)}::text[]) AND conversation_id IS NOT NULL`)
427
422
  const byId = new Map<string, string>()
428
423
  for (const r of received) byId.set(r['id'] as string, r['conversation_id'] as string)
429
424
  const bySes = new Map<string, string>()
@@ -1,4 +1,4 @@
1
- import { sql } from '../../data/db/sql.js'
1
+ import { sql, textArrayParam } from '../../data/db/sql.js'
2
2
  import type { DB } from '../../data/db/index.js'
3
3
  import { pipeSystem } from '../../data/db/db.js'
4
4
  import { insert, remove } from '../../data/write/index.js'
@@ -137,6 +137,8 @@ export interface Mail<T extends EmailTemplates> {
137
137
  unsubscribe(db: DB, address: string, list: string): Promise<void>
138
138
  /** True when `address` (any case) has opted out of `list`. */
139
139
  unsubscribed(db: DB, address: string, list: string): Promise<boolean>
140
+ /** The `addresses` (as given) that have opted out of `list`, trimmed and lowercased to compare, read in one query. */
141
+ unsubscribedAmong(db: DB, addresses: Iterable<string>, list: string): Promise<Set<string>>
140
142
  /** Removes the opt-out of `address` from `list`, so its mail is delivered again. Writes like `unsubscribe`. */
141
143
  resubscribe(db: DB, address: string, list: string): Promise<void>
142
144
  /**
@@ -445,6 +447,22 @@ export function createMail<T extends EmailTemplates>(config: MailConfig<T>): Mai
445
447
  requireList(list)
446
448
  return isUnsubscribed(requireUnscopedExecutor(handle, 'mail.unsubscribed()', 'an opt-out is not tenant data'), address, list)
447
449
  },
450
+ async unsubscribedAmong(handle, addresses, list) {
451
+ requireList(list)
452
+ const executor = requireUnscopedExecutor(handle, 'mail.unsubscribedAmong()', 'an opt-out is not tenant data')
453
+ const given = new Map<string, string[]>()
454
+ for (const address of addresses) {
455
+ const key = normalizeAddress(address)
456
+ const spellings = given.get(key)
457
+ if (spellings === undefined) given.set(key, [address])
458
+ else spellings.push(address)
459
+ }
460
+ const out = new Set<string>()
461
+ if (given.size === 0) return out
462
+ const rows = await executor.execute(sql`SELECT address FROM pipework_email_unsubscribes WHERE list = ${list} AND address = ANY(${textArrayParam([...given.keys()])}::text[])`)
463
+ for (const row of rows) for (const spelling of given.get(row['address'] as string) ?? []) out.add(spelling)
464
+ return out
465
+ },
448
466
  async resubscribe(_db, address, list) {
449
467
  requireList(list)
450
468
  await remove(emailUnsubscribes).where(and(eq(emailUnsubscribes.address, normalizeAddress(address)), eq(emailUnsubscribes.list, list))!)
@@ -50,6 +50,8 @@ export const emailSuppressions = define('pipework_email_suppressions', {
50
50
  reason: field.enum(SUPPRESSION_REASONS),
51
51
  detail: field.jsonb(schema.check.json()).nullable(),
52
52
  createdAt: field.timestamp().default('now'),
53
+ }, {
54
+ auditEntityId: 'hashed',
53
55
  })
54
56
 
55
57
  // One row per (address, list) that opted out of a list's mail. Address lowercased.
@@ -59,6 +61,7 @@ export const emailUnsubscribes = define('pipework_email_unsubscribes', {
59
61
  createdAt: field.timestamp().default('now'),
60
62
  }, {
61
63
  primaryKey: ['address', 'list'],
64
+ auditEntityId: 'hashed',
62
65
  })
63
66
 
64
67
  // One row per received message. Bodies are not stored here: the raw message is
@@ -17,6 +17,8 @@ export const rateLimitCounts = define('pipework_rate_limit_counts', {
17
17
  key: field.text().primaryKey(),
18
18
  count: field.integer().default(0),
19
19
  windowStart: field.timestamp().default('now'),
20
+ }, {
21
+ auditEntityId: 'hashed',
20
22
  })
21
23
 
22
24
  const WINDOWS: readonly RateLimitWindowName[] = ['perUser', 'perTenant', 'perAddress', 'total']