pipework 0.68.0 → 0.70.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 (64) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/REFERENCE.md +98 -1
  3. package/dist/core/config/namespace.d.ts +1 -0
  4. package/dist/core/config/namespace.d.ts.map +1 -1
  5. package/dist/core/config/schema.d.ts +3 -0
  6. package/dist/core/config/schema.d.ts.map +1 -1
  7. package/dist/core/config/schema.js +3 -1
  8. package/dist/core/config/schema.js.map +1 -1
  9. package/dist/data/migrate/internal-definitions.d.ts.map +1 -1
  10. package/dist/data/migrate/internal-definitions.js +8 -1
  11. package/dist/data/migrate/internal-definitions.js.map +1 -1
  12. package/dist/index.d.ts +2 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/infra/access-links/index.d.ts +1 -0
  17. package/dist/infra/access-links/index.d.ts.map +1 -1
  18. package/dist/infra/access-links/namespace.d.ts +24 -0
  19. package/dist/infra/access-links/namespace.d.ts.map +1 -1
  20. package/dist/infra/access-links/namespace.js +23 -0
  21. package/dist/infra/access-links/namespace.js.map +1 -1
  22. package/dist/infra/access-links/person-check.d.ts +45 -0
  23. package/dist/infra/access-links/person-check.d.ts.map +1 -0
  24. package/dist/infra/access-links/person-check.js +128 -0
  25. package/dist/infra/access-links/person-check.js.map +1 -0
  26. package/dist/infra/access-links/schema.d.ts +8 -0
  27. package/dist/infra/access-links/schema.d.ts.map +1 -1
  28. package/dist/infra/access-links/schema.js +7 -1
  29. package/dist/infra/access-links/schema.js.map +1 -1
  30. package/dist/infra/alerts/audit.d.ts +3 -0
  31. package/dist/infra/alerts/audit.d.ts.map +1 -0
  32. package/dist/infra/alerts/audit.js +25 -0
  33. package/dist/infra/alerts/audit.js.map +1 -0
  34. package/dist/infra/alerts/handle.d.ts +95 -0
  35. package/dist/infra/alerts/handle.d.ts.map +1 -0
  36. package/dist/infra/alerts/handle.js +301 -0
  37. package/dist/infra/alerts/handle.js.map +1 -0
  38. package/dist/infra/alerts/index.d.ts +5 -0
  39. package/dist/infra/alerts/index.d.ts.map +1 -0
  40. package/dist/infra/alerts/index.js +2 -0
  41. package/dist/infra/alerts/index.js.map +1 -0
  42. package/dist/infra/alerts/namespace.d.ts +23 -0
  43. package/dist/infra/alerts/namespace.d.ts.map +1 -0
  44. package/dist/infra/alerts/namespace.js +23 -0
  45. package/dist/infra/alerts/namespace.js.map +1 -0
  46. package/dist/infra/alerts/schema.d.ts +123 -0
  47. package/dist/infra/alerts/schema.d.ts.map +1 -0
  48. package/dist/infra/alerts/schema.js +55 -0
  49. package/dist/infra/alerts/schema.js.map +1 -0
  50. package/dist/infra/alerts/sinks.d.ts +60 -0
  51. package/dist/infra/alerts/sinks.d.ts.map +1 -0
  52. package/dist/infra/alerts/sinks.js +50 -0
  53. package/dist/infra/alerts/sinks.js.map +1 -0
  54. package/dist/infra/alerts/sources.d.ts +55 -0
  55. package/dist/infra/alerts/sources.d.ts.map +1 -0
  56. package/dist/infra/alerts/sources.js +134 -0
  57. package/dist/infra/alerts/sources.js.map +1 -0
  58. package/dist/infra/email/sns.d.ts +1 -1
  59. package/dist/infra/email/sns.js +1 -1
  60. package/dist/infra/webhook/sign.d.ts +2 -0
  61. package/dist/infra/webhook/sign.d.ts.map +1 -1
  62. package/dist/infra/webhook/sign.js +1 -1
  63. package/dist/infra/webhook/sign.js.map +1 -1
  64. package/package.json +1 -1
@@ -0,0 +1,128 @@
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { sql } from '../../data/db/sql.js';
3
+ import { pipeSystem } from '../../data/db/db.js';
4
+ const ALGORITHM = 'SHA-256';
5
+ const MIN_SECRET_BYTES = 32;
6
+ const MAX_NUMBER_LIMIT = 10_000_000;
7
+ const PRUNE_BATCH = 100;
8
+ function sha256Hex(data) {
9
+ return createHash('sha256').update(data).digest('hex');
10
+ }
11
+ function sameBytes(a, b) {
12
+ return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
13
+ }
14
+ function sameHex(a, b) {
15
+ return sameBytes(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
16
+ }
17
+ function positive(name, value) {
18
+ if (!Number.isFinite(value) || value <= 0)
19
+ throw new Error(`personCheck: ${name} must be a positive number`);
20
+ return value;
21
+ }
22
+ function createPersonCheck(config) {
23
+ const secret = typeof config.secret === 'string' ? Buffer.from(config.secret, 'utf8') : config.secret;
24
+ if (!Buffer.isBuffer(secret) || secret.length < MIN_SECRET_BYTES) {
25
+ throw new Error(`personCheck: secret must be ${String(MIN_SECRET_BYTES)} bytes or more, the same in every process (no random fallback: a per-process secret breaks passes across processes)`);
26
+ }
27
+ const maxNumber = config.maxNumber ?? 100_000;
28
+ if (!Number.isInteger(maxNumber) || maxNumber < 1 || maxNumber > MAX_NUMBER_LIMIT) {
29
+ throw new Error(`personCheck: maxNumber must be an integer from 1 to ${String(MAX_NUMBER_LIMIT)}`);
30
+ }
31
+ const challengeMs = positive('challengeMs', config.challengeMs ?? 600_000);
32
+ const passMs = positive('passMs', config.passMs ?? 1_800_000);
33
+ const database = config.database;
34
+ const key = (purpose) => createHmac('sha256', secret).update(`pipework access links person check: ${purpose}`).digest();
35
+ const challengeKey = key('challenge');
36
+ const passKey = key('pass');
37
+ function challenge(code) {
38
+ const expires = Math.floor((Date.now() + challengeMs) / 1000);
39
+ const salt = `${randomBytes(12).toString('hex')}?expires=${String(expires)}&link=${encodeURIComponent(code)}`;
40
+ const secretNumber = randomBytes(4).readUInt32BE() % (maxNumber + 1);
41
+ const digest = sha256Hex(salt + String(secretNumber));
42
+ const signature = createHmac('sha256', challengeKey).update(digest).digest('hex');
43
+ return { algorithm: ALGORITHM, challenge: digest, maxnumber: maxNumber, salt, signature };
44
+ }
45
+ function issuePass(code) {
46
+ const exp = Date.now() + passMs;
47
+ const body = Buffer.from(JSON.stringify({ code, exp })).toString('base64url');
48
+ const mac = createHmac('sha256', passKey).update(body).digest('base64url');
49
+ return { pass: `${body}.${mac}`, expiresAt: new Date(exp) };
50
+ }
51
+ // The challenge's expiry in ms when `solution` solves a live challenge made for `code`; otherwise null.
52
+ function solvedUntil(code, solution) {
53
+ if (typeof solution !== 'string')
54
+ return null;
55
+ let payload;
56
+ try {
57
+ payload = JSON.parse(Buffer.from(solution, 'base64').toString('utf8'));
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload))
63
+ return null;
64
+ const { algorithm, challenge: id, number, salt, signature } = payload;
65
+ if (algorithm !== ALGORITHM || typeof id !== 'string' || typeof salt !== 'string' || typeof signature !== 'string')
66
+ return null;
67
+ if (typeof number !== 'number' || !Number.isSafeInteger(number) || number < 0)
68
+ return null;
69
+ const params = new URLSearchParams(salt.split('?')[1] ?? '');
70
+ const expires = Number(params.get('expires'));
71
+ if (!Number.isFinite(expires) || expires * 1000 <= Date.now() || params.get('link') !== code)
72
+ return null;
73
+ const expected = createHmac('sha256', challengeKey).update(id).digest('hex');
74
+ if (!sameHex(expected, signature))
75
+ return null;
76
+ if (!sameHex(sha256Hex(salt + String(number)), id))
77
+ return null;
78
+ return { id, untilMs: expires * 1000 };
79
+ }
80
+ async function verify(code, solution) {
81
+ const solved = solvedUntil(code, solution);
82
+ if (solved === null)
83
+ return null;
84
+ const db = pipeSystem(database);
85
+ const spent = await db.execute(sql `
86
+ INSERT INTO access_link_check_spent (challenge, expires_at)
87
+ VALUES (${solved.id}, ${new Date(solved.untilMs).toISOString()})
88
+ ON CONFLICT DO NOTHING RETURNING 1 AS spent`);
89
+ if (spent.length === 0)
90
+ return null;
91
+ // Kept an hour past expiry so clock skew between processes and the database never reopens a solution.
92
+ await db.execute(sql `
93
+ DELETE FROM access_link_check_spent WHERE ctid IN (
94
+ SELECT ctid FROM access_link_check_spent WHERE expires_at < now() - interval '1 hour' LIMIT ${PRUNE_BATCH})`);
95
+ return issuePass(code);
96
+ }
97
+ const check = ({ link, proof }) => {
98
+ if (typeof proof !== 'string')
99
+ return Promise.resolve(false);
100
+ const parts = proof.split('.');
101
+ const [body, mac] = parts;
102
+ if (parts.length !== 2 || body === undefined || mac === undefined)
103
+ return Promise.resolve(false);
104
+ const expected = createHmac('sha256', passKey).update(body).digest();
105
+ if (!sameBytes(Buffer.from(mac, 'base64url'), expected))
106
+ return Promise.resolve(false);
107
+ try {
108
+ const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
109
+ return Promise.resolve(typeof claims.exp === 'number' && claims.exp > Date.now() && claims.code === link.code);
110
+ }
111
+ catch {
112
+ return Promise.resolve(false);
113
+ }
114
+ };
115
+ return { challenge, verify, check };
116
+ }
117
+ /** Solves a challenge the way the widget does and returns its base64 payload. For tests: it does the work the browser would. */
118
+ function solvePersonChallenge(c) {
119
+ for (let n = 0; n <= c.maxnumber; n++) {
120
+ if (sha256Hex(c.salt + String(n)) === c.challenge) {
121
+ const payload = { algorithm: c.algorithm, challenge: c.challenge, number: n, salt: c.salt, signature: c.signature, took: 0 };
122
+ return Buffer.from(JSON.stringify(payload)).toString('base64');
123
+ }
124
+ }
125
+ throw new Error('personCheck.solve: challenge has no solution');
126
+ }
127
+ export const personCheck = Object.assign(createPersonCheck, { solve: solvePersonChallenge });
128
+ //# sourceMappingURL=person-check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"person-check.js","sourceRoot":"","sources":["../../../src/infra/access-links/person-check.ts"],"names":[],"mappings":"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"}
@@ -124,4 +124,12 @@ export declare const accessLinkUse: import("../../data/domain/types.js").Defined
124
124
  nullable: true;
125
125
  }>;
126
126
  }, "access_link_use">;
127
+ export declare const accessLinkCheckSpent: import("../../data/domain/types.js").DefinedTable<{
128
+ challenge: import("../../data/domain/types.js").FieldDescriptor<string, {
129
+ primaryKey: true;
130
+ }> & import("../../data/domain/field.js").FieldModifiers<string, {
131
+ primaryKey: true;
132
+ }>;
133
+ expiresAt: import("../../data/domain/types.js").FieldDescriptor<Date, {}> & import("../../data/domain/field.js").FieldModifiers<Date, {}>;
134
+ }, "access_link_check_spent">;
127
135
  //# sourceMappingURL=schema.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../src/infra/access-links/schema.ts"],"names":[],"mappings":"AAWA,eAAO,MAAM,wBAAwB,gDAAiD,CAAA;AAEtF,eAAO,MAAM,cAAc;;;;;;;;;;;;;sBAKzB,CAAA;AAEF,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgBrB,CAAA;AAEF,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAiBxB,CAAA"}
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../src/infra/access-links/schema.ts"],"names":[],"mappings":"AAWA,eAAO,MAAM,wBAAwB,gDAAiD,CAAA;AAEtF,eAAO,MAAM,cAAc;;;;;;;;;;;;;sBAKzB,CAAA;AAEF,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgBrB,CAAA;AAEF,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAiBxB,CAAA;AAIF,eAAO,MAAM,oBAAoB;;;;;;;6BAG/B,CAAA"}
@@ -1,4 +1,4 @@
1
- // The three tables behind access links. `access_link_code` is deliberately NOT
1
+ // The four tables behind access links. `access_link_code` is deliberately NOT
2
2
  // tenant-scoped: it maps a code to its tenant so a request with no tenant can
3
3
  // find where to look, and holds nothing else. `access_link` and
4
4
  // `access_link_use` carry a `.tenant()` key, so generated migrations put them
@@ -49,4 +49,10 @@ export const accessLinkUse = define('access_link_use', {
49
49
  byLink: index('idx_access_link_use_link').on(t['linkId'], t['status']),
50
50
  }),
51
51
  });
52
+ // Person-check solutions already accepted, kept until the challenge has expired:
53
+ // one solved challenge makes one pass, across processes. Not tenant-scoped.
54
+ export const accessLinkCheckSpent = define('access_link_check_spent', {
55
+ challenge: field.text().primaryKey(),
56
+ expiresAt: field.timestamp(),
57
+ });
52
58
  //# sourceMappingURL=schema.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../../src/infra/access-links/schema.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../../src/infra/access-links/schema.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ /** Writes an `Alert` audit record under the Flow's open trace step; does nothing when no step is open. */
2
+ export declare function auditAlert(verb: string, key: string, details: Record<string, unknown>, database?: string): Promise<void>;
3
+ //# sourceMappingURL=audit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../../../src/infra/alerts/audit.ts"],"names":[],"mappings":"AAMA,0GAA0G;AAC1G,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAe9H"}
@@ -0,0 +1,25 @@
1
+ import { sql } from '../../data/db/sql.js';
2
+ import { pipeSystem } from '../../data/db/db.js';
3
+ import { getContext } from '../../request/context/store.js';
4
+ import { resolveActorId } from '../../data/write/audit/actor.js';
5
+ import { getBaseLogger } from '../../core/logging/index.js';
6
+ /** Writes an `Alert` audit record under the Flow's open trace step; does nothing when no step is open. */
7
+ export async function auditAlert(verb, key, details, database) {
8
+ const flow = getContext();
9
+ const trace = flow?.trace;
10
+ const stepId = trace?.buffer.openStepId ?? null;
11
+ if (flow === undefined || trace === undefined || trace === null || stepId === null)
12
+ return;
13
+ try {
14
+ await pipeSystem(database).execute(sql `
15
+ INSERT INTO audit_record
16
+ (trace_day, trace_id, trace_step_id, tenant_id, actor_id, entity, verb, entity_id, after, occurred_at)
17
+ VALUES (${trace.openedDay}, ${trace.id}, ${stepId}, ${flow.tenant}, ${resolveActorId(flow)},
18
+ 'Alert', ${verb}, ${key}, ${JSON.stringify(details)}::jsonb, now())
19
+ `);
20
+ }
21
+ catch (err) {
22
+ getBaseLogger().error({ err, traceId: trace.id }, `Failed to write the alert ${verb} audit record`);
23
+ }
24
+ }
25
+ //# sourceMappingURL=audit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.js","sourceRoot":"","sources":["../../../src/infra/alerts/audit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,sBAAsB,CAAA;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAA;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAA;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAE3D,0GAA0G;AAC1G,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAY,EAAE,GAAW,EAAE,OAAgC,EAAE,QAAiB;IAC7G,MAAM,IAAI,GAAG,UAAU,EAAE,CAAA;IACzB,MAAM,KAAK,GAAG,IAAI,EAAE,KAAK,CAAA;IACzB,MAAM,MAAM,GAAG,KAAK,EAAE,MAAM,CAAC,UAAU,IAAI,IAAI,CAAA;IAC/C,IAAI,IAAI,KAAK,SAAS,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,MAAM,KAAK,IAAI;QAAE,OAAM;IAC1F,IAAI,CAAC;QACH,MAAM,UAAU,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,GAAG,CAAA;;;gBAG1B,KAAK,CAAC,SAAS,KAAK,KAAK,CAAC,EAAE,KAAK,MAAM,KAAK,IAAI,CAAC,MAAM,KAAK,cAAc,CAAC,IAAI,CAAC;mBAC7E,IAAI,KAAK,GAAG,KAAK,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;KACtD,CAAC,CAAA;IACJ,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,aAAa,EAAE,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,EAAE,EAAE,6BAA6B,IAAI,eAAe,CAAC,CAAA;IACrG,CAAC;AACH,CAAC"}
@@ -0,0 +1,95 @@
1
+ import type { Queue } from '../../async/jobs/queue.js';
2
+ import type { Handler } from '../../request/di/types.js';
3
+ import type { AlertPayload, AlertSink } from './sinks.js';
4
+ import type { AlertSource } from './sources.js';
5
+ export type AlertSeverity = 'info' | 'warning' | 'error' | 'critical';
6
+ export interface AlertSubject {
7
+ readonly tenantId?: string;
8
+ readonly entity?: string;
9
+ readonly entityId?: string;
10
+ }
11
+ export interface AlertsConfig {
12
+ /** Where alerts are sent. At least one; names must be unique. */
13
+ readonly sinks: readonly AlertSink[];
14
+ /** The queue the alerts job runs on; its worker surface must run `handle.job`. */
15
+ readonly queue: Pick<Queue, 'enqueue'>;
16
+ /** Job type of `handle.job`; default `'pipework.alerts'`. */
17
+ readonly jobType?: string;
18
+ /** Send again while an alert stays open and is raised again after this long since the last send. Default `null`: never. */
19
+ readonly resendAfterMs?: number | null;
20
+ /** Resolve an open alert nobody raised for this long, on the job's tick. Default `null`: only `resolve`. */
21
+ readonly resolveAfterQuietMs?: number | null;
22
+ /** Delivery attempts before a row is given up on; default 10. */
23
+ readonly maxAttempts?: number;
24
+ /** First retry delay, doubling per attempt up to one hour; default 5000. */
25
+ readonly backoffMs?: number;
26
+ readonly sources?: readonly AlertSource[];
27
+ /** Database holding the alert tables; default the default database. */
28
+ readonly database?: string;
29
+ }
30
+ export interface RaiseOptions {
31
+ readonly severity: AlertSeverity;
32
+ readonly message: string;
33
+ readonly subject?: AlertSubject;
34
+ /** Overrides the handle's `resendAfterMs` for this alert. */
35
+ readonly resendAfterMs?: number;
36
+ /** Overrides the handle's `resolveAfterQuietMs` for this alert. */
37
+ readonly resolveAfterQuietMs?: number;
38
+ }
39
+ export interface Alert {
40
+ readonly key: string;
41
+ readonly status: 'open' | 'resolved';
42
+ readonly severity: AlertSeverity;
43
+ readonly message: string;
44
+ readonly subject: AlertSubject | null;
45
+ readonly count: number;
46
+ readonly firstSeenAt: Date;
47
+ readonly lastSeenAt: Date;
48
+ readonly lastSentAt: Date | null;
49
+ readonly resolvedAt: Date | null;
50
+ /** Latest ten traces that raised it, newest first. */
51
+ readonly traceIds: readonly string[];
52
+ }
53
+ export interface DeliveryRow {
54
+ readonly id: string;
55
+ readonly key: string;
56
+ readonly kind: 'raise' | 'repeat' | 'resolve';
57
+ readonly sink: string;
58
+ readonly payload: AlertPayload;
59
+ readonly attempts: number;
60
+ readonly nextAttemptAt: Date;
61
+ readonly deliveredAt: Date | null;
62
+ readonly failedAt: Date | null;
63
+ readonly lastError: string | null;
64
+ }
65
+ export interface TickResult {
66
+ /** Alerts resolved because nobody raised them for their quiet period, or because a source cleared them. */
67
+ readonly resolved: number;
68
+ readonly delivered: number;
69
+ /** Attempts that failed and will be retried. */
70
+ readonly retried: number;
71
+ /** Deliveries given up on. */
72
+ readonly failed: number;
73
+ }
74
+ export interface Alerts {
75
+ /**
76
+ * Raises the condition `key`. Not open: opens it and sends once. Open: groups it (count, last seen, trace id)
77
+ * and sends nothing, unless `resendAfterMs` has passed since the last send. Needs a Flow.
78
+ */
79
+ raise(key: string, options: RaiseOptions): Promise<{
80
+ status: 'opened' | 'grouped' | 'resent';
81
+ }>;
82
+ /** Resolves an open alert and sends `resolve` once. Not open: false, nothing sent. */
83
+ resolve(key: string): Promise<boolean>;
84
+ /** The job to add to a worker surface's `jobs` (and schedule, e.g. every minute): auto-resolve, sources, deliveries. */
85
+ readonly job: Handler<Record<string, unknown>, unknown>;
86
+ /** What one run of `job` does; callable from a test. Needs a Flow. */
87
+ tick(): Promise<TickResult>;
88
+ open(): Promise<Alert[]>;
89
+ get(key: string): Promise<Alert | null>;
90
+ deliveries(options?: {
91
+ key?: string;
92
+ }): Promise<DeliveryRow[]>;
93
+ }
94
+ export declare function createAlerts(config: AlertsConfig): Alerts;
95
+ //# sourceMappingURL=handle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handle.d.ts","sourceRoot":"","sources":["../../../src/infra/alerts/handle.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,301 @@
1
+ import { sql } from '../../data/db/sql.js';
2
+ import { pipeSystem } from '../../data/db/db.js';
3
+ import { fitting } from '../../request/di/index.js';
4
+ import { getContext } from '../../request/context/store.js';
5
+ import { getBaseLogger } from '../../core/logging/index.js';
6
+ import { __flowStep } from '../../trace/runtime/flow-step.js';
7
+ import { auditAlert } from './audit.js';
8
+ const SEND_TIMEOUT_MS = 10_000;
9
+ const LEASE_MS = 30_000;
10
+ const BATCH = 50;
11
+ const MAX_BATCHES = 20;
12
+ const MAX_BACKOFF_MS = 3_600_000;
13
+ const SEVERITIES = new Set(['info', 'warning', 'error', 'critical']);
14
+ function toAlert(row) {
15
+ const date = (v) => new Date(v);
16
+ const dateOrNull = (v) => (v === null || v === undefined ? null : date(v));
17
+ return {
18
+ key: row['key'],
19
+ status: row['status'],
20
+ severity: row['severity'],
21
+ message: row['message'],
22
+ subject: (row['subject'] ?? null),
23
+ count: Number(row['count']),
24
+ firstSeenAt: date(row['first_seen_at']),
25
+ lastSeenAt: date(row['last_seen_at']),
26
+ lastSentAt: dateOrNull(row['last_sent_at']),
27
+ resolvedAt: dateOrNull(row['resolved_at']),
28
+ traceIds: (row['trace_ids'] ?? []),
29
+ };
30
+ }
31
+ function toDeliveryRow(row) {
32
+ return {
33
+ id: row['id'],
34
+ key: row['alert_key'],
35
+ kind: row['kind'],
36
+ sink: row['sink'],
37
+ payload: row['payload'],
38
+ attempts: Number(row['attempts']),
39
+ nextAttemptAt: new Date(row['next_attempt_at']),
40
+ deliveredAt: row['delivered_at'] === null ? null : new Date(row['delivered_at']),
41
+ failedAt: row['failed_at'] === null ? null : new Date(row['failed_at']),
42
+ lastError: (row['last_error'] ?? null),
43
+ };
44
+ }
45
+ function pgTextArray(values) {
46
+ return `{${values.map(v => `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`).join(',')}}`;
47
+ }
48
+ function withTimeout(promise, ms) {
49
+ let timer;
50
+ const timeout = new Promise((_, reject) => {
51
+ timer = setTimeout(() => reject(new Error(`sink did not answer within ${String(ms)} ms`)), ms);
52
+ });
53
+ return Promise.race([promise, timeout]).finally(() => { clearTimeout(timer); });
54
+ }
55
+ export function createAlerts(config) {
56
+ if (config.sinks.length === 0)
57
+ throw new Error('[pipework] alerts.create needs at least one sink.');
58
+ const sinks = new Map();
59
+ for (const s of config.sinks) {
60
+ if (sinks.has(s.name))
61
+ throw new Error(`[pipework] alerts.create: two sinks are named "${s.name}"; give each its own name.`);
62
+ sinks.set(s.name, s);
63
+ }
64
+ const sinkNames = pgTextArray([...sinks.keys()]);
65
+ const jobType = config.jobType ?? 'pipework.alerts';
66
+ const maxAttempts = config.maxAttempts ?? 10;
67
+ const backoffMs = config.backoffMs ?? 5000;
68
+ const defaultResend = config.resendAfterMs ?? null;
69
+ const defaultQuiet = config.resolveAfterQuietMs ?? null;
70
+ const sources = config.sources ?? [];
71
+ const database = config.database;
72
+ const log = getBaseLogger();
73
+ const db = () => pipeSystem(database);
74
+ // The alert and its deliveries move in one statement, so a crash cannot leave one without the other.
75
+ const payloadSql = (kind) => sql `jsonb_build_object(
76
+ 'kind', ${kind}, 'key', up.key, 'severity', up.severity, 'message', up.message, 'subject', up.subject,
77
+ 'count', up.count, 'firstSeenAt', up.first_seen_at, 'lastSeenAt', up.last_seen_at,
78
+ 'resolvedAt', up.resolved_at, 'traceIds', to_jsonb(up.trace_ids))`;
79
+ async function enqueueJob() {
80
+ await config.queue.enqueue(db(), { type: jobType, payload: {}, continueFlow: true });
81
+ }
82
+ // Inside a tick the deliveries are sent by that same tick, so it does not enqueue another.
83
+ async function raise(key, options, enqueue = true) {
84
+ if (key.trim() === '')
85
+ throw new Error('[pipework] alerts.raise needs a non-empty key.');
86
+ if (!SEVERITIES.has(options.severity))
87
+ throw new Error(`[pipework] alerts.raise: severity must be info, warning, error or critical, got "${String(options.severity)}".`);
88
+ const traceId = getContext()?.trace?.id ?? null;
89
+ const traceArray = traceId === null ? '{}' : pgTextArray([traceId]);
90
+ const subject = options.subject === undefined ? null : JSON.stringify(options.subject);
91
+ const rows = await db().execute(sql `
92
+ WITH up AS (
93
+ INSERT INTO pipework_alerts AS a
94
+ (key, status, severity, message, subject, count, first_seen_at, last_seen_at, last_sent_at,
95
+ trace_ids, resend_after_ms, resolve_after_quiet_ms, updated_at)
96
+ SELECT ${key}, 'open', ${options.severity}, ${options.message}, ${subject}::jsonb, 1, c.ts, c.ts, c.ts,
97
+ ${traceArray}::text[], ${options.resendAfterMs ?? null}::int, ${options.resolveAfterQuietMs ?? null}::int, c.ts
98
+ FROM (SELECT clock_timestamp() AS ts) c
99
+ ON CONFLICT (key) DO UPDATE SET
100
+ status = 'open',
101
+ severity = EXCLUDED.severity,
102
+ message = EXCLUDED.message,
103
+ subject = EXCLUDED.subject,
104
+ count = CASE WHEN a.status = 'resolved' THEN 1 ELSE a.count + 1 END,
105
+ first_seen_at = CASE WHEN a.status = 'resolved' THEN EXCLUDED.first_seen_at ELSE a.first_seen_at END,
106
+ last_seen_at = EXCLUDED.last_seen_at,
107
+ resolved_at = NULL,
108
+ trace_ids = CASE
109
+ WHEN a.status = 'resolved' THEN EXCLUDED.trace_ids
110
+ WHEN ${traceId}::text IS NULL THEN a.trace_ids
111
+ ELSE (EXCLUDED.trace_ids || array_remove(a.trace_ids, ${traceId}::text))[1:10] END,
112
+ resend_after_ms = COALESCE(EXCLUDED.resend_after_ms, CASE WHEN a.status = 'resolved' THEN NULL ELSE a.resend_after_ms END),
113
+ resolve_after_quiet_ms = COALESCE(EXCLUDED.resolve_after_quiet_ms, CASE WHEN a.status = 'resolved' THEN NULL ELSE a.resolve_after_quiet_ms END),
114
+ last_sent_at = CASE
115
+ WHEN a.status = 'resolved' OR a.last_sent_at IS NULL THEN EXCLUDED.last_sent_at
116
+ WHEN COALESCE(EXCLUDED.resend_after_ms, a.resend_after_ms, ${defaultResend}::int) IS NOT NULL
117
+ AND EXCLUDED.last_sent_at - a.last_sent_at
118
+ >= COALESCE(EXCLUDED.resend_after_ms, a.resend_after_ms, ${defaultResend}::int) * interval '1 millisecond'
119
+ THEN EXCLUDED.last_sent_at
120
+ ELSE a.last_sent_at END,
121
+ updated_at = EXCLUDED.updated_at
122
+ RETURNING a.*, (a.last_sent_at = a.last_seen_at) AS sent
123
+ ),
124
+ dels AS (
125
+ INSERT INTO pipework_alert_deliveries (alert_key, kind, sink, payload, created_at, next_attempt_at)
126
+ SELECT up.key, CASE WHEN up.count = 1 THEN 'raise' ELSE 'repeat' END, s.sink,
127
+ ${payloadSql(sql `CASE WHEN up.count = 1 THEN 'raise' ELSE 'repeat' END`)}, clock_timestamp(), clock_timestamp()
128
+ FROM up CROSS JOIN unnest(${sinkNames}::text[]) AS s(sink)
129
+ WHERE up.sent
130
+ RETURNING id
131
+ )
132
+ SELECT up.*, (SELECT count(*) FROM dels)::int AS queued FROM up`);
133
+ const row = rows[0];
134
+ const alert = toAlert(row);
135
+ const sent = row['sent'] === true;
136
+ const status = !sent ? 'grouped' : alert.count === 1 ? 'opened' : 'resent';
137
+ await auditAlert(status === 'opened' ? 'raise' : 'group', key, {
138
+ status, severity: alert.severity, count: alert.count, message: alert.message,
139
+ }, database);
140
+ if (sent && enqueue)
141
+ await enqueueJob();
142
+ return { status };
143
+ }
144
+ async function resolveKey(key, onlyIfQuiet, enqueue = true) {
145
+ const rows = await db().execute(sql `
146
+ WITH up AS (
147
+ UPDATE pipework_alerts SET status = 'resolved', resolved_at = clock_timestamp(), updated_at = clock_timestamp()
148
+ WHERE key = ${key} AND status = 'open'
149
+ AND (NOT ${onlyIfQuiet} OR last_seen_at < clock_timestamp()
150
+ - COALESCE(resolve_after_quiet_ms, ${defaultQuiet}::int) * interval '1 millisecond')
151
+ RETURNING *
152
+ ),
153
+ dels AS (
154
+ INSERT INTO pipework_alert_deliveries (alert_key, kind, sink, payload, created_at, next_attempt_at)
155
+ SELECT up.key, 'resolve', s.sink, ${payloadSql(sql `'resolve'`)}, clock_timestamp(), clock_timestamp()
156
+ FROM up CROSS JOIN unnest(${sinkNames}::text[]) AS s(sink)
157
+ RETURNING id
158
+ )
159
+ SELECT up.*, (SELECT count(*) FROM dels)::int AS queued FROM up`);
160
+ if (rows.length === 0)
161
+ return false;
162
+ const alert = toAlert(rows[0]);
163
+ await auditAlert('resolve', key, { count: alert.count, auto: onlyIfQuiet }, database);
164
+ if (enqueue)
165
+ await enqueueJob();
166
+ return true;
167
+ }
168
+ async function autoResolve() {
169
+ if (defaultQuiet === null) {
170
+ const any = await db().execute(sql `SELECT 1 FROM pipework_alerts WHERE status = 'open' AND resolve_after_quiet_ms IS NOT NULL LIMIT 1`);
171
+ if (any.length === 0)
172
+ return 0;
173
+ }
174
+ const due = await db().execute(sql `
175
+ SELECT key FROM pipework_alerts
176
+ WHERE status = 'open'
177
+ AND last_seen_at < clock_timestamp() - COALESCE(resolve_after_quiet_ms, ${defaultQuiet}::int) * interval '1 millisecond'`);
178
+ let resolved = 0;
179
+ for (const row of due)
180
+ if (await resolveKey(row['key'], true, false))
181
+ resolved++;
182
+ return resolved;
183
+ }
184
+ async function runSources() {
185
+ let resolved = 0;
186
+ for (const source of sources) {
187
+ let conditions;
188
+ try {
189
+ conditions = await source.evaluate();
190
+ }
191
+ catch (err) {
192
+ log.error({ err, source: source.name }, 'alert source failed');
193
+ continue;
194
+ }
195
+ for (const c of conditions) {
196
+ await raise(c.key, { severity: c.severity, message: c.message, ...(c.subject !== undefined ? { subject: c.subject } : {}) }, false);
197
+ }
198
+ if (source.owns === undefined)
199
+ continue;
200
+ const live = new Set(conditions.map(c => c.key));
201
+ const opened = await db().execute(sql `SELECT key FROM pipework_alerts WHERE status = 'open'`);
202
+ for (const row of opened) {
203
+ const key = row['key'];
204
+ if (source.owns(key) && !live.has(key) && await resolveKey(key, false, false))
205
+ resolved++;
206
+ }
207
+ }
208
+ return resolved;
209
+ }
210
+ // Leases the due rows by pushing next_attempt_at out, so no row lock is held across a send. A row is
211
+ // due only if no older undelivered row of the same alert and sink exists: a resolve never overtakes its raise.
212
+ async function claimDue(dueBefore) {
213
+ return [...await db().execute(sql `
214
+ UPDATE pipework_alert_deliveries SET next_attempt_at = clock_timestamp() + ${LEASE_MS} * interval '1 millisecond'
215
+ WHERE id IN (
216
+ SELECT d.id FROM pipework_alert_deliveries d
217
+ WHERE d.delivered_at IS NULL AND d.failed_at IS NULL AND d.next_attempt_at <= ${dueBefore}::timestamptz
218
+ AND NOT EXISTS (
219
+ SELECT 1 FROM pipework_alert_deliveries o
220
+ WHERE o.alert_key = d.alert_key AND o.sink = d.sink AND o.delivered_at IS NULL AND o.failed_at IS NULL
221
+ AND (o.created_at, o.id) < (d.created_at, d.id))
222
+ ORDER BY d.next_attempt_at LIMIT ${BATCH} FOR UPDATE OF d SKIP LOCKED)
223
+ RETURNING *`)];
224
+ }
225
+ async function deliverBatch(batch, totals) {
226
+ await Promise.all(batch.map(async (row) => {
227
+ const id = row['id'];
228
+ const key = row['alert_key'];
229
+ const sinkName = row['sink'];
230
+ const kind = row['kind'];
231
+ const attempt = Number(row['attempts']) + 1;
232
+ const sink = sinks.get(sinkName);
233
+ let error = null;
234
+ if (sink === undefined) {
235
+ error = `sink "${sinkName}" is not configured`;
236
+ }
237
+ else {
238
+ try {
239
+ await withTimeout(sink.send({ id, kind, key, sink: sinkName, payload: row['payload'], attempt }), SEND_TIMEOUT_MS);
240
+ }
241
+ catch (err) {
242
+ error = err instanceof Error ? err.message : String(err);
243
+ }
244
+ }
245
+ if (error === null) {
246
+ await db().execute(sql `UPDATE pipework_alert_deliveries SET delivered_at = clock_timestamp(), attempts = ${attempt}, last_error = NULL WHERE id = ${id}::uuid`);
247
+ await auditAlert('deliver', key, { sink: sinkName, kind, attempt }, database);
248
+ totals.delivered++;
249
+ }
250
+ else if (sink === undefined || attempt >= maxAttempts) {
251
+ await db().execute(sql `UPDATE pipework_alert_deliveries SET failed_at = clock_timestamp(), attempts = ${attempt}, last_error = ${error} WHERE id = ${id}::uuid`);
252
+ log.error({ key, sink: sinkName, kind, attempt, error }, 'alert delivery given up');
253
+ await auditAlert('deliver-failed', key, { sink: sinkName, kind, attempt, error }, database);
254
+ totals.failed++;
255
+ }
256
+ else {
257
+ const delay = Math.min(backoffMs * 2 ** (attempt - 1), MAX_BACKOFF_MS);
258
+ await db().execute(sql `UPDATE pipework_alert_deliveries SET attempts = ${attempt}, last_error = ${error},
259
+ next_attempt_at = clock_timestamp() + ${delay} * interval '1 millisecond' WHERE id = ${id}::uuid`);
260
+ totals.retried++;
261
+ }
262
+ }));
263
+ }
264
+ async function runTick() {
265
+ const totals = { delivered: 0, retried: 0, failed: 0 };
266
+ let resolved = await autoResolve();
267
+ resolved += await runSources();
268
+ // Rows that fail during this tick wait for the next one, however short their backoff.
269
+ const dueBefore = String((await db().execute(sql `SELECT clock_timestamp()::text AS t`))[0]['t']);
270
+ for (let i = 0; i < MAX_BATCHES; i++) {
271
+ const batch = await claimDue(dueBefore);
272
+ if (batch.length === 0)
273
+ break;
274
+ await deliverBatch(batch, totals);
275
+ }
276
+ return { resolved, ...totals };
277
+ }
278
+ const tickStep = __flowStep({ ref: 'pipework/alerts:tick' }, runTick);
279
+ const job = fitting.use().job(jobType).fit(async () => tickStep());
280
+ return {
281
+ raise: (key, options) => raise(key, options),
282
+ resolve: key => resolveKey(key, false),
283
+ job: job,
284
+ tick: () => tickStep(),
285
+ async open() {
286
+ const rows = await db().execute(sql `SELECT * FROM pipework_alerts WHERE status = 'open' ORDER BY first_seen_at, key`);
287
+ return rows.map(toAlert);
288
+ },
289
+ async get(key) {
290
+ const rows = await db().execute(sql `SELECT * FROM pipework_alerts WHERE key = ${key}`);
291
+ return rows[0] === undefined ? null : toAlert(rows[0]);
292
+ },
293
+ async deliveries(options = {}) {
294
+ const rows = options.key === undefined
295
+ ? await db().execute(sql `SELECT * FROM pipework_alert_deliveries ORDER BY created_at, id`)
296
+ : await db().execute(sql `SELECT * FROM pipework_alert_deliveries WHERE alert_key = ${options.key} ORDER BY created_at, id`);
297
+ return rows.map(toDeliveryRow);
298
+ },
299
+ };
300
+ }
301
+ //# sourceMappingURL=handle.js.map