diff --git a/.env.example b/.env.example index 8356de1..c62c7da 100644 --- a/.env.example +++ b/.env.example @@ -2,16 +2,13 @@ APP_ORIGIN=http://localhost:3000 GOOGLE_OAUTH_REDIRECT_URI=http://localhost:3000/api/auth/google/callback -# Google Cloud identifiers and server-side OAuth secret +# Google identity (openid email profile). No Picker, Drive or Sheets runtime access. GOOGLE_CLIENT_ID=replace-with-google-oauth-client-id GOOGLE_CLIENT_SECRET=replace-with-google-oauth-client-secret -GOOGLE_API_KEY=replace-with-picker-api-key -GOOGLE_CLOUD_PROJECT_NUMBER=123456789012 ALLOWED_GOOGLE_EMAIL=owner@example.test # Pooled PostgreSQL runtime connection (never use production for tests) DATABASE_URL=postgresql://runtime-user:replace-with-password@localhost:5432/accura -# Generate independent values as documented in docs/anleitungen/produktions-setup.md -TOKEN_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key +# Generate an independent value as documented in docs/anleitungen/produktions-setup.md SESSION_SECRET=replace-with-at-least-32-random-bytes diff --git a/README.md b/README.md index c27cd5b..e104b6d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Accura schafft finanzielle Klarheit für Menschen, die unter engem Budget, Schulden oder mentaler Überforderung leiden. Statt Vermögen zu optimieren, zeigt die App, was wirklich verfügbar ist, was als Nächstes fällig wird und wie sich finanzielle Belastungen entwickeln. Der [Produktüberblick](docs/produkt/ueberblick.md) beschreibt dieses Versprechen und seine bewussten Grenzen. -Heute ist `accura` eine private, deutschsprachige Finanzübersicht als installierbare Web-App (PWA). Genau eine freigegebene Person verbindet eine selbst kontrollierte Google-Tabelle. Die App liest und validiert die Daten, verändert die Tabelle aber nicht. Ein zuletzt erfolgreich geladener Datenstand bleibt lokal offline verfügbar. +Heute ist `accura` eine private, deutschsprachige Finanzübersicht als installierbare Web-App (PWA). Genau eine freigegebene Person meldet sich mit Google an. Der Finanzstand liegt in PostgreSQL. Ein zuletzt erfolgreich geladener Datenstand bleibt lokal offline verfügbar. Die vier Ansichten zeigen verfügbare Mittel, anstehende Zahlungen bis zum nächsten Gehalt, Monatsbudget und Schuldenverlauf. Ein lokaler Privacy-Modus maskiert sichtbare Geldbeträge; er ist ausdrücklich keine Verschlüsselung. @@ -26,7 +26,7 @@ npm install npm run dev:mock ``` -Der Mock-Modus verwendet ausschließlich anonyme Repository-Daten. Für Google OAuth, Picker, Sheets, PostgreSQL und Vercel Functions gilt die [Produktions-Setup-Anleitung](docs/anleitungen/produktions-setup.md). +Der Mock-Modus verwendet ausschließlich anonyme Repository-Daten. Für Google OAuth, PostgreSQL und Vercel Functions gilt die [Produktions-Setup-Anleitung](docs/anleitungen/produktions-setup.md). Der Integrationsstand auf `develop` ist unter [accura-preview.kiumu.app](https://accura-preview.kiumu.app/) mit derselben anonymen, bereits angemeldeten Mock-Sitzung verfügbar. Pull Requests zielen standardmäßig auf `develop`; `master` bleibt der bewusst freizugebende Produktionsstand. diff --git a/api/_lib/config.ts b/api/_lib/config.ts index be78393..b59e4ce 100644 --- a/api/_lib/config.ts +++ b/api/_lib/config.ts @@ -6,12 +6,9 @@ export type ServerConfig = { appOrigin: string; googleClientId: string; googleClientSecret: string; - googleApiKey: string; - googleCloudProjectNumber: string; googleOAuthRedirectUri: string; allowedGoogleEmail: string; databaseUrl: string; - tokenEncryptionKey: string; sessionSecret: string; production: boolean; }; @@ -38,20 +35,15 @@ export function getServerConfig(env: NodeJS.ProcessEnv = process.env): ServerCon } const allowedGoogleEmail = required('ALLOWED_GOOGLE_EMAIL', env).toLowerCase(); if (!z.string().email().safeParse(allowedGoogleEmail).success) throw new Error('ALLOWED_GOOGLE_EMAIL must be an email address.'); - const projectNumber = required('GOOGLE_CLOUD_PROJECT_NUMBER', env); - if (!/^\d+$/.test(projectNumber)) throw new Error('GOOGLE_CLOUD_PROJECT_NUMBER must be numeric.'); const sessionSecret = required('SESSION_SECRET', env); if (Buffer.byteLength(sessionSecret, 'utf8') < 32) throw new Error('SESSION_SECRET must contain at least 32 bytes.'); return { appOrigin, googleClientId: required('GOOGLE_CLIENT_ID', env), googleClientSecret: required('GOOGLE_CLIENT_SECRET', env), - googleApiKey: required('GOOGLE_API_KEY', env), - googleCloudProjectNumber: projectNumber, googleOAuthRedirectUri: redirectUri, allowedGoogleEmail, databaseUrl: required('DATABASE_URL', env), - tokenEncryptionKey: required('TOKEN_ENCRYPTION_KEY', env), sessionSecret, production: env.VERCEL_ENV === 'production' || env.NODE_ENV === 'production', }; diff --git a/api/_lib/database.ts b/api/_lib/database.ts index 195575e..5d4b5d0 100644 --- a/api/_lib/database.ts +++ b/api/_lib/database.ts @@ -16,3 +16,11 @@ export function getDatabase(databaseUrl: string): postgres.Sql { databases.set(databaseUrl, sql); return sql; } + +/** Closes and forgets a pool used by a one-off process such as the operator import. */ +export async function closeDatabase(databaseUrl: string): Promise { + const sql = databases.get(databaseUrl); + if (!sql) return; + databases.delete(databaseUrl); + await sql.end({ timeout: 5 }); +} diff --git a/api/_lib/errors.ts b/api/_lib/errors.ts index 59e44f5..3a18cb8 100644 --- a/api/_lib/errors.ts +++ b/api/_lib/errors.ts @@ -10,13 +10,6 @@ export class AppError extends Error { } } -export class ReconnectRequiredError extends AppError { - constructor() { - super('reconnect_required', 401, 'Die Google-Verbindung muss erneut autorisiert werden.'); - this.name = 'ReconnectRequiredError'; - } -} - export const publicError = (error: unknown) => { if (error instanceof AppError) { return { status: error.status, body: { error: { code: error.code, message: error.message, details: error.details } } }; diff --git a/api/_lib/financeImport.ts b/api/_lib/financeImport.ts new file mode 100644 index 0000000..294438f --- /dev/null +++ b/api/_lib/financeImport.ts @@ -0,0 +1,86 @@ +import { parseSheetsBatchResponse } from '../../src/finance/parser.ts'; +import { financeDataV1Schema } from '../../src/finance/runtime.ts'; +import { z } from 'zod'; +import type { FinanceDataV1, FinanceValidationResult } from '../../src/finance/types.ts'; + +const sheetsBatchResponseSchema = z.object({ + spreadsheetId: z.string().min(1).optional(), + valueRanges: z.array(z.object({ + range: z.string().min(1).optional(), + values: z.array(z.array(z.unknown())).optional(), + }).passthrough()), +}).passthrough(); + +/** Parses operator JSON without allowing V8 to echo source fragments in an error message. */ +export function parseFinanceImportJson(contents: string): unknown { + try { + return JSON.parse(contents) as unknown; + } catch { + throw new Error('Die Importdatei ist kein gültiges JSON.'); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Accepts a Sheets batchGet payload or an already normalized FinanceDataV1 object. */ +export function parseFinanceImportSource(raw: unknown): FinanceValidationResult { + if (!isRecord(raw)) { + return { + success: false, + issues: [{ + tab: '_Meta', + row: 1, + column: '(file)', + expected: 'JSON-Objekt', + message: 'Die Importdatei muss ein JSON-Objekt sein.', + }], + }; + } + if ('valueRanges' in raw || 'spreadsheetId' in raw) { + const sheetsResponse = sheetsBatchResponseSchema.safeParse(raw); + if (!sheetsResponse.success) { + return { + success: false, + issues: [{ + tab: '_Meta', + row: 1, + column: '(file)', + expected: 'vollständige Sheets-batchGet-Antwort', + message: 'Die Importdatei enthält keine gültige Sheets-batchGet-Antwort.', + }], + }; + } + return parseSheetsBatchResponse(sheetsResponse.data); + } + const parsed = financeDataV1Schema.safeParse(raw); + if (parsed.success) return { success: true, data: parsed.data }; + return { + success: false, + issues: [{ + tab: '_Meta', + row: 1, + column: '(file)', + expected: 'FinanceDataV1 oder Sheets-batchGet', + message: 'Die Importdatei entspricht weder Finance Data Schema v1 noch einer Sheets-batchGet-Antwort.', + }], + }; +} + +export function financeImportFingerprint(data: FinanceDataV1) { + return { + asOf: data.asOf, + currency: data.currency, + salaryDay: data.salaryDay, + accounts: data.accounts.length, + accountSnapshots: data.accountSnapshots.length, + pockets: data.pockets.length, + pocketSnapshots: data.pocketSnapshots.length, + budgetItems: data.budgetItems.length, + debts: data.debts.length, + debtSnapshots: data.debtSnapshots.length, + debtMilestones: data.debtMilestones.length, + reliefMilestones: data.reliefMilestones.length, + }; +} diff --git a/api/_lib/financeRepository.ts b/api/_lib/financeRepository.ts index 8edc579..3d86c05 100644 --- a/api/_lib/financeRepository.ts +++ b/api/_lib/financeRepository.ts @@ -1,21 +1,42 @@ import type postgres from 'postgres'; -import { financeDataV1Schema } from '../../src/finance/runtime.js'; -import type { FinanceDataV1 } from '../../src/finance/types.js'; -import { getDatabase } from './database.js'; +import { financeDataV1Schema } from '../../src/finance/runtime.ts'; +import type { FinanceDataV1 } from '../../src/finance/types.ts'; +import { getDatabase } from './database.ts'; export interface FinanceRepository { + ensureOwnerForGoogleSub(googleSub: string): Promise; readForGoogleSub(googleSub: string): Promise; + replaceForGoogleSub(googleSub: string, data: FinanceDataV1): Promise; + replaceForSoleOwner(data: FinanceDataV1): Promise; } export type FinanceDataIntegrityReason = 'invalid_integer' | 'invalid_shape' | 'missing_current_snapshot'; +export type FinanceOwnerMappingReason = 'missing' | 'ambiguous'; + +/** Sanitized operator error: no external subject or financial value is included. */ +export class FinanceOwnerMappingError extends Error { + readonly code = 'finance_owner_mapping_error'; + readonly reason: FinanceOwnerMappingReason; + + constructor(reason: FinanceOwnerMappingReason) { + super(reason === 'missing' + ? 'No verified owner exists. Sign in to accura before importing.' + : 'More than one verified owner exists. The single-owner import is ambiguous.'); + this.name = 'FinanceOwnerMappingError'; + this.reason = reason; + } +} + /** Sanitized internal error: it deliberately carries no row, entity ID, or financial value. */ export class FinanceDataIntegrityError extends Error { readonly code = 'finance_data_integrity_error'; + readonly reason: FinanceDataIntegrityReason; - constructor(readonly reason: FinanceDataIntegrityReason) { + constructor(reason: FinanceDataIntegrityReason) { super('Stored finance data failed integrity validation.'); this.name = 'FinanceDataIntegrityError'; + this.reason = reason; } } @@ -112,6 +133,389 @@ function hasCurrentSnapshot( return snapshots.some((snapshot) => getId(snapshot) === id && getDate(snapshot) <= asOf); } +function milestonePrecision(date: string): { date: string; precision: 'month' | 'day' } { + return date.length === 7 + ? { date: `${date}-01`, precision: 'month' } + : { date, precision: 'day' }; +} + +function assertImportable(data: FinanceDataV1): FinanceDataV1 { + const parsed = financeDataV1Schema.safeParse(data); + if (!parsed.success) throw new FinanceDataIntegrityError('invalid_shape'); + assertCurrentSnapshots(parsed.data); + return parsed.data; +} + +async function resolveOwnerId( + transaction: postgres.TransactionSql, + googleSub: string, + create: boolean, +): Promise { + if (create) { + const created = await transaction` + INSERT INTO owners (google_sub) + VALUES (${googleSub}) + ON CONFLICT (google_sub) DO UPDATE SET google_sub = owners.google_sub + RETURNING id + `; + return created[0]?.id ?? null; + } + const owners = await transaction` + SELECT id + FROM owners + WHERE google_sub = ${googleSub} + LIMIT 1 + `; + return owners[0]?.id ?? null; +} + +async function writeOwnerFinance( + transaction: postgres.TransactionSql, + ownerId: string, + data: FinanceDataV1, +): Promise { + await transaction`DELETE FROM relief_milestones WHERE owner_id = ${ownerId}`; + await transaction`DELETE FROM debt_milestones WHERE owner_id = ${ownerId}`; + await transaction`DELETE FROM debt_snapshots WHERE owner_id = ${ownerId}`; + await transaction`DELETE FROM pocket_snapshots WHERE owner_id = ${ownerId}`; + await transaction`DELETE FROM account_snapshots WHERE owner_id = ${ownerId}`; + await transaction`DELETE FROM pockets WHERE owner_id = ${ownerId}`; + await transaction`DELETE FROM debts WHERE owner_id = ${ownerId}`; + await transaction`DELETE FROM budget_items WHERE owner_id = ${ownerId}`; + await transaction`DELETE FROM accounts WHERE owner_id = ${ownerId}`; + await transaction`DELETE FROM finance_meta WHERE owner_id = ${ownerId}`; + + await transaction` + INSERT INTO finance_meta ( + owner_id, schema_version, as_of, currency, monthly_income_cents, salary_day + ) VALUES ( + ${ownerId}, ${data.schemaVersion}, ${data.asOf}, ${data.currency}, + ${data.monthlyIncomeCents}, ${data.salaryDay} + ) + `; + if (data.accounts.length > 0) { + const rows = data.accounts.map((account) => ({ + owner_id: ownerId, + id: account.id, + name: account.name, + kind: account.kind, + display_order: account.displayOrder, + active: account.active, + })); + await transaction` + INSERT INTO accounts ${transaction(rows, 'owner_id', 'id', 'name', 'kind', 'display_order', 'active')} + `; + } + if (data.accountSnapshots.length > 0) { + const rows = data.accountSnapshots.map((snapshot) => ({ + owner_id: ownerId, + account_id: snapshot.accountId, + as_of: snapshot.asOf, + balance_cents: snapshot.balanceCents, + })); + await transaction` + INSERT INTO account_snapshots ${transaction(rows, 'owner_id', 'account_id', 'as_of', 'balance_cents')} + `; + } + if (data.pockets.length > 0) { + const rows = data.pockets.map((pocket) => ({ + owner_id: ownerId, + id: pocket.id, + account_id: pocket.accountId, + name: pocket.name, + display_order: pocket.displayOrder, + active: pocket.active, + })); + await transaction` + INSERT INTO pockets ${transaction(rows, 'owner_id', 'id', 'account_id', 'name', 'display_order', 'active')} + `; + } + if (data.pocketSnapshots.length > 0) { + const rows = data.pocketSnapshots.map((snapshot) => ({ + owner_id: ownerId, + pocket_id: snapshot.pocketId, + as_of: snapshot.asOf, + balance_cents: snapshot.balanceCents, + })); + await transaction` + INSERT INTO pocket_snapshots ${transaction(rows, 'owner_id', 'pocket_id', 'as_of', 'balance_cents')} + `; + } + if (data.budgetItems.length > 0) { + const rows = data.budgetItems.map((item) => ({ + owner_id: ownerId, + id: item.id, + label: item.label, + monthly_amount_cents: item.monthlyAmountCents, + necessity_id: item.necessityId, + kind: item.kind, + display_order: item.displayOrder, + active: item.active, + note: item.note, + due_day: item.dueDay, + })); + await transaction` + INSERT INTO budget_items ${transaction( + rows, + 'owner_id', 'id', 'label', 'monthly_amount_cents', 'necessity_id', + 'kind', 'display_order', 'active', 'note', 'due_day', + )} + `; + } + if (data.debts.length > 0) { + const rows = data.debts.map((debt) => ({ + owner_id: ownerId, + id: debt.id, + name: debt.name, + kind: debt.kind, + monthly_payment_cents: debt.monthlyPaymentCents, + display_order: debt.displayOrder, + active: debt.active, + note: debt.note, + due_day: debt.dueDay, + })); + await transaction` + INSERT INTO debts ${transaction( + rows, + 'owner_id', 'id', 'name', 'kind', 'monthly_payment_cents', + 'display_order', 'active', 'note', 'due_day', + )} + `; + } + if (data.debtSnapshots.length > 0) { + const rows = data.debtSnapshots.map((snapshot) => ({ + owner_id: ownerId, + debt_id: snapshot.debtId, + as_of: snapshot.asOf, + payoff_balance_cents: snapshot.payoffBalanceCents, + remaining_payment_count: snapshot.remainingPaymentCount, + remaining_scheduled_total_cents: snapshot.remainingScheduledTotalCents, + })); + await transaction` + INSERT INTO debt_snapshots ${transaction( + rows, + 'owner_id', 'debt_id', 'as_of', 'payoff_balance_cents', + 'remaining_payment_count', 'remaining_scheduled_total_cents', + )} + `; + } + if (data.debtMilestones.length > 0) { + const rows = data.debtMilestones.map((milestone) => { + const { date, precision } = milestonePrecision(milestone.date); + return { + owner_id: ownerId, + debt_id: milestone.debtId, + milestone_date: date, + date_precision: precision, + balance_cents: milestone.balanceCents, + }; + }); + await transaction` + INSERT INTO debt_milestones ${transaction( + rows, + 'owner_id', 'debt_id', 'milestone_date', 'date_precision', 'balance_cents', + )} + `; + } + if (data.reliefMilestones.length > 0) { + const rows = data.reliefMilestones.map((milestone) => { + const { date, precision } = milestonePrecision(milestone.date); + return { + owner_id: ownerId, + milestone_date: date, + date_precision: precision, + monthly_relief_cents: milestone.monthlyReliefCents, + event: milestone.event, + event_detail: milestone.eventDetail, + }; + }); + await transaction` + INSERT INTO relief_milestones ${transaction( + rows, + 'owner_id', 'milestone_date', 'date_precision', + 'monthly_relief_cents', 'event', 'event_detail', + )} + `; + } +} + +async function readOwnerFinance( + transaction: postgres.TransactionSql, + ownerId: string, +): Promise { + const metaRows = await transaction` + SELECT schema_version, + TO_CHAR(as_of, 'YYYY-MM-DD') AS as_of, + currency, + monthly_income_cents, + salary_day + FROM finance_meta + WHERE owner_id = ${ownerId} + LIMIT 1 + `; + const meta = metaRows[0]; + if (!meta) return null; + + const accountRows = await transaction` + SELECT id, name, kind, display_order, active + FROM accounts + WHERE owner_id = ${ownerId} + ORDER BY display_order, id + `; + const accountSnapshotRows = await transaction` + SELECT snapshots.account_id, + TO_CHAR(snapshots.as_of, 'YYYY-MM-DD') AS as_of, + snapshots.balance_cents + FROM account_snapshots AS snapshots + JOIN accounts AS accounts + ON accounts.owner_id = snapshots.owner_id AND accounts.id = snapshots.account_id + WHERE snapshots.owner_id = ${ownerId} + ORDER BY accounts.display_order, accounts.id, snapshots.as_of + `; + const pocketRows = await transaction` + SELECT id, account_id, name, display_order, active + FROM pockets + WHERE owner_id = ${ownerId} + ORDER BY display_order, id + `; + const pocketSnapshotRows = await transaction` + SELECT snapshots.pocket_id, + TO_CHAR(snapshots.as_of, 'YYYY-MM-DD') AS as_of, + snapshots.balance_cents + FROM pocket_snapshots AS snapshots + JOIN pockets AS pockets + ON pockets.owner_id = snapshots.owner_id AND pockets.id = snapshots.pocket_id + WHERE snapshots.owner_id = ${ownerId} + ORDER BY pockets.display_order, pockets.id, snapshots.as_of + `; + const budgetItemRows = await transaction` + SELECT id, label, monthly_amount_cents, necessity_id, kind, display_order, + active, note, due_day + FROM budget_items + WHERE owner_id = ${ownerId} + ORDER BY display_order, id + `; + const debtRows = await transaction` + SELECT id, name, kind, monthly_payment_cents, display_order, active, note, due_day + FROM debts + WHERE owner_id = ${ownerId} + ORDER BY display_order, id + `; + const debtSnapshotRows = await transaction` + SELECT snapshots.debt_id, + TO_CHAR(snapshots.as_of, 'YYYY-MM-DD') AS as_of, + snapshots.payoff_balance_cents, + snapshots.remaining_payment_count, + snapshots.remaining_scheduled_total_cents + FROM debt_snapshots AS snapshots + JOIN debts AS debts + ON debts.owner_id = snapshots.owner_id AND debts.id = snapshots.debt_id + WHERE snapshots.owner_id = ${ownerId} + ORDER BY debts.display_order, debts.id, snapshots.as_of + `; + const debtMilestoneRows = await transaction` + SELECT milestones.debt_id, + CASE milestones.date_precision + WHEN 'month' THEN TO_CHAR(milestones.milestone_date, 'YYYY-MM') + ELSE TO_CHAR(milestones.milestone_date, 'YYYY-MM-DD') + END AS date, + milestones.balance_cents + FROM debt_milestones AS milestones + JOIN debts AS debts + ON debts.owner_id = milestones.owner_id AND debts.id = milestones.debt_id + WHERE milestones.owner_id = ${ownerId} + ORDER BY milestones.milestone_date, debts.display_order, debts.id, milestones.date_precision + `; + const reliefMilestoneRows = await transaction` + SELECT CASE date_precision + WHEN 'month' THEN TO_CHAR(milestone_date, 'YYYY-MM') + ELSE TO_CHAR(milestone_date, 'YYYY-MM-DD') + END AS date, + monthly_relief_cents, + event, + event_detail + FROM relief_milestones + WHERE owner_id = ${ownerId} + ORDER BY milestone_date, event, event_detail NULLS FIRST, id + `; + + const candidate = { + schemaVersion: meta.schema_version, + asOf: meta.as_of, + currency: meta.currency, + monthlyIncomeCents: safeInteger(meta.monthly_income_cents), + salaryDay: meta.salary_day, + accounts: accountRows.map((row) => ({ + id: row.id, + name: row.name, + kind: row.kind, + displayOrder: safeInteger(row.display_order), + active: row.active, + })), + accountSnapshots: accountSnapshotRows.map((row) => ({ + accountId: row.account_id, + asOf: row.as_of, + balanceCents: safeInteger(row.balance_cents), + })), + pockets: pocketRows.map((row) => ({ + id: row.id, + name: row.name, + accountId: row.account_id, + displayOrder: safeInteger(row.display_order), + active: row.active, + })), + pocketSnapshots: pocketSnapshotRows.map((row) => ({ + pocketId: row.pocket_id, + asOf: row.as_of, + balanceCents: safeInteger(row.balance_cents), + })), + budgetItems: budgetItemRows.map((row) => ({ + id: row.id, + label: row.label, + monthlyAmountCents: safeInteger(row.monthly_amount_cents), + necessityId: row.necessity_id, + kind: row.kind, + displayOrder: safeInteger(row.display_order), + active: row.active, + note: row.note, + dueDay: row.due_day, + })), + debts: debtRows.map((row) => ({ + id: row.id, + name: row.name, + kind: row.kind, + monthlyPaymentCents: safeInteger(row.monthly_payment_cents), + displayOrder: safeInteger(row.display_order), + active: row.active, + note: row.note, + dueDay: row.due_day, + })), + debtSnapshots: debtSnapshotRows.map((row) => ({ + debtId: row.debt_id, + asOf: row.as_of, + payoffBalanceCents: safeInteger(row.payoff_balance_cents), + remainingPaymentCount: safeInteger(row.remaining_payment_count), + remainingScheduledTotalCents: safeInteger(row.remaining_scheduled_total_cents), + })), + debtMilestones: debtMilestoneRows.map((row) => ({ + debtId: row.debt_id, + date: row.date, + balanceCents: safeInteger(row.balance_cents), + })), + reliefMilestones: reliefMilestoneRows.map((row) => ({ + date: row.date, + monthlyReliefCents: safeInteger(row.monthly_relief_cents), + event: row.event, + eventDetail: row.event_detail, + })), + }; + + const result = financeDataV1Schema.safeParse(candidate); + if (!result.success) throw new FinanceDataIntegrityError('invalid_shape'); + assertCurrentSnapshots(result.data); + return result.data; +} + function assertCurrentSnapshots(data: FinanceDataV1): void { const accountSnapshotsValid = data.accounts .filter(({ active }) => active) @@ -129,190 +533,57 @@ function assertCurrentSnapshots(data: FinanceDataV1): void { } export class PostgresFinanceRepository implements FinanceRepository { - constructor(private readonly sql: postgres.Sql) {} + private readonly sql: postgres.Sql; + + constructor(sql: postgres.Sql) { + this.sql = sql; + } + + async ensureOwnerForGoogleSub(googleSub: string): Promise { + await this.sql` + INSERT INTO owners (google_sub) + VALUES (${googleSub}) + ON CONFLICT (google_sub) DO NOTHING + `; + } async readForGoogleSub(googleSub: string): Promise { return this.sql.begin('read only isolation level repeatable read', async (transaction) => { - const owners = await transaction` - SELECT id - FROM owners - WHERE google_sub = ${googleSub} - LIMIT 1 - `; - const ownerId = owners[0]?.id; + const ownerId = await resolveOwnerId(transaction, googleSub, false); if (!ownerId) return null; + return readOwnerFinance(transaction, ownerId); + }); + } - const metaRows = await transaction` - SELECT schema_version, - TO_CHAR(as_of, 'YYYY-MM-DD') AS as_of, - currency, - monthly_income_cents, - salary_day - FROM finance_meta - WHERE owner_id = ${ownerId} - LIMIT 1 - `; - const meta = metaRows[0]; - if (!meta) return null; + async replaceForGoogleSub(googleSub: string, data: FinanceDataV1): Promise { + const importable = assertImportable(data); + return this.sql.begin(async (transaction) => { + const ownerId = await resolveOwnerId(transaction, googleSub, true); + if (!ownerId) throw new FinanceDataIntegrityError('invalid_shape'); + await writeOwnerFinance(transaction, ownerId, importable); + const stored = await readOwnerFinance(transaction, ownerId); + if (!stored) throw new FinanceDataIntegrityError('invalid_shape'); + return stored; + }); + } - const accountRows = await transaction` - SELECT id, name, kind, display_order, active - FROM accounts - WHERE owner_id = ${ownerId} - ORDER BY display_order, id - `; - const accountSnapshotRows = await transaction` - SELECT snapshots.account_id, - TO_CHAR(snapshots.as_of, 'YYYY-MM-DD') AS as_of, - snapshots.balance_cents - FROM account_snapshots AS snapshots - JOIN accounts AS accounts - ON accounts.owner_id = snapshots.owner_id AND accounts.id = snapshots.account_id - WHERE snapshots.owner_id = ${ownerId} - ORDER BY accounts.display_order, accounts.id, snapshots.as_of - `; - const pocketRows = await transaction` - SELECT id, account_id, name, display_order, active - FROM pockets - WHERE owner_id = ${ownerId} - ORDER BY display_order, id - `; - const pocketSnapshotRows = await transaction` - SELECT snapshots.pocket_id, - TO_CHAR(snapshots.as_of, 'YYYY-MM-DD') AS as_of, - snapshots.balance_cents - FROM pocket_snapshots AS snapshots - JOIN pockets AS pockets - ON pockets.owner_id = snapshots.owner_id AND pockets.id = snapshots.pocket_id - WHERE snapshots.owner_id = ${ownerId} - ORDER BY pockets.display_order, pockets.id, snapshots.as_of - `; - const budgetItemRows = await transaction` - SELECT id, label, monthly_amount_cents, necessity_id, kind, display_order, - active, note, due_day - FROM budget_items - WHERE owner_id = ${ownerId} - ORDER BY display_order, id - `; - const debtRows = await transaction` - SELECT id, name, kind, monthly_payment_cents, display_order, active, note, due_day - FROM debts - WHERE owner_id = ${ownerId} - ORDER BY display_order, id - `; - const debtSnapshotRows = await transaction` - SELECT snapshots.debt_id, - TO_CHAR(snapshots.as_of, 'YYYY-MM-DD') AS as_of, - snapshots.payoff_balance_cents, - snapshots.remaining_payment_count, - snapshots.remaining_scheduled_total_cents - FROM debt_snapshots AS snapshots - JOIN debts AS debts - ON debts.owner_id = snapshots.owner_id AND debts.id = snapshots.debt_id - WHERE snapshots.owner_id = ${ownerId} - ORDER BY debts.display_order, debts.id, snapshots.as_of - `; - const debtMilestoneRows = await transaction` - SELECT milestones.debt_id, - CASE milestones.date_precision - WHEN 'month' THEN TO_CHAR(milestones.milestone_date, 'YYYY-MM') - ELSE TO_CHAR(milestones.milestone_date, 'YYYY-MM-DD') - END AS date, - milestones.balance_cents - FROM debt_milestones AS milestones - JOIN debts AS debts - ON debts.owner_id = milestones.owner_id AND debts.id = milestones.debt_id - WHERE milestones.owner_id = ${ownerId} - ORDER BY milestones.milestone_date, debts.display_order, debts.id, milestones.date_precision - `; - const reliefMilestoneRows = await transaction` - SELECT CASE date_precision - WHEN 'month' THEN TO_CHAR(milestone_date, 'YYYY-MM') - ELSE TO_CHAR(milestone_date, 'YYYY-MM-DD') - END AS date, - monthly_relief_cents, - event, - event_detail - FROM relief_milestones - WHERE owner_id = ${ownerId} - ORDER BY milestone_date, event, event_detail NULLS FIRST, id + async replaceForSoleOwner(data: FinanceDataV1): Promise { + const importable = assertImportable(data); + return this.sql.begin(async (transaction) => { + const owners = await transaction` + SELECT id + FROM owners + ORDER BY id + LIMIT 2 + FOR UPDATE `; - - const candidate = { - schemaVersion: meta.schema_version, - asOf: meta.as_of, - currency: meta.currency, - monthlyIncomeCents: safeInteger(meta.monthly_income_cents), - salaryDay: meta.salary_day, - accounts: accountRows.map((row) => ({ - id: row.id, - name: row.name, - kind: row.kind, - displayOrder: safeInteger(row.display_order), - active: row.active, - })), - accountSnapshots: accountSnapshotRows.map((row) => ({ - accountId: row.account_id, - asOf: row.as_of, - balanceCents: safeInteger(row.balance_cents), - })), - pockets: pocketRows.map((row) => ({ - id: row.id, - accountId: row.account_id, - name: row.name, - displayOrder: safeInteger(row.display_order), - active: row.active, - })), - pocketSnapshots: pocketSnapshotRows.map((row) => ({ - pocketId: row.pocket_id, - asOf: row.as_of, - balanceCents: safeInteger(row.balance_cents), - })), - budgetItems: budgetItemRows.map((row) => ({ - id: row.id, - label: row.label, - monthlyAmountCents: safeInteger(row.monthly_amount_cents), - necessityId: row.necessity_id, - kind: row.kind, - displayOrder: safeInteger(row.display_order), - active: row.active, - note: row.note, - dueDay: row.due_day, - })), - debts: debtRows.map((row) => ({ - id: row.id, - name: row.name, - kind: row.kind, - monthlyPaymentCents: safeInteger(row.monthly_payment_cents), - displayOrder: safeInteger(row.display_order), - active: row.active, - note: row.note, - dueDay: row.due_day, - })), - debtSnapshots: debtSnapshotRows.map((row) => ({ - debtId: row.debt_id, - asOf: row.as_of, - payoffBalanceCents: safeInteger(row.payoff_balance_cents), - remainingPaymentCount: safeInteger(row.remaining_payment_count), - remainingScheduledTotalCents: safeInteger(row.remaining_scheduled_total_cents), - })), - debtMilestones: debtMilestoneRows.map((row) => ({ - debtId: row.debt_id, - date: row.date, - balanceCents: safeInteger(row.balance_cents), - })), - reliefMilestones: reliefMilestoneRows.map((row) => ({ - date: row.date, - monthlyReliefCents: safeInteger(row.monthly_relief_cents), - event: row.event, - eventDetail: row.event_detail, - })), - }; - - const result = financeDataV1Schema.safeParse(candidate); - if (!result.success) throw new FinanceDataIntegrityError('invalid_shape'); - assertCurrentSnapshots(result.data); - return result.data; + if (owners.length === 0) throw new FinanceOwnerMappingError('missing'); + if (owners.length !== 1) throw new FinanceOwnerMappingError('ambiguous'); + const ownerId = owners[0]!.id; + await writeOwnerFinance(transaction, ownerId, importable); + const stored = await readOwnerFinance(transaction, ownerId); + if (!stored) throw new FinanceDataIntegrityError('invalid_shape'); + return stored; }); } } diff --git a/api/_lib/financeService.ts b/api/_lib/financeService.ts deleted file mode 100644 index 8d937fe..0000000 --- a/api/_lib/financeService.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { parseSheetsBatchResponse } from '../../src/finance/parser.js'; -import type { FinanceDataV1 } from '../../src/finance/types.js'; -import type { ServerConfig } from './config.js'; -import { AppError } from './errors.js'; -import { readGoogleFinanceWorkbook, refreshGoogleAccessToken, validateGoogleSpreadsheet } from './google.js'; -import type { ConnectionRepository, GoogleConnection } from './repository.js'; -import { decryptRefreshToken } from './security.js'; - -type Fetch = typeof fetch; - -export async function accessForConnection(config: ServerConfig, connection: GoogleConnection, fetchImpl: Fetch = fetch) { - const refreshToken = decryptRefreshToken(connection.encryptedRefreshToken, config.tokenEncryptionKey, connection.googleSub); - return refreshGoogleAccessToken(config, refreshToken, fetchImpl); -} - -export async function readValidatedFinanceData(config: ServerConfig, connection: GoogleConnection, spreadsheetId: string, fetchImpl: Fetch = fetch): Promise { - const { accessToken } = await accessForConnection(config, connection, fetchImpl); - const response = await readGoogleFinanceWorkbook(accessToken, spreadsheetId, fetchImpl); - const parsed = parseSheetsBatchResponse(response); - if (!parsed.success) { - throw new AppError('invalid_finance_schema', 422, 'Die Tabelle entspricht nicht Finance Data Schema v1.', { issues: parsed.issues }); - } - return parsed.data; -} - -export async function validateAndSaveSpreadsheet( - config: ServerConfig, - repository: ConnectionRepository, - connection: GoogleConnection, - spreadsheetId: string, - fetchImpl: Fetch = fetch, -) { - const { accessToken } = await accessForConnection(config, connection, fetchImpl); - const file = await validateGoogleSpreadsheet(accessToken, spreadsheetId, fetchImpl); - const response = await readGoogleFinanceWorkbook(accessToken, file.id, fetchImpl); - const parsed = parseSheetsBatchResponse(response); - if (!parsed.success) { - throw new AppError('invalid_finance_schema', 422, 'Die Tabelle entspricht nicht Finance Data Schema v1.', { issues: parsed.issues }); - } - await repository.saveSpreadsheet(connection.googleSub, file.id, file.name); - return { file, data: parsed.data }; -} diff --git a/api/_lib/google.ts b/api/_lib/google.ts index 84dd227..5c813f8 100644 --- a/api/_lib/google.ts +++ b/api/_lib/google.ts @@ -1,14 +1,11 @@ import { createRemoteJWKSet, jwtVerify } from 'jose'; -import { GOOGLE_SHEETS_RANGES } from '../../src/finance/schema.js'; -import type { RawSheetsBatchResponse } from '../../src/finance/types.js'; import type { ServerConfig } from './config.js'; -import { AppError, ReconnectRequiredError } from './errors.js'; +import { AppError } from './errors.js'; export const REQUIRED_GOOGLE_SCOPES = [ 'openid', 'email', 'profile', - 'https://www.googleapis.com/auth/drive.file', ] as const; type Fetch = typeof fetch; @@ -19,9 +16,8 @@ export function buildGoogleAuthorizationUrl(config: ServerConfig, input: { state redirect_uri: config.googleOAuthRedirectUri, response_type: 'code', scope: REQUIRED_GOOGLE_SCOPES.join(' '), - access_type: 'offline', include_granted_scopes: 'false', - prompt: 'consent select_account', + prompt: 'select_account', state: input.state, nonce: input.nonce, code_challenge: input.challenge, @@ -45,10 +41,10 @@ export async function exchangeAuthorizationCode(config: ServerConfig, code: stri }); if (!response.ok) throw new AppError('oauth_exchange_failed', 401, 'Google-Anmeldung konnte nicht abgeschlossen werden.'); const body = await response.json() as Record; - if (typeof body.refresh_token !== 'string' || typeof body.id_token !== 'string' || typeof body.scope !== 'string') { - throw new AppError('refresh_token_missing', 401, 'Google hat kein Offline-Zugriffstoken ausgestellt. Bitte erneut verbinden.'); + if (typeof body.id_token !== 'string' || typeof body.scope !== 'string') { + throw new AppError('invalid_google_identity', 401, 'Google-Identität konnte nicht verifiziert werden.'); } - return { refreshToken: body.refresh_token, idToken: body.id_token, scopes: body.scope.split(' ').filter(Boolean) }; + return { idToken: body.id_token, scopes: body.scope.split(' ').filter(Boolean) }; } const googleJwks = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs')); @@ -63,63 +59,3 @@ export async function verifyGoogleIdentity(idToken: string, clientId: string, no } return { sub: payload.sub, email: payload.email, emailVerified: payload.email_verified === true }; } - -export async function refreshGoogleAccessToken(config: ServerConfig, refreshToken: string, fetchImpl: Fetch = fetch) { - const response = await fetchImpl('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: config.googleClientId, - client_secret: config.googleClientSecret, - refresh_token: refreshToken, - grant_type: 'refresh_token', - }), - }); - const body = await response.json().catch(() => ({})) as Record; - if (!response.ok) { - if (body.error === 'invalid_grant') throw new ReconnectRequiredError(); - throw new AppError('google_token_error', 502, 'Google-Zugriff konnte nicht erneuert werden.'); - } - if (typeof body.access_token !== 'string') throw new AppError('google_token_error', 502, 'Google hat kein Zugriffstoken geliefert.'); - return { accessToken: body.access_token, expiresIn: typeof body.expires_in === 'number' ? body.expires_in : 3600 }; -} - -export async function revokeGoogleToken(refreshToken: string, fetchImpl: Fetch = fetch) { - try { - await fetchImpl('https://oauth2.googleapis.com/revoke', { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ token: refreshToken }), - }); - } catch { - // Revocation is best-effort; local deletion must still complete. - } -} - -export type GoogleDriveFile = { id: string; name: string; mimeType: string; trashed?: boolean }; - -export async function validateGoogleSpreadsheet(accessToken: string, fileId: string, fetchImpl: Fetch = fetch): Promise { - const url = new URL(`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}`); - url.searchParams.set('fields', 'id,name,mimeType,trashed'); - url.searchParams.set('supportsAllDrives', 'true'); - const response = await fetchImpl(url, { headers: { authorization: `Bearer ${accessToken}` } }); - if (!response.ok) throw new AppError('spreadsheet_inaccessible', 400, 'Die ausgewählte Tabelle ist nicht zugänglich.'); - const file = await response.json() as GoogleDriveFile; - if (file.trashed || file.mimeType !== 'application/vnd.google-apps.spreadsheet') { - throw new AppError('invalid_spreadsheet_type', 400, 'Bitte eine aktive Google-Sheets-Tabelle auswählen.'); - } - return file; -} - -export async function readGoogleFinanceWorkbook(accessToken: string, spreadsheetId: string, fetchImpl: Fetch = fetch): Promise { - const url = new URL(`https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}/values:batchGet`); - GOOGLE_SHEETS_RANGES.forEach((range) => url.searchParams.append('ranges', range)); - url.searchParams.set('majorDimension', 'ROWS'); - url.searchParams.set('valueRenderOption', 'UNFORMATTED_VALUE'); - url.searchParams.set('dateTimeRenderOption', 'FORMATTED_STRING'); - url.searchParams.set('fields', 'valueRanges(range,values)'); - const response = await fetchImpl(url, { headers: { authorization: `Bearer ${accessToken}` } }); - if (response.status === 401) throw new ReconnectRequiredError(); - if (!response.ok) throw new AppError('sheets_read_failed', 502, 'Google Sheets konnte nicht gelesen werden.'); - return response.json() as Promise; -} diff --git a/api/_lib/http.ts b/api/_lib/http.ts index 52c7a39..464e2be 100644 --- a/api/_lib/http.ts +++ b/api/_lib/http.ts @@ -2,12 +2,10 @@ import type { VercelRequest, VercelResponse } from '@vercel/node'; import type { ServerConfig } from './config.js'; import { getServerConfig } from './config.js'; import { AppError, publicError } from './errors.js'; -import { getConnectionRepository, type ConnectionRepository } from './repository.js'; import { assertCsrf, parseCookies, SESSION_COOKIE, verifySession, type AppSession } from './security.js'; export type HandlerContext = { config: ServerConfig; - repository: ConnectionRepository; session: AppSession; }; @@ -36,7 +34,7 @@ export function authenticated(request: VercelRequest, csrfRequired = false): Han const origin = Array.isArray(request.headers.origin) ? request.headers.origin[0] : request.headers.origin; assertCsrf(session, csrfHeader, origin, config.appOrigin); } - return { config, repository: getConnectionRepository(config.databaseUrl), session }; + return { config, session }; } export async function handle(response: VercelResponse, task: () => Promise) { diff --git a/api/_lib/repository.ts b/api/_lib/repository.ts deleted file mode 100644 index 7d1f999..0000000 --- a/api/_lib/repository.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type postgres from 'postgres'; -import { getDatabase } from './database.js'; - -export type GoogleConnection = { - googleSub: string; - email: string; - encryptedRefreshToken: string; - scopes: string[]; - spreadsheetId: string | null; - spreadsheetName: string | null; - createdAt: Date; - updatedAt: Date; - tokenUpdatedAt: Date; - spreadsheetUpdatedAt: Date | null; -}; - -export interface ConnectionRepository { - get(googleSub: string): Promise; - upsertAuthorization(input: { googleSub: string; email: string; encryptedRefreshToken: string; scopes: string[] }): Promise; - saveSpreadsheet(googleSub: string, spreadsheetId: string, spreadsheetName: string): Promise; - delete(googleSub: string): Promise; -} - -type ConnectionRow = { - google_sub: string; - verified_email: string; - encrypted_refresh_token: string; - granted_scopes: string[]; - spreadsheet_id: string | null; - spreadsheet_name: string | null; - created_at: Date; - updated_at: Date; - token_updated_at: Date; - spreadsheet_updated_at: Date | null; -}; - -const mapRow = (row: ConnectionRow): GoogleConnection => ({ - googleSub: row.google_sub, - email: row.verified_email, - encryptedRefreshToken: row.encrypted_refresh_token, - scopes: row.granted_scopes, - spreadsheetId: row.spreadsheet_id, - spreadsheetName: row.spreadsheet_name, - createdAt: new Date(row.created_at), - updatedAt: new Date(row.updated_at), - tokenUpdatedAt: new Date(row.token_updated_at), - spreadsheetUpdatedAt: row.spreadsheet_updated_at ? new Date(row.spreadsheet_updated_at) : null, -}); - -export class PostgresConnectionRepository implements ConnectionRepository { - constructor(private readonly sql: postgres.Sql) {} - - async get(googleSub: string) { - const rows = await this.sql` - SELECT google_sub, verified_email, encrypted_refresh_token, granted_scopes, - spreadsheet_id, spreadsheet_name, created_at, updated_at, - token_updated_at, spreadsheet_updated_at - FROM google_connections - WHERE google_sub = ${googleSub} - LIMIT 1 - `; - return rows[0] ? mapRow(rows[0]) : null; - } - - async upsertAuthorization(input: { googleSub: string; email: string; encryptedRefreshToken: string; scopes: string[] }) { - const rows = await this.sql` - INSERT INTO google_connections ( - google_sub, verified_email, encrypted_refresh_token, granted_scopes, token_updated_at - ) VALUES ( - ${input.googleSub}, ${input.email.toLowerCase()}, ${input.encryptedRefreshToken}, ${input.scopes}, NOW() - ) - ON CONFLICT (google_sub) DO UPDATE SET - verified_email = EXCLUDED.verified_email, - encrypted_refresh_token = EXCLUDED.encrypted_refresh_token, - granted_scopes = EXCLUDED.granted_scopes, - token_updated_at = NOW(), - updated_at = NOW() - RETURNING google_sub, verified_email, encrypted_refresh_token, granted_scopes, - spreadsheet_id, spreadsheet_name, created_at, updated_at, - token_updated_at, spreadsheet_updated_at - `; - return mapRow(rows[0]!); - } - - async saveSpreadsheet(googleSub: string, spreadsheetId: string, spreadsheetName: string) { - await this.sql` - UPDATE google_connections - SET spreadsheet_id = ${spreadsheetId}, spreadsheet_name = ${spreadsheetName}, - spreadsheet_updated_at = NOW(), updated_at = NOW() - WHERE google_sub = ${googleSub} - `; - } - - async delete(googleSub: string) { - await this.sql`DELETE FROM google_connections WHERE google_sub = ${googleSub}`; - } -} - -const repositories = new Map(); - -export function getConnectionRepository(databaseUrl: string): ConnectionRepository { - const existing = repositories.get(databaseUrl); - if (existing) return existing; - - const repository = new PostgresConnectionRepository(getDatabase(databaseUrl)); - repositories.set(databaseUrl, repository); - return repository; -} diff --git a/api/_lib/security.ts b/api/_lib/security.ts index 8b8ef81..fa184bb 100644 --- a/api/_lib/security.ts +++ b/api/_lib/security.ts @@ -1,4 +1,4 @@ -import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import { z } from 'zod'; import { isAppPath, type AppPath } from '../../src/navigation/appNavigation.js'; import { AppError } from './errors.js'; @@ -77,6 +77,14 @@ export function verifySession(token: string, secret: string, now = Date.now()): return parsed.data; } +/** Returns a stable pseudonymous browser-cache partition without exposing the Google subject. */ +export function financeCacheOwnerKey(sub: string, secret: string): string { + return createHmac('sha256', secret) + .update('accura-finance-cache-owner-v1\0') + .update(sub) + .digest('base64url'); +} + export function createOAuthTransaction( secret: string, returnPath: AppPath = '/', @@ -145,25 +153,3 @@ export function assertCsrf(session: AppSession, csrfHeader: string | undefined, } } -function encryptionKey(key: string) { - const decoded = Buffer.from(key, 'base64'); - if (decoded.length !== 32) throw new Error('TOKEN_ENCRYPTION_KEY must be a base64-encoded 32-byte key.'); - return decoded; -} - -export function encryptRefreshToken(token: string, key: string, googleSub: string) { - const iv = randomBytes(12); - const cipher = createCipheriv('aes-256-gcm', encryptionKey(key), iv); - cipher.setAAD(Buffer.from(`finance-google-token:${googleSub}`, 'utf8')); - const encrypted = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]); - return `v1.${iv.toString('base64url')}.${encrypted.toString('base64url')}.${cipher.getAuthTag().toString('base64url')}`; -} - -export function decryptRefreshToken(value: string, key: string, googleSub: string) { - const [version, iv, encrypted, tag, extra] = value.split('.'); - if (version !== 'v1' || !iv || !encrypted || !tag || extra) throw new Error('Unsupported encrypted token format.'); - const decipher = createDecipheriv('aes-256-gcm', encryptionKey(key), Buffer.from(iv, 'base64url')); - decipher.setAAD(Buffer.from(`finance-google-token:${googleSub}`, 'utf8')); - decipher.setAuthTag(Buffer.from(tag, 'base64url')); - return Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64url')), decipher.final()]).toString('utf8'); -} diff --git a/api/auth/google/callback.ts b/api/auth/google/callback.ts index e4c8cba..49edd0a 100644 --- a/api/auth/google/callback.ts +++ b/api/auth/google/callback.ts @@ -2,12 +2,11 @@ import type { VercelRequest, VercelResponse } from '@vercel/node'; import { getServerConfig } from '../../_lib/config.js'; import { AppError } from '../../_lib/errors.js'; import { exchangeAuthorizationCode, verifyGoogleIdentity } from '../../_lib/google.js'; +import { getFinanceRepository } from '../../_lib/financeRepository.js'; import { method } from '../../_lib/http.js'; -import { getConnectionRepository } from '../../_lib/repository.js'; import { clearCookie, createSession, - encryptRefreshToken, isAllowedGoogleUser, OAUTH_COOKIE, parseCookies, @@ -45,28 +44,19 @@ export default async function handler(request: VercelRequest, response: VercelRe throw new AppError('oauth_callback_failed', 400, 'Google-Anmeldung wurde abgebrochen oder ist unvollständig.'); } const tokens = await exchangeAuthorizationCode(config, code, transaction.verifier); - if (!tokens.scopes.includes('https://www.googleapis.com/auth/drive.file')) { - throw new AppError('missing_required_scope', 403, 'Der erforderliche Zugriff auf ausgewählte Drive-Dateien wurde nicht erteilt.'); - } const identity = await verifyGoogleIdentity(tokens.idToken, config.googleClientId, transaction.nonce); if (!isAllowedGoogleUser(identity.email, identity.emailVerified, config.allowedGoogleEmail)) { throw new AppError('user_not_allowed', 403, 'Dieses Google-Konto ist für die App nicht freigeschaltet.'); } - const repository = getConnectionRepository(config.databaseUrl); - await repository.upsertAuthorization({ - googleSub: identity.sub, - email: identity.email, - encryptedRefreshToken: encryptRefreshToken(tokens.refreshToken, config.tokenEncryptionKey, identity.sub), - scopes: tokens.scopes, - }); + await getFinanceRepository(config.databaseUrl).ensureOwnerForGoogleSub(identity.sub); const { token } = createSession(identity.sub, identity.email, config.sessionSecret); response.setHeader('Set-Cookie', [clearCookie(OAUTH_COOKIE, config.production), sessionCookie(token, config.production)]); response.redirect(302, new URL(returnPath, `${config.appOrigin}/`).href); } catch (error) { response.setHeader('Set-Cookie', clearCookie(OAUTH_COOKIE, config.production)); const code = error instanceof AppError ? error.code : 'oauth_callback_failed'; - const safeCode = ['user_not_allowed', 'missing_required_scope', 'refresh_token_missing', 'invalid_oauth_state', 'oauth_callback_failed', 'oauth_exchange_failed', 'invalid_google_identity'].includes(code) + const safeCode = ['user_not_allowed', 'invalid_oauth_state', 'oauth_callback_failed', 'oauth_exchange_failed', 'invalid_google_identity'].includes(code) ? code : 'oauth_callback_failed'; const errorUrl = new URL(returnPath, `${config.appOrigin}/`); errorUrl.searchParams.set('auth_error', safeCode); diff --git a/api/connection/disconnect.ts b/api/connection/disconnect.ts deleted file mode 100644 index 4c8c445..0000000 --- a/api/connection/disconnect.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { VercelRequest, VercelResponse } from '@vercel/node'; -import { revokeGoogleToken } from '../_lib/google.js'; -import { authenticated, handle, json, method } from '../_lib/http.js'; -import { clearCookie, decryptRefreshToken, SESSION_COOKIE } from '../_lib/security.js'; - -export default async function handler(request: VercelRequest, response: VercelResponse) { - if (!method(request, response, ['POST'])) return; - await handle(response, async () => { - const { config, repository, session } = authenticated(request, true); - const connection = await repository.get(session.sub); - if (connection) { - try { - const refreshToken = decryptRefreshToken(connection.encryptedRefreshToken, config.tokenEncryptionKey, session.sub); - await revokeGoogleToken(refreshToken); - } finally { - await repository.delete(session.sub); - } - } - response.setHeader('Set-Cookie', clearCookie(SESSION_COOKIE, config.production)); - json(response, 200, { ok: true }); - }); -} diff --git a/api/finance.ts b/api/finance.ts index 49c6caa..970c158 100644 --- a/api/finance.ts +++ b/api/finance.ts @@ -1,20 +1,26 @@ import type { VercelRequest, VercelResponse } from '@vercel/node'; import { AppError } from './_lib/errors.js'; -import { readValidatedFinanceData } from './_lib/financeService.js'; +import { FinanceDataIntegrityError, getFinanceRepository } from './_lib/financeRepository.js'; import { authenticated, handle, json, method } from './_lib/http.js'; +import { financeCacheOwnerKey } from './_lib/security.js'; export default async function handler(request: VercelRequest, response: VercelResponse) { if (!method(request, response, ['GET'])) return; await handle(response, async () => { - const { config, repository, session } = authenticated(request); - const connection = await repository.get(session.sub); - if (!connection) throw new AppError('connection_missing', 409, 'Google-Verbindung fehlt.'); - if (!connection.spreadsheetId || !connection.spreadsheetName) throw new AppError('spreadsheet_missing', 409, 'Es wurde noch keine Google-Tabelle ausgewählt.'); - const data = await readValidatedFinanceData(config, connection, connection.spreadsheetId); - json(response, 200, { - spreadsheet: { id: connection.spreadsheetId, name: connection.spreadsheetName }, - data, - refreshedAt: new Date().toISOString(), - }); + const { config, session } = authenticated(request); + try { + const data = await getFinanceRepository(config.databaseUrl).readForGoogleSub(session.sub); + if (!data) throw new AppError('finance_missing', 409, 'Es ist noch kein Finanzstand vorhanden.'); + json(response, 200, { + data, + refreshedAt: new Date().toISOString(), + ownerKey: financeCacheOwnerKey(session.sub, config.sessionSecret), + }); + } catch (error) { + if (error instanceof FinanceDataIntegrityError) { + throw new AppError('finance_data_integrity', 422, 'Der gespeicherte Finanzstand ist ungültig.'); + } + throw error; + } }); } diff --git a/api/google/picker.ts b/api/google/picker.ts deleted file mode 100644 index a841e16..0000000 --- a/api/google/picker.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { VercelRequest, VercelResponse } from '@vercel/node'; -import { accessForConnection } from '../_lib/financeService.js'; -import { AppError } from '../_lib/errors.js'; -import { authenticated, handle, json, method } from '../_lib/http.js'; - -export default async function handler(request: VercelRequest, response: VercelResponse) { - if (!method(request, response, ['GET'])) return; - await handle(response, async () => { - const { config, repository, session } = authenticated(request); - const connection = await repository.get(session.sub); - if (!connection) throw new AppError('connection_missing', 409, 'Google-Verbindung fehlt.'); - const token = await accessForConnection(config, connection); - json(response, 200, { - accessToken: token.accessToken, - expiresIn: token.expiresIn, - apiKey: config.googleApiKey, - appId: config.googleCloudProjectNumber, - clientId: config.googleClientId, - }); - }); -} diff --git a/api/google/spreadsheet.ts b/api/google/spreadsheet.ts deleted file mode 100644 index 1ba684d..0000000 --- a/api/google/spreadsheet.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { VercelRequest, VercelResponse } from '@vercel/node'; -import { z } from 'zod'; -import { AppError } from '../_lib/errors.js'; -import { validateAndSaveSpreadsheet } from '../_lib/financeService.js'; -import { authenticated, handle, json, method } from '../_lib/http.js'; - -const bodySchema = z.object({ fileId: z.string().trim().min(10).max(256) }).strict(); - -export default async function handler(request: VercelRequest, response: VercelResponse) { - if (!method(request, response, ['PUT'])) return; - await handle(response, async () => { - const { config, repository, session } = authenticated(request, true); - const body = bodySchema.safeParse(request.body); - if (!body.success) throw new AppError('invalid_request', 400, 'Ungültige Tabellen-Auswahl.'); - const connection = await repository.get(session.sub); - if (!connection) throw new AppError('connection_missing', 409, 'Google-Verbindung fehlt.'); - const result = await validateAndSaveSpreadsheet(config, repository, connection, body.data.fileId); - json(response, 200, { - spreadsheet: { id: result.file.id, name: result.file.name }, - data: result.data, - refreshedAt: new Date().toISOString(), - }); - }); -} diff --git a/api/session.ts b/api/session.ts index 07be72c..7b83c7d 100644 --- a/api/session.ts +++ b/api/session.ts @@ -1,8 +1,7 @@ import type { VercelRequest, VercelResponse } from '@vercel/node'; import { getServerConfig } from './_lib/config.js'; import { handle, json, method } from './_lib/http.js'; -import { getConnectionRepository } from './_lib/repository.js'; -import { clearCookie, parseCookies, SESSION_COOKIE, verifySession } from './_lib/security.js'; +import { clearCookie, financeCacheOwnerKey, parseCookies, SESSION_COOKIE, verifySession } from './_lib/security.js'; export default async function handler(request: VercelRequest, response: VercelResponse) { if (!method(request, response, ['GET'])) return; @@ -26,15 +25,11 @@ export default async function handler(request: VercelRequest, response: VercelRe json(response, 200, { authenticated: false }); return; } - const connection = await getConnectionRepository(config.databaseUrl).get(session.sub); json(response, 200, { authenticated: true, user: { email: session.email }, csrfToken: session.csrf, - connection: connection ? { - connected: true, - spreadsheet: connection.spreadsheetId ? { id: connection.spreadsheetId, name: connection.spreadsheetName } : null, - } : { connected: false, spreadsheet: null }, + ownerKey: financeCacheOwnerKey(session.sub, config.sessionSecret), }); }); } diff --git a/docs/anleitungen/fehlerdiagnose.md b/docs/anleitungen/fehlerdiagnose.md index e26900b..2e0adb8 100644 --- a/docs/anleitungen/fehlerdiagnose.md +++ b/docs/anleitungen/fehlerdiagnose.md @@ -17,14 +17,8 @@ Zuerst sichtbaren Zustand, Browser-Online-Status, betroffene Aktion und Zeitpunk | `server_configuration_error` | Variable fehlt oder Origin/Callback inkonsistent | Vercel-Scope und [Konfiguration](../referenz/konfiguration.md) prüfen | | `user_not_allowed` | falsche oder unverifizierte Google-E-Mail | `ALLOWED_GOOGLE_EMAIL` und Google-Testnutzer prüfen | | `invalid_oauth_state` | abgelaufene/zweite Transaktion oder Cookie blockiert | Anmeldung in einem Tab neu starten; Cookie-Einstellungen prüfen | -| `refresh_token_missing` | Google gab keinen Offline-Grant aus | App-Grant widerrufen und bewusst neu verbinden | -| `missing_required_scope` | `drive.file` nicht erteilt | Consent/Scope-Konfiguration korrigieren | -| `connection_missing` | Sitzung vorhanden, Postgres-Zeile fehlt | neu verbinden; DB/Environment nicht verwechseln | -| `spreadsheet_missing` | noch keine Auswahl | Picker öffnen und gültige Kopie wählen | -| `spreadsheet_inaccessible` | Datei nicht vom Grant erfasst/gelöscht | Zugriff prüfen und erneut über Picker wählen | -| `invalid_spreadsheet_type` | keine aktive native Google-Tabelle | Google Sheets statt XLSX/PDF wählen | -| `invalid_finance_schema` | Tab, Header, Wert, Referenz oder Snapshot ungültig | strukturierte Issues und [Schema](../referenz/finance-data-schema-v1.md) abarbeiten | -| `reconnect_required` | Grant abgelaufen oder widerrufen | Google neu verbinden | +| `finance_missing` | verifizierte Identität hat keinen Owner oder der Owner kein `finance_meta` | verifizierte Erstanmeldung und Operator-Import prüfen; DB/Environment nicht verwechseln | +| `finance_data_integrity` | gespeicherter Stand verletzt den v1-Vertrag | Importquelle und Constraints prüfen; keine Werte aus Logs erwarten | | `csrf_failed` | Origin/CSRF passt nicht | `APP_ORIGIN`, Proxy-Origin und Session prüfen; nicht Token umgehen | | Offline ohne Daten | noch nie erfolgreich synchronisiert oder Browsercache gelöscht | online ersten gültigen Sync durchführen | | alter Stand trotz Netz | Refresh fehlgeschlagen oder Vordergrundschwelle nicht erreicht | manuell aktualisieren und `/api/finance` prüfen | @@ -33,7 +27,7 @@ Zuerst sichtbaren Zustand, Browser-Online-Status, betroffene Aktion und Zeitpunk ## Schemafehler lesen -Ein Issue nennt `tab`, Tabellenzeile (Header ist Zeile 1), `column`, erwartete Form und eine deutsche Meldung. Erst fehlende Tabs/Header beheben, dann Datentypen, IDs/Fremdschlüssel und zuletzt fehlende Snapshots. Tabellen-ID wird bei einer neuen Auswahl erst nach vollständig erfolgreicher Prüfung gespeichert. +Operator-Importfehler nennen `tab`, Tabellenzeile (Header ist Zeile 1), `column`, erwartete Form und eine deutsche Meldung. Erst fehlende Tabs/Header beheben, dann Datentypen, IDs/Fremdschlüssel und zuletzt fehlende Snapshots. Die Produkt-API gibt für einen ungültigen gespeicherten Stand keine Issues aus. ## Offline und Service Worker diff --git a/docs/anleitungen/lokale-entwicklung.md b/docs/anleitungen/lokale-entwicklung.md index 66eaebb..fcd8c4a 100644 --- a/docs/anleitungen/lokale-entwicklung.md +++ b/docs/anleitungen/lokale-entwicklung.md @@ -13,7 +13,7 @@ npm install npm run dev:mock ``` -Vite nennt die lokale URL. Dieser Modus lädt ausschließlich anonyme Daten aus `src/mocks`, simuliert Sitzung/API und Picker und benötigt weder Google noch PostgreSQL. Die Umschaltung ist nur aktiv, wenn Vite im Entwicklungsmodus läuft und `VITE_USE_MOCK_API=true` gesetzt ist. Ein Produktionsbuild verwendet den Mock nicht. +Vite nennt die lokale URL. Dieser Modus lädt ausschließlich anonyme Daten aus `src/mocks`, simuliert Sitzung und API und benötigt weder Google noch PostgreSQL. Die Umschaltung ist nur aktiv, wenn Vite im Entwicklungsmodus läuft und `VITE_USE_MOCK_API=true` gesetzt ist. Ein Produktionsbuild verwendet den Mock nicht. ## Develop- und Vercel-Preview @@ -32,7 +32,7 @@ npx vercel env pull .env.local --environment=development npx vercel dev --listen 3000 ``` -Öffne `http://localhost:3000`. Google OAuth-Origin und Redirect müssen exakt darauf konfiguriert sein. Verwende eine Kopie der Tabelle, niemals die einzige produktive Quelle für erste Versuche. Die externen Schritte stehen im [Produktions-Setup](produktions-setup.md). +Öffne `http://localhost:3000`. Google OAuth-Origin und Redirect müssen exakt darauf konfiguriert sein. Der Finanzstand kommt aus PostgreSQL; für lokale Realtests den anonymen Stand mit `npm run import:finance -- --from-fixture` importieren. Die externen Schritte stehen im [Produktions-Setup](produktions-setup.md). ## Arbeitsablauf diff --git a/docs/anleitungen/produktions-setup.md b/docs/anleitungen/produktions-setup.md index 5ee0570..10095ac 100644 --- a/docs/anleitungen/produktions-setup.md +++ b/docs/anleitungen/produktions-setup.md @@ -10,16 +10,14 @@ Repository-Code kann externe Konten, APIs, Redirects, Datenbank und Secrets nich ## 1. Google-Cloud-Projekt -1. Ein dediziertes Google-Cloud-Projekt erstellen oder auswählen und die numerische Projektnummer notieren. -2. Google Sheets API, Google Drive API und Google Picker API aktivieren. -3. Google Auth Platform als **External** konfigurieren. Im Testmodus die erlaubte Person als Test User eintragen. -4. Nur `openid`, `email`, `profile` und `https://www.googleapis.com/auth/drive.file` anfordern. -5. Einen OAuth Client vom Typ **Web application** erstellen. -6. Exakte Origins eintragen, zum Beispiel `http://localhost:3000` und die HTTPS-Produktions-Origin. -7. Exakte Redirect-URIs eintragen: Origin plus `/api/auth/google/callback`; keine abweichenden Ports oder abschließenden Slashes. -8. Einen API-Key für Picker erstellen und auf Google Picker API sowie die exakten HTTP-Referrer einschränken. +1. Ein dediziertes Google-Cloud-Projekt erstellen oder auswählen. +2. Google Auth Platform als **External** konfigurieren. Im Testmodus die erlaubte Person als Test User eintragen. +3. Nur `openid`, `email` und `profile` anfordern. +4. Einen OAuth Client vom Typ **Web application** erstellen. +5. Exakte Origins eintragen, zum Beispiel `http://localhost:3000` und die HTTPS-Produktions-Origin. +6. Exakte Redirect-URIs eintragen: Origin plus `/api/auth/google/callback`; keine abweichenden Ports oder abschließenden Slashes. -`GOOGLE_CLOUD_PROJECT_NUMBER` ist die numerische Nummer, nicht die textuelle Projekt-ID. OAuth Client ID und Picker-Key sind browserlesbare Identifikatoren, aber ihre Einschränkungen bleiben sicherheitsrelevant. Das Client-Secret bleibt serverseitig. +Sheets-, Drive- und Picker-APIs werden nicht mehr benötigt. Das Client-Secret bleibt serverseitig. ## 2. PostgreSQL und Neon-Betrieb @@ -32,11 +30,13 @@ Zwei getrennte Verbindungen verwenden: - Vercel Functions erhalten die gepoolte Neon-URL als `DATABASE_URL`. - Migrationen, Rollenverwaltung und Restore-Prüfungen verwenden einen direkten Neon-Endpoint mit administrativen Credentials. Diese URL ist kein Runtime-Secret der App. -Vor jedem Lauf Zielhost, Datenbank, Benutzer und Environment sichtbar prüfen. Migrationen zuerst in Development anwenden, dort die Integrationstests und einen vollständigen Reader-Durchlauf ausführen und erst danach Production getrennt beauftragen: +Vor jedem Lauf Zielhost, Datenbank, Benutzer und Environment sichtbar prüfen. Migrationen zuerst in Development anwenden, dort die Integrationstests und einen vollständigen Reader-Durchlauf ausführen und erst danach Production getrennt beauftragen. Bei einem bestehenden Deployment ist die Cutover-Reihenfolge verbindlich: zunächst 001/002 anwenden, dann den identity-only Runtime-Code deployen und dessen PostgreSQL-Pfad prüfen, erst danach 003 ausführen. Migration 003 entfernt gespeicherte Refresh-Tokens irreversibel; ein Rückfall auf den alten Sheets-Runtime-Pfad benötigt ein zuvor geprüftes Restore oder eine neue Google-Autorisierung. ```bash psql "$DATABASE_DIRECT_URL" -f migrations/001_google_connections.sql psql "$DATABASE_DIRECT_URL" -f migrations/002_finance_data_v1.sql +# identity-only Runtime deployen und prüfen +psql "$DATABASE_DIRECT_URL" -f migrations/003_drop_google_connections.sql ``` `DATABASE_DIRECT_URL` ist hier nur ein Name für die administrative Shell-Variable und keine von der Anwendung gelesene Konfiguration. Das tatsächliche Anwenden auf eine externe Development- oder Production-Datenbank ist ein eigener ausdrücklicher Betriebsauftrag. @@ -47,26 +47,25 @@ Für eine lokale dedizierte Testdatenbank oder den CI-Service gilt: POSTGRES_TEST_URL=postgresql://... npm run test:postgres ``` -Die Suite bricht ohne URL ab, legt unter der Ziel-Datenbank ein isoliertes synthetisches Testschema an, führt 001 und 002 aus und entfernt dieses Schema anschließend wieder. Niemals eine Produktions-URL als `POSTGRES_TEST_URL` verwenden. +Die Suite bricht ohne URL ab, legt unter der Ziel-Datenbank ein isoliertes synthetisches Testschema an, führt 001, 002 und 003 aus und entfernt dieses Schema anschließend wieder. Niemals eine Produktions-URL als `POSTGRES_TEST_URL` verwenden. ### Runtime-Rolle -Direkte Owner-/Migrations-Credentials dürfen nicht als `DATABASE_URL` verwendet werden. Die Runtime-Rolle benötigt im ACC-71-Stand: +Direkte Owner-/Migrations-Credentials dürfen nicht als `DATABASE_URL` verwendet werden. Die Runtime-Rolle benötigt: -- die bestehenden notwendigen `SELECT`-, `INSERT`-, `UPDATE`- und `DELETE`-Rechte auf `google_connections`; -- `SELECT` auf `owners` und allen Finance-Tabellen; +- `SELECT` auf `owners` und allen Finance-Tabellen sowie `INSERT` ausschließlich auf `owners`, damit der verifizierte OAuth-Callback den eigenen Owner idempotent anlegen kann; - keine DDL-, Rollenverwaltungs- oder Schema-Owner-Rechte; -- keine pauschalen Finance-Schreibrechte. Der spätere Editor erweitert Rechte nur auf die ausdrücklich benötigten Tabellen und Operationen. +- keine pauschalen Finance-Schreibrechte. Der Operator-Import und der spätere Editor verwenden einen administrativen beziehungsweise bewusst erweiterten Zugang. -Die konkrete `GRANT`-Konfiguration wird pro Datenbank mit dem administrativen direkten Endpoint angewandt und anschließend durch Anmeldung, Connection-Update und einen Finance-Read mit synthetischem Owner geprüft. +Die konkrete `GRANT`-Konfiguration wird pro Datenbank mit dem administrativen direkten Endpoint angewandt und anschließend durch Anmeldung und einen Finance-Read mit synthetischem Owner geprüft. ### Region Vor Production-Migration und Cutover werden die reale Neon-Region und die tatsächlich ausgeführte Vercel-Functions-Region im jeweiligen Dashboard geprüft. Ziel ist eine sinnvolle gemeinsame EU-Region. Ohne diesen Befund wird keine Region blind in `vercel.json` eingetragen. Gemessene Latenz und der gewählte Stand gehören ins private Betriebsprotokoll, nicht als vermutete Werte ins Repository. -### Backup und Restore vor ACC-66 +### Backup und Restore vor dem privaten Import -Vor dem späteren Import/Cutover sind folgende Punkte verpflichtend: +Vor dem Import privater Daten sind folgende Punkte verpflichtend: 1. Ein für die privaten Daten ausreichendes Neon-Restore-Fenster und ein geeigneter Tarif sind aktiv. 2. Ein Restore wird mit ausschließlich synthetischen Development-Daten praktisch durchgeführt. @@ -81,11 +80,10 @@ Das Repository automatisiert weder Neon-Restore noch externe Migrationen. Schema Unabhängige Werte erzeugen und nur im Vercel-/lokalen Secret Store ablegen: ```bash -openssl rand -base64 32 openssl rand -base64 48 ``` -Der erste Wert eignet sich als 32-Byte-`TOKEN_ENCRYPTION_KEY`; der zweite als starkes `SESSION_SECRET`. Nicht wiederverwenden, nicht in Shell-Historie kopieren, wenn diese geteilt wird, und nie in Git, Screenshots oder `VITE_`-Variablen ablegen. +Der Wert eignet sich als starkes `SESSION_SECRET`. Nicht wiederverwenden, nicht in Shell-Historie kopieren, wenn diese geteilt wird, und nie in Git, Screenshots oder `VITE_`-Variablen ablegen. ## 4. Umgebungsvariablen @@ -99,9 +97,17 @@ Wichtig: - `ALLOWED_GOOGLE_EMAIL` enthält genau die verifizierte Eigentümeradresse. - Development- und Production-Schlüssel sollten verschieden sein. -## 5. Finance-Workbook +## 5. Finance-Import -Eine Google-Tabelle mit allen zehn exakten underscore-präfigierten Tabs des [Finance Data Schema v1](../referenz/finance-data-schema-v1.md) anlegen. Für den ersten Durchlauf eine Kopie mit anonymen oder nicht sensitiven Werten verwenden. `salary_day` und `due_day` sind optionale v1-Erweiterungsspalten; ohne sie bleibt das Workbook gültig, Demnächst kann die entsprechende Projektion jedoch nicht vollständig bilden. +Nach Migration 002 und dem Deployment einmal mit der allowgelisteten Google-Identität anmelden. Erst der vollständig verifizierte OAuth-Callback legt den Owner an. Der Import akzeptiert bewusst keine manuell übergebene Google-Subjekt-ID, verwendet ausschließlich den genau einen vorhandenen Owner und bricht bei keinem oder mehreren Ownern ab. Dadurch kann ein kopierter oder vertippter externer Bezeichner keine Finanzdaten falsch zuordnen. + +Für lokale oder synthetische Tests anschließend: + +```bash +DATABASE_URL="$DATABASE_DIRECT_URL" npm run import:finance -- --from-fixture +``` + +Für den privaten Bestand eine lokale, nicht versionierte JSON-Datei im Sheets-batchGet-Format oder als `FinanceDataV1` verwenden und `--from-file=` setzen. Die Datei darf nicht ins Repository, in CI-Artefakte oder in geteilte Shell-Ausgaben gelangen. Syntax- und Validierungsfehler geben weder Quellfragmente noch Finanzwerte aus. `salary_day` und `due_day` sind optionale v1-Felder; ohne sie bleibt der Stand gültig, Demnächst kann die entsprechende Projektion jedoch nicht vollständig bilden. ## 6. Lokale Realabnahme @@ -111,35 +117,30 @@ npx vercel env pull .env.local --environment=development npx vercel dev --listen 3000 ``` -Mit der allowgelisteten Adresse anmelden, Consent vollständig bestätigen und die kopierte Tabelle auswählen. Falls Google kein Refresh-Token liefert, den bestehenden App-Grant im Google-Konto entfernen und die Verbindung mit `prompt=consent` neu aufbauen. +Mit der allowgelisteten Adresse anmelden. Der Finanzstand kommt aus PostgreSQL; ohne vorherigen Import zeigt die App „Finanzstand fehlt“. ## 7. Deployment und Produktionsabnahme Nach grünen Prüfungen über den eigentümerkontrollierten Vercel-Workflow deployen. Vor der ersten echten Nutzung einzeln prüfen: - Live-Anmeldung und Zurückleitung ohne `auth_error`; -- Picker zeigt Sheets und akzeptiert genau eine Datei; -- Tabellenprüfung, erster Sync und manueller Refresh; -- Wechsel auf eine zweite Testtabelle und zurück; +- vorhandener Import erscheint nach der Anmeldung; +- fehlender Import zeigt „Finanzstand fehlt“; +- manueller Refresh; - Offline-Reload nach erfolgreichem Sync; - `/`, `/demnaechst`, `/budget` und `/schulden` jeweils direkt in einem neuen Browserkontext sowie nach Reload; - unbekannten Pfad prüfen: kontrollierter Wechsel auf `/`, keine API- oder Asset-Umleitung auf die App-Shell; - Browser-Zurück/Vorwärts über alle vier Hauptansichten und OAuth-Rückkehr zum vorherigen Pfad; - installierte Android-PWA kalt aus jedem zuletzt verwendeten Hauptscreen starten und mehrfach systemseitig zurücknavigieren; -- Logout gegenüber erneuter Anmeldung; -- Disconnect löscht Serververbindung und lokalen Finance-Cache; -- Wiederverbinden und erneute Auswahl; -- Privacy und Appearance bleiben über Logout/Disconnect erhalten. +- Logout gegenüber erneuter Anmeldung, ohne den PostgreSQL-Stand zu löschen; +- Privacy und Appearance bleiben über Logout erhalten. - der Info-Dialog verlinkt auf den vollständigen Commit-SHA des tatsächlich betriebenen Stands sowie dessen `LICENSE` und `TRADEMARKS.md`; - `/THIRD_PARTY_NOTICES.txt` ist online und nach einmaligem Laden auch offline erreichbar. -Google-OAuth-Apps im External-Testmodus können Grants mit nicht ausschließlich Basisprofil-Scopes nach sieben Tagen verlieren. Vor Dauerbetrieb den Veröffentlichungsstatus passend konfigurieren und danach bewusst neu verbinden. - ## Secret-Rotation - `SESSION_SECRET`: bestehende Sitzungen und OAuth-Transaktionen werden ungültig; kontrolliert wechseln und neu anmelden. -- `TOKEN_ENCRYPTION_KEY`: vorhandene Refresh-Token sind ohne alten Schlüssel nicht entschlüsselbar. Aktuell gibt es keinen Keyring; Verbindung vor/nach koordiniertem Wechsel löschen und neu autorisieren oder eine explizite Migration bauen. -- Google Client Secret/API-Key: Google- und Vercel-Konfiguration gemeinsam aktualisieren; Referrer/Redirects erneut testen. +- Google Client Secret: Google- und Vercel-Konfiguration gemeinsam aktualisieren; Redirects erneut testen. - `DATABASE_URL`: gepoolte Runtime-URL; Erreichbarkeit, eingeschränkte Rolle und Ziel-Environment prüfen, bevor der Appwert umgestellt wird. ## Nachweis und Fehlerdiagnose diff --git a/docs/anleitungen/testen-und-release.md b/docs/anleitungen/testen-und-release.md index 3f4cd44..54daa85 100644 --- a/docs/anleitungen/testen-und-release.md +++ b/docs/anleitungen/testen-und-release.md @@ -52,9 +52,9 @@ Jedes geänderte Bild einzeln auf Layout, Texte, Theme, Fokuszustand und unerwar - zentrale Überschriften- und Quellcode-Zeilenanker; - alte Dokumentationspfade und alle fünf Lesepfade; -- Login, Picker, Sync, Tabellenwechsel und Schemaprobleme; +- Login, Sync, fehlender Finanzstand und Integritätsprobleme; - Offline-Start mit und ohne vorherigen Cache; -- Logout versus Disconnect und anschließendes Wiederverbinden; +- Logout blendet lokale Finanzdaten aus; erneute Anmeldung mit derselben Identität stellt den ownergebundenen Cache und den PostgreSQL-Stand wieder her; - Appearance-Entwurf, Anwenden, Abbrechen, Bildentfernung und OS-Moduswechsel; - Privacy sichtbar und mit Screenreader-Ausgabe, einschließlich Tabsynchronisierung; - Tastatur, Fokus, Reduced Motion, 320-Pixel-Reflow und Forced Colors. diff --git a/docs/architektur/appearance-und-designsystem.md b/docs/architektur/appearance-und-designsystem.md index 89a27bf..c19aeea 100644 --- a/docs/architektur/appearance-und-designsystem.md +++ b/docs/architektur/appearance-und-designsystem.md @@ -44,7 +44,7 @@ Nur bewusst gewählte JPG-, PNG- oder WebP-Dateien werden verarbeitet. Dekodieru ## Speichervertrag -`finance-appearance-v1` in `localStorage` enthält Version 1, Modus, Quelle, Palettenmetadaten, normalisierte Hex-Seeds, komplettes Theme-Paar und Wallpaper-Metadaten. Ungültige Daten fallen auf Standard zurück. Ein gleichnamiger, aber technisch separater IndexedDB-Speicher hält die Bildvorschau. Ein `storage`-Listener synchronisiert Präferenzen zwischen Tabs. Logout und Disconnect ändern Appearance nicht. +`finance-appearance-v1` in `localStorage` enthält Version 1, Modus, Quelle, Palettenmetadaten, normalisierte Hex-Seeds, komplettes Theme-Paar und Wallpaper-Metadaten. Ungültige Daten fallen auf Standard zurück. Ein gleichnamiger, aber technisch separater IndexedDB-Speicher hält die Bildvorschau. Ein `storage`-Listener synchronisiert Präferenzen zwischen Tabs. Logout ändert Appearance nicht. ## Farben und Tokens diff --git a/docs/architektur/backend-und-sicherheit.md b/docs/architektur/backend-und-sicherheit.md index 8c55613..212ab57 100644 --- a/docs/architektur/backend-und-sicherheit.md +++ b/docs/architektur/backend-und-sicherheit.md @@ -8,9 +8,9 @@ ## Mentales Modell -Die Vercel Functions sind Backend-for-Frontend und Sicherheitsgrenze. Sie verifizieren die einzige erlaubte Identität, verwalten Google-Token und Datenbankverbindung, lesen Sheets, validieren das Finance-Schema und liefern eine kleine same-origin JSON-API. Der Browser spricht Google nur beim bewusst geöffneten Picker direkt an. Ein zentraler Lazy-Pool mit höchstens einer Verbindung pro `DATABASE_URL` wird vom Google-Connection- und vom neuen Finance-Repository geteilt. +Die Vercel Functions sind Backend-for-Frontend und Sicherheitsgrenze. Sie verifizieren die einzige erlaubte Identität, lesen den ownergebundenen Finanzstand aus PostgreSQL und liefern eine kleine same-origin JSON-API. Google wird nur für die Anmeldung angesprochen. Ein zentraler Lazy-Pool mit höchstens einer Verbindung pro `DATABASE_URL` bedient das Finance-Repository. -Dies beschreibt den aktuell implementierten Übergangsstand. Der ownergebundene PostgreSQL-Reader aus [ADR 0013](../entscheidungen/0013-postgresql-als-finanzquelle.md) ist inzwischen implementiert und mit echter PostgreSQL-Instanz getestet, aber absichtlich an keinen HTTP-Endpunkt angeschlossen. `/api/finance` verwendet weiterhin ausschließlich den Sheets-Service. Erst ACC-66 importiert den produktiven Stand und führt den eindeutigen Cutover aus; bis dahin gibt es weder Dual-Read noch PostgreSQL-zu-Sheets-Fallback. Im Zielbild aus [ADR 0014](../entscheidungen/0014-google-oauth-nur-als-identitaet.md) bleibt Google nur Identitätsanbieter, und Picker, `drive.file`, Sheets-Laufzeitzugriff sowie persistierte Refresh-Tokens entfallen. +PostgreSQL ist die einzige produktive Finanzquelle. Es gibt weder Dual-Read noch einen Laufzeit-Fallback auf Sheets. Google OAuth fordert nur `openid email profile`. Picker, `drive.file`, Drive-/Sheets-Laufzeitzugriff und persistierte Refresh-Tokens sind entfernt. Der einmalige Import liegt außerhalb der Produkt-UI. ## Implementierter PostgreSQL-Reader @@ -18,7 +18,7 @@ Dies beschreibt den aktuell implementierten Übergangsstand. Der ownergebundene Der vollständige Read läuft `READ ONLY` und `REPEATABLE READ`. Meta, Stammdaten, sämtliche historische und zukünftige Snapshots sowie Meilensteine stammen deshalb aus einem konsistenten Datenbankstand. `DATE` wird direkt in SQL zu ISO-Text formatiert; `BIGINT` wird nur aus gültiger Integerdarstellung in einen sicheren JavaScript-Integer überführt. Anschließend validiert das gemeinsame Zod-Schema den vollständigen `FinanceDataV1`-Vertrag. Aktive Accounts, Pockets und Debts brauchen zusätzlich einen Snapshot mit Datum am oder vor `asOf`. -Fehlender Owner oder fehlendes `finance_meta` liefert `null`. Ein ungültiger gespeicherter Stand erzeugt einen eigenen internen Integritätsfehler ohne Zeilen, IDs oder Finanzwerte. Da ACC-71 keinen produktiven Endpunkt umstellt, existiert noch keine neue öffentliche HTTP-Fehlerabbildung. +Fehlender Owner oder fehlendes `finance_meta` liefert `null` und wird als `409 finance_missing` abgebildet. Ein ungültiger gespeicherter Stand erzeugt einen eigenen internen Integritätsfehler ohne Zeilen, IDs oder Finanzwerte und wird als `422 finance_data_integrity` ohne Details nach außen gegeben. Implementierung und Test: [api/_lib/financeRepository.ts](../../api/_lib/financeRepository.ts), [PostgreSQL-Integrationstest](../../tests/postgres/financeRepository.postgres.test.ts). @@ -40,9 +40,8 @@ sequenceDiagram B->>A: GET /api/auth/google/callback A->>A: Cookie/State prüfen A->>G: Code + PKCE-Verifier tauschen - G-->>A: ID-, Access- und Refresh-Token + G-->>A: ID-Token A->>A: Nonce, Signatur, Audience, verifizierte Allowlist-Mail prüfen - A->>P: Refresh-Token AES-256-GCM-verschlüsselt upserten A-->>B: signiertes HttpOnly-Session-Cookie + Redirect zum gebundenen Rückweg ``` @@ -54,23 +53,15 @@ Implementierung und Tests: [api/auth/google/start.ts](../../api/auth/google/star Das Session-Cookie heißt `finance_session`, ist `HttpOnly`, `SameSite=Lax`, auf `/` begrenzt und in Produktion `Secure`. Sein signierter Inhalt bindet Google-`sub`, normalisierte E-Mail, CSRF-Token sowie Ausgabe/Ablauf. Jeder authentifizierte Endpunkt prüft Signatur und die konfigurierte `ALLOWED_GOOGLE_EMAIL` erneut. -Schreibende Aktionen (`PUT /api/google/spreadsheet`, Logout, Disconnect) verlangen den CSRF-Wert aus der Sitzung im Header `x-csrf-token` und eine exakte `Origin`, die `APP_ORIGIN` entspricht. Antworten tragen `Cache-Control: no-store`. Die vollständige Methoden-/Antworttabelle steht ausschließlich in der [API-Referenz](../referenz/api.md). +Schreibende Aktionen (Logout) verlangen den CSRF-Wert aus der Sitzung im Header `x-csrf-token` und eine exakte `Origin`, die `APP_ORIGIN` entspricht. Antworten tragen `Cache-Control: no-store`. Die vollständige Methoden-/Antworttabelle steht ausschließlich in der [API-Referenz](../referenz/api.md). -## Google Picker, Drive und Sheets +## Identität ohne Drive-Zugriff -Nach authentifizierter Anfrage erzeugt der Server über das gespeicherte Refresh-Token ein kurzes Access-Token und liefert es nur an die Picker-Konfiguration. Der Picker filtert auf eine native Google-Sheets-Datei. Die Auswahl-ID ist trotzdem untrusted: Der Server validiert sie mit Drive (`id`, `name`, MIME-Typ) und liest erst danach die zehn Bereiche via Sheets `batchGet`. - -Der Scope `drive.file` beschränkt den Zugriff auf mit der App geöffnete/ausgewählte Dateien. Die App schreibt keine Sheets-Werte. Eine Tabellen-ID wird erst nach vollständiger MIME-, Tab-, Header-, Werte-, Fremdschlüssel- und Snapshot-Prüfung gespeichert. - -## Token-Schutz - -Refresh-Token werden mit AES-256-GCM verschlüsselt. Eine zufällige Nonce und Auth-Tag erkennen Manipulation; die Google-Subjekt-ID ist Additional Authenticated Data und bindet das Chiffrat an den Datensatz. `TOKEN_ENCRYPTION_KEY` muss Base64-kodiert genau 32 Byte ergeben. Rotation erfordert derzeit erneute Verbindung oder eine bewusste Migration, weil kein Keyring implementiert ist. - -Der Picker-Access-Token ist kurzlebig und wird nicht persistiert. Client-Secret, Datenbank-URL, Token-Schlüssel und Session-Secret werden nie an den Browser ausgeliefert. Die Picker-API-Key und Client-ID sind öffentliche Identifikatoren, müssen aber auf API/Referrer eingeschränkt werden. +Der Authorization-Code-Fluss fordert nur `openid email profile`. Ein kurzlebiges Access- oder Refresh-Token aus dem Tausch wird nicht persistiert. Die Sitzung entsteht ausschließlich aus dem verifizierten ID-Token. Client-Secret, Datenbank-URL und Session-Secret werden nie an den Browser ausgeliefert. ## Fehlerfälle -Widerrufene oder abgelaufene Google-Grants werden als `reconnect_required` abgebildet. Eine ungültige Tabelle ergibt 422 mit strukturierten Issues. Fehlende Verbindung oder Auswahl ergibt 409. Unbekannte Serverfehler werden auf eine generische Meldung reduziert. Beim Disconnect wird die Postgres-Verbindung in `finally` gelöscht, selbst wenn die Google-Widerruf-Anfrage fehlschlägt. +Fehlender Finanzstand ergibt `409 finance_missing`. Ein intern ungültiger gespeicherter Stand ergibt `422 finance_data_integrity` ohne Issues, IDs oder Beträge. Unbekannte Serverfehler werden auf eine generische Meldung reduziert. Logout löscht nur das Session-Cookie und niemals PostgreSQL-Finanzzeilen. ## Sicherheitsannahmen und Grenzen @@ -83,12 +74,12 @@ Widerrufene oder abgelaufene Google-Grants werden als `reconnect_required` abgeb ## Begründung und Nachweis -Für den aktuellen Übergangsstand siehe die ersetzte [ADR 0003](../entscheidungen/0003-serverseitiger-google-zugriff-und-drive-file.md). Das Zielbild steht in [ADR 0013](../entscheidungen/0013-postgresql-als-finanzquelle.md) und [ADR 0014](../entscheidungen/0014-google-oauth-nur-als-identitaet.md); [ADR 0004](../entscheidungen/0004-single-user-sicherheitsmodell.md) bleibt gültig. +Siehe [ADR 0013](../entscheidungen/0013-postgresql-als-finanzquelle.md), [ADR 0014](../entscheidungen/0014-google-oauth-nur-als-identitaet.md) und [ADR 0004](../entscheidungen/0004-single-user-sicherheitsmodell.md). - Konfiguration: [api/_lib/config.ts](../../api/_lib/config.ts) - Gemeinsamer Datenbankzugang: [api/_lib/database.ts](../../api/_lib/database.ts) - HTTP-Grenze: [api/_lib/http.ts](../../api/_lib/http.ts) -- Google-Client: [api/_lib/google.ts](../../api/_lib/google.ts) -- Finance-Service: [api/_lib/financeService.ts](../../api/_lib/financeService.ts) -- Inaktiver PostgreSQL-Reader: [api/_lib/financeRepository.ts](../../api/_lib/financeRepository.ts) +- Google-Identität: [api/_lib/google.ts](../../api/_lib/google.ts) +- PostgreSQL-Finance-Repository: [api/_lib/financeRepository.ts](../../api/_lib/financeRepository.ts) +- Operator-Import: [api/_lib/financeImport.ts](../../api/_lib/financeImport.ts), [scripts/import-finance.ts](../../scripts/import-finance.ts) - Server-Tests: [src/server](../../src/server) diff --git a/docs/architektur/finanz-domaene.md b/docs/architektur/finanz-domaene.md index 8acd638..76bc907 100644 --- a/docs/architektur/finanz-domaene.md +++ b/docs/architektur/finanz-domaene.md @@ -10,24 +10,24 @@ Die Tabelle enthält Quellen und zeitbezogene Snapshots, keine UI-Gesamtsummen. Der Parser validiert Beziehungen und normalisiert Geld in Integer-Cents. Reine Selektoren wählen den fachlich gültigen Stand und berechnen Summen. Das View-Model ergänzt lokalisierte Texte und Screen-Strukturen. React rendert diese Ausgabe. -`FinanceDataV1` ist die quellenunabhängige Domänengrenze: Der einmalige Sheet-Import wird später denselben Vertrag erzeugen wie das bereits implementierte PostgreSQL-Repository. Die produktive API verwendet im aktuellen Übergangsstand weiterhin Sheets; der PostgreSQL-Reader ist nur intern und in Integrationstests erreichbar. Quelle, Owner-Zuordnung und SQL-Grenze legt [ADR 0013](../entscheidungen/0013-postgresql-als-finanzquelle.md) fest. +`FinanceDataV1` ist die quellenunabhängige Domänengrenze: Der einmalige Sheet-Import erzeugt denselben Vertrag wie das produktive PostgreSQL-Repository. Die produktive API liest ausschließlich PostgreSQL. Quelle, Owner-Zuordnung und SQL-Grenze legt [ADR 0013](../entscheidungen/0013-postgresql-als-finanzquelle.md) fest. -## Aktuelle Sheets- und Finance-Datenpipeline +## Aktuelle Finance-Datenpipeline ```mermaid flowchart LR - S[Sheets batchGet A:Z] --> W[TabularWorkbook] - W --> P[Parser + Validierung] - P --> C[FinanceDataV1 / Integer-Cents] + I["Operator-Import: Sheets-batchGet oder FinanceDataV1"] --> P[Parser + Validierung] + P --> R[PostgreSQL-Repository] + R --> C[FinanceDataV1 / Integer-Cents] C --> SEL[reine Selektoren] SEL --> VM[FinanceViewModel] VM --> UI[Overview / Upcoming / Budget / Debt] P -->|Fehler| ISS[tab, row, column, expected] ``` -Implementierung und Tests: [api/_lib/google.ts](../../api/_lib/google.ts), [src/finance/parser.ts](../../src/finance/parser.ts), [src/finance/selectors.ts](../../src/finance/selectors.ts), [src/finance/viewModel.ts](../../src/finance/viewModel.ts), [src/finance/parser.test.ts](../../src/finance/parser.test.ts). +Implementierung und Tests: [api/_lib/financeRepository.ts](../../api/_lib/financeRepository.ts), [src/finance/parser.ts](../../src/finance/parser.ts), [src/finance/selectors.ts](../../src/finance/selectors.ts), [src/finance/viewModel.ts](../../src/finance/viewModel.ts), [src/finance/parser.test.ts](../../src/finance/parser.test.ts). -## PostgreSQL-Abbildung im Übergangsstand +## PostgreSQL-Abbildung ```mermaid flowchart LR diff --git a/docs/architektur/privacy-modus.md b/docs/architektur/privacy-modus.md index 414386e..e9e40af 100644 --- a/docs/architektur/privacy-modus.md +++ b/docs/architektur/privacy-modus.md @@ -26,7 +26,7 @@ Der bestehende Privacy-Modus liegt als String unter `finance-privacy-v1`. Der ve ## Vergessene PIN -Der Reset bleibt ohne Netzwerk bewusst gesperrt. Online wird eine vorhandene Google-Verbindung über eine auf 15 Sekunden begrenzte, abbrechbare Anfrage serverseitig getrennt, die Sitzung zurückgesetzt und der lokale Finance-Cache gelöscht; erst danach entfernt Accura PIN und App-Schutz. Vor der Cache-Löschung rotiert Accura eine profilweite Cache-Generation. Noch laufende Antworten aus anderen Tabs dürfen deshalb keinen vor dem Reset gestarteten Finance-Snapshot erneut speichern. Die Google-Sheets-Datei selbst bleibt unverändert. Schlägt ein Schritt fehl, bleibt die Sperre aktiv. Als äußerste lokale Alternative kann der Nutzer sämtliche Accura-Sitedaten über Browser- oder Android-Einstellungen löschen. +Der Reset bleibt ohne Netzwerk bewusst gesperrt. Online wird die Sitzung über eine auf 15 Sekunden begrenzte, abbrechbare Anfrage beendet und sämtliche lokalen Finance-Cache-Partitionen werden gelöscht; erst danach entfernt Accura PIN und App-Schutz. Vor der Cache-Löschung rotiert Accura eine profilweite Cache-Generation. Noch laufende Antworten aus anderen Tabs dürfen deshalb keinen vor dem Reset gestarteten Finance-Snapshot erneut speichern. Der PostgreSQL-Finanzstand bleibt unverändert. Schlägt ein Schritt fehl, bleibt die Sperre aktiv. Als äußerste lokale Alternative kann der Nutzer sämtliche Accura-Sitedaten über Browser- oder Android-Einstellungen löschen. ## Sicherheitsgrenze diff --git a/docs/architektur/synchronisation-und-offline.md b/docs/architektur/synchronisation-und-offline.md index 71e38fd..b32d5b5 100644 --- a/docs/architektur/synchronisation-und-offline.md +++ b/docs/architektur/synchronisation-und-offline.md @@ -12,42 +12,43 @@ Der Server liefert immer einen vollständigen validierten Snapshot. Der Browser ## Aktualisierungsauslöser -Synchronisiert wird beim Start mit vorhandener Sitzung und Tabelle, nach erfolgreicher Pickerauswahl, manuell, beim `online`-Ereignis und beim Zurückkehren in den sichtbaren Tab, wenn der letzte Erfolg mehr als zehn Minuten zurückliegt. Es gibt kein Polling, keinen Cron und keinen Push-Kanal. +Synchronisiert wird beim Start mit vorhandener Sitzung, manuell, beim `online`-Ereignis und beim Zurückkehren in den sichtbaren Tab, wenn der letzte Erfolg mehr als zehn Minuten zurückliegt. Es gibt kein Polling, keinen Cron und keinen Push-Kanal. ## Race-Schutz -`refreshPromise` dedupliziert gleichzeitige Refresh-Aufrufe. Eine monoton steigende Generation entwertet Antworten älterer Arbeitsabläufe. `AbortController` beendet Requests beim Provider-Unmount, Tabellenwechsel, Logout und Disconnect. Vor und nach dem asynchronen Cache-Schreiben wird die Generation erneut geprüft. So kann eine verspätete alte Antwort weder neue Auswahl noch Abmeldung überschreiben. +`refreshPromise` dedupliziert gleichzeitige Refresh-Aufrufe. Eine monoton steigende Generation entwertet Antworten älterer Arbeitsabläufe. `AbortController` beendet Requests beim Provider-Unmount und Logout. Vor und nach dem asynchronen Cache-Schreiben werden Request-, Owner- und Cache-Generation erneut geprüft. So kann eine verspätete Antwort weder eine neue Identität noch Abmeldung oder lokale Datenbereinigung überschreiben. ## Speicherorte und Lebensdauer ```mermaid flowchart TB subgraph Server - PG[(PostgreSQL: Google-Verbindung\nbis Disconnect)] - SC[HttpOnly Session-Cookie\nbis Logout/Disconnect/Ablauf] + PG[(PostgreSQL Finance-Daten\nbis ausdrueckliche Serverloeschung)] + SC[HttpOnly Session-Cookie\nbis Logout/Ablauf] OC[OAuth-Transaktionscookie\nca. 10 Minuten] end subgraph Browserprofil - FC[(IndexedDB finance-overview\n1 Last-known-good bis Disconnect/Browserloeschung)] + FC[(IndexedDB finance-overview\nownergebundene Last-known-good-Snapshots)] AP[localStorage finance-appearance-v1\nbis Reset/Browserloeschung] WP[(IndexedDB finance-appearance-v1\n0 oder 1 WebP-Vorschau)] PR[localStorage finance-privacy-v1\nbis Aenderung/Browserloeschung] LK[localStorage finance-app-protection-v1\nbis Reset/Browserloeschung] CG[localStorage finance-cache-generation-v1\nzufaellige Cache-Invalidierung ohne Fachdaten] + CO[localStorage active-finance-cache-owner-v1\npseudonyme aktive Cache-Partition] SV[sessionStorage finance-screen-visits-v1\nbis Tab-Ende] SW[(Service-Worker-Cache\nversionierte App-Shell)] end ``` -Implementierung und Tests: [api/_lib/repository.ts](../../api/_lib/repository.ts), [api/_lib/security.ts](../../api/_lib/security.ts), [src/data/financeCache.ts](../../src/data/financeCache.ts), [src/appearance/wallpaperStore.ts](../../src/appearance/wallpaperStore.ts), [src/privacy/privacyStore.ts](../../src/privacy/privacyStore.ts), [src/privacy/appProtectionStore.ts](../../src/privacy/appProtectionStore.ts), [scripts/offline-smoke.mjs](../../scripts/offline-smoke.mjs). +Implementierung und Tests: [api/_lib/security.ts](../../api/_lib/security.ts), [src/data/financeCache.ts](../../src/data/financeCache.ts), [src/appearance/wallpaperStore.ts](../../src/appearance/wallpaperStore.ts), [src/privacy/privacyStore.ts](../../src/privacy/privacyStore.ts), [src/privacy/appProtectionStore.ts](../../src/privacy/appProtectionStore.ts), [scripts/offline-smoke.mjs](../../scripts/offline-smoke.mjs). ## Service-Worker-Grenze -Workbox precacht statische HTML-, JavaScript-, CSS-, SVG-, PNG- und WOFF2-Artefakte. Navigation fällt auf `index.html` zurück. `/api/*` ist von diesem Fallback ausgeschlossen und verwendet `NetworkOnly`. Der fachliche Cache liegt separat in `finance-overview`, Object Store `last-good`, Schlüssel `finance-data-v1`, und wird beim Lesen erneut mit Zod validiert. +Workbox precacht statische HTML-, JavaScript-, CSS-, SVG-, PNG- und WOFF2-Artefakte. Navigation fällt auf `index.html` zurück. `/api/*` ist von diesem Fallback ausgeschlossen und verwendet `NetworkOnly`. Der fachliche Cache liegt separat in `finance-overview`, Object Store `last-good`. Jeder Eintrag trägt einen serverseitig per HMAC aus der verifizierten Google-Subjekt-ID abgeleiteten pseudonymen Owner-Schlüssel und wird nur für exakt diese Partition geladen. Der alte globale Schlüssel `finance-data-v1` wird beim Datenbank-Upgrade gelöscht. Jeder Snapshot wird beim Lesen erneut mit Zod validiert. ## Fehler und Sicherheitsannahmen -Wenn IndexedDB nicht verfügbar ist, funktioniert Online-Nutzung weiter, aber kein fachlicher Offline-Start. Ein Last-known-good-Snapshot kann vertrauliche Finanzdaten enthalten und ist nicht verschlüsselt. Browserbereinigung oder Speicherdruck können ihn entfernen. Logout lässt ihn bewusst für späteren Offline-/Wiederanmeldestart bestehen; Disconnect löscht ihn nur auf dem aktuellen Gerät. Ein vergessener PIN wird nur online zurückgesetzt und löscht zuerst Verbindung, Sitzung und diesen Finance-Cache; ohne bestätigte Bereinigung bleibt die Sperre aktiv. Die Recovery rotiert davor die profilweite Cache-Generation. Jeder Sync darf nur mit der bei seinem Start gelesenen Generation persistieren, sodass verspätete Antworten anderer Tabs den gelöschten Snapshot nicht wiederherstellen. +Wenn IndexedDB nicht verfügbar ist, funktioniert Online-Nutzung weiter, aber kein fachlicher Offline-Start. Ein Last-known-good-Snapshot kann vertrauliche Finanzdaten enthalten und ist nicht verschlüsselt. Browserbereinigung oder Speicherdruck können ihn entfernen. Beim Start wird online zuerst die Sitzung geprüft und erst danach ausschließlich der zu dieser Identität gehörende Cache geladen; ein vorheriger Owner kann dadurch nicht kurz sichtbar werden. Logout deaktiviert die aktive Cache-Partition, lässt den ownergebundenen Snapshot aber für eine spätere verifizierte Wiederanmeldung bestehen. Ein anschließender Offline-Start zeigt ihn nicht an. Eine vergessene PIN wird nur online zurückgesetzt und löscht Sitzung und sämtliche lokalen Finance-Cache-Partitionen; ohne bestätigte Bereinigung bleibt die Sperre aktiv. Die Recovery rotiert davor die profilweite Cache-Generation. Jeder Sync darf nur mit Owner und Generation seines Starts persistieren, sodass verspätete Antworten anderer Tabs den gelöschten Snapshot nicht wiederherstellen. ## Begründung und Nachweis diff --git a/docs/architektur/tests-und-qualitaet.md b/docs/architektur/tests-und-qualitaet.md index f111a54..1a27216 100644 --- a/docs/architektur/tests-und-qualitaet.md +++ b/docs/architektur/tests-und-qualitaet.md @@ -39,7 +39,7 @@ Unter `tests/visual/__screenshots__/chromium` liegen 38 Referenzbilder für 412, ## Fehlerfälle und Grenzen -Golden Screens hängen an Browser-/Fontdeterminismus; Updates dürfen nur nach bewusster visueller Prüfung committed werden. Axe findet nicht jede Barriere. Mock-Smokes beweisen keine echte Google- oder Vercel-Konfiguration. Die PostgreSQL-Suite beweist Standard-SQL, Constraints und Reader gegen eine echte temporäre Instanz, aber weder Neon-Region/Rollen/Restore noch produktive Daten. Reale OAuth-, Picker-, Sheets- und Disconnect-Abläufe bleiben Betreiber-Abnahme. Desktop-Chromium beweist außerdem nicht die tatsächlich von Android gerenderten Installations-, Launcher-, Task-Switcher- und Splash-Flächen; ACC-7 deckt stattdessen deren Web-Verträge automatisiert ab. +Golden Screens hängen an Browser-/Fontdeterminismus; Updates dürfen nur nach bewusster visueller Prüfung committed werden. Axe findet nicht jede Barriere. Mock-Smokes beweisen keine echte Google- oder Vercel-Konfiguration. Die PostgreSQL-Suite beweist Standard-SQL, Constraints und Reader gegen eine echte temporäre Instanz, aber weder Neon-Region/Rollen/Restore noch produktive Daten. Reale OAuth- und Import-Abläufe bleiben Betreiber-Abnahme. Desktop-Chromium beweist außerdem nicht die tatsächlich von Android gerenderten Installations-, Launcher-, Task-Switcher- und Splash-Flächen; ACC-7 deckt stattdessen deren Web-Verträge automatisiert ab. ## Implementierung und Tests diff --git a/docs/architektur/ueberblick.md b/docs/architektur/ueberblick.md index 4ca0bba..96342fa 100644 --- a/docs/architektur/ueberblick.md +++ b/docs/architektur/ueberblick.md @@ -8,13 +8,11 @@ ## Verbindliche Richtung -Der beschlossene Quellenwechsel trennt Finanzquelle und Identität: PostgreSQL wird die einzige produktive Finanzquelle, Google Sheets bleibt ein einmaliges Importformat und Google OAuth dient danach nur noch der Anmeldung. Der vollständige Browservertrag bleibt `FinanceDataV1`; `owner_id` existiert ausschließlich in der Persistenz. Finanzberechnungen und Snapshot-Auswahl bleiben außerhalb von SQL. Verbindlich sind [ADR 0013](../entscheidungen/0013-postgresql-als-finanzquelle.md) und [ADR 0014](../entscheidungen/0014-google-oauth-nur-als-identitaet.md). +PostgreSQL ist die einzige produktive Finanzquelle. Google Sheets bleibt ein einmaliges Importformat. Google OAuth dient nur der Anmeldung. Der vollständige Browservertrag bleibt `FinanceDataV1`; `owner_id` existiert ausschließlich in der Persistenz. Finanzberechnungen und Snapshot-Auswahl bleiben außerhalb von SQL. Verbindlich sind [ADR 0013](../entscheidungen/0013-postgresql-als-finanzquelle.md) und [ADR 0014](../entscheidungen/0014-google-oauth-nur-als-identitaet.md). -Dieses Zielbild ist noch nicht vollständig implementiert. Bis Schema, Import und Cutover abgeschlossen sind, gilt der folgende Abschnitt als Beschreibung des laufenden Systems; neue Arbeit darf daraus keine fortbestehende Bindung an Sheets ableiten. +## Aktuell implementierter Stand -## Aktuell implementierter Übergangsstand - -`accura` besteht aus einer React-PWA im Browser, same-origin Vercel Functions, PostgreSQL und Google-Diensten. Die Tabelle ist die Finanzquelle; PostgreSQL speichert nur die Verbindung. Der Server bildet die Sicherheits- und Validierungsgrenze. Der Browser erhält ausschließlich normalisierte Finanzdaten und zeigt daraus abgeleitete View-Models. +`accura` besteht aus einer React-PWA im Browser, same-origin Vercel Functions und PostgreSQL. Google ist ausschließlich Identitätsanbieter. Der Server bildet die Sicherheits- und Validierungsgrenze. Der Browser erhält ausschließlich normalisierte Finanzdaten und zeigt daraus abgeleitete View-Models. ## Systemkontext und Vertrauensgrenzen @@ -22,9 +20,8 @@ Dieses Zielbild ist noch nicht vollständig implementiert. Bis Schema, Import un flowchart LR U[Freigegebene Person] -->|bedient| B[Browser / React-PWA] B -->|HTTPS, Session-Cookie, CSRF| V[Vercel Functions] - V -->|OAuth / kurzlebige Access-Token| G[Google OAuth, Drive, Picker, Sheets] - V -->|verschlüsseltes Refresh-Token + Dateireferenz| P[(PostgreSQL)] - G -->|zehn Tabellenbereiche| V + V -->|OAuth ID-Token| G[Google OAuth] + V -->|ownergebundene Finance-Zeilen| P[(PostgreSQL)] V -->|FinanceDataV1| B B -->|Last-known-good| I[(IndexedDB)] B -->|Appearance / Privacy / App-Schutz| L[(localStorage)] @@ -40,23 +37,23 @@ flowchart LR end ``` -Implementierung und Tests: [src/main.tsx](../../src/main.tsx), [api/_lib/http.ts](../../api/_lib/http.ts), [api/_lib/financeService.ts](../../api/_lib/financeService.ts), [scripts/auth-sw-smoke.mjs](../../scripts/auth-sw-smoke.mjs). +Implementierung und Tests: [src/main.tsx](../../src/main.tsx), [api/_lib/http.ts](../../api/_lib/http.ts), [api/_lib/financeRepository.ts](../../api/_lib/financeRepository.ts), [scripts/auth-sw-smoke.mjs](../../scripts/auth-sw-smoke.mjs). ## Startvorgang 1. `index.html` stellt Root-Element, Manifest und frühe Theme-Metadaten bereit. 2. `src/main.tsx` registriert den Service Worker und liest Appearance, Privacy und App-Schutz vor dem ersten React-Render, damit weder Theme noch eine konfigurierte Sperre sichtbar nachladen. 3. React mountet unter `StrictMode` die Provider in der Reihenfolge Privacy → Appearance → FinanceData. -4. `FinanceDataProvider` lädt parallel fachlich zuerst den Cache und prüft danach die Sitzung. Eine vorhandene Auswahl löst einen Sync aus. -5. `App` zeigt eine Connection-State-Seite oder die vier Ziele. Nur die Übersicht ist initial geladen; weitere Ziele werden lazy importiert. +4. `FinanceDataProvider` lädt parallel fachlich zuerst den Cache und prüft danach die Sitzung. Eine gültige Sitzung löst einen Finance-Read aus. +5. `App` zeigt eine Verbindungs-/Leerseite oder die vier Ziele. Nur die Übersicht ist initial geladen; weitere Ziele werden lazy importiert. ## Hauptdatenfluss -Google Sheets → Vercel Function → Tabellenparser → `FinanceDataV1` → Provider → Selektoren/View-Model → React-Screen. Nur eine vollständig gültige Antwort ersetzt den Browsercache. Schreibende App-Aktionen betreffen ausschließlich Verbindung, Tabellenauswahl und Sitzung, nicht die Finanzzeilen. +PostgreSQL → Vercel Function → validiertes `FinanceDataV1` → Provider → Selektoren/View-Model → React-Screen. Nur eine vollständig gültige Antwort ersetzt den Browsercache. Schreibende App-Aktionen betreffen die Sitzung, nicht die Finanzzeilen. Der einmalige Operator-Import schreibt den vollständigen v1-Stand außerhalb der Produkt-UI. ## Architekturentscheidungen -Die Gründe sind in [ADRs](../entscheidungen/README.md) festgehalten. Für die nächste Arbeit sind PostgreSQL als Finanzquelle, Google OAuth nur als Identität, die versionierte Integer-Cent-Domäne, Single-User-Sicherheit und Last-known-good-Offline zentral. ADR 0001 und ADR 0003 erklären nur noch den implementierten historischen Ausgangspunkt. +Die Gründe sind in [ADRs](../entscheidungen/README.md) festgehalten. Für die nächste Arbeit sind PostgreSQL als Finanzquelle, Google OAuth nur als Identität, die versionierte Integer-Cent-Domäne, Single-User-Sicherheit und Last-known-good-Offline zentral. ADR 0001 und ADR 0003 erklären nur noch den historischen Ausgangspunkt. ## Grenzen und Sicherheitsannahmen diff --git a/docs/entscheidungen/0003-serverseitiger-google-zugriff-und-drive-file.md b/docs/entscheidungen/0003-serverseitiger-google-zugriff-und-drive-file.md index 4a62d89..d072f1e 100644 --- a/docs/entscheidungen/0003-serverseitiger-google-zugriff-und-drive-file.md +++ b/docs/entscheidungen/0003-serverseitiger-google-zugriff-und-drive-file.md @@ -36,5 +36,5 @@ Backend, PostgreSQL, OAuth-Consent und Reconnect-Behandlung sind erforderlich. ## Implementierung und Tests -- Implementierung: [api/_lib/google.ts](../../api/_lib/google.ts), [api/_lib/financeService.ts](../../api/_lib/financeService.ts) -- Tests: [src/server/google.test.ts](../../src/server/google.test.ts), [src/server/financeService.test.ts](../../src/server/financeService.test.ts) +- Historische Begründung; ersetzt durch [ADR 0014](0014-google-oauth-nur-als-identitaet.md) +- Aktuelle Identität: [api/_lib/google.ts](../../api/_lib/google.ts), [src/server/google.test.ts](../../src/server/google.test.ts) diff --git a/docs/entscheidungen/0005-last-known-good-und-offline.md b/docs/entscheidungen/0005-last-known-good-und-offline.md index 1f4cca4..adeeef9 100644 --- a/docs/entscheidungen/0005-last-known-good-und-offline.md +++ b/docs/entscheidungen/0005-last-known-good-und-offline.md @@ -14,7 +14,7 @@ Die private Finanzübersicht soll nach erfolgreichem Sync auch ohne Netz nützli ## Entscheidung -Der Browser speichert genau den letzten vollständig validierten `FinanceDataV1`-Snapshot in IndexedDB. Refreshfehler behalten ihn sichtbar veraltet; der Service Worker cached keine API-Antwort. +Der Browser speichert pro pseudonymem Owner genau den letzten vollständig validierten `FinanceDataV1`-Snapshot in IndexedDB. Der Owner-Schlüssel wird serverseitig per HMAC aus der verifizierten Google-Identität abgeleitet; der Browser lädt online erst nach Sitzungsauflösung ausschließlich diese Partition. Refreshfehler behalten den passenden Snapshot sichtbar veraltet; der Service Worker cached keine API-Antwort. ## Begründung @@ -32,7 +32,7 @@ Offline-Start, robuste Fehlerzustände, erneut validierter lokaler Vertrag. ### Negativ -Unverschlüsselte sensitive Gerätedaten, mögliche Veraltung und Browserlöschung; Disconnect löscht nur dieses Gerät. +Unverschlüsselte sensitive Gerätedaten, mögliche Veraltung und Browserlöschung. Die Partition verhindert versehentliches Anzeigen zwischen Identitäten, aber keinen Zugriff durch Code oder Personen mit Zugriff auf dasselbe Browserprofil. ## Implementierung und Tests diff --git a/docs/entscheidungen/0011-lokaler-privacy-modus.md b/docs/entscheidungen/0011-lokaler-privacy-modus.md index 0bd48de..d7ccbe7 100644 --- a/docs/entscheidungen/0011-lokaler-privacy-modus.md +++ b/docs/entscheidungen/0011-lokaler-privacy-modus.md @@ -14,7 +14,7 @@ Geldbeträge sollen in gemeinsam genutzten oder einsehbaren Umgebungen schnell g ## Entscheidung -Ein lokaler Umschalter maskiert Geldwerte in sichtbarer UI und Accessibility-Text. Die boolesche Einstellung liegt als String unter `finance-privacy-v1` in `localStorage`, synchronisiert Tabs und bleibt bei Logout/Disconnect. +Ein lokaler Umschalter maskiert Geldwerte in sichtbarer UI und Accessibility-Text. Die boolesche Einstellung liegt als String unter `finance-privacy-v1` in `localStorage`, synchronisiert Tabs und bleibt bei Logout. ## Begründung diff --git a/docs/entscheidungen/0012-app-vorschau-und-lokaler-pin-lock.md b/docs/entscheidungen/0012-app-vorschau-und-lokaler-pin-lock.md index 33f76a8..458786c 100644 --- a/docs/entscheidungen/0012-app-vorschau-und-lokaler-pin-lock.md +++ b/docs/entscheidungen/0012-app-vorschau-und-lokaler-pin-lock.md @@ -16,7 +16,7 @@ Finanzdaten können in der Betriebssystem-App-Vorschau oder unmittelbar nach der Accura bietet zwei standardmäßig deaktivierte Einstellungen: einen Lifecycle-gesteuerten App-Vorschau-Schutz und einen darauf aufbauenden sechsstelligen PIN-Lock. `visibilitychange` zu `hidden` und `pagehide` verdecken die App synchron; der reine Sichtschutz verlangt eine bewusste Freigabe. Ein PIN sperrt zusätzlich Kaltstart und Reload. -Gespeichert wird ausschließlich ein versionierter PBKDF2-HMAC-SHA-256-Verifier mit zufälligem Salt, 600.000 Iterationen, Fehlversuchszähler und exponentieller Sperrfrist. PIN und Finance-Daten werden nicht gemeinsam verschlüsselt. Eine vergessene PIN kann nur online zurückgesetzt werden: vorhandene Google-Verbindung und Sitzung werden bereinigt, der lokale Finance-Cache wird gelöscht und erst danach fällt die lokale Sperre. Die Google-Sheets-Datei wird nicht verändert. +Gespeichert wird ausschließlich ein versionierter PBKDF2-HMAC-SHA-256-Verifier mit zufälligem Salt, 600.000 Iterationen, Fehlversuchszähler und exponentieller Sperrfrist. PIN und Finance-Daten werden nicht gemeinsam verschlüsselt. Eine vergessene PIN kann nur online zurückgesetzt werden: Die Sitzung wird beendet, sämtliche lokalen Finance-Cache-Partitionen werden gelöscht und erst danach fällt die lokale Sperre. Der PostgreSQL-Finanzstand wird nicht verändert. Der Lockscreen verwendet eine einzelne flächige Theme-Hintergrundfarbe ohne Logo und eine Android-orientierte Ziffernanordnung. Noch nicht eingegebene PIN-Stellen bleiben unsichtbar. Neue Stellen erscheinen aus der Mitte als zufällige Material-3-Expressive-Formen der MIT-lizenzierten Bibliothek [`shape-morph`](https://github.com/Thereallo1026/shape-morph), morphen kurz zum Kreis und enden bei 16 × 16 Pixeln; Reduced Motion und Forced Colors besitzen explizite Fallbacks. diff --git a/docs/entscheidungen/0013-postgresql-als-finanzquelle.md b/docs/entscheidungen/0013-postgresql-als-finanzquelle.md index f5105e4..f584e58 100644 --- a/docs/entscheidungen/0013-postgresql-als-finanzquelle.md +++ b/docs/entscheidungen/0013-postgresql-als-finanzquelle.md @@ -42,14 +42,7 @@ SQL erzwingt strukturelle Integrität, Typen, Eindeutigkeit und Owner-gebundene ## Übergang -Diese ADR legt das verbindliche Zielbild fest, beschreibt aber nicht den bereits implementierten Zustand. Bis zum Cutover darf der vorhandene Sheets-Lesepfad unverändert weiterlaufen; neue Funktionen bauen ihn nicht weiter aus. - -Die Umsetzung erfolgt in klaren Schritten: - -1. ACC-71 erstellt Schema und Repository-Lesepfad, ohne die fachliche Grenze zu ändern. -2. ACC-29 weist mit derselben anonymen Fixture identische Cents, Fälligkeiten und Snapshot-Auswahl für Parser- und PostgreSQL-Pfad nach. Dieser Nachweis ist umgesetzt. -3. ACC-66 importiert den privaten Bestand vollständig, prüft die Parität und schaltet die einzige produktive Quelle auf PostgreSQL um. -4. ACC-72 baut anschließend den eng begrenzten In-App-Editor. +ACC-71, ACC-29 und ACC-66 sind umgesetzt: Schema, Reader, Paritätsnachweis, produktiver `/api/finance`-Read und Operator-Import existieren. Google Sheets ist nur noch Importformat. ACC-72 baut anschließend den In-App-Editor. Der Cutover ist eindeutig. Ein dauerhafter Feature-Flag-Dualbetrieb oder stiller Rückfall auf Sheets ist nicht vorgesehen. @@ -75,7 +68,7 @@ Schema, Migration, Repository und Schreibgrenzen müssen sorgfältig umgesetzt u ## Implementierung und Tests -- Domänenvertrag und bestehender Produktionspfad: [FinanceDataV1](../../src/finance/types.ts), [Sheets-Parser](../../src/finance/parser.ts), [Finance-Service](../../api/_lib/financeService.ts) -- Umgesetzter ACC-71-Stand: [Migration 002](../../migrations/002_finance_data_v1.sql), [PostgreSQL-Reader](../../api/_lib/financeRepository.ts), [Integrationstest](../../tests/postgres/financeRepository.postgres.test.ts) -- Umgesetzter ACC-29-Nachweis: dieselbe Suite vergleicht Cents, `salaryDay`/`dueDay` und `selectLatest*Snapshot` von Parser- und PostgreSQL-Pfad an der anonymen Fixture -- Weitere Nachweise und Cutover: ACC-66 und ACC-72 in Linear +- Domänenvertrag: [FinanceDataV1](../../src/finance/types.ts), [Sheets-Parser als Import](../../src/finance/parser.ts) +- Persistenz und produktiver Read/Write: [Migration 002](../../migrations/002_finance_data_v1.sql), [Migration 003](../../migrations/003_drop_google_connections.sql), [PostgreSQL-Repository](../../api/_lib/financeRepository.ts), [Operator-Import](../../scripts/import-finance.ts) +- Paritätsnachweis: [Integrationstest](../../tests/postgres/financeRepository.postgres.test.ts) +- Nächster Produktschritt: ACC-72 in Linear diff --git a/docs/entscheidungen/0014-google-oauth-nur-als-identitaet.md b/docs/entscheidungen/0014-google-oauth-nur-als-identitaet.md index db6427e..e01145c 100644 --- a/docs/entscheidungen/0014-google-oauth-nur-als-identitaet.md +++ b/docs/entscheidungen/0014-google-oauth-nur-als-identitaet.md @@ -14,19 +14,19 @@ Der implementierte OAuth-Fluss fordert `drive.file`, erzwingt ein Refresh-Token ## Entscheidung -Google OAuth dient nach dem Cutover ausschließlich der Authentifizierung. Der Authorization-Code-Fluss mit State, Nonce und PKCE bleibt; der Ziel-Scope ist `openid email profile`. ID-Token-Signatur, Issuer, Audience, Nonce, verifizierte E-Mail und Allowlist werden weiterhin serverseitig geprüft. +Google OAuth dient ausschließlich der Authentifizierung. Der Authorization-Code-Fluss mit State, Nonce und PKCE verwendet `openid email profile`. ID-Token-Signatur, Issuer, Audience, Nonce, verifizierte E-Mail und Allowlist werden serverseitig geprüft. Google `sub` identifiziert die externe Anmeldung und wird eindeutig einem internen `owners.id` zugeordnet. Die signierte Sitzung trägt weiterhin die verifizierte Identität; Finanzzugriffe lösen daraus serverseitig den Owner auf. ADR 0004 bleibt für diesen Schnitt unverändert: Genau eine konfigurierte Identität darf eine Sitzung erhalten. -Picker, `drive.file`, Drive-/Sheets-Laufzeitzugriff und die Pflicht zu einem dauerhaft gespeicherten Refresh-Token entfallen. Ein beim OAuth-Tausch erhaltenes kurzlebiges Access-Token wird nicht für Finance persistiert. Die bestehenden Picker-Endpunkte, Spreadsheet-Zustände und Tokenfelder sind Übergangscode und werden beim eindeutigen Cutover entfernt. +Picker, `drive.file`, Drive-/Sheets-Laufzeitzugriff, Spreadsheet-Zustände und dauerhaft gespeicherte Refresh-Tokens sind entfernt. Ein beim OAuth-Tausch erhaltenes kurzlebiges Access-Token wird nicht für Finance persistiert. Der einmalige Import aus ACC-66 ist ein kontrollierter Operator-Pfad außerhalb der normalen Produktnutzung. Er kann eine lokale oder einmalig gelesene Tabellenrepräsentation an den bestehenden `validateFinanceWorkbook()`-Parser übergeben, speichert aber keinen dauerhaften Google-Grant und führt keinen Hintergrundsync ein. -Logout beendet weiterhin nur die Sitzung. Disconnect- und PIN-Recovery-Verhalten müssen beim Cutover neu benannt und so angepasst werden, dass das Entfernen einer Google-Anmeldung nicht beiläufig PostgreSQL-Finanzdaten löscht. Löschung oder Export der Finanzdaten benötigen später eine eigene ausdrückliche Aktion. +Logout beendet die Sitzung und deaktiviert den lokalen Cache-Owner, löscht aber weder den ownergebundenen Browser-Cache noch PostgreSQL-Finanzdaten. Eine erneute verifizierte Anmeldung derselben Identität kann den Cache wieder zuordnen. Die PIN-Recovery beendet die Sitzung und löscht den lokalen Finance-Cache; Löschung oder Export der serverseitigen Finanzdaten benötigen eine eigene ausdrückliche Aktion. ## Übergang -Diese ADR ist der verbindliche Zielzustand. Bis ACC-66 den importierten Datenbestand verifiziert und den Cutover ausführt, bleibt der bestehende `drive.file`- und Refresh-Token-Fluss funktionsfähig. Er wird nicht für neue Features erweitert. Erst der Cutover entfernt ihn aus Code, Konfiguration, API, UI und Betriebsdokumentation. +Diese ADR ist umgesetzt: OAuth fordert nur noch `openid email profile`, Picker und persistierte Refresh-Tokens sind entfernt, `/api/finance` liest PostgreSQL. Der einmalige Import bleibt ein Operator-Pfad. ## Begründung @@ -48,5 +48,5 @@ Der einmalige Import benötigt einen bewusst betriebenen Pfad. Der spätere Invi ## Implementierung und Tests -- Aktueller, noch zu ersetzender Fluss: [Google-Client](../../api/_lib/google.ts), [OAuth-Callback](../../api/auth/google/callback.ts), [Google-Verbindungsrepository](../../api/_lib/repository.ts) -- Geplanter Cutover: ACC-66; spätere Invite-only-Erweiterung: ACC-64 +- Identitätsfluss: [Google-Client](../../api/_lib/google.ts), [OAuth-Callback](../../api/auth/google/callback.ts) +- Spätere Invite-only-Erweiterung: ACC-64 diff --git a/docs/grundlagen/daten-validierung-und-speicher.md b/docs/grundlagen/daten-validierung-und-speicher.md index 794a5c8..9ad33c8 100644 --- a/docs/grundlagen/daten-validierung-und-speicher.md +++ b/docs/grundlagen/daten-validierung-und-speicher.md @@ -16,17 +16,17 @@ Jede Systemgrenze liefert zunächst unbekannte Daten: Google Sheets, HTTP-Antwor | Speicher | Geeignet für | Lebensdauer/Grenze in accura | | --- | --- | --- | -| signiertes `HttpOnly`-Cookie | Sitzung | Browser sendet es same-origin; JavaScript kann es nicht lesen; Logout/Disconnect löschen es | +| signiertes `HttpOnly`-Cookie | Sitzung | Browser sendet es same-origin; JavaScript kann es nicht lesen; Logout oder Ablauf löschen es | | kurzlebiges OAuth-Cookie | State, Nonce, PKCE-Verifier | etwa zehn Minuten, nur während Anmeldung | -| `localStorage` | kleine Geräteeinstellungen | Appearance, Privacy, versionierter App-Schutz/PIN-Verifier und eine fachinhaltsfreie Cache-Generation für tabübergreifende Recovery-Invalidierung; bleibt bei Logout/Disconnect | +| `localStorage` | kleine Geräteeinstellungen | Appearance, Privacy, versionierter App-Schutz/PIN-Verifier, fachinhaltsfreie Cache-Generation und pseudonyme aktive Owner-Partition; der Owner-Marker wird bei Logout entfernt | | `sessionStorage` | Tab-Sitzung | besuchte Screens für einmalige Entrance-Motion | -| IndexedDB | strukturierte größere lokale Daten | ein Finance-Snapshot und optional eine reduzierte Wallpaper-Vorschau in getrennten Datenbanken | +| IndexedDB | strukturierte größere lokale Daten | pro pseudonymem Owner höchstens ein Finance-Snapshot und optional eine reduzierte Wallpaper-Vorschau in getrennten Datenbanken | | Service-Worker-Cache | statische App-Shell | keine `/api/*`-Antworten | -| PostgreSQL | serverseitige Google-Verbindung | verschlüsseltes Refresh-Token und gewählte Datei, keine Finanzzeilen | +| PostgreSQL | serverseitige Finanzquelle | Owner-Zuordnung und ownergebundene normalisierte Finanzzeilen; keine Google-Tokens | ## Grenzen -Browserdaten sind nicht automatisch verschlüsselt und können durch Gerätezugriff, Browserprofile, DevTools oder Schadsoftware zugänglich sein. Browser dürfen lokalen Speicher löschen. Cookies verhindern nicht allein CSRF; dafür braucht es Origin- und Token-Prüfung. Verschlüsselung eines Refresh-Tokens in PostgreSQL schützt nicht den bereits laufenden Serverprozess mit Schlüsselzugriff. +Browserdaten sind nicht automatisch verschlüsselt und können durch Gerätezugriff, Browserprofile, DevTools oder Schadsoftware zugänglich sein. Die Owner-Partition verhindert versehentliches Anzeigen zwischen Anmeldungen, ist aber keine Verschlüsselung oder Zugriffskontrolle gegen Code im selben Origin. Browser dürfen lokalen Speicher löschen. Cookies verhindern nicht allein CSRF; dafür braucht es Origin- und Token-Prüfung. ## Implementierung und Tests diff --git a/docs/grundlagen/web-sicherheit-und-oauth.md b/docs/grundlagen/web-sicherheit-und-oauth.md index 6c4e0ef..fec6129 100644 --- a/docs/grundlagen/web-sicherheit-und-oauth.md +++ b/docs/grundlagen/web-sicherheit-und-oauth.md @@ -8,9 +8,9 @@ ## Mentales Modell -OAuth delegiert begrenzten Zugriff: Der Nutzer bestätigt bei Google, dass `accura` ausgewählte Drive-Dateien verwenden darf. Das Google-Passwort wird nie an `accura` übermittelt. Ein Authorization Code gelangt zum Server und wird dort gegen Token getauscht. +OAuth bestätigt hier ausschließlich die Identität: Der Nutzer meldet sich bei Google mit `openid email profile` an. Das Google-Passwort wird nie an `accura` übermittelt. Ein Authorization Code gelangt zum Server und wird dort gegen ein ID-Token und gegebenenfalls ein kurzlebiges Access-Token getauscht; accura speichert keinen Google-Token dauerhaft. -**State** bindet Callback und gestartete Anmeldung zusammen und erschwert Login-CSRF. **Nonce** bindet das ID-Token an die Transaktion. **PKCE** bindet den Authorization Code an einen zuvor erzeugten geheimen Verifier. Das Refresh-Token erlaubt spätere Access-Token und ist deshalb ein langlebiges Server-Secret. +**State** bindet Callback und gestartete Anmeldung zusammen und erschwert Login-CSRF. **Nonce** bindet das ID-Token an die Transaktion. **PKCE** bindet den Authorization Code an einen zuvor erzeugten geheimen Verifier. Ein dauerhaftes Google-Refresh-Token wird für den identity-only Ablauf weder angefordert noch gespeichert. CSRF ist das Auslösen einer authentifizierten Aktion aus einer fremden Website. `SameSite=Lax`-Cookies helfen, reichen aber für schreibende Endpunkte nicht als einziger Vertrag. `accura` verlangt zusätzlich einen zur signierten Sitzung gehörenden CSRF-Header und die exakte konfigurierte Origin. @@ -18,14 +18,13 @@ CSRF ist das Auslösen einer authentifizierten Aktion aus einer fremden Website. - Geheimnisse bleiben serverseitig und erhalten nie ein `VITE_`-Präfix. - Eingaben werden an jeder Grenze validiert; Fehlermeldungen geben keine internen Geheimnisse preis. -- Least Privilege: `drive.file` statt vollständigem Drive-Zugriff. +- Least Privilege: nur die tatsächlich benötigten Scopes; in accura nach dem Cutover `openid email profile`. - Single-User-Allowlist wird serverseitig anhand verifizierter E-Mail geprüft. -- Token werden bei Speicherung mit AES-256-GCM verschlüsselt und an die Google-Subjekt-ID gebunden. - HTTPS ist in Produktion Pflicht; Cookie-Flags und genaue Redirect-URIs hängen davon ab. ## Was nicht garantiert wird -OAuth ist keine lokale Datenverschlüsselung. CSRF-Schutz verhindert nicht XSS. Tokenverschlüsselung schützt nicht gegen einen vollständig kompromittierten Server samt Schlüssel. `drive.file` begrenzt die sichtbaren Dateien, ersetzt aber nicht die serverseitige MIME- und Schemaprüfung. +OAuth ist keine lokale Datenverschlüsselung. CSRF-Schutz verhindert nicht XSS. Ein Session-Secret schützt nicht gegen einen vollständig kompromittierten Server samt Schlüssel. ## Implementierung und Tests diff --git a/docs/grundlagen/web-und-pwa.md b/docs/grundlagen/web-und-pwa.md index 5330bea..0924454 100644 --- a/docs/grundlagen/web-und-pwa.md +++ b/docs/grundlagen/web-und-pwa.md @@ -8,7 +8,7 @@ ## Mentales Modell -Der Browser lädt HTML, CSS und JavaScript vom Server. JavaScript baut daraus die Oberfläche und sendet HTTP-Anfragen an `/api/*`. Diese Endpunkte laufen nicht im Browser, sondern als Vercel Functions. Dadurch können Server-Secrets und Google-Refresh-Token außerhalb des Geräts bleiben. +Der Browser lädt HTML, CSS und JavaScript vom Server. JavaScript baut daraus die Oberfläche und sendet HTTP-Anfragen an `/api/*`. Diese Endpunkte laufen nicht im Browser, sondern als Vercel Functions. Dadurch bleiben Client-Secret, Session-Secret und Datenbankzugang außerhalb des Geräts; Google-Tokens werden nicht dauerhaft gespeichert. HTTP ist ein Anfrage-Antwort-Protokoll. Eine Methode wie `GET` liest, `PUT` ersetzt oder setzt eine Ressource und `POST` löst eine Aktion aus. Statuscodes wie 200, 401, 409, 422 und 500 beschreiben das Ergebnis. HTTPS verschlüsselt den Transport, aber nicht automatisch lokale Browserdaten. diff --git a/docs/produkt/ablaeufe-und-zustaende.md b/docs/produkt/ablaeufe-und-zustaende.md index 8ede115..5fc9272 100644 --- a/docs/produkt/ablaeufe-und-zustaende.md +++ b/docs/produkt/ablaeufe-und-zustaende.md @@ -12,16 +12,13 @@ | --- | --- | --- | | Sitzungsprüfung | Ladeansicht „Verbindung wird geprüft“ | abwarten | | Abgemeldet | Google-Anmeldung wird angeboten | anmelden | -| Google-Verbindung fehlt | gültige Sitzung, aber kein Postgres-Datensatz | neu verbinden | -| Keine Tabelle gewählt | Picker-Aktion, keine Finanzansichten | Tabelle auswählen | -| Picker geöffnet | Auswahl/Prüfung läuft; konkurrierender Sync wird abgebrochen | auswählen oder abbrechen | +| Finanzstand fehlt | gültige Sitzung, aber kein `finance_meta` | Operator-Import ausführen | | Synchronisierung ohne Daten | Ladeansicht | abwarten | | Aktuell | Finanzansichten, `stale=false` | normal verwenden | | Last-known-good/veraltet | alter Stand bleibt sichtbar, Statusbanner warnt | aktualisieren | | Offline mit Cache | alter Stand bleibt sichtbar | Verbindung wiederherstellen | | Offline ohne Cache | „Noch kein lokaler Datenstand“ | online erstmals synchronisieren | -| Ungültiges Schema | konkrete Tab-/Zeilen-/Spaltenprobleme | Tabelle korrigieren oder wechseln | -| Autorisierung abgelaufen/widerrufen | „Google erneut verbinden“ | OAuth erneut ausführen | +| Ungültiger Finanzstand | generische Integritätsmeldung ohne Werte | Operator-Datenstand prüfen | | Netzwerk-/Serverfehler mit Daten | Daten bleiben sichtbar und als veraltet markiert | später erneut laden | | Netzwerk-/Serverfehler ohne Daten | zentrale Fehler-/Einrichtungsansicht | Ursache beheben | | Privacy aus/ein | Geldbeträge sichtbar/maskiert | Umschalter betätigen | @@ -44,19 +41,17 @@ stateDiagram-v2 Pruefen --> Abgemeldet: keine Sitzung Pruefen --> OfflineLeer: Netzwerkfehler, kein Cache Pruefen --> Veraltet: Cache vorhanden - Pruefen --> KeineVerbindung: Sitzung ohne Google-Verbindung - Pruefen --> KeineTabelle: Verbindung ohne Auswahl - Pruefen --> Synchronisieren: Auswahl vorhanden + Pruefen --> KeinFinanzstand: Sitzung ohne finance_meta + Pruefen --> Synchronisieren: Sitzung vorhanden Veraltet --> Synchronisieren: Sitzung/Netz verfügbar Synchronisieren --> Aktuell: validierte Antwort Synchronisieren --> Veraltet: Fehler, alter Stand vorhanden - Synchronisieren --> Schemafehler: ungültige Tabelle - Synchronisieren --> NeuVerbinden: Grant abgelaufen + Synchronisieren --> Schemafehler: ungültiger gespeicherter Stand Aktuell --> Synchronisieren: manuell, online, Vordergrund Aktuell --> OfflineMitDaten: offline OfflineMitDaten --> Synchronisieren: wieder online Abgemeldet --> Pruefen: OAuth abgeschlossen - NeuVerbinden --> Pruefen: OAuth abgeschlossen + KeinFinanzstand --> Synchronisieren: Import vorhanden ``` Implementierung und Tests: [FinanceDataProvider](../../src/data/FinanceDataProvider.tsx), [Provider-Tests](../../src/data/FinanceDataProvider.test.ts), [Verbindungsansichten](../../src/App.tsx), [Offline-Smoke-Test](../../scripts/offline-smoke.mjs). @@ -65,11 +60,11 @@ Implementierung und Tests: [FinanceDataProvider](../../src/data/FinanceDataProvi Explizite Pfade, Reload sowie Zurück/Vorwärts werden immer aus der aktuellen URL bestimmt. Nur der Manifest-Start `/?app-launch=pwa` darf die lokal gespeicherte letzte Destination lesen; der Marker wird dabei per Replace durch den kanonischen Pfad ersetzt. Dadurch bleibt `/` bei normalen Aufrufen eindeutig die Übersicht und die History erhält keinen künstlichen Zwischeneintrag. Lokale Unterzustände innerhalb eines Screens sind nicht Teil dieses Vertrags. -Bei Anmeldung oder erneuter Google-Verbindung sendet der Client nur einen der vier kanonischen Pfade als Rückweg. Der Server validiert ihn, bindet ihn an die signierte OAuth-Transaktion und verwendet ihn nach Erfolg oder einem verifizierten Callbackfehler erneut. Nicht erlaubte Werte fallen auf `/` zurück. +Bei der Google-Anmeldung sendet der Client nur einen der vier kanonischen Pfade als Rückweg. Der Server validiert ihn, bindet ihn an die signierte OAuth-Transaktion und verwendet ihn nach Erfolg oder einem verifizierten Callbackfehler erneut. Nicht erlaubte Werte fallen auf `/` zurück. -## Abmelden und Trennen +## Abmelden -Abmelden löscht nur das signierte Session-Cookie im Browser. Trennen widerruft nach Möglichkeit das Google-Token und löscht selbst bei fehlgeschlagener Widerruf-Anfrage den Verbindungsdatensatz; im Client wird danach der Finance-Cache gelöscht. Appearance, Privacy und App-Schutz sind unabhängige Geräteeinstellungen und bleiben in beiden Fällen erhalten. Nur die ausdrücklich bestätigte Recovery einer vergessenen PIN entfernt den App-Schutz nach erfolgreicher Online-Bereinigung. +Abmelden löscht das signierte Session-Cookie und deaktiviert die aktive Finance-Cache-Partition. PostgreSQL-Finanzzeilen und der ownergebundene lokale Snapshot bleiben erhalten, werden abgemeldet aber nicht angezeigt. Erst eine erneute verifizierte Anmeldung derselben Identität ordnet diesen Cache wieder zu. Appearance, Privacy und App-Schutz sind unabhängige Geräteeinstellungen. Nur die ausdrücklich bestätigte Recovery einer vergessenen PIN entfernt den lokalen App-Schutz und sämtliche lokalen Finance-Cache-Partitionen nach erfolgreichem Logout; der serverseitige Finanzstand bleibt bestehen. ## Appearance-Transaktion diff --git a/docs/produkt/entwicklungsstand.md b/docs/produkt/entwicklungsstand.md index 9a11287..57af8bd 100644 --- a/docs/produkt/entwicklungsstand.md +++ b/docs/produkt/entwicklungsstand.md @@ -11,16 +11,16 @@ - Private Single-User-PWA mit `accura`-Branding und zeitabhängiger Begrüßung. - Übersicht, Demnächst, Budget und Schulden mit adaptiver Bottom-Navigation beziehungsweise Navigation Rail. - Kanonische URLs für alle vier Hauptansichten mit Deep Links, Browser-/PWA-History, sicherer OAuth-Rückkehr und gezielter PWA-Kaltstart-Wiederherstellung. -- Google OAuth mit State, Nonce und PKCE; Picker mit `drive.file`; serverseitige Drive-/Sheets-Zugriffe; verschlüsselte Refresh-Tokens in PostgreSQL. -- Finance Data Schema v1 mit zehn Maschinen-Tabs, Laufzeitvalidierung, Integer-Cents, Fremdschlüsseln, Snapshot-Auswahl, `salary_day` und `due_day`. -- Ownergebundenes PostgreSQL-v1-Schema mit zusammengesetzten Fremdschlüsseln, gemeinsamem Lazy-Pool und internem `READ ONLY`-/`REPEATABLE READ`-Reader zurück zum unveränderten `FinanceDataV1`. Parser- und PostgreSQL-Lesepfad liefern für die anonyme Fixture dieselben Cents, Fälligkeitstage und Snapshot-Auswahl. Der produktive `/api/finance`-Pfad bleibt bis ACC-66 auf Sheets. +- Google OAuth mit State, Nonce und PKCE nur als Identität (`openid email profile`); keine Picker-, Drive- oder Sheets-Laufzeit, keine persistierten Refresh-Tokens. +- Finance Data Schema v1 mit zehn Maschinen-Tabs als Importformat, Laufzeitvalidierung, Integer-Cents, Fremdschlüsseln, Snapshot-Auswahl, `salary_day` und `due_day`. +- Ownergebundenes PostgreSQL-v1-Schema mit zusammengesetzten Fremdschlüsseln, gemeinsamem Lazy-Pool und produktivem `/api/finance`-Reader zurück zum unveränderten `FinanceDataV1`. Parser- und PostgreSQL-Pfad liefern für die anonyme Fixture dieselben Cents, Fälligkeitstage und Snapshot-Auswahl. Der Operator-Import ersetzt einen Ownerstand transaktional. - Last-known-good-Cache in IndexedDB, Offline-App-Shell, getesteter leerer Offline-Start und Netzrückkehr, manuelle und ereignisgesteuerte Datenaktualisierung sowie Race-Schutz. - Kontrollierter PWA-Versionswechsel mit verständlichem „Jetzt neu laden“/„Später“-Hinweis, stabilem Installationsmanifest und automatisierten Android-orientierten Icon-/Systemfarben-Verträgen. - Appearance mit Systemmodus, Hell/Dunkel, Browser-Akzent, neun Presets, lokaler Bildanalyse im Worker und lokaler WebP-Vorschau. - Lokaler Privacy-Modus einschließlich Tabsynchronisierung und Maskierung von sichtbaren sowie zugänglichen Geldtexten. - Optionaler App-Vorschau-Schutz und lokaler sechsstelliger PIN-Lock mit Android-orientiertem, thematisiertem Lockscreen, Expressive-PIN-Formen, Fehlversuchs-Wartezeit und fail-closed Recovery. - Wiederverwendbare MD3-Komponenten, Responsive/Reflow, Reduced Motion, Forced Colors, Fokusmanagement und lokale Google-Sans-Flex-Schrift. -- GitHub-CI für Lint, Unit-Tests, echte PostgreSQL-Integrationstests, Build und Smoke; aktuell 237 normale Vitest-Tests, 13 dedizierte PostgreSQL-Fälle und elf Node-Tests sowie PWA-, Offline-, Golden- und Axe-Prüfungen. +- GitHub-CI für Lint, Unit-Tests, echte PostgreSQL-Integrationstests, Build und Smoke; aktuell 225 normale Vitest-Tests, 14 dedizierte PostgreSQL-Fälle und elf Node-Tests sowie PWA-, Offline-, Golden- und Axe-Prüfungen. ## Historische Meilensteine @@ -30,7 +30,7 @@ Die genaue Commit-Historie bleibt in Git; diese Seite ist kein tägliches Journa ## Bekannte Abdeckungslücken -Reale Produktionsabläufe mit persönlichen externen Diensten können im Repository nicht automatisiert bewiesen werden und benötigen eine Eigentümer-Abnahme. Für den PostgreSQL-Cutover sind insbesondere reale Neon-/Vercel-Region, eingeschränkte Runtime-Rolle, Restore-Fenster und ein praktischer synthetischer Restore vor ACC-66 noch als Betriebsaufgaben offen. Androids tatsächlich gerenderter Installationsdialog, Launcher, Splash und App-Switcher liegen ebenfalls außerhalb der gewählten Desktop-Chromium-Automation; Manifest, Installierbarkeit, Icon-Pixelverträge, Worker-Update und der Web-Lockscreen sind automatisiert abgedeckt. +Reale Produktionsabläufe mit persönlichen externen Diensten können im Repository nicht automatisiert bewiesen werden und benötigen eine Eigentümer-Abnahme. Für den produktiven Operator-Import bleiben reale Neon-/Vercel-Region, eingeschränkte Runtime-Rolle, Restore-Fenster und ein praktischer synthetischer Restore als Betriebsaufgaben offen. Androids tatsächlich gerenderter Installationsdialog, Launcher, Splash und App-Switcher liegen ebenfalls außerhalb der gewählten Desktop-Chromium-Automation; Manifest, Installierbarkeit, Icon-Pixelverträge, Worker-Update und der Web-Lockscreen sind automatisiert abgedeckt. ## Nachweis diff --git a/docs/produkt/funktionen.md b/docs/produkt/funktionen.md index 53b8b95..42bcc60 100644 --- a/docs/produkt/funktionen.md +++ b/docs/produkt/funktionen.md @@ -28,18 +28,16 @@ Schulden stellt Ablösesumme, planmäßige Gesamtkosten, daraus abgeleitete Mehr ## Globale Aktionen -- **Aktualisieren:** liest die gewählte Tabelle erneut. Automatisch wird außerdem beim Start, nach einer Auswahl, bei Rückkehr der Verbindung und nach mehr als zehn Minuten im Hintergrund aktualisiert. -- **Tabelle wechseln:** öffnet Google Picker für genau eine Google-Sheets-Datei; gespeichert wird sie erst nach Drive- und Schemaprüfung. -- **Abmelden:** beendet die App-Sitzung. Google-Verbindung, ausgewählte Tabelle und lokaler Finance-Cache bleiben bestehen. -- **Google-Verbindung trennen:** versucht den Google-Grant zu widerrufen, löscht die Postgres-Verbindung, beendet die Sitzung und entfernt den Finance-Cache auf diesem Gerät. +- **Aktualisieren:** liest den gespeicherten PostgreSQL-Stand erneut. Automatisch wird außerdem beim Start, bei Rückkehr der Verbindung und beim Sichtbarwerden des Tabs aktualisiert, wenn der letzte erfolgreiche Sync mehr als zehn Minuten zurückliegt. In ausgeblendeten Tabs gibt es kein Polling. +- **Abmelden:** beendet die App-Sitzung und blendet lokale Finanzdaten aus. Der gespeicherte Finanzstand in PostgreSQL und der ownergebundene lokale Finance-Cache bleiben bestehen; erst die erneute verifizierte Anmeldung derselben Identität aktiviert ihn wieder. - **Darstellung:** System-, Hell- und Dunkelmodus; Browser-/Systemfarbe, kuratierte Presets oder lokal analysiertes Bild. Entwürfe werden erst durch Anwenden dauerhaft. -- **Privacy:** maskiert oder zeigt Geldbeträge; die Einstellung bleibt lokal über Logout und Disconnect hinweg erhalten und wird zwischen Tabs synchronisiert. +- **Privacy:** maskiert oder zeigt Geldbeträge; die Einstellung bleibt lokal über Logout hinweg erhalten und wird zwischen Tabs synchronisiert. - **App-Schutz:** verdeckt Accura optional nach einem Hintergrundwechsel. Eine zusätzliche sechsstellige lokale PIN sperrt außerdem Start und Reload; beide Schalter liegen in den Einstellungen. - **Begrüßung:** Die Übersicht wählt abhängig von der lokalen Uhr „Guten Morgen“, „Guten Tag“ oder „Guten Abend“ und kombiniert dies mit dem `accura`-Branding. ## Offline-Nutzung -Beim ersten Start ohne vorherigen erfolgreichen Sync gibt es keinen Finanzstand. Nach einem erfolgreichen Sync zeigt ein Offline-Start den Last-known-good-Stand mit sichtbarer Offline-/Veraltet-Markierung. Anmeldung, Picker und Aktualisierung benötigen das Netzwerk. +Beim ersten Start ohne vorherigen erfolgreichen Sync gibt es keinen Finanzstand. Nach einem erfolgreichen Sync zeigt ein Offline-Start den Last-known-good-Stand mit sichtbarer Offline-/Veraltet-Markierung. Anmeldung und Aktualisierung benötigen das Netzwerk. ## Implementierung und Tests diff --git a/docs/produkt/markt-und-positionierung.md b/docs/produkt/markt-und-positionierung.md index c28d54b..07a5e86 100644 --- a/docs/produkt/markt-und-positionierung.md +++ b/docs/produkt/markt-und-positionierung.md @@ -95,7 +95,7 @@ Daneben gibt es plausibel eine größere, aber mit den hier verwendeten Quellen | Ebene | Verbindliche Einordnung | | --- | --- | -| Aktueller Repository-Stand | Private Single-User-PWA mit Google-Sheets-Quelle, vier Ansichten, Offline-Cache, Appearance und lokalem Privacy-Modus; keine öffentliche Dienstleistung | +| Aktueller Repository-Stand | Private Single-User-PWA mit PostgreSQL-Quelle und Google nur als Identität, vier Ansichten, ownergebundenem Offline-Cache, Appearance und lokalem Privacy-Modus; keine öffentliche Dienstleistung | | Langfristige Positionierung | Ruhiges Finanz-Cockpit für Menschen unter finanziellem oder mentalem Druck; kognitive Entlastung durch finanzielle Gewissheit | | Mögliche Zukunft | Nur in der [Roadmap](roadmap.md) gekennzeichnete Ideen und die oben ausdrücklich als Optionen bezeichneten Finanzierungsmodelle | | Externe Beobachtung | Datierte, veränderliche Recherche dieser Seite; keine Produktfunktion und keine Garantie über Wettbewerber | diff --git a/docs/produkt/ueberblick.md b/docs/produkt/ueberblick.md index e93f504..047484c 100644 --- a/docs/produkt/ueberblick.md +++ b/docs/produkt/ueberblick.md @@ -43,7 +43,7 @@ Die App soll keine komplexe Budgetmethode lehren. Informationshierarchie, ruhige ## Heutiger Repository-Stand -Aktuell ist `accura` eine private Single-User-PWA für genau eine serverseitig freigegebene Google-E-Mail-Adresse. Eine selbst gepflegte Google-Tabelle wird in vier Ansichten zusammengeführt: Übersicht, Demnächst, Budget und Schulden. Es gibt weder öffentliche Registrierung noch Rollen, Mandanten, geteilte Haushalte oder SaaS-Betrieb. +Aktuell ist `accura` eine private Single-User-PWA für genau eine serverseitig freigegebene Google-E-Mail-Adresse. Der Finanzstand liegt in PostgreSQL und wird in vier Ansichten zusammengeführt: Übersicht, Demnächst, Budget und Schulden. Es gibt weder öffentliche Registrierung noch Rollen, Mandanten, geteilte Haushalte oder SaaS-Betrieb. Die implementierten Funktionen stehen ausschließlich unter [Funktionen](funktionen.md), der überprüfte Stand unter [Entwicklungsstand](entwicklungsstand.md). Die langfristige Positionierung beschreibt, woran künftige Entscheidungen gemessen werden; sie behauptet keine heute noch nicht implementierte Funktion. @@ -51,13 +51,13 @@ Accura ist derzeit kein öffentlich angebotenes Fintech, keine regulierte Finanz ## Nutzen und Datenhoheit -Die Google-Tabelle bleibt die vom Nutzer kontrollierte fachliche Datenquelle. `accura` liest ausschließlich die zehn Maschinen-Tabs des [Finance Data Schema v1](../referenz/finance-data-schema-v1.md), validiert sie serverseitig und liefert eine normalisierte, versionierte Darstellung an den Browser. Die App schreibt keine Finanzwerte in die Tabelle. +PostgreSQL ist die fachliche Datenquelle. `accura` liest den ownergebundenen Stand als [Finance Data Schema v1](../referenz/finance-data-schema-v1.md) und liefert eine normalisierte, versionierte Darstellung an den Browser. Google Sheets ist nur noch ein einmaliges Importformat. Die Produkt-UI schreibt noch keine Finanzzeilen; das erledigt der kontrollierte Operator-Import. Ein erfolgreich validierter Stand wird auf dem Gerät in IndexedDB gespeichert. So kann die App nach einem späteren Offline-Start den zuletzt bekannten guten Stand zeigen. Sichtbarer Datenstand und Warnhinweise machen deutlich, wenn eine Aktualisierung fehlt. Weil eine falsche verfügbare Summe bei engem Spielraum besonders schädlich wäre, sind Centgenauigkeit, Laufzeitvalidierung und nachvollziehbare Annahmen zentrale Sicherheitsanforderungen. ## Datenschutzmodell -Refresh-Token, Google-Client-Secret, Datenbank-URL, Token-Schlüssel und Session-Secret bleiben auf dem Server. Der Browser erhält nur die Finanzantwort sowie beim bewussten Öffnen des Pickers kurzzeitig ein Zugriffstoken. Details und Vertrauensgrenzen stehen unter [Backend und Sicherheit](../architektur/backend-und-sicherheit.md). +Google-Client-Secret, Datenbank-URL und Session-Secret bleiben auf dem Server. Der Browser erhält nur die Finanzantwort. Details und Vertrauensgrenzen stehen unter [Backend und Sicherheit](../architektur/backend-und-sicherheit.md). Der [Privacy-Modus und App-Schutz](../architektur/privacy-modus.md) maskiert wahlweise Geldbeträge und kann die gesamte App nach einem Hintergrundwechsel verdecken oder mit einer lokalen PIN sperren. Diese Funktionen reduzieren beiläufiges Mitlesen, verschlüsseln jedoch weder Arbeitsspeicher noch IndexedDB und ersetzen keine Gerätesperre oder getrennte Browserprofile. @@ -77,7 +77,7 @@ Diese Grenzen sind strategisch. Eine zusätzliche Funktion ist nur dann sinnvoll ## Technische und operative Ausschlüsse -- kein Bearbeiten der Google-Tabelle durch die App; +- kein Zurückschreiben nach Google Sheets; - keine Bankanbindung, Überweisung oder automatische Kategorisierung im aktuellen Produkt; - kein garantierter Echtzeitstand und kein Hintergrund-Polling; - keine Verschlüsselung lokaler Finance-Daten durch Privacy- oder App-Schutz; @@ -93,7 +93,7 @@ Diese Grenzen sind strategisch. Eine zusätzliche Funktion ist nur dann sinnvoll ## Implementierung und Tests - Produkt-Shell: [src/App.tsx](../../src/App.tsx) -- Server-Datenfluss: [api/_lib/financeService.ts](../../api/_lib/financeService.ts) +- Server-Datenfluss: [api/_lib/financeRepository.ts](../../api/_lib/financeRepository.ts) - Finanzberechnungen: [src/finance/selectors.ts](../../src/finance/selectors.ts), [src/finance/upcoming.ts](../../src/finance/upcoming.ts) - Anonyme Testdaten: [scripts/fixtures/anonymous-finance-data.mjs](../../scripts/fixtures/anonymous-finance-data.mjs) - Browser-Smoke-Tests: [scripts/browser-smoke.mjs](../../scripts/browser-smoke.mjs) diff --git a/docs/referenz/api.md b/docs/referenz/api.md index 1b29ec3..64d53b7 100644 --- a/docs/referenz/api.md +++ b/docs/referenz/api.md @@ -15,11 +15,8 @@ Alle Endpunkte sind same-origin Vercel Functions, antworten mit `Cache-Control: | `GET` | `/api/auth/google/start?return_to=/budget` | nein | 302 zu Google, setzt OAuth-Transaktionscookie | | `GET` | `/api/auth/google/callback` | OAuth-Cookie, Query `code`/`state` | 302 zur App, setzt Session-Cookie | | `GET` | `/api/session` | optionales Session-Cookie | Sitzungsstatus | -| `GET` | `/api/finance` | Session | validierter Finance-Snapshot | -| `GET` | `/api/google/picker` | Session | kurzlebige Picker-Konfiguration | -| `PUT` | `/api/google/spreadsheet` | Session + CSRF + Origin | geprüfte Auswahl und Finance-Snapshot | +| `GET` | `/api/finance` | Session | validierter Finance-Snapshot aus PostgreSQL | | `POST` | `/api/auth/logout` | Session + CSRF + Origin | Sitzung beendet | -| `POST` | `/api/connection/disconnect` | Session + CSRF + Origin | Grant best-effort widerrufen, Verbindung gelöscht | Nicht erlaubte Methoden ergeben 405 und einen `Allow`-Header. @@ -40,74 +37,46 @@ Mit Sitzung: "authenticated": true, "user": { "email": "owner@example.invalid" }, "csrfToken": "…", - "connection": { - "connected": true, - "spreadsheet": { "id": "…", "name": "Anonyme Finanzen" } - } + "ownerKey": "pseudonymer-HMAC-Schlüssel" } ``` -`connection.connected` kann `false` sein; `spreadsheet` kann `null` sein. Ein ungültiges/abgelaufenes Cookie wird gelöscht und als abgemeldet beantwortet. +Ein ungültiges/abgelaufenes Cookie wird gelöscht und als abgemeldet beantwortet. Die Sitzung sagt nichts über das Vorhandensein eines Finanzstands. ## `GET /api/finance` ```json { - "spreadsheet": { "id": "…", "name": "Anonyme Finanzen" }, "data": { "schemaVersion": 1, "asOf": "2026-08-01", "currency": "EUR" }, - "refreshedAt": "2026-08-11T12:00:00.000Z" -} -``` - -`data` ist vollständig gemäß dem normalisierten `FinanceDataV1`-Vertrag; die Kürzung oben ist nur Darstellung. Fehlende Verbindung/Auswahl ergibt 409, ungültiges Workbook 422, abgelaufener Grant 401 `reconnect_required`. - -## `GET /api/google/picker` - -```json -{ - "accessToken": "…", - "expiresIn": 3600, - "apiKey": "…", - "appId": "1234567890", - "clientId": "….apps.googleusercontent.com" + "refreshedAt": "2026-08-11T12:00:00.000Z", + "ownerKey": "pseudonymer-HMAC-Schlüssel" } ``` -Das Access-Token ist kurzlebig und darf nicht persistiert werden. `apiKey`, `appId` und `clientId` sind browserverwendete Identifikatoren. - -## `PUT /api/google/spreadsheet` - -Header: `content-type: application/json`, `x-csrf-token: …`. Exakter Body: - -```json -{ "fileId": "google-drive-file-id" } -``` - -`fileId` ist ein nicht leerer String von 10 bis 256 Zeichen; zusätzliche Felder sind unzulässig. Der Server prüft Drive-Datei und vollständiges Schema vor Speicherung. Die Erfolgsantwort entspricht `/api/finance`. +`data` ist vollständig gemäß dem normalisierten `FinanceDataV1`-Vertrag; die Kürzung oben ist nur Darstellung. `ownerKey` ist ein serverseitig mit `SESSION_SECRET` aus der verifizierten Google-Subjekt-ID abgeleiteter HMAC-Wert. Er partitioniert und bindet Browser-Caches, enthält weder die Google-ID noch `owners.id` und ist kein Autorisierungstoken. Client und Server liefern ihn in Sitzungs- und Finance-Antworten, damit ein Identitätswechsel zwischen Tabs keine Antwort in die falsche Cache-Partition schreibt. Fehlender Owner oder fehlendes `finance_meta` ergibt 409 `finance_missing`. Ein intern ungültiger gespeicherter Stand ergibt 422 `finance_data_integrity` ohne Issues, IDs oder Beträge. -## Logout und Disconnect +## Logout -Beide sind `POST` ohne Body und antworten: +`POST /api/auth/logout` ohne Body antwortet: ```json { "ok": true } ``` -Logout löscht nur das Session-Cookie. Disconnect versucht Google-Widerruf, löscht die Postgres-Verbindung auch bei Revocation-Netzfehler und löscht das Session-Cookie. Der Client entfernt nach Erfolg zusätzlich seinen Finance-IndexedDB-Cache. +Logout löscht nur das Session-Cookie. PostgreSQL-Finanzzeilen bleiben unverändert. Der lokale Finance-Cache bleibt erhalten, bis eine PIN-Recovery ihn bewusst entfernt. ## Fehlerformat ```json { "error": { - "code": "invalid_finance_schema", - "message": "Die Tabelle entspricht nicht Finance Data Schema v1.", - "details": { "issues": [] } + "code": "finance_missing", + "message": "Es ist noch kein Finanzstand vorhanden." } } ``` -`details` ist optional. Relevante Codes sind `method_not_allowed`, `unauthenticated`, `forbidden`, `csrf_failed`, `connection_missing`, `spreadsheet_missing`, `invalid_request`, `spreadsheet_inaccessible`, `invalid_spreadsheet_type`, `invalid_finance_schema`, `reconnect_required`, `google_token_error`, `sheets_read_failed` und `internal_error`. OAuth-Callbackfehler werden als sicher allowgelisteter `auth_error`-Queryparameter zum verifizierten internen Rückweg oder ersatzweise zu `/` umgeleitet. +`details` ist optional und wird für Integritätsfehler des gespeicherten Stands nicht gesetzt. Relevante Codes sind `method_not_allowed`, `unauthenticated`, `forbidden`, `csrf_failed`, `finance_missing`, `finance_data_integrity` und `internal_error`. OAuth-Callbackfehler werden als sicher allowgelisteter `auth_error`-Queryparameter zum verifizierten internen Rückweg oder ersatzweise zu `/` umgeleitet. ## Implementierung und Tests diff --git a/docs/referenz/datenbank.md b/docs/referenz/datenbank.md index 7938177..37813c1 100644 --- a/docs/referenz/datenbank.md +++ b/docs/referenz/datenbank.md @@ -3,10 +3,10 @@ > **Zielgruppe:** Betreiber und Backend-Entwickler. > **Zweck und Lernziel:** PostgreSQL-Schema, Owner-Isolation, Constraints und Betriebsgrenzen verstehen. > **Voraussetzungen:** PostgreSQL-Grundkenntnisse und [Backend und Sicherheit](../architektur/backend-und-sicherheit.md) -> **Kanonisch für:** Migrationen 001/002, `google_connections` und das ownergebundene Finance-v1-Schema. +> **Kanonisch für:** Migrationen 001–003 und das ownergebundene Finance-v1-Schema. > **Verwandte Dokumente:** [Produktions-Setup](../anleitungen/produktions-setup.md), [Finance Data Schema v1](finance-data-schema-v1.md) -`accura` besitzt zwei transaktionale Migrationen. [001_google_connections.sql](../../migrations/001_google_connections.sql) speichert die Google-Verbindung. [002_finance_data_v1.sql](../../migrations/002_finance_data_v1.sql) bildet sämtliche Quellenfelder aus `FinanceDataV1` relational ab. Das Finance-Repository ist implementiert und getestet, `/api/finance` liest bis zum späteren Cutover aber weiterhin Google Sheets. +`accura` besitzt drei transaktionale Migrationen. [001_google_connections.sql](../../migrations/001_google_connections.sql) legte historisch die Google-Verbindung an. [002_finance_data_v1.sql](../../migrations/002_finance_data_v1.sql) bildet sämtliche Quellenfelder aus `FinanceDataV1` relational ab. [003_drop_google_connections.sql](../../migrations/003_drop_google_connections.sql) entfernt die Verbindungs- und Tokentabelle nach dem Cutover. `/api/finance` liest ausschließlich das ownergebundene Finance-Schema. ## Owner-Modell @@ -18,7 +18,7 @@ | `google_sub` | `TEXT` | nein | eindeutig, nach Trimmung nicht leer | | `created_at` | `TIMESTAMPTZ` | nein | Default `NOW()` | -Es besteht absichtlich kein Foreign Key zu `google_connections`. Disconnect darf Finanzdaten nicht löschen. In ACC-71 erzeugt OAuth keinen Owner; erst der kontrollierte Import aus ACC-66 legt den produktiven Datensatz an. Der Reader nimmt ausschließlich Google `sub` aus der verifizierten Sitzung entgegen, löst intern `owners.id` auf und verwendet danach nur diese UUID. +Es besteht absichtlich kein Foreign Key von Finance-Tabellen zur historischen Verbindungstabelle. Logout darf Finanzdaten nicht löschen. OAuth erzeugt keinen Owner; erst der kontrollierte Operator-Import legt den produktiven Datensatz an. Der Reader nimmt ausschließlich Google `sub` aus der verifizierten Sitzung entgegen, löst intern `owners.id` auf und verwendet danach nur diese UUID. Jede Finance-Tabelle besitzt ein nicht-nullbares `owner_id`. Fachliche Primär- und Fremdschlüssel enthalten den Owner, beispielsweise `(owner_id, id)` und `(owner_id, account_id)`. Gleiche fachliche IDs bei zwei Ownern sind damit erlaubt, eine Referenz über Ownergrenzen wird von PostgreSQL abgewiesen. Foreign Keys verwenden das Standardverhalten `NO ACTION`; es gibt keine stillen Lösch-Cascades. @@ -53,34 +53,24 @@ Die fünf gültigen `necessity_id`-Werte sind `essential`, `necessary`, `worthwh Debt- und Relief-Meilensteine speichern neben `milestone_date` eine `date_precision` mit `month` oder `day`. Bei `month` erzwingt ein Check den Monatsersten. Der Reader rekonstruiert daraus ohne Zeitzonenkonvertierung exakt `YYYY-MM` beziehungsweise `YYYY-MM-DD`. Die interne UUID eines Relief-Meilensteins verlässt die Persistenz nicht und erlaubt doppelte fachliche Ereignisse. -## Tabelle `google_connections` +## Historische Tabelle `google_connections` -| Spalte | Typ | Null? | Bedeutung | -| --- | --- | --- | --- | -| `google_sub` | `TEXT` | nein | Primärschlüssel, stabile Google-Subjekt-ID | -| `verified_email` | `TEXT` | nein | beim ID-Token verifizierte, normalisierte E-Mail | -| `encrypted_refresh_token` | `TEXT` | nein | versioniertes AES-256-GCM-Chiffrat, kein Klartext | -| `granted_scopes` | `TEXT[]` | nein | beim OAuth-Tausch gemeldete Scopes | -| `spreadsheet_id`, `spreadsheet_name` | `TEXT` | ja | gemeinsam gesetzte oder gemeinsam leere Auswahl | -| `created_at`, `updated_at`, `token_updated_at` | `TIMESTAMPTZ` | nein | Lebenszykluszeitpunkte | -| `spreadsheet_updated_at` | `TIMESTAMPTZ` | ja | letzte Auswahländerung | - -Ein Check hält Sheet-ID und -Name vollständig; ein eindeutiger Index auf `LOWER(verified_email)` verhindert mehrere Verbindungen derselben Adresse. OAuth schreibt Authorization-Daten, die Picker-Prüfung aktualisiert die Auswahl, Disconnect löscht ausschließlich diese Zeile. +Migration 001 erzeugt diese Tabelle noch, damit bestehende Datenbanken denselben Pfad durchlaufen. Migration 003 entfernt sie einschließlich gespeicherter Refresh-Tokens. Die Laufzeit schreibt nicht mehr darauf. -## Reader und Integritätsgrenze +## Reader, Import und Integritätsgrenze Der Reader läuft in einer `READ ONLY, REPEATABLE READ`-Transaktion und liest sämtliche Snapshots. `BIGINT`-Strings werden explizit geparst und erneut als sichere JavaScript-Integer geprüft; `DATE` wird in SQL als Text formatiert. Das rekonstruierte Objekt muss das Laufzeitschema erfüllen. Zusätzlich braucht jede aktive Account-, Pocket- und Debt-Zeile mindestens einen Snapshot am oder vor `finance_meta.as_of`. -Fehlender Owner oder fehlendes `finance_meta` ergibt `null`, keinen erfundenen Leerstand. Interne Integritätsfehler enthalten weder Datenbankzeilen noch IDs oder Finanzwerte. Snapshot-Auswahl und Berechnungen bleiben in den TypeScript-Selektoren. +Fehlender Owner oder fehlendes `finance_meta` ergibt `null`, keinen erfundenen Leerstand. Der Operator-Import ersetzt den vollständigen Stand eines Owners transaktional und liest ihn vor der Bestätigung erneut. Interne Integritätsfehler enthalten weder Datenbankzeilen noch IDs oder Finanzwerte. Snapshot-Auswahl und Berechnungen bleiben in den TypeScript-Selektoren. ## Migration, Rollen und Backup Migrationen werden über einen direkten administrativen PostgreSQL-Endpunkt bewusst zuerst in Development, später in Production ausgeführt. Die Vercel Runtime verwendet dagegen die gepoolte `DATABASE_URL` und einen eingeschränkten Runtime-Benutzer. Neon ist der aktuelle Betreiber, aber keine Neon-Funktion ist Teil des Schemas; ein anderer PostgreSQL-Anbieter kann denselben Vertrag ausführen. -Backups enthalten verschlüsselte Google-Tokens und künftig hochsensible Finanzzeilen. Vor ACC-66 müssen Restore-Fenster, Rollen, Region und ein praktischer Restore-Test mit synthetischen Daten geklärt sein. Details stehen im [Produktions-Setup](../anleitungen/produktions-setup.md#2-postgresql-und-neon-betrieb). +Backups enthalten hochsensible Finanzzeilen. Restore-Fenster, Rollen, Region und ein praktischer Restore-Test mit synthetischen Daten gehören zum Betriebsprotokoll vor einem produktiven Import. Details stehen im [Produktions-Setup](../anleitungen/produktions-setup.md#2-postgresql-und-neon-betrieb). ## Implementierung und Tests -- Pool und Repositories: [database.ts](../../api/_lib/database.ts), [repository.ts](../../api/_lib/repository.ts), [financeRepository.ts](../../api/_lib/financeRepository.ts) -- Migrationen: [001](../../migrations/001_google_connections.sql), [002](../../migrations/002_finance_data_v1.sql) +- Pool und Repository: [database.ts](../../api/_lib/database.ts), [financeRepository.ts](../../api/_lib/financeRepository.ts) +- Migrationen: [001](../../migrations/001_google_connections.sql), [002](../../migrations/002_finance_data_v1.sql), [003](../../migrations/003_drop_google_connections.sql) - Echte PostgreSQL-Suite: [financeRepository.postgres.test.ts](../../tests/postgres/financeRepository.postgres.test.ts) diff --git a/docs/referenz/finance-data-schema-v1.md b/docs/referenz/finance-data-schema-v1.md index a56b8ce..afe56b0 100644 --- a/docs/referenz/finance-data-schema-v1.md +++ b/docs/referenz/finance-data-schema-v1.md @@ -8,7 +8,7 @@ ## Grundvertrag -Im aktuell implementierten Übergangsstand ist dieser Vertrag zugleich die laufende Google-Sheets-Quelle. Nach dem beschlossenen Cutover aus [ADR 0013](../entscheidungen/0013-postgresql-als-finanzquelle.md) bleibt er als einmaliges Importformat erhalten; der normalisierte `FinanceDataV1`-Vertrag wird dann produktiv aus PostgreSQL gelesen. +Nach dem Cutover aus [ADR 0013](../entscheidungen/0013-postgresql-als-finanzquelle.md) ist dieser Vertrag das einmalige Importformat. Der normalisierte `FinanceDataV1`-Vertrag wird produktiv aus PostgreSQL gelesen. Die ausgewählte native Google-Sheets-Datei enthält genau die benötigten zehn underscore-präfigierten Maschinen-Tabs. Sichtbare Hilfs-Tabs sind zulässig, werden aber ignoriert. Die App liest je Maschinen-Tab `A:Z` mit `UNFORMATTED_VALUE`, verändert keine Zelle und erwartet die Header in Zeile 1. Leere Datenzeilen werden ignoriert. diff --git a/docs/referenz/konfiguration.md b/docs/referenz/konfiguration.md index e58b7f2..089b16a 100644 --- a/docs/referenz/konfiguration.md +++ b/docs/referenz/konfiguration.md @@ -11,18 +11,17 @@ | Variable | Geheim? | Vertrag | | --- | --- | --- | | `APP_ORIGIN` | nein | absolute kanonische Origin ohne Pfad/abschließenden Slash; in Produktion HTTPS | -| `GOOGLE_CLIENT_ID` | nein | OAuth-Web-Client-ID; wird authentifiziert an Picker geliefert | +| `GOOGLE_CLIENT_ID` | nein | OAuth-Web-Client-ID für die Identitätsanmeldung | | `GOOGLE_CLIENT_SECRET` | ja | OAuth-Web-Client-Secret, nur Server | -| `GOOGLE_API_KEY` | nein, aber einschränken | Picker-Key, auf Picker API und exakte Referrer beschränken | -| `GOOGLE_CLOUD_PROJECT_NUMBER` | nein | ausschließlich Ziffern; Picker App ID, nicht Projektname | | `GOOGLE_OAUTH_REDIRECT_URI` | nein | absolute URL mit derselben Origin wie `APP_ORIGIN` und exakt `/api/auth/google/callback` | | `ALLOWED_GOOGLE_EMAIL` | personenbezogene Zugriffspolitik | gültige E-Mail; wird kleingeschrieben und bei Callback sowie Sitzung geprüft | | `DATABASE_URL` | ja | gepoolte PostgreSQL-Verbindungs-URL | -| `TOKEN_ENCRYPTION_KEY` | ja | Base64-kodiert genau 32 Byte für AES-256-GCM | | `SESSION_SECRET` | ja | mindestens 32 UTF-8-Byte für HMAC-signierte OAuth-/Sessiontokens | Alle Variablen sind serverseitig erforderlich. `VERCEL_ENV=production` oder `NODE_ENV=production` aktiviert Produktion und damit HTTPS-Prüfung sowie `Secure`-Cookies. +Der Operator-Import akzeptiert keine Google-Subjekt-ID als Umgebungsvariable. Die verifizierte Anmeldung legt den Owner serverseitig an; der Single-Owner-Import verweigert eine fehlende oder mehrdeutige Zuordnung. + ## Öffentliche Build- und Entwicklungsvariablen | Variable | Vertrag | @@ -47,7 +46,7 @@ Wenn `APP_ORIGIN=https://accura.example` lautet, muss der Callback `https://accu ## Rotation -Die Folgen von Schlüsselrotation stehen im [Produktions-Setup](../anleitungen/produktions-setup.md#secret-rotation). Besonders wichtig: Ohne Keyring kann ein neuer `TOKEN_ENCRYPTION_KEY` alte Token nicht lesen. `SESSION_SECRET` invalidiert bestehende Sessions unmittelbar. +Die Folgen von Schlüsselrotation stehen im [Produktions-Setup](../anleitungen/produktions-setup.md#secret-rotation). `SESSION_SECRET` invalidiert bestehende Sessions unmittelbar. ## Implementierung und Tests diff --git a/docs/referenz/quellcode-karte.md b/docs/referenz/quellcode-karte.md index 0dfb978..f27d37d 100644 --- a/docs/referenz/quellcode-karte.md +++ b/docs/referenz/quellcode-karte.md @@ -13,7 +13,7 @@ | `src/App.tsx` | App-Shell, Connection States, Zielnavigation, Lazy Loading | | `src/screens/` | vier Finance-Ansichten | | `src/components/` | gemeinsame UI-, Dialog-, Navigation-, Privacy- und Diagrammrollen | -| `src/data/` | Browser-API, Picker, Finance-Provider, Laufzeitmodus und IndexedDB-Finance-Cache | +| `src/data/` | Browser-API, Finance-Provider, Laufzeitmodus und IndexedDB-Finance-Cache | | `src/finance/` | Schemaheader, Parser, Laufzeitschema, Typen, Selektoren, Upcoming, View-Model | | `src/appearance/` | Präferenz, Paletten, Tokens, Worker und Wallpaper-IndexedDB | | `src/privacy/` | Geldmaskierung, App-Schutz-/PIN-Store, Expressive-PIN-Formen und gemeinsamer Context | @@ -23,10 +23,10 @@ | `src/mocks/` | ausschließlich anonyme Entwicklungsdaten und Mock-API | | `build/` | geprüfte Buildzeit-Auflösung für Source-Link und Preview-Modus | | `api/` | Vercel Function Entry Points | -| `api/_lib/` | Konfiguration, HTTP, Security, Google, gemeinsamer PostgreSQL-Pool, Connection-/Finance-Repositories und Sheets-Finance-Service | -| `migrations/` | transaktionale PostgreSQL-Migrationen für Google-Verbindung und ownergebundenes Finance-v1-Schema | -| `scripts/` | Node-ESM-, Browser-, Offline- und Service-Worker-Smokes sowie Docs-Check | -| `tests/postgres/` | echte, synthetische PostgreSQL-Migrations-, Constraint- und Finance-Reader-Tests | +| `api/_lib/` | Konfiguration, HTTP, Security, Google-Identität, gemeinsamer PostgreSQL-Pool, Finance-Repository und Operator-Import | +| `migrations/` | transaktionale PostgreSQL-Migrationen für das ownergebundene Finance-v1-Schema | +| `scripts/` | Node-ESM-, Browser-, Offline- und Service-Worker-Smokes, Operator-Import und Docs-Check | +| `tests/postgres/` | echte, synthetische PostgreSQL-Migrations-, Constraint-, Reader- und Import-Tests | | `tests/visual/` | Playwright Golden-/Axe-Spezifikation und Referenzbilder | | `public/` | Icons und statische PWA-Assets | | `vercel.json` | SPA-Deep-Link-Rewrite unter explizitem Ausschluss von `/api` | @@ -37,7 +37,7 @@ Für einen Geldwert beginnt die Spur in einem Header aus [src/finance/schema.ts](../../src/finance/schema.ts), läuft über [src/finance/parser.ts](../../src/finance/parser.ts) in einen Cent-Typ aus [src/finance/types.ts](../../src/finance/types.ts), wird in [src/finance/selectors.ts](../../src/finance/selectors.ts) gewählt/aggregiert, in [src/finance/viewModel.ts](../../src/finance/viewModel.ts) präsentationsfertig und über [src/data/FinanceDataProvider.tsx](../../src/data/FinanceDataProvider.tsx) an einen Screen gereicht. [src/components/MoneyValue.tsx](../../src/components/MoneyValue.tsx) formatiert und maskiert den Wert. -Der noch nicht produktiv angeschlossene PostgreSQL-Pfad beginnt bei der verifizierten Google-Subjekt-ID, löst den internen Owner in [financeRepository.ts](../../api/_lib/financeRepository.ts) auf, liest die Tabellen aus [Migration 002](../../migrations/002_finance_data_v1.sql) und endet ebenfalls am unveränderten `FinanceDataV1`. Beide Repositories teilen [database.ts](../../api/_lib/database.ts); `/api/finance` verwendet bis ACC-66 weiterhin ausschließlich den Sheets-Service. +Der produktive PostgreSQL-Pfad beginnt bei der verifizierten Google-Subjekt-ID, löst den internen Owner in [financeRepository.ts](../../api/_lib/financeRepository.ts) auf, liest die Tabellen aus [Migration 002](../../migrations/002_finance_data_v1.sql) und endet am unveränderten `FinanceDataV1`. `/api/finance` verwendet ausschließlich diesen Reader. Der einmalige Operator-Import schreibt denselben Vertrag zurück. ## Änderungshinweise diff --git a/migrations/003_drop_google_connections.sql b/migrations/003_drop_google_connections.sql new file mode 100644 index 0000000..eeff0be --- /dev/null +++ b/migrations/003_drop_google_connections.sql @@ -0,0 +1,6 @@ +BEGIN; + +-- Deploy only after the identity-only runtime is active. This irreversible drop intentionally removes stored Google refresh tokens. +DROP TABLE IF EXISTS google_connections; + +COMMIT; diff --git a/package-lock.json b/package-lock.json index 071c358..09e8dd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,9 @@ "vite": "^7.1.3", "vite-plugin-pwa": "^1.0.3", "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22.6.0" } }, "node_modules/@axe-core/playwright": { diff --git a/package.json b/package.json index a977e57..766f4f4 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,13 @@ "version": "0.1.0", "license": "AGPL-3.0-only", "type": "module", + "engines": { + "node": ">=22.6.0" + }, "scripts": { "dev": "vite", "dev:mock": "VITE_USE_MOCK_API=true vite", + "import:finance": "node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/import-finance.ts", "docs:check": "node scripts/docs-check.mjs", "docs:check:external": "node scripts/docs-check.mjs --external", "build": "tsc -b && vite build", diff --git a/playwright.config.ts b/playwright.config.ts index 4e49f01..588b1bd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from '@playwright/test'; -const testPortValue = process.env.PLAYWRIGHT_PORT ?? '5173'; +const testPortValue = process.env.PLAYWRIGHT_PORT ?? '5174'; const testPort = /^\d+$/.test(testPortValue) ? Number(testPortValue) : Number.NaN; if (!Number.isInteger(testPort) || testPort < 1 || testPort > 65_535) throw new Error('PLAYWRIGHT_PORT must be a valid TCP port'); const testBaseUrl = `http://127.0.0.1:${testPort}`; @@ -33,9 +33,9 @@ export default defineConfig({ projects: [{ name: 'chromium', use: { browserName: 'chromium' } }], snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}/{arg}{ext}', webServer: { - command: `npm run dev -- --host 127.0.0.1 --port ${testPort} --strictPort`, + command: `env -u VITE_USE_MOCK_API npm run dev -- --host 127.0.0.1 --port ${testPort} --strictPort`, url: testBaseUrl, - reuseExistingServer: true, + reuseExistingServer: false, timeout: 120_000, }, }); diff --git a/scripts/auth-sw-smoke.mjs b/scripts/auth-sw-smoke.mjs index dfb4252..485b6af 100644 --- a/scripts/auth-sw-smoke.mjs +++ b/scripts/auth-sw-smoke.mjs @@ -85,7 +85,7 @@ async function handleRequest(request, response) { authenticated: true, user: { email: 'smoke@example.com' }, csrfToken: 'smoke-csrf-token', - connection: { connected: true, spreadsheet: null }, + ownerKey: 'auth-smoke-owner-cache-key-000000001', } : { authenticated: false }, 'session'); return; } @@ -118,7 +118,7 @@ page.setDefaultTimeout(15_000); try { await page.goto(`${baseUrl}/budget`, { waitUntil: 'networkidle' }); - await page.getByRole('heading', { name: 'Mit deiner Tabelle verbinden' }).waitFor(); + await page.getByRole('heading', { name: 'Bei accura anmelden' }).waitFor(); assert.equal(new URL(page.url()).pathname, '/budget', 'Abgemeldeter Deep Link verlor seinen Pfad'); await page.evaluate(() => navigator.serviceWorker.ready); await page.waitForFunction(() => navigator.serviceWorker.controller !== null); @@ -140,7 +140,7 @@ try { assert.doesNotMatch(await rejectedCallback.text(), / navigator.serviceWorker.controller !== null), true, 'Service Worker verlor vor dem Login die Kontrolle'); @@ -161,7 +161,7 @@ try { assert.equal(callbackResponse.fromServiceWorker(), true, 'Auth callback navigation did not pass through the controlling service worker'); assert.equal(callbackResponse.headers()['x-smoke-function'], 'auth-callback'); assert.equal(callbackResponse.headers()['cache-control'], 'no-store'); - await page.getByRole('heading', { name: 'Google-Tabelle auswählen' }).waitFor(); + await page.getByRole('heading', { name: 'Finanzstand fehlt' }).waitFor(); assert.equal(new URL(page.url()).pathname, '/budget', 'OAuth-Rückkehr verlor den validierten Deep Link'); assert.equal(apiHits.start, 1, 'Login navigation did not reach the auth start function'); diff --git a/scripts/browser-smoke.mjs b/scripts/browser-smoke.mjs index 7b3b687..79dc5a8 100644 --- a/scripts/browser-smoke.mjs +++ b/scripts/browser-smoke.mjs @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; import { chromium } from 'playwright'; -import { installFinanceApiMocks, installPickerMock } from './fixtures/anonymous-finance-data.mjs'; +import { + anonymousFinanceData, + anonymousFinanceResponse, + anonymousSession, + installFinanceApiMocks, +} from './fixtures/anonymous-finance-data.mjs'; import { createAppearanceImageFixture } from './fixtures/appearance-image.mjs'; const baseUrl = process.env.SMOKE_URL ?? 'http://127.0.0.1:5173'; @@ -495,16 +500,14 @@ async function statePage(state, viewport = { width: 412, height: 915 }, contextO const page = await context.newPage(); const errors = collectErrors(page); await installFinanceApiMocks(page, state); - await installPickerMock(page); return { context, page, errors }; } try { for (const [state, expected] of [ - ['signed-out', 'Mit deiner Tabelle verbinden'], - ['no-spreadsheet', 'Google-Tabelle auswählen'], - ['validation-error', 'Tabelle konnte nicht übernommen werden'], - ['reconnect', 'Google erneut verbinden'], + ['signed-out', 'Bei accura anmelden'], + ['no-finance', 'Finanzstand fehlt'], + ['validation-error', 'Finanzstand konnte nicht geladen werden'], ]) { const test = await statePage(state); await test.page.goto(baseUrl, { waitUntil: 'networkidle' }); @@ -522,7 +525,7 @@ try { assert.notEqual(after, before, 'Injizierter Akzent änderte die primäre Aktion nicht'); await test.page.evaluate(() => document.documentElement.style.removeProperty('--color-system-accent-source')); } - const expectedStatus = state === 'validation-error' ? '422' : state === 'reconnect' ? '401' : null; + const expectedStatus = state === 'validation-error' ? '422' : state === 'no-finance' ? '409' : null; const unexpectedErrors = expectedStatus ? test.errors.filter((error) => !error.includes(`status of ${expectedStatus}`)) : test.errors; assert.deepEqual(unexpectedErrors, [], unexpectedErrors.join('\n')); await test.context.close(); @@ -597,22 +600,69 @@ try { assert.deepEqual(routing.errors, [], routing.errors.join('\n')); await routing.context.close(); - const picker = await statePage('no-spreadsheet'); - await picker.page.goto(baseUrl, { waitUntil: 'networkidle' }); - await picker.page.getByRole('button', { name: 'Google-Tabelle auswählen' }).click(); - await picker.page.getByRole('heading', { name: overviewHeading }).waitFor(); - assert.match(await picker.page.locator('#overview-hero').innerText(), /Ausgaben[\s\S]*Rücklagen[\s\S]*Frei/); - assert.deepEqual(picker.errors, [], picker.errors.join('\n')); - await picker.context.close(); + const missing = await statePage('no-finance'); + await missing.page.goto(baseUrl, { waitUntil: 'networkidle' }); + await missing.page.getByRole('heading', { name: 'Finanzstand fehlt' }).waitFor(); + assert.deepEqual(missing.errors.filter((error) => !error.includes('status of 409')), [], missing.errors.join('\n')); + await missing.context.close(); const logout = await statePage('connected'); await logout.page.goto(baseUrl, { waitUntil: 'networkidle' }); await openSettings(logout.page); await logout.page.getByRole('button', { name: 'Abmelden' }).click(); - await logout.page.getByRole('heading', { name: 'Mit deiner Tabelle verbinden' }).waitFor(); + await logout.page.getByRole('heading', { name: 'Bei accura anmelden' }).waitFor(); assert.deepEqual(logout.errors, [], logout.errors.join('\n')); await logout.context.close(); + const ownerSwitch = await statePage('connected'); + await ownerSwitch.page.goto(baseUrl, { waitUntil: 'networkidle' }); + await ownerSwitch.page.getByRole('heading', { name: overviewHeading }).waitFor(); + const previousOwnerKey = await ownerSwitch.page.evaluate(() => localStorage.getItem('active-finance-cache-owner-v1')); + assert.equal(previousOwnerKey, anonymousSession.ownerKey); + const nextOwnerKey = 'browser-test-second-owner-key-0000000001'; + await ownerSwitch.page.route('**/api/session', (route) => route.fulfill({ + json: { ...anonymousSession, ownerKey: nextOwnerKey, user: { email: 'second-owner@example.test' } }, + })); + await ownerSwitch.page.route('**/api/finance', (route) => route.fulfill({ + json: { + ...anonymousFinanceResponse, + data: { ...anonymousFinanceData, monthlyIncomeCents: 100_000 }, + ownerKey: nextOwnerKey, + }, + })); + await ownerSwitch.page.evaluate(({ next, previous }) => { + localStorage.setItem('active-finance-cache-owner-v1', next); + window.dispatchEvent(new StorageEvent('storage', { + key: 'active-finance-cache-owner-v1', + newValue: next, + oldValue: previous, + storageArea: localStorage, + })); + }, { next: nextOwnerKey, previous: previousOwnerKey }); + await ownerSwitch.page.getByText(/von 1\.000,00\s*€/).waitFor(); + const ownerCaches = await ownerSwitch.page.evaluate(async ({ next, previous }) => { + const database = await new Promise((resolve, reject) => { + const request = indexedDB.open('finance-overview', 2); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + try { + const store = database.transaction('last-good', 'readonly').objectStore('last-good'); + const read = (ownerKey) => new Promise((resolve, reject) => { + const request = store.get(`finance-data-v1:${ownerKey}`); + request.onsuccess = () => resolve(request.result ?? null); + request.onerror = () => reject(request.error); + }); + return { next: await read(next), previous: await read(previous) }; + } finally { + database.close(); + } + }, { next: nextOwnerKey, previous: previousOwnerKey }); + assert.equal(ownerCaches.previous?.data.monthlyIncomeCents, anonymousFinanceData.monthlyIncomeCents, 'Identitätswechsel überschrieb den Cache des vorherigen Owners'); + assert.equal(ownerCaches.next?.data.monthlyIncomeCents, 100_000, 'Identitätswechsel persistierte die Antwort nicht in der neuen Owner-Partition'); + assert.deepEqual(ownerSwitch.errors, [], ownerSwitch.errors.join('\n')); + await ownerSwitch.context.close(); + const appearance = await statePage('connected'); await appearance.page.goto(baseUrl, { waitUntil: 'networkidle' }); await appearance.page.getByRole('heading', { name: overviewHeading }).waitFor(); @@ -783,7 +833,7 @@ try { const themeBeforeLogout = await themeSnapshot(appearance.page); await openSettings(appearance.page); await appearance.page.getByRole('button', { name: 'Abmelden' }).click(); - await appearance.page.getByRole('heading', { name: 'Mit deiner Tabelle verbinden' }).waitFor(); + await appearance.page.getByRole('heading', { name: 'Bei accura anmelden' }).waitFor(); assert.deepEqual(await themeSnapshot(appearance.page), themeBeforeLogout, 'Abmelden entfernt die gerätebezogene Appearance-Präferenz'); assert.match(await appearance.page.evaluate(() => localStorage.getItem('finance-appearance-v1') ?? ''), /"version":1/); await assertNoOverflow(appearance.page, 'Appearance-Flow 412×915'); @@ -796,7 +846,6 @@ try { const peer = await tabSync.context.newPage(); const peerErrors = collectErrors(peer); await installFinanceApiMocks(peer, 'connected'); - await installPickerMock(peer); await peer.goto(baseUrl, { waitUntil: 'networkidle' }); await openSettings(peer); const peerColors = await openColors(peer); @@ -1084,34 +1133,34 @@ try { assert.equal(await mobile.page.getByLabel('Informationen schließen').evaluate((element) => element === document.activeElement), true); await mobile.page.keyboard.press('Shift+Tab'); assert.equal(await dialog.evaluate((element) => element.contains(document.activeElement)), true, 'Fokus verlässt den Dialog'); - await mobile.page.getByRole('button', { name: /Google-Verbindung trennen/ }).click(); - await mobile.page.getByText('Google-Verbindung trennen?').waitFor(); - await mobile.page.getByRole('button', { name: 'Abbrechen' }).click(); + assert.equal(await mobile.page.getByRole('button', { name: /Google-Verbindung trennen/ }).count(), 0, 'Entfernter Disconnect wird weiterhin angeboten'); await mobile.page.keyboard.press('Escape'); await dialog.waitFor({ state: 'detached' }); await openSettings(mobile.page); - const disconnectColors = await openColors(mobile.page); - await disconnectColors.locator('.appearance-source-picker').getByRole('radio', { name: 'Farben', exact: true }).check(); - await disconnectColors.getByRole('radio', { name: 'Blau', exact: true }).check(); - await disconnectColors.getByRole('button', { name: 'Anwenden', exact: true }).click(); - await disconnectColors.waitFor({ state: 'detached' }); - const themeBeforeDisconnect = await themeSnapshot(mobile.page); - await mobile.page.getByRole('button', { name: /Google-Verbindung trennen/ }).click(); - await mobile.page.getByRole('button', { name: 'Endgültig trennen' }).click(); - await mobile.page.getByRole('heading', { name: 'Mit deiner Tabelle verbinden' }).waitFor(); - const cachedAfterDisconnect = await mobile.page.evaluate(() => new Promise((resolve, reject) => { - const request = indexedDB.open('finance-overview', 1); + const logoutColors = await openColors(mobile.page); + await logoutColors.locator('.appearance-source-picker').getByRole('radio', { name: 'Farben', exact: true }).check(); + await logoutColors.getByRole('radio', { name: 'Blau', exact: true }).check(); + await logoutColors.getByRole('button', { name: 'Anwenden', exact: true }).click(); + await logoutColors.waitFor({ state: 'detached' }); + const mobileThemeBeforeLogout = await themeSnapshot(mobile.page); + const cacheOwnerBeforeLogout = await mobile.page.evaluate(() => localStorage.getItem('active-finance-cache-owner-v1')); + assert.ok(cacheOwnerBeforeLogout, 'Ownergebundener Cache hat vor Logout keine aktive Partition'); + await mobile.page.getByRole('button', { name: 'Abmelden' }).click(); + await mobile.page.getByRole('heading', { name: 'Bei accura anmelden' }).waitFor(); + assert.equal(await mobile.page.evaluate(() => localStorage.getItem('active-finance-cache-owner-v1')), null, 'Logout ließ die aktive Cache-Partition zugänglich'); + const cachedAfterLogout = await mobile.page.evaluate((ownerKey) => new Promise((resolve, reject) => { + const request = indexedDB.open('finance-overview', 2); request.onerror = () => reject(request.error); request.onsuccess = () => { const database = request.result; const transaction = database.transaction('last-good', 'readonly'); - const getRequest = transaction.objectStore('last-good').get('finance-data-v1'); + const getRequest = transaction.objectStore('last-good').get(`finance-data-v1:${ownerKey}`); getRequest.onsuccess = () => { resolve(getRequest.result ?? null); database.close(); }; getRequest.onerror = () => reject(getRequest.error); }; - })); - assert.equal(cachedAfterDisconnect, null, 'Disconnect hat den IndexedDB-Datenstand nicht entfernt'); - assert.deepEqual(await themeSnapshot(mobile.page), themeBeforeDisconnect, 'Disconnect entfernt die gerätebezogene Appearance-Präferenz'); + }), cacheOwnerBeforeLogout); + assert.equal(cachedAfterLogout?.ownerKey, cacheOwnerBeforeLogout, 'Logout beschädigte den ownergebundenen Cache für eine spätere verifizierte Anmeldung'); + assert.deepEqual(await themeSnapshot(mobile.page), mobileThemeBeforeLogout, 'Logout entfernt die gerätebezogene Appearance-Präferenz'); assert.match(await mobile.page.evaluate(() => localStorage.getItem('finance-appearance-v1') ?? ''), /"version":1/); assert.deepEqual(mobile.errors, [], mobile.errors.join('\n')); await mobile.context.close(); @@ -1232,7 +1281,6 @@ try { const forced = await forcedContext.newPage(); const forcedErrors = collectErrors(forced); await installFinanceApiMocks(forced); - await installPickerMock(forced); await forced.goto(baseUrl, { waitUntil: 'networkidle' }); await forced.getByRole('heading', { name: overviewHeading }).waitFor(); assert.equal(await forced.evaluate(() => matchMedia('(forced-colors: active)').matches), true, 'Forced Colors wurde nicht emuliert'); diff --git a/scripts/docs-check.mjs b/scripts/docs-check.mjs index da3ca25..7a674ac 100644 --- a/scripts/docs-check.mjs +++ b/scripts/docs-check.mjs @@ -6,7 +6,7 @@ import process from 'node:process'; const root = process.cwd(); const externalMode = process.argv.includes('--external'); -const excludedDirectories = new Set(['.git', 'node_modules', 'dist', 'test-results', 'playwright-report']); +const excludedDirectories = new Set(['.git', 'node_modules', 'dist', 'test-results', 'playwright-report', '.local']); const errors = []; const warnings = []; const externalLinks = new Set(); diff --git a/scripts/fixtures/anonymous-finance-data.mjs b/scripts/fixtures/anonymous-finance-data.mjs index 6c1c67f..4fcb1c6 100644 --- a/scripts/fixtures/anonymous-finance-data.mjs +++ b/scripts/fixtures/anonymous-finance-data.mjs @@ -77,13 +77,13 @@ export const anonymousSession = { authenticated: true, user: { email: 'owner@example.test' }, csrfToken: 'browser-test-csrf-token', - connection: { connected: true, spreadsheet: { id: 'anonymous-sheet-id', name: 'Anonyme Finanzen' } }, + ownerKey: 'browser-test-owner-cache-key-000000001', }; export const anonymousFinanceResponse = { - spreadsheet: anonymousSession.connection.spreadsheet, data: anonymousFinanceData, refreshedAt: '2026-08-08T10:00:00.000Z', + ownerKey: anonymousSession.ownerKey, }; export async function installFinanceApiMocks(page, state = 'connected', financeData = anonymousFinanceData) { @@ -91,30 +91,13 @@ export async function installFinanceApiMocks(page, state = 'connected', financeD await page.route('**/api/session', async (route) => { if (state === 'loading') await new Promise((resolve) => setTimeout(resolve, 350)); if (state === 'signed-out') return route.fulfill({ json: { authenticated: false } }); - if (state === 'no-spreadsheet') return route.fulfill({ json: { ...anonymousSession, connection: { connected: true, spreadsheet: null } } }); return route.fulfill({ json: anonymousSession }); }); await page.route('**/api/finance', async (route) => { await new Promise((resolve) => setTimeout(resolve, 80)); - if (state === 'validation-error') return route.fulfill({ status: 422, json: { error: { code: 'invalid_finance_schema', message: 'Die Tabelle entspricht nicht Finance Data Schema v1.', details: { issues: [{ tab: '_Meta', row: 2, column: 'schema_version', message: 'Schema-Version wird nicht unterstützt.', expected: '1' }] } } } }); - if (state === 'reconnect') return route.fulfill({ status: 401, json: { error: { code: 'reconnect_required', message: 'Die Google-Verbindung muss erneut autorisiert werden.' } } }); + if (state === 'no-finance') return route.fulfill({ status: 409, json: { error: { code: 'finance_missing', message: 'Es ist noch kein Finanzstand vorhanden.' } } }); + if (state === 'validation-error') return route.fulfill({ status: 422, json: { error: { code: 'finance_data_integrity', message: 'Der gespeicherte Finanzstand ist ungültig.' } } }); return route.fulfill({ json: financeResponse }); }); - await page.route('**/api/google/picker', (route) => route.fulfill({ json: { accessToken: 'short-lived-browser-token', expiresIn: 3600, apiKey: 'public-picker-key', appId: '123456', clientId: 'client-id' } })); - await page.route('**/api/google/spreadsheet', (route) => route.fulfill({ json: financeResponse })); await page.route('**/api/auth/logout', (route) => route.fulfill({ json: { ok: true } })); - await page.route('**/api/connection/disconnect', (route) => route.fulfill({ json: { ok: true } })); -} - -export async function installPickerMock(page) { - await page.addInitScript(() => { - class DocsView { setMimeTypes() { return this; } setSelectFolderEnabled() { return this; } } - class PickerBuilder { - addView() { return this; } setAppId() { return this; } setDeveloperKey() { return this; } - setOAuthToken() { return this; } setOrigin() { return this; } - setCallback(callback) { this.callback = callback; return this; } - build() { return { setVisible: () => this.callback({ action: 'picked', docs: [{ id: 'anonymous-sheet-id', name: 'Anonyme Finanzen' }] }) }; } - } - window.google = { picker: { Action: { PICKED: 'picked', CANCEL: 'cancel' }, ViewId: { SPREADSHEETS: 'spreadsheets' }, DocsView, PickerBuilder } }; - }); } diff --git a/scripts/import-finance.ts b/scripts/import-finance.ts new file mode 100644 index 0000000..62be6f8 --- /dev/null +++ b/scripts/import-finance.ts @@ -0,0 +1,51 @@ +import { readFile } from 'node:fs/promises'; +import { closeDatabase } from '../api/_lib/database.ts'; +import { financeImportFingerprint, parseFinanceImportJson, parseFinanceImportSource } from '../api/_lib/financeImport.ts'; +import { getFinanceRepository } from '../api/_lib/financeRepository.ts'; +import { anonymousSheetsResponse } from '../src/mocks/anonymousWorkbook.ts'; + +function requiredEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required.`); + return value; +} + +function parseArgs(argv: string[]) { + let fromFixture = false; + let fromFile: string | null = null; + for (const argument of argv) { + if (argument === '--from-fixture') fromFixture = true; + else if (argument.startsWith('--from-file=')) fromFile = argument.slice('--from-file='.length); + else throw new Error(`Unknown argument: ${argument}`); + } + if (fromFixture === Boolean(fromFile)) { + throw new Error('Provide exactly one of --from-fixture or --from-file=.'); + } + return { fromFixture, fromFile }; +} + +const source = parseArgs(process.argv.slice(2)); +const databaseUrl = requiredEnv('DATABASE_URL'); + +const raw = source.fromFixture + ? anonymousSheetsResponse + : parseFinanceImportJson(await readFile(source.fromFile!, 'utf8')); +const parsed = parseFinanceImportSource(raw); +if (!parsed.success) { + const issueCount = parsed.issues.length; + throw new Error(`Import validation failed with ${issueCount} ${issueCount === 1 ? 'issue' : 'issues'}.`); +} + +try { + const repository = getFinanceRepository(databaseUrl); + const stored = await repository.replaceForSoleOwner(parsed.data); + const expected = financeImportFingerprint(parsed.data); + const actual = financeImportFingerprint(stored); + if (JSON.stringify(expected) !== JSON.stringify(actual)) { + throw new Error('Imported finance stand failed the count and date parity check.'); + } + + process.stdout.write(`Imported finance stand for the verified owner. ${JSON.stringify(actual)}\n`); +} finally { + await closeDatabase(databaseUrl); +} diff --git a/scripts/offline-smoke.mjs b/scripts/offline-smoke.mjs index c082c13..b9c88c6 100644 --- a/scripts/offline-smoke.mjs +++ b/scripts/offline-smoke.mjs @@ -78,7 +78,7 @@ try { await page.getByLabel('Einstellungen öffnen').click(); const offlineInfo = await page.getByRole('dialog', { name: 'Informationen' }).innerText(); assert.match(offlineInfo, /Andere Farben · Systemmodus/); - assert.match(offlineInfo, /Offline verfügbar[\s\S]*Anonyme Finanzen/); + assert.match(offlineInfo, /Offline verfügbar[\s\S]*Gespeicherter Finanzstand/); assert.match(offlineInfo, /lokal auf diesem Gerät verfügbar/); assert.doesNotMatch(offlineInfo, /Jetzt aktualisieren|Andere Tabelle auswählen|Abmelden blendet Finanzdaten aus/); await page.keyboard.press('Escape'); @@ -102,12 +102,17 @@ try { assert.equal(await page.locator('[data-destination="debt"]').isVisible(), true, 'Online-Rückkehr verlor die aktive Ansicht'); assert.equal(await page.evaluate(() => navigator.serviceWorker.controller !== null), true, 'Online-Rückkehr verlor die Service-Worker-Kontrolle'); const recoveredCache = await page.evaluate(() => new Promise((resolve, reject) => { - const request = indexedDB.open('finance-overview', 1); + const ownerKey = localStorage.getItem('active-finance-cache-owner-v1'); + if (!ownerKey) { + reject(new Error('Aktiver Cache-Owner fehlt.')); + return; + } + const request = indexedDB.open('finance-overview', 2); request.onerror = () => reject(request.error); request.onsuccess = () => { const database = request.result; const transaction = database.transaction('last-good', 'readonly'); - const getRequest = transaction.objectStore('last-good').get('finance-data-v1'); + const getRequest = transaction.objectStore('last-good').get(`finance-data-v1:${ownerKey}`); getRequest.onerror = () => reject(getRequest.error); getRequest.onsuccess = () => { resolve(getRequest.result ?? null); @@ -134,7 +139,7 @@ try { try { await installFinanceApiMocks(coldPage, 'signed-out'); await coldPage.goto(baseUrl, { waitUntil: 'networkidle' }); - await coldPage.getByRole('heading', { name: 'Mit deiner Tabelle verbinden' }).waitFor(); + await coldPage.getByRole('heading', { name: 'Bei accura anmelden' }).waitFor(); await coldPage.evaluate(() => navigator.serviceWorker.ready); await coldPage.waitForFunction(() => navigator.serviceWorker.controller !== null); await coldPage.unrouteAll({ behavior: 'wait' }); diff --git a/scripts/pwa-smoke.mjs b/scripts/pwa-smoke.mjs index aac305c..dd119c8 100644 --- a/scripts/pwa-smoke.mjs +++ b/scripts/pwa-smoke.mjs @@ -149,7 +149,7 @@ async function assertTheme(base, colorScheme, expectedMode) { const page = await context.newPage(); try { await page.goto(base, { waitUntil: 'networkidle' }); - await page.getByRole('heading', { name: 'Mit deiner Tabelle verbinden' }).waitFor(); + await page.getByRole('heading', { name: 'Bei accura anmelden' }).waitFor(); const theme = await page.evaluate(() => ({ active: document.querySelector('meta[data-appearance-theme-color]')?.getAttribute('content'), page: getComputedStyle(document.documentElement).getPropertyValue('--color-page').trim(), @@ -174,7 +174,7 @@ page.on('pageerror', (error) => errors.push(`Laufzeit: ${error.message}`)); try { await page.goto(baseUrl, { waitUntil: 'networkidle' }); - await page.getByRole('heading', { name: 'Mit deiner Tabelle verbinden' }).waitFor(); + await page.getByRole('heading', { name: 'Bei accura anmelden' }).waitFor(); const manifestResponse = await context.request.get(`${baseUrl}/manifest.webmanifest`); assert.equal(manifestResponse.ok(), true, 'Manifest konnte nicht geladen werden'); @@ -209,10 +209,10 @@ try { await page.evaluate(() => navigator.serviceWorker.ready); await page.reload({ waitUntil: 'networkidle' }); - if (!await page.getByRole('heading', { name: 'Mit deiner Tabelle verbinden' }).isVisible().catch(() => false)) { + if (!await page.getByRole('heading', { name: 'Bei accura anmelden' }).isVisible().catch(() => false)) { throw new Error(`App failed after service-worker control. URL=${page.url()} errors=${errors.join(' | ')} body=${(await page.locator('body').innerText()).slice(0, 500)}`); } - await page.getByRole('heading', { name: 'Mit deiner Tabelle verbinden' }).waitFor(); + await page.getByRole('heading', { name: 'Bei accura anmelden' }).waitFor(); await page.waitForFunction(() => navigator.serviceWorker.controller !== null); assert.equal(await controllerGeneration(page), 1); @@ -315,12 +315,12 @@ try { assert.equal(await page.evaluate(async () => Boolean((await navigator.serviceWorker.getRegistration())?.waiting)), true, 'Später aktivierte den wartenden Worker'); await page.reload({ waitUntil: 'networkidle' }); - await page.getByRole('heading', { name: 'Mit deiner Tabelle verbinden' }).waitFor(); + await page.getByRole('heading', { name: 'Bei accura anmelden' }).waitFor(); await notice.getByText('Neue Version verfügbar').waitFor(); const navigationsBeforeUpdate = mainNavigations; await notice.getByRole('button', { name: 'Jetzt neu laden' }).click(); await page.waitForFunction(() => navigator.serviceWorker.controller !== null && !document.querySelector('.pwa-update-notice')); - await page.getByRole('heading', { name: 'Mit deiner Tabelle verbinden' }).waitFor(); + await page.getByRole('heading', { name: 'Bei accura anmelden' }).waitFor(); assert.equal(mainNavigations, navigationsBeforeUpdate + 1, 'Aktivierung lud die App nicht genau einmal neu'); assert.equal(await controllerGeneration(page), 2, 'Generation 2 kontrolliert die App nach der Aktualisierung nicht'); assert.deepEqual(errors, [], errors.join('\n')); diff --git a/src/App.tsx b/src/App.tsx index 62ecd31..e24ef02 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,22 +43,8 @@ function ScreenLoading({ label = 'Ansicht wird geladen …' }: { label?: string export function ConnectionStateScreen() { const finance = useFinanceData(); - const isLoading = finance.authState === 'checking' || finance.pickerOpen || (finance.syncState === 'syncing' && !finance.data); - if (isLoading) return ; - - if (finance.connectionState === 'reconnect') { - return ( - Mit Google neu verbinden} - eyebrow="Autorisierung abgelaufen" - mark={} - state="reconnect" - supporting="Der Zugriff wurde widerrufen oder ist abgelaufen. Verbinde das freigegebene Konto erneut; deine Tabelle wird danach wieder geladen." - title="Google erneut verbinden" - tone="warning" - /> - ); - } + const isLoading = finance.authState === 'checking' || (finance.syncState === 'syncing' && !finance.data); + if (isLoading) return ; if (finance.syncState === 'offline' && !finance.data) { return ( @@ -66,7 +52,7 @@ export function ConnectionStateScreen() { eyebrow="Offline" mark={} state="offline-empty" - supporting="Stelle eine Internetverbindung her, um dich anzumelden und deine Tabelle erstmals zu synchronisieren." + supporting="Stelle eine Internetverbindung her, um dich anzumelden und deinen Finanzstand erstmals zu laden." title="Noch kein lokaler Datenstand" tone="warning" /> @@ -78,10 +64,10 @@ export function ConnectionStateScreen() { Mit Google anmelden} eyebrow="Private Finanzübersicht" - mark={} + mark={} state="signed-out" - supporting="Melde dich mit dem freigegebenen Google-Konto an und wähle anschließend genau eine Tabelle im Finance Data Schema v1 aus." - title="Mit deiner Tabelle verbinden" + supporting="Melde dich mit dem freigegebenen Google-Konto an. Google dient nur der Anmeldung; der Finanzstand liegt in accura." + title="Bei accura anmelden" > {finance.error ?

{finance.error.message}

: null}
@@ -91,12 +77,12 @@ export function ConnectionStateScreen() { if (finance.syncState === 'validation-error') { return ( void finance.selectSpreadsheet()} size="large">Andere Tabelle auswählen} - eyebrow="Schema nicht gültig" + action={ void finance.refresh()} size="large">Erneut laden} + eyebrow="Datenstand ungültig" mark={} state="validation-error" - supporting={finance.error?.message ?? 'Die ausgewählte Tabelle entspricht nicht dem Finance Data Schema v1.'} - title="Tabelle konnte nicht übernommen werden" + supporting={finance.error?.message ?? 'Der gespeicherte Finanzstand ist ungültig.'} + title="Finanzstand konnte nicht geladen werden" tone="danger" > @@ -104,21 +90,14 @@ export function ConnectionStateScreen() { ); } - const needsConnection = finance.connectionState === 'disconnected'; return ( void finance.selectSpreadsheet()} size="large"> - {needsConnection ? 'Google verbinden' : 'Google-Tabelle auswählen'} - - )} - eyebrow={needsConnection ? 'Google-Verbindung fehlt' : 'Fast fertig'} - mark={} - state={needsConnection ? 'disconnected' : 'no-spreadsheet'} - supporting={needsConnection - ? 'Die Anmeldung ist gültig, aber es wurde keine gespeicherte Google-Verbindung gefunden.' - : 'Der Picker zeigt ausschließlich Google-Sheets-Dateien. Die Auswahl wird erst nach erfolgreicher Schema-Prüfung gespeichert.'} - title={needsConnection ? 'Google erneut verbinden' : 'Google-Tabelle auswählen'} + action={ void finance.refresh()} size="large">Erneut prüfen} + eyebrow="Noch kein Finanzstand" + mark={} + state="no-finance" + supporting="Die Anmeldung ist gültig, aber es wurde noch kein Finanzstand importiert. Der einmalige Import liegt außerhalb der App." + title="Finanzstand fehlt" /> ); } @@ -181,7 +160,7 @@ function App({ initialDestination }: AppProps) {
-
accura{hasData ? screenName : 'Verbindung'}
+
accura{hasData ? screenName : 'Anmeldung'}
@@ -190,7 +169,7 @@ function App({ initialDestination }: AppProps) {
{hasData ? : null} -
+
{hasData ? : }
{hasData ? : null} diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 3d58984..facda46 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -34,17 +34,16 @@ export function SettingsEntry() { const [colorsOpen, setColorsOpen] = useState(false); const [pinDialogMode, setPinDialogMode] = useState(null); const [protectionMessage, setProtectionMessage] = useState(null); - const [confirmDisconnect, setConfirmDisconnect] = useState(false); + const triggerRef = useRef(null); const surfaceRef = useRef(null); const closeRef = useRef(null); const colorsRef = useRef(null); const privacyScreenRef = useRef(null); const pinProtectionRef = useRef(null); - const cancelDisconnectRef = useRef(null); + const closeSettings = useCallback(() => { - setConfirmDisconnect(false); setColorsOpen(false); setPinDialogMode(null); setProtectionMessage(null); @@ -63,12 +62,6 @@ export function SettingsEntry() { } }, [colorsOpen, pinDialogMode]); - useEffect(() => { - if (!confirmDisconnect) return; - const frame = requestAnimationFrame(() => cancelDisconnectRef.current?.focus({ preventScroll: true })); - return () => cancelAnimationFrame(frame); - }, [confirmDisconnect]); - useEffect(() => { if (!open || !privacy.appProtection.privacyScreenEnabled) return; const closeWhenHidden = () => { @@ -108,12 +101,18 @@ export function SettingsEntry() { : localDataOnly ? finance.syncState === 'offline' ? 'Offline verfügbar' : 'Lokaler Datenstand' : 'Nicht angemeldet'; - const sourceName = finance.spreadsheet?.name ?? (localDataOnly ? 'Gespeicherte Finanzdaten' : 'Keine Tabelle ausgewählt'); + const sourceName = finance.data + ? 'Gespeicherter Finanzstand' + : authenticated + ? 'Noch kein Finanzstand' + : localDataOnly + ? 'Gespeicherte Finanzdaten' + : 'Kein lokaler Datenstand'; const sourceSupporting = authenticated - ? 'OAuth-Tokens und Google-Zugangsdaten bleiben ausschließlich auf dem Server.' + ? 'Google dient nur der Anmeldung. Der Finanzstand liegt in accura und wird nicht aus einer Tabelle gelesen.' : localDataOnly ? 'Dieser Datenstand ist lokal auf diesem Gerät verfügbar. Es werden keine Google-Zugangsdaten im Browser gespeichert.' - : 'Es sind weder eine Google-Verbindung noch lokale Finanzdaten auf diesem Gerät aktiv.'; + : 'Es sind weder eine Anmeldung noch lokale Finanzdaten auf diesem Gerät aktiv.'; const pinConfigured = Boolean(privacy.appProtection.pin); const pinSetupAvailable = privacy.pinSecurityAvailable && privacy.appProtectionStorageAvailable; @@ -134,7 +133,7 @@ export function SettingsEntry() { ariaLabelledBy="settings-title" className="settings-layer" initialFocusRef={closeRef} - onClose={() => { if (confirmDisconnect) setConfirmDisconnect(false); else closeSettings(); }} + onClose={closeSettings} open={open} returnFocusRef={triggerRef} surfaceClassName="settings-surface" @@ -145,18 +144,7 @@ export function SettingsEntry() { - {confirmDisconnect ? ( -
-

Google-Verbindung trennen?

-

Google-Zugriff, gespeicherte Verbindung und der Offline-Datenstand auf diesem Gerät werden entfernt.

-

Farben, Design und App-Schutz bleiben als gerätebezogene Einstellungen erhalten.

-
- setConfirmDisconnect(false)} ref={cancelDisconnectRef} variant="tonal">Abbrechen - void finance.disconnect().then(closeSettings)} variant="danger">Endgültig trennen -
-
- ) : ( -
+

Darstellung

@@ -243,13 +230,12 @@ export function SettingsEntry() {

{authenticated ? 'Konto & Datenschutz' : 'Datenschutz'}

{authenticated - ? 'Abmelden blendet Finanzdaten aus. Nur das Trennen entfernt die Google-Verbindung und lokale Offline-Finanzdaten; Farben, Design und App-Schutz bleiben erhalten.' + ? 'Abmelden beendet nur die Sitzung auf diesem Gerät. Der gespeicherte Finanzstand in accura bleibt erhalten; Farben, Design und App-Schutz ebenfalls.' : 'Farben, Design und App-Schutz bleiben ausschließlich auf diesem Gerät. Lokale Finanzdaten werden nur als letzter gültiger Offline-Stand gespeichert.'}

{authenticated ? (
} onClick={() => void finance.logout().then(closeSettings)} variant="tonal">Abmelden - } onClick={() => setConfirmDisconnect(true)} variant="danger">Google-Verbindung trennen
) : null} @@ -265,8 +251,7 @@ export function SettingsEntry() { Drittanbieter-Lizenzen -
- )} +
diff --git a/src/components/SyncStatusBanner.tsx b/src/components/SyncStatusBanner.tsx index 81e3e84..04da916 100644 --- a/src/components/SyncStatusBanner.tsx +++ b/src/components/SyncStatusBanner.tsx @@ -13,10 +13,9 @@ export function SyncStatusBanner() { const status = finance.syncState === 'syncing' ? 'Wird aktualisiert …' : finance.syncState === 'offline' ? 'Offline · gespeicherter Stand' : finance.syncState === 'validation-error' ? 'Neue Daten ungültig · letzter gültiger Stand' - : finance.connectionState === 'reconnect' ? 'Google muss erneut verbunden werden' - : finance.stale ? 'Gespeicherter Stand' : 'Aktuell'; + : finance.stale ? 'Gespeicherter Stand' : 'Aktuell'; const tone = finance.syncState === 'validation-error' ? 'danger' - : finance.syncState === 'offline' || finance.connectionState === 'reconnect' || finance.stale ? 'warning' + : finance.syncState === 'offline' || finance.stale ? 'warning' : 'neutral'; const important = tone !== 'neutral'; @@ -49,7 +48,6 @@ export function SyncStatusBanner() { {important ? (

{finance.error?.message ?? status}

- {finance.connectionState === 'reconnect' ? Neu verbinden : null}
) : null} diff --git a/src/data/FinanceDataProvider.test.ts b/src/data/FinanceDataProvider.test.ts index 8ac5145..360bf8c 100644 --- a/src/data/FinanceDataProvider.test.ts +++ b/src/data/FinanceDataProvider.test.ts @@ -6,41 +6,45 @@ import { financeProviderReducer, initialFinanceState } from './FinanceDataProvid const parsed = parseSheetsBatchResponse(anonymousSheetsResponse); if (!parsed.success) throw new Error('Anonymous workbook is invalid.'); const data = parsed.data; -const sheet = { id: 'sheet-id', name: 'Anonyme Finanzen' }; describe('finance provider state transitions', () => { - it('represents disconnected, authenticated-no-sheet, loading, stale, offline, and validation-error states', () => { + it('represents signed-out, missing finance, loading, stale, offline, and integrity-error states', () => { const signedOut = financeProviderReducer(initialFinanceState, { type: 'signed-out' }); - expect(signedOut).toMatchObject({ authState: 'signed-out', connectionState: 'disconnected' }); + expect(signedOut).toMatchObject({ authState: 'signed-out', connectionState: 'unknown' }); - const noSheet = financeProviderReducer(initialFinanceState, { + const missing = financeProviderReducer(initialFinanceState, { type: 'authenticated', - session: { authenticated: true, user: { email: 'owner@example.test' }, csrfToken: 'csrf', connection: { connected: true, spreadsheet: null } }, + session: { + authenticated: true, + user: { email: 'owner@example.test' }, + csrfToken: 'csrf', + ownerKey: 'test-owner-cache-key-0000000000000001', + }, }); - expect(noSheet).toMatchObject({ authState: 'authenticated', connectionState: 'no-spreadsheet', syncState: 'initial' }); + expect(missing).toMatchObject({ authState: 'authenticated', connectionState: 'unknown', syncState: 'initial' }); + expect(financeProviderReducer(missing, { + type: 'sync-failed', + syncState: 'error', + missing: true, + error: { code: 'finance_missing', message: 'Es ist noch kein Finanzstand vorhanden.' }, + })).toMatchObject({ connectionState: 'missing' }); - const cached = financeProviderReducer(initialFinanceState, { type: 'cache-loaded', data, refreshedAt: '2026-08-08T10:00:00.000Z', spreadsheet: sheet }); - expect(cached).toMatchObject({ syncState: 'stale', stale: true, data }); + const cached = financeProviderReducer(initialFinanceState, { type: 'cache-loaded', data, refreshedAt: '2026-08-08T10:00:00.000Z' }); + expect(cached).toMatchObject({ syncState: 'stale', stale: true, data, connectionState: 'ready' }); expect(financeProviderReducer(cached, { type: 'sync-started' }).syncState).toBe('syncing'); expect(financeProviderReducer(cached, { type: 'offline-startup', hasData: true })).toMatchObject({ authState: 'offline', syncState: 'offline', data }); - expect(financeProviderReducer(cached, { type: 'sync-failed', syncState: 'validation-error', error: { code: 'invalid_finance_schema', message: 'Ungültig' } })).toMatchObject({ syncState: 'validation-error', stale: true }); + expect(financeProviderReducer(cached, { type: 'sync-failed', syncState: 'validation-error', error: { code: 'finance_data_integrity', message: 'Ungültig' } })).toMatchObject({ syncState: 'validation-error', stale: true }); }); it('retains the last-known-good data after an invalid refresh', () => { - const valid = financeProviderReducer(initialFinanceState, { type: 'sync-succeeded', data, refreshedAt: '2026-08-08T10:00:00.000Z', spreadsheet: sheet }); + const valid = financeProviderReducer(initialFinanceState, { type: 'sync-succeeded', data, refreshedAt: '2026-08-08T10:00:00.000Z' }); const failed = financeProviderReducer(valid, { type: 'sync-failed', syncState: 'validation-error', - error: { code: 'invalid_finance_schema', message: 'Tabelle ungültig', issues: [{ tab: '_Meta', row: 2, column: 'schema_version', expected: '1', message: 'Ungültig' }] }, + error: { code: 'finance_data_integrity', message: 'Der gespeicherte Finanzstand ist ungültig.' }, }); expect(failed.data).toBe(data); expect(failed.lastSuccessfulRefresh).toBe('2026-08-08T10:00:00.000Z'); - expect(failed).toMatchObject({ stale: true, syncState: 'validation-error' }); - }); - - it('enters a reconnect state for revoked authorization without discarding valid data', () => { - const valid = financeProviderReducer(initialFinanceState, { type: 'sync-succeeded', data, refreshedAt: '2026-08-08T10:00:00.000Z', spreadsheet: sheet }); - const reconnect = financeProviderReducer(valid, { type: 'sync-failed', syncState: 'error', reconnect: true, error: { code: 'reconnect_required', message: 'Neu verbinden' } }); - expect(reconnect).toMatchObject({ connectionState: 'reconnect', data, stale: true }); + expect(failed).toMatchObject({ stale: true, syncState: 'validation-error', connectionState: 'ready' }); }); }); diff --git a/src/data/FinanceDataProvider.tsx b/src/data/FinanceDataProvider.tsx index 50e75b7..ffa9547 100644 --- a/src/data/FinanceDataProvider.tsx +++ b/src/data/FinanceDataProvider.tsx @@ -5,22 +5,25 @@ import { createFinanceViewModel, type FinanceViewModel } from '../finance/viewMo import { getCurrentUserDateISO, millisecondsUntilNextLocalDay } from '../lib/calendarDate'; import { safeAppReturnPath } from '../navigation/appNavigation'; import { + ACTIVE_FINANCE_CACHE_OWNER_STORAGE_KEY, clearCachedFinanceData, + clearActiveFinanceCacheOwner, loadCachedFinanceData, + readActiveFinanceCacheOwner, readFinanceCacheGeneration, restoreFinanceCacheGeneration, rotateFinanceCacheGeneration, saveCachedFinanceData, + setActiveFinanceCacheOwner, } from './financeCache'; import { FinanceApiError, productionFinanceApi, type FinanceApi, type FinanceSession } from './financeApi'; -import { launchGooglePicker, selectSpreadsheetWithPicker, type PickerLauncher } from './googlePicker'; import { runWithAbortTimeout } from './requestTimeout'; const STALE_AFTER_MS = 10 * 60 * 1000; export const PROTECTED_ACCESS_RECOVERY_TIMEOUT_MS = 15_000; export type AuthState = 'checking' | 'signed-out' | 'authenticated' | 'offline'; -export type ConnectionState = 'unknown' | 'disconnected' | 'no-spreadsheet' | 'connected' | 'reconnect'; +export type ConnectionState = 'unknown' | 'ready' | 'missing'; export type SyncState = 'initial' | 'syncing' | 'idle' | 'stale' | 'offline' | 'validation-error' | 'error'; export type ProtectedAccessRecoveryResult = 'success' | 'offline' | 'error'; @@ -36,12 +39,10 @@ export type FinanceProviderState = { syncState: SyncState; email: string | null; csrfToken: string | null; - spreadsheet: { id: string; name: string } | null; data: FinanceDataV1 | null; lastSuccessfulRefresh: string | null; stale: boolean; error: FinanceUiError | null; - pickerOpen: boolean; }; export const initialFinanceState: FinanceProviderState = { @@ -50,62 +51,62 @@ export const initialFinanceState: FinanceProviderState = { syncState: 'initial', email: null, csrfToken: null, - spreadsheet: null, data: null, lastSuccessfulRefresh: null, stale: false, error: null, - pickerOpen: false, }; type Action = - | { type: 'cache-loaded'; data: FinanceDataV1; refreshedAt: string; spreadsheet: { id: string; name: string } } + | { type: 'cache-loaded'; data: FinanceDataV1; refreshedAt: string } | { type: 'signed-out'; error?: FinanceUiError } | { type: 'authenticated'; session: Extract } | { type: 'offline-startup'; hasData: boolean } | { type: 'sync-started' } - | { type: 'sync-succeeded'; data: FinanceDataV1; refreshedAt: string; spreadsheet: { id: string; name: string } } - | { type: 'sync-failed'; error: FinanceUiError; syncState: SyncState; reconnect?: boolean } - | { type: 'picker-started' } - | { type: 'picker-finished' } + | { type: 'sync-succeeded'; data: FinanceDataV1; refreshedAt: string } + | { type: 'sync-failed'; error: FinanceUiError; syncState: SyncState; missing?: boolean } | { type: 'reset' }; export function financeProviderReducer(state: FinanceProviderState, action: Action): FinanceProviderState { switch (action.type) { case 'cache-loaded': - return { ...state, data: action.data, lastSuccessfulRefresh: action.refreshedAt, spreadsheet: action.spreadsheet, stale: true, syncState: 'stale' }; + return { ...state, data: action.data, lastSuccessfulRefresh: action.refreshedAt, stale: true, syncState: 'stale', connectionState: 'ready' }; case 'signed-out': - return { ...initialFinanceState, authState: 'signed-out', connectionState: 'disconnected', syncState: 'idle', error: action.error ?? null }; - case 'authenticated': { - const spreadsheet = action.session.connection.spreadsheet; + return { ...initialFinanceState, authState: 'signed-out', connectionState: 'unknown', syncState: 'idle', error: action.error ?? null }; + case 'authenticated': return { ...state, authState: 'authenticated', - connectionState: action.session.connection.connected ? (spreadsheet ? 'connected' : 'no-spreadsheet') : 'disconnected', + connectionState: state.data ? 'ready' : 'unknown', email: action.session.user.email, csrfToken: action.session.csrfToken, - spreadsheet, - data: spreadsheet && state.spreadsheet?.id === spreadsheet.id ? state.data : null, - lastSuccessfulRefresh: spreadsheet && state.spreadsheet?.id === spreadsheet.id ? state.lastSuccessfulRefresh : null, - stale: Boolean(spreadsheet && state.data), - syncState: spreadsheet && state.data ? 'stale' : 'initial', + stale: Boolean(state.data), + syncState: state.data ? 'stale' : 'initial', error: null, }; - } case 'offline-startup': - return { ...state, authState: action.hasData ? 'offline' : 'signed-out', connectionState: action.hasData ? 'connected' : 'unknown', syncState: 'offline', stale: action.hasData, error: action.hasData ? null : { code: 'network_error', message: 'Offline und keine gespeicherten Daten verfügbar.' } }; + return { + ...state, + authState: action.hasData ? 'offline' : 'signed-out', + connectionState: action.hasData ? 'ready' : 'unknown', + syncState: 'offline', + stale: action.hasData, + error: action.hasData ? null : { code: 'network_error', message: 'Offline und keine gespeicherten Daten verfügbar.' }, + }; case 'sync-started': return { ...state, syncState: 'syncing', error: null }; case 'sync-succeeded': - return { ...state, connectionState: 'connected', syncState: 'idle', spreadsheet: action.spreadsheet, data: action.data, lastSuccessfulRefresh: action.refreshedAt, stale: false, error: null }; + return { ...state, connectionState: 'ready', syncState: 'idle', data: action.data, lastSuccessfulRefresh: action.refreshedAt, stale: false, error: null }; case 'sync-failed': - return { ...state, connectionState: action.reconnect ? 'reconnect' : state.connectionState, syncState: action.syncState, stale: Boolean(state.data), error: action.error }; - case 'picker-started': - return { ...state, pickerOpen: true, error: null }; - case 'picker-finished': - return { ...state, pickerOpen: false }; + return { + ...state, + connectionState: action.missing && !state.data ? 'missing' : state.connectionState, + syncState: action.syncState, + stale: Boolean(state.data), + error: action.error, + }; case 'reset': - return { ...initialFinanceState, authState: 'signed-out', connectionState: 'disconnected', syncState: 'idle' }; + return { ...initialFinanceState, authState: 'signed-out', connectionState: 'unknown', syncState: 'idle' }; } } @@ -113,9 +114,7 @@ type FinanceContextValue = FinanceProviderState & { viewModel: FinanceViewModel | null; online: boolean; refresh: () => Promise; - selectSpreadsheet: () => Promise; logout: () => Promise; - disconnect: () => Promise; recoverProtectedAccess: () => Promise; signIn: () => void; }; @@ -128,19 +127,17 @@ const toUiError = (error: unknown): FinanceUiError => error instanceof FinanceAp const syncStateFor = (error: FinanceUiError, online: boolean): SyncState => { if (!online || error.code === 'network_error') return 'offline'; - if (error.code === 'invalid_finance_schema') return 'validation-error'; + if (error.code === 'finance_data_integrity' || error.code === 'invalid_finance_schema') return 'validation-error'; return 'error'; }; export function FinanceDataProvider({ children, api = productionFinanceApi, - pickerLauncher = launchGooglePicker, initialState = initialFinanceState, }: { children: ReactNode; api?: FinanceApi; - pickerLauncher?: PickerLauncher; initialState?: FinanceProviderState; }) { const [state, dispatch] = useReducer(financeProviderReducer, initialState); @@ -151,37 +148,49 @@ export function FinanceDataProvider({ const abortController = useRef(null); const generation = useRef(0); const cacheGeneration = useRef(readFinanceCacheGeneration()); + const cacheOwnerKey = useRef(null); stateRef.current = state; const acceptFinanceResponse = useCallback(async ( response: Awaited>, requestGeneration: number, requestCacheGeneration: string, + requestOwnerKey: string, ) => { if (generation.current !== requestGeneration) return; + if (response.ownerKey !== requestOwnerKey) { + generation.current += 1; + clearActiveFinanceCacheOwner(); + cacheOwnerKey.current = null; + const action = { type: 'reset' as const }; + stateRef.current = financeProviderReducer(stateRef.current, action); + dispatch(action); + window.location.reload(); + return; + } const persisted = await saveCachedFinanceData({ - spreadsheetId: response.spreadsheet.id, - spreadsheetName: response.spreadsheet.name, refreshedAt: response.refreshedAt, data: response.data, - }, requestCacheGeneration); + }, requestOwnerKey, requestCacheGeneration); if (!persisted || generation.current !== requestGeneration) return; - dispatch({ type: 'sync-succeeded', ...response }); + dispatch({ type: 'sync-succeeded', data: response.data, refreshedAt: response.refreshedAt }); }, []); const refresh = useCallback(async () => { if (refreshPromise.current) return refreshPromise.current; const current = stateRef.current; - if (!current.spreadsheet || (current.authState !== 'authenticated' && current.authState !== 'offline')) return; + if (current.authState !== 'authenticated') return; const requestGeneration = generation.current; const requestCacheGeneration = cacheGeneration.current; + const requestOwnerKey = cacheOwnerKey.current; + if (!requestOwnerKey) return; const controller = new AbortController(); abortController.current = controller; const task = (async () => { dispatch({ type: 'sync-started' }); try { const response = await api.getFinance(controller.signal); - await acceptFinanceResponse(response, requestGeneration, requestCacheGeneration); + await acceptFinanceResponse(response, requestGeneration, requestCacheGeneration, requestOwnerKey); } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return; if (generation.current !== requestGeneration) return; @@ -190,7 +199,7 @@ export function FinanceDataProvider({ type: 'sync-failed', error: uiError, syncState: syncStateFor(uiError, navigator.onLine), - reconnect: uiError.code === 'reconnect_required', + missing: uiError.code === 'finance_missing', }); } finally { if (abortController.current === controller) abortController.current = null; @@ -222,18 +231,11 @@ export function FinanceDataProvider({ useEffect(() => { let active = true; void (async () => { - const cached = await loadCachedFinanceData().catch(() => null); - if (!active) return; - if (cached) dispatch({ - type: 'cache-loaded', - data: cached.data, - refreshedAt: cached.refreshedAt, - spreadsheet: { id: cached.spreadsheetId, name: cached.spreadsheetName }, - }); try { const session = await api.getSession(); if (!active) return; if (!session.authenticated) { + clearActiveFinanceCacheOwner(); const authError = new URLSearchParams(window.location.search).get('auth_error'); if (authError) { const currentUrl = new URL(window.location.href); @@ -250,11 +252,29 @@ export function FinanceDataProvider({ dispatch({ type: 'signed-out', error: message ? { code: authError!, message } : undefined }); return; } + cacheOwnerKey.current = session.ownerKey; + setActiveFinanceCacheOwner(session.ownerKey); + const cached = await loadCachedFinanceData(session.ownerKey).catch(() => null); + if (!active) return; + if (cached) { + const action = { type: 'cache-loaded' as const, data: cached.data, refreshedAt: cached.refreshedAt }; + dispatch(action); + stateRef.current = financeProviderReducer(stateRef.current, action); + } dispatch({ type: 'authenticated', session }); stateRef.current = financeProviderReducer(stateRef.current, { type: 'authenticated', session }); - if (session.connection.spreadsheet) await refresh(); + await refresh(); } catch { - if (active) dispatch({ type: 'offline-startup', hasData: Boolean(cached) }); + const ownerKey = readActiveFinanceCacheOwner(); + cacheOwnerKey.current = ownerKey; + const cached = ownerKey ? await loadCachedFinanceData(ownerKey).catch(() => null) : null; + if (!active) return; + if (cached) { + const action = { type: 'cache-loaded' as const, data: cached.data, refreshedAt: cached.refreshedAt }; + dispatch(action); + stateRef.current = financeProviderReducer(stateRef.current, action); + } + dispatch({ type: 'offline-startup', hasData: Boolean(cached) }); } })(); return () => { @@ -264,10 +284,50 @@ export function FinanceDataProvider({ }; }, [api, refresh]); + const restoreSessionAfterOffline = useCallback(async () => { + try { + const session = await api.getSession(); + if (!session.authenticated) { + generation.current += 1; + abortController.current?.abort(); + clearActiveFinanceCacheOwner(); + cacheOwnerKey.current = null; + const action = { type: 'signed-out' as const }; + stateRef.current = financeProviderReducer(stateRef.current, action); + dispatch(action); + return; + } + + if (cacheOwnerKey.current !== session.ownerKey) { + generation.current += 1; + abortController.current?.abort(); + const resetAction = { type: 'reset' as const }; + stateRef.current = financeProviderReducer(stateRef.current, resetAction); + dispatch(resetAction); + cacheOwnerKey.current = session.ownerKey; + setActiveFinanceCacheOwner(session.ownerKey); + const cached = await loadCachedFinanceData(session.ownerKey).catch(() => null); + if (cached) { + const cacheAction = { type: 'cache-loaded' as const, data: cached.data, refreshedAt: cached.refreshedAt }; + stateRef.current = financeProviderReducer(stateRef.current, cacheAction); + dispatch(cacheAction); + } + } + + const authenticatedAction = { type: 'authenticated' as const, session }; + stateRef.current = financeProviderReducer(stateRef.current, authenticatedAction); + dispatch(authenticatedAction); + await refresh(); + } catch { + // Keep the validated local snapshot in its offline state until session resolution succeeds. + } + }, [api, refresh]); + useEffect(() => { const onOnline = () => { setOnline(true); - if (stateRef.current.spreadsheet) void refresh(); + if (stateRef.current.authState === 'authenticated') void refresh(); + else if (stateRef.current.authState === 'offline') void restoreSessionAfterOffline(); }; const onOffline = () => { setOnline(false); @@ -278,39 +338,27 @@ export function FinanceDataProvider({ const last = stateRef.current.lastSuccessfulRefresh; if (!last || Date.now() - new Date(last).valueOf() > STALE_AFTER_MS) void refresh(); }; + const onCacheOwnerChange = (event: StorageEvent) => { + if (event.key !== ACTIVE_FINANCE_CACHE_OWNER_STORAGE_KEY || event.newValue === cacheOwnerKey.current) return; + generation.current += 1; + abortController.current?.abort(); + cacheOwnerKey.current = null; + const action = { type: 'reset' as const }; + stateRef.current = financeProviderReducer(stateRef.current, action); + dispatch(action); + if (event.newValue && navigator.onLine) void restoreSessionAfterOffline(); + }; window.addEventListener('online', onOnline); window.addEventListener('offline', onOffline); document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('storage', onCacheOwnerChange); return () => { window.removeEventListener('online', onOnline); window.removeEventListener('offline', onOffline); document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('storage', onCacheOwnerChange); }; - }, [refresh, setOnline]); - - const selectSpreadsheet = useCallback(async () => { - const csrfToken = stateRef.current.csrfToken; - if (!csrfToken) return; - generation.current += 1; - abortController.current?.abort(); - const requestGeneration = generation.current; - const requestCacheGeneration = cacheGeneration.current; - const controller = new AbortController(); - abortController.current = controller; - dispatch({ type: 'picker-started' }); - try { - const response = await selectSpreadsheetWithPicker(api, pickerLauncher, csrfToken, controller.signal); - if (response) await acceptFinanceResponse(response, requestGeneration, requestCacheGeneration); - } catch (error) { - if (!(error instanceof DOMException && error.name === 'AbortError')) { - const uiError = toUiError(error); - dispatch({ type: 'sync-failed', error: uiError, syncState: syncStateFor(uiError, navigator.onLine), reconnect: uiError.code === 'reconnect_required' }); - } - } finally { - if (abortController.current === controller) abortController.current = null; - dispatch({ type: 'picker-finished' }); - } - }, [acceptFinanceResponse, api, pickerLauncher]); + }, [refresh, restoreSessionAfterOffline, setOnline]); const logout = useCallback(async () => { const csrfToken = stateRef.current.csrfToken; @@ -319,21 +367,8 @@ export function FinanceDataProvider({ await api.logout(csrfToken); generation.current += 1; abortController.current?.abort(); - dispatch({ type: 'reset' }); - } catch (error) { - const uiError = toUiError(error); - dispatch({ type: 'sync-failed', error: uiError, syncState: syncStateFor(uiError, navigator.onLine) }); - } - }, [api]); - - const disconnect = useCallback(async () => { - const csrfToken = stateRef.current.csrfToken; - if (!csrfToken) return; - try { - await api.disconnect(csrfToken); - generation.current += 1; - abortController.current?.abort(); - await clearCachedFinanceData(); + clearActiveFinanceCacheOwner(); + cacheOwnerKey.current = null; dispatch({ type: 'reset' }); } catch (error) { const uiError = toUiError(error); @@ -344,11 +379,11 @@ export function FinanceDataProvider({ const recoverProtectedAccess = useCallback(async (): Promise => { if (!navigator.onLine || stateRef.current.authState === 'offline') return 'offline'; const current = stateRef.current; - let disconnectConnection: ((signal: AbortSignal) => Promise) | null = null; + let endSession: ((signal: AbortSignal) => Promise) | null = null; if (current.authState === 'authenticated') { if (!current.csrfToken) return 'error'; const csrfToken = current.csrfToken; - disconnectConnection = (signal) => api.disconnect(csrfToken, signal); + endSession = (signal) => api.logout(csrfToken, signal); } else if (current.authState !== 'signed-out') { return 'error'; } @@ -356,26 +391,25 @@ export function FinanceDataProvider({ const recoveryCacheGeneration = rotateFinanceCacheGeneration(); if (!recoveryCacheGeneration) return 'error'; cacheGeneration.current = recoveryCacheGeneration; - let connectionRemoved = disconnectConnection === null; + let sessionEnded = endSession === null; try { - if (disconnectConnection) { + if (endSession) { await runWithAbortTimeout( - disconnectConnection, + endSession, PROTECTED_ACCESS_RECOVERY_TIMEOUT_MS, ); - connectionRemoved = true; + sessionEnded = true; } generation.current += 1; abortController.current?.abort(); - // Reset in-memory data before the fallible IndexedDB cleanup. If cleanup - // fails after a successful server disconnect, a retry can then finish it - // through the signed-out path without exposing the stale finance state. stateRef.current = financeProviderReducer(stateRef.current, { type: 'reset' }); dispatch({ type: 'reset' }); await clearCachedFinanceData(); + clearActiveFinanceCacheOwner(); + cacheOwnerKey.current = null; return 'success'; } catch { - if (!connectionRemoved && restoreFinanceCacheGeneration(previousCacheGeneration)) { + if (!sessionEnded && restoreFinanceCacheGeneration(previousCacheGeneration)) { cacheGeneration.current = previousCacheGeneration; } return navigator.onLine ? 'error' : 'offline'; @@ -392,12 +426,10 @@ export function FinanceDataProvider({ viewModel, online, refresh, - selectSpreadsheet, logout, - disconnect, recoverProtectedAccess, signIn, - }), [state, viewModel, online, refresh, selectSpreadsheet, logout, disconnect, recoverProtectedAccess, signIn]); + }), [state, viewModel, online, refresh, logout, recoverProtectedAccess, signIn]); return {children}; } diff --git a/src/data/financeApi.test.ts b/src/data/financeApi.test.ts index 7d14c35..ae3fc72 100644 --- a/src/data/financeApi.test.ts +++ b/src/data/financeApi.test.ts @@ -6,7 +6,7 @@ describe('production finance API', () => { vi.unstubAllGlobals(); }); - it('forwards the disconnect abort signal to fetch', async () => { + it('forwards the logout abort signal to fetch', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' }, status: 200, @@ -14,9 +14,9 @@ describe('production finance API', () => { vi.stubGlobal('fetch', fetchMock); const controller = new AbortController(); - await productionFinanceApi.disconnect('csrf-token', controller.signal); + await productionFinanceApi.logout('csrf-token', controller.signal); - expect(fetchMock).toHaveBeenCalledWith('/api/connection/disconnect', expect.objectContaining({ + expect(fetchMock).toHaveBeenCalledWith('/api/auth/logout', expect.objectContaining({ headers: { 'x-csrf-token': 'csrf-token' }, method: 'POST', signal: controller.signal, diff --git a/src/data/financeApi.ts b/src/data/financeApi.ts index 7fc72e3..4e032d9 100644 --- a/src/data/financeApi.ts +++ b/src/data/financeApi.ts @@ -2,32 +2,23 @@ import { z } from 'zod'; import { financeDataV1Schema } from '../finance/runtime'; import type { FinanceValidationIssue } from '../finance/types'; -const spreadsheetSchema = z.object({ id: z.string().min(1), name: z.string().min(1) }); const sessionSchema = z.discriminatedUnion('authenticated', [ z.object({ authenticated: z.literal(false) }), z.object({ authenticated: z.literal(true), user: z.object({ email: z.string().email() }), csrfToken: z.string().min(1), - connection: z.object({ connected: z.boolean(), spreadsheet: spreadsheetSchema.nullable() }), + ownerKey: z.string().min(32).max(128).regex(/^[A-Za-z0-9_-]+$/), }), ]); const financeResponseSchema = z.object({ - spreadsheet: spreadsheetSchema, data: financeDataV1Schema, refreshedAt: z.string().datetime(), -}); -const pickerConfigSchema = z.object({ - accessToken: z.string().min(1), - expiresIn: z.number().positive(), - apiKey: z.string().min(1), - appId: z.string().min(1), - clientId: z.string().min(1), + ownerKey: z.string().min(32).max(128).regex(/^[A-Za-z0-9_-]+$/), }); export type FinanceSession = z.infer; export type FinanceResponse = z.infer; -export type PickerConfig = z.infer; export class FinanceApiError extends Error { constructor( @@ -66,22 +57,17 @@ async function request(path: string, schema: z.ZodType, options: RequestIn export interface FinanceApi { getSession(signal?: AbortSignal): Promise; getFinance(signal?: AbortSignal): Promise; - getPickerConfig(signal?: AbortSignal): Promise; - saveSpreadsheet(fileId: string, csrfToken: string, signal?: AbortSignal): Promise; - logout(csrfToken: string): Promise; - disconnect(csrfToken: string, signal?: AbortSignal): Promise; + logout(csrfToken: string, signal?: AbortSignal): Promise; } export const productionFinanceApi: FinanceApi = { getSession: (signal) => request('/api/session', sessionSchema, { signal }), getFinance: (signal) => request('/api/finance', financeResponseSchema, { signal }), - getPickerConfig: (signal) => request('/api/google/picker', pickerConfigSchema, { signal }), - saveSpreadsheet: (fileId, csrfToken, signal) => request('/api/google/spreadsheet', financeResponseSchema, { - method: 'PUT', - headers: { 'content-type': 'application/json', 'x-csrf-token': csrfToken }, - body: JSON.stringify({ fileId }), - signal, - }), - logout: async (csrfToken) => { await request('/api/auth/logout', z.object({ ok: z.literal(true) }), { method: 'POST', headers: { 'x-csrf-token': csrfToken } }); }, - disconnect: async (csrfToken, signal) => { await request('/api/connection/disconnect', z.object({ ok: z.literal(true) }), { method: 'POST', headers: { 'x-csrf-token': csrfToken }, signal }); }, + logout: async (csrfToken, signal) => { + await request('/api/auth/logout', z.object({ ok: z.literal(true) }), { + method: 'POST', + headers: { 'x-csrf-token': csrfToken }, + signal, + }); + }, }; diff --git a/src/data/financeCache.test.ts b/src/data/financeCache.test.ts index d88f101..ddc0d95 100644 --- a/src/data/financeCache.test.ts +++ b/src/data/financeCache.test.ts @@ -13,27 +13,32 @@ import { const parsed = parseSheetsBatchResponse(anonymousSheetsResponse); if (!parsed.success) throw new Error('Anonymous workbook is invalid.'); +const ownerA = 'owner-cache-key-a'; +const ownerB = 'owner-cache-key-b'; describe('last-known-good IndexedDB cache', () => { beforeEach(async () => clearCachedFinanceData()); it('stores and loads only normalized FinanceDataV1 snapshots', async () => { await saveCachedFinanceData({ - spreadsheetId: 'spreadsheet-id', - spreadsheetName: 'Anonyme Finanzen', refreshedAt: '2026-08-08T10:00:00.000Z', data: parsed.data, - }); - await expect(loadCachedFinanceData()).resolves.toEqual(expect.objectContaining({ - spreadsheetId: 'spreadsheet-id', + }, ownerA); + await expect(loadCachedFinanceData(ownerA)).resolves.toEqual(expect.objectContaining({ + ownerKey: ownerA, data: expect.objectContaining({ schemaVersion: 1, monthlyIncomeCents: 259_132 }), })); }); - it('removes cached personal finance data on disconnect cleanup', async () => { - await saveCachedFinanceData({ spreadsheetId: 'id', spreadsheetName: 'Name', refreshedAt: '2026-08-08T10:00:00.000Z', data: parsed.data }); + it('never returns one owner\'s cached finance data for another owner', async () => { + await saveCachedFinanceData({ refreshedAt: '2026-08-08T10:00:00.000Z', data: parsed.data }, ownerA); + await expect(loadCachedFinanceData(ownerB)).resolves.toBeNull(); + }); + + it('removes cached personal finance data on local cleanup', async () => { + await saveCachedFinanceData({ refreshedAt: '2026-08-08T10:00:00.000Z', data: parsed.data }, ownerA); await clearCachedFinanceData(); - await expect(loadCachedFinanceData()).resolves.toBeNull(); + await expect(loadCachedFinanceData(ownerA)).resolves.toBeNull(); }); it('rejects a cache write started before a protected-access recovery generation change', async () => { @@ -48,11 +53,9 @@ describe('last-known-good IndexedDB cache', () => { expect(values.get(FINANCE_CACHE_GENERATION_STORAGE_KEY)).not.toBe(requestGeneration); await expect(saveCachedFinanceData({ - spreadsheetId: 'stale-id', - spreadsheetName: 'Veralteter Stand', refreshedAt: '2026-08-08T10:00:00.000Z', data: parsed.data, - }, requestGeneration, storage)).resolves.toBe(false); - await expect(loadCachedFinanceData()).resolves.toBeNull(); + }, ownerA, requestGeneration, storage)).resolves.toBe(false); + await expect(loadCachedFinanceData(ownerA)).resolves.toBeNull(); }); }); diff --git a/src/data/financeCache.ts b/src/data/financeCache.ts index 50e430c..6342b1d 100644 --- a/src/data/financeCache.ts +++ b/src/data/financeCache.ts @@ -3,34 +3,40 @@ import { financeDataV1Schema } from '../finance/runtime'; import type { FinanceDataV1 } from '../finance/types'; const DATABASE_NAME = 'finance-overview'; -const DATABASE_VERSION = 1; +const DATABASE_VERSION = 2; const STORE_NAME = 'last-good'; -const CACHE_KEY = 'finance-data-v1'; +const LEGACY_CACHE_KEY = 'finance-data-v1'; +const CACHE_KEY_PREFIX = 'finance-data-v1:'; export const FINANCE_CACHE_GENERATION_STORAGE_KEY = 'finance-cache-generation-v1'; +export const ACTIVE_FINANCE_CACHE_OWNER_STORAGE_KEY = 'active-finance-cache-owner-v1'; const INITIAL_CACHE_GENERATION = '0'; type StorageLike = Pick; const cacheSchema = z.object({ - key: z.literal(CACHE_KEY), - spreadsheetId: z.string().min(1), - spreadsheetName: z.string().min(1), + key: z.string().startsWith(CACHE_KEY_PREFIX), + ownerKey: z.string().min(1), refreshedAt: z.string().datetime(), data: financeDataV1Schema, }); export type CachedFinanceSnapshot = { - key: typeof CACHE_KEY; - spreadsheetId: string; - spreadsheetName: string; + key: string; + ownerKey: string; refreshedAt: string; data: FinanceDataV1; }; +const cacheKeyForOwner = (ownerKey: string) => `${CACHE_KEY_PREFIX}${ownerKey}`; + const openDatabase = (): Promise => new Promise((resolve, reject) => { const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); request.onupgradeneeded = () => { - if (!request.result.objectStoreNames.contains(STORE_NAME)) request.result.createObjectStore(STORE_NAME, { keyPath: 'key' }); + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME, { keyPath: 'key' }); + } else { + request.transaction?.objectStore(STORE_NAME).delete(LEGACY_CACHE_KEY); + } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error('IndexedDB konnte nicht geöffnet werden.')); @@ -59,6 +65,33 @@ export function readFinanceCacheGeneration(storage: StorageLike | null = browser } } +export function readActiveFinanceCacheOwner(storage: StorageLike | null = browserStorage()): string | null { + if (!storage) return null; + try { + return storage.getItem(ACTIVE_FINANCE_CACHE_OWNER_STORAGE_KEY); + } catch { + return null; + } +} + +export function setActiveFinanceCacheOwner(ownerKey: string, storage: StorageLike | null = browserStorage()): boolean { + if (!storage) return false; + try { + storage.setItem(ACTIVE_FINANCE_CACHE_OWNER_STORAGE_KEY, ownerKey); + return true; + } catch { + return false; + } +} + +export function clearActiveFinanceCacheOwner(storage: StorageLike | null = browserStorage()): void { + try { + storage?.removeItem(ACTIVE_FINANCE_CACHE_OWNER_STORAGE_KEY); + } catch { + // Storage can be unavailable in hardened browser contexts. + } +} + export function rotateFinanceCacheGeneration(storage: StorageLike | null = browserStorage()): string | null { if (!storage) return null; try { @@ -85,25 +118,26 @@ export function restoreFinanceCacheGeneration( } } -export async function loadCachedFinanceData(): Promise { +export async function loadCachedFinanceData(ownerKey: string): Promise { if (!globalThis.indexedDB) return null; const database = await openDatabase(); try { const transaction = database.transaction(STORE_NAME, 'readonly'); - const request = transaction.objectStore(STORE_NAME).get(CACHE_KEY); + const request = transaction.objectStore(STORE_NAME).get(cacheKeyForOwner(ownerKey)); const value = await new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const parsed = cacheSchema.safeParse(value); - return parsed.success ? parsed.data : null; + return parsed.success && parsed.data.ownerKey === ownerKey ? parsed.data : null; } finally { database.close(); } } export async function saveCachedFinanceData( - snapshot: Omit, + snapshot: Pick, + ownerKey: string, expectedGeneration = readFinanceCacheGeneration(), storage: StorageLike | null = browserStorage(), ): Promise { @@ -112,7 +146,7 @@ export async function saveCachedFinanceData( try { if (readFinanceCacheGeneration(storage) !== expectedGeneration) return false; const transaction = database.transaction(STORE_NAME, 'readwrite'); - transaction.objectStore(STORE_NAME).put({ key: CACHE_KEY, ...snapshot }); + transaction.objectStore(STORE_NAME).put({ key: cacheKeyForOwner(ownerKey), ownerKey, ...snapshot }); await transactionDone(transaction); return true; } finally { @@ -125,7 +159,7 @@ export async function clearCachedFinanceData() { const database = await openDatabase(); try { const transaction = database.transaction(STORE_NAME, 'readwrite'); - transaction.objectStore(STORE_NAME).delete(CACHE_KEY); + transaction.objectStore(STORE_NAME).clear(); await transactionDone(transaction); } finally { database.close(); diff --git a/src/data/googlePicker.test.ts b/src/data/googlePicker.test.ts deleted file mode 100644 index aa2710d..0000000 --- a/src/data/googlePicker.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { FinanceApi } from './financeApi'; -import { selectSpreadsheetWithPicker } from './googlePicker'; - -const response = { - spreadsheet: { id: 'chosen-sheet-id', name: 'Gewählte Tabelle' }, - data: { - schemaVersion: 1 as const, - asOf: '2026-08-08', - currency: 'EUR' as const, - monthlyIncomeCents: 0, - accounts: [], accountSnapshots: [], pockets: [], pocketSnapshots: [], budgetItems: [], debts: [], debtSnapshots: [], debtMilestones: [], reliefMilestones: [], - }, - refreshedAt: '2026-08-08T10:00:00.000Z', -}; - -const api = (): FinanceApi => ({ - getSession: vi.fn(), - getFinance: vi.fn(), - getPickerConfig: vi.fn().mockResolvedValue({ accessToken: 'short-lived-only', expiresIn: 3600, apiKey: 'public-key', appId: '123', clientId: 'client' }), - saveSpreadsheet: vi.fn().mockResolvedValue(response), - logout: vi.fn(), - disconnect: vi.fn(), -}); - -describe('Google Picker selection', () => { - it('uses a mocked single-file Picker selection and sends only the chosen ID to the backend', async () => { - const mockApi = api(); - const launcher = vi.fn().mockResolvedValue({ id: 'chosen-sheet-id', name: 'Untrusted Picker name' }); - await expect(selectSpreadsheetWithPicker(mockApi, launcher, 'csrf')).resolves.toEqual(response); - expect(mockApi.saveSpreadsheet).toHaveBeenCalledWith('chosen-sheet-id', 'csrf', undefined); - }); - - it('does not call the save endpoint when Picker is cancelled', async () => { - const mockApi = api(); - await expect(selectSpreadsheetWithPicker(mockApi, vi.fn().mockResolvedValue(null), 'csrf')).resolves.toBeNull(); - expect(mockApi.saveSpreadsheet).not.toHaveBeenCalled(); - }); -}); diff --git a/src/data/googlePicker.ts b/src/data/googlePicker.ts deleted file mode 100644 index 6da1d2a..0000000 --- a/src/data/googlePicker.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { FinanceApi, FinanceResponse, PickerConfig } from './financeApi'; - -export type PickerSelection = { id: string; name: string } | null; -export type PickerLauncher = (config: PickerConfig) => Promise; - -export async function selectSpreadsheetWithPicker( - api: FinanceApi, - launcher: PickerLauncher, - csrfToken: string, - signal?: AbortSignal, -): Promise { - const config = await api.getPickerConfig(signal); - const selected = await launcher(config); - if (!selected) return null; - return api.saveSpreadsheet(selected.id, csrfToken, signal); -} - -let pickerScript: Promise | null = null; - -function loadPickerScript() { - if (globalThis.google?.picker) return Promise.resolve(); - if (pickerScript) return pickerScript; - pickerScript = new Promise((resolve, reject) => { - const existing = document.querySelector('script[data-google-picker]'); - if (existing) { - existing.addEventListener('load', () => resolve(), { once: true }); - existing.addEventListener('error', () => reject(new Error('Google Picker konnte nicht geladen werden.')), { once: true }); - return; - } - const script = document.createElement('script'); - script.src = 'https://apis.google.com/js/api.js'; - script.async = true; - script.dataset.googlePicker = 'true'; - script.onload = () => globalThis.gapi.load('picker', { callback: resolve, onerror: () => reject(new Error('Google Picker konnte nicht geladen werden.')) }); - script.onerror = () => reject(new Error('Google Picker konnte nicht geladen werden.')); - document.head.append(script); - }); - return pickerScript; -} - -export const launchGooglePicker: PickerLauncher = async (config) => { - await loadPickerScript(); - return new Promise((resolve) => { - let accessToken = config.accessToken; - const view = new google.picker.DocsView(google.picker.ViewId.SPREADSHEETS) - .setMimeTypes('application/vnd.google-apps.spreadsheet') - .setSelectFolderEnabled(false); - const picker = new google.picker.PickerBuilder() - .addView(view) - .setAppId(config.appId) - .setDeveloperKey(config.apiKey) - .setOAuthToken(accessToken) - .setOrigin(window.location.origin) - .setCallback((event) => { - if (event.action === google.picker.Action.PICKED) { - const document = event.docs?.[0]; - accessToken = ''; - resolve(document ? { id: document.id, name: document.name } : null); - } else if (event.action === google.picker.Action.CANCEL) { - accessToken = ''; - resolve(null); - } - }) - .build(); - picker.setVisible(true); - }); -}; diff --git a/src/finance/parser.ts b/src/finance/parser.ts index 0e271c7..1b871ec 100644 --- a/src/finance/parser.ts +++ b/src/finance/parser.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { FINANCE_TAB_HEADERS } from './schema.js'; +import { FINANCE_TAB_HEADERS } from './schema.ts'; import { FINANCE_SCHEMA_VERSION, FINANCE_TAB_NAMES, @@ -8,7 +8,7 @@ import { type FinanceValidationResult, type RawSheetsBatchResponse, type TabularWorkbook, -} from './types.js'; +} from './types.ts'; const kebabCase = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const isoDate = /^\d{4}-\d{2}-\d{2}$/; diff --git a/src/finance/runtime.ts b/src/finance/runtime.ts index 4d779ef..bb9a985 100644 --- a/src/finance/runtime.ts +++ b/src/finance/runtime.ts @@ -1,10 +1,27 @@ import { z } from 'zod'; const id = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); -const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); -const milestoneDate = z.string().regex(/^\d{4}-\d{2}(?:-\d{2})?$/); +const isoDatePattern = /^\d{4}-\d{2}-\d{2}$/; +const milestoneDatePattern = /^\d{4}-\d{2}(?:-\d{2})?$/; +const isActualDate = (value: string) => { + if (!isoDatePattern.test(value) || value.startsWith('0000-')) return false; + const date = new Date(`${value}T00:00:00Z`); + return Number.isFinite(date.valueOf()) && date.toISOString().slice(0, 10) === value; +}; +const isActualMilestoneDate = (value: string) => { + if (!milestoneDatePattern.test(value) || value.startsWith('0000-')) return false; + if (value.length === 7) { + const month = Number(value.slice(5, 7)); + return month >= 1 && month <= 12; + } + return isActualDate(value); +}; +const isoDate = z.string().refine(isActualDate); +const milestoneDate = z.string().refine(isActualMilestoneDate); const cents = z.number().int().safe(); const count = z.number().int().safe().nonnegative(); +const nonBlankText = z.string().refine((value) => value.trim().length > 0); +const optionalText = nonBlankText.nullable(); export const financeDataV1Schema = z.object({ schemaVersion: z.literal(1), @@ -14,7 +31,7 @@ export const financeDataV1Schema = z.object({ salaryDay: z.number().int().min(1).max(31).nullable(), accounts: z.array(z.object({ id, - name: z.string().min(1), + name: nonBlankText, kind: z.enum(['bank', 'wallet', 'cash']), displayOrder: z.number().int().safe(), active: z.boolean(), @@ -23,30 +40,30 @@ export const financeDataV1Schema = z.object({ pockets: z.array(z.object({ id, accountId: id, - name: z.string().min(1), + name: nonBlankText, displayOrder: z.number().int().safe(), active: z.boolean(), })), pocketSnapshots: z.array(z.object({ pocketId: id, asOf: isoDate, balanceCents: cents })), budgetItems: z.array(z.object({ id, - label: z.string().min(1), + label: nonBlankText, monthlyAmountCents: cents, necessityId: z.enum(['essential', 'necessary', 'worthwhile', 'optional', 'unnecessary']), kind: z.enum(['expense', 'reserve']), displayOrder: z.number().int().safe(), active: z.boolean(), - note: z.string().nullable(), + note: optionalText, dueDay: z.number().int().min(1).max(31).nullable(), })), debts: z.array(z.object({ id, - name: z.string().min(1), + name: nonBlankText, kind: z.enum(['loan', 'installment']), monthlyPaymentCents: cents, displayOrder: z.number().int().safe(), active: z.boolean(), - note: z.string().nullable(), + note: optionalText, dueDay: z.number().int().min(1).max(31).nullable(), })), debtSnapshots: z.array(z.object({ @@ -57,5 +74,44 @@ export const financeDataV1Schema = z.object({ remainingScheduledTotalCents: cents, })), debtMilestones: z.array(z.object({ debtId: id, date: milestoneDate, balanceCents: cents })), - reliefMilestones: z.array(z.object({ date: milestoneDate, monthlyReliefCents: cents, event: z.string().min(1), eventDetail: z.string().nullable() })), -}).strict(); + reliefMilestones: z.array(z.object({ date: milestoneDate, monthlyReliefCents: cents, event: nonBlankText, eventDetail: optionalText })), +}).strict().superRefine((data, context) => { + const unique = (values: T[], key: (value: T) => string, path: (number | string)[]) => { + const seen = new Set(); + values.forEach((value, index) => { + const candidate = key(value); + if (seen.has(candidate)) { + context.addIssue({ code: 'custom', message: 'Doppelter Schlüssel.', path: [...path, index] }); + } + seen.add(candidate); + }); + }; + const knownReference = ( + values: T[], + reference: (value: T) => string, + known: Set, + path: (number | string)[], + ) => values.forEach((value, index) => { + if (!known.has(reference(value))) { + context.addIssue({ code: 'custom', message: 'Referenzierte Entität fehlt.', path: [...path, index] }); + } + }); + + unique(data.accounts, ({ id: value }) => value, ['accounts']); + unique(data.accountSnapshots, (value) => `${value.accountId}|${value.asOf}`, ['accountSnapshots']); + unique(data.pockets, ({ id: value }) => value, ['pockets']); + unique(data.pocketSnapshots, (value) => `${value.pocketId}|${value.asOf}`, ['pocketSnapshots']); + unique(data.budgetItems, ({ id: value }) => value, ['budgetItems']); + unique(data.debts, ({ id: value }) => value, ['debts']); + unique(data.debtSnapshots, (value) => `${value.debtId}|${value.asOf}`, ['debtSnapshots']); + unique(data.debtMilestones, (value) => `${value.debtId}|${value.date}`, ['debtMilestones']); + + const accountIds = new Set(data.accounts.map(({ id: value }) => value)); + const pocketIds = new Set(data.pockets.map(({ id: value }) => value)); + const debtIds = new Set(data.debts.map(({ id: value }) => value)); + knownReference(data.accountSnapshots, ({ accountId }) => accountId, accountIds, ['accountSnapshots']); + knownReference(data.pockets, ({ accountId }) => accountId, accountIds, ['pockets']); + knownReference(data.pocketSnapshots, ({ pocketId }) => pocketId, pocketIds, ['pocketSnapshots']); + knownReference(data.debtSnapshots, ({ debtId }) => debtId, debtIds, ['debtSnapshots']); + knownReference(data.debtMilestones, ({ debtId }) => debtId, debtIds, ['debtMilestones']); +}); diff --git a/src/finance/schema.ts b/src/finance/schema.ts index 382bbbb..ab5d8b6 100644 --- a/src/finance/schema.ts +++ b/src/finance/schema.ts @@ -1,4 +1,4 @@ -import type { FinanceTabName } from './types.js'; +import type { FinanceTabName } from './types.ts'; export const FINANCE_TAB_HEADERS: Record = { _Meta: ['schema_version', 'as_of', 'currency', 'monthly_income'], diff --git a/src/google-picker.d.ts b/src/google-picker.d.ts deleted file mode 100644 index ced59e9..0000000 --- a/src/google-picker.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -declare namespace google.picker { - enum Action { PICKED = 'picked', CANCEL = 'cancel' } - enum ViewId { SPREADSHEETS = 'spreadsheets' } - type ResponseObject = { action: Action; docs?: Array<{ id: string; name: string }> }; - class DocsView { - constructor(viewId: ViewId); - setMimeTypes(value: string): this; - setSelectFolderEnabled(value: boolean): this; - } - class PickerBuilder { - addView(view: DocsView): this; - setAppId(value: string): this; - setDeveloperKey(value: string): this; - setOAuthToken(value: string): this; - setOrigin(value: string): this; - setCallback(callback: (event: ResponseObject) => void): this; - build(): { setVisible(value: boolean): void }; - } -} - -declare namespace gapi { - function load(name: string, options: { callback: () => void; onerror: () => void }): void; -} diff --git a/src/main.tsx b/src/main.tsx index aa8447c..f433445 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,7 +5,6 @@ import { AppearanceProvider } from './appearance/AppearanceProvider'; import { initializeAppearanceBeforeRender } from './appearance/appearanceStore'; import { FinanceDataProvider } from './data/FinanceDataProvider'; import { productionFinanceApi } from './data/financeApi'; -import type { PickerLauncher } from './data/googlePicker'; import { initializeNavigationBeforeRender } from './navigation/appNavigation'; import { PrivacyProvider } from './privacy/PrivacyProvider'; import { initializeAppProtectionBeforeRender } from './privacy/appProtectionStore'; @@ -18,18 +17,15 @@ const initialDestination = initializeNavigationBeforeRender(); const initialPrivacy = initializePrivacyBeforeRender(); const initialProtection = initializeAppProtectionBeforeRender(); -let financeApi = productionFinanceApi; -let mockPicker: PickerLauncher | undefined; -if (__ACCURA_MOCK_API_ENABLED__) { - financeApi = (await import('./mocks/mockFinanceApi')).mockFinanceApi; - mockPicker = async () => ({ id: 'mock-spreadsheet-id', name: 'Anonyme Beispieldaten' }); -} +const financeApi = __ACCURA_MOCK_API_ENABLED__ + ? (await import('./mocks/mockFinanceApi')).mockFinanceApi + : productionFinanceApi; createRoot(document.getElementById('root')!).render( - + diff --git a/src/mocks/anonymousWorkbook.ts b/src/mocks/anonymousWorkbook.ts index 32222d1..cf850f1 100644 --- a/src/mocks/anonymousWorkbook.ts +++ b/src/mocks/anonymousWorkbook.ts @@ -1,4 +1,4 @@ -import type { RawSheetsBatchResponse, TabularWorkbook } from '../finance/types'; +import type { RawSheetsBatchResponse, TabularWorkbook } from '../finance/types.ts'; /** Fully synthetic data for deterministic tests and explicitly enabled local mocks only. */ export const anonymousWorkbook: TabularWorkbook = { diff --git a/src/mocks/mockFinanceApi.ts b/src/mocks/mockFinanceApi.ts index 08906a8..52ee91c 100644 --- a/src/mocks/mockFinanceApi.ts +++ b/src/mocks/mockFinanceApi.ts @@ -6,9 +6,9 @@ const parsed = parseSheetsBatchResponse(anonymousSheetsResponse); if (!parsed.success) throw new Error('Anonymous mock workbook is invalid.'); const financeResponse = { - spreadsheet: { id: 'mock-spreadsheet-id', name: 'Anonyme Beispieldaten' }, data: parsed.data, refreshedAt: '2026-08-08T10:00:00.000Z', + ownerKey: 'mock-owner-cache-key-0000000000000001', }; export const mockFinanceApi: FinanceApi = { @@ -16,11 +16,8 @@ export const mockFinanceApi: FinanceApi = { authenticated: true, user: { email: 'developer@example.test' }, csrfToken: 'mock-csrf-token', - connection: { connected: true, spreadsheet: financeResponse.spreadsheet }, + ownerKey: 'mock-owner-cache-key-0000000000000001', }), getFinance: async () => financeResponse, - getPickerConfig: async () => ({ accessToken: 'ephemeral-mock-token', expiresIn: 3600, apiKey: 'mock-key', appId: '123', clientId: 'mock-client' }), - saveSpreadsheet: async () => financeResponse, logout: async () => undefined, - disconnect: async () => undefined, }; diff --git a/src/privacy/privacy.test.tsx b/src/privacy/privacy.test.tsx index 136caab..2f23089 100644 --- a/src/privacy/privacy.test.tsx +++ b/src/privacy/privacy.test.tsx @@ -24,25 +24,20 @@ const visibleText = (markup: string) => markup.replace(/|<[^>]+>/g, ''); const mockApi: FinanceApi = { getSession: async () => ({ authenticated: false }), getFinance: async () => { throw new Error('not implemented'); }, - getPickerConfig: async () => { throw new Error('not implemented'); }, - saveSpreadsheet: async () => { throw new Error('not implemented'); }, logout: async () => {}, - disconnect: async () => {}, }; function TestWrapper({ children, privacyEnabled = true }: { children: React.ReactNode; privacyEnabled?: boolean }) { const initialState = { authState: 'authenticated' as const, - connectionState: 'connected' as const, + connectionState: 'ready' as const, syncState: 'idle' as const, email: 'user@example.test', csrfToken: 'token', - spreadsheet: { id: 'mock-id', name: 'Mock Sheet' }, data: baseData, lastSuccessfulRefresh: '2026-08-08T10:00:00Z', stale: false, error: null, - pickerOpen: false, }; return ( diff --git a/src/screens/UpcomingScreen.tsx b/src/screens/UpcomingScreen.tsx index d146181..99d1582 100644 --- a/src/screens/UpcomingScreen.tsx +++ b/src/screens/UpcomingScreen.tsx @@ -40,7 +40,7 @@ export function UpcomingScreen() { className="financial-hero--allocation" id="upcoming-hero" label="Bis Gehalt verfügbar" - supporting={upcoming.nextSalaryDateLabel ? `Frei bis zum Gehaltseingang am ${upcoming.nextSalaryDateLabel}` : 'Kein Gehaltstag in der Tabelle hinterlegt'} + supporting={upcoming.nextSalaryDateLabel ? `Frei bis zum Gehaltseingang am ${upcoming.nextSalaryDateLabel}` : 'Kein Gehaltstag hinterlegt'} tone={isNegativeSafeToSpend ? 'attention' : 'positive'} value={} visual={( @@ -61,7 +61,7 @@ export function UpcomingScreen() { /> @@ -77,10 +77,9 @@ export function UpcomingScreen() { ) : null} {!hasSalaryConfig ? ( - } title="Gehaltstag in Google-Tabelle angeben" tone="info"> + } title="Kein Gehaltstag hinterlegt" tone="info">

- Füge im Tab _Meta die Spalte salary_day mit deinem Gehaltstag (z. B. 25) ein, - um deine ausstehenden Zahlungen und den verfügbaren Betrag bis zum nächsten Gehalt automatisch zu berechnen. + Ohne Gehaltstag kann Demnächst offene Zahlungen bis zum nächsten Gehaltseingang nicht berechnen.

) : null} diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 18bfc74..089e40a 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -5,19 +5,16 @@ const env = (): NodeJS.ProcessEnv => ({ APP_ORIGIN: 'http://localhost:3000', GOOGLE_CLIENT_ID: 'client', GOOGLE_CLIENT_SECRET: 'secret', - GOOGLE_API_KEY: 'key', - GOOGLE_CLOUD_PROJECT_NUMBER: '123456', GOOGLE_OAUTH_REDIRECT_URI: 'http://localhost:3000/api/auth/google/callback', ALLOWED_GOOGLE_EMAIL: 'owner@example.test', DATABASE_URL: 'postgres://database', - TOKEN_ENCRYPTION_KEY: Buffer.alloc(32).toString('base64'), SESSION_SECRET: 'a-session-secret-longer-than-thirty-two-bytes', NODE_ENV: 'development', }); describe('server environment contract', () => { it('accepts a consistent localhost server configuration', () => { - expect(getServerConfig(env())).toMatchObject({ appOrigin: 'http://localhost:3000', production: false, googleCloudProjectNumber: '123456' }); + expect(getServerConfig(env())).toMatchObject({ appOrigin: 'http://localhost:3000', production: false }); }); it('rejects mismatched callbacks, short session secrets, and non-HTTPS production origins', () => { diff --git a/src/server/financeImport.test.ts b/src/server/financeImport.test.ts new file mode 100644 index 0000000..644f5c9 --- /dev/null +++ b/src/server/financeImport.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { financeImportFingerprint, parseFinanceImportJson, parseFinanceImportSource } from '../../api/_lib/financeImport'; +import { anonymousSheetsResponse } from '../mocks/anonymousWorkbook'; + +const parsedFixture = parseFinanceImportSource(anonymousSheetsResponse); +if (!parsedFixture.success) throw new Error('Anonymous finance fixture must be valid.'); + +describe('finance import source', () => { + it('accepts the anonymous Sheets batchGet fixture without exposing amounts in the fingerprint', () => { + const parsed = parseFinanceImportSource(anonymousSheetsResponse); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + expect(financeImportFingerprint(parsed.data)).toEqual({ + asOf: '2026-08-08', + currency: 'EUR', + salaryDay: 25, + accounts: parsed.data.accounts.length, + accountSnapshots: parsed.data.accountSnapshots.length, + pockets: parsed.data.pockets.length, + pocketSnapshots: parsed.data.pocketSnapshots.length, + budgetItems: parsed.data.budgetItems.length, + debts: parsed.data.debts.length, + debtSnapshots: parsed.data.debtSnapshots.length, + debtMilestones: parsed.data.debtMilestones.length, + reliefMilestones: parsed.data.reliefMilestones.length, + }); + expect(JSON.stringify(financeImportFingerprint(parsed.data))).not.toContain(String(parsed.data.monthlyIncomeCents)); + }); + + it('accepts an already normalized FinanceDataV1 object', () => { + const parsed = parseFinanceImportSource(parsedFixture.data); + expect(parsed).toEqual({ success: true, data: parsedFixture.data }); + }); + + it.each([ + ['duplicate database key', { + ...parsedFixture.data, + accounts: [...parsedFixture.data.accounts, parsedFixture.data.accounts[0]!], + }], + ['blank constrained text', { + ...parsedFixture.data, + accounts: parsedFixture.data.accounts.map((account, index) => index === 0 ? { ...account, name: ' ' } : account), + }], + ['invalid calendar date', { ...parsedFixture.data, asOf: '2026-02-31' }], + ['unknown foreign reference', { + ...parsedFixture.data, + accountSnapshots: parsedFixture.data.accountSnapshots.map((snapshot, index) => index === 0 + ? { ...snapshot, accountId: 'missing-account' } + : snapshot), + }], + ])('rejects normalized input with %s before PostgreSQL', (_label, source) => { + expect(parseFinanceImportSource(source).success).toBe(false); + }); + + it('rejects a non-object payload without leaking source text', () => { + const parsed = parseFinanceImportSource('secret-amount-999'); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(JSON.stringify(parsed.issues)).not.toContain('secret-amount-999'); + }); + + it('sanitizes malformed JSON errors without exposing file contents', () => { + const secretSource = '{"monthlyIncomeCents": 987654321'; + expect(() => parseFinanceImportJson(secretSource)).toThrow('Die Importdatei ist kein gültiges JSON.'); + try { + parseFinanceImportJson(secretSource); + } catch (error) { + expect(String(error)).not.toContain('987654321'); + } + }); + + it('rejects an unsupported object without leaking its source content', () => { + const parsed = parseFinanceImportSource({ secret: 'private-finance-value-999' }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(JSON.stringify(parsed.issues)).not.toContain('private-finance-value-999'); + }); + + it('validates the complete relevant shape of Sheets payloads', () => { + const parsed = parseFinanceImportSource({ spreadsheetId: 'sheet-id', valueRanges: 'private-finance-value-999' }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(JSON.stringify(parsed.issues)).not.toContain('private-finance-value-999'); + }); +}); diff --git a/src/server/financeService.test.ts b/src/server/financeService.test.ts deleted file mode 100644 index db29aac..0000000 --- a/src/server/financeService.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { anonymousSheetsResponse } from '../mocks/anonymousWorkbook'; -import { validateAndSaveSpreadsheet } from '../../api/_lib/financeService'; -import type { ConnectionRepository, GoogleConnection } from '../../api/_lib/repository'; -import { encryptRefreshToken } from '../../api/_lib/security'; -import { testServerConfig } from './google.test'; - -const connection: GoogleConnection = { - googleSub: 'immutable-google-sub', - email: 'owner@example.test', - encryptedRefreshToken: encryptRefreshToken('refresh-value', testServerConfig.tokenEncryptionKey, 'immutable-google-sub'), - scopes: [], - spreadsheetId: null, - spreadsheetName: null, - createdAt: new Date(0), - updatedAt: new Date(0), - tokenUpdatedAt: new Date(0), - spreadsheetUpdatedAt: null, -}; - -function repository(): ConnectionRepository & { saveSpreadsheet: ReturnType } { - return { - get: vi.fn(), - upsertAuthorization: vi.fn(), - saveSpreadsheet: vi.fn(), - delete: vi.fn(), - }; -} - -const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json' } }); - -describe('spreadsheet selection service', () => { - it('commits a selected file only after Drive validation and schema validation succeed', async () => { - const repo = repository(); - const fetchMock = vi.fn(async (input) => { - const url = String(input); - if (url.includes('oauth2.googleapis.com/token')) return json({ access_token: 'short-lived', expires_in: 3600 }); - if (url.includes('googleapis.com/drive/')) return json({ id: 'valid-spreadsheet-id', name: 'Anonyme Finanzen', mimeType: 'application/vnd.google-apps.spreadsheet' }); - if (url.includes('sheets.googleapis.com/')) return json(anonymousSheetsResponse); - return json({}, 404); - }); - - const result = await validateAndSaveSpreadsheet(testServerConfig, repo, connection, 'valid-spreadsheet-id', fetchMock); - expect(result.data.schemaVersion).toBe(1); - expect(repo.saveSpreadsheet).toHaveBeenCalledWith('immutable-google-sub', 'valid-spreadsheet-id', 'Anonyme Finanzen'); - }); - - it('does not replace the stored selection if the candidate workbook is invalid', async () => { - const repo = repository(); - const fetchMock = vi.fn(async (input) => { - const url = String(input); - if (url.includes('oauth2.googleapis.com/token')) return json({ access_token: 'short-lived' }); - if (url.includes('googleapis.com/drive/')) return json({ id: 'invalid-sheet-id', name: 'Fehlerhaft', mimeType: 'application/vnd.google-apps.spreadsheet' }); - if (url.includes('sheets.googleapis.com/')) return json({ valueRanges: [] }); - return json({}, 404); - }); - - await expect(validateAndSaveSpreadsheet(testServerConfig, repo, connection, 'invalid-sheet-id', fetchMock)).rejects.toMatchObject({ code: 'invalid_finance_schema', status: 422 }); - expect(repo.saveSpreadsheet).not.toHaveBeenCalled(); - }); -}); diff --git a/src/server/google.test.ts b/src/server/google.test.ts index 6bb37af..b9286ef 100644 --- a/src/server/google.test.ts +++ b/src/server/google.test.ts @@ -1,68 +1,41 @@ import { describe, expect, it, vi } from 'vitest'; -import { anonymousSheetsResponse } from '../mocks/anonymousWorkbook'; import type { ServerConfig } from '../../api/_lib/config'; -import { ReconnectRequiredError } from '../../api/_lib/errors'; import { buildGoogleAuthorizationUrl, - readGoogleFinanceWorkbook, - refreshGoogleAccessToken, + exchangeAuthorizationCode, REQUIRED_GOOGLE_SCOPES, - validateGoogleSpreadsheet, } from '../../api/_lib/google'; export const testServerConfig: ServerConfig = { appOrigin: 'http://localhost:3000', googleClientId: 'client-id.apps.googleusercontent.com', googleClientSecret: 'server-secret', - googleApiKey: 'picker-public-api-key', - googleCloudProjectNumber: '1234567890', googleOAuthRedirectUri: 'http://localhost:3000/api/auth/google/callback', allowedGoogleEmail: 'owner@example.test', databaseUrl: 'postgres://unused', - tokenEncryptionKey: Buffer.alloc(32, 9).toString('base64'), sessionSecret: 'test-session-secret-with-at-least-32-bytes', production: false, }; -describe('Google OAuth and API adapters', () => { - it('requests only the required scopes with offline access, state, nonce, and PKCE', () => { +describe('Google identity adapters', () => { + it('requests only identity scopes with state, nonce, and PKCE', () => { const url = new URL(buildGoogleAuthorizationUrl(testServerConfig, { state: 'state', challenge: 'challenge', nonce: 'nonce' })); expect(url.searchParams.get('scope')?.split(' ')).toEqual(REQUIRED_GOOGLE_SCOPES); - expect(url.searchParams.get('access_type')).toBe('offline'); - expect(url.searchParams.get('prompt')).toContain('consent'); + expect(url.searchParams.get('access_type')).toBeNull(); + expect(url.searchParams.get('prompt')).toBe('select_account'); expect(url.searchParams.get('state')).toBe('state'); expect(url.searchParams.get('nonce')).toBe('nonce'); expect(url.searchParams.get('code_challenge_method')).toBe('S256'); }); - it('maps an invalid or revoked refresh token to reconnect_required', async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })); - await expect(refreshGoogleAccessToken(testServerConfig, 'revoked-token', fetchMock)).rejects.toBeInstanceOf(ReconnectRequiredError); - }); - - it('validates selected Drive file MIME type and accessibility', async () => { - const validFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ - id: 'sheet-file-id', name: 'Finanzdaten', mimeType: 'application/vnd.google-apps.spreadsheet', trashed: false, - }))); - await expect(validateGoogleSpreadsheet('access', 'sheet-file-id', validFetch)).resolves.toEqual(expect.objectContaining({ name: 'Finanzdaten' })); - - const invalidFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ - id: 'document-id', name: 'Dokument', mimeType: 'application/vnd.google-apps.document', trashed: false, + it('accepts an identity token exchange without a refresh token', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + id_token: 'identity-token', + scope: 'openid email profile', }))); - await expect(validateGoogleSpreadsheet('access', 'document-id', invalidFetch)).rejects.toMatchObject({ code: 'invalid_spreadsheet_type' }); - }); - - it('reads all machine tabs through one Sheets batchGet using unformatted values', async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(anonymousSheetsResponse))); - await expect(readGoogleFinanceWorkbook('access', 'spreadsheet-id', fetchMock)).resolves.toEqual(anonymousSheetsResponse); - const requested = new URL(String(fetchMock.mock.calls[0]?.[0])); - expect(requested.pathname).toContain('/values:batchGet'); - expect(requested.searchParams.getAll('ranges')).toHaveLength(10); - expect(requested.searchParams.get('valueRenderOption')).toBe('UNFORMATTED_VALUE'); - }); - - it('returns structured Sheets failures without response data or credentials', async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 503 })); - await expect(readGoogleFinanceWorkbook('access', 'spreadsheet-id', fetchMock)).rejects.toMatchObject({ code: 'sheets_read_failed', status: 502 }); + await expect(exchangeAuthorizationCode(testServerConfig, 'code', 'verifier', fetchMock)).resolves.toEqual({ + idToken: 'identity-token', + scopes: ['openid', 'email', 'profile'], + }); }); }); diff --git a/src/server/security.test.ts b/src/server/security.test.ts index 45fd757..9cde965 100644 --- a/src/server/security.test.ts +++ b/src/server/security.test.ts @@ -4,25 +4,16 @@ import { assertCsrf, createOAuthTransaction, createSession, - decryptRefreshToken, - encryptRefreshToken, + financeCacheOwnerKey, isAllowedGoogleUser, verifyOAuthTransaction, verifySession, sessionCookie, } from '../../api/_lib/security'; -const encryptionKey = Buffer.alloc(32, 7).toString('base64'); const sessionSecret = 'test-session-secret-with-at-least-32-bytes'; describe('server security primitives', () => { - it('encrypts refresh tokens with authenticated encryption and subject-bound AAD', () => { - const encrypted = encryptRefreshToken('refresh-token-value', encryptionKey, 'google-sub-1'); - expect(encrypted).not.toContain('refresh-token-value'); - expect(decryptRefreshToken(encrypted, encryptionKey, 'google-sub-1')).toBe('refresh-token-value'); - expect(() => decryptRefreshToken(encrypted, encryptionKey, 'other-sub')).toThrow(); - }); - it('accepts only the verified allowlisted Google email case-insensitively', () => { expect(isAllowedGoogleUser('OWNER@example.test', true, 'owner@example.test')).toBe(true); expect(isAllowedGoogleUser('owner@example.test', false, 'owner@example.test')).toBe(false); @@ -38,6 +29,14 @@ describe('server security primitives', () => { expect(session.csrf.length).toBeGreaterThan(20); }); + it('partitions browser finance caches with a pseudonymous owner key', () => { + const first = financeCacheOwnerKey('immutable-sub', sessionSecret); + expect(first).toHaveLength(43); + expect(first).not.toContain('immutable-sub'); + expect(financeCacheOwnerKey('immutable-sub', sessionSecret)).toBe(first); + expect(financeCacheOwnerKey('other-sub', sessionSecret)).not.toBe(first); + }); + it('validates OAuth state, signed transaction age, and PKCE material', () => { const now = Date.UTC(2026, 7, 8); const { token, transaction, challenge } = createOAuthTransaction(sessionSecret, '/budget', now); diff --git a/tests/postgres/financeRepository.postgres.test.ts b/tests/postgres/financeRepository.postgres.test.ts index 0c9504f..e6e6694 100644 --- a/tests/postgres/financeRepository.postgres.test.ts +++ b/tests/postgres/financeRepository.postgres.test.ts @@ -4,6 +4,7 @@ import postgres from 'postgres'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { FinanceDataIntegrityError, + FinanceOwnerMappingError, PostgresFinanceRepository, } from '../../api/_lib/financeRepository'; import { financeDataV1Schema } from '../../src/finance/runtime'; @@ -53,15 +54,16 @@ beforeAll(async () => { schemaCreated = true; const migration001 = await readFile(new URL('../../migrations/001_google_connections.sql', import.meta.url), 'utf8'); const migration002 = await readFile(new URL('../../migrations/002_finance_data_v1.sql', import.meta.url), 'utf8'); + const migration003 = await readFile(new URL('../../migrations/003_drop_google_connections.sql', import.meta.url), 'utf8'); await sql.unsafe(migration001); await sql.unsafe(migration002); + await sql.unsafe(migration003); }); beforeEach(async () => { await sql.unsafe(`TRUNCATE TABLE ${[ ...financeTables, 'owners', - 'google_connections', ].join(', ')}`); }); @@ -72,7 +74,7 @@ afterAll(async () => { }); describe.sequential('Finance PostgreSQL migration and repository', () => { - it('applies migrations 001 and 002 with non-null owner isolation on every finance table', async () => { + it('applies migrations 001 to 003 with non-null owner isolation on every finance table', async () => { const tables = await sql<{ table_name: string }[]>` SELECT table_name FROM information_schema.tables @@ -80,10 +82,10 @@ describe.sequential('Finance PostgreSQL migration and repository', () => { ORDER BY table_name `; expect(tables.map(({ table_name }) => table_name)).toEqual(expect.arrayContaining([ - 'google_connections', 'owners', ...financeTables, ])); + expect(tables.map(({ table_name }) => table_name)).not.toContain('google_connections'); const ownerColumns = await sql<{ table_name: string; is_nullable: string }[]>` SELECT table_name, is_nullable @@ -97,12 +99,12 @@ describe.sequential('Finance PostgreSQL migration and repository', () => { expect(ownerColumns.every(({ is_nullable }) => is_nullable === 'NO')).toBe(true); }); - it('reconstructs the complete normalized anonymous fixture as runtime-valid FinanceDataV1', async () => { - const ownerId = await createOwner('fixture-owner'); - await insertFinanceData(ownerId, fixture); - + it('replaces and reconstructs the complete normalized anonymous fixture as runtime-valid FinanceDataV1', async () => { + const written = await repository.replaceForGoogleSub('fixture-owner', fixture); const result = await repository.readForGoogleSub('fixture-owner'); + expect(written).toEqual(inRepositoryOrder(fixture)); + expect(financeDataV1Schema.safeParse(result).success).toBe(true); expect(result).toEqual(inRepositoryOrder(fixture)); expect(result?.monthlyIncomeCents).toBe(259_132); @@ -115,6 +117,50 @@ describe.sequential('Finance PostgreSQL migration and repository', () => { }); }); + it('replaces an existing owner stand without leaving previous rows', async () => { + await repository.replaceForGoogleSub('replace-owner', fixture); + await repository.replaceForGoogleSub('other-owner', fixture); + const reduced = { + ...fixture, + monthlyIncomeCents: 1, + accounts: fixture.accounts.filter(({ id }) => id === 'daily-account'), + accountSnapshots: fixture.accountSnapshots.filter(({ accountId }) => accountId === 'daily-account'), + pockets: [], + pocketSnapshots: [], + budgetItems: [], + debts: [], + debtSnapshots: [], + debtMilestones: [], + reliefMilestones: [], + }; + + const written = await repository.replaceForGoogleSub('replace-owner', reduced); + + expect(written.monthlyIncomeCents).toBe(1); + expect(written.accounts).toHaveLength(1); + expect(written.pockets).toHaveLength(0); + expect(written.debts).toHaveLength(0); + const [replaced] = await sql<{ count: string }[]>` + SELECT COUNT(*)::text AS count + FROM budget_items AS items + JOIN owners ON owners.id = items.owner_id + WHERE owners.google_sub = 'replace-owner' + `; + expect(replaced).toEqual({ count: '0' }); + const untouched = await repository.readForGoogleSub('other-owner'); + expect(untouched?.budgetItems).toHaveLength(fixture.budgetItems.length); + }); + + it('imports only for the sole owner established by verified sign-in', async () => { + await expect(repository.replaceForSoleOwner(fixture)).rejects.toMatchObject({ reason: 'missing' }); + + await repository.ensureOwnerForGoogleSub('verified-owner'); + await expect(repository.replaceForSoleOwner(fixture)).resolves.toEqual(inRepositoryOrder(fixture)); + + await repository.ensureOwnerForGoogleSub('second-verified-owner'); + await expect(repository.replaceForSoleOwner(fixture)).rejects.toMatchObject({ reason: 'ambiguous' }); + }); + it('preserves negative amounts and distinguishes month from day milestone precision', async () => { const ownerId = await createOwner('precision-owner'); await insertFinanceData(ownerId, fixture); diff --git a/tests/visual/__screenshots__/chromium/1024-light-pin-confirmation.png b/tests/visual/__screenshots__/chromium/1024-light-pin-confirmation.png index 2d38133..5d26f73 100644 Binary files a/tests/visual/__screenshots__/chromium/1024-light-pin-confirmation.png and b/tests/visual/__screenshots__/chromium/1024-light-pin-confirmation.png differ diff --git a/tests/visual/__screenshots__/chromium/1024-light-pin-setup.png b/tests/visual/__screenshots__/chromium/1024-light-pin-setup.png index fe476b2..3b6276e 100644 Binary files a/tests/visual/__screenshots__/chromium/1024-light-pin-setup.png and b/tests/visual/__screenshots__/chromium/1024-light-pin-setup.png differ diff --git a/tests/visual/__screenshots__/chromium/412-dark-info-dialog.png b/tests/visual/__screenshots__/chromium/412-dark-info-dialog.png index 30d4c1f..8276790 100644 Binary files a/tests/visual/__screenshots__/chromium/412-dark-info-dialog.png and b/tests/visual/__screenshots__/chromium/412-dark-info-dialog.png differ diff --git a/tests/visual/__screenshots__/chromium/412-dark-validation-error.png b/tests/visual/__screenshots__/chromium/412-dark-validation-error.png index 01fc334..fdf25ab 100644 Binary files a/tests/visual/__screenshots__/chromium/412-dark-validation-error.png and b/tests/visual/__screenshots__/chromium/412-dark-validation-error.png differ diff --git a/tests/visual/__screenshots__/chromium/412-light-edge-empty-budget.png b/tests/visual/__screenshots__/chromium/412-light-edge-empty-budget.png index 600678d..570db37 100644 Binary files a/tests/visual/__screenshots__/chromium/412-light-edge-empty-budget.png and b/tests/visual/__screenshots__/chromium/412-light-edge-empty-budget.png differ diff --git a/tests/visual/__screenshots__/chromium/412-light-info-dialog.png b/tests/visual/__screenshots__/chromium/412-light-info-dialog.png index fd8fa07..41e5e76 100644 Binary files a/tests/visual/__screenshots__/chromium/412-light-info-dialog.png and b/tests/visual/__screenshots__/chromium/412-light-info-dialog.png differ diff --git a/tests/visual/__screenshots__/chromium/412-light-state-no-finance.png b/tests/visual/__screenshots__/chromium/412-light-state-no-finance.png new file mode 100644 index 0000000..4d77b2a Binary files /dev/null and b/tests/visual/__screenshots__/chromium/412-light-state-no-finance.png differ diff --git a/tests/visual/__screenshots__/chromium/412-light-state-no-spreadsheet.png b/tests/visual/__screenshots__/chromium/412-light-state-no-spreadsheet.png deleted file mode 100644 index 1341bea..0000000 Binary files a/tests/visual/__screenshots__/chromium/412-light-state-no-spreadsheet.png and /dev/null differ diff --git a/tests/visual/__screenshots__/chromium/412-light-state-offline-empty.png b/tests/visual/__screenshots__/chromium/412-light-state-offline-empty.png index d702968..8117cbc 100644 Binary files a/tests/visual/__screenshots__/chromium/412-light-state-offline-empty.png and b/tests/visual/__screenshots__/chromium/412-light-state-offline-empty.png differ diff --git a/tests/visual/__screenshots__/chromium/412-light-state-signed-out.png b/tests/visual/__screenshots__/chromium/412-light-state-signed-out.png index 01a3cd7..8ef8bf5 100644 Binary files a/tests/visual/__screenshots__/chromium/412-light-state-signed-out.png and b/tests/visual/__screenshots__/chromium/412-light-state-signed-out.png differ diff --git a/tests/visual/__screenshots__/chromium/412-light-state-validation-error.png b/tests/visual/__screenshots__/chromium/412-light-state-validation-error.png index 3f0b6e2..18a4453 100644 Binary files a/tests/visual/__screenshots__/chromium/412-light-state-validation-error.png and b/tests/visual/__screenshots__/chromium/412-light-state-validation-error.png differ diff --git a/tests/visual/finance-ui.spec.ts b/tests/visual/finance-ui.spec.ts index b5854b2..a31296f 100644 --- a/tests/visual/finance-ui.spec.ts +++ b/tests/visual/finance-ui.spec.ts @@ -1,6 +1,6 @@ import AxeBuilder from '@axe-core/playwright'; import { expect, test, type BrowserContext, type Page } from '@playwright/test'; -import { anonymousFinanceData, installFinanceApiMocks, installPickerMock } from '../../scripts/fixtures/anonymous-finance-data.mjs'; +import { anonymousFinanceData, installFinanceApiMocks } from '../../scripts/fixtures/anonymous-finance-data.mjs'; import { denseOverviewFinanceData, emptyCollectionsFinanceData, @@ -10,7 +10,7 @@ import { APP_PROTECTION_STORAGE_KEY, PIN_PBKDF2_ITERATIONS } from '../../src/pri type Theme = 'light' | 'dark'; type FinanceDestination = 'overview' | 'upcoming' | 'budget' | 'debt'; -type FinanceState = 'connected' | 'signed-out' | 'no-spreadsheet' | 'validation-error' | 'reconnect'; +type FinanceState = 'connected' | 'signed-out' | 'no-finance' | 'validation-error'; const defaultVisualTime = new Date('2026-08-09T06:00:00Z'); const overviewHeading = /^(?:Guten Morgen|Guten Tag|Guten Abend|Gute Nacht)$/; const protectedStorageState = JSON.stringify({ @@ -47,8 +47,8 @@ async function preparePage( await page.addInitScript(() => { localStorage.removeItem('finance-appearance-v1'); sessionStorage.clear(); + indexedDB.deleteDatabase('finance-overview'); }); - await installPickerMock(page); if (state === 'offline-empty') { await page.addInitScript(() => { Object.defineProperty(Navigator.prototype, 'onLine', { configurable: true, get: () => false }); @@ -57,7 +57,15 @@ async function preparePage( } else { await installFinanceApiMocks(page, state, financeData); } + let sawFinanceApi = false; + page.on('request', (request) => { + const pathname = new URL(request.url()).pathname; + if (pathname === '/api/session' || pathname === '/api/finance') sawFinanceApi = true; + }); await page.goto(initialPath, { waitUntil: 'networkidle' }); + if (state !== 'connected' && !sawFinanceApi) { + throw new Error('Visual-Tests haben /api/session nicht erreicht. Der Dev-Server läuft mit In-Bundle-Mock (VITE_USE_MOCK_API=true / npm run dev:mock). Diese Suite braucht productionFinanceApi und page.route.'); + } await page.evaluate(() => { document.documentElement.style.setProperty('--color-system-accent-source', '#2F667A'); document.documentElement.style.setProperty('--color-on-system-accent-source', '#FFFFFF'); @@ -94,15 +102,15 @@ async function seedPinProtection(page: Page) { async function hasCachedFinanceData(page: Page) { return page.evaluate(async () => { const database = await new Promise((resolve, reject) => { - const request = indexedDB.open('finance-overview', 1); + const request = indexedDB.open('finance-overview', 2); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); try { const transaction = database.transaction('last-good', 'readonly'); - const request = transaction.objectStore('last-good').get('finance-data-v1'); + const request = transaction.objectStore('last-good').getAllKeys(); return await new Promise((resolve, reject) => { - request.onsuccess = () => resolve(Boolean(request.result)); + request.onsuccess = () => resolve(request.result.length > 0); request.onerror = () => reject(request.error); }); } finally { @@ -309,9 +317,9 @@ for (const scenario of [ } for (const scenario of [ - { name: 'signed-out', state: 'signed-out' as const, heading: 'Mit deiner Tabelle verbinden' }, - { name: 'no-spreadsheet', state: 'no-spreadsheet' as const, heading: 'Google-Tabelle auswählen' }, - { name: 'validation-error', state: 'validation-error' as const, heading: 'Tabelle konnte nicht übernommen werden' }, + { name: 'signed-out', state: 'signed-out' as const, heading: 'Bei accura anmelden' }, + { name: 'no-finance', state: 'no-finance' as const, heading: 'Finanzstand fehlt' }, + { name: 'validation-error', state: 'validation-error' as const, heading: 'Finanzstand konnte nicht geladen werden' }, { name: 'offline-empty', state: 'offline-empty' as const, heading: 'Noch kein lokaler Datenstand' }, ]) { test(`412 light state ${scenario.name}`, async ({ page, context }) => { @@ -454,16 +462,6 @@ test('412 light info dialog', async ({ page, context }) => { await expect(page.getByRole('dialog', { name: 'Informationen' }).getByRole('status')).toHaveText(''); }); -test('412 light disconnect confirmation', async ({ page, context }) => { - await page.setViewportSize({ width: 412, height: 915 }); - await preparePage(page, context, 'connected', 'light', true); - await page.getByRole('heading', { name: 'Guten Morgen' }).waitFor(); - await page.getByLabel('Einstellungen öffnen').click(); - await page.getByRole('button', { name: /Google-Verbindung trennen/ }).click(); - await page.getByText('Google-Verbindung trennen?').waitFor(); - await capture(page, '412-light-disconnect-confirmation.png'); -}); - test('412 light app preview protection covers background and requires deliberate reveal', async ({ page, context }) => { const runtimeErrors = trackRuntimeErrors(page); await page.setViewportSize({ width: 412, height: 915 }); @@ -691,11 +689,11 @@ test('dark PIN lockscreen follows the active theme and remains usable in constra await expectNoAxeViolations(page, 'PIN-Lockscreen Forced Colors'); }); -test('forgotten PIN recovery stays locked offline and clears protected local data after disconnect', async ({ page, context }) => { +test('forgotten PIN recovery stays locked offline and clears protected local data after logout', async ({ page, context }) => { const runtimeErrors = trackRuntimeErrors(page); - let disconnectRequests = 0; + let logoutRequests = 0; page.on('request', (request) => { - if (new URL(request.url()).pathname === '/api/connection/disconnect') disconnectRequests += 1; + if (new URL(request.url()).pathname === '/api/auth/logout') logoutRequests += 1; }); await seedPinProtection(page); await page.setViewportSize({ width: 412, height: 915 }); @@ -714,7 +712,7 @@ test('forgotten PIN recovery stays locked offline and clears protected local dat await recovery.getByRole('button', { name: 'Sicher zurücksetzen' }).click(); await expect(recovery.getByText(/Internetverbindung benötigt/)).toBeVisible(); await expect(page.locator('html')).toHaveAttribute('data-app-covered', 'true'); - expect(disconnectRequests).toBe(0); + expect(logoutRequests).toBe(0); await context.setOffline(false); await siblingPage.route('**/api/session', (route) => route.fulfill({ json: { authenticated: false } })); @@ -726,7 +724,7 @@ test('forgotten PIN recovery stays locked offline and clears protected local dat await siblingReload; await expect(siblingPage.getByRole('button', { name: 'Mit Google anmelden' })).toBeVisible(); await expect(siblingPage.getByRole('dialog', { name: 'PIN eingeben' })).toHaveCount(0); - expect(disconnectRequests).toBe(1); + expect(logoutRequests).toBe(1); expect(await page.evaluate(() => localStorage.getItem('finance-app-protection-v1'))).toBeNull(); expect(await hasCachedFinanceData(page)).toBe(false); await siblingPage.close(); @@ -749,7 +747,7 @@ for (const scenario of [ test('412 dark validation error', async ({ page, context }) => { await page.setViewportSize({ width: 412, height: 915 }); await preparePage(page, context, 'validation-error', 'dark'); - await page.getByRole('heading', { name: 'Tabelle konnte nicht übernommen werden' }).waitFor(); + await page.getByRole('heading', { name: 'Finanzstand konnte nicht geladen werden' }).waitFor(); await capture(page, '412-dark-validation-error.png'); }); @@ -786,7 +784,7 @@ test('WCAG AA finance screens', async ({ page, context }) => { }); test('WCAG AA connection states and dialogs', async ({ browser }) => { - for (const state of ['signed-out', 'no-spreadsheet', 'validation-error', 'reconnect', 'offline-empty'] as const) { + for (const state of ['signed-out', 'no-finance', 'validation-error', 'offline-empty'] as const) { const context = await browser.newContext({ viewport: { width: 412, height: 915 }, locale: 'de-DE', serviceWorkers: 'block' }); const page = await context.newPage(); await preparePage(page, context, state); @@ -800,8 +798,6 @@ test('WCAG AA connection states and dialogs', async ({ browser }) => { await page.getByLabel('Einstellungen öffnen').click(); await page.getByRole('dialog', { name: 'Informationen' }).waitFor(); await expectNoAxeViolations(page, 'Info-Dialog'); - await page.getByRole('button', { name: /Google-Verbindung trennen/ }).click(); - await expectNoAxeViolations(page, 'Disconnect-Dialog'); await context.close(); }); diff --git a/tsconfig.app.json b/tsconfig.app.json index 7c36963..28ca4f7 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -12,6 +12,7 @@ "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, diff --git a/tsconfig.server.json b/tsconfig.server.json index 1fdfb32..d79ec51 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -5,6 +5,8 @@ "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "NodeNext", "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, "types": ["node"], "strict": true, "skipLibCheck": true,