@accounter/scraper-app 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -0
- package/docs/plan.md +76 -0
- package/index.html +12 -0
- package/package.json +40 -0
- package/src/env.template +2 -0
- package/src/server/__tests__/accounts-routes.test.ts +133 -0
- package/src/server/__tests__/check-accounts.test.ts +305 -0
- package/src/server/__tests__/filter-payload.test.ts +193 -0
- package/src/server/__tests__/graphql-client.integration.test.ts +98 -0
- package/src/server/__tests__/graphql-client.test.ts +508 -0
- package/src/server/__tests__/healthz.test.ts +22 -0
- package/src/server/__tests__/history.test.ts +111 -0
- package/src/server/__tests__/otp-manager.test.ts +132 -0
- package/src/server/__tests__/scrape-runner.test.ts +144 -0
- package/src/server/__tests__/settings-routes.test.ts +117 -0
- package/src/server/__tests__/sources-routes.test.ts +149 -0
- package/src/server/__tests__/validate-payload.test.ts +193 -0
- package/src/server/__tests__/vault-routes.test.ts +174 -0
- package/src/server/__tests__/vault.test.ts +33 -0
- package/src/server/__tests__/websocket.test.ts +151 -0
- package/src/server/account-discovery.ts +49 -0
- package/src/server/accounts-routes.ts +74 -0
- package/src/server/check-accounts.ts +79 -0
- package/src/server/filter-payload.ts +145 -0
- package/src/server/graphql/client.ts +103 -0
- package/src/server/graphql/mutations.ts +518 -0
- package/src/server/history-routes.ts +11 -0
- package/src/server/history.ts +53 -0
- package/src/server/index.ts +40 -0
- package/src/server/otp-manager.ts +63 -0
- package/src/server/payload-schemas/amex.schema.ts +2 -0
- package/src/server/payload-schemas/cal.schema.ts +27 -0
- package/src/server/payload-schemas/currency-rates.schema.ts +11 -0
- package/src/server/payload-schemas/discount.schema.ts +26 -0
- package/src/server/payload-schemas/isracard.schema.ts +58 -0
- package/src/server/payload-schemas/max.schema.ts +27 -0
- package/src/server/payload-schemas/poalim-foreign.schema.ts +30 -0
- package/src/server/payload-schemas/poalim-ils.schema.ts +31 -0
- package/src/server/payload-schemas/poalim-swift.schema.ts +21 -0
- package/src/server/scrape-runner.ts +165 -0
- package/src/server/scrapers/__tests__/amex.test.ts +142 -0
- package/src/server/scrapers/__tests__/cal.test.ts +135 -0
- package/src/server/scrapers/__tests__/currency-rates.test.ts +105 -0
- package/src/server/scrapers/__tests__/discount.test.ts +160 -0
- package/src/server/scrapers/__tests__/isracard.test.ts +142 -0
- package/src/server/scrapers/__tests__/max.test.ts +115 -0
- package/src/server/scrapers/__tests__/poalim.test.ts +154 -0
- package/src/server/scrapers/amex.ts +63 -0
- package/src/server/scrapers/cal.ts +56 -0
- package/src/server/scrapers/currency-rates.ts +64 -0
- package/src/server/scrapers/discount.ts +62 -0
- package/src/server/scrapers/isracard.ts +68 -0
- package/src/server/scrapers/max.ts +32 -0
- package/src/server/scrapers/poalim.ts +103 -0
- package/src/server/settings-routes.ts +27 -0
- package/src/server/sources-routes.ts +182 -0
- package/src/server/validate-payload.ts +74 -0
- package/src/server/vault-routes.ts +99 -0
- package/src/server/vault-store.ts +42 -0
- package/src/server/vault.ts +216 -0
- package/src/server/websocket.ts +454 -0
- package/src/shared/source-types.ts +10 -0
- package/src/shared/types.ts +20 -0
- package/src/shared/ws-protocol.ts +177 -0
- package/src/test-setup.ts +6 -0
- package/src/ui/__tests__/accounts-tab.test.tsx +134 -0
- package/src/ui/__tests__/config.test.tsx +99 -0
- package/src/ui/__tests__/history.test.tsx +94 -0
- package/src/ui/__tests__/run.test.tsx +195 -0
- package/src/ui/__tests__/settings-tab.test.tsx +79 -0
- package/src/ui/__tests__/sources-tab.test.tsx +139 -0
- package/src/ui/__tests__/vault-setup.test.tsx +105 -0
- package/src/ui/__tests__/vault-unlock.test.tsx +78 -0
- package/src/ui/app.tsx +109 -0
- package/src/ui/components/error-boundary.tsx +54 -0
- package/src/ui/components/otp-modal.tsx +82 -0
- package/src/ui/components/skeleton.tsx +58 -0
- package/src/ui/components/task-row.tsx +241 -0
- package/src/ui/contexts/vault-context.tsx +77 -0
- package/src/ui/lib/api.ts +117 -0
- package/src/ui/lib/ws.ts +137 -0
- package/src/ui/main.tsx +9 -0
- package/src/ui/screens/config/accounts-tab.tsx +185 -0
- package/src/ui/screens/config/config.tsx +163 -0
- package/src/ui/screens/config/settings-tab.tsx +167 -0
- package/src/ui/screens/config/source-forms.tsx +518 -0
- package/src/ui/screens/config/source-types.ts +91 -0
- package/src/ui/screens/config/sources-tab.tsx +176 -0
- package/src/ui/screens/history.tsx +234 -0
- package/src/ui/screens/run.tsx +266 -0
- package/src/ui/screens/vault-setup.tsx +120 -0
- package/src/ui/screens/vault-unlock.tsx +38 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +10 -0
- package/vite.config.ts +24 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z, ZodError } from 'zod';
|
|
2
|
+
import type { IsracardCardsTransactionsList } from '@accounter/modern-poalim-scraper';
|
|
3
|
+
import { AmexPayloadSchema } from './payload-schemas/amex.schema.js';
|
|
4
|
+
import { CalPayloadSchema } from './payload-schemas/cal.schema.js';
|
|
5
|
+
import type { CalPayload } from './payload-schemas/cal.schema.js';
|
|
6
|
+
import { CurrencyRatesPayloadSchema } from './payload-schemas/currency-rates.schema.js';
|
|
7
|
+
import type { CurrencyRatesPayload } from './payload-schemas/currency-rates.schema.js';
|
|
8
|
+
import { DiscountPayloadSchema } from './payload-schemas/discount.schema.js';
|
|
9
|
+
import type { DiscountPayload } from './payload-schemas/discount.schema.js';
|
|
10
|
+
import { IsracardPayloadSchema } from './payload-schemas/isracard.schema.js';
|
|
11
|
+
import { MaxPayloadSchema } from './payload-schemas/max.schema.js';
|
|
12
|
+
import type { MaxPayload } from './payload-schemas/max.schema.js';
|
|
13
|
+
import { PoalimForeignPayloadSchema } from './payload-schemas/poalim-foreign.schema.js';
|
|
14
|
+
import type { PoalimForeignPayload } from './payload-schemas/poalim-foreign.schema.js';
|
|
15
|
+
import { PoalimIlsPayloadSchema } from './payload-schemas/poalim-ils.schema.js';
|
|
16
|
+
import type { PoalimIlsPayload } from './payload-schemas/poalim-ils.schema.js';
|
|
17
|
+
import { PoalimSwiftPayloadSchema } from './payload-schemas/poalim-swift.schema.js';
|
|
18
|
+
import type { PoalimSwiftPayload } from './payload-schemas/poalim-swift.schema.js';
|
|
19
|
+
|
|
20
|
+
export type PayloadType =
|
|
21
|
+
| 'poalim-ils'
|
|
22
|
+
| 'poalim-foreign'
|
|
23
|
+
| 'poalim-swift'
|
|
24
|
+
| 'isracard'
|
|
25
|
+
| 'amex'
|
|
26
|
+
| 'cal'
|
|
27
|
+
| 'discount'
|
|
28
|
+
| 'max'
|
|
29
|
+
| 'currency-rates';
|
|
30
|
+
|
|
31
|
+
type PayloadMap = {
|
|
32
|
+
'poalim-ils': PoalimIlsPayload;
|
|
33
|
+
'poalim-foreign': PoalimForeignPayload;
|
|
34
|
+
'poalim-swift': PoalimSwiftPayload;
|
|
35
|
+
isracard: IsracardCardsTransactionsList;
|
|
36
|
+
amex: IsracardCardsTransactionsList;
|
|
37
|
+
cal: CalPayload;
|
|
38
|
+
discount: DiscountPayload;
|
|
39
|
+
max: MaxPayload;
|
|
40
|
+
'currency-rates': CurrencyRatesPayload;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export class PayloadValidationError extends Error {
|
|
44
|
+
constructor(
|
|
45
|
+
public readonly payloadType: PayloadType,
|
|
46
|
+
public readonly zodError: ZodError,
|
|
47
|
+
) {
|
|
48
|
+
super(`[${payloadType}] payload validation failed: ${zodError.message}`);
|
|
49
|
+
this.name = 'PayloadValidationError';
|
|
50
|
+
this.cause = zodError;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
55
|
+
const schemas: Record<PayloadType, z.ZodType<any>> = {
|
|
56
|
+
'poalim-ils': PoalimIlsPayloadSchema,
|
|
57
|
+
'poalim-foreign': PoalimForeignPayloadSchema,
|
|
58
|
+
'poalim-swift': PoalimSwiftPayloadSchema,
|
|
59
|
+
isracard: IsracardPayloadSchema,
|
|
60
|
+
amex: AmexPayloadSchema,
|
|
61
|
+
cal: CalPayloadSchema,
|
|
62
|
+
discount: DiscountPayloadSchema,
|
|
63
|
+
max: MaxPayloadSchema,
|
|
64
|
+
'currency-rates': CurrencyRatesPayloadSchema,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function validatePayload<T extends PayloadType>(type: T, raw: unknown): PayloadMap[T] {
|
|
68
|
+
const schema = schemas[type];
|
|
69
|
+
const result = schema.safeParse(raw);
|
|
70
|
+
if (!result.success) {
|
|
71
|
+
throw new PayloadValidationError(type, result.error);
|
|
72
|
+
}
|
|
73
|
+
return result.data as PayloadMap[T];
|
|
74
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { registerAccountsRoutes } from './accounts-routes.js';
|
|
3
|
+
import { registerSettingsRoutes } from './settings-routes.js';
|
|
4
|
+
import { registerSourcesRoutes } from './sources-routes.js';
|
|
5
|
+
import { getVault, hasVaultFile, isLocked, lockVault, unlockVault } from './vault-store.js';
|
|
6
|
+
import { defaultVault, getVaultPath, saveVaultFile } from './vault.js';
|
|
7
|
+
|
|
8
|
+
type UnlockBody = { password: string };
|
|
9
|
+
type CreateBody = { password: string; serverUrl: string; apiKey: string };
|
|
10
|
+
|
|
11
|
+
export async function registerVaultRoutes(app: FastifyInstance): Promise<void> {
|
|
12
|
+
app.get('/api/vault/status', async () => ({
|
|
13
|
+
locked: isLocked(),
|
|
14
|
+
hasFile: await hasVaultFile(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
app.post<{ Body: UnlockBody }>(
|
|
18
|
+
'/api/vault/unlock',
|
|
19
|
+
{
|
|
20
|
+
schema: {
|
|
21
|
+
body: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
required: ['password'],
|
|
24
|
+
properties: { password: { type: 'string' } },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
async (req, reply) => {
|
|
29
|
+
const result = await unlockVault(req.body.password);
|
|
30
|
+
if (result === 'ok') return { ok: true };
|
|
31
|
+
const status = result === 'not-found' ? 404 : 401;
|
|
32
|
+
return reply.status(status).send({ error: result });
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
app.post<{ Body: CreateBody }>(
|
|
37
|
+
'/api/vault/create',
|
|
38
|
+
{
|
|
39
|
+
schema: {
|
|
40
|
+
body: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
required: ['password', 'serverUrl', 'apiKey'],
|
|
43
|
+
properties: {
|
|
44
|
+
password: { type: 'string' },
|
|
45
|
+
serverUrl: { type: 'string' },
|
|
46
|
+
apiKey: { type: 'string' },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
async (req, reply) => {
|
|
52
|
+
if (await hasVaultFile()) return reply.status(409).send({ error: 'vault-already-exists' });
|
|
53
|
+
const { password, serverUrl, apiKey } = req.body;
|
|
54
|
+
lockVault();
|
|
55
|
+
const vault = defaultVault();
|
|
56
|
+
vault.settings.serverUrl = serverUrl;
|
|
57
|
+
vault.settings.apiKey = apiKey;
|
|
58
|
+
await saveVaultFile(getVaultPath(), vault, password);
|
|
59
|
+
const result = await unlockVault(password);
|
|
60
|
+
if (result !== 'ok') return reply.status(500).send({ error: result });
|
|
61
|
+
return reply.status(201).send({ ok: true });
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
app.get('/api/vault/path', async (_req, reply) => {
|
|
66
|
+
if (isLocked()) return reply.status(401).send({ error: 'vault-locked' });
|
|
67
|
+
return { path: getVaultPath() };
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
app.get('/api/vault/test-connection', async (_req, reply) => {
|
|
71
|
+
if (isLocked()) return reply.status(401).send({ error: 'vault-locked' });
|
|
72
|
+
const { serverUrl, apiKey } = getVault().settings;
|
|
73
|
+
if (!serverUrl || !apiKey) {
|
|
74
|
+
return reply.status(400).send({ error: 'Server URL and API key are not configured' });
|
|
75
|
+
}
|
|
76
|
+
const t0 = Date.now();
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(serverUrl, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
81
|
+
body: JSON.stringify({ query: '{ __typename }' }),
|
|
82
|
+
signal: AbortSignal.timeout(10_000),
|
|
83
|
+
});
|
|
84
|
+
const latencyMs = Date.now() - t0;
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const text = await res.text().catch(() => res.statusText);
|
|
87
|
+
return { ok: false, error: `HTTP ${res.status}: ${text}`, latencyMs };
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, latencyMs };
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const latencyMs = Date.now() - t0;
|
|
92
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err), latencyMs };
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await registerSourcesRoutes(app);
|
|
97
|
+
await registerSettingsRoutes(app);
|
|
98
|
+
await registerAccountsRoutes(app);
|
|
99
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { getVaultPath, loadVaultFile, saveVaultFile, type Vault } from './vault.js';
|
|
3
|
+
|
|
4
|
+
let _vault: Vault | null = null;
|
|
5
|
+
let _password: string | null = null;
|
|
6
|
+
|
|
7
|
+
export async function hasVaultFile(): Promise<boolean> {
|
|
8
|
+
return access(getVaultPath())
|
|
9
|
+
.then(() => true)
|
|
10
|
+
.catch(() => false);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function unlockVault(
|
|
14
|
+
password: string,
|
|
15
|
+
): Promise<'ok' | 'wrong-password' | 'not-found'> {
|
|
16
|
+
if (!(await hasVaultFile())) return 'not-found';
|
|
17
|
+
const vault = await loadVaultFile(getVaultPath(), password);
|
|
18
|
+
if (vault === null) return 'wrong-password';
|
|
19
|
+
_vault = vault;
|
|
20
|
+
_password = password;
|
|
21
|
+
return 'ok';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getVault(): Vault {
|
|
25
|
+
if (_vault === null) throw new Error('Vault is locked');
|
|
26
|
+
return _vault;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function updateVault(fn: (v: Vault) => Vault): Promise<void> {
|
|
30
|
+
if (_vault === null || _password === null) throw new Error('Vault is locked');
|
|
31
|
+
_vault = fn(_vault);
|
|
32
|
+
await saveVaultFile(getVaultPath(), _vault, _password);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isLocked(): boolean {
|
|
36
|
+
return _vault === null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function lockVault(): void {
|
|
40
|
+
_vault = null;
|
|
41
|
+
_password = null;
|
|
42
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'node:crypto';
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { z, ZodError } from 'zod';
|
|
4
|
+
|
|
5
|
+
export function getVaultPath(): string {
|
|
6
|
+
return process.env['VAULT_PATH'] ?? '.vault';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
10
|
+
const KEY_LEN = 32;
|
|
11
|
+
const SALT_LEN = 32;
|
|
12
|
+
const IV_LEN = 12;
|
|
13
|
+
const TAG_LEN = 16;
|
|
14
|
+
|
|
15
|
+
// Scrypt cost parameters stored in the blob header so decryption is independent
|
|
16
|
+
// of whatever the current code constant says (enables future param upgrades).
|
|
17
|
+
const SCRYPT_N = 65_536;
|
|
18
|
+
const SCRYPT_R = 8;
|
|
19
|
+
const SCRYPT_P = 1;
|
|
20
|
+
// Node default maxmem is 32 MB; N=65536 r=8 requires ~64 MB. Compute with 2× headroom.
|
|
21
|
+
const SCRYPT_MAXMEM = 128 * SCRYPT_N * SCRYPT_R * 2;
|
|
22
|
+
// Blob header: N as uint32BE (4 bytes) + r as uint8 (1 byte) + p as uint8 (1 byte)
|
|
23
|
+
const HEADER_LEN = 6;
|
|
24
|
+
|
|
25
|
+
export const PoalimAccountSchema = z.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
nickname: z.string().optional(),
|
|
28
|
+
userCode: z.string(),
|
|
29
|
+
password: z.string(),
|
|
30
|
+
options: z
|
|
31
|
+
.object({
|
|
32
|
+
isBusinessAccount: z.boolean().optional(),
|
|
33
|
+
acceptedAccountNumbers: z.array(z.string()).optional(),
|
|
34
|
+
acceptedBranchNumbers: z.array(z.string()).optional(),
|
|
35
|
+
ignoredAccountNumbers: z.array(z.string()).optional(),
|
|
36
|
+
ignoredBranchNumbers: z.array(z.string()).optional(),
|
|
37
|
+
})
|
|
38
|
+
.optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const DiscountAccountSchema = z.object({
|
|
42
|
+
id: z.string(),
|
|
43
|
+
ID: z.string(),
|
|
44
|
+
password: z.string(),
|
|
45
|
+
code: z.string().optional(),
|
|
46
|
+
nickname: z.string().optional(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const IsracardAmexAccountSchema = z.object({
|
|
50
|
+
id: z.string(),
|
|
51
|
+
nickname: z.string().optional(),
|
|
52
|
+
ownerId: z.string(),
|
|
53
|
+
password: z.string(),
|
|
54
|
+
last6Digits: z.string(),
|
|
55
|
+
options: z
|
|
56
|
+
.object({
|
|
57
|
+
acceptedCardNumbers: z.array(z.string()).optional(),
|
|
58
|
+
ignoredCardNumbers: z.array(z.string()).optional(),
|
|
59
|
+
cardNumberMapping: z.record(z.string(), z.string()).optional(),
|
|
60
|
+
})
|
|
61
|
+
.optional(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const CalAccountSchema = z.object({
|
|
65
|
+
id: z.string(),
|
|
66
|
+
nickname: z.string().optional(),
|
|
67
|
+
username: z.string(),
|
|
68
|
+
password: z.string(),
|
|
69
|
+
last4Digits: z.string(),
|
|
70
|
+
options: z
|
|
71
|
+
.object({
|
|
72
|
+
acceptedCardNumbers: z.array(z.string()).optional(),
|
|
73
|
+
ignoredCardNumbers: z.array(z.string()).optional(),
|
|
74
|
+
})
|
|
75
|
+
.optional(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export const MaxAccountSchema = z.object({
|
|
79
|
+
id: z.string(),
|
|
80
|
+
nickname: z.string().optional(),
|
|
81
|
+
username: z.string(),
|
|
82
|
+
password: z.string(),
|
|
83
|
+
options: z
|
|
84
|
+
.object({
|
|
85
|
+
acceptedCardNumbers: z.array(z.string()).optional(),
|
|
86
|
+
ignoredCardNumbers: z.array(z.string()).optional(),
|
|
87
|
+
})
|
|
88
|
+
.optional(),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export const SettingsSchema = z.object({
|
|
92
|
+
showBrowser: z.boolean().default(false),
|
|
93
|
+
fetchBankOfIsraelRates: z.boolean().default(true),
|
|
94
|
+
concurrentScraping: z.boolean().default(false),
|
|
95
|
+
defaultDateRangeMonths: z.number().int().positive().default(3),
|
|
96
|
+
historyFilePath: z.string().default('./history.json'),
|
|
97
|
+
saveHistory: z.boolean().default(true),
|
|
98
|
+
serverUrl: z.string().optional(),
|
|
99
|
+
apiKey: z.string().optional(),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
export type Settings = z.infer<typeof SettingsSchema>;
|
|
103
|
+
|
|
104
|
+
export const AccountRecordSchema = z.object({
|
|
105
|
+
id: z.string(),
|
|
106
|
+
sourceId: z.string(),
|
|
107
|
+
sourceType: z.enum(['poalim', 'discount', 'isracard', 'amex', 'cal', 'max']),
|
|
108
|
+
accountNumber: z.string(),
|
|
109
|
+
branchNumber: z.string().optional(),
|
|
110
|
+
status: z.enum(['accepted', 'ignored', 'pending']).default('pending'),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export type AccountRecord = z.infer<typeof AccountRecordSchema>;
|
|
114
|
+
|
|
115
|
+
export const VaultSchema = z.object({
|
|
116
|
+
poalimAccounts: z.array(PoalimAccountSchema).default([]),
|
|
117
|
+
discountAccounts: z.array(DiscountAccountSchema).default([]),
|
|
118
|
+
isracardAccounts: z.array(IsracardAmexAccountSchema).default([]),
|
|
119
|
+
amexAccounts: z.array(IsracardAmexAccountSchema).default([]),
|
|
120
|
+
calAccounts: z.array(CalAccountSchema).default([]),
|
|
121
|
+
maxAccounts: z.array(MaxAccountSchema).default([]),
|
|
122
|
+
accountRecords: z.array(AccountRecordSchema).default([]),
|
|
123
|
+
settings: SettingsSchema.default({
|
|
124
|
+
showBrowser: false,
|
|
125
|
+
fetchBankOfIsraelRates: true,
|
|
126
|
+
concurrentScraping: false,
|
|
127
|
+
defaultDateRangeMonths: 3,
|
|
128
|
+
historyFilePath: './history.json',
|
|
129
|
+
saveHistory: true,
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export type Vault = z.infer<typeof VaultSchema>;
|
|
134
|
+
|
|
135
|
+
function deriveKey(
|
|
136
|
+
password: string,
|
|
137
|
+
salt: Buffer,
|
|
138
|
+
options: { N: number; r: number; p: number; maxmem: number },
|
|
139
|
+
): Promise<Buffer> {
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
scrypt(password, salt, KEY_LEN, options, (err, key) => {
|
|
142
|
+
if (err) reject(err);
|
|
143
|
+
else resolve(key);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function encryptVault(vault: Vault, password: string): Promise<string> {
|
|
149
|
+
const salt = randomBytes(SALT_LEN);
|
|
150
|
+
const iv = randomBytes(IV_LEN);
|
|
151
|
+
const key = await deriveKey(password, salt, {
|
|
152
|
+
N: SCRYPT_N,
|
|
153
|
+
r: SCRYPT_R,
|
|
154
|
+
p: SCRYPT_P,
|
|
155
|
+
maxmem: SCRYPT_MAXMEM,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
159
|
+
const encrypted = Buffer.concat([cipher.update(JSON.stringify(vault), 'utf8'), cipher.final()]);
|
|
160
|
+
const authTag = cipher.getAuthTag();
|
|
161
|
+
|
|
162
|
+
const header = Buffer.alloc(HEADER_LEN);
|
|
163
|
+
header.writeUInt32BE(SCRYPT_N, 0);
|
|
164
|
+
header.writeUInt8(SCRYPT_R, 4);
|
|
165
|
+
header.writeUInt8(SCRYPT_P, 5);
|
|
166
|
+
|
|
167
|
+
// Layout: [N(4)] [r(1)] [p(1)] [salt(32)] [iv(12)] [authTag(16)] [ciphertext]
|
|
168
|
+
return Buffer.concat([header, salt, iv, authTag, encrypted]).toString('base64');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function decryptVault(blob: string, password: string): Promise<Vault | null> {
|
|
172
|
+
try {
|
|
173
|
+
const buf = Buffer.from(blob, 'base64');
|
|
174
|
+
const N = buf.readUInt32BE(0);
|
|
175
|
+
const r = buf.readUInt8(4);
|
|
176
|
+
const p = buf.readUInt8(5);
|
|
177
|
+
|
|
178
|
+
const salt = buf.subarray(HEADER_LEN, HEADER_LEN + SALT_LEN);
|
|
179
|
+
const iv = buf.subarray(HEADER_LEN + SALT_LEN, HEADER_LEN + SALT_LEN + IV_LEN);
|
|
180
|
+
const authTag = buf.subarray(
|
|
181
|
+
HEADER_LEN + SALT_LEN + IV_LEN,
|
|
182
|
+
HEADER_LEN + SALT_LEN + IV_LEN + TAG_LEN,
|
|
183
|
+
);
|
|
184
|
+
const body = buf.subarray(HEADER_LEN + SALT_LEN + IV_LEN + TAG_LEN);
|
|
185
|
+
|
|
186
|
+
const key = await deriveKey(password, salt, { N, r, p, maxmem: 128 * N * r * 2 });
|
|
187
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
188
|
+
decipher.setAuthTag(authTag);
|
|
189
|
+
|
|
190
|
+
const plaintext = Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');
|
|
191
|
+
return VaultSchema.parse(JSON.parse(plaintext));
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err instanceof ZodError) {
|
|
194
|
+
console.error('[vault] schema validation failed after decryption:', err.issues);
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function defaultVault(): Vault {
|
|
201
|
+
return VaultSchema.parse({ settings: {} });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function loadVaultFile(filePath: string, password: string): Promise<Vault | null> {
|
|
205
|
+
const blob = await readFile(filePath, 'utf8');
|
|
206
|
+
return decryptVault(blob.trim(), password);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function saveVaultFile(
|
|
210
|
+
filePath: string,
|
|
211
|
+
vault: Vault,
|
|
212
|
+
password: string,
|
|
213
|
+
): Promise<void> {
|
|
214
|
+
const data = await encryptVault(vault, password);
|
|
215
|
+
await writeFile(filePath, data, 'utf8');
|
|
216
|
+
}
|