@absolutejs/vulnerabilities-postgres 0.1.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/LICENSE ADDED
@@ -0,0 +1,91 @@
1
+ # Business Source License 1.1
2
+
3
+ **Licensor:** Alex Kahn
4
+
5
+ **Licensed Work:** @absolutejs/vulnerabilities-postgres (https://github.com/absolutejs/vulnerabilities-postgres)
6
+
7
+ **Change Date:** July 18, 2030
8
+
9
+ **Change License:** Apache License, Version 2.0
10
+
11
+ ---
12
+
13
+ ## Terms
14
+
15
+ The Licensor hereby grants you the right to copy, modify, create derivative
16
+ works, redistribute, and make non-production use of the Licensed Work. The
17
+ Licensor may make an Additional Use Grant, permitting limited production use.
18
+
19
+ ### Additional Use Grant
20
+
21
+ You may use the Licensed Work in production, provided your use does not include
22
+ any of the following:
23
+
24
+ 1. **Offering a Competing Service.** You may not offer the Licensed Work, or
25
+ any derivative or substantial portion of it, to third parties as a hosted or
26
+ managed vulnerability-management, exposure-management, container-security,
27
+ cloud-security-posture-management, or attack-surface-management service.
28
+ This includes any product whose primary value to its users is the
29
+ functionality the Licensed Work provides.
30
+
31
+ 2. **Resale or Redistribution as a Standalone Product.** You may not sell,
32
+ license, or distribute the Licensed Work, or any derivative or fork of it,
33
+ as a standalone commercial product.
34
+
35
+ 3. **Removal of Attribution.** Any derivative work, fork, or redistribution of
36
+ the Licensed Work must prominently credit AbsoluteJS and include a link to
37
+ the original project repository
38
+ (https://github.com/absolutejs/vulnerabilities-postgres).
39
+
40
+ For clarity, the following uses are expressly permitted:
41
+
42
+ - Using the Licensed Work to scan, inventory, gate, remediate, or report on
43
+ vulnerabilities in your own applications, websites, internal tools, or SaaS
44
+ products, so long as vulnerability management itself is not the primary
45
+ product you are selling.
46
+ - Using the Licensed Work as a dependency in commercial software you build and
47
+ sell, as long as that software is not a competing hosted security service of
48
+ the kind described in clause 1.
49
+ - Providing consulting, development, or professional services to clients using
50
+ the Licensed Work.
51
+ - Forking and modifying the Licensed Work for your own internal use, provided
52
+ attribution is maintained.
53
+
54
+ ### Change Date and Change License
55
+
56
+ On the Change Date specified above, or on such other date as the Licensor may
57
+ specify by written notice, the Licensed Work will be made available under the
58
+ Change License (Apache License, Version 2.0). Until the Change Date, the terms
59
+ of this Business Source License 1.1 apply.
60
+
61
+ ### Trademark
62
+
63
+ This license does not grant you any rights to use the "AbsoluteJS" or
64
+ "@absolutejs" name, logo, or any related trademarks. Forks and derivative works
65
+ must not be named or branded in a manner that suggests endorsement by or
66
+ affiliation with AbsoluteJS or the Licensor.
67
+
68
+ ### Notices
69
+
70
+ You must not remove or obscure any licensing, copyright, or other notices
71
+ included in the Licensed Work.
72
+
73
+ ### No Warranty
74
+
75
+ THE LICENSED WORK IS PROVIDED "AS IS". THE LICENSOR HEREBY DISCLAIMS ALL
76
+ WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
77
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO
78
+ EVENT SHALL THE LICENSOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY,
79
+ WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR
80
+ IN CONNECTION WITH THE LICENSED WORK OR THE USE OR OTHER DEALINGS IN THE
81
+ LICENSED WORK.
82
+
83
+ ---
84
+
85
+ ## Contact
86
+
87
+ For commercial licensing inquiries or additional permissions, contact:
88
+
89
+ - **Alex Kahn**
90
+ - alexkahndev@gmail.com
91
+ - alexkahndev.github.io
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @absolutejs/vulnerabilities-postgres
2
+
3
+ Durable Postgres persistence for `@absolutejs/vulnerabilities`.
4
+
5
+ ```ts
6
+ import { createPostgresVulnerabilityStore } from "@absolutejs/vulnerabilities-postgres";
7
+ import postgres from "postgres";
8
+
9
+ const persistence = createPostgresVulnerabilityStore({
10
+ sql: postgres(process.env.DATABASE_URL!),
11
+ });
12
+
13
+ const osvSnapshots = persistence.snapshots();
14
+ ```
15
+
16
+ The package stores complete provider snapshots and records, appendable sync
17
+ history, tenant-scoped managed findings, and expiring distributed refresh
18
+ leases. Snapshot replacement is one Postgres statement, so readers never see a
19
+ half-replaced record set. Schema creation is lazy and idempotent, or can be
20
+ disabled when application migrations own the tables.
21
+
22
+ The SQL surface is compatible with postgres.js and Neon-style tagged-template
23
+ clients. Table prefixes are strictly validated before identifiers are included
24
+ in SQL.
@@ -0,0 +1,29 @@
1
+ import { type FeedSnapshotStore, type FeedSyncRunStore, type ManagedFindingStore } from "@absolutejs/vulnerabilities";
2
+ export type PostgresTag = {
3
+ (strings: TemplateStringsArray, ...values: never[]): PromiseLike<unknown[]>;
4
+ unsafe: (sql: string, ...args: never[]) => PromiseLike<unknown[]>;
5
+ };
6
+ export type FeedLeaseRequest = {
7
+ feedId: string;
8
+ now?: Date;
9
+ ownerId: string;
10
+ ttlMs: number;
11
+ };
12
+ export type FeedLeaseStore = {
13
+ acquire: (request: FeedLeaseRequest) => Promise<boolean>;
14
+ release: (feedId: string, ownerId: string) => Promise<boolean>;
15
+ };
16
+ export type PostgresVulnerabilityStore = {
17
+ ensureSchema: () => Promise<void>;
18
+ findings: ManagedFindingStore;
19
+ leases: FeedLeaseStore;
20
+ snapshots: <T>() => FeedSnapshotStore<T>;
21
+ syncRuns: FeedSyncRunStore;
22
+ };
23
+ export type CreatePostgresVulnerabilityStoreOptions = {
24
+ ensureSchema?: boolean;
25
+ sql: PostgresTag;
26
+ tablePrefix?: string;
27
+ };
28
+ export declare const vulnerabilityPostgresSchemaSql: (tablePrefix?: string) => string;
29
+ export declare const createPostgresVulnerabilityStore: (options: CreatePostgresVulnerabilityStoreOptions) => PostgresVulnerabilityStore;
package/dist/index.js ADDED
@@ -0,0 +1,423 @@
1
+ // @bun
2
+ // src/index.ts
3
+ import {
4
+ ManagedVulnerabilityFindingSchema
5
+ } from "@absolutejs/vulnerabilities";
6
+ import { Value } from "@sinclair/typebox/value";
7
+ var IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
8
+ var SYNC_STATUSES = new Set([
9
+ "failed",
10
+ "not_modified",
11
+ "stale",
12
+ "updated"
13
+ ]);
14
+ var requiredText = (value, label) => {
15
+ const normalized = value.trim();
16
+ if (normalized.length === 0)
17
+ throw new Error(`${label} is required`);
18
+ return normalized;
19
+ };
20
+ var limit = (value, fallback = 100) => {
21
+ const normalized = value ?? fallback;
22
+ if (!Number.isInteger(normalized) || normalized < 1 || normalized > 1000)
23
+ throw new Error("Postgres query limit must be an integer from 1 through 1000");
24
+ return normalized;
25
+ };
26
+ var timestamp = (value, label) => {
27
+ if (value instanceof Date)
28
+ return value.toISOString();
29
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value)))
30
+ throw new Error(`${label} must be a valid timestamp`);
31
+ return new Date(value).toISOString();
32
+ };
33
+ var json = (value, label) => {
34
+ if (typeof value === "string") {
35
+ try {
36
+ return JSON.parse(value);
37
+ } catch {
38
+ throw new Error(`${label} must contain valid JSON`);
39
+ }
40
+ }
41
+ return value;
42
+ };
43
+ var normalizeCursor = (value) => {
44
+ const cursor = json(value, "Feed cursor");
45
+ const nullable = (entry, label) => {
46
+ if (entry === null || typeof entry === "string")
47
+ return entry;
48
+ throw new Error(`${label} must be a string or null`);
49
+ };
50
+ return {
51
+ etag: nullable(cursor.etag, "Feed cursor etag"),
52
+ lastModified: nullable(cursor.lastModified, "Feed cursor lastModified"),
53
+ token: nullable(cursor.token, "Feed cursor token")
54
+ };
55
+ };
56
+ var prefixTables = (prefix) => ({
57
+ findings: `${prefix}_findings`,
58
+ leases: `${prefix}_feed_leases`,
59
+ records: `${prefix}_feed_records`,
60
+ runs: `${prefix}_feed_sync_runs`,
61
+ snapshots: `${prefix}_feed_snapshots`
62
+ });
63
+ var vulnerabilityPostgresSchemaSql = (tablePrefix = "vulnerability") => {
64
+ if (!IDENTIFIER.test(tablePrefix))
65
+ throw new Error(`[vulnerabilities-postgres] invalid tablePrefix "${tablePrefix}"; must match ${IDENTIFIER.source}`);
66
+ const table = prefixTables(tablePrefix);
67
+ return `
68
+ CREATE TABLE IF NOT EXISTS ${table.snapshots} (
69
+ feed_id text PRIMARY KEY,
70
+ feed_name text NOT NULL,
71
+ feed_url text NOT NULL,
72
+ cursor jsonb NOT NULL,
73
+ fetched_at timestamptz NOT NULL,
74
+ revision text,
75
+ updated_at timestamptz NOT NULL DEFAULT now()
76
+ );
77
+ CREATE TABLE IF NOT EXISTS ${table.records} (
78
+ feed_id text NOT NULL REFERENCES ${table.snapshots}(feed_id) ON DELETE CASCADE,
79
+ record_id text NOT NULL,
80
+ modified_at timestamptz NOT NULL,
81
+ value jsonb NOT NULL,
82
+ PRIMARY KEY (feed_id, record_id)
83
+ );
84
+ CREATE INDEX IF NOT EXISTS ${table.records}_modified_idx
85
+ ON ${table.records} (feed_id, modified_at DESC);
86
+ CREATE TABLE IF NOT EXISTS ${table.runs} (
87
+ id text PRIMARY KEY,
88
+ feed_id text NOT NULL,
89
+ started_at timestamptz NOT NULL,
90
+ completed_at timestamptz NOT NULL,
91
+ status text NOT NULL CHECK (status IN ('failed', 'not_modified', 'stale', 'updated')),
92
+ error text,
93
+ records integer NOT NULL CHECK (records >= 0),
94
+ revision text
95
+ );
96
+ CREATE INDEX IF NOT EXISTS ${table.runs}_feed_started_idx
97
+ ON ${table.runs} (feed_id, started_at DESC);
98
+ CREATE INDEX IF NOT EXISTS ${table.runs}_status_started_idx
99
+ ON ${table.runs} (status, started_at DESC);
100
+ CREATE TABLE IF NOT EXISTS ${table.findings} (
101
+ tenant_id text NOT NULL,
102
+ finding_id text NOT NULL,
103
+ asset_id text NOT NULL,
104
+ component_id text NOT NULL,
105
+ severity text NOT NULL,
106
+ status text NOT NULL,
107
+ first_seen_at timestamptz NOT NULL,
108
+ last_seen_at timestamptz NOT NULL,
109
+ value jsonb NOT NULL,
110
+ updated_at timestamptz NOT NULL DEFAULT now(),
111
+ PRIMARY KEY (tenant_id, finding_id)
112
+ );
113
+ CREATE INDEX IF NOT EXISTS ${table.findings}_tenant_status_idx
114
+ ON ${table.findings} (tenant_id, status, last_seen_at DESC);
115
+ CREATE INDEX IF NOT EXISTS ${table.findings}_tenant_severity_idx
116
+ ON ${table.findings} (tenant_id, severity, last_seen_at DESC);
117
+ CREATE INDEX IF NOT EXISTS ${table.findings}_tenant_asset_idx
118
+ ON ${table.findings} (tenant_id, asset_id, last_seen_at DESC);
119
+ CREATE TABLE IF NOT EXISTS ${table.leases} (
120
+ feed_id text PRIMARY KEY,
121
+ owner_id text NOT NULL,
122
+ expires_at timestamptz NOT NULL,
123
+ updated_at timestamptz NOT NULL DEFAULT now()
124
+ );
125
+ `;
126
+ };
127
+ var createPostgresVulnerabilityStore = (options) => {
128
+ const tablePrefix = options.tablePrefix ?? "vulnerability";
129
+ const ddl = vulnerabilityPostgresSchemaSql(tablePrefix);
130
+ const table = prefixTables(tablePrefix);
131
+ const sql = options.sql;
132
+ const shouldEnsureSchema = options.ensureSchema ?? true;
133
+ let schemaPromise;
134
+ const ensureSchema = () => {
135
+ if (!shouldEnsureSchema)
136
+ return Promise.resolve();
137
+ if (schemaPromise)
138
+ return schemaPromise;
139
+ schemaPromise = Promise.resolve(sql.unsafe(ddl)).then(() => {
140
+ return;
141
+ });
142
+ return schemaPromise;
143
+ };
144
+ const snapshots = () => ({
145
+ load: async (feedId) => {
146
+ await ensureSchema();
147
+ const id = requiredText(feedId, "Feed id");
148
+ const rows = await sql`
149
+ SELECT
150
+ snapshot.feed_id,
151
+ snapshot.feed_name,
152
+ snapshot.feed_url,
153
+ snapshot.cursor,
154
+ snapshot.fetched_at,
155
+ snapshot.revision,
156
+ COALESCE((
157
+ SELECT jsonb_agg(
158
+ jsonb_build_object(
159
+ 'id', record.record_id,
160
+ 'modifiedAt', record.modified_at,
161
+ 'value', record.value
162
+ ) ORDER BY record.record_id
163
+ )
164
+ FROM ${sql.unsafe(table.records)} record
165
+ WHERE record.feed_id = snapshot.feed_id
166
+ ), '[]'::jsonb) AS records
167
+ FROM ${sql.unsafe(table.snapshots)} snapshot
168
+ WHERE snapshot.feed_id = ${id}
169
+ `;
170
+ const row = rows[0];
171
+ if (!row)
172
+ return null;
173
+ const records = json(row.records, "Feed records");
174
+ if (!Array.isArray(records))
175
+ throw new Error("Feed records must be an array");
176
+ return {
177
+ cursor: normalizeCursor(row.cursor),
178
+ feed: {
179
+ id: row.feed_id,
180
+ name: row.feed_name,
181
+ url: row.feed_url
182
+ },
183
+ fetchedAt: timestamp(row.fetched_at, "Feed fetchedAt"),
184
+ records: records.map((record) => ({
185
+ id: requiredText(record.id, "Feed record id"),
186
+ modifiedAt: timestamp(record.modifiedAt, "Feed record modifiedAt"),
187
+ value: record.value
188
+ })),
189
+ revision: row.revision
190
+ };
191
+ },
192
+ save: async (snapshot) => {
193
+ await ensureSchema();
194
+ const records = JSON.stringify(snapshot.records.map((record) => ({
195
+ id: requiredText(record.id, "Feed record id"),
196
+ modified_at: timestamp(record.modifiedAt, "Feed record modifiedAt"),
197
+ value: record.value
198
+ })));
199
+ const cursor = JSON.stringify(snapshot.cursor);
200
+ await sql`
201
+ WITH saved_snapshot AS (
202
+ INSERT INTO ${sql.unsafe(table.snapshots)} (
203
+ feed_id, feed_name, feed_url, cursor, fetched_at, revision, updated_at
204
+ ) VALUES (
205
+ ${requiredText(snapshot.feed.id, "Feed id")},
206
+ ${requiredText(snapshot.feed.name, "Feed name")},
207
+ ${requiredText(snapshot.feed.url, "Feed URL")},
208
+ ${cursor}::jsonb,
209
+ ${timestamp(snapshot.fetchedAt, "Feed fetchedAt")}::timestamptz,
210
+ ${snapshot.revision},
211
+ now()
212
+ )
213
+ ON CONFLICT (feed_id) DO UPDATE SET
214
+ feed_name = EXCLUDED.feed_name,
215
+ feed_url = EXCLUDED.feed_url,
216
+ cursor = EXCLUDED.cursor,
217
+ fetched_at = EXCLUDED.fetched_at,
218
+ revision = EXCLUDED.revision,
219
+ updated_at = now()
220
+ RETURNING feed_id
221
+ ), incoming AS (
222
+ SELECT item.id, item.modified_at::timestamptz, item.value
223
+ FROM jsonb_to_recordset(${records}::jsonb)
224
+ AS item(id text, modified_at text, value jsonb)
225
+ ), removed AS (
226
+ DELETE FROM ${sql.unsafe(table.records)} record
227
+ WHERE record.feed_id = ${snapshot.feed.id}
228
+ AND NOT EXISTS (
229
+ SELECT 1 FROM incoming WHERE incoming.id = record.record_id
230
+ )
231
+ RETURNING record_id
232
+ ), written AS (
233
+ INSERT INTO ${sql.unsafe(table.records)} (
234
+ feed_id, record_id, modified_at, value
235
+ )
236
+ SELECT saved_snapshot.feed_id, incoming.id, incoming.modified_at, incoming.value
237
+ FROM saved_snapshot CROSS JOIN incoming
238
+ ON CONFLICT (feed_id, record_id) DO UPDATE SET
239
+ modified_at = EXCLUDED.modified_at,
240
+ value = EXCLUDED.value
241
+ RETURNING record_id
242
+ )
243
+ SELECT
244
+ (SELECT count(*) FROM removed) AS removed,
245
+ (SELECT count(*) FROM written) AS written
246
+ `;
247
+ }
248
+ });
249
+ const syncRuns = {
250
+ append: async (run) => {
251
+ await ensureSchema();
252
+ if (!SYNC_STATUSES.has(run.status))
253
+ throw new Error(`Unsupported feed sync status: ${run.status}`);
254
+ if (!Number.isInteger(run.records) || run.records < 0)
255
+ throw new Error("Feed sync records must be a non-negative integer");
256
+ await sql`
257
+ INSERT INTO ${sql.unsafe(table.runs)} (
258
+ id, feed_id, started_at, completed_at, status, error, records, revision
259
+ ) VALUES (
260
+ ${requiredText(run.id, "Feed sync run id")},
261
+ ${requiredText(run.feedId, "Feed sync feed id")},
262
+ ${timestamp(run.startedAt, "Feed sync startedAt")}::timestamptz,
263
+ ${timestamp(run.completedAt, "Feed sync completedAt")}::timestamptz,
264
+ ${run.status}, ${run.error}, ${run.records}, ${run.revision}
265
+ )
266
+ ON CONFLICT (id) DO UPDATE SET
267
+ feed_id = EXCLUDED.feed_id,
268
+ started_at = EXCLUDED.started_at,
269
+ completed_at = EXCLUDED.completed_at,
270
+ status = EXCLUDED.status,
271
+ error = EXCLUDED.error,
272
+ records = EXCLUDED.records,
273
+ revision = EXCLUDED.revision
274
+ `;
275
+ },
276
+ list: async (filter = {}) => {
277
+ await ensureSchema();
278
+ const feedId = filter.feedId?.trim() || null;
279
+ const status = filter.status ?? null;
280
+ const rows = await sql`
281
+ SELECT id, feed_id, started_at, completed_at, status, error, records, revision
282
+ FROM ${sql.unsafe(table.runs)}
283
+ WHERE (${feedId}::text IS NULL OR feed_id = ${feedId})
284
+ AND (${status}::text IS NULL OR status = ${status})
285
+ ORDER BY started_at DESC, id DESC
286
+ LIMIT ${limit(filter.limit)}
287
+ `;
288
+ return rows.map((row) => ({
289
+ completedAt: timestamp(row.completed_at, "Feed sync completedAt"),
290
+ error: row.error,
291
+ feedId: row.feed_id,
292
+ id: row.id,
293
+ records: Number(row.records),
294
+ revision: row.revision,
295
+ startedAt: timestamp(row.started_at, "Feed sync startedAt"),
296
+ status: row.status
297
+ }));
298
+ }
299
+ };
300
+ const saveFindings = async (findings2) => {
301
+ await ensureSchema();
302
+ if (findings2.length === 0)
303
+ return;
304
+ for (const finding of findings2) {
305
+ const findingId = finding.id;
306
+ if (!Value.Check(ManagedVulnerabilityFindingSchema, finding))
307
+ throw new Error(`Managed finding ${findingId} is invalid`);
308
+ }
309
+ const payload = JSON.stringify(findings2.map((finding) => ({
310
+ asset_id: finding.assetId,
311
+ component_id: finding.componentId,
312
+ finding_id: finding.id,
313
+ first_seen_at: finding.firstSeenAt,
314
+ last_seen_at: finding.lastSeenAt,
315
+ severity: finding.severity,
316
+ status: finding.status,
317
+ tenant_id: finding.tenantId,
318
+ value: finding
319
+ })));
320
+ await sql`
321
+ INSERT INTO ${sql.unsafe(table.findings)} (
322
+ tenant_id, finding_id, asset_id, component_id, severity, status,
323
+ first_seen_at, last_seen_at, value, updated_at
324
+ )
325
+ SELECT
326
+ item.tenant_id, item.finding_id, item.asset_id, item.component_id,
327
+ item.severity, item.status, item.first_seen_at::timestamptz,
328
+ item.last_seen_at::timestamptz, item.value, now()
329
+ FROM jsonb_to_recordset(${payload}::jsonb) AS item(
330
+ tenant_id text, finding_id text, asset_id text, component_id text,
331
+ severity text, status text, first_seen_at text, last_seen_at text,
332
+ value jsonb
333
+ )
334
+ ON CONFLICT (tenant_id, finding_id) DO UPDATE SET
335
+ asset_id = EXCLUDED.asset_id,
336
+ component_id = EXCLUDED.component_id,
337
+ severity = EXCLUDED.severity,
338
+ status = EXCLUDED.status,
339
+ first_seen_at = LEAST(${sql.unsafe(table.findings)}.first_seen_at, EXCLUDED.first_seen_at),
340
+ last_seen_at = GREATEST(${sql.unsafe(table.findings)}.last_seen_at, EXCLUDED.last_seen_at),
341
+ value = EXCLUDED.value,
342
+ updated_at = now()
343
+ `;
344
+ };
345
+ const parseFinding = (value) => {
346
+ const finding = json(value, "Managed finding");
347
+ if (!Value.Check(ManagedVulnerabilityFindingSchema, finding))
348
+ throw new Error("Stored managed finding is invalid");
349
+ return finding;
350
+ };
351
+ const findings = {
352
+ get: async (tenantId, findingId) => {
353
+ await ensureSchema();
354
+ const rows = await sql`
355
+ SELECT value FROM ${sql.unsafe(table.findings)}
356
+ WHERE tenant_id = ${requiredText(tenantId, "Tenant id")}
357
+ AND finding_id = ${requiredText(findingId, "Finding id")}
358
+ `;
359
+ return rows[0] ? parseFinding(rows[0].value) : null;
360
+ },
361
+ list: async (filter) => {
362
+ await ensureSchema();
363
+ const tenantId = requiredText(filter.tenantId, "Tenant id");
364
+ const assetId = filter.assetId?.trim() || null;
365
+ const severity = filter.severity ?? null;
366
+ const status = filter.status ?? null;
367
+ const rows = await sql`
368
+ SELECT value FROM ${sql.unsafe(table.findings)}
369
+ WHERE tenant_id = ${tenantId}
370
+ AND (${assetId}::text IS NULL OR asset_id = ${assetId})
371
+ AND (${severity}::text IS NULL OR severity = ${severity})
372
+ AND (${status}::text IS NULL OR status = ${status})
373
+ ORDER BY last_seen_at DESC, finding_id
374
+ LIMIT ${limit(filter.limit)}
375
+ `;
376
+ return rows.map(({ value }) => parseFinding(value));
377
+ },
378
+ save: async (finding) => saveFindings([finding]),
379
+ saveMany: saveFindings
380
+ };
381
+ const leases = {
382
+ acquire: async (request) => {
383
+ await ensureSchema();
384
+ if (!Number.isFinite(request.ttlMs) || request.ttlMs <= 0)
385
+ throw new Error("Feed lease ttlMs must be positive and finite");
386
+ const now = request.now ?? new Date;
387
+ const expiresAt = new Date(now.getTime() + request.ttlMs);
388
+ const rows = await sql`
389
+ INSERT INTO ${sql.unsafe(table.leases)} (
390
+ feed_id, owner_id, expires_at, updated_at
391
+ ) VALUES (
392
+ ${requiredText(request.feedId, "Feed lease feed id")},
393
+ ${requiredText(request.ownerId, "Feed lease owner id")},
394
+ ${expiresAt.toISOString()}::timestamptz,
395
+ ${now.toISOString()}::timestamptz
396
+ )
397
+ ON CONFLICT (feed_id) DO UPDATE SET
398
+ owner_id = EXCLUDED.owner_id,
399
+ expires_at = EXCLUDED.expires_at,
400
+ updated_at = EXCLUDED.updated_at
401
+ WHERE ${sql.unsafe(table.leases)}.expires_at <= ${now.toISOString()}::timestamptz
402
+ OR ${sql.unsafe(table.leases)}.owner_id = EXCLUDED.owner_id
403
+ RETURNING owner_id
404
+ `;
405
+ return rows[0]?.owner_id === request.ownerId.trim();
406
+ },
407
+ release: async (feedId, ownerId) => {
408
+ await ensureSchema();
409
+ const rows = await sql`
410
+ DELETE FROM ${sql.unsafe(table.leases)}
411
+ WHERE feed_id = ${requiredText(feedId, "Feed lease feed id")}
412
+ AND owner_id = ${requiredText(ownerId, "Feed lease owner id")}
413
+ RETURNING feed_id
414
+ `;
415
+ return rows.length > 0;
416
+ }
417
+ };
418
+ return { ensureSchema, findings, leases, snapshots, syncRuns };
419
+ };
420
+ export {
421
+ vulnerabilityPostgresSchemaSql,
422
+ createPostgresVulnerabilityStore
423
+ };
@@ -0,0 +1,2 @@
1
+ import type { CreatePostgresVulnerabilityStoreOptions } from "./index";
2
+ export declare const manifest: import("@absolutejs/manifest").PackageManifest<CreatePostgresVulnerabilityStoreOptions, never>;
@@ -0,0 +1,84 @@
1
+ // @bun
2
+ // src/manifest.ts
3
+ import { defineImplementation, defineManifest } from "@absolutejs/manifest";
4
+ import { Type } from "@sinclair/typebox";
5
+ var manifest = defineManifest()({
6
+ contract: 2,
7
+ discovery: {
8
+ audiences: ["platform-operators", "security-teams"],
9
+ intents: [
10
+ "persist vulnerability intelligence",
11
+ "retain vulnerability sync history",
12
+ "store managed vulnerability findings"
13
+ ],
14
+ keywords: ["vulnerabilities", "Postgres", "CVE", "feed-history"],
15
+ protocols: ["PostgreSQL", "AbsoluteJS vulnerability contracts"]
16
+ },
17
+ identity: {
18
+ accent: "#336791",
19
+ category: "operations",
20
+ description: "Durable Postgres snapshots, records, sync history, managed findings, and distributed refresh leases.",
21
+ docsUrl: "https://github.com/absolutejs/vulnerabilities-postgres",
22
+ name: "@absolutejs/vulnerabilities-postgres",
23
+ tagline: "Keep vulnerability intelligence durable and auditable."
24
+ },
25
+ implements: [
26
+ defineImplementation()({
27
+ contract: "vulnerabilities/persistence",
28
+ factory: "createPostgresVulnerabilityStore",
29
+ from: "@absolutejs/vulnerabilities-postgres",
30
+ requires: {
31
+ env: [
32
+ {
33
+ description: "Postgres connection string for vulnerability intelligence and findings",
34
+ example: "postgres://user:pass@host/db",
35
+ key: "DATABASE_URL",
36
+ secret: true
37
+ }
38
+ ],
39
+ peers: [
40
+ {
41
+ name: "@neondatabase/serverless",
42
+ range: ">=0.10.0",
43
+ reason: "HTTP Postgres client for serverless environments"
44
+ }
45
+ ],
46
+ services: [
47
+ {
48
+ description: "Stores vulnerability intelligence, history, findings, and leases",
49
+ id: "postgres"
50
+ }
51
+ ]
52
+ },
53
+ settings: Type.Object({
54
+ ensureSchema: Type.Optional(Type.Boolean({
55
+ default: true,
56
+ description: "Create vulnerability tables automatically on first use.",
57
+ title: "Create tables automatically"
58
+ })),
59
+ tablePrefix: Type.Optional(Type.String({
60
+ default: "vulnerability",
61
+ description: "Prefix for all vulnerability persistence tables.",
62
+ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
63
+ title: "Table prefix"
64
+ }))
65
+ }),
66
+ title: "Your Postgres database (durable vulnerability intelligence)",
67
+ wiring: {
68
+ code: "createPostgresVulnerabilityStore({ sql: neon(${env.DATABASE_URL} ?? ''), ...${settings} })",
69
+ imports: [
70
+ { from: "@neondatabase/serverless", names: ["neon"] },
71
+ {
72
+ from: "@absolutejs/vulnerabilities-postgres",
73
+ names: ["createPostgresVulnerabilityStore"]
74
+ }
75
+ ]
76
+ }
77
+ })
78
+ ],
79
+ settings: Type.Object({}),
80
+ wiring: []
81
+ });
82
+ export {
83
+ manifest
84
+ };
@@ -0,0 +1,103 @@
1
+ {
2
+ "contract": 2,
3
+ "discovery": {
4
+ "audiences": [
5
+ "platform-operators",
6
+ "security-teams"
7
+ ],
8
+ "intents": [
9
+ "persist vulnerability intelligence",
10
+ "retain vulnerability sync history",
11
+ "store managed vulnerability findings"
12
+ ],
13
+ "keywords": [
14
+ "vulnerabilities",
15
+ "Postgres",
16
+ "CVE",
17
+ "feed-history"
18
+ ],
19
+ "protocols": [
20
+ "PostgreSQL",
21
+ "AbsoluteJS vulnerability contracts"
22
+ ]
23
+ },
24
+ "identity": {
25
+ "accent": "#336791",
26
+ "category": "operations",
27
+ "description": "Durable Postgres snapshots, records, sync history, managed findings, and distributed refresh leases.",
28
+ "docsUrl": "https://github.com/absolutejs/vulnerabilities-postgres",
29
+ "name": "@absolutejs/vulnerabilities-postgres",
30
+ "tagline": "Keep vulnerability intelligence durable and auditable."
31
+ },
32
+ "implements": [
33
+ {
34
+ "contract": "vulnerabilities/persistence",
35
+ "factory": "createPostgresVulnerabilityStore",
36
+ "from": "@absolutejs/vulnerabilities-postgres",
37
+ "requires": {
38
+ "env": [
39
+ {
40
+ "description": "Postgres connection string for vulnerability intelligence and findings",
41
+ "example": "postgres://user:pass@host/db",
42
+ "key": "DATABASE_URL",
43
+ "secret": true
44
+ }
45
+ ],
46
+ "peers": [
47
+ {
48
+ "name": "@neondatabase/serverless",
49
+ "range": ">=0.10.0",
50
+ "reason": "HTTP Postgres client for serverless environments"
51
+ }
52
+ ],
53
+ "services": [
54
+ {
55
+ "description": "Stores vulnerability intelligence, history, findings, and leases",
56
+ "id": "postgres"
57
+ }
58
+ ]
59
+ },
60
+ "settings": {
61
+ "type": "object",
62
+ "properties": {
63
+ "ensureSchema": {
64
+ "default": true,
65
+ "description": "Create vulnerability tables automatically on first use.",
66
+ "title": "Create tables automatically",
67
+ "type": "boolean"
68
+ },
69
+ "tablePrefix": {
70
+ "default": "vulnerability",
71
+ "description": "Prefix for all vulnerability persistence tables.",
72
+ "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
73
+ "title": "Table prefix",
74
+ "type": "string"
75
+ }
76
+ }
77
+ },
78
+ "title": "Your Postgres database (durable vulnerability intelligence)",
79
+ "wiring": {
80
+ "code": "createPostgresVulnerabilityStore({ sql: neon(${env.DATABASE_URL} ?? ''), ...${settings} })",
81
+ "imports": [
82
+ {
83
+ "from": "@neondatabase/serverless",
84
+ "names": [
85
+ "neon"
86
+ ]
87
+ },
88
+ {
89
+ "from": "@absolutejs/vulnerabilities-postgres",
90
+ "names": [
91
+ "createPostgresVulnerabilityStore"
92
+ ]
93
+ }
94
+ ]
95
+ }
96
+ }
97
+ ],
98
+ "settings": {
99
+ "type": "object",
100
+ "properties": {}
101
+ },
102
+ "wiring": []
103
+ }
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@absolutejs/vulnerabilities-postgres",
3
+ "version": "0.1.0",
4
+ "description": "Durable Postgres feed snapshots, sync history, managed findings, and refresh leases for AbsoluteJS vulnerability management.",
5
+ "type": "module",
6
+ "license": "BSL-1.1",
7
+ "author": "Alex Kahn",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/absolutejs/vulnerabilities-postgres.git"
11
+ },
12
+ "homepage": "https://github.com/absolutejs/vulnerabilities-postgres",
13
+ "bugs": {
14
+ "url": "https://github.com/absolutejs/vulnerabilities-postgres/issues"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "absolutejs": {
22
+ "manifestContract": 2
23
+ },
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
+ },
29
+ "./manifest": {
30
+ "types": "./dist/manifest.d.ts",
31
+ "import": "./dist/manifest.js"
32
+ },
33
+ "./manifest.json": "./dist/manifest.json"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "scripts": {
41
+ "format": "prettier --write \"./**/*.{ts,json,md}\"",
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "bun test",
44
+ "build": "rm -rf dist && bun build src/index.ts src/manifest.ts --outdir dist --target=bun --external @absolutejs/manifest --external @absolutejs/vulnerabilities --external @sinclair/typebox --external postgres --external @neondatabase/serverless && tsc -p tsconfig.build.json && absolute-manifest emit",
45
+ "check:package": "bun run format && bun run typecheck && bun run test && bun run build"
46
+ },
47
+ "keywords": [
48
+ "absolutejs",
49
+ "cve",
50
+ "postgres",
51
+ "security",
52
+ "vulnerability-management"
53
+ ],
54
+ "dependencies": {
55
+ "@absolutejs/manifest": "0.3.0",
56
+ "@absolutejs/vulnerabilities": "0.5.0",
57
+ "@sinclair/typebox": "0.34.52"
58
+ },
59
+ "peerDependencies": {
60
+ "@neondatabase/serverless": ">=0.10.0",
61
+ "postgres": ">=3.4.0"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@neondatabase/serverless": {
65
+ "optional": true
66
+ },
67
+ "postgres": {
68
+ "optional": true
69
+ }
70
+ },
71
+ "devDependencies": {
72
+ "@electric-sql/pglite": "0.3.14",
73
+ "@types/bun": "1.3.14",
74
+ "prettier": "3.8.1",
75
+ "typescript": "5.9.3"
76
+ }
77
+ }