@absolutejs/vulnerabilities-postgres 0.5.3 → 0.6.1

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/README.md CHANGED
@@ -11,15 +11,20 @@ const persistence = createPostgresVulnerabilityStore({
11
11
  });
12
12
 
13
13
  const osvSnapshots = persistence.snapshots();
14
+ const alertPolicies = persistence.alertPolicies;
15
+ const alertIncidents = persistence.alertIncidents;
14
16
  ```
15
17
 
16
18
  The package stores complete provider snapshots and records, appendable sync
17
19
  history, tenant-scoped managed findings, correlation observations, VEX decisions
18
- and applications, remediation plans and evidence, risk assessments, and expiring distributed refresh leases. Snapshot replacement is
20
+ and applications, remediation plans and evidence, risk assessments, immutable
21
+ alert-policy versions, incident timelines, and leased notification deliveries.
22
+ Snapshot replacement is
19
23
  one Postgres statement, so readers never see a half-replaced record set. Schema
20
24
  creation is lazy and idempotent, or can be disabled when application migrations
21
25
  own the tables.
22
26
 
23
27
  The SQL surface is compatible with postgres.js and Neon-style tagged-template
24
28
  clients. Table prefixes are strictly validated before identifiers are included
25
- in SQL.
29
+ in SQL. Alert lifecycle mutations require a client with a `begin` transaction
30
+ method, such as postgres.js or Bun SQL.
@@ -0,0 +1,148 @@
1
+ import { type VulnerabilityAlert, type VulnerabilityAlertConfiguration } from "@absolutejs/vulnerabilities";
2
+ export type AlertPostgresTag = {
3
+ <T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): PromiseLike<T[]>;
4
+ begin?: <T>(callback: (sql: AlertPostgresTag) => Promise<T>) => Promise<T>;
5
+ unsafe: (sql: string, ...args: never[]) => PromiseLike<unknown[]>;
6
+ };
7
+ export type VulnerabilityAlertPolicyVersion = {
8
+ activated_at: Date;
9
+ activated_by: string;
10
+ configuration: VulnerabilityAlertConfiguration;
11
+ reason: string;
12
+ status: "active" | "superseded";
13
+ tenant_id: string;
14
+ version: number;
15
+ };
16
+ export type ActiveVulnerabilityAlertPolicy = {
17
+ configuration: VulnerabilityAlertConfiguration;
18
+ tenantId: string;
19
+ version: number | null;
20
+ };
21
+ export type VulnerabilityAlertIncidentRow = {
22
+ acknowledged_at: Date | null;
23
+ acknowledged_by: string | null;
24
+ alert_id: string;
25
+ asset_id: string | null;
26
+ body: string;
27
+ due_at: Date | null;
28
+ finding_id: string | null;
29
+ first_observed_at: Date;
30
+ kind: VulnerabilityAlert["kind"];
31
+ last_observed_at: Date;
32
+ next_escalation_at: Date | null;
33
+ observation_count: number;
34
+ occurrence_count: number;
35
+ plan_id: string | null;
36
+ policy_version: number | null;
37
+ resolved_at: Date | null;
38
+ severity: VulnerabilityAlert["severity"];
39
+ source_id: string | null;
40
+ status: "acknowledged" | "open" | "resolved";
41
+ tenant_id: string;
42
+ title: string;
43
+ updated_at: Date;
44
+ value: VulnerabilityAlert;
45
+ };
46
+ export type VulnerabilityAlertEventRow = {
47
+ actor_id: string | null;
48
+ alert_id: string;
49
+ created_at: Date;
50
+ id: number;
51
+ kind: "acknowledged" | "alert_dismissed" | "alert_retried" | "delivery_failed" | "delivered" | "escalated" | "observed" | "opened" | "resolved";
52
+ metadata: Record<string, unknown>;
53
+ };
54
+ export type VulnerabilityAlertDeliveryRow = {
55
+ alert_id: string;
56
+ attempt_count: number;
57
+ audience: "admin" | "owner";
58
+ created_at: Date;
59
+ delivered_at: Date | null;
60
+ id: string;
61
+ idempotency_key: string;
62
+ kind: "escalated" | "opened" | "resolved";
63
+ last_error: string | null;
64
+ lease_expires_at: Date | null;
65
+ next_attempt_at: Date;
66
+ state: "delivered" | "delivering" | "dismissed" | "failed" | "pending";
67
+ updated_at: Date;
68
+ };
69
+ export type ClaimedVulnerabilityAlertDelivery = VulnerabilityAlertDeliveryRow & {
70
+ asset_id: string | null;
71
+ body: string;
72
+ severity: VulnerabilityAlert["severity"];
73
+ tenant_id: string;
74
+ title: string;
75
+ };
76
+ export declare class VulnerabilityAlertConflictError extends Error {
77
+ }
78
+ export declare class VulnerabilityAlertNotFoundError extends Error {
79
+ }
80
+ export type VulnerabilityAlertPolicyStore = {
81
+ activate: (input: {
82
+ activatedAt: string;
83
+ activatedBy: string;
84
+ configuration: VulnerabilityAlertConfiguration;
85
+ reason: string;
86
+ tenantId: string;
87
+ }) => Promise<ActiveVulnerabilityAlertPolicy & {
88
+ activatedAt: string;
89
+ reason: string;
90
+ }>;
91
+ active: () => Promise<ActiveVulnerabilityAlertPolicy[]>;
92
+ list: (limit?: number) => Promise<VulnerabilityAlertPolicyVersion[]>;
93
+ };
94
+ export type VulnerabilityAlertIncidentStore = {
95
+ acknowledge: (input: {
96
+ acknowledgedAt: string;
97
+ acknowledgedBy: string;
98
+ alertId: string;
99
+ }) => Promise<boolean>;
100
+ claimDeliveries: (input?: {
101
+ leaseMs?: number;
102
+ limit?: number;
103
+ tenantIds?: readonly string[];
104
+ }) => Promise<ClaimedVulnerabilityAlertDelivery[]>;
105
+ completeDelivery: (input: {
106
+ alertId: string;
107
+ deliveryId: string;
108
+ audience: "admin" | "owner";
109
+ kind: "escalated" | "opened" | "resolved";
110
+ }) => Promise<boolean>;
111
+ deliveries: (limit?: number) => Promise<VulnerabilityAlertDeliveryRow[]>;
112
+ events: (limit?: number) => Promise<VulnerabilityAlertEventRow[]>;
113
+ failDelivery: (input: {
114
+ alertId: string;
115
+ deliveryId: string;
116
+ error: string;
117
+ kind: "escalated" | "opened" | "resolved";
118
+ retryAt: string;
119
+ }) => Promise<boolean>;
120
+ incidents: (limit?: number) => Promise<VulnerabilityAlertIncidentRow[]>;
121
+ observe: (input: {
122
+ alert: VulnerabilityAlert;
123
+ policy: ActiveVulnerabilityAlertPolicy;
124
+ observedAt?: string;
125
+ }) => Promise<"observed" | "opened" | "reopened">;
126
+ processEscalations: (input: {
127
+ fallbackPolicy?: ActiveVulnerabilityAlertPolicy;
128
+ policies: ReadonlyMap<string, ActiveVulnerabilityAlertPolicy>;
129
+ tenantIds?: readonly string[];
130
+ }) => Promise<number>;
131
+ resolveInactive: (input: {
132
+ activeAlertIds: ReadonlySet<string>;
133
+ fallbackPolicy?: ActiveVulnerabilityAlertPolicy;
134
+ policies: ReadonlyMap<string, ActiveVulnerabilityAlertPolicy>;
135
+ resolvedAt?: string;
136
+ tenantIds?: readonly string[];
137
+ }) => Promise<number>;
138
+ };
139
+ export declare const vulnerabilityAlertPostgresSchemaSql: (tablePrefix?: string) => string;
140
+ export declare const createPostgresVulnerabilityAlertStores: (options: {
141
+ ensureSchema?: boolean;
142
+ sql: AlertPostgresTag;
143
+ tablePrefix?: string;
144
+ }) => {
145
+ alertIncidents: VulnerabilityAlertIncidentStore;
146
+ alertPolicies: VulnerabilityAlertPolicyStore;
147
+ ensureAlertSchema: () => Promise<void>;
148
+ };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import { type FeedSnapshotStore, type FeedSyncRunStore, type ManagedFindingStore, type RemediationExecutionStore, type RemediationPlanStore, type RemediationVerificationStore, type VulnerabilityObservationStore, type VulnerabilityRiskAssessmentStore, type VexDecisionStore, type VexFindingApplicationStore } from "@absolutejs/vulnerabilities";
2
- export type PostgresTag = {
3
- (strings: TemplateStringsArray, ...values: never[]): PromiseLike<unknown[]>;
4
- unsafe: (sql: string, ...args: never[]) => PromiseLike<unknown[]>;
5
- };
2
+ import { type AlertPostgresTag, type VulnerabilityAlertIncidentStore, type VulnerabilityAlertPolicyStore } from "./alerts";
3
+ export * from "./alerts";
4
+ export type PostgresTag = AlertPostgresTag;
6
5
  export type FeedLeaseRequest = {
7
6
  feedId: string;
8
7
  now?: Date;
@@ -14,6 +13,8 @@ export type FeedLeaseStore = {
14
13
  release: (feedId: string, ownerId: string) => Promise<boolean>;
15
14
  };
16
15
  export type PostgresVulnerabilityStore = {
16
+ alertIncidents: VulnerabilityAlertIncidentStore;
17
+ alertPolicies: VulnerabilityAlertPolicyStore;
17
18
  ensureSchema: () => Promise<void>;
18
19
  findings: ManagedFindingStore;
19
20
  leases: FeedLeaseStore;
package/dist/index.js CHANGED
@@ -11,7 +11,328 @@ import {
11
11
  VexFindingApplicationSchema
12
12
  } from "@absolutejs/vulnerabilities";
13
13
  import { Value } from "@sinclair/typebox/value";
14
+
15
+ // src/alerts.ts
16
+ import {
17
+ resolveVulnerabilityAlertAudiences,
18
+ validateVulnerabilityAlertConfiguration
19
+ } from "@absolutejs/vulnerabilities";
20
+
21
+ class VulnerabilityAlertConflictError extends Error {
22
+ }
23
+
24
+ class VulnerabilityAlertNotFoundError extends Error {
25
+ }
14
26
  var IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
27
+ var tables = (prefix) => ({
28
+ deliveries: `${prefix}_alert_deliveries`,
29
+ events: `${prefix}_alert_events`,
30
+ incidents: `${prefix}_alert_incidents`,
31
+ policies: `${prefix}_alert_policy_versions`
32
+ });
33
+ var boundedLimit = (value = 1000) => {
34
+ if (!Number.isInteger(value) || value < 1 || value > 1000)
35
+ throw new Error("Alert query limit must be an integer from 1 through 1000");
36
+ return value;
37
+ };
38
+ var transaction = async (sql, callback) => {
39
+ if (!sql.begin)
40
+ throw new Error("Vulnerability alert persistence requires a transaction-capable Postgres tag");
41
+ return sql.begin(callback);
42
+ };
43
+ var tenantPredicate = (tenantIds) => tenantIds && tenantIds.length > 0 ? JSON.stringify(tenantIds) : null;
44
+ var vulnerabilityAlertPostgresSchemaSql = (tablePrefix = "vulnerability") => {
45
+ if (!IDENTIFIER.test(tablePrefix))
46
+ throw new Error(`[vulnerabilities-postgres] invalid tablePrefix "${tablePrefix}"; must match ${IDENTIFIER.source}`);
47
+ const table = tables(tablePrefix);
48
+ return `
49
+ CREATE TABLE IF NOT EXISTS ${table.policies} (
50
+ activated_at timestamptz NOT NULL,
51
+ activated_by uuid NOT NULL,
52
+ configuration jsonb NOT NULL,
53
+ reason text NOT NULL,
54
+ status text NOT NULL CHECK (status IN ('active', 'superseded')),
55
+ tenant_id text NOT NULL,
56
+ version integer NOT NULL,
57
+ PRIMARY KEY (tenant_id, version)
58
+ );
59
+ CREATE UNIQUE INDEX IF NOT EXISTS ${table.policies}_active_idx ON ${table.policies} (tenant_id) WHERE status = 'active';
60
+ CREATE INDEX IF NOT EXISTS ${table.policies}_history_idx ON ${table.policies} (tenant_id, activated_at);
61
+ CREATE TABLE IF NOT EXISTS ${table.incidents} (
62
+ acknowledged_at timestamptz,
63
+ acknowledged_by uuid,
64
+ alert_id text PRIMARY KEY,
65
+ asset_id text,
66
+ body text NOT NULL,
67
+ due_at timestamptz,
68
+ finding_id text,
69
+ first_observed_at timestamptz NOT NULL,
70
+ kind text NOT NULL,
71
+ last_observed_at timestamptz NOT NULL,
72
+ next_escalation_at timestamptz,
73
+ observation_count integer NOT NULL DEFAULT 1,
74
+ occurrence_count integer NOT NULL DEFAULT 1,
75
+ plan_id text,
76
+ policy_version integer,
77
+ resolved_at timestamptz,
78
+ severity text NOT NULL,
79
+ source_id text,
80
+ status text NOT NULL DEFAULT 'open' CHECK (status IN ('acknowledged', 'open', 'resolved')),
81
+ tenant_id text NOT NULL,
82
+ title text NOT NULL,
83
+ updated_at timestamptz NOT NULL DEFAULT now(),
84
+ value jsonb NOT NULL
85
+ );
86
+ CREATE INDEX IF NOT EXISTS ${table.incidents}_status_idx ON ${table.incidents} (status, last_observed_at);
87
+ CREATE INDEX IF NOT EXISTS ${table.incidents}_tenant_idx ON ${table.incidents} (tenant_id, status);
88
+ CREATE INDEX IF NOT EXISTS ${table.incidents}_escalation_idx ON ${table.incidents} (status, next_escalation_at);
89
+ CREATE TABLE IF NOT EXISTS ${table.events} (
90
+ actor_id uuid,
91
+ alert_id text NOT NULL REFERENCES ${table.incidents}(alert_id) ON DELETE CASCADE,
92
+ created_at timestamptz NOT NULL DEFAULT now(),
93
+ id bigserial PRIMARY KEY,
94
+ kind text NOT NULL,
95
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb
96
+ );
97
+ CREATE INDEX IF NOT EXISTS ${table.events}_alert_idx ON ${table.events} (alert_id, created_at);
98
+ CREATE TABLE IF NOT EXISTS ${table.deliveries} (
99
+ alert_id text NOT NULL REFERENCES ${table.incidents}(alert_id) ON DELETE CASCADE,
100
+ attempt_count integer NOT NULL DEFAULT 0,
101
+ audience text NOT NULL CHECK (audience IN ('admin', 'owner')),
102
+ created_at timestamptz NOT NULL DEFAULT now(),
103
+ delivered_at timestamptz,
104
+ id uuid PRIMARY KEY,
105
+ idempotency_key text NOT NULL,
106
+ kind text NOT NULL CHECK (kind IN ('escalated', 'opened', 'resolved')),
107
+ last_error text,
108
+ lease_expires_at timestamptz,
109
+ next_attempt_at timestamptz NOT NULL DEFAULT now(),
110
+ state text NOT NULL DEFAULT 'pending' CHECK (state IN ('delivered', 'delivering', 'dismissed', 'failed', 'pending')),
111
+ updated_at timestamptz NOT NULL DEFAULT now()
112
+ );
113
+ CREATE UNIQUE INDEX IF NOT EXISTS ${table.deliveries}_key_idx ON ${table.deliveries} (idempotency_key);
114
+ CREATE INDEX IF NOT EXISTS ${table.deliveries}_state_idx ON ${table.deliveries} (state, next_attempt_at);
115
+ `;
116
+ };
117
+ var createPostgresVulnerabilityAlertStores = (options) => {
118
+ const tablePrefix = options.tablePrefix ?? "vulnerability";
119
+ const table = tables(tablePrefix);
120
+ const ddl = vulnerabilityAlertPostgresSchemaSql(tablePrefix);
121
+ const sql = options.sql;
122
+ let schema;
123
+ const ensureAlertSchema = async () => {
124
+ schema ??= Promise.resolve(sql.unsafe(ddl)).then(() => {
125
+ return;
126
+ });
127
+ await schema;
128
+ };
129
+ const ready = async () => {
130
+ if (options.ensureSchema !== false)
131
+ await ensureAlertSchema();
132
+ };
133
+ const queue = async (tx, alert, configuration, kind, occurrence) => {
134
+ const audiences = resolveVulnerabilityAlertAudiences({
135
+ configuration,
136
+ hasOwner: alert.assetId !== null,
137
+ kind,
138
+ severity: alert.severity
139
+ });
140
+ for (const audience of audiences)
141
+ await tx`
142
+ INSERT INTO ${tx.unsafe(table.deliveries)} (
143
+ id, alert_id, audience, idempotency_key, kind
144
+ ) VALUES (
145
+ ${crypto.randomUUID()}, ${alert.id}, ${audience},
146
+ ${`${alert.id}:${audience}:${kind}:${occurrence}`}, ${kind}
147
+ ) ON CONFLICT (idempotency_key) DO NOTHING
148
+ `;
149
+ };
150
+ const alertPolicies = {
151
+ activate: async (input) => {
152
+ await ready();
153
+ const configuration = validateVulnerabilityAlertConfiguration(input.configuration);
154
+ const reason = input.reason.trim();
155
+ if (!reason)
156
+ throw new Error("Policy activation reason is required");
157
+ return transaction(sql, async (tx) => {
158
+ await tx`SELECT pg_advisory_xact_lock(hashtext(${`vulnerability-alert-policy:${input.tenantId}`}))`;
159
+ const [latest] = await tx`SELECT COALESCE(MAX(version), 0)::integer AS version FROM ${tx.unsafe(table.policies)} WHERE tenant_id = ${input.tenantId}`;
160
+ const version = (latest?.version ?? 0) + 1;
161
+ await tx`UPDATE ${tx.unsafe(table.policies)} SET status = 'superseded' WHERE tenant_id = ${input.tenantId} AND status = 'active'`;
162
+ await tx`INSERT INTO ${tx.unsafe(table.policies)} (activated_at, activated_by, configuration, reason, status, tenant_id, version) VALUES (${input.activatedAt}, ${input.activatedBy}, ${JSON.stringify(configuration)}::jsonb, ${reason}, 'active', ${input.tenantId}, ${version})`;
163
+ return {
164
+ activatedAt: input.activatedAt,
165
+ configuration,
166
+ reason,
167
+ tenantId: input.tenantId,
168
+ version
169
+ };
170
+ });
171
+ },
172
+ active: async () => {
173
+ await ready();
174
+ const rows = await sql`SELECT configuration, tenant_id, version FROM ${sql.unsafe(table.policies)} WHERE status = 'active'`;
175
+ return rows.map((row) => ({
176
+ configuration: validateVulnerabilityAlertConfiguration(row.configuration),
177
+ tenantId: row.tenant_id,
178
+ version: row.version
179
+ }));
180
+ },
181
+ list: async (requestedLimit) => {
182
+ await ready();
183
+ return sql`SELECT * FROM ${sql.unsafe(table.policies)} ORDER BY activated_at DESC LIMIT ${boundedLimit(requestedLimit)}`;
184
+ }
185
+ };
186
+ const observe = async ({
187
+ alert,
188
+ policy,
189
+ observedAt
190
+ }) => {
191
+ await ready();
192
+ const at = observedAt ?? new Date().toISOString();
193
+ const configuration = validateVulnerabilityAlertConfiguration(policy.configuration);
194
+ return transaction(sql, async (tx) => {
195
+ const [existing] = await tx`SELECT occurrence_count, status FROM ${tx.unsafe(table.incidents)} WHERE alert_id = ${alert.id} FOR UPDATE`;
196
+ const reopened = existing?.status === "resolved";
197
+ const nextEscalationAt = new Date(new Date(at).getTime() + configuration.escalationAfterMs[alert.severity]).toISOString();
198
+ await tx`INSERT INTO ${tx.unsafe(table.incidents)} (alert_id, asset_id, body, due_at, finding_id, first_observed_at, kind, last_observed_at, next_escalation_at, plan_id, policy_version, severity, source_id, status, tenant_id, title, value) VALUES (${alert.id}, ${alert.assetId}, ${alert.body}, ${alert.dueAt}, ${alert.findingId}, ${alert.observedAt}, ${alert.kind}, ${alert.observedAt}, ${nextEscalationAt}, ${alert.planId}, ${policy.version}, ${alert.severity}, ${alert.sourceId}, 'open', ${alert.tenantId}, ${alert.title}, ${JSON.stringify(alert)}::jsonb) ON CONFLICT (alert_id) DO UPDATE SET asset_id = EXCLUDED.asset_id, body = EXCLUDED.body, due_at = EXCLUDED.due_at, finding_id = EXCLUDED.finding_id, last_observed_at = EXCLUDED.last_observed_at, observation_count = ${tx.unsafe(table.incidents)}.observation_count + 1, occurrence_count = CASE WHEN ${tx.unsafe(table.incidents)}.status = 'resolved' THEN ${tx.unsafe(table.incidents)}.occurrence_count + 1 ELSE ${tx.unsafe(table.incidents)}.occurrence_count END, plan_id = EXCLUDED.plan_id, policy_version = EXCLUDED.policy_version, severity = EXCLUDED.severity, source_id = EXCLUDED.source_id, tenant_id = EXCLUDED.tenant_id, title = EXCLUDED.title, updated_at = now(), value = EXCLUDED.value, status = CASE WHEN ${tx.unsafe(table.incidents)}.status = 'resolved' THEN 'open' ELSE ${tx.unsafe(table.incidents)}.status END, resolved_at = CASE WHEN ${tx.unsafe(table.incidents)}.status = 'resolved' THEN null ELSE ${tx.unsafe(table.incidents)}.resolved_at END, next_escalation_at = CASE WHEN ${tx.unsafe(table.incidents)}.status = 'resolved' THEN EXCLUDED.next_escalation_at WHEN ${tx.unsafe(table.incidents)}.status = 'open' AND ${tx.unsafe(table.incidents)}.policy_version IS DISTINCT FROM EXCLUDED.policy_version THEN EXCLUDED.next_escalation_at ELSE ${tx.unsafe(table.incidents)}.next_escalation_at END`;
199
+ if (!existing || reopened) {
200
+ await tx`INSERT INTO ${tx.unsafe(table.events)} (alert_id, kind) VALUES (${alert.id}, 'opened')`;
201
+ await queue(tx, alert, configuration, "opened", existing ? existing.occurrence_count + 1 : 1);
202
+ }
203
+ return !existing ? "opened" : reopened ? "reopened" : "observed";
204
+ });
205
+ };
206
+ const alertIncidents = {
207
+ acknowledge: async (input) => {
208
+ await ready();
209
+ return transaction(sql, async (tx) => {
210
+ const [present] = await tx`SELECT status FROM ${tx.unsafe(table.incidents)} WHERE alert_id = ${input.alertId} FOR UPDATE`;
211
+ if (!present)
212
+ throw new VulnerabilityAlertNotFoundError("Vulnerability alert not found");
213
+ if (present.status === "resolved")
214
+ throw new VulnerabilityAlertConflictError("Resolved vulnerability alerts cannot be acknowledged");
215
+ if (present.status === "acknowledged")
216
+ return false;
217
+ await tx`UPDATE ${tx.unsafe(table.incidents)} SET acknowledged_at = ${input.acknowledgedAt}, acknowledged_by = ${input.acknowledgedBy}, next_escalation_at = null, status = 'acknowledged', updated_at = ${input.acknowledgedAt} WHERE alert_id = ${input.alertId}`;
218
+ await tx`INSERT INTO ${tx.unsafe(table.events)} (actor_id, alert_id, kind) VALUES (${input.acknowledgedBy}, ${input.alertId}, 'acknowledged')`;
219
+ return true;
220
+ });
221
+ },
222
+ claimDeliveries: async (input = {}) => {
223
+ await ready();
224
+ const leaseMs = input.leaseMs ?? 300000;
225
+ const queryLimit = boundedLimit(input.limit ?? 100);
226
+ const tenants = tenantPredicate(input.tenantIds);
227
+ return transaction(sql, async (tx) => {
228
+ const rows = await tx`SELECT d.id FROM ${tx.unsafe(table.deliveries)} d JOIN ${tx.unsafe(table.incidents)} i ON i.alert_id = d.alert_id WHERE (((d.state IN ('pending', 'failed') AND d.next_attempt_at <= now()) OR (d.state = 'delivering' AND d.lease_expires_at <= now()))) AND (${tenants}::jsonb IS NULL OR i.tenant_id IN (SELECT jsonb_array_elements_text(${tenants}::jsonb))) ORDER BY d.created_at FOR UPDATE OF d SKIP LOCKED LIMIT ${queryLimit}`;
229
+ const ids = rows.map(({ id }) => id);
230
+ if (ids.length === 0)
231
+ return [];
232
+ const encoded = JSON.stringify(ids);
233
+ return tx`UPDATE ${tx.unsafe(table.deliveries)} d SET attempt_count = attempt_count + 1, lease_expires_at = now() + ${leaseMs} * INTERVAL '1 millisecond', state = 'delivering', updated_at = now() FROM ${tx.unsafe(table.incidents)} i WHERE d.alert_id = i.alert_id AND d.id::text IN (SELECT jsonb_array_elements_text(${encoded}::jsonb)) RETURNING d.*, i.asset_id, i.body, i.severity, i.tenant_id, i.title`;
234
+ });
235
+ },
236
+ completeDelivery: async (input) => {
237
+ await ready();
238
+ return transaction(sql, async (tx) => {
239
+ const updated = await tx`UPDATE ${tx.unsafe(table.deliveries)} SET delivered_at = now(), last_error = null, lease_expires_at = null, state = 'delivered', updated_at = now() WHERE id = ${input.deliveryId} AND state = 'delivering' RETURNING id`;
240
+ if (updated.length === 0)
241
+ return false;
242
+ await tx`INSERT INTO ${tx.unsafe(table.events)} (alert_id, kind, metadata) VALUES (${input.alertId}, 'delivered', ${JSON.stringify({ audience: input.audience, kind: input.kind })}::jsonb)`;
243
+ return true;
244
+ });
245
+ },
246
+ deliveries: async (requestedLimit) => {
247
+ await ready();
248
+ return sql`SELECT * FROM ${sql.unsafe(table.deliveries)} ORDER BY created_at DESC LIMIT ${boundedLimit(requestedLimit)}`;
249
+ },
250
+ events: async (requestedLimit) => {
251
+ await ready();
252
+ return sql`SELECT * FROM ${sql.unsafe(table.events)} ORDER BY created_at DESC LIMIT ${boundedLimit(requestedLimit)}`;
253
+ },
254
+ failDelivery: async (input) => {
255
+ await ready();
256
+ return transaction(sql, async (tx) => {
257
+ const updated = await tx`UPDATE ${tx.unsafe(table.deliveries)} SET last_error = ${input.error}, lease_expires_at = null, next_attempt_at = ${input.retryAt}, state = 'failed', updated_at = now() WHERE id = ${input.deliveryId} AND state = 'delivering' RETURNING id`;
258
+ if (updated.length === 0)
259
+ return false;
260
+ await tx`INSERT INTO ${tx.unsafe(table.events)} (alert_id, kind, metadata) VALUES (${input.alertId}, 'delivery_failed', ${JSON.stringify({ error: input.error, kind: input.kind })}::jsonb)`;
261
+ return true;
262
+ });
263
+ },
264
+ incidents: async (requestedLimit) => {
265
+ await ready();
266
+ return sql`SELECT * FROM ${sql.unsafe(table.incidents)} ORDER BY last_observed_at DESC LIMIT ${boundedLimit(requestedLimit)}`;
267
+ },
268
+ observe,
269
+ processEscalations: async ({ fallbackPolicy, policies, tenantIds }) => {
270
+ await ready();
271
+ const tenants = tenantPredicate(tenantIds);
272
+ const due = await sql`SELECT alert_id, asset_id, occurrence_count, severity, tenant_id FROM ${sql.unsafe(table.incidents)} WHERE status = 'open' AND next_escalation_at <= now() AND (${tenants}::jsonb IS NULL OR tenant_id IN (SELECT jsonb_array_elements_text(${tenants}::jsonb)))`;
273
+ let processed = 0;
274
+ for (const incident of due) {
275
+ const policy = policies.get(incident.tenant_id) ?? fallbackPolicy;
276
+ if (!policy)
277
+ continue;
278
+ const changed = await transaction(sql, async (tx) => {
279
+ const claimed = await tx`UPDATE ${tx.unsafe(table.incidents)} SET next_escalation_at = null, updated_at = now() WHERE alert_id = ${incident.alert_id} AND status = 'open' AND next_escalation_at <= now() RETURNING alert_id`;
280
+ if (claimed.length === 0)
281
+ return false;
282
+ await tx`INSERT INTO ${tx.unsafe(table.events)} (alert_id, kind) VALUES (${incident.alert_id}, 'escalated')`;
283
+ await queue(tx, {
284
+ assetId: incident.asset_id,
285
+ id: incident.alert_id,
286
+ severity: incident.severity
287
+ }, policy.configuration, "escalated", incident.occurrence_count);
288
+ return true;
289
+ });
290
+ if (changed)
291
+ processed += 1;
292
+ }
293
+ return processed;
294
+ },
295
+ resolveInactive: async ({
296
+ activeAlertIds,
297
+ fallbackPolicy,
298
+ policies,
299
+ resolvedAt,
300
+ tenantIds
301
+ }) => {
302
+ await ready();
303
+ const tenants = tenantPredicate(tenantIds);
304
+ const open = await sql`SELECT alert_id, asset_id, occurrence_count, severity, tenant_id FROM ${sql.unsafe(table.incidents)} WHERE status IN ('open', 'acknowledged') AND (${tenants}::jsonb IS NULL OR tenant_id IN (SELECT jsonb_array_elements_text(${tenants}::jsonb)))`;
305
+ const at = resolvedAt ?? new Date().toISOString();
306
+ let processed = 0;
307
+ for (const incident of open) {
308
+ if (activeAlertIds.has(incident.alert_id))
309
+ continue;
310
+ const policy = policies.get(incident.tenant_id) ?? fallbackPolicy;
311
+ if (!policy)
312
+ continue;
313
+ const changed = await transaction(sql, async (tx) => {
314
+ const resolved = await tx`UPDATE ${tx.unsafe(table.incidents)} SET next_escalation_at = null, resolved_at = ${at}, status = 'resolved', updated_at = now() WHERE alert_id = ${incident.alert_id} AND status IN ('open', 'acknowledged') RETURNING alert_id`;
315
+ if (resolved.length === 0)
316
+ return false;
317
+ await tx`INSERT INTO ${tx.unsafe(table.events)} (alert_id, kind) VALUES (${incident.alert_id}, 'resolved')`;
318
+ await queue(tx, {
319
+ assetId: incident.asset_id,
320
+ id: incident.alert_id,
321
+ severity: incident.severity
322
+ }, policy.configuration, "resolved", incident.occurrence_count);
323
+ return true;
324
+ });
325
+ if (changed)
326
+ processed += 1;
327
+ }
328
+ return processed;
329
+ }
330
+ };
331
+ return { alertIncidents, alertPolicies, ensureAlertSchema };
332
+ };
333
+
334
+ // src/index.ts
335
+ var IDENTIFIER2 = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
15
336
  var SYNC_STATUSES = new Set([
16
337
  "failed",
17
338
  "not_modified",
@@ -75,10 +396,10 @@ var prefixTables = (prefix) => ({
75
396
  vexDecisions: `${prefix}_vex_decisions`
76
397
  });
77
398
  var vulnerabilityPostgresSchemaSql = (tablePrefix = "vulnerability") => {
78
- if (!IDENTIFIER.test(tablePrefix))
79
- throw new Error(`[vulnerabilities-postgres] invalid tablePrefix "${tablePrefix}"; must match ${IDENTIFIER.source}`);
399
+ if (!IDENTIFIER2.test(tablePrefix))
400
+ throw new Error(`[vulnerabilities-postgres] invalid tablePrefix "${tablePrefix}"; must match ${IDENTIFIER2.source}`);
80
401
  const table = prefixTables(tablePrefix);
81
- return `
402
+ return `${vulnerabilityAlertPostgresSchemaSql(tablePrefix)}
82
403
  CREATE TABLE IF NOT EXISTS ${table.snapshots} (
83
404
  feed_id text PRIMARY KEY,
84
405
  feed_name text NOT NULL,
@@ -238,6 +559,11 @@ var createPostgresVulnerabilityStore = (options) => {
238
559
  const table = prefixTables(tablePrefix);
239
560
  const sql = options.sql;
240
561
  const shouldEnsureSchema = options.ensureSchema ?? true;
562
+ const alertStores = createPostgresVulnerabilityAlertStores({
563
+ ensureSchema: shouldEnsureSchema,
564
+ sql: options.sql,
565
+ tablePrefix
566
+ });
241
567
  let schemaPromise;
242
568
  const ensureSchema = () => {
243
569
  if (!shouldEnsureSchema)
@@ -952,6 +1278,8 @@ var createPostgresVulnerabilityStore = (options) => {
952
1278
  }
953
1279
  };
954
1280
  return {
1281
+ alertIncidents: alertStores.alertIncidents,
1282
+ alertPolicies: alertStores.alertPolicies,
955
1283
  ensureSchema,
956
1284
  findings,
957
1285
  leases,
@@ -968,5 +1296,9 @@ var createPostgresVulnerabilityStore = (options) => {
968
1296
  };
969
1297
  export {
970
1298
  vulnerabilityPostgresSchemaSql,
971
- createPostgresVulnerabilityStore
1299
+ vulnerabilityAlertPostgresSchemaSql,
1300
+ createPostgresVulnerabilityStore,
1301
+ createPostgresVulnerabilityAlertStores,
1302
+ VulnerabilityAlertNotFoundError,
1303
+ VulnerabilityAlertConflictError
972
1304
  };
package/dist/manifest.js CHANGED
@@ -9,7 +9,7 @@ var manifest = defineManifest()({
9
9
  intents: [
10
10
  "persist vulnerability intelligence",
11
11
  "retain vulnerability sync history",
12
- "store managed vulnerability findings, VEX decisions, remediation evidence, and risk assessments"
12
+ "store managed vulnerability findings, alert incidents, policy history, notification deliveries, VEX decisions, remediation evidence, and risk assessments"
13
13
  ],
14
14
  keywords: ["vulnerabilities", "Postgres", "CVE", "feed-history"],
15
15
  protocols: ["PostgreSQL", "AbsoluteJS vulnerability contracts"]
@@ -17,7 +17,7 @@ var manifest = defineManifest()({
17
17
  identity: {
18
18
  accent: "#336791",
19
19
  category: "operations",
20
- description: "Durable Postgres snapshots, findings, VEX decisions, remediation evidence, risk assessments, and distributed refresh leases.",
20
+ description: "Durable Postgres snapshots, findings, alert policy history, incident timelines, notification deliveries, remediation evidence, and risk assessments.",
21
21
  docsUrl: "https://github.com/absolutejs/vulnerabilities-postgres",
22
22
  name: "@absolutejs/vulnerabilities-postgres",
23
23
  tagline: "Keep vulnerability intelligence durable and auditable."
@@ -8,7 +8,7 @@
8
8
  "intents": [
9
9
  "persist vulnerability intelligence",
10
10
  "retain vulnerability sync history",
11
- "store managed vulnerability findings, VEX decisions, remediation evidence, and risk assessments"
11
+ "store managed vulnerability findings, alert incidents, policy history, notification deliveries, VEX decisions, remediation evidence, and risk assessments"
12
12
  ],
13
13
  "keywords": [
14
14
  "vulnerabilities",
@@ -24,7 +24,7 @@
24
24
  "identity": {
25
25
  "accent": "#336791",
26
26
  "category": "operations",
27
- "description": "Durable Postgres snapshots, findings, VEX decisions, remediation evidence, risk assessments, and distributed refresh leases.",
27
+ "description": "Durable Postgres snapshots, findings, alert policy history, incident timelines, notification deliveries, remediation evidence, and risk assessments.",
28
28
  "docsUrl": "https://github.com/absolutejs/vulnerabilities-postgres",
29
29
  "name": "@absolutejs/vulnerabilities-postgres",
30
30
  "tagline": "Keep vulnerability intelligence durable and auditable."
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@absolutejs/vulnerabilities-postgres",
3
- "version": "0.5.3",
4
- "description": "Durable Postgres feeds, findings, VEX decisions, remediation evidence, risk assessments, and refresh leases for AbsoluteJS vulnerability management.",
3
+ "version": "0.6.1",
4
+ "description": "Durable Postgres feeds, findings, alert incidents, policy history, delivery queues, remediation evidence, and risk assessments for AbsoluteJS vulnerability management.",
5
5
  "type": "module",
6
6
  "license": "BSL-1.1",
7
7
  "author": "Alex Kahn",