@festapp/banksync 0.0.0-bootstrap.20260831
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 +21 -0
- package/README.md +155 -0
- package/dist/chunk-5QXHZGAW.js +696 -0
- package/dist/chunk-PBN2BUNR.cjs +696 -0
- package/dist/cloudflare.cjs +3768 -0
- package/dist/cloudflare.d.cts +84 -0
- package/dist/cloudflare.d.ts +84 -0
- package/dist/cloudflare.js +3768 -0
- package/dist/index.cjs +42 -0
- package/dist/index.d.cts +121 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +42 -0
- package/dist/types-Cb5l2lXq.d.cts +86 -0
- package/dist/types-Cb5l2lXq.d.ts +86 -0
- package/migrations/0001_schema.sql +220 -0
- package/migrations/0010_security_hardening.sql +7 -0
- package/package.json +68 -0
- package/scripts/decrypt-backup.mjs +32 -0
|
@@ -0,0 +1,3768 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FioRateLimited,
|
|
3
|
+
FioTransientFailure,
|
|
4
|
+
buildWebhookEnvelope,
|
|
5
|
+
detectProvider,
|
|
6
|
+
fetchNewTransactions,
|
|
7
|
+
mapFioTransaction,
|
|
8
|
+
parseEmail,
|
|
9
|
+
resolveVariableSymbol,
|
|
10
|
+
setFioPointer,
|
|
11
|
+
signWebhook
|
|
12
|
+
} from "./chunk-5QXHZGAW.js";
|
|
13
|
+
|
|
14
|
+
// src/mime.ts
|
|
15
|
+
import PostalMime from "postal-mime";
|
|
16
|
+
async function extractEmailBody(raw) {
|
|
17
|
+
const rawString = await new Response(raw).text();
|
|
18
|
+
const parsed = await PostalMime.parse(rawString);
|
|
19
|
+
const text = parsed.text ? parsed.text : parsed.html ? parsed.html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() : "";
|
|
20
|
+
const fromAddr = parsed.from && "address" in parsed.from && parsed.from.address ? parsed.from.address.toLowerCase() : void 0;
|
|
21
|
+
const toFirst = parsed.to?.[0];
|
|
22
|
+
const toAddr = toFirst && "address" in toFirst && toFirst.address ? toFirst.address.toLowerCase() : void 0;
|
|
23
|
+
return {
|
|
24
|
+
subject: parsed.subject,
|
|
25
|
+
text,
|
|
26
|
+
fromHeader: fromAddr,
|
|
27
|
+
toHeader: toAddr,
|
|
28
|
+
messageId: parsed.messageId
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/email_classify.ts
|
|
33
|
+
function extractPairingCode(toAddress) {
|
|
34
|
+
const m = toAddress.match(/^([0-9a-f]{4,32})@/i);
|
|
35
|
+
return m ? m[1].toLowerCase() : null;
|
|
36
|
+
}
|
|
37
|
+
function parseAllowlist(raw) {
|
|
38
|
+
return (raw ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
function senderAllowed(from, allowlist) {
|
|
41
|
+
if (!from) return false;
|
|
42
|
+
const fromLc = from.toLowerCase();
|
|
43
|
+
const fromDomain = fromLc.includes("@") ? fromLc.slice(fromLc.indexOf("@") + 1) : "";
|
|
44
|
+
for (const entry of allowlist) {
|
|
45
|
+
if (entry.startsWith("@")) {
|
|
46
|
+
const dom = entry.slice(1);
|
|
47
|
+
if (fromDomain === dom || fromDomain.endsWith("." + dom)) return true;
|
|
48
|
+
} else if (fromLc === entry) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
function classifyEmail(extracted, identity, account, allowlist) {
|
|
55
|
+
if (!senderAllowed(identity.sender, allowlist)) {
|
|
56
|
+
return { kind: "reject", reason: "sender_not_allowed", bankAccountId: null, received: false };
|
|
57
|
+
}
|
|
58
|
+
const toAddress = identity.recipient;
|
|
59
|
+
const pairingCode = extractPairingCode(toAddress);
|
|
60
|
+
if (!pairingCode) {
|
|
61
|
+
return { kind: "reject", reason: `no_pairing_code: ${toAddress}`, bankAccountId: null, received: false };
|
|
62
|
+
}
|
|
63
|
+
if (!account) {
|
|
64
|
+
return { kind: "reject", reason: `unknown_pairing_code: ${pairingCode}`, bankAccountId: null, received: false };
|
|
65
|
+
}
|
|
66
|
+
if (account.ingest_mode === "api") {
|
|
67
|
+
return { kind: "reject", reason: "email_ingest_disabled", bankAccountId: account.id, received: false };
|
|
68
|
+
}
|
|
69
|
+
const provider = detectProvider(extracted.text, account.account_number);
|
|
70
|
+
if (provider === "unknown") {
|
|
71
|
+
return { kind: "reject", reason: `unknown_provider: ${account.account_number}`, bankAccountId: account.id, received: true };
|
|
72
|
+
}
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = parseEmail(extracted.text, provider);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return { kind: "reject", reason: `${err}`.replace(/^Error:\s*/, ""), bankAccountId: account.id, received: true };
|
|
78
|
+
}
|
|
79
|
+
if (parsed === null) {
|
|
80
|
+
const looksLikeTransaction = /\d[\s.,]\d|Kč|CZK|EUR|USD/i.test(extracted.text);
|
|
81
|
+
return looksLikeTransaction ? { kind: "reject", reason: `parse_failed: ${provider}`, bankAccountId: account.id, received: true } : { kind: "skip", reason: `not_transaction: ${provider}`, bankAccountId: account.id, received: true };
|
|
82
|
+
}
|
|
83
|
+
return { kind: "insert", account, parsed, received: true };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/email_auth.ts
|
|
87
|
+
var EmailAuthenticationError = class extends Error {
|
|
88
|
+
constructor(code) {
|
|
89
|
+
super(code);
|
|
90
|
+
this.code = code;
|
|
91
|
+
this.name = "EmailAuthenticationError";
|
|
92
|
+
}
|
|
93
|
+
code;
|
|
94
|
+
};
|
|
95
|
+
function inspectAuthenticationResults(value) {
|
|
96
|
+
const auth = value?.trim() ?? "";
|
|
97
|
+
const separator = auth.indexOf(";");
|
|
98
|
+
const candidate = separator >= 0 ? auth.slice(0, separator).trim().toLowerCase() : "";
|
|
99
|
+
const ambiguous = auth.includes(",");
|
|
100
|
+
const observedAuthservId = !ambiguous && candidate.length > 0 && candidate.length <= 253 && /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/.test(candidate) ? candidate : null;
|
|
101
|
+
return { observedAuthservId, ambiguous };
|
|
102
|
+
}
|
|
103
|
+
function normalizeAddress(value) {
|
|
104
|
+
if (!value) return null;
|
|
105
|
+
const match = /<([^<>]+)>/.exec(value);
|
|
106
|
+
const normalized = (match?.[1] ?? value).trim().toLowerCase();
|
|
107
|
+
return /^[^\s@]+@[^\s@]+$/.test(normalized) ? normalized : null;
|
|
108
|
+
}
|
|
109
|
+
function addressDomain(address) {
|
|
110
|
+
return address.slice(address.lastIndexOf("@") + 1);
|
|
111
|
+
}
|
|
112
|
+
function aligned(child, parent) {
|
|
113
|
+
return child === parent || child.endsWith(`.${parent}`);
|
|
114
|
+
}
|
|
115
|
+
function resultDomain(fields, mechanism) {
|
|
116
|
+
const property = mechanism === "dkim" ? "header.d" : "header.from";
|
|
117
|
+
const mechanismFields = fields.filter((value) => new RegExp(`^${mechanism}\\s*=`, "i").test(value));
|
|
118
|
+
if (mechanismFields.length !== 1) return null;
|
|
119
|
+
const field = mechanismFields[0];
|
|
120
|
+
if (!new RegExp(`^${mechanism}\\s*=\\s*pass\\b`, "i").test(field)) return null;
|
|
121
|
+
const match = new RegExp(`(?:^|\\s)${property.replace(".", "\\.")}=([^\\s;,]+)`, "i").exec(field);
|
|
122
|
+
return match?.[1]?.toLowerCase().replace(/\.$/, "") ?? null;
|
|
123
|
+
}
|
|
124
|
+
function authenticateEmailIdentity(evidence, mime) {
|
|
125
|
+
const sender = normalizeAddress(evidence.mailFrom);
|
|
126
|
+
const recipient = normalizeAddress(evidence.rcptTo);
|
|
127
|
+
const headerSender = normalizeAddress(mime.fromHeader);
|
|
128
|
+
const headerRecipient = normalizeAddress(mime.toHeader);
|
|
129
|
+
if (!sender || !recipient || !headerSender || !headerRecipient) {
|
|
130
|
+
throw new EmailAuthenticationError("email_identity_missing");
|
|
131
|
+
}
|
|
132
|
+
if (sender !== headerSender || recipient !== headerRecipient) {
|
|
133
|
+
throw new EmailAuthenticationError("email_identity_mismatch");
|
|
134
|
+
}
|
|
135
|
+
const trusted = evidence.trustedAuthservId.trim().toLowerCase();
|
|
136
|
+
const auth = evidence.authenticationResults?.trim() ?? "";
|
|
137
|
+
if (!trusted || !auth) throw new EmailAuthenticationError("trusted_authentication_missing");
|
|
138
|
+
const inspection = inspectAuthenticationResults(auth);
|
|
139
|
+
if (inspection.ambiguous || inspection.observedAuthservId !== trusted) {
|
|
140
|
+
throw new EmailAuthenticationError("trusted_authentication_ambiguous");
|
|
141
|
+
}
|
|
142
|
+
const fields = auth.split(";").slice(1).map((value) => value.trim()).filter(Boolean);
|
|
143
|
+
const senderDomain = addressDomain(sender);
|
|
144
|
+
const dmarcDomain = resultDomain(fields, "dmarc");
|
|
145
|
+
if (dmarcDomain && aligned(senderDomain, dmarcDomain)) {
|
|
146
|
+
return { sender, recipient, authenticatedDomain: dmarcDomain, mechanism: "dmarc" };
|
|
147
|
+
}
|
|
148
|
+
const dkimDomain = resultDomain(fields, "dkim");
|
|
149
|
+
if (dkimDomain && aligned(senderDomain, dkimDomain)) {
|
|
150
|
+
return { sender, recipient, authenticatedDomain: dkimDomain, mechanism: "dkim" };
|
|
151
|
+
}
|
|
152
|
+
throw new EmailAuthenticationError("authenticated_sender_not_aligned");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/crypto.ts
|
|
156
|
+
var IV_BYTES = 12;
|
|
157
|
+
var MIN_PAYLOAD = IV_BYTES + 16;
|
|
158
|
+
async function importAesGcmKey(b64, label) {
|
|
159
|
+
let bytes;
|
|
160
|
+
try {
|
|
161
|
+
const std = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
162
|
+
bytes = Uint8Array.from(atob(std), (c) => c.charCodeAt(0));
|
|
163
|
+
} catch {
|
|
164
|
+
throw new Error(`${label} must be 32 bytes (256 bits) base64-encoded`);
|
|
165
|
+
}
|
|
166
|
+
if (bytes.length !== 32) {
|
|
167
|
+
throw new Error(`${label} must be 32 bytes (256 bits) base64-encoded`);
|
|
168
|
+
}
|
|
169
|
+
return crypto.subtle.importKey("raw", bytes.buffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
170
|
+
}
|
|
171
|
+
function toBase64(bytes) {
|
|
172
|
+
const CHUNK = 8192;
|
|
173
|
+
let out = "";
|
|
174
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
175
|
+
out += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
176
|
+
}
|
|
177
|
+
return btoa(out);
|
|
178
|
+
}
|
|
179
|
+
async function webhookEncrypt(plaintext, kekBase64OrRaw) {
|
|
180
|
+
const key = await importAesGcmKey(kekBase64OrRaw, "WEBHOOK_KEK");
|
|
181
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
182
|
+
const ptBytes = new TextEncoder().encode(plaintext);
|
|
183
|
+
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, ptBytes);
|
|
184
|
+
const combined = new Uint8Array(IV_BYTES + ct.byteLength);
|
|
185
|
+
combined.set(iv, 0);
|
|
186
|
+
combined.set(new Uint8Array(ct), IV_BYTES);
|
|
187
|
+
return toBase64(combined);
|
|
188
|
+
}
|
|
189
|
+
async function webhookDecrypt(cipherBase64, kekBase64OrRaw) {
|
|
190
|
+
const key = await importAesGcmKey(kekBase64OrRaw, "WEBHOOK_KEK");
|
|
191
|
+
let bytes;
|
|
192
|
+
try {
|
|
193
|
+
bytes = Uint8Array.from(atob(cipherBase64), (c) => c.charCodeAt(0));
|
|
194
|
+
} catch {
|
|
195
|
+
throw new Error("cipher payload invalid base64");
|
|
196
|
+
}
|
|
197
|
+
if (bytes.length < MIN_PAYLOAD) {
|
|
198
|
+
throw new Error("cipher payload too short");
|
|
199
|
+
}
|
|
200
|
+
const iv = bytes.slice(0, IV_BYTES);
|
|
201
|
+
const ctAndTag = bytes.slice(IV_BYTES);
|
|
202
|
+
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ctAndTag);
|
|
203
|
+
return new TextDecoder().decode(pt);
|
|
204
|
+
}
|
|
205
|
+
function parseKeyVersion(raw) {
|
|
206
|
+
const value = raw ?? "1";
|
|
207
|
+
if (!/^[1-9]\d*$/.test(value)) {
|
|
208
|
+
throw new Error("ENCRYPTION_KEY_VERSION must be a positive integer");
|
|
209
|
+
}
|
|
210
|
+
return Number(value);
|
|
211
|
+
}
|
|
212
|
+
function getVersionedKey(env, version) {
|
|
213
|
+
const key = env[`ENCRYPTION_KEY_V${version}`];
|
|
214
|
+
if (!key) {
|
|
215
|
+
throw new Error(`ENCRYPTION_KEY_V${version} is not configured`);
|
|
216
|
+
}
|
|
217
|
+
return key;
|
|
218
|
+
}
|
|
219
|
+
async function encryptWithBase64Key(plaintext, keyBase64, keyLabel) {
|
|
220
|
+
const key = await importAesGcmKey(keyBase64, keyLabel);
|
|
221
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
222
|
+
const ptBytes = new TextEncoder().encode(plaintext);
|
|
223
|
+
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, ptBytes);
|
|
224
|
+
const combined = new Uint8Array(IV_BYTES + ct.byteLength);
|
|
225
|
+
combined.set(iv, 0);
|
|
226
|
+
combined.set(new Uint8Array(ct), IV_BYTES);
|
|
227
|
+
return toBase64(combined);
|
|
228
|
+
}
|
|
229
|
+
async function decryptWithBase64Key(cipherBase64, keyBase64, keyLabel) {
|
|
230
|
+
const key = await importAesGcmKey(keyBase64, keyLabel);
|
|
231
|
+
let bytes;
|
|
232
|
+
try {
|
|
233
|
+
bytes = Uint8Array.from(atob(cipherBase64), (c) => c.charCodeAt(0));
|
|
234
|
+
} catch {
|
|
235
|
+
throw new Error("cipher payload invalid base64");
|
|
236
|
+
}
|
|
237
|
+
if (bytes.length < MIN_PAYLOAD) {
|
|
238
|
+
throw new Error("cipher payload too short");
|
|
239
|
+
}
|
|
240
|
+
const iv = bytes.slice(0, IV_BYTES);
|
|
241
|
+
const ctAndTag = bytes.slice(IV_BYTES);
|
|
242
|
+
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ctAndTag);
|
|
243
|
+
return new TextDecoder().decode(pt);
|
|
244
|
+
}
|
|
245
|
+
async function encryptSecret(plaintext, env) {
|
|
246
|
+
const keyVersion = parseKeyVersion(env.ENCRYPTION_KEY_VERSION);
|
|
247
|
+
const keyLabel = `ENCRYPTION_KEY_V${keyVersion}`;
|
|
248
|
+
const cipher = await encryptWithBase64Key(plaintext, getVersionedKey(env, keyVersion), keyLabel);
|
|
249
|
+
return { cipher, keyVersion };
|
|
250
|
+
}
|
|
251
|
+
async function decryptSecret(cipher, keyVersion, env) {
|
|
252
|
+
if (!Number.isInteger(keyVersion) || keyVersion <= 0) {
|
|
253
|
+
throw new Error("invalid encryption key version");
|
|
254
|
+
}
|
|
255
|
+
const keyLabel = `ENCRYPTION_KEY_V${keyVersion}`;
|
|
256
|
+
return decryptWithBase64Key(cipher, getVersionedKey(env, keyVersion), keyLabel);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/logger.ts
|
|
260
|
+
var REDACTED_KEYS_RE = /^(secret|.*_secret|.*token.*|.*cipher.*|fio_secret|webhook_kek|encryption_key|.*encryption.*key.*|.*_kek|.*_key|raw_email)$/i;
|
|
261
|
+
var ALLOW_LIST_KEYS = /* @__PURE__ */ new Set(["secret_prefix", "api_token_prefix", "fio_token_prefix"]);
|
|
262
|
+
function redactValue(value, visited) {
|
|
263
|
+
if (value === null || value === void 0 || typeof value !== "object") {
|
|
264
|
+
return value;
|
|
265
|
+
}
|
|
266
|
+
if (visited.has(value)) {
|
|
267
|
+
return value;
|
|
268
|
+
}
|
|
269
|
+
visited.add(value);
|
|
270
|
+
if (Array.isArray(value)) {
|
|
271
|
+
return value.map((item) => redactValue(item, visited));
|
|
272
|
+
}
|
|
273
|
+
const redactedObj = {};
|
|
274
|
+
for (const [key, val] of Object.entries(value)) {
|
|
275
|
+
if (REDACTED_KEYS_RE.test(key) && !ALLOW_LIST_KEYS.has(key)) {
|
|
276
|
+
redactedObj[key] = "[REDACTED]";
|
|
277
|
+
} else {
|
|
278
|
+
redactedObj[key] = redactValue(val, visited);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return redactedObj;
|
|
282
|
+
}
|
|
283
|
+
function redact(data) {
|
|
284
|
+
const visited = /* @__PURE__ */ new Set();
|
|
285
|
+
const redactedObj = {};
|
|
286
|
+
for (const [key, val] of Object.entries(data)) {
|
|
287
|
+
if (REDACTED_KEYS_RE.test(key) && !ALLOW_LIST_KEYS.has(key)) {
|
|
288
|
+
redactedObj[key] = "[REDACTED]";
|
|
289
|
+
} else {
|
|
290
|
+
redactedObj[key] = redactValue(val, visited);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return redactedObj;
|
|
294
|
+
}
|
|
295
|
+
function log(event, data) {
|
|
296
|
+
console.log(
|
|
297
|
+
JSON.stringify({
|
|
298
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
299
|
+
event,
|
|
300
|
+
...redact(data ?? {})
|
|
301
|
+
})
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
function logError(event, err, data) {
|
|
305
|
+
console.error(
|
|
306
|
+
JSON.stringify({
|
|
307
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
308
|
+
event,
|
|
309
|
+
error: String(err),
|
|
310
|
+
...redact(data ?? {})
|
|
311
|
+
})
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/db.ts
|
|
316
|
+
var SUPPORTED_SCHEMA_VERSIONS = ["10"];
|
|
317
|
+
var _checkedDbs = /* @__PURE__ */ new Set();
|
|
318
|
+
async function assertSchemaVersion(db) {
|
|
319
|
+
if (_checkedDbs.has(db)) return;
|
|
320
|
+
const row = await db.prepare(`SELECT value FROM schema_meta WHERE key = 'version'`).first();
|
|
321
|
+
if (!row || !SUPPORTED_SCHEMA_VERSIONS.includes(row.value)) {
|
|
322
|
+
throw new Error(`schema_version_mismatch: got ${row?.value ?? "null"}, expected one of ${SUPPORTED_SCHEMA_VERSIONS.join(", ")}`);
|
|
323
|
+
}
|
|
324
|
+
_checkedDbs.add(db);
|
|
325
|
+
}
|
|
326
|
+
var BANK_ACCOUNT_PUBLIC_COLS = `
|
|
327
|
+
id, account_number, account_type, ingest_mode, pairing_code, label, owner_app_id, cf_rule_id,
|
|
328
|
+
(api_token_cipher IS NOT NULL) AS api_token_set,
|
|
329
|
+
api_token_prefix, api_fetch_enabled, api_last_fetch_at, api_last_success_at, api_last_error, api_backfill_done,
|
|
330
|
+
created_at
|
|
331
|
+
`;
|
|
332
|
+
function mapBankAccount(row) {
|
|
333
|
+
return {
|
|
334
|
+
...row,
|
|
335
|
+
api_token_set: Boolean(row.api_token_set),
|
|
336
|
+
api_fetch_enabled: Boolean(row.api_fetch_enabled),
|
|
337
|
+
api_backfill_done: Boolean(row.api_backfill_done)
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function mapApiFetchAccount(row) {
|
|
341
|
+
return {
|
|
342
|
+
...mapBankAccount(row),
|
|
343
|
+
api_token_cipher: row.api_token_cipher,
|
|
344
|
+
api_token_key_ver: row.api_token_key_ver
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
var SQL_FIND_BANK_ACCOUNT_BY_PAIRING_CODE = `SELECT ${BANK_ACCOUNT_PUBLIC_COLS} FROM bank_accounts WHERE pairing_code = ?`;
|
|
348
|
+
async function findBankAccountByPairingCode(db, pairingCode) {
|
|
349
|
+
const row = await db.prepare(SQL_FIND_BANK_ACCOUNT_BY_PAIRING_CODE).bind(pairingCode).first();
|
|
350
|
+
return row ? mapBankAccount(row) : null;
|
|
351
|
+
}
|
|
352
|
+
var SQL_PAIRING_EXISTS = `SELECT 1 FROM bank_accounts WHERE pairing_code = ? LIMIT 1`;
|
|
353
|
+
async function pairingCodeExists(db, pairingCode) {
|
|
354
|
+
const r = await db.prepare(SQL_PAIRING_EXISTS).bind(pairingCode).first();
|
|
355
|
+
return r !== null;
|
|
356
|
+
}
|
|
357
|
+
var SQL_FIND_BANK_ACCOUNT_BY_ID = `SELECT ${BANK_ACCOUNT_PUBLIC_COLS} FROM bank_accounts WHERE id = ?`;
|
|
358
|
+
async function findBankAccountById(db, id) {
|
|
359
|
+
const row = await db.prepare(SQL_FIND_BANK_ACCOUNT_BY_ID).bind(id).first();
|
|
360
|
+
return row ? mapBankAccount(row) : null;
|
|
361
|
+
}
|
|
362
|
+
var SQL_CREATE_BANK_ACCOUNT = `
|
|
363
|
+
INSERT INTO bank_accounts (
|
|
364
|
+
account_number, account_type, ingest_mode, pairing_code, label, owner_app_id, cf_rule_id,
|
|
365
|
+
api_token_cipher, api_token_key_ver, api_token_prefix, api_fetch_enabled
|
|
366
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
367
|
+
RETURNING ${BANK_ACCOUNT_PUBLIC_COLS}
|
|
368
|
+
`;
|
|
369
|
+
async function createBankAccount(db, args) {
|
|
370
|
+
const row = await db.prepare(SQL_CREATE_BANK_ACCOUNT).bind(
|
|
371
|
+
args.account_number,
|
|
372
|
+
args.account_type ?? "FIO",
|
|
373
|
+
args.ingest_mode ?? "email",
|
|
374
|
+
args.pairing_code,
|
|
375
|
+
args.label ?? null,
|
|
376
|
+
args.owner_app_id ?? null,
|
|
377
|
+
args.cf_rule_id ?? null,
|
|
378
|
+
args.api_token_cipher ?? null,
|
|
379
|
+
args.api_token_key_ver ?? null,
|
|
380
|
+
args.api_token_prefix ?? null,
|
|
381
|
+
args.api_fetch_enabled ? 1 : 0
|
|
382
|
+
).first();
|
|
383
|
+
if (!row) throw new Error("createBankAccount: no row returned");
|
|
384
|
+
return mapBankAccount(row);
|
|
385
|
+
}
|
|
386
|
+
var SQL_LIST_BANK_ACCOUNTS = `SELECT ${BANK_ACCOUNT_PUBLIC_COLS} FROM bank_accounts ORDER BY id`;
|
|
387
|
+
var SQL_LIST_BANK_ACCOUNTS_BY_OWNER = `SELECT ${BANK_ACCOUNT_PUBLIC_COLS} FROM bank_accounts WHERE owner_app_id = ? ORDER BY id`;
|
|
388
|
+
async function listBankAccounts(db, ownerAppId) {
|
|
389
|
+
if (ownerAppId !== void 0) {
|
|
390
|
+
const r = await db.prepare(SQL_LIST_BANK_ACCOUNTS_BY_OWNER).bind(ownerAppId).all();
|
|
391
|
+
return r.results.map(mapBankAccount);
|
|
392
|
+
}
|
|
393
|
+
const result = await db.prepare(SQL_LIST_BANK_ACCOUNTS).all();
|
|
394
|
+
return result.results.map(mapBankAccount);
|
|
395
|
+
}
|
|
396
|
+
var SQL_UPDATE_BANK_ACCOUNT = `
|
|
397
|
+
UPDATE bank_accounts
|
|
398
|
+
SET account_number = COALESCE(?, account_number),
|
|
399
|
+
account_type = COALESCE(?, account_type),
|
|
400
|
+
label = COALESCE(?, label),
|
|
401
|
+
ingest_mode = COALESCE(?, ingest_mode)
|
|
402
|
+
WHERE id = ?
|
|
403
|
+
RETURNING ${BANK_ACCOUNT_PUBLIC_COLS}
|
|
404
|
+
`;
|
|
405
|
+
async function updateBankAccount(db, id, patch) {
|
|
406
|
+
const row = await db.prepare(SQL_UPDATE_BANK_ACCOUNT).bind(patch.account_number ?? null, patch.account_type ?? null, patch.label === void 0 ? null : patch.label, patch.ingest_mode ?? null, id).first();
|
|
407
|
+
return row ? mapBankAccount(row) : null;
|
|
408
|
+
}
|
|
409
|
+
var SQL_UPDATE_BANK_ACCOUNT_OWNER = `
|
|
410
|
+
UPDATE bank_accounts
|
|
411
|
+
SET owner_app_id = ?
|
|
412
|
+
WHERE id = ?
|
|
413
|
+
RETURNING ${BANK_ACCOUNT_PUBLIC_COLS}
|
|
414
|
+
`;
|
|
415
|
+
async function updateBankAccountOwner(db, id, ownerAppId) {
|
|
416
|
+
const row = await db.prepare(SQL_UPDATE_BANK_ACCOUNT_OWNER).bind(ownerAppId, id).first();
|
|
417
|
+
return row ? mapBankAccount(row) : null;
|
|
418
|
+
}
|
|
419
|
+
var SQL_REGENERATE_PAIRING = `UPDATE bank_accounts SET pairing_code = ?, cf_rule_id = ? WHERE id = ? RETURNING ${BANK_ACCOUNT_PUBLIC_COLS}`;
|
|
420
|
+
async function regenerateBankAccountPairing(db, id, newPairing, newCfRuleId) {
|
|
421
|
+
const row = await db.prepare(SQL_REGENERATE_PAIRING).bind(newPairing, newCfRuleId, id).first();
|
|
422
|
+
return row ? mapBankAccount(row) : null;
|
|
423
|
+
}
|
|
424
|
+
var SQL_SET_BANK_ACCOUNT_API_TOKEN = `
|
|
425
|
+
UPDATE bank_accounts
|
|
426
|
+
SET api_token_cipher = ?,
|
|
427
|
+
api_token_key_ver = ?,
|
|
428
|
+
api_token_prefix = ?,
|
|
429
|
+
api_fetch_enabled = ?,
|
|
430
|
+
ingest_mode = COALESCE(?, ingest_mode),
|
|
431
|
+
api_last_error = NULL
|
|
432
|
+
WHERE id = ?
|
|
433
|
+
RETURNING ${BANK_ACCOUNT_PUBLIC_COLS}
|
|
434
|
+
`;
|
|
435
|
+
async function setBankAccountApiToken(db, id, args) {
|
|
436
|
+
const row = await db.prepare(SQL_SET_BANK_ACCOUNT_API_TOKEN).bind(args.token_cipher, args.token_key_ver, args.token_prefix, args.fetch_enabled ? 1 : 0, args.ingest_mode ?? null, id).first();
|
|
437
|
+
return row ? mapBankAccount(row) : null;
|
|
438
|
+
}
|
|
439
|
+
var SQL_SET_BANK_ACCOUNT_API_FETCH_ENABLED = `
|
|
440
|
+
UPDATE bank_accounts
|
|
441
|
+
SET api_fetch_enabled = ?,
|
|
442
|
+
ingest_mode = COALESCE(?, ingest_mode)
|
|
443
|
+
WHERE id = ?
|
|
444
|
+
RETURNING ${BANK_ACCOUNT_PUBLIC_COLS}
|
|
445
|
+
`;
|
|
446
|
+
async function setBankAccountApiFetchEnabled(db, id, args) {
|
|
447
|
+
const row = await db.prepare(SQL_SET_BANK_ACCOUNT_API_FETCH_ENABLED).bind(args.fetch_enabled ? 1 : 0, args.ingest_mode ?? null, id).first();
|
|
448
|
+
return row ? mapBankAccount(row) : null;
|
|
449
|
+
}
|
|
450
|
+
var SQL_CLEAR_BANK_ACCOUNT_API_TOKEN = `
|
|
451
|
+
UPDATE bank_accounts
|
|
452
|
+
SET api_token_cipher = NULL,
|
|
453
|
+
api_token_key_ver = NULL,
|
|
454
|
+
api_token_prefix = NULL,
|
|
455
|
+
api_fetch_enabled = 0,
|
|
456
|
+
ingest_mode = 'email',
|
|
457
|
+
api_last_error = NULL
|
|
458
|
+
WHERE id = ?
|
|
459
|
+
RETURNING ${BANK_ACCOUNT_PUBLIC_COLS}
|
|
460
|
+
`;
|
|
461
|
+
async function clearBankAccountApiToken(db, id) {
|
|
462
|
+
const row = await db.prepare(SQL_CLEAR_BANK_ACCOUNT_API_TOKEN).bind(id).first();
|
|
463
|
+
return row ? mapBankAccount(row) : null;
|
|
464
|
+
}
|
|
465
|
+
var SQL_FIND_BANK_ACCOUNT_API_MATERIALS = `
|
|
466
|
+
SELECT ${BANK_ACCOUNT_PUBLIC_COLS}, api_token_cipher, api_token_key_ver
|
|
467
|
+
FROM bank_accounts
|
|
468
|
+
WHERE id = ? AND api_token_cipher IS NOT NULL AND api_token_key_ver IS NOT NULL
|
|
469
|
+
`;
|
|
470
|
+
async function findBankAccountApiMaterials(db, id) {
|
|
471
|
+
const row = await db.prepare(SQL_FIND_BANK_ACCOUNT_API_MATERIALS).bind(id).first();
|
|
472
|
+
return row ? mapApiFetchAccount(row) : null;
|
|
473
|
+
}
|
|
474
|
+
var SQL_LIST_DUE_API_ACCOUNTS = `
|
|
475
|
+
SELECT ${BANK_ACCOUNT_PUBLIC_COLS}, api_token_cipher, api_token_key_ver
|
|
476
|
+
FROM bank_accounts
|
|
477
|
+
WHERE api_fetch_enabled = 1
|
|
478
|
+
AND api_token_cipher IS NOT NULL
|
|
479
|
+
AND api_token_key_ver IS NOT NULL
|
|
480
|
+
AND ingest_mode IN ('api', 'both')
|
|
481
|
+
AND (api_last_fetch_at IS NULL OR (julianday('now') - julianday(api_last_fetch_at)) * 86400 >= ?)
|
|
482
|
+
ORDER BY COALESCE(api_last_fetch_at, '1970-01-01T00:00:00.000Z'), id
|
|
483
|
+
LIMIT ?
|
|
484
|
+
`;
|
|
485
|
+
async function listDueApiFetchAccounts(db, minIntervalS, limit = 20) {
|
|
486
|
+
const result = await db.prepare(SQL_LIST_DUE_API_ACCOUNTS).bind(minIntervalS, limit).all();
|
|
487
|
+
return result.results.map(mapApiFetchAccount);
|
|
488
|
+
}
|
|
489
|
+
async function tryAcquireApiSyncLease(db, leaseSeconds) {
|
|
490
|
+
await db.prepare(`INSERT OR IGNORE INTO schema_meta (key, value) VALUES ('api_sync_lease_until', '1970-01-01 00:00:00')`).run();
|
|
491
|
+
const result = await db.prepare(`
|
|
492
|
+
UPDATE schema_meta
|
|
493
|
+
SET value = datetime('now', ?)
|
|
494
|
+
WHERE key = 'api_sync_lease_until'
|
|
495
|
+
AND datetime(value) <= datetime('now')
|
|
496
|
+
`).bind(`+${Math.max(1, Math.floor(leaseSeconds))} seconds`).run();
|
|
497
|
+
return (result.meta.changes ?? 0) > 0;
|
|
498
|
+
}
|
|
499
|
+
var SQL_IS_BANK_ACCOUNT_API_DUE = `
|
|
500
|
+
SELECT 1
|
|
501
|
+
FROM bank_accounts
|
|
502
|
+
WHERE id = ?
|
|
503
|
+
AND (api_last_fetch_at IS NULL OR (julianday('now') - julianday(api_last_fetch_at)) * 86400 >= ?)
|
|
504
|
+
LIMIT 1
|
|
505
|
+
`;
|
|
506
|
+
async function isBankAccountApiFetchDue(db, id, minIntervalS) {
|
|
507
|
+
const row = await db.prepare(SQL_IS_BANK_ACCOUNT_API_DUE).bind(id, minIntervalS).first();
|
|
508
|
+
return row !== null;
|
|
509
|
+
}
|
|
510
|
+
async function markBankAccountApiFetchStarted(db, id) {
|
|
511
|
+
await db.prepare(`UPDATE bank_accounts SET api_last_fetch_at = datetime('now') WHERE id = ?`).bind(id).run();
|
|
512
|
+
}
|
|
513
|
+
async function markBankAccountApiPointerSet(db, id) {
|
|
514
|
+
await db.prepare(`
|
|
515
|
+
UPDATE bank_accounts
|
|
516
|
+
SET api_last_fetch_at = datetime('now'),
|
|
517
|
+
api_last_success_at = datetime('now'),
|
|
518
|
+
api_last_error = NULL,
|
|
519
|
+
api_backfill_done = 0
|
|
520
|
+
WHERE id = ?
|
|
521
|
+
`).bind(id).run();
|
|
522
|
+
}
|
|
523
|
+
async function markBankAccountApiFetchSuccess(db, id, args) {
|
|
524
|
+
await db.prepare(`
|
|
525
|
+
UPDATE bank_accounts
|
|
526
|
+
SET api_last_fetch_at = datetime('now'),
|
|
527
|
+
api_last_success_at = datetime('now'),
|
|
528
|
+
api_last_error = NULL,
|
|
529
|
+
api_backfill_done = ?
|
|
530
|
+
WHERE id = ?
|
|
531
|
+
`).bind(args.backfill_done ? 1 : 0, id).run();
|
|
532
|
+
}
|
|
533
|
+
async function markBankAccountApiFetchFailure(db, id, error) {
|
|
534
|
+
await db.prepare(`UPDATE bank_accounts SET api_last_fetch_at = datetime('now'), api_last_error = ? WHERE id = ?`).bind(error.slice(0, 500), id).run();
|
|
535
|
+
}
|
|
536
|
+
async function deleteBankAccount(db, id) {
|
|
537
|
+
await db.prepare(`DELETE FROM webhook_subscriptions WHERE bank_account_id = ?`).bind(id).run();
|
|
538
|
+
const r = await db.prepare(`DELETE FROM bank_accounts WHERE id = ?`).bind(id).run();
|
|
539
|
+
return r.meta.changes > 0;
|
|
540
|
+
}
|
|
541
|
+
var SQL_CREATE_CONSUMER = `INSERT INTO webhook_consumers (app_id, callback_url, secret_cipher, secret_hash, secret_prefix) VALUES (?, ?, ?, ?, ?) RETURNING id, app_id, callback_url, secret_cipher, secret_hash, secret_prefix, prev_secret_cipher, prev_expires_at, admin_key_hash, admin_key_prefix, created_at`;
|
|
542
|
+
async function createConsumer(db, args) {
|
|
543
|
+
const row = await db.prepare(SQL_CREATE_CONSUMER).bind(args.app_id, args.callback_url, args.secret_cipher, args.secret_hash, args.secret_prefix).first();
|
|
544
|
+
if (!row) throw new Error("createConsumer: no row returned");
|
|
545
|
+
return row;
|
|
546
|
+
}
|
|
547
|
+
var SQL_FIND_CONSUMER_BY_APP_ID = `SELECT id, app_id, callback_url, secret_cipher, secret_hash, secret_prefix, prev_secret_cipher, prev_expires_at, admin_key_hash, admin_key_prefix, created_at FROM webhook_consumers WHERE app_id = ?`;
|
|
548
|
+
var SQL_FIND_CONSUMER_BY_ADMIN_KEY_PREFIX = `SELECT id, app_id, callback_url, secret_cipher, secret_hash, secret_prefix, prev_secret_cipher, prev_expires_at, admin_key_hash, admin_key_prefix, created_at FROM webhook_consumers WHERE admin_key_prefix = ?`;
|
|
549
|
+
async function findConsumerByAdminKeyPrefix(db, prefix) {
|
|
550
|
+
const row = await db.prepare(SQL_FIND_CONSUMER_BY_ADMIN_KEY_PREFIX).bind(prefix).first();
|
|
551
|
+
return row ?? null;
|
|
552
|
+
}
|
|
553
|
+
var SQL_SET_CONSUMER_ADMIN_KEY = `UPDATE webhook_consumers SET admin_key_hash = ?, admin_key_prefix = ? WHERE app_id = ?`;
|
|
554
|
+
async function setConsumerAdminKey(db, appId, hash, prefix) {
|
|
555
|
+
await db.prepare(SQL_SET_CONSUMER_ADMIN_KEY).bind(hash, prefix, appId).run();
|
|
556
|
+
}
|
|
557
|
+
async function findConsumerByAppId(db, appId) {
|
|
558
|
+
const row = await db.prepare(SQL_FIND_CONSUMER_BY_APP_ID).bind(appId).first();
|
|
559
|
+
return row ?? null;
|
|
560
|
+
}
|
|
561
|
+
var SQL_ROTATE_SECRET = `UPDATE webhook_consumers SET prev_secret_cipher = secret_cipher, prev_expires_at = ?, secret_cipher = ?, secret_hash = ?, secret_prefix = ? WHERE app_id = ?`;
|
|
562
|
+
async function rotateConsumerSecret(db, appId, rotated) {
|
|
563
|
+
const result = await db.prepare(SQL_ROTATE_SECRET).bind(rotated.prevExpiresAt, rotated.newCipher, rotated.newHash, rotated.newPrefix, appId).run();
|
|
564
|
+
if (result.meta.changes === 0) throw new Error(`rotateConsumerSecret: app_id not found: ${appId}`);
|
|
565
|
+
}
|
|
566
|
+
var SQL_UPDATE_CONSUMER_CALLBACK = `UPDATE webhook_consumers SET callback_url = ? WHERE app_id = ? RETURNING id, app_id, callback_url, secret_cipher, secret_hash, secret_prefix, prev_secret_cipher, prev_expires_at, admin_key_hash, admin_key_prefix, created_at`;
|
|
567
|
+
async function updateConsumer(db, appId, input) {
|
|
568
|
+
const row = await db.prepare(SQL_UPDATE_CONSUMER_CALLBACK).bind(input.callback_url, appId).first();
|
|
569
|
+
return row ?? null;
|
|
570
|
+
}
|
|
571
|
+
var SQL_DELETE_CONSUMER = `DELETE FROM webhook_consumers WHERE app_id = ?`;
|
|
572
|
+
async function deleteConsumer(db, appId) {
|
|
573
|
+
await db.prepare(SQL_DELETE_CONSUMER).bind(appId).run();
|
|
574
|
+
}
|
|
575
|
+
var SQL_COUNT_SUBSCRIPTIONS = `SELECT COUNT(*) as cnt FROM webhook_subscriptions WHERE bank_account_id = ? AND deleted_at IS NULL`;
|
|
576
|
+
var SQL_CREATE_SUBSCRIPTION = `
|
|
577
|
+
INSERT INTO webhook_subscriptions (bank_account_id, consumer_app_id) VALUES (?, ?)
|
|
578
|
+
ON CONFLICT(bank_account_id, consumer_app_id) DO UPDATE SET deleted_at = NULL, created_at = datetime('now')
|
|
579
|
+
WHERE webhook_subscriptions.deleted_at IS NOT NULL
|
|
580
|
+
RETURNING id, bank_account_id, consumer_app_id, created_at`;
|
|
581
|
+
var SQL_FIND_ACTIVE_SUBSCRIPTION = `SELECT id, bank_account_id, consumer_app_id, created_at FROM webhook_subscriptions WHERE bank_account_id = ? AND consumer_app_id = ? AND deleted_at IS NULL`;
|
|
582
|
+
async function createSubscription(db, args) {
|
|
583
|
+
const countRow = await db.prepare(SQL_COUNT_SUBSCRIPTIONS).bind(args.bank_account_id).first();
|
|
584
|
+
if ((countRow?.cnt ?? 0) >= 20) throw new Error("subscription_cap_reached:20");
|
|
585
|
+
const row = await db.prepare(SQL_CREATE_SUBSCRIPTION).bind(args.bank_account_id, args.app_id).first();
|
|
586
|
+
if (row) return row;
|
|
587
|
+
const existing = await db.prepare(SQL_FIND_ACTIVE_SUBSCRIPTION).bind(args.bank_account_id, args.app_id).first();
|
|
588
|
+
if (existing) throw new Error("subscription_already_exists");
|
|
589
|
+
throw new Error("createSubscription: no row returned");
|
|
590
|
+
}
|
|
591
|
+
var SQL_DELETE_SUBSCRIPTION = `UPDATE webhook_subscriptions SET deleted_at = datetime('now') WHERE id = ? AND deleted_at IS NULL`;
|
|
592
|
+
async function deleteSubscription(db, id) {
|
|
593
|
+
await db.prepare(SQL_DELETE_SUBSCRIPTION).bind(id).run();
|
|
594
|
+
}
|
|
595
|
+
var SQL_INSERT_TRANSACTION = `
|
|
596
|
+
INSERT OR IGNORE INTO transactions (
|
|
597
|
+
bank_account_id, amount_cents, currency, counter_account, bank_code, bank_name,
|
|
598
|
+
vs, ks, ss, message, sender_name, user_identification, transaction_type,
|
|
599
|
+
performed_by, comment, command_id, source, date, date_offset_min,
|
|
600
|
+
transaction_id, external_id
|
|
601
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
602
|
+
`;
|
|
603
|
+
var SQL_SELECT_TRANSACTION_BY_ID = `SELECT id, bank_account_id, amount_cents, currency, counter_account, bank_code, bank_name, vs, ks, ss, message, sender_name, user_identification, transaction_type, performed_by, comment, command_id, source, date, date_offset_min, transaction_id, external_id FROM transactions WHERE id = ?`;
|
|
604
|
+
var SQL_FIND_DUPLICATE_EXT = `SELECT id FROM transactions WHERE external_id = ? AND external_id IS NOT NULL LIMIT 1`;
|
|
605
|
+
var SQL_FIND_DUPLICATE_FIO = `SELECT id FROM transactions WHERE bank_account_id = ? AND transaction_id = ? AND transaction_id IS NOT NULL LIMIT 1`;
|
|
606
|
+
var SQL_FIND_DUPLICATE_FUZZY = `
|
|
607
|
+
SELECT id
|
|
608
|
+
FROM transactions
|
|
609
|
+
WHERE bank_account_id = ?
|
|
610
|
+
AND vs = ?
|
|
611
|
+
AND amount_cents = ?
|
|
612
|
+
AND currency = ?
|
|
613
|
+
AND substr(date, 1, 10) BETWEEN date(?, '-3 days') AND date(?, '+3 days')
|
|
614
|
+
LIMIT 1
|
|
615
|
+
`;
|
|
616
|
+
function fuzzyDateDay(date) {
|
|
617
|
+
return /^\d{4}-\d{2}-\d{2}/.test(date) ? date.slice(0, 10) : null;
|
|
618
|
+
}
|
|
619
|
+
async function findFuzzyDuplicate(db, bankAccountId, p) {
|
|
620
|
+
if (!p.vs) return false;
|
|
621
|
+
const dateDay = fuzzyDateDay(p.date);
|
|
622
|
+
if (!dateDay) return false;
|
|
623
|
+
const hit = await db.prepare(SQL_FIND_DUPLICATE_FUZZY).bind(bankAccountId, p.vs, p.amount_cents, p.currency, dateDay, dateDay).first();
|
|
624
|
+
return hit !== null;
|
|
625
|
+
}
|
|
626
|
+
async function insertTransaction(db, args) {
|
|
627
|
+
const p = {
|
|
628
|
+
...args.payload,
|
|
629
|
+
vs: resolveVariableSymbol(args.payload)
|
|
630
|
+
};
|
|
631
|
+
if (p.transaction_id != null) {
|
|
632
|
+
const hit = await db.prepare(SQL_FIND_DUPLICATE_FIO).bind(args.bank_account_id, p.transaction_id).first();
|
|
633
|
+
if (hit) return { status: "skipped", reason: "duplicate_transaction_id" };
|
|
634
|
+
}
|
|
635
|
+
if (p.external_id != null) {
|
|
636
|
+
const hit = await db.prepare(SQL_FIND_DUPLICATE_EXT).bind(p.external_id).first();
|
|
637
|
+
if (hit) return { status: "skipped", reason: "duplicate_external_id" };
|
|
638
|
+
}
|
|
639
|
+
if (await findFuzzyDuplicate(db, args.bank_account_id, p)) {
|
|
640
|
+
return { status: "skipped", reason: "fuzzy_duplicate" };
|
|
641
|
+
}
|
|
642
|
+
const result = await db.prepare(SQL_INSERT_TRANSACTION).bind(
|
|
643
|
+
args.bank_account_id,
|
|
644
|
+
p.amount_cents,
|
|
645
|
+
p.currency,
|
|
646
|
+
p.counter_account ?? null,
|
|
647
|
+
p.bank_code ?? null,
|
|
648
|
+
p.bank_name ?? null,
|
|
649
|
+
p.vs ?? null,
|
|
650
|
+
p.ks ?? null,
|
|
651
|
+
p.ss ?? null,
|
|
652
|
+
p.message ?? null,
|
|
653
|
+
p.sender_name ?? null,
|
|
654
|
+
p.user_identification ?? null,
|
|
655
|
+
p.transaction_type ?? null,
|
|
656
|
+
p.performed_by ?? null,
|
|
657
|
+
p.comment ?? null,
|
|
658
|
+
p.command_id ?? null,
|
|
659
|
+
p.source,
|
|
660
|
+
p.date,
|
|
661
|
+
p.date_offset_min ?? null,
|
|
662
|
+
p.transaction_id ?? null,
|
|
663
|
+
p.external_id ?? null
|
|
664
|
+
).run();
|
|
665
|
+
if (result.meta.changes === 0) {
|
|
666
|
+
if (p.transaction_id != null) {
|
|
667
|
+
const hit = await db.prepare(SQL_FIND_DUPLICATE_FIO).bind(args.bank_account_id, p.transaction_id).first();
|
|
668
|
+
if (hit) return { status: "skipped", reason: "duplicate_transaction_id" };
|
|
669
|
+
}
|
|
670
|
+
if (p.external_id != null) {
|
|
671
|
+
const hit = await db.prepare(SQL_FIND_DUPLICATE_EXT).bind(p.external_id).first();
|
|
672
|
+
if (hit) return { status: "skipped", reason: "duplicate_external_id" };
|
|
673
|
+
}
|
|
674
|
+
if (await findFuzzyDuplicate(db, args.bank_account_id, p)) {
|
|
675
|
+
return { status: "skipped", reason: "fuzzy_duplicate" };
|
|
676
|
+
}
|
|
677
|
+
return { status: "skipped", reason: "fuzzy_duplicate" };
|
|
678
|
+
}
|
|
679
|
+
const rowId = result.meta.last_row_id;
|
|
680
|
+
const row = await db.prepare(SQL_SELECT_TRANSACTION_BY_ID).bind(rowId).first();
|
|
681
|
+
if (!row) throw new Error("insertTransaction: row vanished after insert");
|
|
682
|
+
return { status: "inserted", transaction: row };
|
|
683
|
+
}
|
|
684
|
+
var SQL_INSERT_PARSE_LOG = `INSERT INTO parse_log (bank_account_id, external_id, raw_data, error_message) VALUES (?, ?, ?, ?)`;
|
|
685
|
+
async function insertParseLog(db, args) {
|
|
686
|
+
try {
|
|
687
|
+
await db.prepare(SQL_INSERT_PARSE_LOG).bind(args.bank_account_id ?? null, args.external_id ?? null, args.raw_data ?? null, args.error_message).run();
|
|
688
|
+
} catch (err) {
|
|
689
|
+
console.error(JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), event: "parse_log_write_failed", error: String(err) }));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
var SQL_GET_CONSUMER_SECRET = `SELECT callback_url, secret_cipher, prev_secret_cipher, prev_expires_at FROM webhook_consumers WHERE app_id = ?`;
|
|
693
|
+
async function getConsumerSecretMaterials(db, appId) {
|
|
694
|
+
const row = await db.prepare(SQL_GET_CONSUMER_SECRET).bind(appId).first();
|
|
695
|
+
if (!row) return null;
|
|
696
|
+
const inGrace = row.prev_expires_at != null && row.prev_secret_cipher != null ? row.prev_expires_at > (/* @__PURE__ */ new Date()).toISOString() : false;
|
|
697
|
+
return {
|
|
698
|
+
callback_url: row.callback_url,
|
|
699
|
+
primary_cipher: row.secret_cipher,
|
|
700
|
+
prev_cipher_in_grace: inGrace ? row.prev_secret_cipher : null
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
var SQL_INSERT_WEBHOOK_LOG = `INSERT INTO webhook_log (delivery_id, consumer_app_id, bank_account_id, transaction_id, attempt, http_status, error_message) VALUES (?, ?, ?, ?, ?, ?, ?)`;
|
|
704
|
+
async function insertWebhookLogEntry(db, args) {
|
|
705
|
+
await db.prepare(SQL_INSERT_WEBHOOK_LOG).bind(
|
|
706
|
+
args.delivery_id,
|
|
707
|
+
args.consumer_app_id,
|
|
708
|
+
args.bank_account_id ?? null,
|
|
709
|
+
args.transaction_id ?? null,
|
|
710
|
+
args.attempt,
|
|
711
|
+
args.http_status ?? null,
|
|
712
|
+
args.error_message ?? null
|
|
713
|
+
).run();
|
|
714
|
+
}
|
|
715
|
+
var SQL_WRITE_EVENT = `INSERT INTO event_log (event_type, bank_account_id, detail) VALUES (?, ?, ?)`;
|
|
716
|
+
async function writeEvent(db, args) {
|
|
717
|
+
await db.prepare(SQL_WRITE_EVENT).bind(args.event_type, args.bank_account_id ?? null, args.detail != null ? JSON.stringify(args.detail) : null).run();
|
|
718
|
+
}
|
|
719
|
+
var SQL_LIST_CONSUMERS = `SELECT id, app_id, callback_url, secret_prefix, admin_key_prefix, created_at FROM webhook_consumers ORDER BY id`;
|
|
720
|
+
async function listConsumers(db) {
|
|
721
|
+
const result = await db.prepare(SQL_LIST_CONSUMERS).all();
|
|
722
|
+
return result.results;
|
|
723
|
+
}
|
|
724
|
+
var SQL_LIST_SUBSCRIPTIONS_ALL = `SELECT id, bank_account_id, consumer_app_id, created_at FROM webhook_subscriptions ORDER BY id`;
|
|
725
|
+
var SQL_LIST_SUBSCRIPTIONS_BY_APP = `SELECT id, bank_account_id, consumer_app_id, created_at FROM webhook_subscriptions WHERE consumer_app_id = ? ORDER BY id`;
|
|
726
|
+
var SQL_LIST_SUBSCRIPTIONS_BY_ACCOUNT = `SELECT id, bank_account_id, consumer_app_id, created_at FROM webhook_subscriptions WHERE bank_account_id = ? ORDER BY id`;
|
|
727
|
+
async function listSubscriptions(db, filters) {
|
|
728
|
+
if (filters?.app_id) {
|
|
729
|
+
const r2 = await db.prepare(SQL_LIST_SUBSCRIPTIONS_BY_APP).bind(filters.app_id).all();
|
|
730
|
+
return r2.results;
|
|
731
|
+
}
|
|
732
|
+
if (filters?.bank_account_id != null) {
|
|
733
|
+
const r2 = await db.prepare(SQL_LIST_SUBSCRIPTIONS_BY_ACCOUNT).bind(filters.bank_account_id).all();
|
|
734
|
+
return r2.results;
|
|
735
|
+
}
|
|
736
|
+
const r = await db.prepare(SQL_LIST_SUBSCRIPTIONS_ALL).all();
|
|
737
|
+
return r.results;
|
|
738
|
+
}
|
|
739
|
+
var SQL_LIST_TRANSACTIONS = `SELECT id, bank_account_id, amount_cents, currency, vs, source, date, external_id, transaction_id, sender_name, counter_account, bank_code, bank_name, message, created_at FROM transactions WHERE created_at > datetime(?) ORDER BY id LIMIT ?`;
|
|
740
|
+
var SQL_LIST_TRANSACTIONS_BY_OWNER = `SELECT t.id, t.bank_account_id, t.amount_cents, t.currency, t.vs, t.source, t.date, t.external_id, t.transaction_id, t.sender_name, t.counter_account, t.bank_code, t.bank_name, t.message, t.created_at FROM transactions t JOIN bank_accounts b ON b.id = t.bank_account_id WHERE b.owner_app_id = ? AND t.created_at > datetime(?) ORDER BY t.id LIMIT ?`;
|
|
741
|
+
async function listTransactions(db, since, limit, ownerAppId) {
|
|
742
|
+
const r = ownerAppId ? await db.prepare(SQL_LIST_TRANSACTIONS_BY_OWNER).bind(ownerAppId, since, limit).all() : await db.prepare(SQL_LIST_TRANSACTIONS).bind(since, limit).all();
|
|
743
|
+
return r.results;
|
|
744
|
+
}
|
|
745
|
+
var SQL_LIST_PARSE_LOG = `SELECT id, bank_account_id, external_id, error_message, raw_data, created_at FROM parse_log WHERE created_at > datetime(?) ORDER BY id DESC LIMIT ?`;
|
|
746
|
+
var SQL_LIST_PARSE_LOG_BY_OWNER = `SELECT p.id, p.bank_account_id, p.external_id, p.error_message, p.raw_data, p.created_at FROM parse_log p JOIN bank_accounts b ON b.id = p.bank_account_id WHERE b.owner_app_id = ? AND p.created_at > datetime(?) ORDER BY p.id DESC LIMIT ?`;
|
|
747
|
+
async function listParseLog(db, since, limit, ownerAppId) {
|
|
748
|
+
const r = ownerAppId ? await db.prepare(SQL_LIST_PARSE_LOG_BY_OWNER).bind(ownerAppId, since, limit).all() : await db.prepare(SQL_LIST_PARSE_LOG).bind(since, limit).all();
|
|
749
|
+
return r.results;
|
|
750
|
+
}
|
|
751
|
+
var SQL_LIST_UNMATCHED_MAILS = `SELECT id, external_id, error_message, raw_data, created_at FROM parse_log WHERE (error_message LIKE 'no_pairing_code:%' OR error_message LIKE 'unknown_pairing_code:%') AND created_at > datetime(?) ORDER BY id DESC LIMIT ?`;
|
|
752
|
+
async function listUnmatchedMails(db, since, limit) {
|
|
753
|
+
const r = await db.prepare(SQL_LIST_UNMATCHED_MAILS).bind(since, limit).all();
|
|
754
|
+
return r.results;
|
|
755
|
+
}
|
|
756
|
+
var SQL_LIST_WEBHOOK_LOG = `SELECT id, delivery_id, consumer_app_id, http_status, error_message, attempt, created_at FROM webhook_log WHERE created_at > datetime(?) ORDER BY id LIMIT ?`;
|
|
757
|
+
var SQL_LIST_WEBHOOK_LOG_BY_CONSUMER = `SELECT id, delivery_id, consumer_app_id, http_status, error_message, attempt, created_at FROM webhook_log WHERE consumer_app_id = ? AND created_at > datetime(?) ORDER BY id LIMIT ?`;
|
|
758
|
+
async function listWebhookLog(db, since, limit, consumerAppId) {
|
|
759
|
+
const r = consumerAppId ? await db.prepare(SQL_LIST_WEBHOOK_LOG_BY_CONSUMER).bind(consumerAppId, since, limit).all() : await db.prepare(SQL_LIST_WEBHOOK_LOG).bind(since, limit).all();
|
|
760
|
+
return r.results;
|
|
761
|
+
}
|
|
762
|
+
var SQL_PARSE_FAILURES_24H = `SELECT COUNT(*) as cnt FROM parse_log
|
|
763
|
+
WHERE created_at > datetime('now','-1 day')
|
|
764
|
+
AND error_message NOT LIKE 'not_transaction:%'
|
|
765
|
+
AND error_message NOT IN ('email_identity_mismatch', 'email_ingest_disabled_untrusted_authserv')`;
|
|
766
|
+
var SQL_NOT_TRANSACTION_24H = `SELECT COUNT(*) as cnt FROM parse_log WHERE error_message LIKE 'not_transaction:%' AND created_at > datetime('now','-1 day')`;
|
|
767
|
+
var SQL_TX_INSERTED_24H = `SELECT COUNT(*) as cnt FROM event_log WHERE event_type = 'tx_inserted' AND created_at > datetime('now','-1 day')`;
|
|
768
|
+
var SQL_UNKNOWN_CURRENCY_24H = `SELECT COUNT(*) as cnt FROM parse_log WHERE error_message LIKE 'unknown_currency:%' AND created_at > datetime('now','-1 day')`;
|
|
769
|
+
var SQL_OUTGOING_FILTERED_24H = `SELECT COUNT(*) as cnt FROM parse_log WHERE error_message = 'outgoing_filtered' AND created_at > datetime('now','-1 day')`;
|
|
770
|
+
var SQL_UNKNOWN_PROVIDER_24H = `SELECT COUNT(*) as cnt FROM parse_log WHERE error_message LIKE 'unknown_provider:%' AND created_at > datetime('now','-1 day')`;
|
|
771
|
+
var SQL_UNMATCHED_24H = `SELECT COUNT(*) as cnt FROM parse_log WHERE (error_message LIKE 'no_pairing_code:%' OR error_message LIKE 'unknown_pairing_code:%') AND created_at > datetime('now','-1 day')`;
|
|
772
|
+
var SQL_DELIVERY_ACTIVE = `SELECT COUNT(*) as cnt FROM webhook_delivery_jobs WHERE status IN ('pending', 'dispatching', 'queued')`;
|
|
773
|
+
var SQL_DELIVERY_STALLED = `SELECT COUNT(*) as cnt FROM webhook_delivery_jobs WHERE status IN ('pending', 'dispatching', 'queued') AND julianday(created_at) <= julianday('now', '-10 minutes')`;
|
|
774
|
+
var SQL_DELIVERY_TERMINAL = `SELECT COUNT(*) as cnt FROM webhook_delivery_jobs WHERE status = 'terminal'`;
|
|
775
|
+
var SQL_DELIVERY_OLDEST_PENDING = `SELECT MIN(created_at) as ts FROM webhook_delivery_jobs WHERE status IN ('pending', 'dispatching', 'queued')`;
|
|
776
|
+
var SQL_PENDING_DELIVERY_ALERTS = `SELECT COUNT(*) as cnt FROM webhook_delivery_alerts WHERE posted_at IS NULL`;
|
|
777
|
+
var SQL_PER_ACCOUNT_TX_7D = `SELECT bank_account_id, COUNT(*) as cnt FROM transactions WHERE created_at > datetime('now','-7 days') GROUP BY bank_account_id`;
|
|
778
|
+
var SQL_PER_ACCOUNT_PARSE_ERRORS_24H = `SELECT bank_account_id, COUNT(*) as cnt FROM parse_log WHERE bank_account_id IS NOT NULL AND created_at > datetime('now','-1 day') GROUP BY bank_account_id`;
|
|
779
|
+
var SQL_PER_ACCOUNT_LAST_EMAIL = `SELECT bank_account_id, MAX(created_at) as ts FROM event_log WHERE event_type = 'tx_inserted' GROUP BY bank_account_id`;
|
|
780
|
+
var SQL_API_RATE_LIMITED_24H = `SELECT COUNT(*) as cnt FROM event_log WHERE event_type = 'api_rate_limited' AND created_at > datetime('now','-1 day')`;
|
|
781
|
+
var SQL_PER_CONSUMER_WEBHOOK_LAST = `SELECT consumer_app_id, MAX(created_at) as ts FROM webhook_log GROUP BY consumer_app_id`;
|
|
782
|
+
var SQL_PER_CONSUMER_WEBHOOKS_24H = `SELECT consumer_app_id, COUNT(*) as cnt FROM webhook_log WHERE created_at > datetime('now','-1 day') GROUP BY consumer_app_id`;
|
|
783
|
+
var SQL_PER_CONSUMER_ERRORS_24H = `SELECT consumer_app_id, COUNT(*) as cnt FROM webhook_log WHERE http_status IS NOT NULL AND (http_status < 200 OR http_status >= 300) AND created_at > datetime('now','-1 day') GROUP BY consumer_app_id`;
|
|
784
|
+
var SQL_PER_CONSUMER_DELIVERED = `SELECT consumer_app_id, MAX(delivered_at) as ts FROM webhook_delivery_jobs WHERE delivered_at IS NOT NULL GROUP BY consumer_app_id`;
|
|
785
|
+
async function getStatusData(db) {
|
|
786
|
+
const [
|
|
787
|
+
failures,
|
|
788
|
+
txInserted24h,
|
|
789
|
+
unknownCurr,
|
|
790
|
+
outgoing,
|
|
791
|
+
unknownProv,
|
|
792
|
+
unmatched,
|
|
793
|
+
deliveryActive,
|
|
794
|
+
deliveryStalled,
|
|
795
|
+
deliveryTerminal,
|
|
796
|
+
oldestUndelivered,
|
|
797
|
+
pendingAlerts,
|
|
798
|
+
notTransaction,
|
|
799
|
+
apiRateLimited,
|
|
800
|
+
accountRows,
|
|
801
|
+
txPer7d,
|
|
802
|
+
parseErrPerAcct,
|
|
803
|
+
lastEmailPerAcct,
|
|
804
|
+
consumerRows,
|
|
805
|
+
webhookLast,
|
|
806
|
+
webhooks24h,
|
|
807
|
+
webhookErrors24h,
|
|
808
|
+
deliveredPerConsumer
|
|
809
|
+
] = await Promise.all([
|
|
810
|
+
db.prepare(SQL_PARSE_FAILURES_24H).first(),
|
|
811
|
+
db.prepare(SQL_TX_INSERTED_24H).first(),
|
|
812
|
+
db.prepare(SQL_UNKNOWN_CURRENCY_24H).first(),
|
|
813
|
+
db.prepare(SQL_OUTGOING_FILTERED_24H).first(),
|
|
814
|
+
db.prepare(SQL_UNKNOWN_PROVIDER_24H).first(),
|
|
815
|
+
db.prepare(SQL_UNMATCHED_24H).first(),
|
|
816
|
+
db.prepare(SQL_DELIVERY_ACTIVE).first(),
|
|
817
|
+
db.prepare(SQL_DELIVERY_STALLED).first(),
|
|
818
|
+
db.prepare(SQL_DELIVERY_TERMINAL).first(),
|
|
819
|
+
db.prepare(SQL_DELIVERY_OLDEST_PENDING).first(),
|
|
820
|
+
db.prepare(SQL_PENDING_DELIVERY_ALERTS).first(),
|
|
821
|
+
db.prepare(SQL_NOT_TRANSACTION_24H).first(),
|
|
822
|
+
db.prepare(SQL_API_RATE_LIMITED_24H).first(),
|
|
823
|
+
db.prepare(SQL_LIST_BANK_ACCOUNTS).all(),
|
|
824
|
+
db.prepare(SQL_PER_ACCOUNT_TX_7D).all(),
|
|
825
|
+
db.prepare(SQL_PER_ACCOUNT_PARSE_ERRORS_24H).all(),
|
|
826
|
+
db.prepare(SQL_PER_ACCOUNT_LAST_EMAIL).all(),
|
|
827
|
+
db.prepare(SQL_LIST_CONSUMERS).all(),
|
|
828
|
+
db.prepare(SQL_PER_CONSUMER_WEBHOOK_LAST).all(),
|
|
829
|
+
db.prepare(SQL_PER_CONSUMER_WEBHOOKS_24H).all(),
|
|
830
|
+
db.prepare(SQL_PER_CONSUMER_ERRORS_24H).all(),
|
|
831
|
+
db.prepare(SQL_PER_CONSUMER_DELIVERED).all()
|
|
832
|
+
]);
|
|
833
|
+
const failuresCount = failures?.cnt ?? 0;
|
|
834
|
+
const insertedCount = txInserted24h?.cnt ?? 0;
|
|
835
|
+
const total24h = failuresCount + insertedCount;
|
|
836
|
+
const tx7dMap = new Map(txPer7d.results.map((r) => [r.bank_account_id, r.cnt]));
|
|
837
|
+
const parseErrMap = new Map(parseErrPerAcct.results.map((r) => [r.bank_account_id, r.cnt]));
|
|
838
|
+
const lastEmailMap = new Map(lastEmailPerAcct.results.map((r) => [r.bank_account_id, r.ts]));
|
|
839
|
+
const webhookLastMap = new Map(webhookLast.results.map((r) => [r.consumer_app_id, r.ts]));
|
|
840
|
+
const webhooks24hMap = new Map(webhooks24h.results.map((r) => [r.consumer_app_id, r.cnt]));
|
|
841
|
+
const webhookErrMap = new Map(webhookErrors24h.results.map((r) => [r.consumer_app_id, r.cnt]));
|
|
842
|
+
const deliveredMap = new Map(deliveredPerConsumer.results.map((r) => [r.consumer_app_id, r.ts]));
|
|
843
|
+
return {
|
|
844
|
+
service: {
|
|
845
|
+
parse_failures_24h: failuresCount,
|
|
846
|
+
parse_failure_rate_24h: total24h > 0 ? Math.round(failuresCount / total24h * 1e4) / 1e4 : 0,
|
|
847
|
+
unknown_currency_24h: unknownCurr?.cnt ?? 0,
|
|
848
|
+
outgoing_filtered_24h: outgoing?.cnt ?? 0,
|
|
849
|
+
unknown_provider_24h: unknownProv?.cnt ?? 0,
|
|
850
|
+
unmatched_24h: unmatched?.cnt ?? 0,
|
|
851
|
+
not_transaction_24h: notTransaction?.cnt ?? 0
|
|
852
|
+
},
|
|
853
|
+
bank_accounts: accountRows.results.map((a) => ({
|
|
854
|
+
id: a.id,
|
|
855
|
+
label: a.label ?? null,
|
|
856
|
+
account_type: a.account_type,
|
|
857
|
+
last_email_at: lastEmailMap.get(a.id) ?? null,
|
|
858
|
+
last_api_pull_at: a.api_last_success_at ?? null,
|
|
859
|
+
tx_count_7d: tx7dMap.get(a.id) ?? 0,
|
|
860
|
+
parse_errors_24h: parseErrMap.get(a.id) ?? 0
|
|
861
|
+
})),
|
|
862
|
+
consumers: consumerRows.results.map((c) => ({
|
|
863
|
+
app_id: c.app_id,
|
|
864
|
+
last_webhook_at: deliveredMap.get(c.app_id) ?? webhookLastMap.get(c.app_id) ?? null,
|
|
865
|
+
webhooks_24h: webhooks24hMap.get(c.app_id) ?? 0,
|
|
866
|
+
webhook_errors_24h: webhookErrMap.get(c.app_id) ?? 0
|
|
867
|
+
})),
|
|
868
|
+
queues: {
|
|
869
|
+
main_pending: 0,
|
|
870
|
+
// Cloudflare Analytics binding not available in basic setup
|
|
871
|
+
delivery_active: deliveryActive?.cnt ?? 0,
|
|
872
|
+
delivery_stalled: deliveryStalled?.cnt ?? 0,
|
|
873
|
+
delivery_terminal: deliveryTerminal?.cnt ?? 0,
|
|
874
|
+
oldest_undelivered_at: oldestUndelivered?.ts ?? null,
|
|
875
|
+
pending_delivery_alerts: pendingAlerts?.cnt ?? 0,
|
|
876
|
+
api_rate_limited_24h: apiRateLimited?.cnt ?? 0
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
var SQL_PRUNE_PARSE_LOG = `DELETE FROM parse_log WHERE created_at < datetime('now', '-30 days')`;
|
|
881
|
+
var SQL_PRUNE_WEBHOOK_LOG = `DELETE FROM webhook_log WHERE created_at < datetime('now', '-90 days')`;
|
|
882
|
+
var SQL_PRUNE_EVENT_LOG = `DELETE FROM event_log WHERE created_at < datetime('now', '-30 days')`;
|
|
883
|
+
var SQL_PRUNE_TRANSACTIONS = `DELETE FROM transactions WHERE created_at < datetime('now', '-90 days')`;
|
|
884
|
+
var SQL_PRUNE_IDEMPOTENCY_KEYS = `DELETE FROM idempotency_keys WHERE created_at < datetime('now', '-24 hours')`;
|
|
885
|
+
var SQL_PRUNE_ADMIN_AUDIT_LOG = `DELETE FROM admin_audit_log WHERE created_at < datetime('now', '-90 days')`;
|
|
886
|
+
var SQL_CLEAR_EXPIRED_PREV_SECRETS = `UPDATE webhook_consumers SET prev_secret_cipher = NULL, prev_expires_at = NULL WHERE prev_expires_at < datetime('now')`;
|
|
887
|
+
async function pruneRetention(db) {
|
|
888
|
+
const parseLogResult = await db.prepare(SQL_PRUNE_PARSE_LOG).run();
|
|
889
|
+
const webhookLogResult = await db.prepare(SQL_PRUNE_WEBHOOK_LOG).run();
|
|
890
|
+
const eventLogResult = await db.prepare(SQL_PRUNE_EVENT_LOG).run();
|
|
891
|
+
const txResult = await db.prepare(SQL_PRUNE_TRANSACTIONS).run();
|
|
892
|
+
const idempotencyResult = await db.prepare(SQL_PRUNE_IDEMPOTENCY_KEYS).run();
|
|
893
|
+
const auditResult = await db.prepare(SQL_PRUNE_ADMIN_AUDIT_LOG).run();
|
|
894
|
+
const secretsResult = await db.prepare(SQL_CLEAR_EXPIRED_PREV_SECRETS).run();
|
|
895
|
+
return {
|
|
896
|
+
parse_log_deleted: parseLogResult.meta.changes,
|
|
897
|
+
webhook_log_deleted: webhookLogResult.meta.changes,
|
|
898
|
+
event_log_deleted: eventLogResult.meta.changes,
|
|
899
|
+
expired_prev_secrets_cleared: secretsResult.meta.changes,
|
|
900
|
+
transactions_deleted: txResult.meta.changes,
|
|
901
|
+
idempotency_keys_deleted: idempotencyResult.meta.changes,
|
|
902
|
+
admin_audit_log_deleted: auditResult.meta.changes
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// src/validation.ts
|
|
907
|
+
import * as v from "valibot";
|
|
908
|
+
var ValidationError = class extends Error {
|
|
909
|
+
constructor(issues, message) {
|
|
910
|
+
super(message);
|
|
911
|
+
this.issues = issues;
|
|
912
|
+
this.name = "ValidationError";
|
|
913
|
+
}
|
|
914
|
+
issues;
|
|
915
|
+
};
|
|
916
|
+
function parseBody(schema, body) {
|
|
917
|
+
try {
|
|
918
|
+
return v.parse(schema, body);
|
|
919
|
+
} catch (err) {
|
|
920
|
+
if (err instanceof v.ValiError) {
|
|
921
|
+
const message = `validation_failed: ${err.issues[0]?.message ?? "unknown"}`;
|
|
922
|
+
throw new ValidationError(err.issues, message);
|
|
923
|
+
}
|
|
924
|
+
throw err;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
var CreateBankAccountSchema = v.object({
|
|
928
|
+
account_number: v.pipe(v.string(), v.minLength(3), v.maxLength(64)),
|
|
929
|
+
account_type: v.optional(v.picklist(["FIO", "AIRBANK"])),
|
|
930
|
+
ingest_mode: v.optional(v.picklist(["email", "api", "both"])),
|
|
931
|
+
fio_api_token: v.optional(v.pipe(v.string(), v.minLength(8), v.maxLength(512))),
|
|
932
|
+
label: v.optional(v.pipe(v.string(), v.maxLength(120))),
|
|
933
|
+
/** owner consumer app_id — must already exist as a registered consumer (otherwise FK fails). */
|
|
934
|
+
owner_app_id: v.pipe(v.string(), v.regex(/^[a-z0-9][a-z0-9-_]{1,63}$/))
|
|
935
|
+
});
|
|
936
|
+
var UpdateBankAccountSchema = v.pipe(
|
|
937
|
+
v.object({
|
|
938
|
+
account_number: v.optional(v.pipe(v.string(), v.minLength(3), v.maxLength(64))),
|
|
939
|
+
account_type: v.optional(v.picklist(["FIO", "AIRBANK"])),
|
|
940
|
+
ingest_mode: v.optional(v.picklist(["email", "api", "both"])),
|
|
941
|
+
label: v.optional(v.union([v.pipe(v.string(), v.maxLength(120)), v.null_()]))
|
|
942
|
+
}),
|
|
943
|
+
v.check(
|
|
944
|
+
(o) => o.account_number !== void 0 || o.account_type !== void 0 || o.ingest_mode !== void 0 || o.label !== void 0,
|
|
945
|
+
"at least one of account_number, account_type, ingest_mode, label must be provided"
|
|
946
|
+
)
|
|
947
|
+
);
|
|
948
|
+
var UpdateBankAccountOwnerSchema = v.object({
|
|
949
|
+
owner_app_id: v.pipe(v.string(), v.regex(/^[a-z0-9][a-z0-9-_]{1,63}$/))
|
|
950
|
+
});
|
|
951
|
+
var UpdateFioTokenSchema = v.pipe(
|
|
952
|
+
v.object({
|
|
953
|
+
fio_api_token: v.optional(v.pipe(v.string(), v.maxLength(512))),
|
|
954
|
+
fetch_enabled: v.optional(v.boolean()),
|
|
955
|
+
ingest_mode: v.optional(v.picklist(["api", "both"]))
|
|
956
|
+
}),
|
|
957
|
+
v.check(
|
|
958
|
+
(o) => o.fio_api_token !== void 0 || o.fetch_enabled !== void 0 || o.ingest_mode !== void 0,
|
|
959
|
+
"at least one of fio_api_token, fetch_enabled, ingest_mode must be provided"
|
|
960
|
+
)
|
|
961
|
+
);
|
|
962
|
+
var CreateConsumerSchema = v.object({
|
|
963
|
+
app_id: v.pipe(v.string(), v.regex(/^[a-z0-9][a-z0-9-_]{1,63}$/)),
|
|
964
|
+
callback_url: v.pipe(v.string(), v.url(), v.regex(/^https:\/\//))
|
|
965
|
+
});
|
|
966
|
+
var UpdateConsumerSchema = v.object({
|
|
967
|
+
callback_url: v.pipe(v.string(), v.url(), v.regex(/^https:\/\//))
|
|
968
|
+
});
|
|
969
|
+
var SPECIAL_HOSTS = /* @__PURE__ */ new Set([
|
|
970
|
+
"localhost",
|
|
971
|
+
"localhost.localdomain",
|
|
972
|
+
"local",
|
|
973
|
+
"internal",
|
|
974
|
+
"test",
|
|
975
|
+
"invalid",
|
|
976
|
+
"example",
|
|
977
|
+
"onion",
|
|
978
|
+
"home.arpa"
|
|
979
|
+
]);
|
|
980
|
+
var SPECIAL_SUFFIXES = [".localhost", ".local", ".internal", ".test", ".invalid", ".example", ".onion", ".home.arpa"];
|
|
981
|
+
function isIpLiteral(hostname) {
|
|
982
|
+
if (hostname.startsWith("[") || hostname.endsWith("]")) return true;
|
|
983
|
+
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname);
|
|
984
|
+
}
|
|
985
|
+
function validateCallbackUrl(raw, options) {
|
|
986
|
+
let url2;
|
|
987
|
+
try {
|
|
988
|
+
url2 = new URL(raw);
|
|
989
|
+
} catch {
|
|
990
|
+
throw new ValidationError([], "invalid_callback_url");
|
|
991
|
+
}
|
|
992
|
+
const hostname = url2.hostname.toLowerCase().replace(/\.$/, "");
|
|
993
|
+
if (url2.protocol !== "https:" || url2.username || url2.password || url2.hash || !hostname || SPECIAL_HOSTS.has(hostname) || SPECIAL_SUFFIXES.some((suffix) => hostname.endsWith(suffix)) || isIpLiteral(hostname)) {
|
|
994
|
+
throw new ValidationError([], "invalid_callback_url");
|
|
995
|
+
}
|
|
996
|
+
url2.hostname = hostname;
|
|
997
|
+
const allowed = (options.allowlist ?? "").split(",").map((value) => value.trim().toLowerCase().replace(/\.$/, "")).filter(Boolean);
|
|
998
|
+
if (options.environment === "production" && (!allowed.length || !allowed.includes(hostname))) {
|
|
999
|
+
throw new ValidationError([], "callback_host_not_allowed");
|
|
1000
|
+
}
|
|
1001
|
+
return url2.toString();
|
|
1002
|
+
}
|
|
1003
|
+
var CreateSubscriptionSchema = v.object({
|
|
1004
|
+
app_id: v.pipe(v.string(), v.regex(/^[a-z0-9][a-z0-9-_]{1,63}$/)),
|
|
1005
|
+
bank_account_id: v.pipe(v.number(), v.integer(), v.minValue(1))
|
|
1006
|
+
});
|
|
1007
|
+
var ReplayWebhookSchema = v.pipe(
|
|
1008
|
+
v.object({
|
|
1009
|
+
tx_id: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
|
|
1010
|
+
delivery_id: v.optional(v.pipe(v.string(), v.regex(/^[0-9A-HJKMNP-TV-Z]{26}$/))),
|
|
1011
|
+
// A replay must carry an operator reason; it is audited with the actor. An
|
|
1012
|
+
// already-delivered job is only re-driven when force=true.
|
|
1013
|
+
reason: v.pipe(v.string(), v.trim(), v.minLength(3), v.maxLength(500)),
|
|
1014
|
+
force: v.optional(v.boolean())
|
|
1015
|
+
}),
|
|
1016
|
+
v.check(
|
|
1017
|
+
(obj) => obj.tx_id !== void 0 || obj.delivery_id !== void 0,
|
|
1018
|
+
"tx_id or delivery_id required"
|
|
1019
|
+
)
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
// src/deliveryPolicy.ts
|
|
1023
|
+
var MAX_AUTOMATIC_HTTP_ATTEMPTS = 32;
|
|
1024
|
+
function classifyHttpStatus(status) {
|
|
1025
|
+
if (status >= 200 && status <= 299) return "delivered";
|
|
1026
|
+
if (status >= 300 && status <= 399) return "terminal";
|
|
1027
|
+
if (status === 408 || status === 429 || status >= 500) return "retryable";
|
|
1028
|
+
if (status >= 400 && status <= 499) return "terminal";
|
|
1029
|
+
return "retryable";
|
|
1030
|
+
}
|
|
1031
|
+
function retryDelaySeconds(httpAttempt) {
|
|
1032
|
+
return Math.min(30 * 2 ** Math.max(0, httpAttempt - 1), 3600);
|
|
1033
|
+
}
|
|
1034
|
+
function backoffSeconds(attempts) {
|
|
1035
|
+
return Math.min(30 * 2 ** (attempts - 1), 1800);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// src/webhookDelivery.ts
|
|
1039
|
+
import { ulid } from "ulid";
|
|
1040
|
+
|
|
1041
|
+
// src/alertOutbox.ts
|
|
1042
|
+
var STALLED_GRACE_SECONDS = 10 * 60;
|
|
1043
|
+
var CLAIM_LEASE_SECONDS = 60;
|
|
1044
|
+
function incidentPayload(kind, job, service) {
|
|
1045
|
+
return JSON.stringify({
|
|
1046
|
+
service,
|
|
1047
|
+
incident_kind: kind,
|
|
1048
|
+
delivery_job_id: job.id,
|
|
1049
|
+
delivery_id: job.delivery_id,
|
|
1050
|
+
consumer_app_id: job.consumer_app_id,
|
|
1051
|
+
status: job.status,
|
|
1052
|
+
last_error: job.last_error ?? null,
|
|
1053
|
+
last_http_status: job.last_http_status ?? null,
|
|
1054
|
+
detected_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
async function enqueue(db, row) {
|
|
1058
|
+
await db.prepare(`
|
|
1059
|
+
INSERT OR IGNORE INTO webhook_delivery_alerts
|
|
1060
|
+
(delivery_job_id, incident_kind, incident_key, payload)
|
|
1061
|
+
VALUES (?, ?, ?, ?)
|
|
1062
|
+
`).bind(row.deliveryJobId, row.kind, row.incidentKey, row.payload).run();
|
|
1063
|
+
}
|
|
1064
|
+
async function enqueueTerminalIncident(db, job, service) {
|
|
1065
|
+
await enqueue(db, {
|
|
1066
|
+
deliveryJobId: job.id,
|
|
1067
|
+
kind: "terminal",
|
|
1068
|
+
incidentKey: `job:${job.id}:terminal:${job.incident_version}`,
|
|
1069
|
+
payload: incidentPayload("terminal", job, service)
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
async function enqueueRecoveryIfOpen(db, job, service) {
|
|
1073
|
+
const open = await db.prepare(`
|
|
1074
|
+
SELECT id FROM webhook_delivery_alerts
|
|
1075
|
+
WHERE delivery_job_id = ? AND incident_kind IN ('terminal', 'stalled') AND posted_at IS NOT NULL
|
|
1076
|
+
AND NOT EXISTS (
|
|
1077
|
+
SELECT 1 FROM webhook_delivery_alerts r
|
|
1078
|
+
WHERE r.delivery_job_id = webhook_delivery_alerts.delivery_job_id
|
|
1079
|
+
AND r.incident_kind = 'recovered'
|
|
1080
|
+
AND r.incident_key = 'job:' || webhook_delivery_alerts.delivery_job_id || ':recovered:' || webhook_delivery_alerts.id
|
|
1081
|
+
)
|
|
1082
|
+
ORDER BY id DESC LIMIT 1
|
|
1083
|
+
`).bind(job.id).first();
|
|
1084
|
+
if (!open) return;
|
|
1085
|
+
await enqueue(db, {
|
|
1086
|
+
deliveryJobId: job.id,
|
|
1087
|
+
kind: "recovered",
|
|
1088
|
+
incidentKey: `job:${job.id}:recovered:${open.id}`,
|
|
1089
|
+
payload: incidentPayload("recovered", job, service)
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
async function detectStalledIncidents(db, service) {
|
|
1093
|
+
const rows = await db.prepare(`
|
|
1094
|
+
SELECT j.id, j.delivery_id, j.consumer_app_id, j.incident_version, j.last_error, j.last_http_status, j.status
|
|
1095
|
+
FROM webhook_delivery_jobs j
|
|
1096
|
+
WHERE j.status IN ('pending', 'dispatching', 'queued')
|
|
1097
|
+
AND datetime(j.created_at) <= datetime('now', '-${STALLED_GRACE_SECONDS} seconds')
|
|
1098
|
+
AND NOT EXISTS (
|
|
1099
|
+
SELECT 1 FROM webhook_delivery_alerts a
|
|
1100
|
+
WHERE a.delivery_job_id = j.id AND a.incident_kind = 'stalled'
|
|
1101
|
+
AND a.incident_key = 'job:' || j.id || ':stalled:' || j.incident_version
|
|
1102
|
+
)
|
|
1103
|
+
LIMIT 100
|
|
1104
|
+
`).all();
|
|
1105
|
+
for (const job of rows.results) {
|
|
1106
|
+
await enqueue(db, {
|
|
1107
|
+
deliveryJobId: job.id,
|
|
1108
|
+
kind: "stalled",
|
|
1109
|
+
incidentKey: `job:${job.id}:stalled:${job.incident_version}`,
|
|
1110
|
+
payload: incidentPayload("stalled", job, service)
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
const recovered = await db.prepare(`
|
|
1114
|
+
SELECT DISTINCT j.id, j.delivery_id, j.consumer_app_id, j.incident_version,
|
|
1115
|
+
j.last_error, j.last_http_status, j.status
|
|
1116
|
+
FROM webhook_delivery_jobs j
|
|
1117
|
+
JOIN webhook_delivery_alerts a ON a.delivery_job_id = j.id
|
|
1118
|
+
WHERE j.status = 'delivered'
|
|
1119
|
+
AND a.incident_kind IN ('terminal', 'stalled')
|
|
1120
|
+
AND a.posted_at IS NOT NULL
|
|
1121
|
+
AND NOT EXISTS (
|
|
1122
|
+
SELECT 1 FROM webhook_delivery_alerts r
|
|
1123
|
+
WHERE r.delivery_job_id = a.delivery_job_id
|
|
1124
|
+
AND r.incident_kind = 'recovered'
|
|
1125
|
+
AND r.incident_key = 'job:' || a.delivery_job_id || ':recovered:' || a.id
|
|
1126
|
+
)
|
|
1127
|
+
LIMIT 100
|
|
1128
|
+
`).all();
|
|
1129
|
+
for (const job of recovered.results) await enqueueRecoveryIfOpen(db, job, service);
|
|
1130
|
+
return rows.results.length;
|
|
1131
|
+
}
|
|
1132
|
+
function backoffSeconds2(attempts) {
|
|
1133
|
+
return Math.min(30 * 2 ** Math.max(0, attempts), 1800);
|
|
1134
|
+
}
|
|
1135
|
+
async function drainDeliveryAlerts(db, cfg, limit = 20) {
|
|
1136
|
+
if (!cfg.webhookUrl) return { posted: 0, failed: 0 };
|
|
1137
|
+
const due = await db.prepare(`
|
|
1138
|
+
SELECT id, payload, post_attempts, incident_key
|
|
1139
|
+
FROM webhook_delivery_alerts
|
|
1140
|
+
WHERE posted_at IS NULL
|
|
1141
|
+
AND datetime(next_attempt_at) <= datetime('now')
|
|
1142
|
+
AND (lease_until IS NULL OR datetime(lease_until) <= datetime('now'))
|
|
1143
|
+
ORDER BY next_attempt_at, id
|
|
1144
|
+
LIMIT ?
|
|
1145
|
+
`).bind(limit).all();
|
|
1146
|
+
let posted = 0;
|
|
1147
|
+
let failed = 0;
|
|
1148
|
+
for (const alert of due.results) {
|
|
1149
|
+
const token = crypto.randomUUID();
|
|
1150
|
+
const claim = await db.prepare(`
|
|
1151
|
+
UPDATE webhook_delivery_alerts
|
|
1152
|
+
SET lease_until = datetime('now', '+${CLAIM_LEASE_SECONDS} seconds'), dispatch_token = ?
|
|
1153
|
+
WHERE id = ? AND posted_at IS NULL
|
|
1154
|
+
AND (lease_until IS NULL OR datetime(lease_until) <= datetime('now'))
|
|
1155
|
+
`).bind(token, alert.id).run();
|
|
1156
|
+
if (claim.meta.changes !== 1) continue;
|
|
1157
|
+
let ok = false;
|
|
1158
|
+
try {
|
|
1159
|
+
const res = await fetch(cfg.webhookUrl, {
|
|
1160
|
+
method: "POST",
|
|
1161
|
+
headers: {
|
|
1162
|
+
"Content-Type": "application/json",
|
|
1163
|
+
...cfg.webhookSecret ? { Authorization: `Bearer ${cfg.webhookSecret}` } : {}
|
|
1164
|
+
},
|
|
1165
|
+
body: alert.payload
|
|
1166
|
+
});
|
|
1167
|
+
ok = res.ok;
|
|
1168
|
+
if (!ok) throw new Error(`HTTP ${res.status}`);
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
failed++;
|
|
1171
|
+
await db.prepare(`
|
|
1172
|
+
UPDATE webhook_delivery_alerts
|
|
1173
|
+
SET post_attempts = post_attempts + 1, lease_until = NULL,
|
|
1174
|
+
next_attempt_at = datetime('now', '+' || ? || ' seconds'), last_error = ?
|
|
1175
|
+
WHERE id = ? AND dispatch_token = ?
|
|
1176
|
+
`).bind(backoffSeconds2(alert.post_attempts + 1), String(err), alert.id, token).run();
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
await db.prepare(`
|
|
1180
|
+
UPDATE webhook_delivery_alerts
|
|
1181
|
+
SET posted_at = datetime('now'), lease_until = NULL, last_error = NULL
|
|
1182
|
+
WHERE id = ? AND dispatch_token = ?
|
|
1183
|
+
`).bind(alert.id, token).run();
|
|
1184
|
+
posted++;
|
|
1185
|
+
}
|
|
1186
|
+
if (posted > 0 || failed > 0) log("delivery_alerts_drained", { posted, failed });
|
|
1187
|
+
return { posted, failed };
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// src/webhookDelivery.ts
|
|
1191
|
+
var QUEUE_OWNERSHIP_HORIZON_SECONDS = 2 * 60 * 60;
|
|
1192
|
+
async function sha256Hex(input) {
|
|
1193
|
+
const bytes = new TextEncoder().encode(input);
|
|
1194
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
1195
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1196
|
+
}
|
|
1197
|
+
function newDispatchToken() {
|
|
1198
|
+
return crypto.randomUUID();
|
|
1199
|
+
}
|
|
1200
|
+
async function ensureDeliveryJobs(db, transactionId) {
|
|
1201
|
+
const candidates = await db.prepare(`
|
|
1202
|
+
SELECT t.*, b.pairing_code, s.consumer_app_id
|
|
1203
|
+
FROM transactions t
|
|
1204
|
+
JOIN bank_accounts b ON b.id = t.bank_account_id
|
|
1205
|
+
JOIN webhook_subscriptions s ON s.bank_account_id = t.bank_account_id
|
|
1206
|
+
WHERE (? IS NULL OR t.id = ?)
|
|
1207
|
+
AND NOT EXISTS (
|
|
1208
|
+
SELECT 1 FROM webhook_delivery_jobs j
|
|
1209
|
+
WHERE j.transaction_id = t.id AND j.consumer_app_id = s.consumer_app_id
|
|
1210
|
+
AND j.event_kind = 'transaction.received'
|
|
1211
|
+
)
|
|
1212
|
+
-- Intent is decided by the subscription interval at ingest, not by the
|
|
1213
|
+
-- current active-subscription view. Exact UTC datetime (not julianday) so
|
|
1214
|
+
-- a same-second create/delete/transaction is deterministic.
|
|
1215
|
+
AND datetime(s.created_at) <= datetime(t.created_at)
|
|
1216
|
+
AND (s.deleted_at IS NULL OR datetime(t.created_at) < datetime(s.deleted_at))
|
|
1217
|
+
`).bind(transactionId ?? null, transactionId ?? null).all();
|
|
1218
|
+
let created = 0;
|
|
1219
|
+
for (const row of candidates.results) {
|
|
1220
|
+
const deliveryId = ulid();
|
|
1221
|
+
const envelope = buildWebhookEnvelope({
|
|
1222
|
+
delivery_id: deliveryId,
|
|
1223
|
+
pairing_code: row.pairing_code,
|
|
1224
|
+
transaction: row
|
|
1225
|
+
});
|
|
1226
|
+
const payload = JSON.stringify(envelope);
|
|
1227
|
+
const result = await db.prepare(`
|
|
1228
|
+
INSERT OR IGNORE INTO webhook_delivery_jobs
|
|
1229
|
+
(transaction_id, consumer_app_id, event_kind, delivery_id, payload, payload_sha256, status, next_attempt_at)
|
|
1230
|
+
VALUES (?, ?, 'transaction.received', ?, ?, ?, 'pending', datetime('now'))
|
|
1231
|
+
`).bind(row.id, row.consumer_app_id, deliveryId, payload, await sha256Hex(payload)).run();
|
|
1232
|
+
created += result.meta.changes;
|
|
1233
|
+
}
|
|
1234
|
+
return created;
|
|
1235
|
+
}
|
|
1236
|
+
async function dueJobs(db, limit) {
|
|
1237
|
+
const result = await db.prepare(`
|
|
1238
|
+
SELECT id, transaction_id, consumer_app_id, event_kind, delivery_id, payload,
|
|
1239
|
+
payload_sha256, status, generation, dispatch_token, http_attempt_count
|
|
1240
|
+
FROM webhook_delivery_jobs
|
|
1241
|
+
WHERE status IN ('pending', 'dispatching', 'queued')
|
|
1242
|
+
AND datetime(next_attempt_at) <= datetime('now')
|
|
1243
|
+
AND (lease_until IS NULL OR datetime(lease_until) <= datetime('now'))
|
|
1244
|
+
ORDER BY next_attempt_at, id
|
|
1245
|
+
LIMIT ?
|
|
1246
|
+
`).bind(limit).all();
|
|
1247
|
+
return result.results;
|
|
1248
|
+
}
|
|
1249
|
+
async function claimJob(db, jobId) {
|
|
1250
|
+
const token = newDispatchToken();
|
|
1251
|
+
const result = await db.prepare(`
|
|
1252
|
+
UPDATE webhook_delivery_jobs
|
|
1253
|
+
SET status = 'dispatching',
|
|
1254
|
+
dispatch_token = ?,
|
|
1255
|
+
dispatch_count = dispatch_count + 1,
|
|
1256
|
+
lease_until = datetime('now', '+${QUEUE_OWNERSHIP_HORIZON_SECONDS} seconds'),
|
|
1257
|
+
last_error = NULL
|
|
1258
|
+
WHERE id = ?
|
|
1259
|
+
AND status IN ('pending', 'dispatching', 'queued')
|
|
1260
|
+
AND datetime(next_attempt_at) <= datetime('now')
|
|
1261
|
+
AND (lease_until IS NULL OR datetime(lease_until) <= datetime('now'))
|
|
1262
|
+
`).bind(token, jobId).run();
|
|
1263
|
+
return result.meta.changes === 1 ? token : null;
|
|
1264
|
+
}
|
|
1265
|
+
async function enqueueClaimedJob(env, jobId, token) {
|
|
1266
|
+
const job = await env.DB.prepare(`
|
|
1267
|
+
SELECT id, transaction_id, consumer_app_id, event_kind, delivery_id, payload,
|
|
1268
|
+
payload_sha256, status, generation, dispatch_token
|
|
1269
|
+
FROM webhook_delivery_jobs WHERE id = ?
|
|
1270
|
+
`).bind(jobId).first();
|
|
1271
|
+
if (!job || job.dispatch_token !== token) return false;
|
|
1272
|
+
let envelope;
|
|
1273
|
+
try {
|
|
1274
|
+
if (job.payload_sha256 && await sha256Hex(job.payload) !== job.payload_sha256) {
|
|
1275
|
+
throw new Error("payload_hash_mismatch");
|
|
1276
|
+
}
|
|
1277
|
+
envelope = JSON.parse(job.payload);
|
|
1278
|
+
} catch (parseErr) {
|
|
1279
|
+
await recordDeliveryOutcome(env.DB, {
|
|
1280
|
+
deliveryJobId: job.id,
|
|
1281
|
+
generation: job.generation,
|
|
1282
|
+
dispatchToken: token,
|
|
1283
|
+
kind: "terminal",
|
|
1284
|
+
error: `invalid_delivery_payload:${String(parseErr)}`
|
|
1285
|
+
});
|
|
1286
|
+
return false;
|
|
1287
|
+
}
|
|
1288
|
+
try {
|
|
1289
|
+
await env.WEBHOOK_QUEUE.send({
|
|
1290
|
+
message_version: 2,
|
|
1291
|
+
delivery_job_id: job.id,
|
|
1292
|
+
delivery_id: job.delivery_id,
|
|
1293
|
+
consumer_app_id: job.consumer_app_id,
|
|
1294
|
+
event_kind: job.event_kind,
|
|
1295
|
+
bank_account_id: envelope.data.bank_account_id,
|
|
1296
|
+
transaction_id: job.transaction_id,
|
|
1297
|
+
generation: job.generation,
|
|
1298
|
+
dispatch_token: token,
|
|
1299
|
+
envelope
|
|
1300
|
+
});
|
|
1301
|
+
} catch (sendErr) {
|
|
1302
|
+
await env.DB.prepare(`
|
|
1303
|
+
UPDATE webhook_delivery_jobs
|
|
1304
|
+
SET status = 'pending', lease_until = NULL, next_attempt_at = datetime('now'),
|
|
1305
|
+
last_error = ?
|
|
1306
|
+
WHERE id = ? AND generation = ? AND dispatch_token = ? AND status = 'dispatching'
|
|
1307
|
+
`).bind(`queue_send_failed:${String(sendErr)}`, job.id, job.generation, token).run();
|
|
1308
|
+
return false;
|
|
1309
|
+
}
|
|
1310
|
+
try {
|
|
1311
|
+
await env.DB.prepare(`
|
|
1312
|
+
UPDATE webhook_delivery_jobs
|
|
1313
|
+
SET status = 'queued', lease_until = NULL,
|
|
1314
|
+
next_attempt_at = datetime('now', '+${QUEUE_OWNERSHIP_HORIZON_SECONDS} seconds'),
|
|
1315
|
+
last_error = NULL
|
|
1316
|
+
WHERE id = ? AND generation = ? AND dispatch_token = ? AND status = 'dispatching'
|
|
1317
|
+
`).bind(job.id, job.generation, token).run();
|
|
1318
|
+
} catch {
|
|
1319
|
+
}
|
|
1320
|
+
return true;
|
|
1321
|
+
}
|
|
1322
|
+
async function dispatchDeliveryJob(env, jobId) {
|
|
1323
|
+
const token = await claimJob(env.DB, jobId);
|
|
1324
|
+
if (!token) return false;
|
|
1325
|
+
return enqueueClaimedJob(env, jobId, token);
|
|
1326
|
+
}
|
|
1327
|
+
async function dispatchDueDeliveryJobs(env, limit = 50) {
|
|
1328
|
+
const jobs = await dueJobs(env.DB, limit);
|
|
1329
|
+
let queued = 0;
|
|
1330
|
+
let failed = 0;
|
|
1331
|
+
for (const job of jobs) {
|
|
1332
|
+
if (await dispatchDeliveryJob(env, job.id)) queued++;
|
|
1333
|
+
else failed++;
|
|
1334
|
+
}
|
|
1335
|
+
return { considered: jobs.length, queued, failed };
|
|
1336
|
+
}
|
|
1337
|
+
async function recordDeliveryOutcome(db, args) {
|
|
1338
|
+
const { deliveryJobId, generation, dispatchToken, kind } = args;
|
|
1339
|
+
const httpStatus = args.httpStatus ?? null;
|
|
1340
|
+
let changes = 0;
|
|
1341
|
+
if (kind === "delivered") {
|
|
1342
|
+
const res = await db.prepare(`
|
|
1343
|
+
UPDATE webhook_delivery_jobs
|
|
1344
|
+
SET status = 'delivered', http_attempt_count = http_attempt_count + 1,
|
|
1345
|
+
lease_until = NULL, last_http_status = ?, last_error = NULL,
|
|
1346
|
+
business_outcome = ?, business_outcome_version = ?, receipt_json = ?,
|
|
1347
|
+
delivered_at = datetime('now')
|
|
1348
|
+
WHERE id = ? AND generation = ? AND status <> 'delivered'
|
|
1349
|
+
`).bind(
|
|
1350
|
+
httpStatus,
|
|
1351
|
+
args.receipt?.outcome ?? null,
|
|
1352
|
+
args.receipt?.receipt_version ?? null,
|
|
1353
|
+
args.receipt ? JSON.stringify(args.receipt) : null,
|
|
1354
|
+
deliveryJobId,
|
|
1355
|
+
generation
|
|
1356
|
+
).run();
|
|
1357
|
+
changes = res.meta.changes;
|
|
1358
|
+
} else if (kind === "queue_retry") {
|
|
1359
|
+
const res = await db.prepare(`
|
|
1360
|
+
UPDATE webhook_delivery_jobs
|
|
1361
|
+
SET status = 'queued', http_attempt_count = http_attempt_count + 1,
|
|
1362
|
+
lease_until = NULL, last_http_status = ?, last_error = ?,
|
|
1363
|
+
next_attempt_at = datetime('now', '+${QUEUE_OWNERSHIP_HORIZON_SECONDS} seconds')
|
|
1364
|
+
WHERE id = ? AND generation = ? AND dispatch_token = ? AND status NOT IN ('delivered', 'terminal')
|
|
1365
|
+
`).bind(httpStatus, args.error ?? null, deliveryJobId, generation, dispatchToken ?? null).run();
|
|
1366
|
+
changes = res.meta.changes;
|
|
1367
|
+
} else {
|
|
1368
|
+
const delay = args.delaySeconds ?? retryDelaySeconds(1);
|
|
1369
|
+
const forceTerminal = kind === "terminal" ? 1 : 0;
|
|
1370
|
+
const res = await db.prepare(`
|
|
1371
|
+
UPDATE webhook_delivery_jobs
|
|
1372
|
+
SET http_attempt_count = http_attempt_count + 1,
|
|
1373
|
+
status = CASE WHEN ? = 1 OR http_attempt_count + 1 >= ? THEN 'terminal' ELSE 'pending' END,
|
|
1374
|
+
lease_until = NULL,
|
|
1375
|
+
last_http_status = ?,
|
|
1376
|
+
last_error = CASE WHEN ? = 1 OR http_attempt_count + 1 >= ?
|
|
1377
|
+
THEN COALESCE(?, 'max_automatic_attempts_exhausted') ELSE ? END,
|
|
1378
|
+
next_attempt_at = CASE WHEN ? = 1 OR http_attempt_count + 1 >= ?
|
|
1379
|
+
THEN next_attempt_at ELSE datetime('now', ?) END,
|
|
1380
|
+
terminal_at = CASE WHEN ? = 1 OR http_attempt_count + 1 >= ?
|
|
1381
|
+
THEN datetime('now') ELSE terminal_at END
|
|
1382
|
+
WHERE id = ? AND generation = ? AND dispatch_token = ? AND status NOT IN ('delivered', 'terminal')
|
|
1383
|
+
`).bind(
|
|
1384
|
+
forceTerminal,
|
|
1385
|
+
MAX_AUTOMATIC_HTTP_ATTEMPTS,
|
|
1386
|
+
httpStatus,
|
|
1387
|
+
forceTerminal,
|
|
1388
|
+
MAX_AUTOMATIC_HTTP_ATTEMPTS,
|
|
1389
|
+
args.error ?? null,
|
|
1390
|
+
args.error ?? null,
|
|
1391
|
+
forceTerminal,
|
|
1392
|
+
MAX_AUTOMATIC_HTTP_ATTEMPTS,
|
|
1393
|
+
`+${delay} seconds`,
|
|
1394
|
+
forceTerminal,
|
|
1395
|
+
MAX_AUTOMATIC_HTTP_ATTEMPTS,
|
|
1396
|
+
deliveryJobId,
|
|
1397
|
+
generation,
|
|
1398
|
+
dispatchToken ?? null
|
|
1399
|
+
).run();
|
|
1400
|
+
changes = res.meta.changes;
|
|
1401
|
+
}
|
|
1402
|
+
const row = await db.prepare(`
|
|
1403
|
+
SELECT id, status, generation, dispatch_token, delivery_id, consumer_app_id,
|
|
1404
|
+
incident_version, last_error, last_http_status
|
|
1405
|
+
FROM webhook_delivery_jobs WHERE id = ?
|
|
1406
|
+
`).bind(deliveryJobId).first();
|
|
1407
|
+
if (!row) return { applied: false, status: "not_found" };
|
|
1408
|
+
if (changes > 0) {
|
|
1409
|
+
const service = args.alertService ?? "banksync";
|
|
1410
|
+
if (row.status === "terminal") await enqueueTerminalIncident(db, row, service);
|
|
1411
|
+
else if (row.status === "delivered") await enqueueRecoveryIfOpen(db, row, service);
|
|
1412
|
+
return { applied: true, status: row.status };
|
|
1413
|
+
}
|
|
1414
|
+
if (row.status === "delivered") return { applied: false, status: "delivered" };
|
|
1415
|
+
const generationMismatch = row.generation !== generation;
|
|
1416
|
+
const tokenMismatch = dispatchToken != null && row.dispatch_token !== dispatchToken;
|
|
1417
|
+
if (generationMismatch || tokenMismatch) return { applied: false, status: "stale" };
|
|
1418
|
+
return { applied: false, status: row.status };
|
|
1419
|
+
}
|
|
1420
|
+
async function replayDelivery(db, queue, jobId, opts = {}) {
|
|
1421
|
+
const job = await db.prepare(`
|
|
1422
|
+
SELECT id, delivery_id, status FROM webhook_delivery_jobs WHERE id = ?
|
|
1423
|
+
`).bind(jobId).first();
|
|
1424
|
+
if (!job) return { found: false, queued: false };
|
|
1425
|
+
if (job.status === "pending" || job.status === "dispatching" || job.status === "queued") {
|
|
1426
|
+
return { found: true, queued: false, delivery_id: job.delivery_id, noop: "active", previous_status: job.status };
|
|
1427
|
+
}
|
|
1428
|
+
if (job.status === "delivered" && !opts.force) {
|
|
1429
|
+
return { found: true, queued: false, delivery_id: job.delivery_id, noop: "already_delivered", previous_status: job.status };
|
|
1430
|
+
}
|
|
1431
|
+
const incidentBump = job.status === "delivered" && opts.force ? ", incident_version = incident_version + 1" : "";
|
|
1432
|
+
await db.prepare(`
|
|
1433
|
+
UPDATE webhook_delivery_jobs
|
|
1434
|
+
SET status = 'pending', generation = generation + 1, dispatch_token = NULL,
|
|
1435
|
+
http_attempt_count = 0, dispatch_count = 0, lease_until = NULL,
|
|
1436
|
+
next_attempt_at = datetime('now'), delivered_at = NULL, terminal_at = NULL,
|
|
1437
|
+
last_http_status = NULL, last_error = 'manual_replay_requested'${incidentBump}
|
|
1438
|
+
WHERE id = ?
|
|
1439
|
+
`).bind(jobId).run();
|
|
1440
|
+
const queued = await dispatchDeliveryJob({ DB: db, WEBHOOK_QUEUE: queue }, jobId);
|
|
1441
|
+
return { found: true, queued, delivery_id: job.delivery_id, previous_status: job.status };
|
|
1442
|
+
}
|
|
1443
|
+
async function findDeliveryJob(db, deliveryId) {
|
|
1444
|
+
return await db.prepare(`
|
|
1445
|
+
SELECT id, transaction_id, consumer_app_id, event_kind, delivery_id, status,
|
|
1446
|
+
generation, dispatch_token, http_attempt_count, next_attempt_at,
|
|
1447
|
+
lease_until, last_http_status, last_error, created_at, delivered_at, terminal_at
|
|
1448
|
+
FROM webhook_delivery_jobs WHERE delivery_id = ?
|
|
1449
|
+
`).bind(deliveryId).first();
|
|
1450
|
+
}
|
|
1451
|
+
async function findDeliveryJobsForTransaction(db, transactionId) {
|
|
1452
|
+
const result = await db.prepare(`
|
|
1453
|
+
SELECT id, transaction_id, consumer_app_id, event_kind, delivery_id, payload,
|
|
1454
|
+
status, generation, http_attempt_count
|
|
1455
|
+
FROM webhook_delivery_jobs WHERE transaction_id = ? ORDER BY id
|
|
1456
|
+
`).bind(transactionId).all();
|
|
1457
|
+
return result.results;
|
|
1458
|
+
}
|
|
1459
|
+
async function listDeliveryJobs(db, query) {
|
|
1460
|
+
const where = [];
|
|
1461
|
+
const binds = [];
|
|
1462
|
+
if (query.consumerAppId !== void 0) {
|
|
1463
|
+
where.push("consumer_app_id = ?");
|
|
1464
|
+
binds.push(query.consumerAppId);
|
|
1465
|
+
}
|
|
1466
|
+
if (query.status !== void 0) {
|
|
1467
|
+
where.push("status = ?");
|
|
1468
|
+
binds.push(query.status);
|
|
1469
|
+
}
|
|
1470
|
+
if (query.cursor !== void 0) {
|
|
1471
|
+
where.push("id < ?");
|
|
1472
|
+
binds.push(query.cursor);
|
|
1473
|
+
}
|
|
1474
|
+
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
1475
|
+
binds.push(query.limit);
|
|
1476
|
+
const result = await db.prepare(`
|
|
1477
|
+
SELECT id, transaction_id, consumer_app_id, event_kind, delivery_id, status,
|
|
1478
|
+
generation, http_attempt_count, next_attempt_at, lease_until,
|
|
1479
|
+
last_http_status, last_error, created_at, delivered_at, terminal_at
|
|
1480
|
+
FROM webhook_delivery_jobs
|
|
1481
|
+
${whereSql}
|
|
1482
|
+
ORDER BY id DESC LIMIT ?
|
|
1483
|
+
`).bind(...binds).all();
|
|
1484
|
+
return result.results;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// src/webhookSender.ts
|
|
1488
|
+
var FETCH_TIMEOUT_MS = 1e4;
|
|
1489
|
+
var MAX_RECEIPT_BYTES = 8 * 1024;
|
|
1490
|
+
async function readReceipt(response, deliveryId) {
|
|
1491
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
1492
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_RECEIPT_BYTES) return { error: "receipt_too_large" };
|
|
1493
|
+
if (!response.body) return { error: "receipt_missing" };
|
|
1494
|
+
const reader = response.body.getReader();
|
|
1495
|
+
const chunks = [];
|
|
1496
|
+
let total = 0;
|
|
1497
|
+
try {
|
|
1498
|
+
while (true) {
|
|
1499
|
+
const { done, value } = await reader.read();
|
|
1500
|
+
if (done) break;
|
|
1501
|
+
total += value.byteLength;
|
|
1502
|
+
if (total > MAX_RECEIPT_BYTES) {
|
|
1503
|
+
await reader.cancel();
|
|
1504
|
+
return { error: "receipt_too_large" };
|
|
1505
|
+
}
|
|
1506
|
+
chunks.push(value);
|
|
1507
|
+
}
|
|
1508
|
+
} catch {
|
|
1509
|
+
return { error: "receipt_read_failed" };
|
|
1510
|
+
}
|
|
1511
|
+
if (total === 0) return { error: "receipt_missing" };
|
|
1512
|
+
const bytes = new Uint8Array(total);
|
|
1513
|
+
let offset = 0;
|
|
1514
|
+
for (const chunk of chunks) {
|
|
1515
|
+
bytes.set(chunk, offset);
|
|
1516
|
+
offset += chunk.byteLength;
|
|
1517
|
+
}
|
|
1518
|
+
try {
|
|
1519
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
1520
|
+
if (parsed.ok !== true || parsed.receipt_version !== 1 || parsed.delivery_id !== deliveryId || typeof parsed.outcome !== "string" || !/^[a-z0-9_]{1,64}$/.test(parsed.outcome) || parsed.order_id !== void 0 && (typeof parsed.order_id !== "string" || parsed.order_id.length > 200)) {
|
|
1521
|
+
return { error: "receipt_invalid" };
|
|
1522
|
+
}
|
|
1523
|
+
const receipt = {
|
|
1524
|
+
receipt_version: 1,
|
|
1525
|
+
delivery_id: parsed.delivery_id,
|
|
1526
|
+
outcome: parsed.outcome
|
|
1527
|
+
};
|
|
1528
|
+
if (typeof parsed.order_id === "string") receipt.order_id = parsed.order_id;
|
|
1529
|
+
return { receipt };
|
|
1530
|
+
} catch {
|
|
1531
|
+
return { error: "receipt_invalid" };
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
async function httpResult(response, deliveryId, usedPrevSecret, primaryStatus) {
|
|
1535
|
+
if (response.status < 200 || response.status > 299) {
|
|
1536
|
+
return { kind: "http", httpStatus: response.status, usedPrevSecret, primaryStatus };
|
|
1537
|
+
}
|
|
1538
|
+
const parsed = await readReceipt(response, deliveryId);
|
|
1539
|
+
return "receipt" in parsed ? { kind: "http", httpStatus: response.status, usedPrevSecret, primaryStatus, receipt: parsed.receipt } : { kind: "http", httpStatus: response.status, usedPrevSecret, primaryStatus, receiptError: parsed.error };
|
|
1540
|
+
}
|
|
1541
|
+
async function postSigned(url2, envelope, secret) {
|
|
1542
|
+
const signed = await signWebhook({ envelope, secret });
|
|
1543
|
+
const controller = new AbortController();
|
|
1544
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
1545
|
+
try {
|
|
1546
|
+
return await fetch(url2, {
|
|
1547
|
+
method: "POST",
|
|
1548
|
+
body: signed.bodyBytes.buffer,
|
|
1549
|
+
headers: signed.headers,
|
|
1550
|
+
signal: controller.signal,
|
|
1551
|
+
redirect: "manual"
|
|
1552
|
+
});
|
|
1553
|
+
} finally {
|
|
1554
|
+
clearTimeout(timeoutId);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
function createWebhookSender(env) {
|
|
1558
|
+
return {
|
|
1559
|
+
async send(job) {
|
|
1560
|
+
const consumer = await getConsumerSecretMaterials(env.DB, job.consumer_app_id);
|
|
1561
|
+
if (!consumer) return { kind: "no_consumer" };
|
|
1562
|
+
let callbackUrl;
|
|
1563
|
+
try {
|
|
1564
|
+
callbackUrl = validateCallbackUrl(consumer.callback_url, {
|
|
1565
|
+
environment: env.ENV,
|
|
1566
|
+
allowlist: env.CALLBACK_HOST_ALLOWLIST
|
|
1567
|
+
});
|
|
1568
|
+
} catch {
|
|
1569
|
+
return { kind: "configuration_error", error: "callback_url_not_allowed" };
|
|
1570
|
+
}
|
|
1571
|
+
let primarySecret;
|
|
1572
|
+
try {
|
|
1573
|
+
primarySecret = await webhookDecrypt(consumer.primary_cipher, env.WEBHOOK_KEK);
|
|
1574
|
+
} catch {
|
|
1575
|
+
return { kind: "configuration_error", error: "consumer_secret_decrypt_failed" };
|
|
1576
|
+
}
|
|
1577
|
+
let primary;
|
|
1578
|
+
try {
|
|
1579
|
+
primary = await postSigned(callbackUrl, job.envelope, primarySecret);
|
|
1580
|
+
} catch (err) {
|
|
1581
|
+
return { kind: "network_error", error: String(err), usedPrevSecret: false };
|
|
1582
|
+
}
|
|
1583
|
+
if ((primary.status === 401 || primary.status === 403) && consumer.prev_cipher_in_grace !== null) {
|
|
1584
|
+
let prevSecret;
|
|
1585
|
+
try {
|
|
1586
|
+
prevSecret = await webhookDecrypt(consumer.prev_cipher_in_grace, env.WEBHOOK_KEK);
|
|
1587
|
+
} catch {
|
|
1588
|
+
return { kind: "configuration_error", error: "consumer_secret_decrypt_failed" };
|
|
1589
|
+
}
|
|
1590
|
+
let prev;
|
|
1591
|
+
try {
|
|
1592
|
+
prev = await postSigned(callbackUrl, job.envelope, prevSecret);
|
|
1593
|
+
} catch (err) {
|
|
1594
|
+
return { kind: "network_error", error: String(err), usedPrevSecret: true };
|
|
1595
|
+
}
|
|
1596
|
+
return httpResult(prev, job.delivery_id, true, primary.status);
|
|
1597
|
+
}
|
|
1598
|
+
return httpResult(primary, job.delivery_id, false, null);
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// src/webhookDeliveryCoordinator.ts
|
|
1604
|
+
function decodeV2Fence(m) {
|
|
1605
|
+
if (m.message_version !== 2) return null;
|
|
1606
|
+
if (typeof m.generation !== "number") return null;
|
|
1607
|
+
if (typeof m.dispatch_token !== "string" || m.dispatch_token.length === 0) return null;
|
|
1608
|
+
return { generation: m.generation, dispatchToken: m.dispatch_token };
|
|
1609
|
+
}
|
|
1610
|
+
function retryUnlessSettled(outcome, delaySeconds) {
|
|
1611
|
+
if (outcome.status === "stale" || outcome.status === "delivered") return { action: "ack" };
|
|
1612
|
+
return { action: "retry", delaySeconds };
|
|
1613
|
+
}
|
|
1614
|
+
function createWebhookDeliveryCoordinator(env, deps = {}) {
|
|
1615
|
+
const db = env.DB;
|
|
1616
|
+
const sender = deps.sender ?? createWebhookSender(env);
|
|
1617
|
+
async function deadLetter(m, fence) {
|
|
1618
|
+
await recordDeliveryOutcome(db, {
|
|
1619
|
+
deliveryJobId: m.delivery_job_id,
|
|
1620
|
+
...fence,
|
|
1621
|
+
kind: "retryable",
|
|
1622
|
+
error: "queue_max_retries_exhausted",
|
|
1623
|
+
delaySeconds: 300
|
|
1624
|
+
});
|
|
1625
|
+
log("webhook_delivery_dead_letter_requeued", { delivery_id: m.delivery_id, consumer_app_id: m.consumer_app_id });
|
|
1626
|
+
return { action: "ack" };
|
|
1627
|
+
}
|
|
1628
|
+
async function handlePrimary(m, attempts, fence) {
|
|
1629
|
+
const logBase = {
|
|
1630
|
+
delivery_id: m.delivery_id,
|
|
1631
|
+
consumer_app_id: m.consumer_app_id,
|
|
1632
|
+
bank_account_id: m.bank_account_id,
|
|
1633
|
+
transaction_id: m.transaction_id,
|
|
1634
|
+
attempt: attempts
|
|
1635
|
+
};
|
|
1636
|
+
const result = await sender.send({ delivery_id: m.delivery_id, consumer_app_id: m.consumer_app_id, envelope: m.envelope });
|
|
1637
|
+
if (result.kind === "no_consumer") {
|
|
1638
|
+
log("webhook_consumer_not_found", { delivery_id: m.delivery_id, consumer_app_id: m.consumer_app_id });
|
|
1639
|
+
await insertWebhookLogEntry(db, { ...logBase, http_status: null, error_message: "consumer_not_found" });
|
|
1640
|
+
await recordDeliveryOutcome(db, { deliveryJobId: m.delivery_job_id, ...fence, kind: "terminal", error: "consumer_not_found" });
|
|
1641
|
+
return { action: "ack" };
|
|
1642
|
+
}
|
|
1643
|
+
if (result.kind === "configuration_error") {
|
|
1644
|
+
log("webhook_consumer_configuration_error", { delivery_id: m.delivery_id, consumer_app_id: m.consumer_app_id });
|
|
1645
|
+
await insertWebhookLogEntry(db, { ...logBase, http_status: null, error_message: result.error });
|
|
1646
|
+
await recordDeliveryOutcome(db, { deliveryJobId: m.delivery_job_id, ...fence, kind: "terminal", error: result.error });
|
|
1647
|
+
return { action: "ack" };
|
|
1648
|
+
}
|
|
1649
|
+
if (result.kind === "network_error") {
|
|
1650
|
+
const delaySeconds2 = backoffSeconds(attempts);
|
|
1651
|
+
const errorMessage2 = result.usedPrevSecret ? `retry_with_prev:network_error:${result.error}` : result.error;
|
|
1652
|
+
await insertWebhookLogEntry(db, { ...logBase, http_status: 0, error_message: errorMessage2 });
|
|
1653
|
+
const outcome2 = await recordDeliveryOutcome(db, { deliveryJobId: m.delivery_job_id, ...fence, kind: "queue_retry", httpStatus: 0, error: errorMessage2, delaySeconds: delaySeconds2 });
|
|
1654
|
+
log("webhook_network_error", { delivery_id: m.delivery_id, consumer_app_id: m.consumer_app_id, attempts, delay_seconds: delaySeconds2 });
|
|
1655
|
+
return retryUnlessSettled(outcome2, delaySeconds2);
|
|
1656
|
+
}
|
|
1657
|
+
const { httpStatus, usedPrevSecret, primaryStatus } = result;
|
|
1658
|
+
const cls = classifyHttpStatus(httpStatus);
|
|
1659
|
+
if (cls === "delivered") {
|
|
1660
|
+
if (!result.receipt) {
|
|
1661
|
+
const delaySeconds2 = backoffSeconds(attempts);
|
|
1662
|
+
const errorMessage2 = result.receiptError ?? "receipt_missing";
|
|
1663
|
+
await insertWebhookLogEntry(db, { ...logBase, http_status: httpStatus, error_message: errorMessage2 });
|
|
1664
|
+
const outcome2 = await recordDeliveryOutcome(db, { deliveryJobId: m.delivery_job_id, ...fence, kind: "queue_retry", httpStatus, error: errorMessage2, delaySeconds: delaySeconds2 });
|
|
1665
|
+
log("webhook_receipt_invalid", { delivery_id: m.delivery_id, consumer_app_id: m.consumer_app_id, reason: errorMessage2, attempts, delay_seconds: delaySeconds2 });
|
|
1666
|
+
return retryUnlessSettled(outcome2, delaySeconds2);
|
|
1667
|
+
}
|
|
1668
|
+
await insertWebhookLogEntry(db, { ...logBase, http_status: httpStatus, error_message: usedPrevSecret ? "retry_with_prev" : null });
|
|
1669
|
+
await recordDeliveryOutcome(db, { deliveryJobId: m.delivery_job_id, ...fence, kind: "delivered", httpStatus, receipt: result.receipt });
|
|
1670
|
+
log(usedPrevSecret ? "webhook_delivered_with_prev_secret" : "webhook_delivered", { delivery_id: m.delivery_id, consumer_app_id: m.consumer_app_id, http_status: httpStatus });
|
|
1671
|
+
return { action: "ack" };
|
|
1672
|
+
}
|
|
1673
|
+
if (cls === "terminal") {
|
|
1674
|
+
const terminalKind = httpStatus >= 300 && httpStatus <= 399 ? "redirect_not_allowed" : "4xx_client_error";
|
|
1675
|
+
const errorMessage2 = usedPrevSecret ? `retry_with_prev:${terminalKind}:primary_${primaryStatus}_prev_${httpStatus}` : terminalKind;
|
|
1676
|
+
await insertWebhookLogEntry(db, { ...logBase, http_status: httpStatus, error_message: errorMessage2 });
|
|
1677
|
+
await recordDeliveryOutcome(db, { deliveryJobId: m.delivery_job_id, ...fence, kind: "terminal", httpStatus, error: errorMessage2 });
|
|
1678
|
+
log("webhook_4xx_ack", { delivery_id: m.delivery_id, consumer_app_id: m.consumer_app_id, http_status: httpStatus });
|
|
1679
|
+
return { action: "ack" };
|
|
1680
|
+
}
|
|
1681
|
+
const delaySeconds = backoffSeconds(attempts);
|
|
1682
|
+
const errorMessage = `http_${httpStatus}`;
|
|
1683
|
+
await insertWebhookLogEntry(db, { ...logBase, http_status: httpStatus, error_message: errorMessage });
|
|
1684
|
+
const outcome = await recordDeliveryOutcome(db, { deliveryJobId: m.delivery_job_id, ...fence, kind: "queue_retry", httpStatus, error: errorMessage, delaySeconds });
|
|
1685
|
+
log("webhook_retry", { delivery_id: m.delivery_id, consumer_app_id: m.consumer_app_id, http_status: httpStatus, attempts, delay_seconds: delaySeconds });
|
|
1686
|
+
return retryUnlessSettled(outcome, delaySeconds);
|
|
1687
|
+
}
|
|
1688
|
+
return {
|
|
1689
|
+
async observeTransaction(transactionId) {
|
|
1690
|
+
const created = await ensureDeliveryJobs(db, transactionId);
|
|
1691
|
+
const jobs = await findDeliveryJobsForTransaction(db, transactionId);
|
|
1692
|
+
const results = await Promise.all(jobs.map((job) => dispatchDeliveryJob(env, job.id)));
|
|
1693
|
+
return { created, dispatched: results.filter(Boolean).length };
|
|
1694
|
+
},
|
|
1695
|
+
async sweep(options) {
|
|
1696
|
+
const created = await ensureDeliveryJobs(db);
|
|
1697
|
+
const dispatch2 = await dispatchDueDeliveryJobs(env, options?.limit ?? 50);
|
|
1698
|
+
return { created, ...dispatch2 };
|
|
1699
|
+
},
|
|
1700
|
+
handleAttempt(message, ctx) {
|
|
1701
|
+
const fence = decodeV2Fence(message);
|
|
1702
|
+
if (!fence) {
|
|
1703
|
+
log("webhook_message_protocol_error", { delivery_id: message.delivery_id, consumer_app_id: message.consumer_app_id });
|
|
1704
|
+
return Promise.resolve({ action: "ack" });
|
|
1705
|
+
}
|
|
1706
|
+
return ctx.source === "dead_letter" ? deadLetter(message, fence) : handlePrimary(message, ctx.attempts, fence);
|
|
1707
|
+
},
|
|
1708
|
+
replay(jobId, opts) {
|
|
1709
|
+
return replayDelivery(db, env.WEBHOOK_QUEUE, jobId, opts);
|
|
1710
|
+
}
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
var DeliveryQueries = {
|
|
1714
|
+
list(db, query) {
|
|
1715
|
+
return listDeliveryJobs(db, query);
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
|
|
1719
|
+
// src/queue.ts
|
|
1720
|
+
async function handleQueueBatch(batch, env) {
|
|
1721
|
+
await assertSchemaVersion(env.DB);
|
|
1722
|
+
const coordinator = createWebhookDeliveryCoordinator(env);
|
|
1723
|
+
if (batch.queue === "banksync-webhooks") {
|
|
1724
|
+
for (const raw of batch.messages) {
|
|
1725
|
+
const msg = raw;
|
|
1726
|
+
await applyDisposition(msg, () => coordinator.handleAttempt(msg.body, { attempts: msg.attempts, source: "primary" }));
|
|
1727
|
+
}
|
|
1728
|
+
} else if (batch.queue === "banksync-webhooks-dlq") {
|
|
1729
|
+
for (const raw of batch.messages) {
|
|
1730
|
+
const msg = raw;
|
|
1731
|
+
await applyDisposition(msg, () => coordinator.handleAttempt(msg.body, { attempts: msg.attempts, source: "dead_letter" }));
|
|
1732
|
+
}
|
|
1733
|
+
} else {
|
|
1734
|
+
logError("queue_unknown_binding", new Error(`unknown queue: ${batch.queue}`), { queue: batch.queue });
|
|
1735
|
+
for (const raw of batch.messages) raw.ack();
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
async function applyDisposition(msg, run) {
|
|
1739
|
+
try {
|
|
1740
|
+
const disposition = await run();
|
|
1741
|
+
if (disposition.action === "ack") msg.ack();
|
|
1742
|
+
else msg.retry({ delaySeconds: disposition.delaySeconds });
|
|
1743
|
+
} catch (err) {
|
|
1744
|
+
logError("webhook_unexpected_error", err, { delivery_id: msg.body.delivery_id, consumer_app_id: msg.body.consumer_app_id });
|
|
1745
|
+
msg.retry({ delaySeconds: 60 });
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// src/cf_routing.ts
|
|
1750
|
+
var CF_API = "https://api.cloudflare.com/client/v4";
|
|
1751
|
+
var CfRoutingError = class extends Error {
|
|
1752
|
+
constructor(status, cfErrors, summary) {
|
|
1753
|
+
super(summary);
|
|
1754
|
+
this.status = status;
|
|
1755
|
+
this.cfErrors = cfErrors;
|
|
1756
|
+
this.name = "CfRoutingError";
|
|
1757
|
+
}
|
|
1758
|
+
status;
|
|
1759
|
+
cfErrors;
|
|
1760
|
+
};
|
|
1761
|
+
function cfRoutingEnabled(cfg) {
|
|
1762
|
+
return Boolean(cfg.apiToken && cfg.apiToken.length > 0);
|
|
1763
|
+
}
|
|
1764
|
+
function pairingEmail(domain, pairingCode) {
|
|
1765
|
+
return `${pairingCode}@${domain}`;
|
|
1766
|
+
}
|
|
1767
|
+
async function createRule(cfg, pairingCode) {
|
|
1768
|
+
if (!cfRoutingEnabled(cfg)) {
|
|
1769
|
+
log("cf_routing_skipped", { op: "create", pairing_code: pairingCode, reason: "CF_API_TOKEN unset" });
|
|
1770
|
+
return null;
|
|
1771
|
+
}
|
|
1772
|
+
const matcherValue = pairingEmail(cfg.domain, pairingCode);
|
|
1773
|
+
const body = {
|
|
1774
|
+
matchers: [{ type: "literal", field: "to", value: matcherValue }],
|
|
1775
|
+
actions: [{ type: "worker", value: ["banksync"] }],
|
|
1776
|
+
enabled: true,
|
|
1777
|
+
name: `auto: ${matcherValue}`
|
|
1778
|
+
};
|
|
1779
|
+
const res = await fetch(`${CF_API}/zones/${cfg.zoneId}/email/routing/rules`, {
|
|
1780
|
+
method: "POST",
|
|
1781
|
+
headers: {
|
|
1782
|
+
"Authorization": `Bearer ${cfg.apiToken}`,
|
|
1783
|
+
"Content-Type": "application/json"
|
|
1784
|
+
},
|
|
1785
|
+
body: JSON.stringify(body)
|
|
1786
|
+
});
|
|
1787
|
+
const json = await res.json();
|
|
1788
|
+
if (!res.ok || !json.success || !json.result?.id) {
|
|
1789
|
+
const errs = json.errors ?? [];
|
|
1790
|
+
throw new CfRoutingError(
|
|
1791
|
+
res.status,
|
|
1792
|
+
errs,
|
|
1793
|
+
`cf_routing_create_failed: ${res.status} ${errs.map((e) => `[${e.code}]${e.message}`).join("; ") || "unknown"}`
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
log("cf_routing_rule_created", { rule_id: json.result.id, pairing_code: pairingCode });
|
|
1797
|
+
return json.result.id;
|
|
1798
|
+
}
|
|
1799
|
+
async function deleteRule(cfg, ruleId) {
|
|
1800
|
+
if (!cfRoutingEnabled(cfg)) {
|
|
1801
|
+
log("cf_routing_skipped", { op: "delete", rule_id: ruleId, reason: "CF_API_TOKEN unset" });
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
const res = await fetch(`${CF_API}/zones/${cfg.zoneId}/email/routing/rules/${ruleId}`, {
|
|
1805
|
+
method: "DELETE",
|
|
1806
|
+
headers: { "Authorization": `Bearer ${cfg.apiToken}` }
|
|
1807
|
+
});
|
|
1808
|
+
if (res.status === 404) {
|
|
1809
|
+
log("cf_routing_rule_already_gone", { rule_id: ruleId });
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
const json = await res.json();
|
|
1813
|
+
if (!res.ok || !json.success) {
|
|
1814
|
+
const errs = json.errors ?? [];
|
|
1815
|
+
throw new CfRoutingError(
|
|
1816
|
+
res.status,
|
|
1817
|
+
errs,
|
|
1818
|
+
`cf_routing_delete_failed: ${res.status} ${errs.map((e) => `[${e.code}]${e.message}`).join("; ") || "unknown"}`
|
|
1819
|
+
);
|
|
1820
|
+
}
|
|
1821
|
+
log("cf_routing_rule_deleted", { rule_id: ruleId });
|
|
1822
|
+
}
|
|
1823
|
+
async function safeDeleteRule(cfg, ruleId) {
|
|
1824
|
+
try {
|
|
1825
|
+
await deleteRule(cfg, ruleId);
|
|
1826
|
+
return true;
|
|
1827
|
+
} catch (err) {
|
|
1828
|
+
logError("cf_routing_safe_delete_failed", err, { rule_id: ruleId });
|
|
1829
|
+
return false;
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// src/auth.ts
|
|
1834
|
+
function timingSafeEqual(a, b) {
|
|
1835
|
+
if (a.length !== b.length) return false;
|
|
1836
|
+
let diff = 0;
|
|
1837
|
+
for (let i = 0; i < a.length; i++) {
|
|
1838
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
1839
|
+
}
|
|
1840
|
+
return diff === 0;
|
|
1841
|
+
}
|
|
1842
|
+
async function hashKey(plain) {
|
|
1843
|
+
const bytes = new TextEncoder().encode(plain);
|
|
1844
|
+
const buf = await crypto.subtle.digest("SHA-256", bytes);
|
|
1845
|
+
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1846
|
+
}
|
|
1847
|
+
async function generateTenantAdminKey() {
|
|
1848
|
+
const random = crypto.getRandomValues(new Uint8Array(32));
|
|
1849
|
+
const b64 = btoa(String.fromCharCode(...random)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1850
|
+
const plain = `bksk_${b64}`;
|
|
1851
|
+
const prefix = plain.slice(0, 12);
|
|
1852
|
+
const hash = await hashKey(plain);
|
|
1853
|
+
return { plain, prefix, hash };
|
|
1854
|
+
}
|
|
1855
|
+
async function resolveAuth(req, env) {
|
|
1856
|
+
const adminSecret = req.headers.get("X-Admin-Secret");
|
|
1857
|
+
if (adminSecret && env.ADMIN_SECRET && timingSafeEqual(adminSecret, env.ADMIN_SECRET)) {
|
|
1858
|
+
return { type: "admin" };
|
|
1859
|
+
}
|
|
1860
|
+
const tenantKey = req.headers.get("X-Tenant-Secret");
|
|
1861
|
+
if (tenantKey && tenantKey.length >= 12) {
|
|
1862
|
+
const prefix = tenantKey.slice(0, 12);
|
|
1863
|
+
const consumer = await findConsumerByAdminKeyPrefix(env.DB, prefix);
|
|
1864
|
+
if (consumer && consumer.admin_key_hash) {
|
|
1865
|
+
const candidateHash = await hashKey(tenantKey);
|
|
1866
|
+
if (timingSafeEqual(candidateHash, consumer.admin_key_hash)) {
|
|
1867
|
+
return { type: "tenant", app_id: consumer.app_id };
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
return { type: "unauth" };
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
// src/outbox.ts
|
|
1875
|
+
var MAX_ATTEMPTS = 10;
|
|
1876
|
+
function nextAttemptAt(attempts) {
|
|
1877
|
+
const seconds = Math.min(60 * Math.pow(2, attempts - 1), 3600);
|
|
1878
|
+
return new Date(Date.now() + seconds * 1e3).toISOString().replace("T", " ").replace(/\..+$/, "");
|
|
1879
|
+
}
|
|
1880
|
+
async function enqueueCreate(db, args) {
|
|
1881
|
+
const row = await db.prepare(`INSERT INTO cf_routing_outbox (op, pairing_code, bank_account_id) VALUES ('create', ?, ?) RETURNING id`).bind(args.pairing_code, args.bank_account_id).first();
|
|
1882
|
+
if (!row) throw new Error("enqueueCreate: no row returned");
|
|
1883
|
+
return row.id;
|
|
1884
|
+
}
|
|
1885
|
+
async function enqueueDelete(db, args) {
|
|
1886
|
+
const row = await db.prepare(`INSERT INTO cf_routing_outbox (op, cf_rule_id) VALUES ('delete', ?) RETURNING id`).bind(args.cf_rule_id).first();
|
|
1887
|
+
if (!row) throw new Error("enqueueDelete: no row returned");
|
|
1888
|
+
return row.id;
|
|
1889
|
+
}
|
|
1890
|
+
async function markCompleted(db, outboxId, cfRuleIdForCreate) {
|
|
1891
|
+
const outboxUpdate = db.prepare(
|
|
1892
|
+
`UPDATE cf_routing_outbox SET status = 'completed', completed_at = datetime('now'), cf_rule_id = COALESCE(?, cf_rule_id) WHERE id = ?`
|
|
1893
|
+
).bind(cfRuleIdForCreate ?? null, outboxId);
|
|
1894
|
+
if (cfRuleIdForCreate) {
|
|
1895
|
+
const row = await db.prepare(`SELECT bank_account_id FROM cf_routing_outbox WHERE id = ?`).bind(outboxId).first();
|
|
1896
|
+
const bankAccountId = row?.bank_account_id ?? null;
|
|
1897
|
+
if (bankAccountId !== null) {
|
|
1898
|
+
const bankUpdate = db.prepare(
|
|
1899
|
+
`UPDATE bank_accounts SET cf_rule_id = ? WHERE id = ?`
|
|
1900
|
+
).bind(cfRuleIdForCreate, bankAccountId);
|
|
1901
|
+
await db.batch([outboxUpdate, bankUpdate]);
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
await outboxUpdate.run();
|
|
1906
|
+
}
|
|
1907
|
+
async function markFailureAttempt(db, outboxId, error) {
|
|
1908
|
+
const row = await db.prepare(`SELECT attempts FROM cf_routing_outbox WHERE id = ?`).bind(outboxId).first();
|
|
1909
|
+
const currentAttempts = row?.attempts ?? 0;
|
|
1910
|
+
const newAttempts = currentAttempts + 1;
|
|
1911
|
+
const nextAt = nextAttemptAt(newAttempts);
|
|
1912
|
+
const newStatus = newAttempts >= MAX_ATTEMPTS ? "failed" : "pending";
|
|
1913
|
+
await db.prepare(`UPDATE cf_routing_outbox SET attempts = ?, last_error = ?, next_attempt_at = ?, status = ? WHERE id = ?`).bind(newAttempts, error, nextAt, newStatus, outboxId).run();
|
|
1914
|
+
}
|
|
1915
|
+
async function fetchDuePending(db, limit = 50) {
|
|
1916
|
+
const result = await db.prepare(`SELECT * FROM cf_routing_outbox WHERE status = 'pending' AND next_attempt_at <= datetime('now') ORDER BY id LIMIT ?`).bind(limit).all();
|
|
1917
|
+
return result.results;
|
|
1918
|
+
}
|
|
1919
|
+
async function currentCreateTarget(db, row) {
|
|
1920
|
+
if (row.bank_account_id === null) return null;
|
|
1921
|
+
return db.prepare(`SELECT pairing_code, cf_rule_id FROM bank_accounts WHERE id = ?`).bind(row.bank_account_id).first();
|
|
1922
|
+
}
|
|
1923
|
+
async function claimCreatedRule(db, row, ruleId) {
|
|
1924
|
+
if (row.bank_account_id === null || !row.pairing_code) return false;
|
|
1925
|
+
const claimed = await db.prepare(`UPDATE bank_accounts
|
|
1926
|
+
SET cf_rule_id = ?
|
|
1927
|
+
WHERE id = ? AND pairing_code = ? AND cf_rule_id IS NULL
|
|
1928
|
+
RETURNING id`).bind(ruleId, row.bank_account_id, row.pairing_code).first();
|
|
1929
|
+
return claimed !== null;
|
|
1930
|
+
}
|
|
1931
|
+
async function processOutbox(db, cfg) {
|
|
1932
|
+
const counts = { attempted: 0, succeeded: 0, failed_attempt: 0, exhausted: 0 };
|
|
1933
|
+
if (!cfRoutingEnabled(cfg)) {
|
|
1934
|
+
log("outbox_skipped", { reason: "CF_API_TOKEN unset" });
|
|
1935
|
+
return counts;
|
|
1936
|
+
}
|
|
1937
|
+
const rows = await fetchDuePending(db);
|
|
1938
|
+
for (const row of rows) {
|
|
1939
|
+
counts.attempted++;
|
|
1940
|
+
try {
|
|
1941
|
+
if (row.op === "create") {
|
|
1942
|
+
if (!row.pairing_code || row.bank_account_id === null) {
|
|
1943
|
+
await markFailureAttempt(db, row.id, "outbox_create_missing_pairing_code");
|
|
1944
|
+
counts.failed_attempt++;
|
|
1945
|
+
if (row.attempts + 1 >= MAX_ATTEMPTS) counts.exhausted++;
|
|
1946
|
+
continue;
|
|
1947
|
+
}
|
|
1948
|
+
const target = await currentCreateTarget(db, row);
|
|
1949
|
+
if (!target || target.pairing_code !== row.pairing_code) {
|
|
1950
|
+
await markCompleted(db, row.id);
|
|
1951
|
+
log("outbox_create_cancelled", { outbox_id: row.id, reason: "stale_target" });
|
|
1952
|
+
counts.succeeded++;
|
|
1953
|
+
continue;
|
|
1954
|
+
}
|
|
1955
|
+
if (target.cf_rule_id) {
|
|
1956
|
+
await markCompleted(db, row.id, target.cf_rule_id);
|
|
1957
|
+
counts.succeeded++;
|
|
1958
|
+
continue;
|
|
1959
|
+
}
|
|
1960
|
+
const ruleId = await createRule(cfg, row.pairing_code);
|
|
1961
|
+
if (!ruleId) throw new Error("outbox_create_missing_rule_id");
|
|
1962
|
+
if (!await claimCreatedRule(db, row, ruleId)) {
|
|
1963
|
+
await deleteRule(cfg, ruleId);
|
|
1964
|
+
await markCompleted(db, row.id);
|
|
1965
|
+
log("outbox_create_cancelled", { outbox_id: row.id, reason: "lost_claim" });
|
|
1966
|
+
counts.succeeded++;
|
|
1967
|
+
continue;
|
|
1968
|
+
}
|
|
1969
|
+
await markCompleted(db, row.id, ruleId);
|
|
1970
|
+
log("outbox_create_succeeded", { outbox_id: row.id, pairing_code: row.pairing_code });
|
|
1971
|
+
counts.succeeded++;
|
|
1972
|
+
} else {
|
|
1973
|
+
if (!row.cf_rule_id) {
|
|
1974
|
+
await markFailureAttempt(db, row.id, "outbox_delete_missing_cf_rule_id");
|
|
1975
|
+
counts.failed_attempt++;
|
|
1976
|
+
if (row.attempts + 1 >= MAX_ATTEMPTS) counts.exhausted++;
|
|
1977
|
+
continue;
|
|
1978
|
+
}
|
|
1979
|
+
await deleteRule(cfg, row.cf_rule_id);
|
|
1980
|
+
await markCompleted(db, row.id);
|
|
1981
|
+
log("outbox_delete_succeeded", { outbox_id: row.id, cf_rule_id: row.cf_rule_id });
|
|
1982
|
+
counts.succeeded++;
|
|
1983
|
+
}
|
|
1984
|
+
} catch (err) {
|
|
1985
|
+
const errMsg = err instanceof CfRoutingError ? `CF ${err.status}: ${err.message}` : String(err);
|
|
1986
|
+
logError("outbox_op_failed", err, { outbox_id: row.id, op: row.op });
|
|
1987
|
+
const prevAttempts = row.attempts;
|
|
1988
|
+
await markFailureAttempt(db, row.id, errMsg);
|
|
1989
|
+
counts.failed_attempt++;
|
|
1990
|
+
if (prevAttempts + 1 >= MAX_ATTEMPTS) counts.exhausted++;
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
if (counts.attempted > 0) {
|
|
1994
|
+
log("outbox_reconcile_done", {
|
|
1995
|
+
attempted: counts.attempted,
|
|
1996
|
+
succeeded: counts.succeeded,
|
|
1997
|
+
failed_attempt: counts.failed_attempt,
|
|
1998
|
+
exhausted: counts.exhausted
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
return counts;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// src/cfIntent.ts
|
|
2005
|
+
async function provisionRoute(db, cfg, args) {
|
|
2006
|
+
await db.prepare(`UPDATE cf_routing_outbox SET bank_account_id = ? WHERE id = ?`).bind(args.bankAccountId, args.outboxId).run();
|
|
2007
|
+
try {
|
|
2008
|
+
const cfRuleId = await createRule(cfg, args.pairing);
|
|
2009
|
+
await markCompleted(db, args.outboxId, cfRuleId);
|
|
2010
|
+
return cfRuleId;
|
|
2011
|
+
} catch (err) {
|
|
2012
|
+
await markFailureAttempt(db, args.outboxId, String(err));
|
|
2013
|
+
logError("cf_routing_create_deferred", err, { bank_account_id: args.bankAccountId });
|
|
2014
|
+
return null;
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
async function deprovisionRoute(db, cfg, cfRuleId) {
|
|
2018
|
+
await enqueueDelete(db, { cf_rule_id: cfRuleId });
|
|
2019
|
+
safeDeleteRule(cfg, cfRuleId).catch((err) => {
|
|
2020
|
+
logError("cf_routing_delete_deferred", err, { cf_rule_id: cfRuleId });
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
async function regenerateRoute(db, cfg, args) {
|
|
2024
|
+
let newCfRuleId = null;
|
|
2025
|
+
try {
|
|
2026
|
+
newCfRuleId = await createRule(cfg, args.newPairing);
|
|
2027
|
+
} catch (err) {
|
|
2028
|
+
logError("cf_routing_create_failed_on_regen", err, { id: args.id, pairing_code: args.newPairing });
|
|
2029
|
+
const status = err instanceof CfRoutingError ? 503 : 500;
|
|
2030
|
+
return { ok: false, status, body: { error: "cf_routing_create_failed" } };
|
|
2031
|
+
}
|
|
2032
|
+
let account;
|
|
2033
|
+
try {
|
|
2034
|
+
account = await regenerateBankAccountPairing(db, args.id, args.newPairing, newCfRuleId);
|
|
2035
|
+
} catch (err) {
|
|
2036
|
+
if (newCfRuleId) await safeDeleteRule(cfg, newCfRuleId);
|
|
2037
|
+
throw err;
|
|
2038
|
+
}
|
|
2039
|
+
if (!account) {
|
|
2040
|
+
if (newCfRuleId) await safeDeleteRule(cfg, newCfRuleId);
|
|
2041
|
+
return { ok: false, status: 404, body: { error: "not_found" } };
|
|
2042
|
+
}
|
|
2043
|
+
if (args.existingCfRuleId) {
|
|
2044
|
+
await safeDeleteRule(cfg, args.existingCfRuleId);
|
|
2045
|
+
}
|
|
2046
|
+
return { ok: true, account };
|
|
2047
|
+
}
|
|
2048
|
+
async function clearStuckOutbox(db) {
|
|
2049
|
+
const result = await db.prepare(
|
|
2050
|
+
`DELETE FROM cf_routing_outbox
|
|
2051
|
+
WHERE last_error LIKE '%409%'
|
|
2052
|
+
AND attempts >= 2
|
|
2053
|
+
AND status IN ('pending', 'failed')`
|
|
2054
|
+
).run();
|
|
2055
|
+
return result.meta.changes ?? 0;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// src/idempotency.ts
|
|
2059
|
+
async function sha256Hex2(s) {
|
|
2060
|
+
const bytes = new TextEncoder().encode(s);
|
|
2061
|
+
const buf = await crypto.subtle.digest("SHA-256", bytes);
|
|
2062
|
+
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2063
|
+
}
|
|
2064
|
+
async function checkIdempotency(args) {
|
|
2065
|
+
const { db, request, authPrincipal, requestBodyText } = args;
|
|
2066
|
+
const idemKey = request.headers.get("Idempotency-Key");
|
|
2067
|
+
if (!idemKey || idemKey.length < 1 || idemKey.length > 255) {
|
|
2068
|
+
return { kind: "miss", keyHash: "" };
|
|
2069
|
+
}
|
|
2070
|
+
const requestPath = new URL(request.url).pathname;
|
|
2071
|
+
const requestMethod = request.method.toUpperCase();
|
|
2072
|
+
const keyHash = await sha256Hex2(`${authPrincipal}
|
|
2073
|
+
${requestMethod}
|
|
2074
|
+
${requestPath}
|
|
2075
|
+
${idemKey}`);
|
|
2076
|
+
const bodyHash = requestBodyText.length > 0 ? await sha256Hex2(requestBodyText) : null;
|
|
2077
|
+
const reservation = await db.prepare(`
|
|
2078
|
+
INSERT OR IGNORE INTO idempotency_keys
|
|
2079
|
+
(key_hash, auth_principal, request_path, request_method, request_body_hash, response_status, response_body)
|
|
2080
|
+
VALUES (?, ?, ?, ?, ?, 0, '')
|
|
2081
|
+
`).bind(keyHash, authPrincipal, requestPath, requestMethod, bodyHash).run();
|
|
2082
|
+
if (reservation.meta.changes === 1) return { kind: "miss", keyHash };
|
|
2083
|
+
const row = await db.prepare(`SELECT auth_principal, request_path, request_method, request_body_hash, response_status, response_body FROM idempotency_keys WHERE key_hash = ?`).bind(keyHash).first();
|
|
2084
|
+
if (!row) return { kind: "miss", keyHash };
|
|
2085
|
+
if (row.auth_principal !== authPrincipal || row.request_path !== requestPath || row.request_method !== requestMethod || (row.request_body_hash ?? null) !== bodyHash) {
|
|
2086
|
+
return { kind: "mismatch" };
|
|
2087
|
+
}
|
|
2088
|
+
if (row.response_status === 0) return { kind: "in_flight" };
|
|
2089
|
+
return { kind: "hit", status: row.response_status, body: row.response_body };
|
|
2090
|
+
}
|
|
2091
|
+
async function recordIdempotency(args) {
|
|
2092
|
+
const { db, keyHash, authPrincipal, requestPath, requestMethod, requestBodyHash, responseStatus, responseBody } = args;
|
|
2093
|
+
if (!keyHash) return;
|
|
2094
|
+
if (responseStatus >= 500) {
|
|
2095
|
+
await db.prepare(`DELETE FROM idempotency_keys WHERE key_hash = ? AND response_status = 0`).bind(keyHash).run();
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
await db.prepare(`
|
|
2099
|
+
UPDATE idempotency_keys
|
|
2100
|
+
SET response_status = ?, response_body = ?
|
|
2101
|
+
WHERE key_hash = ? AND auth_principal = ? AND request_path = ?
|
|
2102
|
+
AND request_method = ? AND request_body_hash IS ? AND response_status = 0
|
|
2103
|
+
`).bind(responseStatus, responseBody, keyHash, authPrincipal, requestPath, requestMethod, requestBodyHash).run();
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// src/audit.ts
|
|
2107
|
+
var REDACT_PATTERN = /^(.*secret.*|.*token.*|.*key.*|.*password.*|raw_email)$/i;
|
|
2108
|
+
var REDACT_ALLOWLIST = /* @__PURE__ */ new Set(["secret_prefix", "admin_key_prefix", "key_hash", "api_token_prefix", "fio_token_prefix"]);
|
|
2109
|
+
var MAX_BYTES = 4096;
|
|
2110
|
+
function truncateTo4KB(s) {
|
|
2111
|
+
const encoded = new TextEncoder().encode(s);
|
|
2112
|
+
if (encoded.length <= MAX_BYTES) return s;
|
|
2113
|
+
return new TextDecoder().decode(encoded.slice(0, MAX_BYTES));
|
|
2114
|
+
}
|
|
2115
|
+
function redactObject(obj) {
|
|
2116
|
+
if (Array.isArray(obj)) {
|
|
2117
|
+
return obj.map(redactObject);
|
|
2118
|
+
}
|
|
2119
|
+
if (obj !== null && typeof obj === "object") {
|
|
2120
|
+
const result = {};
|
|
2121
|
+
for (const [k, v2] of Object.entries(obj)) {
|
|
2122
|
+
if (!REDACT_ALLOWLIST.has(k) && REDACT_PATTERN.test(k)) {
|
|
2123
|
+
result[k] = "[REDACTED]";
|
|
2124
|
+
} else {
|
|
2125
|
+
result[k] = redactObject(v2);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
return result;
|
|
2129
|
+
}
|
|
2130
|
+
return obj;
|
|
2131
|
+
}
|
|
2132
|
+
function redactBodyForAudit(body) {
|
|
2133
|
+
try {
|
|
2134
|
+
const parsed = JSON.parse(body);
|
|
2135
|
+
const redacted = redactObject(parsed);
|
|
2136
|
+
return truncateTo4KB(JSON.stringify(redacted));
|
|
2137
|
+
} catch {
|
|
2138
|
+
return "[UNPARSEABLE BODY REDACTED]";
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
function shouldAuditMethod(method) {
|
|
2142
|
+
return method === "POST" || method === "PUT" || method === "DELETE" || method === "PATCH";
|
|
2143
|
+
}
|
|
2144
|
+
async function recordAudit(args) {
|
|
2145
|
+
const {
|
|
2146
|
+
db,
|
|
2147
|
+
authPrincipal,
|
|
2148
|
+
httpMethod,
|
|
2149
|
+
requestPath,
|
|
2150
|
+
httpStatus,
|
|
2151
|
+
requestBodySummary,
|
|
2152
|
+
sourceIp,
|
|
2153
|
+
userAgent,
|
|
2154
|
+
durationMs
|
|
2155
|
+
} = args;
|
|
2156
|
+
const summary = requestBodySummary != null ? truncateTo4KB(requestBodySummary) : null;
|
|
2157
|
+
try {
|
|
2158
|
+
await db.prepare(
|
|
2159
|
+
`INSERT INTO admin_audit_log
|
|
2160
|
+
(auth_principal, http_method, request_path, http_status, request_body_summary, source_ip, user_agent, duration_ms)
|
|
2161
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2162
|
+
).bind(
|
|
2163
|
+
authPrincipal,
|
|
2164
|
+
httpMethod,
|
|
2165
|
+
requestPath,
|
|
2166
|
+
httpStatus,
|
|
2167
|
+
summary ?? null,
|
|
2168
|
+
sourceIp ?? null,
|
|
2169
|
+
userAgent ?? null,
|
|
2170
|
+
durationMs ?? null
|
|
2171
|
+
).run();
|
|
2172
|
+
} catch (err) {
|
|
2173
|
+
console.error("audit_write_failed", err);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
async function listAuditLog(db, args = {}) {
|
|
2177
|
+
const since = args.since ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1e3).toISOString();
|
|
2178
|
+
const principal = args.authPrincipal ?? null;
|
|
2179
|
+
const limit = Math.min(args.limit ?? 50, 500);
|
|
2180
|
+
const result = await db.prepare(
|
|
2181
|
+
`SELECT id, auth_principal, http_method, request_path, http_status,
|
|
2182
|
+
request_body_summary, source_ip, user_agent, duration_ms, created_at
|
|
2183
|
+
FROM admin_audit_log
|
|
2184
|
+
WHERE created_at > datetime(?) AND (? IS NULL OR auth_principal = ?)
|
|
2185
|
+
ORDER BY id DESC
|
|
2186
|
+
LIMIT ?`
|
|
2187
|
+
).bind(since, principal, principal, limit).all();
|
|
2188
|
+
return result.results;
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
// src/rate_limit.ts
|
|
2192
|
+
var DEFAULT_RATE_LIMIT = { windowSeconds: 60, limit: 100 };
|
|
2193
|
+
function bucketKey(date) {
|
|
2194
|
+
return date.toISOString().slice(0, 16).replace("T", " ");
|
|
2195
|
+
}
|
|
2196
|
+
async function checkAndIncrement(db, principal, cfg = DEFAULT_RATE_LIMIT) {
|
|
2197
|
+
const now = /* @__PURE__ */ new Date();
|
|
2198
|
+
const windowStart = new Date(now.getTime() - cfg.windowSeconds * 1e3);
|
|
2199
|
+
const currentBucket = bucketKey(now);
|
|
2200
|
+
const windowStartBucket = bucketKey(windowStart);
|
|
2201
|
+
await db.prepare(`
|
|
2202
|
+
INSERT INTO rate_limit_buckets (principal, window_start, count)
|
|
2203
|
+
VALUES (?, ?, 1)
|
|
2204
|
+
ON CONFLICT(principal, window_start) DO UPDATE SET count = count + 1
|
|
2205
|
+
`).bind(principal, currentBucket).run();
|
|
2206
|
+
const sumRow = await db.prepare(`SELECT COALESCE(SUM(count), 0) as total FROM rate_limit_buckets WHERE principal = ? AND window_start >= ?`).bind(principal, windowStartBucket).first();
|
|
2207
|
+
const currentCount = sumRow?.total ?? 0;
|
|
2208
|
+
if (currentCount > cfg.limit) {
|
|
2209
|
+
log("rate_limit_denied", { principal, count: currentCount, limit: cfg.limit });
|
|
2210
|
+
return {
|
|
2211
|
+
allowed: false,
|
|
2212
|
+
count: cfg.limit,
|
|
2213
|
+
limit: cfg.limit,
|
|
2214
|
+
retryAfter: cfg.windowSeconds
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
return {
|
|
2218
|
+
allowed: true,
|
|
2219
|
+
count: currentCount,
|
|
2220
|
+
limit: cfg.limit,
|
|
2221
|
+
retryAfter: 0
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
async function pruneOldBuckets(db) {
|
|
2225
|
+
const cutoff = new Date(Date.now() - 60 * 60 * 1e3);
|
|
2226
|
+
const cutoffBucket = bucketKey(cutoff);
|
|
2227
|
+
const result = await db.prepare(`DELETE FROM rate_limit_buckets WHERE window_start < ?`).bind(cutoffBucket).run();
|
|
2228
|
+
return { deleted: result.meta.changes };
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
// src/health_deep.ts
|
|
2232
|
+
function probeRequiredSecrets(secrets) {
|
|
2233
|
+
if (!secrets) return "green";
|
|
2234
|
+
const allPresent = secrets.alertWebhookPresent && secrets.backupsPresent && secrets.emailAuthPresent && secrets.callbackPolicyPresent;
|
|
2235
|
+
if (allPresent) return "green";
|
|
2236
|
+
return secrets.isProduction ? "red" : "yellow";
|
|
2237
|
+
}
|
|
2238
|
+
var CF_API2 = "https://api.cloudflare.com/client/v4";
|
|
2239
|
+
function aggregateStatus(statuses) {
|
|
2240
|
+
if (statuses.some((s) => s === "red")) return "red";
|
|
2241
|
+
if (statuses.some((s) => s === "yellow")) return "yellow";
|
|
2242
|
+
return "green";
|
|
2243
|
+
}
|
|
2244
|
+
async function probeDbRead(db) {
|
|
2245
|
+
const t0 = Date.now();
|
|
2246
|
+
try {
|
|
2247
|
+
await db.prepare("SELECT 1 as ok").first();
|
|
2248
|
+
const ms = Date.now() - t0;
|
|
2249
|
+
const status = ms < 200 ? "green" : ms < 1e3 ? "yellow" : "red";
|
|
2250
|
+
return { status, latency_ms: ms };
|
|
2251
|
+
} catch {
|
|
2252
|
+
return { status: "red", latency_ms: Date.now() - t0 };
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
async function probeDbWrite(db) {
|
|
2256
|
+
const t0 = Date.now();
|
|
2257
|
+
let rowId = null;
|
|
2258
|
+
try {
|
|
2259
|
+
const inserted = await db.prepare(`INSERT INTO event_log (event_type) VALUES ('health_probe') RETURNING id`).first();
|
|
2260
|
+
rowId = inserted?.id ?? null;
|
|
2261
|
+
const ms = Date.now() - t0;
|
|
2262
|
+
const status = ms < 500 ? "green" : ms < 2e3 ? "yellow" : "red";
|
|
2263
|
+
return { status, latency_ms: ms };
|
|
2264
|
+
} catch {
|
|
2265
|
+
return { status: "red", latency_ms: Date.now() - t0 };
|
|
2266
|
+
} finally {
|
|
2267
|
+
if (rowId !== null) {
|
|
2268
|
+
try {
|
|
2269
|
+
await db.prepare("DELETE FROM event_log WHERE id = ?").bind(rowId).run();
|
|
2270
|
+
} catch {
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
function probeQueueProducer(queue) {
|
|
2276
|
+
if (queue === null) return { status: "yellow", skipped: true };
|
|
2277
|
+
return { status: typeof queue.send === "function" ? "green" : "yellow", skipped: false };
|
|
2278
|
+
}
|
|
2279
|
+
async function probeCfApi(cfg) {
|
|
2280
|
+
if (!cfRoutingEnabled(cfg)) return { status: "green" };
|
|
2281
|
+
const t0 = Date.now();
|
|
2282
|
+
try {
|
|
2283
|
+
const res = await fetch(`${CF_API2}/zones/${cfg.zoneId}/email/routing`, {
|
|
2284
|
+
method: "GET",
|
|
2285
|
+
headers: { "Authorization": `Bearer ${cfg.apiToken}` }
|
|
2286
|
+
});
|
|
2287
|
+
const ms = Date.now() - t0;
|
|
2288
|
+
const httpStatus = res.status;
|
|
2289
|
+
if (!res.ok) return { status: "red", latency_ms: ms, http_status: httpStatus };
|
|
2290
|
+
const status = ms < 1e3 ? "green" : ms < 3e3 ? "yellow" : "red";
|
|
2291
|
+
return { status, latency_ms: ms, http_status: httpStatus };
|
|
2292
|
+
} catch {
|
|
2293
|
+
return { status: "red", latency_ms: Date.now() - t0 };
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
async function probeOutboxDrift(db) {
|
|
2297
|
+
try {
|
|
2298
|
+
const [pendingRow, failedRow] = await Promise.all([
|
|
2299
|
+
db.prepare(`SELECT COUNT(*) as cnt FROM cf_routing_outbox WHERE status = 'pending'`).first(),
|
|
2300
|
+
db.prepare(`SELECT COUNT(*) as cnt FROM cf_routing_outbox WHERE status = 'failed'`).first()
|
|
2301
|
+
]);
|
|
2302
|
+
const pending = pendingRow?.cnt ?? 0;
|
|
2303
|
+
const failed = failedRow?.cnt ?? 0;
|
|
2304
|
+
let status = "green";
|
|
2305
|
+
if (failed > 0) status = "red";
|
|
2306
|
+
else if (pending > 0) status = "yellow";
|
|
2307
|
+
return { status, pending_count: pending, failed_count: failed };
|
|
2308
|
+
} catch {
|
|
2309
|
+
return { status: "red", pending_count: 0, failed_count: 0 };
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
async function deepHealth(args) {
|
|
2313
|
+
const probed_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
2314
|
+
const [dbRead, dbWrite, cfApi, outboxDrift] = await Promise.all([
|
|
2315
|
+
probeDbRead(args.db),
|
|
2316
|
+
probeDbWrite(args.db),
|
|
2317
|
+
probeCfApi(args.cf),
|
|
2318
|
+
probeOutboxDrift(args.db)
|
|
2319
|
+
]);
|
|
2320
|
+
const queueProbe = probeQueueProducer(args.queue);
|
|
2321
|
+
const components = {
|
|
2322
|
+
db_read: dbRead.status,
|
|
2323
|
+
db_write: dbWrite.status,
|
|
2324
|
+
queue_producer: queueProbe.status,
|
|
2325
|
+
cf_api: cfApi.status,
|
|
2326
|
+
outbox_drift: outboxDrift.status,
|
|
2327
|
+
required_secrets: probeRequiredSecrets(args.secrets)
|
|
2328
|
+
};
|
|
2329
|
+
const status = aggregateStatus(Object.values(components));
|
|
2330
|
+
const details = {
|
|
2331
|
+
queue_send_skipped: queueProbe.skipped,
|
|
2332
|
+
outbox_pending_count: outboxDrift.pending_count,
|
|
2333
|
+
outbox_failed_count: outboxDrift.failed_count
|
|
2334
|
+
};
|
|
2335
|
+
if (dbRead.latency_ms !== void 0) details.db_read_latency_ms = dbRead.latency_ms;
|
|
2336
|
+
if (dbWrite.latency_ms !== void 0) details.db_write_latency_ms = dbWrite.latency_ms;
|
|
2337
|
+
if (cfApi.latency_ms !== void 0) details.cf_api_latency_ms = cfApi.latency_ms;
|
|
2338
|
+
if (cfApi.http_status !== void 0) details.cf_api_status = cfApi.http_status;
|
|
2339
|
+
return {
|
|
2340
|
+
status,
|
|
2341
|
+
components,
|
|
2342
|
+
details,
|
|
2343
|
+
probed_at
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
async function fetchAllCfRules(cfg) {
|
|
2347
|
+
const rules = [];
|
|
2348
|
+
let url2 = `${CF_API2}/zones/${cfg.zoneId}/email/routing/rules?per_page=1000`;
|
|
2349
|
+
while (url2 !== null) {
|
|
2350
|
+
const res = await fetch(url2, {
|
|
2351
|
+
headers: { "Authorization": `Bearer ${cfg.apiToken}` }
|
|
2352
|
+
});
|
|
2353
|
+
if (!res.ok) throw new Error(`CF rules list failed: ${res.status}`);
|
|
2354
|
+
const json = await res.json();
|
|
2355
|
+
if (!json.success || !json.result) throw new Error("CF rules list: success=false");
|
|
2356
|
+
rules.push(...json.result);
|
|
2357
|
+
const cursor = json.result_info?.cursor;
|
|
2358
|
+
url2 = cursor ? `${CF_API2}/zones/${cfg.zoneId}/email/routing/rules?per_page=1000&cursor=${encodeURIComponent(cursor)}` : null;
|
|
2359
|
+
}
|
|
2360
|
+
return rules;
|
|
2361
|
+
}
|
|
2362
|
+
async function auditCfRoutingDrift(args) {
|
|
2363
|
+
const { db, cf } = args;
|
|
2364
|
+
const outboxResult = await db.prepare(`SELECT id, op, pairing_code, cf_rule_id, bank_account_id, status, attempts, last_error, created_at FROM cf_routing_outbox WHERE status IN ('pending', 'failed') ORDER BY id`).all();
|
|
2365
|
+
const outbox_pending = outboxResult.results;
|
|
2366
|
+
if (!cfRoutingEnabled(cf)) {
|
|
2367
|
+
return {
|
|
2368
|
+
outbox_pending,
|
|
2369
|
+
db_rows_missing_cf_rule: [],
|
|
2370
|
+
cf_rules_orphaned: [],
|
|
2371
|
+
cf_api_disabled: true
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
const dbAccountsResult = await db.prepare(`SELECT id, account_number, pairing_code, cf_rule_id FROM bank_accounts WHERE ingest_mode IN ('email', 'both') ORDER BY id`).all();
|
|
2375
|
+
const dbAccounts = dbAccountsResult.results;
|
|
2376
|
+
const cfRules = await fetchAllCfRules(cf);
|
|
2377
|
+
const cfRuleIds = new Set(cfRules.map((r) => r.id));
|
|
2378
|
+
const db_rows_missing_cf_rule = dbAccounts.filter(
|
|
2379
|
+
(a) => a.cf_rule_id === null || !cfRuleIds.has(a.cf_rule_id)
|
|
2380
|
+
);
|
|
2381
|
+
const dbCfRuleIds = new Set(
|
|
2382
|
+
dbAccounts.map((a) => a.cf_rule_id).filter((id) => id !== null)
|
|
2383
|
+
);
|
|
2384
|
+
const banksyncPattern = new RegExp(`^[0-9a-f]{4,32}@${escapeRegExp(cf.domain)}$`);
|
|
2385
|
+
const cf_rules_orphaned = cfRules.filter((r) => {
|
|
2386
|
+
const firstMatcher = r.matchers?.[0];
|
|
2387
|
+
if (!firstMatcher) return false;
|
|
2388
|
+
if (firstMatcher.type !== "literal") return false;
|
|
2389
|
+
const val = firstMatcher.value ?? "";
|
|
2390
|
+
if (!banksyncPattern.test(val)) return false;
|
|
2391
|
+
return !dbCfRuleIds.has(r.id);
|
|
2392
|
+
}).map((r) => ({
|
|
2393
|
+
rule_id: r.id,
|
|
2394
|
+
matcher_value: r.matchers[0].value ?? ""
|
|
2395
|
+
}));
|
|
2396
|
+
return {
|
|
2397
|
+
outbox_pending,
|
|
2398
|
+
db_rows_missing_cf_rule,
|
|
2399
|
+
cf_rules_orphaned,
|
|
2400
|
+
cf_api_disabled: false
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
function escapeRegExp(s) {
|
|
2404
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
// src/alerter.ts
|
|
2408
|
+
var DEFAULT_THRESHOLDS = {
|
|
2409
|
+
parse_failure_rate_24h: 0.05,
|
|
2410
|
+
unmatched_24h: 10
|
|
2411
|
+
};
|
|
2412
|
+
async function evaluateThresholds(db, cfg) {
|
|
2413
|
+
const status = await getStatusData(db);
|
|
2414
|
+
const triggered = [];
|
|
2415
|
+
if (status.service.parse_failure_rate_24h > cfg.thresholds.parse_failure_rate_24h) {
|
|
2416
|
+
triggered.push({
|
|
2417
|
+
metric: "parse_failure_rate_24h",
|
|
2418
|
+
value: status.service.parse_failure_rate_24h,
|
|
2419
|
+
threshold: cfg.thresholds.parse_failure_rate_24h
|
|
2420
|
+
});
|
|
2421
|
+
}
|
|
2422
|
+
if (status.service.unmatched_24h >= cfg.thresholds.unmatched_24h) {
|
|
2423
|
+
triggered.push({
|
|
2424
|
+
metric: "unmatched_24h",
|
|
2425
|
+
value: status.service.unmatched_24h,
|
|
2426
|
+
threshold: cfg.thresholds.unmatched_24h
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
if (triggered.length === 0) {
|
|
2430
|
+
return null;
|
|
2431
|
+
}
|
|
2432
|
+
const severity = status.service.parse_failure_rate_24h > 0.5 ? "error" : "warn";
|
|
2433
|
+
return {
|
|
2434
|
+
service: cfg.service,
|
|
2435
|
+
severity,
|
|
2436
|
+
triggered_thresholds: triggered,
|
|
2437
|
+
status_snapshot: {
|
|
2438
|
+
parse_failure_rate_24h: status.service.parse_failure_rate_24h,
|
|
2439
|
+
parse_failures_24h: status.service.parse_failures_24h,
|
|
2440
|
+
unknown_currency_24h: status.service.unknown_currency_24h,
|
|
2441
|
+
unknown_provider_24h: status.service.unknown_provider_24h,
|
|
2442
|
+
unmatched_24h: status.service.unmatched_24h
|
|
2443
|
+
},
|
|
2444
|
+
probed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
2447
|
+
function toSlackPayload(p) {
|
|
2448
|
+
const lines = p.triggered_thresholds.map((t) => `\u2022 ${t.metric}: ${t.value} (threshold: ${t.threshold})`).join("\n");
|
|
2449
|
+
const emoji = p.severity === "error" ? "\u{1F6A8}" : "\u26A0\uFE0F";
|
|
2450
|
+
return {
|
|
2451
|
+
text: `${emoji} banksync alert (${p.severity}) on ${p.service}
|
|
2452
|
+
${lines}`,
|
|
2453
|
+
severity: p.severity,
|
|
2454
|
+
service: p.service,
|
|
2455
|
+
triggered_thresholds: p.triggered_thresholds,
|
|
2456
|
+
status_snapshot: p.status_snapshot,
|
|
2457
|
+
probed_at: p.probed_at
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
async function postAlert(payload, cfg) {
|
|
2461
|
+
if (!cfg.webhookUrl) {
|
|
2462
|
+
return false;
|
|
2463
|
+
}
|
|
2464
|
+
const body = toSlackPayload(payload);
|
|
2465
|
+
try {
|
|
2466
|
+
const response = await globalThis.fetch(cfg.webhookUrl, {
|
|
2467
|
+
method: "POST",
|
|
2468
|
+
headers: {
|
|
2469
|
+
"Content-Type": "application/json",
|
|
2470
|
+
...cfg.webhookSecret ? { Authorization: `Bearer ${cfg.webhookSecret}` } : {}
|
|
2471
|
+
},
|
|
2472
|
+
body: JSON.stringify(body)
|
|
2473
|
+
});
|
|
2474
|
+
if (!response.ok) {
|
|
2475
|
+
logError("alert_post_failed", new Error(`HTTP ${response.status}`), {
|
|
2476
|
+
metric_parse_rate: payload.status_snapshot.parse_failure_rate_24h
|
|
2477
|
+
});
|
|
2478
|
+
return false;
|
|
2479
|
+
}
|
|
2480
|
+
log("alert_posted", {
|
|
2481
|
+
severity: payload.severity,
|
|
2482
|
+
triggered_count: payload.triggered_thresholds.length
|
|
2483
|
+
});
|
|
2484
|
+
return true;
|
|
2485
|
+
} catch (err) {
|
|
2486
|
+
logError("alert_post_exception", err, {
|
|
2487
|
+
metric_parse_rate: payload.status_snapshot.parse_failure_rate_24h
|
|
2488
|
+
});
|
|
2489
|
+
return false;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
async function runAlerterTick(db, cfg) {
|
|
2493
|
+
if (!cfg.webhookUrl) {
|
|
2494
|
+
return { fired: false };
|
|
2495
|
+
}
|
|
2496
|
+
const payload = await evaluateThresholds(db, cfg);
|
|
2497
|
+
if (!payload) {
|
|
2498
|
+
const prior2 = await db.prepare(`SELECT value FROM alert_state WHERE key = 'alerter_active'`).first();
|
|
2499
|
+
if (!prior2 || prior2.value !== "active") return { fired: false };
|
|
2500
|
+
const status = await getStatusData(db);
|
|
2501
|
+
const recovery = {
|
|
2502
|
+
service: cfg.service,
|
|
2503
|
+
severity: "warn",
|
|
2504
|
+
triggered_thresholds: [{ metric: "delivery_recovered", value: 0, threshold: 0 }],
|
|
2505
|
+
status_snapshot: {
|
|
2506
|
+
parse_failure_rate_24h: status.service.parse_failure_rate_24h,
|
|
2507
|
+
parse_failures_24h: status.service.parse_failures_24h,
|
|
2508
|
+
unknown_currency_24h: status.service.unknown_currency_24h,
|
|
2509
|
+
unknown_provider_24h: status.service.unknown_provider_24h,
|
|
2510
|
+
unmatched_24h: status.service.unmatched_24h
|
|
2511
|
+
},
|
|
2512
|
+
probed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2513
|
+
};
|
|
2514
|
+
const posted2 = await postAlert(recovery, cfg);
|
|
2515
|
+
if (posted2) {
|
|
2516
|
+
await db.prepare(`INSERT INTO alert_state (key, value, updated_at) VALUES ('alerter_active', 'resolved', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`).bind((/* @__PURE__ */ new Date()).toISOString()).run();
|
|
2517
|
+
}
|
|
2518
|
+
return { fired: true, payload: recovery, posted: posted2 };
|
|
2519
|
+
}
|
|
2520
|
+
const prior = await db.prepare(`SELECT value, updated_at FROM alert_state WHERE key = 'alerter_active'`).first();
|
|
2521
|
+
const repeatAfterMs = 30 * 60 * 1e3;
|
|
2522
|
+
if (prior?.value === "active" && Date.now() - new Date(prior.updated_at).getTime() < repeatAfterMs) {
|
|
2523
|
+
return { fired: false };
|
|
2524
|
+
}
|
|
2525
|
+
const posted = await postAlert(payload, cfg);
|
|
2526
|
+
if (posted) {
|
|
2527
|
+
await db.prepare(`INSERT INTO alert_state (key, value, updated_at) VALUES ('alerter_active', 'active', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`).bind((/* @__PURE__ */ new Date()).toISOString()).run();
|
|
2528
|
+
}
|
|
2529
|
+
return { fired: true, payload, posted };
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
// src/backup.ts
|
|
2533
|
+
var TABLES = [
|
|
2534
|
+
"bank_accounts",
|
|
2535
|
+
"transactions",
|
|
2536
|
+
"webhook_consumers",
|
|
2537
|
+
"webhook_subscriptions",
|
|
2538
|
+
"parse_log",
|
|
2539
|
+
"webhook_log",
|
|
2540
|
+
"webhook_delivery_jobs",
|
|
2541
|
+
"webhook_delivery_alerts",
|
|
2542
|
+
"alert_state",
|
|
2543
|
+
"event_log",
|
|
2544
|
+
"schema_meta",
|
|
2545
|
+
"cf_routing_outbox",
|
|
2546
|
+
"admin_audit_log"
|
|
2547
|
+
];
|
|
2548
|
+
function base64(bytes) {
|
|
2549
|
+
let value = "";
|
|
2550
|
+
for (const byte of bytes) value += String.fromCharCode(byte);
|
|
2551
|
+
return btoa(value);
|
|
2552
|
+
}
|
|
2553
|
+
function fromBase64(value) {
|
|
2554
|
+
const decoded = atob(value);
|
|
2555
|
+
return Uint8Array.from(decoded, (char) => char.charCodeAt(0));
|
|
2556
|
+
}
|
|
2557
|
+
async function importBackupKey(encoded) {
|
|
2558
|
+
const bytes = fromBase64(encoded);
|
|
2559
|
+
if (bytes.byteLength !== 32) throw new Error("backup_encryption_key_must_be_32_bytes");
|
|
2560
|
+
return crypto.subtle.importKey("raw", bytes.buffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
2561
|
+
}
|
|
2562
|
+
function backupAad(envelope) {
|
|
2563
|
+
return new TextEncoder().encode(JSON.stringify(envelope));
|
|
2564
|
+
}
|
|
2565
|
+
async function encryptBackup(sql, encodedKey, keyVersion) {
|
|
2566
|
+
if (!Number.isInteger(keyVersion) || keyVersion < 1) throw new Error("invalid_backup_key_version");
|
|
2567
|
+
const metadata = {
|
|
2568
|
+
format: "banksync-backup",
|
|
2569
|
+
version: 1,
|
|
2570
|
+
key_version: keyVersion,
|
|
2571
|
+
algorithm: "AES-256-GCM",
|
|
2572
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2573
|
+
};
|
|
2574
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
2575
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
2576
|
+
{ name: "AES-GCM", iv: iv.buffer, additionalData: backupAad(metadata).buffer, tagLength: 128 },
|
|
2577
|
+
await importBackupKey(encodedKey),
|
|
2578
|
+
new TextEncoder().encode(sql)
|
|
2579
|
+
);
|
|
2580
|
+
const envelope = {
|
|
2581
|
+
...metadata,
|
|
2582
|
+
iv: base64(iv),
|
|
2583
|
+
ciphertext: base64(new Uint8Array(ciphertext))
|
|
2584
|
+
};
|
|
2585
|
+
return new TextEncoder().encode(JSON.stringify(envelope));
|
|
2586
|
+
}
|
|
2587
|
+
function dateKey(d = /* @__PURE__ */ new Date()) {
|
|
2588
|
+
const year = d.getUTCFullYear();
|
|
2589
|
+
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
2590
|
+
const day = String(d.getUTCDate()).padStart(2, "0");
|
|
2591
|
+
return `${year}${month}${day}`;
|
|
2592
|
+
}
|
|
2593
|
+
function escapeSqlString(s) {
|
|
2594
|
+
return s.replace(/'/g, "''");
|
|
2595
|
+
}
|
|
2596
|
+
function sqlValue(v2) {
|
|
2597
|
+
if (v2 === null || v2 === void 0) return "NULL";
|
|
2598
|
+
if (typeof v2 === "number") {
|
|
2599
|
+
return Number.isFinite(v2) ? String(v2) : "NULL";
|
|
2600
|
+
}
|
|
2601
|
+
if (typeof v2 === "boolean") return v2 ? "1" : "0";
|
|
2602
|
+
return `'${escapeSqlString(String(v2))}'`;
|
|
2603
|
+
}
|
|
2604
|
+
async function buildSqlDump(db) {
|
|
2605
|
+
const lines = [];
|
|
2606
|
+
lines.push(`-- banksync backup ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
2607
|
+
const versionRow = await db.prepare(`SELECT value FROM schema_meta WHERE key = 'version'`).first();
|
|
2608
|
+
lines.push(`-- schema_version=${versionRow?.value ?? "unknown"}`);
|
|
2609
|
+
lines.push(`-- restore: re-apply migrations then load this file`);
|
|
2610
|
+
lines.push("");
|
|
2611
|
+
const rowCounts = {};
|
|
2612
|
+
for (const table of TABLES) {
|
|
2613
|
+
const r = await db.prepare(`SELECT * FROM ${table}`).all();
|
|
2614
|
+
rowCounts[table] = r.results.length;
|
|
2615
|
+
if (r.results.length === 0) continue;
|
|
2616
|
+
const cols = Object.keys(r.results[0]);
|
|
2617
|
+
lines.push(`-- ${table} (${r.results.length} rows)`);
|
|
2618
|
+
const insert = table === "schema_meta" ? "INSERT OR REPLACE INTO" : "INSERT INTO";
|
|
2619
|
+
for (const row of r.results) {
|
|
2620
|
+
const values = cols.map((c) => sqlValue(row[c])).join(", ");
|
|
2621
|
+
lines.push(`${insert} ${table} (${cols.join(", ")}) VALUES (${values});`);
|
|
2622
|
+
}
|
|
2623
|
+
lines.push("");
|
|
2624
|
+
}
|
|
2625
|
+
return { sql: lines.join("\n"), rowCounts };
|
|
2626
|
+
}
|
|
2627
|
+
async function runBackupTick(db, cfg) {
|
|
2628
|
+
if (!cfg.bucket) {
|
|
2629
|
+
return { uploaded: false, skipped_reason: "r2_bucket_unbound" };
|
|
2630
|
+
}
|
|
2631
|
+
if (!cfg.encryptionKey || !cfg.keyVersion) {
|
|
2632
|
+
return { uploaded: false, skipped_reason: "backup_encryption_not_configured" };
|
|
2633
|
+
}
|
|
2634
|
+
const { sql, rowCounts } = await buildSqlDump(db);
|
|
2635
|
+
const key = `${cfg.prefix}-${dateKey()}.sql.enc`;
|
|
2636
|
+
const buf = await encryptBackup(sql, cfg.encryptionKey, cfg.keyVersion);
|
|
2637
|
+
await cfg.bucket.put(key, buf, {
|
|
2638
|
+
httpMetadata: { contentType: "application/octet-stream" },
|
|
2639
|
+
customMetadata: {
|
|
2640
|
+
banksync_table_count: String(TABLES.length),
|
|
2641
|
+
banksync_backup_format: "aes-256-gcm-v1",
|
|
2642
|
+
banksync_backup_key_version: String(cfg.keyVersion),
|
|
2643
|
+
banksync_total_rows: String(Object.values(rowCounts).reduce((a, b) => a + b, 0))
|
|
2644
|
+
}
|
|
2645
|
+
});
|
|
2646
|
+
const retain = cfg.retain ?? 8;
|
|
2647
|
+
const list = await cfg.bucket.list({ prefix: cfg.prefix, limit: 1e3 });
|
|
2648
|
+
const sortedKeys = list.objects.map((o) => o.key).sort().reverse();
|
|
2649
|
+
const toPrune = sortedKeys.slice(retain);
|
|
2650
|
+
for (const k of toPrune) {
|
|
2651
|
+
await cfg.bucket.delete(k);
|
|
2652
|
+
}
|
|
2653
|
+
return {
|
|
2654
|
+
uploaded: true,
|
|
2655
|
+
key,
|
|
2656
|
+
size_bytes: buf.byteLength,
|
|
2657
|
+
table_row_counts: rowCounts,
|
|
2658
|
+
pruned_keys: toPrune
|
|
2659
|
+
};
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
// src/cloudflare.ts
|
|
2663
|
+
function fioProxyConfig(env) {
|
|
2664
|
+
const url2 = env.FIO_PROXY_URL?.trim();
|
|
2665
|
+
const secret = env.FIO_PROXY_SECRET?.trim();
|
|
2666
|
+
return url2 && secret ? { url: url2, secret } : void 0;
|
|
2667
|
+
}
|
|
2668
|
+
function cfRoutingConfig(env) {
|
|
2669
|
+
return {
|
|
2670
|
+
apiToken: env.CF_API_TOKEN,
|
|
2671
|
+
zoneId: env.CF_ZONE_ID ?? "",
|
|
2672
|
+
domain: env.BANKSYNC_DOMAIN ?? "banksync.festapp.net"
|
|
2673
|
+
};
|
|
2674
|
+
}
|
|
2675
|
+
async function processEmail(rawStream, env, envelope) {
|
|
2676
|
+
let bodyText;
|
|
2677
|
+
try {
|
|
2678
|
+
let extracted;
|
|
2679
|
+
try {
|
|
2680
|
+
extracted = await extractEmailBody(rawStream);
|
|
2681
|
+
bodyText = extracted.text;
|
|
2682
|
+
} catch (err) {
|
|
2683
|
+
const rawText = await new Response(rawStream).text().catch(() => "(failed to read raw)");
|
|
2684
|
+
await insertParseLog(env.DB, {
|
|
2685
|
+
error_message: `mime_parse_failed: ${err}`,
|
|
2686
|
+
raw_data: rawText === "(failed to read raw)" ? rawText : "(raw MIME omitted)"
|
|
2687
|
+
});
|
|
2688
|
+
log("email_rejected_mime", {});
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
let identity;
|
|
2692
|
+
try {
|
|
2693
|
+
identity = authenticateEmailIdentity(envelope, extracted);
|
|
2694
|
+
} catch (err) {
|
|
2695
|
+
const reason = err instanceof EmailAuthenticationError ? err.code : "email_authentication_failed";
|
|
2696
|
+
await insertParseLog(env.DB, { error_message: reason, raw_data: sanitizeDiagnosticRaw(extracted.text), external_id: extracted.messageId ?? null });
|
|
2697
|
+
log("email_rejected_authentication", { reason });
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
const allowlist = parseAllowlist(env.SENDER_ALLOWLIST);
|
|
2701
|
+
const pairingCode = extractPairingCode(identity.recipient);
|
|
2702
|
+
const account = pairingCode ? await findBankAccountByPairingCode(env.DB, pairingCode) : null;
|
|
2703
|
+
const outcome = classifyEmail(extracted, identity, account, allowlist);
|
|
2704
|
+
if (outcome.received) {
|
|
2705
|
+
log("email_received", { pairing_code: pairingCode, message_id: extracted.messageId ?? null });
|
|
2706
|
+
await writeEvent(env.DB, { event_type: "email_received", bank_account_id: account.id, detail: { message_id: extracted.messageId ?? null } });
|
|
2707
|
+
}
|
|
2708
|
+
if (outcome.kind === "reject" || outcome.kind === "skip") {
|
|
2709
|
+
await insertParseLog(env.DB, {
|
|
2710
|
+
bank_account_id: outcome.bankAccountId ?? null,
|
|
2711
|
+
error_message: outcome.reason,
|
|
2712
|
+
raw_data: sanitizeDiagnosticRaw(extracted.text),
|
|
2713
|
+
external_id: extracted.messageId ?? null
|
|
2714
|
+
});
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2717
|
+
const acct = outcome.account;
|
|
2718
|
+
let result;
|
|
2719
|
+
try {
|
|
2720
|
+
result = await insertTransaction(env.DB, {
|
|
2721
|
+
bank_account_id: acct.id,
|
|
2722
|
+
payload: {
|
|
2723
|
+
...outcome.parsed,
|
|
2724
|
+
date: outcome.parsed.date ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
2725
|
+
external_id: extracted.messageId ?? null
|
|
2726
|
+
}
|
|
2727
|
+
});
|
|
2728
|
+
} catch (err) {
|
|
2729
|
+
await insertParseLog(env.DB, {
|
|
2730
|
+
bank_account_id: acct.id,
|
|
2731
|
+
error_message: `db_insert_failed: ${err}`,
|
|
2732
|
+
raw_data: sanitizeDiagnosticRaw(JSON.stringify({ ...outcome.parsed, external_id: extracted.messageId ?? null })),
|
|
2733
|
+
external_id: extracted.messageId ?? null
|
|
2734
|
+
});
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
if (result.status === "skipped") {
|
|
2738
|
+
log("tx_skipped", { reason: result.reason, bank_account_id: acct.id });
|
|
2739
|
+
await writeEvent(env.DB, { event_type: "tx_skipped", bank_account_id: acct.id, detail: { reason: result.reason } });
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
const tx = result.transaction;
|
|
2743
|
+
log("tx_inserted", { bank_account_id: acct.id, tx_id: tx.id, vs: tx.vs, amount_cents: tx.amount_cents, currency: tx.currency });
|
|
2744
|
+
await writeEvent(env.DB, { event_type: "tx_inserted", bank_account_id: acct.id, detail: { tx_id: tx.id } });
|
|
2745
|
+
await createWebhookDeliveryCoordinator(env).observeTransaction(tx.id);
|
|
2746
|
+
} catch (err) {
|
|
2747
|
+
try {
|
|
2748
|
+
await insertParseLog(env.DB, {
|
|
2749
|
+
error_message: `unhandled: ${err}`,
|
|
2750
|
+
raw_data: sanitizeDiagnosticRaw(bodyText ?? "Body read failed")
|
|
2751
|
+
});
|
|
2752
|
+
} catch {
|
|
2753
|
+
}
|
|
2754
|
+
logError("email_unhandled_exception", err, {});
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
function sanitizeDiagnosticRaw(value) {
|
|
2758
|
+
return value.slice(0, 4096).replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]").replace(/\b\d{6,}\b/g, "[number]");
|
|
2759
|
+
}
|
|
2760
|
+
function generatePairingCode() {
|
|
2761
|
+
const bytes = new Uint8Array(5);
|
|
2762
|
+
crypto.getRandomValues(bytes);
|
|
2763
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2764
|
+
}
|
|
2765
|
+
async function generateUniquePairingCode(db) {
|
|
2766
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
2767
|
+
const code = generatePairingCode();
|
|
2768
|
+
if (!await pairingCodeExists(db, code)) return code;
|
|
2769
|
+
log("pairing_code_collision_retry", { attempt: attempt + 1 });
|
|
2770
|
+
}
|
|
2771
|
+
throw new Error("pairing_code_collision_exhausted: 5 consecutive collisions (entropy/RNG misconfiguration?)");
|
|
2772
|
+
}
|
|
2773
|
+
function generateConsumerSecret() {
|
|
2774
|
+
const bytes = new Uint8Array(32);
|
|
2775
|
+
crypto.getRandomValues(bytes);
|
|
2776
|
+
const b64 = btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
2777
|
+
return "whsec_" + b64;
|
|
2778
|
+
}
|
|
2779
|
+
function apiMinIntervalS(env) {
|
|
2780
|
+
const raw = env.FIO_MIN_INTERVAL_S;
|
|
2781
|
+
if (!raw) return 30;
|
|
2782
|
+
const n = Number.parseInt(raw, 10);
|
|
2783
|
+
return Number.isFinite(n) && n >= 0 ? n : 30;
|
|
2784
|
+
}
|
|
2785
|
+
function tokenPrefix(token) {
|
|
2786
|
+
return token.slice(0, 6);
|
|
2787
|
+
}
|
|
2788
|
+
function yyyyMmDdDaysAgo(days) {
|
|
2789
|
+
const d = /* @__PURE__ */ new Date();
|
|
2790
|
+
d.setUTCDate(d.getUTCDate() - days);
|
|
2791
|
+
return d.toISOString().slice(0, 10);
|
|
2792
|
+
}
|
|
2793
|
+
var API_POLL_CRON = "* * * * *";
|
|
2794
|
+
var MAINTENANCE_CRON = "15 3 * * *";
|
|
2795
|
+
var API_SYNC_LEASE_S = 30;
|
|
2796
|
+
function scheduledCron(event) {
|
|
2797
|
+
const cron = event.cron;
|
|
2798
|
+
return typeof cron === "string" ? cron : null;
|
|
2799
|
+
}
|
|
2800
|
+
function shouldRunApiPolling(event) {
|
|
2801
|
+
const cron = scheduledCron(event);
|
|
2802
|
+
return cron === null || cron === API_POLL_CRON;
|
|
2803
|
+
}
|
|
2804
|
+
function shouldRunMaintenance(event) {
|
|
2805
|
+
const cron = scheduledCron(event);
|
|
2806
|
+
return cron === null || cron === MAINTENANCE_CRON;
|
|
2807
|
+
}
|
|
2808
|
+
function apiSyncDelayS(env) {
|
|
2809
|
+
return Math.max(30, apiMinIntervalS(env));
|
|
2810
|
+
}
|
|
2811
|
+
async function runBankApiSync(env, account) {
|
|
2812
|
+
if (account.account_type !== "FIO") {
|
|
2813
|
+
throw new Error(`unsupported_api_account_type: ${account.account_type}`);
|
|
2814
|
+
}
|
|
2815
|
+
const isBackfill = !account.api_backfill_done;
|
|
2816
|
+
try {
|
|
2817
|
+
const token = await decryptSecret(account.api_token_cipher, account.api_token_key_ver, env);
|
|
2818
|
+
if (isBackfill && account.api_last_success_at === null) {
|
|
2819
|
+
await markBankAccountApiFetchStarted(env.DB, account.id);
|
|
2820
|
+
await setFioPointer(token, yyyyMmDdDaysAgo(90), fioProxyConfig(env));
|
|
2821
|
+
await markBankAccountApiPointerSet(env.DB, account.id);
|
|
2822
|
+
await writeEvent(env.DB, {
|
|
2823
|
+
event_type: "api_pointer_set",
|
|
2824
|
+
bank_account_id: account.id,
|
|
2825
|
+
detail: { provider: account.account_type, backfill_days: 90 }
|
|
2826
|
+
});
|
|
2827
|
+
return {
|
|
2828
|
+
bank_account_id: account.id,
|
|
2829
|
+
provider: account.account_type,
|
|
2830
|
+
inserted: 0,
|
|
2831
|
+
skipped_duplicate: 0,
|
|
2832
|
+
skipped_outgoing: 0,
|
|
2833
|
+
parse_errors: 0,
|
|
2834
|
+
queued_webhooks: 0,
|
|
2835
|
+
backfill: true,
|
|
2836
|
+
deferred: true
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
await markBankAccountApiFetchStarted(env.DB, account.id);
|
|
2840
|
+
const raw = await fetchNewTransactions(token, fioProxyConfig(env));
|
|
2841
|
+
let inserted = 0;
|
|
2842
|
+
let skippedDuplicate = 0;
|
|
2843
|
+
let skippedOutgoing = 0;
|
|
2844
|
+
let parseErrors = 0;
|
|
2845
|
+
let queuedWebhooks = 0;
|
|
2846
|
+
for (const item of raw) {
|
|
2847
|
+
let mapped;
|
|
2848
|
+
try {
|
|
2849
|
+
mapped = mapFioTransaction(item);
|
|
2850
|
+
} catch (err) {
|
|
2851
|
+
parseErrors++;
|
|
2852
|
+
await insertParseLog(env.DB, {
|
|
2853
|
+
bank_account_id: account.id,
|
|
2854
|
+
error_message: `${err}`.replace(/^Error:\s*/, ""),
|
|
2855
|
+
raw_data: JSON.stringify({ fields: Object.keys(item).sort() })
|
|
2856
|
+
});
|
|
2857
|
+
continue;
|
|
2858
|
+
}
|
|
2859
|
+
if (mapped === null) {
|
|
2860
|
+
skippedOutgoing++;
|
|
2861
|
+
await insertParseLog(env.DB, {
|
|
2862
|
+
bank_account_id: account.id,
|
|
2863
|
+
error_message: "outgoing_filtered",
|
|
2864
|
+
raw_data: null
|
|
2865
|
+
});
|
|
2866
|
+
continue;
|
|
2867
|
+
}
|
|
2868
|
+
const insertResult = await insertTransaction(env.DB, {
|
|
2869
|
+
bank_account_id: account.id,
|
|
2870
|
+
payload: mapped
|
|
2871
|
+
});
|
|
2872
|
+
if (insertResult.status === "skipped") {
|
|
2873
|
+
skippedDuplicate++;
|
|
2874
|
+
continue;
|
|
2875
|
+
}
|
|
2876
|
+
inserted++;
|
|
2877
|
+
await writeEvent(env.DB, {
|
|
2878
|
+
event_type: "tx_inserted",
|
|
2879
|
+
bank_account_id: account.id,
|
|
2880
|
+
detail: { tx_id: insertResult.transaction.id, source: "api" }
|
|
2881
|
+
});
|
|
2882
|
+
if (!isBackfill) {
|
|
2883
|
+
queuedWebhooks += (await createWebhookDeliveryCoordinator(env).observeTransaction(insertResult.transaction.id)).dispatched;
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
await markBankAccountApiFetchSuccess(env.DB, account.id, { backfill_done: true });
|
|
2887
|
+
await writeEvent(env.DB, {
|
|
2888
|
+
event_type: "api_pull_completed",
|
|
2889
|
+
bank_account_id: account.id,
|
|
2890
|
+
detail: { provider: account.account_type, inserted, skipped_duplicate: skippedDuplicate, parse_errors: parseErrors, backfill: isBackfill }
|
|
2891
|
+
});
|
|
2892
|
+
return {
|
|
2893
|
+
bank_account_id: account.id,
|
|
2894
|
+
provider: account.account_type,
|
|
2895
|
+
inserted,
|
|
2896
|
+
skipped_duplicate: skippedDuplicate,
|
|
2897
|
+
skipped_outgoing: skippedOutgoing,
|
|
2898
|
+
parse_errors: parseErrors,
|
|
2899
|
+
queued_webhooks: queuedWebhooks,
|
|
2900
|
+
backfill: isBackfill,
|
|
2901
|
+
deferred: false
|
|
2902
|
+
};
|
|
2903
|
+
} catch (err) {
|
|
2904
|
+
await markBankAccountApiFetchFailure(env.DB, account.id, String(err));
|
|
2905
|
+
if (err instanceof FioRateLimited) {
|
|
2906
|
+
await writeEvent(env.DB, {
|
|
2907
|
+
event_type: "api_rate_limited",
|
|
2908
|
+
bank_account_id: account.id,
|
|
2909
|
+
detail: { provider: account.account_type, retry_after_s: err.retryAfterS }
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
throw err;
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
async function runDueBankApiSyncs(env) {
|
|
2916
|
+
const accounts = await listDueApiFetchAccounts(env.DB, apiMinIntervalS(env));
|
|
2917
|
+
const processedTokenPrefixes = /* @__PURE__ */ new Set();
|
|
2918
|
+
for (const account of accounts) {
|
|
2919
|
+
const tokenKey = account.api_token_prefix ?? `account:${account.id}`;
|
|
2920
|
+
if (processedTokenPrefixes.has(tokenKey)) {
|
|
2921
|
+
log("api_sync_skipped_duplicate_token_prefix", { bank_account_id: account.id, account_type: account.account_type });
|
|
2922
|
+
continue;
|
|
2923
|
+
}
|
|
2924
|
+
processedTokenPrefixes.add(tokenKey);
|
|
2925
|
+
try {
|
|
2926
|
+
const result = await runBankApiSync(env, account);
|
|
2927
|
+
log("api_sync_completed", {
|
|
2928
|
+
bank_account_id: account.id,
|
|
2929
|
+
account_type: account.account_type,
|
|
2930
|
+
inserted: result.inserted,
|
|
2931
|
+
skipped_duplicate: result.skipped_duplicate,
|
|
2932
|
+
parse_errors: result.parse_errors,
|
|
2933
|
+
backfill: result.backfill,
|
|
2934
|
+
deferred: result.deferred
|
|
2935
|
+
});
|
|
2936
|
+
} catch (err) {
|
|
2937
|
+
if (err instanceof FioRateLimited) {
|
|
2938
|
+
log("api_rate_limited", { bank_account_id: account.id, account_type: account.account_type, retry_after_s: err.retryAfterS });
|
|
2939
|
+
continue;
|
|
2940
|
+
}
|
|
2941
|
+
if (err instanceof FioTransientFailure) {
|
|
2942
|
+
logError("api_transient_failure", err, { bank_account_id: account.id, account_type: account.account_type });
|
|
2943
|
+
continue;
|
|
2944
|
+
}
|
|
2945
|
+
logError("api_sync_failed", err, { bank_account_id: account.id, account_type: account.account_type });
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
async function enqueueApiSyncTick(env, delaySeconds, source) {
|
|
2950
|
+
if (!env.API_SYNC_QUEUE) return false;
|
|
2951
|
+
await env.API_SYNC_QUEUE.send({
|
|
2952
|
+
kind: "api_sync_tick",
|
|
2953
|
+
source,
|
|
2954
|
+
enqueued_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2955
|
+
}, { delaySeconds });
|
|
2956
|
+
return true;
|
|
2957
|
+
}
|
|
2958
|
+
async function handleApiSyncQueue(batch, env) {
|
|
2959
|
+
await assertSchemaVersion(env.DB);
|
|
2960
|
+
for (const rawMsg of batch.messages) {
|
|
2961
|
+
const msg = rawMsg;
|
|
2962
|
+
if (msg.body?.kind !== "api_sync_tick") {
|
|
2963
|
+
logError("api_sync_queue_unknown_message", new Error("unknown api sync queue message"), { queue: batch.queue });
|
|
2964
|
+
msg.ack();
|
|
2965
|
+
continue;
|
|
2966
|
+
}
|
|
2967
|
+
try {
|
|
2968
|
+
const acquired = await tryAcquireApiSyncLease(env.DB, API_SYNC_LEASE_S);
|
|
2969
|
+
if (!acquired) {
|
|
2970
|
+
log("api_sync_tick_skipped_lease", { source: msg.body.source });
|
|
2971
|
+
msg.ack();
|
|
2972
|
+
continue;
|
|
2973
|
+
}
|
|
2974
|
+
await runDueBankApiSyncs(env);
|
|
2975
|
+
msg.ack();
|
|
2976
|
+
} catch (err) {
|
|
2977
|
+
logError("api_sync_tick_failed", err, { source: msg.body.source });
|
|
2978
|
+
msg.retry({ delaySeconds: apiSyncDelayS(env) });
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
function unauthorized() {
|
|
2983
|
+
return new Response("Unauthorized", { status: 401, headers: { "WWW-Authenticate": "Bearer" } });
|
|
2984
|
+
}
|
|
2985
|
+
function forbidden() {
|
|
2986
|
+
return new Response(JSON.stringify({ error: "forbidden" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
2987
|
+
}
|
|
2988
|
+
function jsonResponse(body, status = 200, extraHeaders) {
|
|
2989
|
+
return new Response(JSON.stringify(body), {
|
|
2990
|
+
status,
|
|
2991
|
+
headers: { "Content-Type": "application/json", ...extraHeaders }
|
|
2992
|
+
});
|
|
2993
|
+
}
|
|
2994
|
+
function notFound() {
|
|
2995
|
+
return new Response("Not found", { status: 404 });
|
|
2996
|
+
}
|
|
2997
|
+
async function healthResponse(env) {
|
|
2998
|
+
try {
|
|
2999
|
+
await assertSchemaVersion(env.DB);
|
|
3000
|
+
return jsonResponse({ ok: true });
|
|
3001
|
+
} catch {
|
|
3002
|
+
return jsonResponse({ ok: false }, 503);
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
async function statusResponse(env) {
|
|
3006
|
+
const data = await getStatusData(env.DB);
|
|
3007
|
+
return jsonResponse(data);
|
|
3008
|
+
}
|
|
3009
|
+
async function runTestEmail(req, env) {
|
|
3010
|
+
const raw = req.body;
|
|
3011
|
+
if (!raw) return new Response("Empty body", { status: 400 });
|
|
3012
|
+
const mailFrom = req.headers.get("x-test-mail-from");
|
|
3013
|
+
const rcptTo = req.headers.get("x-test-rcpt-to");
|
|
3014
|
+
if (!mailFrom || !rcptTo || !env.EMAIL_AUTHSERV_ID) return jsonResponse({ error: "test_envelope_required" }, 400);
|
|
3015
|
+
await processEmail(raw, env, {
|
|
3016
|
+
mailFrom,
|
|
3017
|
+
rcptTo,
|
|
3018
|
+
authenticationResults: req.headers.get("authentication-results"),
|
|
3019
|
+
trustedAuthservId: env.EMAIL_AUTHSERV_ID
|
|
3020
|
+
});
|
|
3021
|
+
return new Response("ok", { status: 200 });
|
|
3022
|
+
}
|
|
3023
|
+
async function adminFetch(req, env) {
|
|
3024
|
+
const url2 = new URL(req.url);
|
|
3025
|
+
if (url2.pathname === "/health" && req.method === "GET") return healthResponse(env);
|
|
3026
|
+
const isOperatorRoute = req.method === "GET" && (url2.pathname === "/status" || url2.pathname === "/health/deep");
|
|
3027
|
+
if (isOperatorRoute) {
|
|
3028
|
+
const candidate = req.headers.get("X-Admin-Secret") ?? "";
|
|
3029
|
+
if (!candidate || !env.ADMIN_SECRET || !timingSafeEqual(candidate, env.ADMIN_SECRET)) return unauthorized();
|
|
3030
|
+
await assertSchemaVersion(env.DB);
|
|
3031
|
+
const probeLimit = await checkAndIncrement(env.DB, "admin:operator-probe", { windowSeconds: 60, limit: 60 });
|
|
3032
|
+
if (!probeLimit.allowed) {
|
|
3033
|
+
return jsonResponse({ error: "rate_limited", retry_after: probeLimit.retryAfter }, 429, { "Retry-After": String(probeLimit.retryAfter) });
|
|
3034
|
+
}
|
|
3035
|
+
if (url2.pathname === "/status") return statusResponse(env);
|
|
3036
|
+
const result = await deepHealth({
|
|
3037
|
+
db: env.DB,
|
|
3038
|
+
queue: env.WEBHOOK_QUEUE ?? null,
|
|
3039
|
+
cf: cfRoutingConfig(env),
|
|
3040
|
+
secrets: {
|
|
3041
|
+
alertWebhookPresent: Boolean(env.ALERT_WEBHOOK_URL && env.ALERT_WEBHOOK_SECRET),
|
|
3042
|
+
backupsPresent: Boolean(
|
|
3043
|
+
env.BACKUPS && Number.isInteger(Number(env.BACKUP_ENCRYPTION_KEY_VERSION)) && env[`BACKUP_ENCRYPTION_KEY_V${env.BACKUP_ENCRYPTION_KEY_VERSION}`]
|
|
3044
|
+
),
|
|
3045
|
+
emailAuthPresent: Boolean(env.EMAIL_AUTHSERV_ID?.trim()),
|
|
3046
|
+
callbackPolicyPresent: Boolean(env.CALLBACK_HOST_ALLOWLIST?.trim()),
|
|
3047
|
+
isProduction: env.ENV === "production"
|
|
3048
|
+
}
|
|
3049
|
+
});
|
|
3050
|
+
return jsonResponse(result, result.status === "red" ? 503 : 200);
|
|
3051
|
+
}
|
|
3052
|
+
await assertSchemaVersion(env.DB);
|
|
3053
|
+
const authCtx = await resolveAuth(req, env);
|
|
3054
|
+
const authPrincipal = authCtx.type === "admin" ? "admin" : authCtx.type === "tenant" ? `tenant:${authCtx.app_id}` : "unauth";
|
|
3055
|
+
if (url2.pathname === "/__test/email" && req.method === "POST") {
|
|
3056
|
+
if (env.ENV === "production") return notFound();
|
|
3057
|
+
if (authCtx.type !== "admin") return unauthorized();
|
|
3058
|
+
return runTestEmail(req, env);
|
|
3059
|
+
}
|
|
3060
|
+
if (authCtx.type === "unauth") {
|
|
3061
|
+
const sourceIp = req.headers.get("cf-connecting-ip") ?? "unknown";
|
|
3062
|
+
const failed = await checkAndIncrement(env.DB, `auth-failed:${sourceIp}`, { windowSeconds: 60, limit: 20 });
|
|
3063
|
+
if (!failed.allowed) return jsonResponse({ error: "rate_limited", retry_after: failed.retryAfter }, 429, { "Retry-After": String(failed.retryAfter) });
|
|
3064
|
+
return unauthorized();
|
|
3065
|
+
}
|
|
3066
|
+
{
|
|
3067
|
+
const rl = await checkAndIncrement(env.DB, authPrincipal, authCtx.type === "admin" ? { windowSeconds: 60, limit: 1e3 } : void 0);
|
|
3068
|
+
if (!rl.allowed) {
|
|
3069
|
+
return jsonResponse({ error: "rate_limited", retry_after: rl.retryAfter }, 429, { "Retry-After": String(rl.retryAfter) });
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
const isMutating = req.method !== "GET" && req.method !== "HEAD";
|
|
3073
|
+
let bodyText = "";
|
|
3074
|
+
if (isMutating) {
|
|
3075
|
+
const read = await readBoundedBody(req, 256 * 1024);
|
|
3076
|
+
if (read instanceof Response) return read;
|
|
3077
|
+
bodyText = read;
|
|
3078
|
+
}
|
|
3079
|
+
const credentialRoute = isCredentialReturningRoute(url2.pathname, req.method);
|
|
3080
|
+
if (credentialRoute && req.headers.has("Idempotency-Key")) {
|
|
3081
|
+
return jsonResponse({ error: "idempotency_not_supported" }, 400);
|
|
3082
|
+
}
|
|
3083
|
+
if (isMutating && !credentialRoute) {
|
|
3084
|
+
const idem = await checkIdempotency({ db: env.DB, request: req, authPrincipal, requestBodyText: bodyText });
|
|
3085
|
+
if (idem.kind === "hit") return new Response(idem.body, { status: idem.status, headers: { "Content-Type": "application/json" } });
|
|
3086
|
+
if (idem.kind === "in_flight") return jsonResponse({ error: "idempotency_request_in_flight" }, 409, { "Retry-After": "1" });
|
|
3087
|
+
if (idem.kind === "mismatch") return jsonResponse({ error: "idempotency_key_body_mismatch" }, 422);
|
|
3088
|
+
}
|
|
3089
|
+
const start = Date.now();
|
|
3090
|
+
let handlerResponse;
|
|
3091
|
+
try {
|
|
3092
|
+
handlerResponse = await dispatch(url2, req, env, authCtx, bodyText);
|
|
3093
|
+
} catch (err) {
|
|
3094
|
+
const idemKey = !credentialRoute ? req.headers.get("Idempotency-Key") : null;
|
|
3095
|
+
if (idemKey && idemKey.length >= 1 && idemKey.length <= 255) {
|
|
3096
|
+
await recordIdempotency({
|
|
3097
|
+
db: env.DB,
|
|
3098
|
+
keyHash: await sha256Hex2(`${authPrincipal}
|
|
3099
|
+
${req.method.toUpperCase()}
|
|
3100
|
+
${url2.pathname}
|
|
3101
|
+
${idemKey}`),
|
|
3102
|
+
authPrincipal,
|
|
3103
|
+
requestPath: url2.pathname,
|
|
3104
|
+
requestMethod: req.method,
|
|
3105
|
+
requestBodyHash: bodyText.length > 0 ? await sha256Hex2(bodyText) : null,
|
|
3106
|
+
responseStatus: 500,
|
|
3107
|
+
responseBody: ""
|
|
3108
|
+
});
|
|
3109
|
+
}
|
|
3110
|
+
throw err;
|
|
3111
|
+
}
|
|
3112
|
+
const duration = Date.now() - start;
|
|
3113
|
+
const respClone = handlerResponse.clone();
|
|
3114
|
+
const respBody = await respClone.text();
|
|
3115
|
+
if (isMutating && !credentialRoute) {
|
|
3116
|
+
const idemKey = req.headers.get("Idempotency-Key");
|
|
3117
|
+
if (idemKey && idemKey.length >= 1 && idemKey.length <= 255) {
|
|
3118
|
+
const keyHash = await sha256Hex2(`${authPrincipal}
|
|
3119
|
+
${req.method.toUpperCase()}
|
|
3120
|
+
${url2.pathname}
|
|
3121
|
+
${idemKey}`);
|
|
3122
|
+
const bodyHash = bodyText.length > 0 ? await sha256Hex2(bodyText) : null;
|
|
3123
|
+
await recordIdempotency({
|
|
3124
|
+
db: env.DB,
|
|
3125
|
+
keyHash,
|
|
3126
|
+
authPrincipal,
|
|
3127
|
+
requestPath: url2.pathname,
|
|
3128
|
+
requestMethod: req.method,
|
|
3129
|
+
requestBodyHash: bodyHash,
|
|
3130
|
+
responseStatus: handlerResponse.status,
|
|
3131
|
+
responseBody: respBody
|
|
3132
|
+
});
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
if (shouldAuditMethod(req.method)) {
|
|
3136
|
+
await recordAudit({
|
|
3137
|
+
db: env.DB,
|
|
3138
|
+
authPrincipal,
|
|
3139
|
+
httpMethod: req.method,
|
|
3140
|
+
requestPath: url2.pathname,
|
|
3141
|
+
httpStatus: handlerResponse.status,
|
|
3142
|
+
requestBodySummary: bodyText ? redactBodyForAudit(bodyText) : null,
|
|
3143
|
+
sourceIp: req.headers.get("cf-connecting-ip"),
|
|
3144
|
+
userAgent: req.headers.get("user-agent"),
|
|
3145
|
+
durationMs: duration
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3148
|
+
return handlerResponse;
|
|
3149
|
+
}
|
|
3150
|
+
function isCredentialReturningRoute(pathname, method) {
|
|
3151
|
+
if (method !== "POST") return false;
|
|
3152
|
+
return pathname === "/consumers" || /^\/consumers\/[^/]+\/(?:rotate-secret|rotate-admin-key)$/.test(pathname);
|
|
3153
|
+
}
|
|
3154
|
+
async function readBoundedBody(req, maxBytes) {
|
|
3155
|
+
const declared = req.headers.get("content-length");
|
|
3156
|
+
if (declared && (!/^\d+$/.test(declared) || Number(declared) > maxBytes)) {
|
|
3157
|
+
return jsonResponse({ error: "payload_too_large" }, 413);
|
|
3158
|
+
}
|
|
3159
|
+
if (!req.body) return "";
|
|
3160
|
+
const reader = req.body.getReader();
|
|
3161
|
+
const chunks = [];
|
|
3162
|
+
let size = 0;
|
|
3163
|
+
while (true) {
|
|
3164
|
+
const { done, value } = await reader.read();
|
|
3165
|
+
if (done) break;
|
|
3166
|
+
size += value.byteLength;
|
|
3167
|
+
if (size > maxBytes) {
|
|
3168
|
+
await reader.cancel("payload_too_large");
|
|
3169
|
+
return jsonResponse({ error: "payload_too_large" }, 413);
|
|
3170
|
+
}
|
|
3171
|
+
chunks.push(value);
|
|
3172
|
+
}
|
|
3173
|
+
const all = new Uint8Array(size);
|
|
3174
|
+
let offset = 0;
|
|
3175
|
+
for (const chunk of chunks) {
|
|
3176
|
+
all.set(chunk, offset);
|
|
3177
|
+
offset += chunk.byteLength;
|
|
3178
|
+
}
|
|
3179
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(all);
|
|
3180
|
+
}
|
|
3181
|
+
async function requireOwnedAccount(env, id, authCtx) {
|
|
3182
|
+
const existing = await findBankAccountById(env.DB, id);
|
|
3183
|
+
if (!existing) return jsonResponse({ error: "not_found" }, 404);
|
|
3184
|
+
if (authCtx.type === "tenant" && existing.owner_app_id !== authCtx.app_id) return forbidden();
|
|
3185
|
+
return existing;
|
|
3186
|
+
}
|
|
3187
|
+
function parseOr400(schema, bodyText) {
|
|
3188
|
+
let body;
|
|
3189
|
+
try {
|
|
3190
|
+
body = JSON.parse(bodyText);
|
|
3191
|
+
} catch {
|
|
3192
|
+
return jsonResponse({ error: "invalid JSON" }, 400);
|
|
3193
|
+
}
|
|
3194
|
+
try {
|
|
3195
|
+
return parseBody(schema, body);
|
|
3196
|
+
} catch (err) {
|
|
3197
|
+
if (err instanceof ValidationError) return jsonResponse({ error: err.message, issues: err.issues }, 400);
|
|
3198
|
+
throw err;
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
async function dispatch(url2, req, env, authCtx, bodyText) {
|
|
3202
|
+
if (url2.pathname === "/bank-accounts" && req.method === "POST") {
|
|
3203
|
+
const input = parseOr400(CreateBankAccountSchema, bodyText);
|
|
3204
|
+
if (input instanceof Response) return input;
|
|
3205
|
+
if (authCtx.type === "tenant" && input.owner_app_id !== authCtx.app_id) {
|
|
3206
|
+
return jsonResponse({ error: "cannot_create_for_other_tenant" }, 403);
|
|
3207
|
+
}
|
|
3208
|
+
const owner = await findConsumerByAppId(env.DB, input.owner_app_id);
|
|
3209
|
+
if (!owner) return jsonResponse({ error: `unknown owner_app_id: ${input.owner_app_id}` }, 422);
|
|
3210
|
+
if (input.fio_api_token !== void 0 && (input.account_type ?? "FIO") !== "FIO") {
|
|
3211
|
+
return jsonResponse({ error: "fio_api_token_supported_only_for_fio" }, 400);
|
|
3212
|
+
}
|
|
3213
|
+
const pairingCode = await generateUniquePairingCode(env.DB);
|
|
3214
|
+
const ingestMode = input.ingest_mode ?? (input.fio_api_token ? "api" : "email");
|
|
3215
|
+
const createEmailRoute = ingestMode === "email" || ingestMode === "both";
|
|
3216
|
+
const encryptedApiToken = input.fio_api_token ? await encryptSecret(input.fio_api_token, env) : null;
|
|
3217
|
+
const outboxId = createEmailRoute ? await enqueueCreate(env.DB, { pairing_code: pairingCode, bank_account_id: 0 }) : null;
|
|
3218
|
+
let account;
|
|
3219
|
+
try {
|
|
3220
|
+
account = await createBankAccount(env.DB, {
|
|
3221
|
+
account_number: input.account_number,
|
|
3222
|
+
...input.account_type !== void 0 ? { account_type: input.account_type } : {},
|
|
3223
|
+
ingest_mode: ingestMode,
|
|
3224
|
+
pairing_code: pairingCode,
|
|
3225
|
+
...input.label !== void 0 ? { label: input.label } : {},
|
|
3226
|
+
owner_app_id: input.owner_app_id,
|
|
3227
|
+
cf_rule_id: null,
|
|
3228
|
+
api_token_cipher: encryptedApiToken?.cipher ?? null,
|
|
3229
|
+
api_token_key_ver: encryptedApiToken?.keyVersion ?? null,
|
|
3230
|
+
api_token_prefix: input.fio_api_token ? tokenPrefix(input.fio_api_token) : null,
|
|
3231
|
+
api_fetch_enabled: Boolean(input.fio_api_token && ingestMode !== "email")
|
|
3232
|
+
});
|
|
3233
|
+
} catch (err) {
|
|
3234
|
+
throw err;
|
|
3235
|
+
}
|
|
3236
|
+
if (outboxId !== null) {
|
|
3237
|
+
const cfRuleId = await provisionRoute(env.DB, cfRoutingConfig(env), {
|
|
3238
|
+
outboxId,
|
|
3239
|
+
pairing: pairingCode,
|
|
3240
|
+
bankAccountId: account.id
|
|
3241
|
+
});
|
|
3242
|
+
if (cfRuleId) account = { ...account, cf_rule_id: cfRuleId };
|
|
3243
|
+
}
|
|
3244
|
+
try {
|
|
3245
|
+
await createSubscription(env.DB, { app_id: input.owner_app_id, bank_account_id: account.id });
|
|
3246
|
+
} catch (err) {
|
|
3247
|
+
logError("owner_auto_subscribe_failed", err, { app_id: input.owner_app_id, bank_account_id: account.id });
|
|
3248
|
+
}
|
|
3249
|
+
return jsonResponse(account, 201);
|
|
3250
|
+
}
|
|
3251
|
+
if (url2.pathname === "/bank-accounts" && req.method === "GET") {
|
|
3252
|
+
const owner = authCtx.type === "tenant" ? authCtx.app_id : url2.searchParams.get("owner") ?? void 0;
|
|
3253
|
+
const accounts = await listBankAccounts(env.DB, owner);
|
|
3254
|
+
return jsonResponse(accounts);
|
|
3255
|
+
}
|
|
3256
|
+
const baMatch = url2.pathname.match(/^\/bank-accounts\/(\d+)$/);
|
|
3257
|
+
if (baMatch && req.method === "PUT") {
|
|
3258
|
+
const id = Number(baMatch[1]);
|
|
3259
|
+
const existing = await requireOwnedAccount(env, id, authCtx);
|
|
3260
|
+
if (existing instanceof Response) return existing;
|
|
3261
|
+
const input = parseOr400(UpdateBankAccountSchema, bodyText);
|
|
3262
|
+
if (input instanceof Response) return input;
|
|
3263
|
+
const updated = await updateBankAccount(env.DB, id, input);
|
|
3264
|
+
if (!updated) return jsonResponse({ error: "not_found" }, 404);
|
|
3265
|
+
return jsonResponse(updated);
|
|
3266
|
+
}
|
|
3267
|
+
const ownerMatch = url2.pathname.match(/^\/bank-accounts\/(\d+)\/owner$/);
|
|
3268
|
+
if (ownerMatch && req.method === "PUT") {
|
|
3269
|
+
if (authCtx.type !== "admin") return forbidden();
|
|
3270
|
+
const id = Number(ownerMatch[1]);
|
|
3271
|
+
const existing = await findBankAccountById(env.DB, id);
|
|
3272
|
+
if (!existing) return jsonResponse({ error: "not_found" }, 404);
|
|
3273
|
+
const input = parseOr400(UpdateBankAccountOwnerSchema, bodyText);
|
|
3274
|
+
if (input instanceof Response) return input;
|
|
3275
|
+
const owner = await findConsumerByAppId(env.DB, input.owner_app_id);
|
|
3276
|
+
if (!owner) return jsonResponse({ error: `unknown owner_app_id: ${input.owner_app_id}` }, 422);
|
|
3277
|
+
const updated = await updateBankAccountOwner(env.DB, id, input.owner_app_id);
|
|
3278
|
+
if (!updated) return jsonResponse({ error: "not_found" }, 404);
|
|
3279
|
+
return jsonResponse(updated);
|
|
3280
|
+
}
|
|
3281
|
+
if (baMatch && req.method === "DELETE") {
|
|
3282
|
+
const id = Number(baMatch[1]);
|
|
3283
|
+
const existing = await requireOwnedAccount(env, id, authCtx);
|
|
3284
|
+
if (existing instanceof Response) return existing;
|
|
3285
|
+
if (existing.cf_rule_id) {
|
|
3286
|
+
await deprovisionRoute(env.DB, cfRoutingConfig(env), existing.cf_rule_id);
|
|
3287
|
+
}
|
|
3288
|
+
const deleted = await deleteBankAccount(env.DB, id);
|
|
3289
|
+
if (!deleted) return jsonResponse({ error: "not_found" }, 404);
|
|
3290
|
+
return new Response(null, { status: 204 });
|
|
3291
|
+
}
|
|
3292
|
+
const fioTokenMatch = url2.pathname.match(/^\/bank-accounts\/(\d+)\/fio-token$/);
|
|
3293
|
+
if (fioTokenMatch && req.method === "PUT") {
|
|
3294
|
+
const id = Number(fioTokenMatch[1]);
|
|
3295
|
+
const existing = await requireOwnedAccount(env, id, authCtx);
|
|
3296
|
+
if (existing instanceof Response) return existing;
|
|
3297
|
+
if (existing.account_type !== "FIO") return jsonResponse({ error: "fio_api_token_supported_only_for_fio" }, 400);
|
|
3298
|
+
const input = parseOr400(UpdateFioTokenSchema, bodyText);
|
|
3299
|
+
if (input instanceof Response) return input;
|
|
3300
|
+
const rawToken = input.fio_api_token?.trim();
|
|
3301
|
+
if (rawToken !== void 0 && rawToken.length > 0 && rawToken.length < 8) {
|
|
3302
|
+
return jsonResponse({ error: "validation_failed: fio_api_token must be at least 8 characters" }, 400);
|
|
3303
|
+
}
|
|
3304
|
+
if (rawToken && rawToken.length > 0) {
|
|
3305
|
+
const encrypted = await encryptSecret(rawToken, env);
|
|
3306
|
+
const updated2 = await setBankAccountApiToken(env.DB, id, {
|
|
3307
|
+
token_cipher: encrypted.cipher,
|
|
3308
|
+
token_key_ver: encrypted.keyVersion,
|
|
3309
|
+
token_prefix: tokenPrefix(rawToken),
|
|
3310
|
+
fetch_enabled: input.fetch_enabled ?? true,
|
|
3311
|
+
ingest_mode: input.ingest_mode ?? (existing.ingest_mode === "email" ? "api" : existing.ingest_mode)
|
|
3312
|
+
});
|
|
3313
|
+
if (!updated2) return jsonResponse({ error: "not_found" }, 404);
|
|
3314
|
+
log("bank_account_api_token_updated", { bank_account_id: id, account_type: existing.account_type, api_token_prefix: updated2.api_token_prefix });
|
|
3315
|
+
return jsonResponse(updated2);
|
|
3316
|
+
}
|
|
3317
|
+
if ((input.fetch_enabled ?? false) && !existing.api_token_set) {
|
|
3318
|
+
return jsonResponse({ error: "api_token_not_configured" }, 422);
|
|
3319
|
+
}
|
|
3320
|
+
const fetchPatch = {
|
|
3321
|
+
fetch_enabled: input.fetch_enabled ?? existing.api_fetch_enabled
|
|
3322
|
+
};
|
|
3323
|
+
if (input.ingest_mode !== void 0) fetchPatch.ingest_mode = input.ingest_mode;
|
|
3324
|
+
const updated = await setBankAccountApiFetchEnabled(env.DB, id, fetchPatch);
|
|
3325
|
+
if (!updated) return jsonResponse({ error: "not_found" }, 404);
|
|
3326
|
+
return jsonResponse(updated);
|
|
3327
|
+
}
|
|
3328
|
+
if (fioTokenMatch && req.method === "DELETE") {
|
|
3329
|
+
const id = Number(fioTokenMatch[1]);
|
|
3330
|
+
const existing = await requireOwnedAccount(env, id, authCtx);
|
|
3331
|
+
if (existing instanceof Response) return existing;
|
|
3332
|
+
if (existing.account_type !== "FIO") return jsonResponse({ error: "fio_api_token_supported_only_for_fio" }, 400);
|
|
3333
|
+
const updated = await clearBankAccountApiToken(env.DB, id);
|
|
3334
|
+
if (!updated) return jsonResponse({ error: "not_found" }, 404);
|
|
3335
|
+
log("bank_account_api_token_deleted", { bank_account_id: id, account_type: existing.account_type });
|
|
3336
|
+
return jsonResponse(updated);
|
|
3337
|
+
}
|
|
3338
|
+
const fioSyncMatch = url2.pathname.match(/^\/bank-accounts\/(\d+)\/fio-sync$/);
|
|
3339
|
+
if (fioSyncMatch && req.method === "POST") {
|
|
3340
|
+
const id = Number(fioSyncMatch[1]);
|
|
3341
|
+
const existing = await requireOwnedAccount(env, id, authCtx);
|
|
3342
|
+
if (existing instanceof Response) return existing;
|
|
3343
|
+
if (existing.account_type !== "FIO") return jsonResponse({ error: "fio_api_token_supported_only_for_fio" }, 400);
|
|
3344
|
+
const minIntervalS = apiMinIntervalS(env);
|
|
3345
|
+
if (!await isBankAccountApiFetchDue(env.DB, id, minIntervalS)) {
|
|
3346
|
+
return jsonResponse({
|
|
3347
|
+
error: "api_fetch_throttled",
|
|
3348
|
+
retry_after_s: minIntervalS,
|
|
3349
|
+
api_last_fetch_at: existing.api_last_fetch_at,
|
|
3350
|
+
api_last_success_at: existing.api_last_success_at,
|
|
3351
|
+
api_last_error: existing.api_last_error
|
|
3352
|
+
}, 429, { "Retry-After": String(minIntervalS) });
|
|
3353
|
+
}
|
|
3354
|
+
const account = await findBankAccountApiMaterials(env.DB, id);
|
|
3355
|
+
if (!account) return jsonResponse({ error: "api_token_not_configured" }, 422);
|
|
3356
|
+
try {
|
|
3357
|
+
const result = await runBankApiSync(env, account);
|
|
3358
|
+
const updated = await findBankAccountById(env.DB, id);
|
|
3359
|
+
return jsonResponse({
|
|
3360
|
+
inserted: result.inserted,
|
|
3361
|
+
skipped_duplicate: result.skipped_duplicate,
|
|
3362
|
+
skipped_outgoing: result.skipped_outgoing,
|
|
3363
|
+
parse_errors: result.parse_errors,
|
|
3364
|
+
queued_webhooks: result.queued_webhooks,
|
|
3365
|
+
backfill: result.backfill,
|
|
3366
|
+
deferred: result.deferred,
|
|
3367
|
+
api_last_fetch_at: updated?.api_last_fetch_at ?? null,
|
|
3368
|
+
api_last_success_at: updated?.api_last_success_at ?? null,
|
|
3369
|
+
api_last_error: updated?.api_last_error ?? null,
|
|
3370
|
+
api_backfill_done: updated?.api_backfill_done ?? false
|
|
3371
|
+
});
|
|
3372
|
+
} catch (err) {
|
|
3373
|
+
if (err instanceof FioRateLimited || err instanceof FioTransientFailure) {
|
|
3374
|
+
logError("fio_api_manual_sync_failed", err, { bank_account_id: id });
|
|
3375
|
+
return jsonResponse({ error: "fio_api_transient_failure" }, 503);
|
|
3376
|
+
}
|
|
3377
|
+
throw err;
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
const regenMatch = url2.pathname.match(/^\/bank-accounts\/(\d+)\/regenerate-pairing$/);
|
|
3381
|
+
if (regenMatch && req.method === "POST") {
|
|
3382
|
+
const id = Number(regenMatch[1]);
|
|
3383
|
+
const existing = await requireOwnedAccount(env, id, authCtx);
|
|
3384
|
+
if (existing instanceof Response) return existing;
|
|
3385
|
+
const newPairing = await generateUniquePairingCode(env.DB);
|
|
3386
|
+
const result = await regenerateRoute(env.DB, cfRoutingConfig(env), {
|
|
3387
|
+
id,
|
|
3388
|
+
existingCfRuleId: existing.cf_rule_id,
|
|
3389
|
+
newPairing
|
|
3390
|
+
});
|
|
3391
|
+
if (!result.ok) return jsonResponse(result.body, result.status);
|
|
3392
|
+
return jsonResponse(result.account);
|
|
3393
|
+
}
|
|
3394
|
+
if (url2.pathname === "/consumers" && req.method === "POST") {
|
|
3395
|
+
if (authCtx.type === "tenant") return forbidden();
|
|
3396
|
+
const input = parseOr400(CreateConsumerSchema, bodyText);
|
|
3397
|
+
if (input instanceof Response) return input;
|
|
3398
|
+
let callbackUrl;
|
|
3399
|
+
try {
|
|
3400
|
+
callbackUrl = validateCallbackUrl(input.callback_url, { environment: env.ENV, allowlist: env.CALLBACK_HOST_ALLOWLIST });
|
|
3401
|
+
} catch (err) {
|
|
3402
|
+
if (err instanceof ValidationError) return jsonResponse({ error: err.message }, 400);
|
|
3403
|
+
throw err;
|
|
3404
|
+
}
|
|
3405
|
+
const plain = generateConsumerSecret();
|
|
3406
|
+
const secretPrefix = plain.slice(0, 12);
|
|
3407
|
+
const secretHash = await sha256Hex2(plain);
|
|
3408
|
+
const secretCipher = await webhookEncrypt(plain, env.WEBHOOK_KEK);
|
|
3409
|
+
const consumer = await createConsumer(env.DB, {
|
|
3410
|
+
app_id: input.app_id,
|
|
3411
|
+
callback_url: callbackUrl,
|
|
3412
|
+
secret_cipher: secretCipher,
|
|
3413
|
+
secret_hash: secretHash,
|
|
3414
|
+
secret_prefix: secretPrefix
|
|
3415
|
+
});
|
|
3416
|
+
const { plain: adminKeyPlain, prefix: adminKeyPrefix, hash: adminKeyHash } = await generateTenantAdminKey();
|
|
3417
|
+
await setConsumerAdminKey(env.DB, input.app_id, adminKeyHash, adminKeyPrefix);
|
|
3418
|
+
log("tenant_admin_key_issued", { app_id: input.app_id, admin_key_prefix: adminKeyPrefix });
|
|
3419
|
+
log("consumer_secret_issued", { app_id: consumer.app_id, secret_prefix: secretPrefix });
|
|
3420
|
+
return jsonResponse({ app_id: consumer.app_id, callback_url: consumer.callback_url, secret: plain, admin_key: adminKeyPlain, admin_key_prefix: adminKeyPrefix }, 201, { "Cache-Control": "no-store" });
|
|
3421
|
+
}
|
|
3422
|
+
if (url2.pathname === "/consumers" && req.method === "GET") {
|
|
3423
|
+
if (authCtx.type === "tenant") return forbidden();
|
|
3424
|
+
const consumers = await listConsumers(env.DB);
|
|
3425
|
+
return jsonResponse(consumers);
|
|
3426
|
+
}
|
|
3427
|
+
const deleteConsumerMatch = url2.pathname.match(/^\/consumers\/([^/]+)$/);
|
|
3428
|
+
if (deleteConsumerMatch && req.method === "PUT") {
|
|
3429
|
+
const appId = decodeURIComponent(deleteConsumerMatch[1]);
|
|
3430
|
+
if (authCtx.type === "tenant" && authCtx.app_id !== appId) return forbidden();
|
|
3431
|
+
const input = parseOr400(UpdateConsumerSchema, bodyText);
|
|
3432
|
+
if (input instanceof Response) return input;
|
|
3433
|
+
let callbackUrl;
|
|
3434
|
+
try {
|
|
3435
|
+
callbackUrl = validateCallbackUrl(input.callback_url, { environment: env.ENV, allowlist: env.CALLBACK_HOST_ALLOWLIST });
|
|
3436
|
+
} catch (err) {
|
|
3437
|
+
if (err instanceof ValidationError) return jsonResponse({ error: err.message }, 400);
|
|
3438
|
+
throw err;
|
|
3439
|
+
}
|
|
3440
|
+
const updated = await updateConsumer(env.DB, appId, { callback_url: callbackUrl });
|
|
3441
|
+
if (!updated) return jsonResponse({ error: "not_found" }, 404);
|
|
3442
|
+
log("consumer_callback_updated", { app_id: appId });
|
|
3443
|
+
return jsonResponse(updated);
|
|
3444
|
+
}
|
|
3445
|
+
if (deleteConsumerMatch && req.method === "DELETE") {
|
|
3446
|
+
if (authCtx.type === "tenant") return forbidden();
|
|
3447
|
+
const appId = decodeURIComponent(deleteConsumerMatch[1]);
|
|
3448
|
+
await deleteConsumer(env.DB, appId);
|
|
3449
|
+
return new Response(null, { status: 204 });
|
|
3450
|
+
}
|
|
3451
|
+
const rotateMatch = url2.pathname.match(/^\/consumers\/([^/]+)\/rotate-secret$/);
|
|
3452
|
+
if (rotateMatch && req.method === "POST") {
|
|
3453
|
+
if (authCtx.type === "tenant") return forbidden();
|
|
3454
|
+
const appId = decodeURIComponent(rotateMatch[1]);
|
|
3455
|
+
const existing = await findConsumerByAppId(env.DB, appId);
|
|
3456
|
+
if (!existing) return jsonResponse({ error: "consumer not found" }, 404);
|
|
3457
|
+
const newPlain = generateConsumerSecret();
|
|
3458
|
+
const newPrefix = newPlain.slice(0, 12);
|
|
3459
|
+
const newHash = await sha256Hex2(newPlain);
|
|
3460
|
+
const newCipher = await webhookEncrypt(newPlain, env.WEBHOOK_KEK);
|
|
3461
|
+
const prevExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1e3).toISOString();
|
|
3462
|
+
await rotateConsumerSecret(env.DB, appId, {
|
|
3463
|
+
newCipher,
|
|
3464
|
+
newHash,
|
|
3465
|
+
newPrefix,
|
|
3466
|
+
prevExpiresAt
|
|
3467
|
+
});
|
|
3468
|
+
log("consumer_secret_rotated", { app_id: appId, secret_prefix: newPrefix });
|
|
3469
|
+
return jsonResponse({ secret: newPlain }, 200, { "Cache-Control": "no-store" });
|
|
3470
|
+
}
|
|
3471
|
+
const rotateAdminMatch = url2.pathname.match(/^\/consumers\/([a-z0-9][a-z0-9-_]{1,63})\/rotate-admin-key$/);
|
|
3472
|
+
if (rotateAdminMatch && req.method === "POST") {
|
|
3473
|
+
const appId = rotateAdminMatch[1];
|
|
3474
|
+
if (authCtx.type === "tenant" && authCtx.app_id !== appId) return forbidden();
|
|
3475
|
+
const consumer = await findConsumerByAppId(env.DB, appId);
|
|
3476
|
+
if (!consumer) return jsonResponse({ error: "not_found" }, 404);
|
|
3477
|
+
const { plain, prefix, hash } = await generateTenantAdminKey();
|
|
3478
|
+
await setConsumerAdminKey(env.DB, appId, hash, prefix);
|
|
3479
|
+
log("tenant_admin_key_rotated", { app_id: appId, admin_key_prefix: prefix });
|
|
3480
|
+
return jsonResponse({ admin_key: plain, admin_key_prefix: prefix }, 200, { "Cache-Control": "no-store" });
|
|
3481
|
+
}
|
|
3482
|
+
if (url2.pathname === "/subscriptions" && req.method === "POST") {
|
|
3483
|
+
const input = parseOr400(CreateSubscriptionSchema, bodyText);
|
|
3484
|
+
if (input instanceof Response) return input;
|
|
3485
|
+
if (authCtx.type === "tenant") {
|
|
3486
|
+
if (input.app_id !== authCtx.app_id) return forbidden();
|
|
3487
|
+
const account = await findBankAccountById(env.DB, input.bank_account_id);
|
|
3488
|
+
if (!account || account.owner_app_id !== authCtx.app_id) return forbidden();
|
|
3489
|
+
}
|
|
3490
|
+
let sub;
|
|
3491
|
+
try {
|
|
3492
|
+
sub = await createSubscription(env.DB, { app_id: input.app_id, bank_account_id: input.bank_account_id });
|
|
3493
|
+
} catch (err) {
|
|
3494
|
+
const msg = String(err);
|
|
3495
|
+
if (msg.includes("subscription_cap_reached")) return jsonResponse({ error: "subscription_cap_reached" }, 422);
|
|
3496
|
+
throw err;
|
|
3497
|
+
}
|
|
3498
|
+
return jsonResponse(sub, 201);
|
|
3499
|
+
}
|
|
3500
|
+
if (url2.pathname === "/subscriptions" && req.method === "GET") {
|
|
3501
|
+
const appId = authCtx.type === "tenant" ? authCtx.app_id : url2.searchParams.get("app_id") ?? void 0;
|
|
3502
|
+
const bankAccountIdStr = url2.searchParams.get("bank_account_id");
|
|
3503
|
+
const bankAccountId = bankAccountIdStr ? parseInt(bankAccountIdStr, 10) : void 0;
|
|
3504
|
+
const filters = {};
|
|
3505
|
+
if (appId !== void 0) filters.app_id = appId;
|
|
3506
|
+
if (bankAccountId !== void 0) filters.bank_account_id = bankAccountId;
|
|
3507
|
+
const subs = await listSubscriptions(env.DB, filters);
|
|
3508
|
+
return jsonResponse(subs);
|
|
3509
|
+
}
|
|
3510
|
+
const deleteSubMatch = url2.pathname.match(/^\/subscriptions\/(\d+)$/);
|
|
3511
|
+
if (deleteSubMatch && req.method === "DELETE") {
|
|
3512
|
+
const id = parseInt(deleteSubMatch[1], 10);
|
|
3513
|
+
if (authCtx.type === "tenant") {
|
|
3514
|
+
const sub = await env.DB.prepare(`
|
|
3515
|
+
SELECT s.consumer_app_id, b.owner_app_id
|
|
3516
|
+
FROM webhook_subscriptions s
|
|
3517
|
+
JOIN bank_accounts b ON b.id = s.bank_account_id
|
|
3518
|
+
WHERE s.id = ? AND s.deleted_at IS NULL
|
|
3519
|
+
`).bind(id).first();
|
|
3520
|
+
if (!sub || sub.consumer_app_id !== authCtx.app_id || sub.owner_app_id !== authCtx.app_id) return forbidden();
|
|
3521
|
+
}
|
|
3522
|
+
await deleteSubscription(env.DB, id);
|
|
3523
|
+
return new Response(null, { status: 204 });
|
|
3524
|
+
}
|
|
3525
|
+
if (url2.pathname === "/webhooks/replay" && req.method === "POST") {
|
|
3526
|
+
const input = parseOr400(ReplayWebhookSchema, bodyText);
|
|
3527
|
+
if (input instanceof Response) return input;
|
|
3528
|
+
if (input.delivery_id) {
|
|
3529
|
+
const job = await findDeliveryJob(env.DB, input.delivery_id);
|
|
3530
|
+
if (!job) return jsonResponse({ error: "delivery_id not found" }, 404);
|
|
3531
|
+
if (authCtx.type === "tenant" && job.consumer_app_id !== authCtx.app_id) return forbidden();
|
|
3532
|
+
const replay = await createWebhookDeliveryCoordinator(env).replay(job.id, { force: input.force ?? false });
|
|
3533
|
+
return jsonResponse({ queued: replay.queued, noop: replay.noop ?? null, previous_status: replay.previous_status ?? null, delivery_id: input.delivery_id, job_id: job.id });
|
|
3534
|
+
}
|
|
3535
|
+
if (input.tx_id) {
|
|
3536
|
+
const txRow = await env.DB.prepare(`SELECT t.id, b.owner_app_id FROM transactions t JOIN bank_accounts b ON b.id = t.bank_account_id WHERE t.id = ?`).bind(input.tx_id).first();
|
|
3537
|
+
if (!txRow) return jsonResponse({ error: "transaction not found" }, 404);
|
|
3538
|
+
if (authCtx.type === "tenant" && txRow.owner_app_id !== authCtx.app_id) return forbidden();
|
|
3539
|
+
const coordinator = createWebhookDeliveryCoordinator(env);
|
|
3540
|
+
await ensureDeliveryJobs(env.DB, txRow.id);
|
|
3541
|
+
const jobs = (await findDeliveryJobsForTransaction(env.DB, txRow.id)).filter((job) => authCtx.type !== "tenant" || job.consumer_app_id === authCtx.app_id);
|
|
3542
|
+
const replayed = await Promise.all(jobs.map((job) => coordinator.replay(job.id, { force: input.force ?? false })));
|
|
3543
|
+
return jsonResponse({ queued: replayed.some((result) => result.queued), delivery_ids: replayed.flatMap((result) => result.delivery_id ? [result.delivery_id] : []), tx_id: input.tx_id });
|
|
3544
|
+
}
|
|
3545
|
+
return jsonResponse({ error: "unreachable" }, 500);
|
|
3546
|
+
}
|
|
3547
|
+
if (url2.pathname === "/webhooks/deliveries" && req.method === "GET") {
|
|
3548
|
+
const limitStr = url2.searchParams.get("limit");
|
|
3549
|
+
const limit = limitStr ? Math.min(parseInt(limitStr, 10), 100) : 50;
|
|
3550
|
+
const cursorStr = url2.searchParams.get("cursor");
|
|
3551
|
+
const cursor = cursorStr ? parseInt(cursorStr, 10) : void 0;
|
|
3552
|
+
const entries = await DeliveryQueries.list(env.DB, {
|
|
3553
|
+
limit,
|
|
3554
|
+
...cursor !== void 0 && !Number.isNaN(cursor) ? { cursor } : {},
|
|
3555
|
+
...authCtx.type === "tenant" ? { consumerAppId: authCtx.app_id } : {}
|
|
3556
|
+
});
|
|
3557
|
+
const next_cursor = entries.length === limit ? entries[entries.length - 1].id : null;
|
|
3558
|
+
return jsonResponse({ jobs: entries, next_cursor });
|
|
3559
|
+
}
|
|
3560
|
+
if (url2.pathname === "/transactions" && req.method === "GET") {
|
|
3561
|
+
const since = url2.searchParams.get("since") ?? (/* @__PURE__ */ new Date(0)).toISOString();
|
|
3562
|
+
const limitStr = url2.searchParams.get("limit");
|
|
3563
|
+
const limit = limitStr ? Math.min(parseInt(limitStr, 10), 200) : 50;
|
|
3564
|
+
return jsonResponse(await listTransactions(env.DB, since, limit, authCtx.type === "tenant" ? authCtx.app_id : void 0));
|
|
3565
|
+
}
|
|
3566
|
+
if (url2.pathname === "/parse-log" && req.method === "GET") {
|
|
3567
|
+
const since = url2.searchParams.get("since") ?? (/* @__PURE__ */ new Date(0)).toISOString();
|
|
3568
|
+
const limitStr = url2.searchParams.get("limit");
|
|
3569
|
+
const limit = limitStr ? Math.min(parseInt(limitStr, 10), 200) : 50;
|
|
3570
|
+
return jsonResponse(await listParseLog(env.DB, since, limit, authCtx.type === "tenant" ? authCtx.app_id : void 0));
|
|
3571
|
+
}
|
|
3572
|
+
if (url2.pathname === "/webhook-log" && req.method === "GET") {
|
|
3573
|
+
const since = url2.searchParams.get("since") ?? (/* @__PURE__ */ new Date(0)).toISOString();
|
|
3574
|
+
const limitStr = url2.searchParams.get("limit");
|
|
3575
|
+
const limit = limitStr ? Math.min(parseInt(limitStr, 10), 200) : 50;
|
|
3576
|
+
return jsonResponse(await listWebhookLog(env.DB, since, limit, authCtx.type === "tenant" ? authCtx.app_id : void 0));
|
|
3577
|
+
}
|
|
3578
|
+
if (url2.pathname === "/unmatched-mails" && req.method === "GET") {
|
|
3579
|
+
const since = url2.searchParams.get("since") ?? new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3).toISOString();
|
|
3580
|
+
const limitStr = url2.searchParams.get("limit");
|
|
3581
|
+
const limit = limitStr ? Math.min(parseInt(limitStr, 10), 200) : 50;
|
|
3582
|
+
const rows = await listUnmatchedMails(env.DB, since, limit);
|
|
3583
|
+
const filtered = authCtx.type === "tenant" ? [] : rows;
|
|
3584
|
+
return jsonResponse(filtered);
|
|
3585
|
+
}
|
|
3586
|
+
if (url2.pathname === "/admin/audit-cf-rules" && req.method === "GET") {
|
|
3587
|
+
if (authCtx.type !== "admin") return forbidden();
|
|
3588
|
+
const result = await auditCfRoutingDrift({ db: env.DB, cf: cfRoutingConfig(env) });
|
|
3589
|
+
return jsonResponse(result);
|
|
3590
|
+
}
|
|
3591
|
+
if (url2.pathname === "/admin/audit-log" && req.method === "GET") {
|
|
3592
|
+
if (authCtx.type !== "admin") return forbidden();
|
|
3593
|
+
const sinceParam = url2.searchParams.get("since");
|
|
3594
|
+
const principalParam = url2.searchParams.get("principal");
|
|
3595
|
+
const limitRaw = url2.searchParams.get("limit");
|
|
3596
|
+
const limit = limitRaw ? Math.min(parseInt(limitRaw, 10), 500) : 50;
|
|
3597
|
+
const auditArgs = { limit };
|
|
3598
|
+
if (sinceParam !== null) auditArgs.since = sinceParam;
|
|
3599
|
+
if (principalParam !== null) auditArgs.authPrincipal = principalParam;
|
|
3600
|
+
const rows = await listAuditLog(env.DB, auditArgs);
|
|
3601
|
+
return jsonResponse(rows);
|
|
3602
|
+
}
|
|
3603
|
+
if (url2.pathname === "/admin/process-outbox" && req.method === "POST") {
|
|
3604
|
+
if (authCtx.type !== "admin") return forbidden();
|
|
3605
|
+
const result = await processOutbox(env.DB, cfRoutingConfig(env));
|
|
3606
|
+
return jsonResponse(result);
|
|
3607
|
+
}
|
|
3608
|
+
{
|
|
3609
|
+
const orphanMatch = url2.pathname.match(/^\/admin\/cf-rules\/([A-Za-z0-9]+)$/);
|
|
3610
|
+
if (orphanMatch && req.method === "DELETE") {
|
|
3611
|
+
if (authCtx.type !== "admin") return forbidden();
|
|
3612
|
+
const ruleId = orphanMatch[1];
|
|
3613
|
+
await deleteRule(cfRoutingConfig(env), ruleId);
|
|
3614
|
+
return jsonResponse({ deleted: true, rule_id: ruleId });
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
if (url2.pathname === "/admin/outbox/clear-stuck" && req.method === "POST") {
|
|
3618
|
+
if (authCtx.type !== "admin") return forbidden();
|
|
3619
|
+
const cleared = await clearStuckOutbox(env.DB);
|
|
3620
|
+
return jsonResponse({ cleared });
|
|
3621
|
+
}
|
|
3622
|
+
return notFound();
|
|
3623
|
+
}
|
|
3624
|
+
var cloudflare_default = {
|
|
3625
|
+
async email(message, env, _ctx) {
|
|
3626
|
+
await assertSchemaVersion(env.DB);
|
|
3627
|
+
if (message.rawSize > 5 * 1024 * 1024) {
|
|
3628
|
+
await insertParseLog(env.DB, {
|
|
3629
|
+
error_message: `email_too_large: ${message.rawSize}`,
|
|
3630
|
+
raw_data: "(truncated, oversized email \u2014 first 4KB unavailable, body never read)"
|
|
3631
|
+
});
|
|
3632
|
+
log("email_rejected_oversized", { size: message.rawSize });
|
|
3633
|
+
return;
|
|
3634
|
+
}
|
|
3635
|
+
const trustedAuthservId = env.EMAIL_AUTHSERV_ID?.trim();
|
|
3636
|
+
if (!trustedAuthservId) {
|
|
3637
|
+
const inspection = inspectAuthenticationResults(message.headers.get("authentication-results"));
|
|
3638
|
+
await insertParseLog(env.DB, { error_message: "email_ingest_disabled_untrusted_authserv", raw_data: null });
|
|
3639
|
+
log("email_rejected_authentication", {
|
|
3640
|
+
reason: "trusted_authserv_not_configured",
|
|
3641
|
+
authentication_results_present: message.headers.has("authentication-results"),
|
|
3642
|
+
observed_authserv_id: inspection.observedAuthservId,
|
|
3643
|
+
authentication_results_ambiguous: inspection.ambiguous
|
|
3644
|
+
});
|
|
3645
|
+
return;
|
|
3646
|
+
}
|
|
3647
|
+
await processEmail(message.raw, env, {
|
|
3648
|
+
mailFrom: message.from,
|
|
3649
|
+
rcptTo: message.to,
|
|
3650
|
+
authenticationResults: message.headers.get("authentication-results"),
|
|
3651
|
+
trustedAuthservId
|
|
3652
|
+
});
|
|
3653
|
+
},
|
|
3654
|
+
async fetch(req, env, _ctx) {
|
|
3655
|
+
try {
|
|
3656
|
+
return await adminFetch(req, env);
|
|
3657
|
+
} catch (err) {
|
|
3658
|
+
const requestId = crypto.randomUUID();
|
|
3659
|
+
logError("admin_fetch_error", err, { request_id: requestId });
|
|
3660
|
+
return jsonResponse({ error: "internal_error", request_id: requestId }, 500);
|
|
3661
|
+
}
|
|
3662
|
+
},
|
|
3663
|
+
async queue(batch, env, _ctx) {
|
|
3664
|
+
await assertSchemaVersion(env.DB);
|
|
3665
|
+
if (batch.queue === "banksync-api-sync") {
|
|
3666
|
+
await handleApiSyncQueue(batch, env);
|
|
3667
|
+
return;
|
|
3668
|
+
}
|
|
3669
|
+
await handleQueueBatch(batch, env);
|
|
3670
|
+
},
|
|
3671
|
+
async scheduled(event, env, _ctx) {
|
|
3672
|
+
await assertSchemaVersion(env.DB);
|
|
3673
|
+
const runPolling = shouldRunApiPolling(event);
|
|
3674
|
+
const runMaintenance = shouldRunMaintenance(event);
|
|
3675
|
+
if (runMaintenance) {
|
|
3676
|
+
try {
|
|
3677
|
+
const result = await pruneRetention(env.DB);
|
|
3678
|
+
log("retention_pruned", {
|
|
3679
|
+
parse_log_deleted: result.parse_log_deleted,
|
|
3680
|
+
webhook_log_deleted: result.webhook_log_deleted,
|
|
3681
|
+
event_log_deleted: result.event_log_deleted,
|
|
3682
|
+
transactions_deleted: result.transactions_deleted,
|
|
3683
|
+
idempotency_keys_deleted: result.idempotency_keys_deleted,
|
|
3684
|
+
admin_audit_log_deleted: result.admin_audit_log_deleted,
|
|
3685
|
+
expired_prev_secrets_cleared: result.expired_prev_secrets_cleared
|
|
3686
|
+
});
|
|
3687
|
+
} catch (err) {
|
|
3688
|
+
logError("retention_failed", err, {});
|
|
3689
|
+
throw err;
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3692
|
+
if (runPolling) {
|
|
3693
|
+
try {
|
|
3694
|
+
const outboxResult = await processOutbox(env.DB, cfRoutingConfig(env));
|
|
3695
|
+
log("outbox_processed", outboxResult);
|
|
3696
|
+
} catch (err) {
|
|
3697
|
+
logError("outbox_process_failed", err, {});
|
|
3698
|
+
}
|
|
3699
|
+
try {
|
|
3700
|
+
const swept = await createWebhookDeliveryCoordinator(env).sweep();
|
|
3701
|
+
if (swept.created > 0 || swept.considered > 0) {
|
|
3702
|
+
log("webhook_delivery_sweep", { created: swept.created, considered: swept.considered, queued: swept.queued, failed: swept.failed });
|
|
3703
|
+
}
|
|
3704
|
+
} catch (err) {
|
|
3705
|
+
logError("webhook_delivery_sweep_failed", err, {});
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
if (runMaintenance) {
|
|
3709
|
+
try {
|
|
3710
|
+
await pruneOldBuckets(env.DB);
|
|
3711
|
+
} catch (err) {
|
|
3712
|
+
logError("rate_limit_prune_failed", err, {});
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
if (runPolling) {
|
|
3716
|
+
try {
|
|
3717
|
+
if (await enqueueApiSyncTick(env, 0, "cron")) {
|
|
3718
|
+
log("api_sync_tick_enqueued", { delay_seconds: 0, source: "cron" });
|
|
3719
|
+
} else {
|
|
3720
|
+
await runDueBankApiSyncs(env);
|
|
3721
|
+
}
|
|
3722
|
+
} catch (err) {
|
|
3723
|
+
logError("api_sync_tick_failed", err, {});
|
|
3724
|
+
}
|
|
3725
|
+
}
|
|
3726
|
+
if (runPolling && env.ALERT_WEBHOOK_URL) {
|
|
3727
|
+
try {
|
|
3728
|
+
const alert = await runAlerterTick(env.DB, {
|
|
3729
|
+
webhookUrl: env.ALERT_WEBHOOK_URL,
|
|
3730
|
+
webhookSecret: env.ALERT_WEBHOOK_SECRET,
|
|
3731
|
+
service: env.BANKSYNC_DOMAIN ?? "banksync",
|
|
3732
|
+
thresholds: DEFAULT_THRESHOLDS
|
|
3733
|
+
});
|
|
3734
|
+
if (alert.fired) log("alert_fired", { posted: alert.posted, severity: alert.payload.severity });
|
|
3735
|
+
} catch (err) {
|
|
3736
|
+
logError("alerter_tick_failed", err, {});
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
if (runPolling && env.ALERT_WEBHOOK_URL) {
|
|
3740
|
+
try {
|
|
3741
|
+
const service = env.BANKSYNC_DOMAIN ?? "banksync";
|
|
3742
|
+
await detectStalledIncidents(env.DB, service);
|
|
3743
|
+
await drainDeliveryAlerts(env.DB, {
|
|
3744
|
+
webhookUrl: env.ALERT_WEBHOOK_URL,
|
|
3745
|
+
webhookSecret: env.ALERT_WEBHOOK_SECRET,
|
|
3746
|
+
service
|
|
3747
|
+
});
|
|
3748
|
+
} catch (err) {
|
|
3749
|
+
logError("delivery_alert_drain_failed", err, {});
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
const dayOfWeek = (/* @__PURE__ */ new Date()).getUTCDay();
|
|
3753
|
+
if (runMaintenance && dayOfWeek === 0 && env.BACKUPS) {
|
|
3754
|
+
try {
|
|
3755
|
+
const version = Number(env.BACKUP_ENCRYPTION_KEY_VERSION ?? "");
|
|
3756
|
+
const encryptionKey = Number.isInteger(version) ? env[`BACKUP_ENCRYPTION_KEY_V${version}`] : void 0;
|
|
3757
|
+
const backup = await runBackupTick(env.DB, { bucket: env.BACKUPS, prefix: "banksync", retain: 8, encryptionKey, keyVersion: version });
|
|
3758
|
+
log("backup_tick", { uploaded: backup.uploaded, key: backup.key ?? null, size_bytes: backup.size_bytes ?? null });
|
|
3759
|
+
} catch (err) {
|
|
3760
|
+
logError("backup_tick_failed", err, {});
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
};
|
|
3765
|
+
export {
|
|
3766
|
+
cloudflare_default as default,
|
|
3767
|
+
processEmail
|
|
3768
|
+
};
|