@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.
@@ -0,0 +1,220 @@
1
+ -- BankSync canonical schema baseline (schema version 10).
2
+ -- Production already recorded the original 0001-0009 filenames. This file is
3
+ -- for fresh databases; the next additive migration MUST be 0011 or higher.
4
+
5
+ CREATE TABLE webhook_consumers (
6
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7
+ app_id TEXT NOT NULL UNIQUE,
8
+ callback_url TEXT NOT NULL,
9
+ secret_cipher TEXT NOT NULL,
10
+ secret_hash TEXT NOT NULL,
11
+ secret_prefix TEXT NOT NULL,
12
+ prev_secret_cipher TEXT,
13
+ prev_expires_at TEXT,
14
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
15
+ admin_key_hash TEXT,
16
+ admin_key_prefix TEXT
17
+ );
18
+ CREATE INDEX idx_consumers_admin_key_prefix ON webhook_consumers(admin_key_prefix) WHERE admin_key_prefix IS NOT NULL;
19
+
20
+ CREATE TABLE bank_accounts (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ account_number TEXT NOT NULL,
23
+ account_type TEXT NOT NULL DEFAULT 'FIO',
24
+ pairing_code TEXT NOT NULL UNIQUE,
25
+ label TEXT,
26
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
27
+ owner_app_id TEXT REFERENCES webhook_consumers(app_id),
28
+ cf_rule_id TEXT,
29
+ ingest_mode TEXT NOT NULL DEFAULT 'email' CHECK (ingest_mode IN ('email', 'api', 'both')),
30
+ api_token_cipher TEXT,
31
+ api_token_key_ver INTEGER,
32
+ api_token_prefix TEXT,
33
+ api_fetch_enabled INTEGER NOT NULL DEFAULT 0,
34
+ api_last_fetch_at TEXT,
35
+ api_last_success_at TEXT,
36
+ api_last_error TEXT,
37
+ api_backfill_done INTEGER NOT NULL DEFAULT 0
38
+ );
39
+ CREATE INDEX idx_ba_owner ON bank_accounts(owner_app_id) WHERE owner_app_id IS NOT NULL;
40
+ CREATE INDEX idx_ba_api_fetch ON bank_accounts(account_type, api_fetch_enabled, api_last_fetch_at) WHERE api_fetch_enabled = 1;
41
+
42
+ CREATE TABLE transactions (
43
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ bank_account_id INTEGER NOT NULL REFERENCES bank_accounts(id),
45
+ amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
46
+ currency TEXT NOT NULL CHECK (length(currency) = 3 AND currency = upper(currency)),
47
+ counter_account TEXT,
48
+ bank_code TEXT,
49
+ bank_name TEXT,
50
+ vs TEXT,
51
+ ks TEXT,
52
+ ss TEXT,
53
+ message TEXT,
54
+ sender_name TEXT,
55
+ user_identification TEXT,
56
+ transaction_type TEXT,
57
+ performed_by TEXT,
58
+ comment TEXT,
59
+ command_id TEXT,
60
+ source TEXT NOT NULL,
61
+ date TEXT NOT NULL,
62
+ date_offset_min INTEGER,
63
+ transaction_id TEXT,
64
+ external_id TEXT,
65
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
66
+ );
67
+ CREATE UNIQUE INDEX idx_tx_ext ON transactions(external_id) WHERE external_id IS NOT NULL;
68
+ CREATE UNIQUE INDEX idx_tx_fio ON transactions(bank_account_id, transaction_id) WHERE transaction_id IS NOT NULL;
69
+ CREATE INDEX idx_tx_fuzzy_lookup ON transactions(bank_account_id, vs, amount_cents, currency, date) WHERE vs IS NOT NULL;
70
+ CREATE UNIQUE INDEX idx_tx_fuzzy_same_day ON transactions(bank_account_id, vs, amount_cents, currency, substr(date, 1, 10)) WHERE vs IS NOT NULL;
71
+
72
+ CREATE TABLE webhook_subscriptions (
73
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
74
+ bank_account_id INTEGER NOT NULL REFERENCES bank_accounts(id),
75
+ consumer_app_id TEXT NOT NULL REFERENCES webhook_consumers(app_id),
76
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
77
+ deleted_at TEXT,
78
+ UNIQUE(bank_account_id, consumer_app_id)
79
+ );
80
+ CREATE INDEX idx_ws_account ON webhook_subscriptions(bank_account_id);
81
+
82
+ CREATE TABLE parse_log (
83
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
84
+ bank_account_id INTEGER,
85
+ external_id TEXT,
86
+ raw_data TEXT,
87
+ error_message TEXT,
88
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
89
+ );
90
+
91
+ CREATE TABLE webhook_log (
92
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ delivery_id TEXT NOT NULL,
94
+ consumer_app_id TEXT NOT NULL,
95
+ bank_account_id INTEGER,
96
+ transaction_id INTEGER,
97
+ attempt INTEGER NOT NULL DEFAULT 1,
98
+ http_status INTEGER,
99
+ error_message TEXT,
100
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
101
+ );
102
+ CREATE INDEX idx_wl_consumer ON webhook_log(consumer_app_id, created_at);
103
+ CREATE INDEX idx_wl_delivery ON webhook_log(delivery_id);
104
+
105
+ CREATE TABLE event_log (
106
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
107
+ event_type TEXT NOT NULL,
108
+ bank_account_id INTEGER,
109
+ detail TEXT,
110
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
111
+ );
112
+ CREATE INDEX idx_el_type_time ON event_log(event_type, created_at);
113
+
114
+ CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
115
+ INSERT INTO schema_meta (key, value) VALUES ('version', '10');
116
+
117
+ CREATE TABLE idempotency_keys (
118
+ key_hash TEXT PRIMARY KEY,
119
+ auth_principal TEXT NOT NULL,
120
+ request_path TEXT NOT NULL,
121
+ request_method TEXT NOT NULL,
122
+ request_body_hash TEXT,
123
+ response_status INTEGER NOT NULL,
124
+ response_body TEXT NOT NULL,
125
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
126
+ );
127
+ CREATE INDEX idx_idem_created ON idempotency_keys(created_at);
128
+
129
+ CREATE TABLE admin_audit_log (
130
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
131
+ auth_principal TEXT NOT NULL,
132
+ http_method TEXT NOT NULL,
133
+ request_path TEXT NOT NULL,
134
+ http_status INTEGER NOT NULL,
135
+ request_body_summary TEXT,
136
+ source_ip TEXT,
137
+ user_agent TEXT,
138
+ duration_ms INTEGER,
139
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
140
+ );
141
+ CREATE INDEX idx_audit_principal_time ON admin_audit_log(auth_principal, created_at);
142
+ CREATE INDEX idx_audit_path_time ON admin_audit_log(request_path, created_at);
143
+
144
+ CREATE TABLE rate_limit_buckets (
145
+ principal TEXT NOT NULL,
146
+ window_start TEXT NOT NULL,
147
+ count INTEGER NOT NULL DEFAULT 1,
148
+ PRIMARY KEY (principal, window_start)
149
+ );
150
+ CREATE INDEX idx_rl_principal_window ON rate_limit_buckets(principal, window_start);
151
+
152
+ CREATE TABLE cf_routing_outbox (
153
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
154
+ op TEXT NOT NULL CHECK (op IN ('create', 'delete')),
155
+ pairing_code TEXT,
156
+ cf_rule_id TEXT,
157
+ bank_account_id INTEGER,
158
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'failed')),
159
+ attempts INTEGER NOT NULL DEFAULT 0,
160
+ last_error TEXT,
161
+ next_attempt_at TEXT NOT NULL DEFAULT (datetime('now')),
162
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
163
+ completed_at TEXT
164
+ );
165
+ CREATE INDEX idx_cfo_pending ON cf_routing_outbox(status, next_attempt_at) WHERE status = 'pending';
166
+ CREATE INDEX idx_cfo_bank_account ON cf_routing_outbox(bank_account_id) WHERE bank_account_id IS NOT NULL;
167
+
168
+ CREATE TABLE webhook_delivery_jobs (
169
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
170
+ transaction_id INTEGER,
171
+ consumer_app_id TEXT NOT NULL,
172
+ event_kind TEXT NOT NULL DEFAULT 'transaction.received',
173
+ delivery_id TEXT NOT NULL UNIQUE,
174
+ payload TEXT NOT NULL,
175
+ payload_sha256 TEXT,
176
+ status TEXT NOT NULL CHECK (status IN ('pending', 'dispatching', 'queued', 'delivered', 'terminal')),
177
+ generation INTEGER NOT NULL DEFAULT 1,
178
+ dispatch_token TEXT,
179
+ dispatch_count INTEGER NOT NULL DEFAULT 0,
180
+ http_attempt_count INTEGER NOT NULL DEFAULT 0,
181
+ next_attempt_at TEXT NOT NULL,
182
+ lease_until TEXT,
183
+ last_http_status INTEGER,
184
+ last_error TEXT,
185
+ business_outcome TEXT,
186
+ business_outcome_version INTEGER,
187
+ receipt_json TEXT,
188
+ incident_version INTEGER NOT NULL DEFAULT 0,
189
+ import_source TEXT,
190
+ legacy_dlq_id INTEGER,
191
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
192
+ delivered_at TEXT,
193
+ terminal_at TEXT,
194
+ UNIQUE(transaction_id, consumer_app_id, event_kind),
195
+ UNIQUE(legacy_dlq_id)
196
+ );
197
+ CREATE INDEX idx_webhook_delivery_jobs_due ON webhook_delivery_jobs(status, next_attempt_at, lease_until);
198
+ CREATE INDEX idx_webhook_delivery_jobs_txn ON webhook_delivery_jobs(transaction_id, consumer_app_id, event_kind);
199
+
200
+ CREATE TABLE alert_state (
201
+ key TEXT PRIMARY KEY,
202
+ value TEXT NOT NULL,
203
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
204
+ );
205
+
206
+ CREATE TABLE webhook_delivery_alerts (
207
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
208
+ delivery_job_id INTEGER NOT NULL,
209
+ incident_kind TEXT NOT NULL CHECK (incident_kind IN ('stalled', 'terminal', 'business', 'recovered')),
210
+ incident_key TEXT NOT NULL UNIQUE,
211
+ payload TEXT NOT NULL,
212
+ post_attempts INTEGER NOT NULL DEFAULT 0,
213
+ next_attempt_at TEXT NOT NULL DEFAULT (datetime('now')),
214
+ lease_until TEXT,
215
+ dispatch_token TEXT,
216
+ posted_at TEXT,
217
+ last_error TEXT,
218
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
219
+ );
220
+ CREATE INDEX idx_webhook_delivery_alerts_due ON webhook_delivery_alerts(posted_at, next_attempt_at, lease_until);
@@ -0,0 +1,7 @@
1
+ -- Production upgrade from the recorded 0001-0009 history to schema v10.
2
+ -- Cached mutation responses are intentionally ephemeral and may contain
3
+ -- credentials produced before credential routes rejected idempotency.
4
+ DELETE FROM idempotency_keys;
5
+
6
+ INSERT INTO schema_meta (key, value) VALUES ('version', '10')
7
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value;
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@festapp/banksync",
3
+ "version": "0.0.0-bootstrap.20260831",
4
+ "description": "Bank transaction ingestion for Cloudflare Workers: Czech bank email and Fio API parsing, durable deduplication, and signed webhook delivery.",
5
+ "type": "module",
6
+ "packageManager": "pnpm@11.24.0",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/festappnet/banksync.git"
11
+ },
12
+ "homepage": "https://github.com/festappnet/banksync#readme",
13
+ "bugs": "https://github.com/festappnet/banksync/issues",
14
+ "files": [
15
+ "dist",
16
+ "migrations",
17
+ "scripts/decrypt-backup.mjs",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js",
25
+ "require": "./dist/index.cjs",
26
+ "default": "./dist/index.js"
27
+ },
28
+ "./cloudflare": {
29
+ "types": "./dist/cloudflare.d.ts",
30
+ "import": "./dist/cloudflare.js",
31
+ "require": "./dist/cloudflare.cjs",
32
+ "default": "./dist/cloudflare.js"
33
+ }
34
+ },
35
+ "scripts": {
36
+ "build": "tsup src/index.ts src/cloudflare.ts --format esm,cjs --dts --clean --splitting",
37
+ "dev": "wrangler dev",
38
+ "deploy": "wrangler deploy",
39
+ "test": "vitest run",
40
+ "security:secrets": "node scripts/check-secrets.mjs",
41
+ "typecheck": "tsc --noEmit",
42
+ "check": "pnpm typecheck && pnpm test && pnpm build && pnpm check:package-exports",
43
+ "check:package-exports": "node scripts/check-package-exports.mjs",
44
+ "prepack": "pnpm build"
45
+ },
46
+ "dependencies": {
47
+ "postal-mime": "^2.7.5",
48
+ "ulid": "^2.3.0",
49
+ "valibot": "^1.2.0"
50
+ },
51
+ "devDependencies": {
52
+ "@cloudflare/workers-types": "^5.20260730.1",
53
+ "@types/better-sqlite3": "^7.6.13",
54
+ "better-sqlite3": "^12.11.1",
55
+ "tsup": "^8.5.0",
56
+ "typescript": "^5.9.3",
57
+ "vite": "^6.4.2",
58
+ "vitest": "^4.1.5",
59
+ "wrangler": "4.118.0"
60
+ },
61
+ "engines": {
62
+ "node": ">=20"
63
+ },
64
+ "publishConfig": {
65
+ "access": "public",
66
+ "provenance": true
67
+ }
68
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { webcrypto } from 'node:crypto';
4
+
5
+ const [input, output] = process.argv.slice(2);
6
+ const encodedKey = process.env.BACKUP_DECRYPTION_KEY;
7
+ if (!input || !output || !encodedKey) {
8
+ console.error('Usage: BACKUP_DECRYPTION_KEY=<base64-32-bytes> node scripts/decrypt-backup.mjs <input.sql.enc> <output.sql>');
9
+ process.exit(2);
10
+ }
11
+ const envelope = JSON.parse(await readFile(input, 'utf8'));
12
+ if (envelope.format !== 'banksync-backup' || envelope.version !== 1 || envelope.algorithm !== 'AES-256-GCM') {
13
+ throw new Error('unsupported_backup_format');
14
+ }
15
+ const metadata = {
16
+ format: envelope.format,
17
+ version: envelope.version,
18
+ key_version: envelope.key_version,
19
+ algorithm: envelope.algorithm,
20
+ created_at: envelope.created_at,
21
+ };
22
+ const keyBytes = Buffer.from(encodedKey, 'base64');
23
+ if (keyBytes.length !== 32) throw new Error('BACKUP_DECRYPTION_KEY must decode to 32 bytes');
24
+ const key = await webcrypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt']);
25
+ const plaintext = await webcrypto.subtle.decrypt({
26
+ name: 'AES-GCM',
27
+ iv: Buffer.from(envelope.iv, 'base64'),
28
+ additionalData: new TextEncoder().encode(JSON.stringify(metadata)),
29
+ tagLength: 128,
30
+ }, key, Buffer.from(envelope.ciphertext, 'base64'));
31
+ await writeFile(output, new Uint8Array(plaintext), { mode: 0o600, flag: 'wx' });
32
+ console.error(`Decrypted backup written to ${output} with mode 0600.`);