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.
- package/CHANGELOG.md +8 -0
- package/REFERENCE.md +2 -2
- package/dist/audit/schema/audit-record-fields.d.ts.map +1 -1
- package/dist/audit/schema/audit-record-fields.js +2 -1
- package/dist/audit/schema/audit-record-fields.js.map +1 -1
- package/dist/auth/auth/schema.d.ts.map +1 -1
- package/dist/auth/auth/schema.js +4 -0
- package/dist/auth/auth/schema.js.map +1 -1
- package/dist/data/db/sql.d.ts +2 -0
- package/dist/data/db/sql.d.ts.map +1 -1
- package/dist/data/db/sql.js +4 -0
- package/dist/data/db/sql.js.map +1 -1
- package/dist/data/domain/types.d.ts +7 -0
- package/dist/data/domain/types.d.ts.map +1 -1
- package/dist/data/write/audit/audit-select.d.ts +2 -1
- package/dist/data/write/audit/audit-select.d.ts.map +1 -1
- package/dist/data/write/audit/audit-select.js +6 -4
- package/dist/data/write/audit/audit-select.js.map +1 -1
- package/dist/data/write/audit/primary-key.d.ts +2 -1
- package/dist/data/write/audit/primary-key.d.ts.map +1 -1
- package/dist/data/write/audit/primary-key.js +5 -2
- package/dist/data/write/audit/primary-key.js.map +1 -1
- package/dist/infra/access-links/schema.d.ts.map +1 -1
- package/dist/infra/access-links/schema.js +6 -0
- package/dist/infra/access-links/schema.js.map +1 -1
- package/dist/infra/alerts/handle.d.ts.map +1 -1
- package/dist/infra/alerts/handle.js +8 -11
- package/dist/infra/alerts/handle.js.map +1 -1
- package/dist/infra/audit/namespace.d.ts +1 -1
- package/dist/infra/audit/namespace.js +1 -1
- package/dist/infra/email/inbound.d.ts.map +1 -1
- package/dist/infra/email/inbound.js +3 -7
- package/dist/infra/email/inbound.js.map +1 -1
- package/dist/infra/email/mail.d.ts +2 -0
- package/dist/infra/email/mail.d.ts.map +1 -1
- package/dist/infra/email/mail.js +22 -1
- package/dist/infra/email/mail.js.map +1 -1
- package/dist/infra/email/schema.d.ts.map +1 -1
- package/dist/infra/email/schema.js +3 -0
- package/dist/infra/email/schema.js.map +1 -1
- package/dist/request/http/rate-limit-classes.d.ts.map +1 -1
- package/dist/request/http/rate-limit-classes.js +2 -0
- package/dist/request/http/rate-limit-classes.js.map +1 -1
- package/package.json +1 -1
- package/src/audit/schema/audit-record-fields.ts +2 -1
- package/src/auth/auth/schema.ts +4 -0
- package/src/data/db/sql.ts +5 -0
- package/src/data/domain/types.ts +7 -0
- package/src/data/write/audit/audit-select.ts +6 -4
- package/src/data/write/audit/primary-key.ts +6 -2
- package/src/infra/access-links/schema.ts +6 -0
- package/src/infra/alerts/handle.ts +8 -12
- package/src/infra/audit/namespace.ts +1 -1
- package/src/infra/email/inbound.ts +3 -8
- package/src/infra/email/mail.ts +19 -1
- package/src/infra/email/schema.ts +3 -0
- 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 =
|
|
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
|
|
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 =
|
|
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 ? '{}' :
|
|
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(${
|
|
547
|
-
AND (${severities === null}::boolean OR severity = ANY(${
|
|
548
|
-
AND (${owners === null}::boolean OR owner = ANY(${
|
|
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(${
|
|
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(${
|
|
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>()
|
package/src/infra/email/mail.ts
CHANGED
|
@@ -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']
|