@blamejs/core 0.16.15 → 0.16.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/lib/dsr.js +134 -99
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.16.x
|
|
10
10
|
|
|
11
|
+
- v0.16.16 (2026-07-12) — **Build the data-subject-request ticket store's SQL through the shared b.sql query builder instead of hand-assembled statements, and add a static check that keeps db-handle primitives composing b.sql.** A maintainability change with no behavior difference for operators. The b.dsr ticket store built its reads and writes by concatenating table and column names into SQL strings passed to db.prepare, re-implementing the identifier quoting and sealed-field handling that b.sql — the same builder b.db.from() uses — already provides. That hand-rolled shape is how b.tenant.quota's storage query drifted from the query builder and accrued a run of parity defects fixed in 0.16.15 (reserved-word names, schema-qualified names, sealed-column filtering). The store's DML now composes b.sql (its schema DDL, which is not a b.sql concern, stays as direct statements), so its SQL cannot diverge from the builder. A new codebase-patterns check flags any db-handle primitive that passes an inline SELECT / INSERT / UPDATE / DELETE string literal to db.prepare / runSql, directing it to compose b.sql instead, so this class of drift cannot recur. **Changed:** *b.dsr ticket store composes b.sql for its reads and writes* — The data-subject-request ticket store (insert / get / list / update / delete / purge and the legacy re-seal backfill) now builds its DML with the b.sql query builder — sql.select / insert / update / delete(table, { dialect, quoteName }).…toSql() — and prepares the resulting statement, rather than concatenating identifiers into SQL strings by hand. This removes a hand-rolled identifier-quoting surface that could drift from what b.db.from() accepts. Schema provisioning (CREATE TABLE / INDEX, ALTER, PRAGMA) is not a b.sql concern and remains as direct statements. One behavior change: on a store backed by a vault, a ticket payload is AEAD-sealed and base64-encoded (~4/3 expansion) before it is bound, and the bound cell must fit the query builder's 64 MiB per-value ceiling — so the payload is now capped at an expansion-safe plaintext size (~48 MiB) and a larger ticket is refused with dsr/ticket-too-large (route large access/portability exports through chunked storage rather than one giant sealed cell). Plaintext stores keep the full 64 MiB limit. When a vault is first enabled on a table that already holds an over-cap legacy plaintext row, the one-time re-seal backfill still migrates that row's subject columns and derived hashes — so it stays findable by subject lookup and erasable by the data-subject erasure purge — and leaves only the over-cap payload plaintext (still under the read ceiling, DB-encrypted at rest, and removed when the row is erased), rather than failing provisioning with a query-builder error. **Detectors:** *Static check: db-handle primitives must compose b.sql for DML* — A new codebase-patterns check flags any primitive holding a db handle that runs DML by passing an inline SELECT / INSERT / UPDATE / DELETE string literal to db.prepare / runSql — the shape that lets a query drift from b.sql's identifier quoting and sealed-field rewrite (the b.tenant.quota storage defect class). It directs authors to build the query with b.sql and prepare the resulting string. DDL and PRAGMA (not b.sql verbs) and queries already built through a b.sql variable are out of scope.
|
|
12
|
+
|
|
11
13
|
- v0.16.15 (2026-07-11) — **Restore break-glass certificate key escrow, hand a failed production-security assertion its real diagnostic message, and make tenant storage-byte quotas actually enforce — three defects surfaced by broadening test coverage and fixed at the root.** Three primitives had defects that only a hostile or previously-untested path reached. b.cert key escrow — the optional break-glass path that seals a renewed private key to an operator's offline recipient — never worked: writeEscrow called a b.crypto method that does not exist, so any certificate configured with keyEscrow threw the moment renewal tried to seal the key. It now seals via b.crypto.encrypt (ML-KEM-1024, plus the P-384 hybrid leg when the recipient supplies an ecPublicKey) and the operator recovers the key offline with b.crypto.decrypt; the recipient accepts an ML-KEM-1024 public-key PEM string or a { publicKey, ecPublicKey } pair from b.crypto.generateEncryptionKeyPair(). b.security.assertProduction constructed its error with the code and message transposed, so a failed production-security assertion threw with a bare token (BAD_OPT / ASSERT_FAILED) as its .message and buried the human-readable explanation in .code — operators now get the full diagnostic where they read it. And b.tenant.quota storage-byte accounting was broken several ways: the per-tenant byte sum issued a query the builder rejects, so snapshot / assert / list always threw once a storage cap was set; it read rows through the auto-unsealing ORM, so a sealed column was measured as its small decrypted plaintext rather than the larger on-disk vault envelope (letting sealed-column tenants slip under the cap); when the tenant identifier itself was a sealed column, the plaintext lookup matched no rows at all and the cap silently counted zero; and BLOB columns (handed back as Uint8Array by node:sqlite) were stringified before measuring, roughly tripling their counted size and refusing writes far below the real cap. All are fixed — the sum now filters a sealed tenant id by its derived-hash blind index, reads the raw stored rows, and measures true on-disk byte lengths — so storage quotas enforce at the configured limit. **Fixed:** *b.cert break-glass key escrow seals the renewed key instead of throwing* — A certificate configured with keyEscrow forwarded the private key to writeEscrow, which called a b.crypto.encryptEnvelope method that does not exist — so escrow threw on every renewal and the break-glass recovery path was unusable. It now seals the key to the operator's offline recipient with b.crypto.encrypt: ML-KEM-1024 always, plus a P-384 hybrid leg when the recipient carries an ecPublicKey. The recipient accepts an ML-KEM-1024 public-key PEM string or a { publicKey, ecPublicKey } pair from b.crypto.generateEncryptionKeyPair(); the sealed key is never decrypted by the framework and is recovered offline with b.crypto.decrypt and the matching private key(s). · *b.security.assertProduction throws with the diagnostic in .message* — SecurityAssertError was constructed with its code and message arguments transposed, so a failed production-security assertion surfaced a bare token (BAD_OPT / ASSERT_FAILED) as its .message while the explanatory text — including the per-assertion failure list — landed in .code. Operators catching the error now read the full diagnostic in .message and the stable token in .code, as documented. · *b.tenant.quota enforces storage-byte caps at the configured limit* — The per-tenant storage-bytes accounting had several defects. It issued a query the query builder rejects (a literal '*' column), so snapshot / assert / list threw as soon as a storage cap was configured — the storage half of tenant quotas never ran against a real database. It read rows through the ORM, which auto-unseals sealed columns, so a sealed cell was measured as its small decrypted plaintext rather than the much larger vault envelope actually on disk — a tenant whose data lives in sealed columns could sail under the cap. When the tenant identifier column itself was sealed, the plaintext lookup compared against the on-disk envelope and matched no rows, so the cap silently counted zero for those tenants. And BLOB columns, which node:sqlite returns as a Uint8Array rather than a Node Buffer, were stringified before measuring: String(uint8array) is the decimal-joined bytes, roughly a 3x overcount that refused writes well below the real cap. The sum now filters a sealed tenant identifier by its derived-hash blind index (as the query builder does), reads the raw stored rows (no unseal), and counts text as its UTF-8 byte length and typed-array views by their true byte length, so a storage cap — including data in sealed columns — enforces at the limit operators set.
|
|
12
14
|
|
|
13
15
|
- v0.16.14 (2026-07-11) — **Make the object-store single-backend shorthand work for remote backends, and return a string time zone (not an array) when importing an iCalendar event — two defects found by covering previously-untested configuration and import branches.** Covering more configuration and import branches surfaced two genuine defects, now fixed at the root. The documented object-store single-backend shorthand — b.storage.init({ backend: 'sigv4' | 'gcs' | 'azure-blob' | 'http-put', ... }) — never worked for a remote backend: it forwarded the caller's options with the backend key intact, but the object-store backend builder resolves protocol, so the backend was constructed with no protocol and initialization threw a missing-protocol error. Only the local shorthand (which happens to name the key correctly) worked. The shorthand now translates backend to protocol, so all four remote backends construct as documented. And b.calendar.fromIcal mapped a DTSTART;TZID=<zone> parameter to a JSCalendar timeZone that was an array (['America/New_York']) instead of the string RFC 8984 §4.7.1 requires — it only round-tripped by accident because a single-element array coerces to a string; the parameter is now unwrapped to a scalar string. **Fixed:** *b.storage remote single-backend shorthand constructs the backend* — b.storage.init({ backend: 'sigv4' | 'gcs' | 'azure-blob' | 'http-put', ... }) forwarded the options with the backend key, but the object-store backend builder reads protocol — so the default backend had no protocol and initialization threw a missing-protocol ObjectStoreError. The remote shorthand never worked (only the { backend: 'local' } form, which names protocol correctly under the hood, did). The shorthand now maps backend to protocol and drops the backend key, so all four remote backends build as documented. · *b.calendar.fromIcal returns a string time zone for DTSTART/DUE;TZID* — An imported event's DTSTART;TZID=<zone> (or a task's DUE;TZID) mapped to a JSCalendar timeZone that was a single-element array rather than the string RFC 8984 §4.7.1 requires. It happened to round-trip back through toIcal because a one-element array coerces to a string, but consumers reading timeZone as a string saw an array. The property parameter is now unwrapped to its scalar first value.
|
package/lib/dsr.js
CHANGED
|
@@ -116,6 +116,7 @@ var bCrypto = require("./crypto");
|
|
|
116
116
|
var lazyRequire = require("./lazy-require");
|
|
117
117
|
var validateOpts = require("./validate-opts");
|
|
118
118
|
var safeSql = require("./safe-sql");
|
|
119
|
+
var sql = require("./sql");
|
|
119
120
|
var safeJson = require("./safe-json");
|
|
120
121
|
var boundedMap = require("./bounded-map");
|
|
121
122
|
var { defineClass } = require("./framework-error");
|
|
@@ -124,6 +125,13 @@ var DsrError = defineClass("DsrError", { alwaysPermanent: true });
|
|
|
124
125
|
|
|
125
126
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
126
127
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
128
|
+
|
|
129
|
+
// A vaulted store AEAD-seals + base64-encodes the payload before binding it
|
|
130
|
+
// (~4/3 expansion), and the bound cell must fit b.sql's 64 MiB per-value
|
|
131
|
+
// ceiling. Cap the plaintext at an expansion-safe size — leave a KiB for the
|
|
132
|
+
// vault nonce / tag / prefix — so the sealed cell binds through b.sql; a
|
|
133
|
+
// plaintext store keeps the full read ceiling (safeJson.ABSOLUTE_MAX_BYTES).
|
|
134
|
+
var VAULTED_SEAL_SAFE_MAX_BYTES = Math.floor(safeJson.ABSOLUTE_MAX_BYTES * 3 / 4) - C.BYTES.kib(1);
|
|
127
135
|
// cryptoField + vault lazy-required: dbTicketStore seals subject PII + the
|
|
128
136
|
// full ticket payload at rest so a GDPR Art 17 erasure leaves no
|
|
129
137
|
// decryptable copy. Lazy so the module loads in vault-less / test-tooling
|
|
@@ -1027,6 +1035,12 @@ function dbTicketStore(opts) {
|
|
|
1027
1035
|
validateOpts.requireMethods(db, ["runSql", "prepare"],
|
|
1028
1036
|
"dbTicketStore: opts.db (b.db-shaped handle)", DsrError, "dsr/bad-db");
|
|
1029
1037
|
var tableRaw = opts.table || "dsr_tickets";
|
|
1038
|
+
// b.sql builder opts for the DML below — quoteName quotes the operator-
|
|
1039
|
+
// supplied table name exactly as db.from() does (reserved words, schema-
|
|
1040
|
+
// qualified "schema.table"), so the store composes b.sql instead of
|
|
1041
|
+
// hand-rolling identifier quoting. DDL (CREATE/ALTER) stays hand-rolled —
|
|
1042
|
+
// b.sql is a DML builder, not a schema tool.
|
|
1043
|
+
var SQL_OPTS = { dialect: "sqlite", quoteName: true };
|
|
1030
1044
|
var qTable, qEmailIdx, qStatusIdx;
|
|
1031
1045
|
try {
|
|
1032
1046
|
qTable = safeSql.quoteIdentifier(tableRaw, "sqlite");
|
|
@@ -1109,32 +1123,57 @@ function dbTicketStore(opts) {
|
|
|
1109
1123
|
// selected) and cheap (an empty scan) once migrated.
|
|
1110
1124
|
if (vault().isInitialized()) {
|
|
1111
1125
|
_ensureDsrSealTable();
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1126
|
+
// The grouped-OR predicate has no operator input; it is a fixed
|
|
1127
|
+
// structural condition, so it rides through b.sql's whereRaw escape
|
|
1128
|
+
// (allow:hand-rolled-sql — a static, param-free legacy-detection filter
|
|
1129
|
+
// b.sql's structured where() cannot express as one OR-of-ANDs group).
|
|
1130
|
+
var legacySel = sql.select(tableRaw, SQL_OPTS)
|
|
1131
|
+
.columns(["id", "subject_id", "subject_email", "subject_phone", "payload"])
|
|
1132
|
+
.whereRaw("(subject_email IS NOT NULL AND subject_email_hash IS NULL) OR (subject_id IS NOT NULL AND subject_id_hash IS NULL)")
|
|
1133
|
+
.toSql();
|
|
1134
|
+
var legacyStmt = db.prepare(legacySel.sql);
|
|
1135
|
+
var legacyRows = legacyStmt.all.apply(legacyStmt, legacySel.params);
|
|
1116
1136
|
for (var bi = 0; bi < (legacyRows || []).length; bi++) {
|
|
1117
1137
|
var lrow = legacyRows[bi];
|
|
1118
1138
|
var lEmailDerived = cryptoField().computeDerived(DSR_SEAL_TABLE, "subject_email", lrow.subject_email);
|
|
1119
1139
|
var lIdDerived = cryptoField().computeDerived(DSR_SEAL_TABLE, "subject_id", lrow.subject_id);
|
|
1120
|
-
|
|
1140
|
+
// A legacy plaintext payload above the expansion-safe cap cannot be
|
|
1141
|
+
// sealed (its sealed form would exceed b.sql's per-value ceiling). The
|
|
1142
|
+
// row must still become findable + erasable, so ALWAYS seal the (small)
|
|
1143
|
+
// subject columns and populate the derived hashes — otherwise, in
|
|
1144
|
+
// vaulted mode, _subjectConds filters only on the hash columns and
|
|
1145
|
+
// list({ subject }) / the erasure purge would never see this prior
|
|
1146
|
+
// ticket, leaving its PII un-erasable. When the payload is over-cap,
|
|
1147
|
+
// keep it plaintext (still ≤ the read ceiling, so it binds; it is
|
|
1148
|
+
// DB-encrypted at rest and removed when the row is erased) and surface
|
|
1149
|
+
// it so the operator can chunk-migrate it.
|
|
1150
|
+
var payloadTooBig = Buffer.byteLength(String(lrow.payload == null ? "" : lrow.payload), "utf8") > VAULTED_SEAL_SAFE_MAX_BYTES;
|
|
1151
|
+
if (payloadTooBig) {
|
|
1152
|
+
try {
|
|
1153
|
+
observability().safeEvent("dsr.backfill.payload_left_plaintext", 1, { table: tableRaw });
|
|
1154
|
+
} catch (_e) { /* drop-silent */ }
|
|
1155
|
+
}
|
|
1156
|
+
// Omit the payload from the seal input when it is over-cap — sealRow
|
|
1157
|
+
// skips absent fields, so only the subject columns are sealed and the
|
|
1158
|
+
// plaintext payload is written back below.
|
|
1159
|
+
var lSealInput = {
|
|
1121
1160
|
id: lrow.id,
|
|
1122
1161
|
subject_id: lrow.subject_id,
|
|
1123
1162
|
subject_email: lrow.subject_email,
|
|
1124
1163
|
subject_phone: lrow.subject_phone,
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1164
|
+
};
|
|
1165
|
+
if (!payloadTooBig) lSealInput.payload = lrow.payload;
|
|
1166
|
+
var lSealed = cryptoField().sealRow(DSR_SEAL_TABLE, lSealInput);
|
|
1167
|
+
var lUpd = sql.update(tableRaw, SQL_OPTS).set({
|
|
1168
|
+
subject_id: lSealed.subject_id,
|
|
1169
|
+
subject_email: lSealed.subject_email,
|
|
1170
|
+
subject_phone: lSealed.subject_phone,
|
|
1171
|
+
payload: payloadTooBig ? lrow.payload : lSealed.payload,
|
|
1172
|
+
subject_email_hash: lEmailDerived ? lEmailDerived.value : null,
|
|
1173
|
+
subject_id_hash: lIdDerived ? lIdDerived.value : null,
|
|
1174
|
+
}).where("id", "=", lrow.id).toSql();
|
|
1175
|
+
var lUpdStmt = db.prepare(lUpd.sql);
|
|
1176
|
+
lUpdStmt.run.apply(lUpdStmt, lUpd.params);
|
|
1138
1177
|
}
|
|
1139
1178
|
}
|
|
1140
1179
|
}
|
|
@@ -1146,15 +1185,23 @@ function dbTicketStore(opts) {
|
|
|
1146
1185
|
// it stores plaintext (matching the agent-* fallback).
|
|
1147
1186
|
function _sealColumns(id, ticket) {
|
|
1148
1187
|
// The payload column is read back through safeJson.parse, whose hard
|
|
1149
|
-
// ceiling (safeJson.ABSOLUTE_MAX_BYTES) caps what any read can accept
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1188
|
+
// ceiling (safeJson.ABSOLUTE_MAX_BYTES) caps what any read can accept, so
|
|
1189
|
+
// the plaintext must not exceed it on write either. When a vault is
|
|
1190
|
+
// configured the payload is AEAD-sealed and base64-encoded before it is
|
|
1191
|
+
// bound, which expands it ~4/3; the bound (sealed) cell must still fit the
|
|
1192
|
+
// query builder's per-value binding ceiling (b.sql's MAX_PARAM_BYTES, the
|
|
1193
|
+
// same 64 MiB). Cap the plaintext at an expansion-safe size when vaulted so
|
|
1194
|
+
// an oversized ticket is refused here with the store's own error rather
|
|
1195
|
+
// than a SqlBuilderError deep in the insert — and route large exports to
|
|
1196
|
+
// chunked storage rather than binding one giant sealed cell.
|
|
1153
1197
|
var serializedPayload = JSON.stringify(ticket);
|
|
1154
|
-
|
|
1198
|
+
var vaulted = vault().isInitialized();
|
|
1199
|
+
var maxPlaintextBytes = vaulted ? VAULTED_SEAL_SAFE_MAX_BYTES : safeJson.ABSOLUTE_MAX_BYTES;
|
|
1200
|
+
if (Buffer.byteLength(serializedPayload, "utf8") > maxPlaintextBytes) {
|
|
1155
1201
|
throw new DsrError("dsr/ticket-too-large",
|
|
1156
|
-
"_sealColumns: ticket " + id + " payload exceeds the " +
|
|
1157
|
-
|
|
1202
|
+
"_sealColumns: ticket " + id + " payload exceeds the " + maxPlaintextBytes +
|
|
1203
|
+
"-byte store limit" +
|
|
1204
|
+
(vaulted ? " (sealing expands it; store large exports via chunked storage)" : ""));
|
|
1158
1205
|
}
|
|
1159
1206
|
var row = {
|
|
1160
1207
|
id: id,
|
|
@@ -1205,7 +1252,8 @@ function dbTicketStore(opts) {
|
|
|
1205
1252
|
{ key: "email", plainCol: "subject_email", sealField: "subject_email", hashCol: "subject_email_hash", param: "$email" },
|
|
1206
1253
|
{ key: "subjectId", plainCol: "subject_id", sealField: "subject_id", hashCol: "subject_id_hash", param: "$sid" },
|
|
1207
1254
|
];
|
|
1208
|
-
|
|
1255
|
+
// AND the subject-match predicate(s) onto a b.sql select builder.
|
|
1256
|
+
function _subjectConds(filter, qb) {
|
|
1209
1257
|
if (!filter.subject) return;
|
|
1210
1258
|
var vaulted = vault().isInitialized();
|
|
1211
1259
|
if (vaulted) _ensureDsrSealTable();
|
|
@@ -1213,8 +1261,7 @@ function dbTicketStore(opts) {
|
|
|
1213
1261
|
var supplied = filter.subject[spec.key];
|
|
1214
1262
|
if (!supplied) return;
|
|
1215
1263
|
if (!vaulted) {
|
|
1216
|
-
|
|
1217
|
-
params[spec.param] = supplied;
|
|
1264
|
+
qb.where(spec.plainCol, "=", supplied);
|
|
1218
1265
|
return;
|
|
1219
1266
|
}
|
|
1220
1267
|
// Vaulted: match BOTH the active keyed-MAC digest AND the legacy
|
|
@@ -1226,111 +1273,99 @@ function dbTicketStore(opts) {
|
|
|
1226
1273
|
var cand = cryptoField().lookupHashCandidates(DSR_SEAL_TABLE, spec.sealField, supplied);
|
|
1227
1274
|
var values = cand && cand.values ? cand.values : [];
|
|
1228
1275
|
if (values.length === 0) return;
|
|
1229
|
-
|
|
1230
|
-
var p = spec.param + "_" + i;
|
|
1231
|
-
params[p] = v;
|
|
1232
|
-
return p;
|
|
1233
|
-
});
|
|
1234
|
-
conds.push(spec.hashCol + " IN (" + placeholders.join(", ") + ")");
|
|
1276
|
+
qb.whereIn(spec.hashCol, values);
|
|
1235
1277
|
});
|
|
1236
1278
|
}
|
|
1237
1279
|
|
|
1238
1280
|
return {
|
|
1239
1281
|
insert: async function (ticket) {
|
|
1240
1282
|
var cols = _sealColumns(ticket.id, ticket);
|
|
1241
|
-
var
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
$processedAt: ticket.processedAt || null,
|
|
1260
|
-
$verLevel: ticket.verificationLevel || null,
|
|
1261
|
-
$posture: ticket.posture || null,
|
|
1262
|
-
$payload: cols.$payload,
|
|
1263
|
-
});
|
|
1283
|
+
var built = sql.insert(tableRaw, SQL_OPTS).values({
|
|
1284
|
+
id: ticket.id,
|
|
1285
|
+
type: ticket.type,
|
|
1286
|
+
status: ticket.status,
|
|
1287
|
+
subject_id: cols.$sid,
|
|
1288
|
+
subject_email: cols.$email,
|
|
1289
|
+
subject_phone: cols.$phone,
|
|
1290
|
+
subject_email_hash: cols.$emailHash,
|
|
1291
|
+
subject_id_hash: cols.$idHash,
|
|
1292
|
+
submitted_at: ticket.submittedAt,
|
|
1293
|
+
deadline_at: ticket.deadlineAt,
|
|
1294
|
+
processed_at: ticket.processedAt || null,
|
|
1295
|
+
verification_level: ticket.verificationLevel || null,
|
|
1296
|
+
posture: ticket.posture || null,
|
|
1297
|
+
payload: cols.$payload,
|
|
1298
|
+
}).toSql();
|
|
1299
|
+
var stmt = db.prepare(built.sql);
|
|
1300
|
+
stmt.run.apply(stmt, built.params);
|
|
1264
1301
|
},
|
|
1265
1302
|
get: async function (id) {
|
|
1266
|
-
var
|
|
1267
|
-
|
|
1303
|
+
var built = sql.select(tableRaw, SQL_OPTS).columns(["id", "payload"]).where("id", "=", id).toSql();
|
|
1304
|
+
var stmt = db.prepare(built.sql);
|
|
1305
|
+
var rows = stmt.all.apply(stmt, built.params);
|
|
1268
1306
|
if (!rows || rows.length === 0) return null;
|
|
1269
1307
|
return safeJson.parse(_unsealPayload(rows[0].payload, rows[0].id), { maxBytes: safeJson.ABSOLUTE_MAX_BYTES });
|
|
1270
1308
|
},
|
|
1271
1309
|
list: async function (filter) {
|
|
1272
1310
|
filter = filter || {};
|
|
1273
|
-
var
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
_subjectConds(filter, conds, params);
|
|
1281
|
-
if (conds.length > 0) sql += " WHERE " + conds.join(" AND ");
|
|
1282
|
-
sql += " ORDER BY submitted_at DESC";
|
|
1283
|
-
var rows = db.prepare(sql).all(params);
|
|
1311
|
+
var qb = sql.select(tableRaw, SQL_OPTS).columns(["id", "payload"]);
|
|
1312
|
+
if (filter.status) qb.where("status", "=", filter.status);
|
|
1313
|
+
_subjectConds(filter, qb);
|
|
1314
|
+
qb.orderBy("submitted_at", "DESC");
|
|
1315
|
+
var built = qb.toSql();
|
|
1316
|
+
var stmt = db.prepare(built.sql);
|
|
1317
|
+
var rows = stmt.all.apply(stmt, built.params);
|
|
1284
1318
|
return rows.map(function (r) { return safeJson.parse(_unsealPayload(r.payload, r.id), { maxBytes: safeJson.ABSOLUTE_MAX_BYTES }); });
|
|
1285
1319
|
},
|
|
1286
1320
|
update: async function (id, ticket) {
|
|
1287
1321
|
var cols = _sealColumns(id, ticket);
|
|
1288
|
-
var
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
$submittedAt: ticket.submittedAt,
|
|
1306
|
-
$deadlineAt: ticket.deadlineAt,
|
|
1307
|
-
$processedAt: ticket.processedAt || null,
|
|
1308
|
-
$verLevel: ticket.verificationLevel || null,
|
|
1309
|
-
$posture: ticket.posture || null,
|
|
1310
|
-
$payload: cols.$payload,
|
|
1311
|
-
});
|
|
1322
|
+
var built = sql.update(tableRaw, SQL_OPTS).set({
|
|
1323
|
+
type: ticket.type,
|
|
1324
|
+
status: ticket.status,
|
|
1325
|
+
subject_id: cols.$sid,
|
|
1326
|
+
subject_email: cols.$email,
|
|
1327
|
+
subject_phone: cols.$phone,
|
|
1328
|
+
subject_email_hash: cols.$emailHash,
|
|
1329
|
+
subject_id_hash: cols.$idHash,
|
|
1330
|
+
submitted_at: ticket.submittedAt,
|
|
1331
|
+
deadline_at: ticket.deadlineAt,
|
|
1332
|
+
processed_at: ticket.processedAt || null,
|
|
1333
|
+
verification_level: ticket.verificationLevel || null,
|
|
1334
|
+
posture: ticket.posture || null,
|
|
1335
|
+
payload: cols.$payload,
|
|
1336
|
+
}).where("id", "=", id).toSql();
|
|
1337
|
+
var stmt = db.prepare(built.sql);
|
|
1338
|
+
var info = stmt.run.apply(stmt, built.params);
|
|
1312
1339
|
if (info && info.changes === 0) {
|
|
1313
1340
|
throw new DsrError("dsr/ticket-not-found",
|
|
1314
1341
|
"dbTicketStore: ticket " + id + " not found for update");
|
|
1315
1342
|
}
|
|
1316
1343
|
},
|
|
1317
1344
|
delete: async function (id) {
|
|
1318
|
-
var
|
|
1345
|
+
var built = sql.delete(tableRaw, SQL_OPTS).where("id", "=", id).toSql();
|
|
1346
|
+
var stmt = db.prepare(built.sql);
|
|
1347
|
+
var info = stmt.run.apply(stmt, built.params);
|
|
1319
1348
|
return !!(info && info.changes > 0);
|
|
1320
1349
|
},
|
|
1321
1350
|
purgeExpired: async function (asOfMs) {
|
|
1322
1351
|
// Bulk-delete tickets in terminal states whose retentionUntil
|
|
1323
1352
|
// is in the past. Returns the number of rows removed.
|
|
1324
1353
|
var asOf = (typeof asOfMs === "number" && isFinite(asOfMs)) ? asOfMs : Date.now();
|
|
1325
|
-
var
|
|
1326
|
-
|
|
1354
|
+
var selBuilt = sql.select(tableRaw, SQL_OPTS).columns(["id", "payload"])
|
|
1355
|
+
.whereIn("status", ["completed", "partially_completed", "cancelled", "rejected", "expired"])
|
|
1356
|
+
.toSql();
|
|
1357
|
+
var selStmt = db.prepare(selBuilt.sql);
|
|
1358
|
+
var rows = selStmt.all.apply(selStmt, selBuilt.params);
|
|
1327
1359
|
var purged = 0;
|
|
1328
|
-
var del = db.prepare("DELETE FROM " + qTable + " WHERE id = $id");
|
|
1329
1360
|
for (var i = 0; i < rows.length; i++) {
|
|
1330
1361
|
try {
|
|
1331
1362
|
var t = safeJson.parse(_unsealPayload(rows[i].payload, rows[i].id), { maxBytes: safeJson.ABSOLUTE_MAX_BYTES });
|
|
1332
1363
|
if (t.retentionUntil && t.retentionUntil < asOf) {
|
|
1333
|
-
|
|
1364
|
+
// The delete SQL string is identical every iteration, so db.prepare
|
|
1365
|
+
// returns the same cached statement — composing per-row is free.
|
|
1366
|
+
var dBuilt = sql.delete(tableRaw, SQL_OPTS).where("id", "=", rows[i].id).toSql();
|
|
1367
|
+
var dStmt = db.prepare(dBuilt.sql);
|
|
1368
|
+
dStmt.run.apply(dStmt, dBuilt.params);
|
|
1334
1369
|
purged += 1;
|
|
1335
1370
|
}
|
|
1336
1371
|
} catch (_e) { /* malformed payload — leave it */ }
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:4536f3b2-ce54-4f57-930f-5250f9a34d41",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-12T04:34:36.482Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.16.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.16.16",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.16.
|
|
25
|
+
"version": "0.16.16",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.16.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.16.16",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.16.
|
|
57
|
+
"ref": "@blamejs/core@0.16.16",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|